refactor(media): unify source identity and music browsing

This commit is contained in:
jxxghp
2026-08-12 12:56:40 +08:00
parent 9359dcb63d
commit 8bf2f601a6
129 changed files with 3614 additions and 3983 deletions

View File

@@ -8,7 +8,7 @@ from app.helper.scraper import MediaScraperHelper
from app.log import logger
from app.modules import _ModuleBase
from app.modules.anilist.anilist import AniListApi
from app.schemas.types import MediaRecognizeType, MediaType, ModuleType
from app.schemas.types import MediaRecognizeType, MediaSource, MediaType, ModuleType
from app.utils.media import is_media_source_enabled
@@ -61,14 +61,14 @@ class AniListModule(_ModuleBase):
return 4
@staticmethod
def _source_enabled(source: Optional[str]) -> bool:
def _source_enabled(media_source: Optional[MediaSource]) -> bool:
"""
判断本次识别是否指定 AniList。
:param source: 请求级识别数据源
:param media_source: 请求级识别数据源
:return: 是否启用 AniList 识别
"""
return (source or settings.RECOGNIZE_SOURCE) == "anilist"
return (media_source or settings.RECOGNIZE_SOURCE) == MediaSource.AniList
@staticmethod
def _media_type(info: dict) -> MediaType:
@@ -217,16 +217,16 @@ class AniListModule(_ModuleBase):
def recognize_media(
self,
meta: MetaBase = None,
anilistid: Optional[int] = None,
source: Optional[str] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
**kwargs,
) -> Optional[MediaInfo]:
"""
按 AniList ID 或标题识别动画媒体信息。
:param meta: 标题解析元数据
:param anilistid: AniList 媒体 ID
:param source: 请求级识别数据源
:param media_source: 请求级识别数据源
:param media_id: 数据源原生ID
:return: 统一媒体信息
"""
# AniList 只处理动画影视,不能在音乐模块未响应时接管音乐请求。
@@ -235,7 +235,14 @@ class AniListModule(_ModuleBase):
or getattr(meta, "type", None) == MediaType.MUSIC
):
return None
if not anilistid and (not meta or not self._source_enabled(source)):
if media_source and media_source != MediaSource.AniList:
return None
if media_id is not None and (
media_source != MediaSource.AniList or not str(media_id).isdigit()
):
return None
anilistid = int(media_id) if media_id is not None else None
if not anilistid and (not meta or not self._source_enabled(media_source)):
return None
info = self.anilist_api.detail(anilistid) if anilistid else self._match_by_meta(meta)
if not info:
@@ -252,16 +259,16 @@ class AniListModule(_ModuleBase):
async def async_recognize_media(
self,
meta: MetaBase = None,
anilistid: Optional[int] = None,
source: Optional[str] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
**kwargs,
) -> Optional[MediaInfo]:
"""
异步按 AniList ID 或标题识别动画媒体信息。
:param meta: 标题解析元数据
:param anilistid: AniList 媒体 ID
:param source: 请求级识别数据源
:param media_source: 请求级识别数据源
:param media_id: 数据源原生ID
:return: 统一媒体信息
"""
# 与同步入口保持同一类型边界,音乐请求不得进入 AniList。
@@ -270,7 +277,14 @@ class AniListModule(_ModuleBase):
or getattr(meta, "type", None) == MediaType.MUSIC
):
return None
if not anilistid and (not meta or not self._source_enabled(source)):
if media_source and media_source != MediaSource.AniList:
return None
if media_id is not None and (
media_source != MediaSource.AniList or not str(media_id).isdigit()
):
return None
anilistid = int(media_id) if media_id is not None else None
if not anilistid and (not meta or not self._source_enabled(media_source)):
return None
info = (
await self.anilist_api.async_detail(anilistid)
@@ -313,16 +327,16 @@ class AniListModule(_ModuleBase):
return None
def search_medias(
self, meta: MetaBase, source: Optional[str] = None
self, meta: MetaBase, media_source: Optional[MediaSource] = None
) -> Optional[List[MediaInfo]]:
"""
搜索 AniList 动画媒体信息。
:param meta: 标题解析元数据
:param source: 请求级搜索数据源
:param media_source: 请求级搜索数据源
:return: 统一媒体信息列表
"""
if not is_media_source_enabled(source, "anilist"):
if not is_media_source_enabled(media_source, "anilist"):
return None
if not meta or not meta.name:
return []
@@ -333,16 +347,16 @@ class AniListModule(_ModuleBase):
]
async def async_search_medias(
self, meta: MetaBase, source: Optional[str] = None
self, meta: MetaBase, media_source: Optional[MediaSource] = None
) -> Optional[List[MediaInfo]]:
"""
异步搜索 AniList 动画媒体信息。
:param meta: 标题解析元数据
:param source: 请求级搜索数据源
:param media_source: 请求级搜索数据源
:return: 统一媒体信息列表
"""
if not is_media_source_enabled(source, "anilist"):
if not is_media_source_enabled(media_source, "anilist"):
return None
if not meta or not meta.name:
return []

View File

@@ -8,7 +8,7 @@ from app.helper.scraper import MediaScraperHelper
from app.log import logger
from app.modules import _ModuleBase
from app.modules.bangumi.bangumi import BangumiApi
from app.schemas.types import MediaRecognizeType, MediaType, ModuleType
from app.schemas.types import MediaRecognizeType, MediaSource, MediaType, ModuleType
from app.utils.http import RequestUtils
from app.utils.media import is_media_source_enabled
@@ -82,15 +82,15 @@ class BangumiModule(_ModuleBase):
def recognize_media(
self,
meta: MetaBase = None,
bangumiid: int = None,
source: Optional[str] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
**kwargs,
) -> Optional[MediaInfo]:
"""
识别媒体信息
:param meta: 识别的元数据
:param bangumiid: 识别的Bangumi ID
:param source: 请求级识别数据源
:param media_source: 请求级识别数据源
:param media_id: 数据源原生ID
:return: 识别的媒体信息,包括剧集信息
"""
# Bangumi 只处理影视,不能在音乐模块未响应时接管音乐请求。
@@ -99,8 +99,15 @@ class BangumiModule(_ModuleBase):
or getattr(meta, "type", None) == MediaType.MUSIC
):
return None
if media_source and media_source != MediaSource.Bangumi:
return None
if media_id is not None and (
media_source != MediaSource.Bangumi or not str(media_id).isdigit()
):
return None
bangumiid = int(media_id) if media_id is not None else None
if not bangumiid and (
not meta or (source or settings.RECOGNIZE_SOURCE) != "bangumi"
not meta or (media_source or settings.RECOGNIZE_SOURCE) != MediaSource.Bangumi
):
return None
@@ -124,15 +131,15 @@ class BangumiModule(_ModuleBase):
async def async_recognize_media(
self,
meta: MetaBase = None,
bangumiid: int = None,
source: Optional[str] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
**kwargs,
) -> Optional[MediaInfo]:
"""
识别媒体信息(异步版本)
:param meta: 识别的元数据
:param bangumiid: 识别的Bangumi ID
:param source: 请求级识别数据源
:param media_source: 请求级识别数据源
:param media_id: 数据源原生ID
:return: 识别的媒体信息,包括剧集信息
"""
# 与同步入口保持同一类型边界,音乐请求不得进入 Bangumi。
@@ -141,8 +148,15 @@ class BangumiModule(_ModuleBase):
or getattr(meta, "type", None) == MediaType.MUSIC
):
return None
if media_source and media_source != MediaSource.Bangumi:
return None
if media_id is not None and (
media_source != MediaSource.Bangumi or not str(media_id).isdigit()
):
return None
bangumiid = int(media_id) if media_id is not None else None
if not bangumiid and (
not meta or (source or settings.RECOGNIZE_SOURCE) != "bangumi"
not meta or (media_source or settings.RECOGNIZE_SOURCE) != MediaSource.Bangumi
):
return None
@@ -207,15 +221,15 @@ class BangumiModule(_ModuleBase):
return None
def search_medias(
self, meta: MetaBase, source: Optional[str] = None
self, meta: MetaBase, media_source: Optional[MediaSource] = None
) -> Optional[List[MediaInfo]]:
"""
搜索媒体信息
:param meta: 识别的元数据
:param source: 请求级搜索数据源
:param media_source: 请求级搜索数据源
:return: 媒体信息
"""
if not is_media_source_enabled(source, "bangumi"):
if not is_media_source_enabled(media_source, "bangumi"):
return None
if not meta.name:
return []
@@ -227,15 +241,15 @@ class BangumiModule(_ModuleBase):
return []
async def async_search_medias(
self, meta: MetaBase, source: Optional[str] = None
self, meta: MetaBase, media_source: Optional[MediaSource] = None
) -> Optional[List[MediaInfo]]:
"""
搜索媒体信息(异步版本)
:param meta: 识别的元数据
:param source: 请求级搜索数据源
:param media_source: 请求级搜索数据源
:return: 媒体信息
"""
if not is_media_source_enabled(source, "bangumi"):
if not is_media_source_enabled(media_source, "bangumi"):
return None
if not meta.name:
return []

View File

@@ -20,6 +20,7 @@ from app.schemas import MediaPerson, APIRateLimitException
from app.schemas.types import (
MUSIC_ENTITY_ALBUM,
MUSIC_ENTITY_RECORDING,
MediaSource,
MediaType,
ModuleType,
MediaRecognizeType,
@@ -34,7 +35,7 @@ from app.utils.zhconv import convert as zhconv_convert
class DoubanModule(_ModuleBase):
"""提供豆瓣影视与豆瓣音乐元数据识别能力。"""
_music_source = "doubanmusic"
_music_source = MediaSource.DoubanMusic
doubanapi: DoubanApi = None
scraper: DoubanScraper = None
@@ -91,10 +92,10 @@ class DoubanModule(_ModuleBase):
self,
meta: MetaMusic,
limit: int = 20,
source: Optional[str] = None,
media_source: Optional[str] = None,
) -> Optional[List[MusicInfo]]:
"""按请求来源搜索豆瓣音乐专辑,并转换为统一音乐候选。"""
if not is_media_source_selected(source, self._music_source):
if not is_media_source_selected(media_source, self._music_source):
return None
keyword = meta.album or meta.title
if not keyword:
@@ -104,19 +105,19 @@ class DoubanModule(_ModuleBase):
def recognize_music(
self,
source: str,
media_source: str,
media_id: str,
music_type: Optional[str] = None,
) -> Optional[MusicInfo]:
"""按豆瓣音乐原生 ID 和实体类型获取专辑或专辑内曲目详情。"""
if source != self._music_source or not media_id:
if media_source != self._music_source or not media_id:
return None
album_id, separator, track_id = str(media_id).partition(":")
if music_type == MUSIC_ENTITY_RECORDING and not separator:
return None
if music_type == MUSIC_ENTITY_ALBUM and separator:
return None
album = self.music_album(source, album_id)
album = self.music_album(media_source, album_id)
if not album:
return None
if separator and track_id:
@@ -129,39 +130,69 @@ class DoubanModule(_ModuleBase):
)
return album.to_music_info()
def music_album(self, source: str, media_id: str) -> Optional[MusicAlbumInfo]:
def music_album(self, media_source: str, media_id: str) -> Optional[MusicAlbumInfo]:
"""按豆瓣音乐专辑 ID 获取标准化专辑详情和曲目。"""
if source != self._music_source or not media_id:
if media_source != self._music_source or not media_id:
return None
info = self.doubanapi.music_detail(subject_id=str(media_id))
return self._douban_music_to_album(info) if info else None
def music_discover(
self,
source: str,
media_source: str,
page: int = 1,
count: int = 30,
entity: str = MUSIC_ENTITY_ALBUM,
country: str = "us",
mode: str = "chart",
tags: str = "",
sort: str = "U",
) -> Optional[List[MusicInfo]]:
"""分页读取豆瓣音乐推荐合集,并保留豆瓣条目原生身份。"""
if source != self._music_source:
"""按官方新碟榜或标签交集浏览豆瓣音乐,并保留豆瓣条目原生身份。"""
if media_source != self._music_source:
return None
del entity, country
result = self.doubanapi.music_single(
start=max(page - 1, 0) * max(1, count),
count=max(1, count),
)
return self._build_music_search_results(result)
del entity
if mode == "chart":
chart_items = self._build_music_search_results(self.doubanapi.music_chart())
start = max(page - 1, 0) * max(1, count)
return chart_items[start:start + max(1, count)]
selected_tags = [tag.strip() for tag in str(tags or "").split(",") if tag.strip()]
if not selected_tags:
selected_tags = ["流行"]
if len(selected_tags) == 1:
result = self.doubanapi.music_tag(
tag=selected_tags[0],
start=max(page - 1, 0) * max(1, count),
count=max(1, count),
sort=sort,
)
return self._build_music_search_results(result)
# 豆瓣官网的多标签 URL 会按完整文本标签匹配;分别读取后按原生 ID
# 求交集,才能实现风格与地区的真实组合筛选。
scan_count = min(max(page * count * 4, 100), 300)
tag_results = [
self._build_music_search_results(
self.doubanapi.music_tag(tag=tag, start=0, count=scan_count, sort=sort)
)
for tag in selected_tags
]
if not tag_results:
return []
shared_ids = set(item.media_id for item in tag_results[0])
for items in tag_results[1:]:
shared_ids.intersection_update(item.media_id for item in items)
matched = [item for item in tag_results[0] if item.media_id in shared_ids]
start = max(page - 1, 0) * max(1, count)
return matched[start:start + max(1, count)]
def music_album_related(
self,
source: str,
media_source: str,
media_id: str,
count: int = 24,
) -> Optional[List[MusicInfo]]:
"""按豆瓣音乐专辑 ID 返回相关推荐条目。"""
if source != self._music_source or not media_id:
if media_source != self._music_source or not media_id:
return None
result = self.doubanapi.music_recommendations(
subject_id=str(media_id),
@@ -173,24 +204,24 @@ class DoubanModule(_ModuleBase):
def _recognize_music_media(
self,
meta: Optional[MetaMusic],
source: Optional[str],
mediaid: Optional[str],
media_source: Optional[str],
media_id: Optional[str],
music_type: Optional[str] = None,
) -> Optional[MusicInfo]:
"""执行豆瓣音乐详情识别或按专辑名称匹配。"""
if source != self._music_source:
if media_source != self._music_source:
return None
resolved_media_id = mediaid or (meta.media_id if meta else None)
resolved_media_id = media_id or (meta.media_id if meta else None)
if resolved_media_id:
detail_kwargs = (
{"music_type": music_type} if music_type is not None else {}
)
return self.recognize_music(
source, str(resolved_media_id), **detail_kwargs
media_source, str(resolved_media_id), **detail_kwargs
)
if not meta:
return None
candidates = self.search_music(meta=meta, limit=20, source=source) or []
candidates = self.search_music(meta=meta, limit=20, media_source=media_source) or []
expected_title = meta.album or meta.title
for candidate in candidates:
if not self._same_music_text(expected_title, candidate.title):
@@ -204,7 +235,7 @@ class DoubanModule(_ModuleBase):
if music_type == MUSIC_ENTITY_ALBUM:
return candidate
if meta.album and meta.title:
album = self.music_album(source, candidate.media_id)
album = self.music_album(media_source, candidate.media_id)
matched_track = self._select_douban_music_track(meta, album)
if matched_track:
return matched_track
@@ -217,14 +248,14 @@ class DoubanModule(_ModuleBase):
async def _async_recognize_music_media(
self,
meta: Optional[MetaMusic],
source: Optional[str],
mediaid: Optional[str],
media_source: Optional[str],
media_id: Optional[str],
music_type: Optional[str] = None,
) -> Optional[MusicInfo]:
"""异步执行豆瓣音乐详情识别或按专辑名称匹配。"""
if source != self._music_source:
if media_source != self._music_source:
return None
resolved_media_id = mediaid or (meta.media_id if meta else None)
resolved_media_id = media_id or (meta.media_id if meta else None)
if resolved_media_id:
album_id, separator, track_id = str(resolved_media_id).partition(":")
if music_type == MUSIC_ENTITY_RECORDING and not separator:
@@ -358,7 +389,7 @@ class DoubanModule(_ModuleBase):
release_date = cls._douban_music_date(target)
cover_url = cls._douban_music_cover(target)
candidate = MusicInfo(
source=cls._music_source,
media_source=cls._music_source,
media_id=media_id,
music_type=MUSIC_ENTITY_ALBUM,
title=title,
@@ -371,6 +402,10 @@ class DoubanModule(_ModuleBase):
cover_url=cover_url,
names=[title],
detail_link=f"https://music.douban.com/subject/{media_id}/",
raw_data={
"rating": cls._douban_music_float(target["rating"].get("value")),
"rating_votes": cls._douban_music_int(target["rating"].get("count")),
} if isinstance(target.get("rating"), dict) else {},
)
candidates.append(candidate)
return candidates
@@ -392,7 +427,7 @@ class DoubanModule(_ModuleBase):
genres = [str(item) for item in info.get("genres") or [] if item]
rating = info.get("rating") if isinstance(info.get("rating"), dict) else {}
album = MusicAlbumInfo(
source=cls._music_source,
media_source=cls._music_source,
media_id=media_id,
title=title,
artists=artists,
@@ -466,7 +501,7 @@ class DoubanModule(_ModuleBase):
if not title:
continue
results.append(MusicInfo(
source=cls._music_source,
media_source=cls._music_source,
# 豆瓣歌曲没有独立 subject ID使用专辑内绝对顺序避免多碟曲序重复。
media_id=f"{album.media_id}:{index}",
title=title,
@@ -647,7 +682,7 @@ class DoubanModule(_ModuleBase):
if (
meta
and not doubanid
and (kwargs.get("source") or settings.RECOGNIZE_SOURCE) != "douban"
and (kwargs.get("media_source") or settings.RECOGNIZE_SOURCE) != "douban"
):
return None
@@ -718,7 +753,7 @@ class DoubanModule(_ModuleBase):
if (
meta
and not doubanid
and (kwargs.get("source") or settings.RECOGNIZE_SOURCE) != "douban"
and (kwargs.get("media_source") or settings.RECOGNIZE_SOURCE) != "douban"
):
return None
@@ -770,63 +805,73 @@ class DoubanModule(_ModuleBase):
def recognize_media(self, meta: MetaBase = None,
mtype: MediaType = None,
doubanid: Optional[str] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
**kwargs) -> Optional[MediaInfo]:
"""
识别媒体信息
:param meta: 识别的元数据
:param mtype: 识别的媒体类型与doubanid配套
:param doubanid: 豆瓣ID
:param mtype: 识别的媒体类型
:param media_source: 媒体来源
:param media_id: 媒体来源原生ID
:return: 识别的媒体信息,包括剧集信息
"""
source = kwargs.get("source")
if source == self._music_source:
if media_source == self._music_source:
return self._recognize_music_media(
meta=meta if isinstance(meta, MetaMusic) else None,
source=source,
mediaid=kwargs.get("mediaid"),
media_source=media_source,
media_id=media_id,
music_type=kwargs.get("music_type"),
)
# 音乐请求必须显式使用 doubanmusic避免与影视豆瓣源混淆。
if isinstance(meta, MetaMusic) or mtype == MediaType.MUSIC:
return None
if media_source and media_source != MediaSource.Douban:
return None
doubanid = str(media_id) if media_id is not None else None
return self._recognize_media_core(
meta=meta,
mtype=mtype,
doubanid=doubanid,
douban_info_func=self.douban_info,
match_doubaninfo_func=self.match_doubaninfo,
media_source=media_source,
**kwargs
)
async def async_recognize_media(self, meta: MetaBase = None,
mtype: MediaType = None,
doubanid: Optional[str] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
**kwargs) -> Optional[MediaInfo]:
"""
识别媒体信息(异步版本)
:param meta: 识别的元数据
:param mtype: 识别的媒体类型与doubanid配套
:param doubanid: 豆瓣ID
:param mtype: 识别的媒体类型
:param media_source: 媒体来源
:param media_id: 媒体来源原生ID
:return: 识别的媒体信息,包括剧集信息
"""
source = kwargs.get("source")
if source == self._music_source:
if media_source == self._music_source:
return await self._async_recognize_music_media(
meta=meta if isinstance(meta, MetaMusic) else None,
source=source,
mediaid=kwargs.get("mediaid"),
media_source=media_source,
media_id=media_id,
music_type=kwargs.get("music_type"),
)
# 音乐请求必须显式使用 doubanmusic避免与影视豆瓣源混淆。
if isinstance(meta, MetaMusic) or mtype == MediaType.MUSIC:
return None
if media_source and media_source != MediaSource.Douban:
return None
doubanid = str(media_id) if media_id is not None else None
return await self._async_recognize_media_core(
meta=meta,
mtype=mtype,
doubanid=doubanid,
async_douban_info_func=self.async_douban_info,
async_match_doubaninfo_func=self.async_match_doubaninfo,
media_source=media_source,
**kwargs
)
@@ -1380,15 +1425,15 @@ class DoubanModule(_ModuleBase):
return []
def search_medias(
self, meta: MetaBase, source: Optional[str] = None
self, meta: MetaBase, media_source: Optional[MediaSource] = None
) -> Optional[List[MediaInfo]]:
"""
搜索媒体信息
:param meta: 识别的元数据
:param source: 请求级搜索数据源
:param media_source: 请求级搜索数据源
:return: 媒体信息
"""
if not is_media_source_enabled(source, "douban"):
if not is_media_source_enabled(media_source, "douban"):
return None
if not meta.name:
return []
@@ -1399,15 +1444,15 @@ class DoubanModule(_ModuleBase):
return self._build_search_medias_result(meta, result.get("items"))
async def async_search_medias(
self, meta: MetaBase, source: Optional[str] = None
self, meta: MetaBase, media_source: Optional[MediaSource] = None
) -> Optional[List[MediaInfo]]:
"""
搜索媒体信息(异步版本)
:param meta: 识别的元数据
:param source: 请求级搜索数据源
:param media_source: 请求级搜索数据源
:return: 媒体信息
"""
if not is_media_source_enabled(source, "douban"):
if not is_media_source_enabled(media_source, "douban"):
return None
if not meta.name:
return []
@@ -1418,15 +1463,15 @@ class DoubanModule(_ModuleBase):
return self._build_search_medias_result(meta, result.get("items"))
def search_persons(
self, name: str, source: Optional[str] = None
self, name: str, media_source: Optional[MediaSource] = None
) -> Optional[List[MediaPerson]]:
"""
搜索人物信息
:param name: 人物名称
:param source: 请求级搜索数据源
:param media_source: 请求级搜索数据源
:return: 人物信息列表
"""
if not is_media_source_enabled(source, "douban"):
if not is_media_source_enabled(media_source, "douban"):
return None
if not name:
return []
@@ -1443,15 +1488,15 @@ class DoubanModule(_ModuleBase):
return []
async def async_search_persons(
self, name: str, source: Optional[str] = None
self, name: str, media_source: Optional[MediaSource] = None
) -> Optional[List[MediaPerson]]:
"""
搜索人物信息(异步版本)
:param name: 人物名称
:param source: 请求级搜索数据源
:param media_source: 请求级搜索数据源
:return: 人物信息列表
"""
if not is_media_source_enabled(source, "douban"):
if not is_media_source_enabled(media_source, "douban"):
return None
if not name:
return []
@@ -1637,7 +1682,7 @@ class DoubanModule(_ModuleBase):
:param mediainfo: 媒体信息
:return: None 表示不处理MediaInfo 表示继续处理
"""
if mediainfo.source != "douban" and settings.RECOGNIZE_SOURCE != "douban":
if mediainfo.media_source != MediaSource.Douban and settings.RECOGNIZE_SOURCE != "douban":
return None
if not mediainfo.douban_id:
return None

View File

@@ -2,6 +2,7 @@
import base64
import hashlib
import hmac
import re
from datetime import datetime
from random import choice
from typing import Optional, Union
@@ -9,6 +10,7 @@ from urllib import parse
import httpx
import requests
from bs4 import BeautifulSoup
from app.core.cache import cached
from app.core.config import settings
@@ -17,6 +19,8 @@ from app.utils.singleton import WeakSingleton
class DoubanApi(metaclass=WeakSingleton):
"""封装豆瓣 Frodo API 与音乐官网公开浏览页面。"""
_urls = {
# 搜索类
# sort=U:近期热门 T:标记最多 S:评分最高 R:最新上映
@@ -152,6 +156,7 @@ class DoubanApi(metaclass=WeakSingleton):
_api_key2 = "0ab215a8b1977939201640fa14c66bab"
_base_url = "https://frodo.douban.com/api/v2"
_api_url = "https://api.douban.com/v2"
_music_web_url = "https://music.douban.com"
def __init__(self):
self._session = requests.Session()
@@ -635,6 +640,133 @@ class DoubanApi(metaclass=WeakSingleton):
self._urls["music_single"], start=start, count=count
)
@cached(maxsize=settings.CONF.douban, ttl=settings.CONF.meta, skip_none=True)
def music_tag(
self,
tag: str,
start: int = 0,
count: int = 20,
sort: str = "U",
) -> dict:
"""从豆瓣音乐官方标签页读取专辑,并适配为统一的音乐条目列表。"""
normalized_tag = str(tag or "").strip()
if not normalized_tag:
return {"items": []}
page_size = 20
first_page = max(start, 0) // page_size
first_offset = max(start, 0) % page_size
required = first_offset + max(count, 1)
items = []
page = first_page
while len(items) < required:
url = f"{self._music_web_url}/tag/{parse.quote(normalized_tag, safe='')}"
response = RequestUtils(
ua=settings.NORMAL_USER_AGENT,
proxies=settings.PROXY,
timeout=20,
accept_type="text/html,application/xhtml+xml",
).get_res(url=url, params={"start": page * page_size, "type": sort})
page_items = self._parse_music_tag_page(response.content if response else b"")
if not page_items:
break
items.extend(page_items)
if len(page_items) < page_size:
break
page += 1
return {"items": items[first_offset:first_offset + max(count, 1)]}
@cached(maxsize=settings.CONF.douban, ttl=settings.CONF.meta, skip_none=True)
def music_chart(self) -> dict:
"""从豆瓣音乐官方榜单页读取新碟榜,并补充专辑详情供卡片展示。"""
response = RequestUtils(
ua=settings.NORMAL_USER_AGENT,
proxies=settings.PROXY,
timeout=20,
accept_type="text/html,application/xhtml+xml",
).get_res(url=f"{self._music_web_url}/chart")
chart_items = self._parse_music_chart_page(response.content if response else b"")
items = []
for chart_item in chart_items:
detail = self.music_detail(subject_id=chart_item["id"])
if isinstance(detail, dict) and detail:
detail.setdefault("id", chart_item["id"])
detail.setdefault("title", chart_item["title"])
if chart_item.get("artists") and not detail.get("artists"):
detail["artists"] = chart_item["artists"]
items.append(detail)
else:
items.append(chart_item)
return {"items": items}
@staticmethod
def _parse_music_tag_page(content: bytes) -> list[dict]:
"""解析豆瓣音乐标签页中的专辑 ID、封面、发行信息和评分。"""
if not content:
return []
soup = BeautifulSoup(content, "html.parser")
items = []
for row in soup.select("tr.item[id]"):
media_id = str(row.get("id") or "").strip()
title_link = row.select_one("div.pl2 > a[href*='/subject/']")
if not media_id or not title_link:
continue
title = next(title_link.stripped_strings, "").strip()
metadata = row.select_one("div.pl2 > p.pl")
metadata_text = metadata.get_text(" ", strip=True) if metadata else ""
parts = [part.strip() for part in metadata_text.split("/") if part.strip()]
release_date = next(
(part for part in parts if re.fullmatch(r"\d{4}(?:-\d{1,2}(?:-\d{1,2})?)?", part)),
None,
)
image = row.select_one("a.nbg img")
rating_node = row.select_one("span.rating_nums")
votes_node = row.select_one("div.star span.pl")
votes_match = re.search(r"(\d+)\s*人评价", votes_node.get_text(" ", strip=True) if votes_node else "")
item = {
"id": media_id,
"type": "music",
"title": title,
"artists": [{"name": parts[0]}] if parts else [],
"pubdate": [release_date] if release_date else [],
"year": release_date[:4] if release_date else None,
"cover_url": image.get("src") if image else None,
"rating": {
"value": rating_node.get_text(strip=True) if rating_node else None,
"count": votes_match.group(1) if votes_match else None,
},
}
items.append(item)
return items
@staticmethod
def _parse_music_chart_page(content: bytes) -> list[dict]:
"""解析豆瓣新碟榜的专辑 ID、标题和艺术家。"""
if not content:
return []
soup = BeautifulSoup(content, "html.parser")
heading = next(
(node for node in soup.select("h2") if "豆瓣新碟榜" in node.get_text()),
None,
)
container = heading.parent if heading else None
items = []
for row in container.select("ul.col3 li") if container else []:
link = row.select_one("p.entry a[href*='/subject/']")
if not link:
continue
match = re.search(r"/subject/(\d+)", str(link.get("href") or ""))
if not match:
continue
entry_text = row.select_one("p.entry").get_text(" ", strip=True)
artist = entry_text.split("/", 1)[1].strip() if "/" in entry_text else ""
items.append({
"id": match.group(1),
"type": "music",
"title": link.get_text(" ", strip=True),
"artists": [{"name": artist}] if artist else [],
})
return items
def music_recommendations(
self,
subject_id: str,

View File

@@ -176,7 +176,8 @@ class EmbyModule(_ModuleBase, _MediaServerBase[Emby]):
)
movies = s.get_movies(title=mediainfo.title,
year=mediainfo.year,
tmdb_id=mediainfo.tmdb_id)
media_source=mediainfo.media_source,
media_id=mediainfo.media_id)
if not movies:
logger.info(f"{mediainfo.title_year} 没有在媒体库 {name}")
continue
@@ -191,7 +192,8 @@ class EmbyModule(_ModuleBase, _MediaServerBase[Emby]):
else:
itemid, tvs = s.get_tv_episodes(title=mediainfo.title,
year=mediainfo.year,
tmdb_id=mediainfo.tmdb_id,
media_source=mediainfo.media_source,
media_id=mediainfo.media_id,
item_id=itemid)
if not tvs:
logger.info(f"{mediainfo.title_year} 没有在媒体库 {name}")

View File

@@ -9,10 +9,10 @@ from requests import Response
from app import schemas
from app.core.config import settings
from app.helper.mediaserver import MusicMediaServerHelper
from app.helper.mediaserver import MediaServerIdentityHelper, MusicMediaServerHelper
from app.log import logger
from app.schemas import MediaServerItem
from app.schemas.types import MediaType
from app.schemas.types import MediaSource, MediaType
from app.utils.http import RequestUtils
from app.utils.url import UrlUtils
@@ -380,12 +380,14 @@ class Emby:
def get_movies(self,
title: str,
year: Optional[str] = None,
tmdb_id: Optional[int] = None) -> Optional[List[schemas.MediaServerItem]]:
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None) -> Optional[List[schemas.MediaServerItem]]:
"""
根据标题和年份检查电影是否在Emby中存在存在则返回列表
:param title: 标题
:param year: 年份,可以为空,为空时不按年份过滤
:param tmdb_id: TMDB ID
:param media_source: 媒体来源
:param media_id: 媒体来源原生ID
:return: 含title、year属性的字典列表
"""
if not self._host or not self._apikey:
@@ -412,7 +414,9 @@ class Emby:
continue
mediaserver_item = self.__format_item_info(item)
if mediaserver_item:
if (not tmdb_id or mediaserver_item.tmdbid == tmdb_id) and \
if MediaServerIdentityHelper.is_compatible(
mediaserver_item, media_source, media_id
) and \
mediaserver_item.title == title and \
(not year or str(mediaserver_item.year) == str(year)):
ret_movies.append(mediaserver_item)
@@ -454,7 +458,8 @@ class Emby:
item_id: Optional[str] = None,
title: Optional[str] = None,
year: Optional[str] = None,
tmdb_id: Optional[int] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
season: Optional[int] = None
) -> Tuple[Optional[str], Optional[Dict[int, List[int]]]]:
"""
@@ -462,7 +467,8 @@ class Emby:
:param item_id: Emby中的ID
:param title: 标题
:param year: 年份
:param tmdb_id: TMDBID
:param media_source: 媒体来源
:param media_id: 媒体来源原生ID
:param season: 季
:return: 每一季的已有集数
"""
@@ -476,7 +482,7 @@ class Emby:
return None, None
if not item_id:
return None, {}
# 验证tmdbid是否相同
# 校验媒体服务器返回的主身份是否与目标冲突
item_info = self.get_iteminfo(item_id)
if not item_info and cached_item_id and title:
# 媒体删除后重新入库会导致缓存ID失效回退到标题搜索避免误判整部剧缺失。
@@ -489,10 +495,8 @@ class Emby:
item_info = self.get_iteminfo(item_id)
if not item_info:
return None, {}
if item_info:
if tmdb_id and item_info.tmdbid:
if str(tmdb_id) != str(item_info.tmdbid):
return None, {}
if not MediaServerIdentityHelper.is_compatible(item_info, media_source, media_id):
return None, {}
# 查集的信息
if season is None:
season = None
@@ -740,7 +744,9 @@ class Emby:
play_count=item.get("UserData", {}).get("PlayCount"),
percentage=item.get("UserData", {}).get("PlayedPercentage"),
)
tmdbid = item.get("ProviderIds", {}).get("Tmdb")
media_source, media_id = MediaServerIdentityHelper.from_provider_ids(
item.get("ProviderIds")
)
return schemas.MediaServerItem(
server="emby",
library=item.get("ParentId"),
@@ -750,9 +756,8 @@ class Emby:
title=item.get("Name"),
original_title=item.get("OriginalTitle"),
year=item.get("ProductionYear"),
tmdbid=int(tmdbid) if tmdbid else None,
imdbid=item.get("ProviderIds", {}).get("Imdb"),
tvdbid=item.get("ProviderIds", {}).get("Tvdb"),
media_source=media_source,
media_id=media_id,
path=item.get("Path"),
note=MusicMediaServerHelper.build_note(item)
if item.get("Type") in {"MusicAlbum", "Audio"} else None,

View File

@@ -16,6 +16,8 @@ from app.modules.indexer.spider.rousi import RousiSpider
from app.modules.indexer.spider.sunnypt import SunnyPTSpider
from app.modules.indexer.spider.tnode import TNodeSpider
from app.modules.indexer.spider.torrentleech import TorrentLeech
from app.schemas.types import MediaSource
from app.utils.media import resolve_media_identity
from app.modules.indexer.spider.yema import YemaSpider
from app.schemas import SiteUserData
from app.schemas.types import MediaType, ModuleType, OtherModulesType
@@ -156,14 +158,26 @@ class IndexerModule(_ModuleBase):
return []
logger.info(
f"{site.get('name')} 搜索完成,耗时 {seconds} 秒,返回数据:{len(result_array)}")
return [TorrentInfo(site=site.get("id"),
site_name=site.get("name"),
site_cookie=site.get("cookie"),
site_ua=site.get("ua"),
site_proxy=site.get("proxy"),
site_order=site.get("pri"),
site_downloader=site.get("downloader"),
**result) for result in result_array]
torrents = []
for result in result_array:
result = dict(result)
legacy_imdb_id = result.pop("imdbid", None)
media_source, media_id = resolve_media_identity(media=result)
if not media_source and legacy_imdb_id:
media_source, media_id = MediaSource.IMDb, str(legacy_imdb_id)
result["media_source"] = media_source
result["media_id"] = media_id
torrents.append(TorrentInfo(
site=site.get("id"),
site_name=site.get("name"),
site_cookie=site.get("cookie"),
site_ua=site.get("ua"),
site_proxy=site.get("proxy"),
site_order=site.get("pri"),
site_downloader=site.get("downloader"),
**result,
))
return torrents
@staticmethod
def __parse_subtitle_result(site: dict, result_array: list, seconds: int) -> List[SubtitleInfo]:

View File

@@ -175,7 +175,10 @@ class JellyfinModule(_ModuleBase, _MediaServerBase[Jellyfin]):
server=name,
itemid=movie.item_id
)
movies = s.get_movies(title=mediainfo.title, year=mediainfo.year, tmdb_id=mediainfo.tmdb_id)
movies = s.get_movies(title=mediainfo.title,
year=mediainfo.year,
media_source=mediainfo.media_source,
media_id=mediainfo.media_id)
if not movies:
logger.info(f"{mediainfo.title_year} 没有在媒体库 {name}")
continue
@@ -190,7 +193,8 @@ class JellyfinModule(_ModuleBase, _MediaServerBase[Jellyfin]):
else:
itemid, tvs = s.get_tv_episodes(title=mediainfo.title,
year=mediainfo.year,
tmdb_id=mediainfo.tmdb_id,
media_source=mediainfo.media_source,
media_id=mediainfo.media_id,
item_id=itemid)
if not tvs:
logger.info(f"{mediainfo.title_year} 没有在媒体库 {name}")

View File

@@ -7,9 +7,10 @@ from requests import Response
from app import schemas
from app.core.config import settings
from app.helper.mediaserver import MusicMediaServerHelper
from app.helper.mediaserver import MediaServerIdentityHelper, MusicMediaServerHelper
from app.log import logger
from app.schemas import MediaType
from app.schemas.types import MediaSource
from app.utils.http import RequestUtils
from app.utils.url import UrlUtils
from app.schemas import MediaServerItem
@@ -437,12 +438,14 @@ class Jellyfin:
def get_movies(self,
title: str,
year: Optional[str] = None,
tmdb_id: Optional[int] = None) -> Optional[List[schemas.MediaServerItem]]:
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None) -> Optional[List[schemas.MediaServerItem]]:
"""
根据标题和年份检查电影是否在Jellyfin中存在存在则返回列表
:param title: 标题
:param year: 年份,为空则不过滤
:param tmdb_id: TMDB ID
:param media_source: 媒体来源
:param media_id: 媒体来源原生ID
:return: 含title、year属性的字典列表
"""
if not self._host or not self._apikey or not self.user:
@@ -468,7 +471,9 @@ class Jellyfin:
continue
mediaserver_item = self.__format_item_info(item)
if mediaserver_item:
if (not tmdb_id or mediaserver_item.tmdbid == tmdb_id) and \
if MediaServerIdentityHelper.is_compatible(
mediaserver_item, media_source, media_id
) and \
mediaserver_item.title == title and \
(not year or str(mediaserver_item.year) == str(year)):
ret_movies.append(mediaserver_item)
@@ -510,14 +515,16 @@ class Jellyfin:
item_id: Optional[str] = None,
title: Optional[str] = None,
year: Optional[str] = None,
tmdb_id: Optional[int] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
season: Optional[int] = None) -> Tuple[Optional[str], Optional[Dict[int, list]]]:
"""
根据标题和年份和季返回Jellyfin中的剧集列表
:param item_id: Jellyfin中的Id
:param title: 标题
:param year: 年份
:param tmdb_id: TMDBID
:param media_source: 媒体来源
:param media_id: 媒体来源原生ID
:param season: 季
:return: 集号的列表
"""
@@ -531,7 +538,7 @@ class Jellyfin:
return None, None
if not item_id:
return None, {}
# 验证tmdbid是否相同
# 校验媒体服务器返回的主身份是否与目标冲突
item_info = self.get_iteminfo(item_id)
if not item_info and cached_item_id and title:
# 媒体删除后重新入库会导致缓存ID失效回退到标题搜索避免误判整部剧缺失。
@@ -544,10 +551,8 @@ class Jellyfin:
item_info = self.get_iteminfo(item_id)
if not item_info:
return None, {}
if item_info:
if tmdb_id and item_info.tmdbid:
if str(tmdb_id) != str(item_info.tmdbid):
return None, {}
if not MediaServerIdentityHelper.is_compatible(item_info, media_source, media_id):
return None, {}
if season is None:
season = None
url = f"{self._host}Shows/{item_id}/Episodes"
@@ -894,7 +899,9 @@ class Jellyfin:
play_count=item.get("UserData", {}).get("PlayCount"),
percentage=item.get("UserData", {}).get("PlayedPercentage"),
)
tmdbid = item.get("ProviderIds", {}).get("Tmdb")
media_source, media_id = MediaServerIdentityHelper.from_provider_ids(
item.get("ProviderIds")
)
return schemas.MediaServerItem(
server="jellyfin",
library=item.get("ParentId"),
@@ -903,9 +910,8 @@ class Jellyfin:
title=item.get("Name"),
original_title=item.get("OriginalTitle"),
year=item.get("ProductionYear"),
tmdbid=int(tmdbid) if tmdbid else None,
imdbid=item.get("ProviderIds", {}).get("Imdb"),
tvdbid=item.get("ProviderIds", {}).get("Tvdb"),
media_source=media_source,
media_id=media_id,
path=item.get("Path"),
note=MusicMediaServerHelper.build_note(item)
if item.get("Type") in {"MusicAlbum", "Audio"} else None,

View File

@@ -8,6 +8,7 @@ from app.modules import _ModuleBase
from app.schemas.types import (
MUSIC_ENTITY_ALBUM,
MUSIC_ENTITY_RECORDING,
MediaSource,
ModuleType,
OtherModulesType,
)
@@ -43,7 +44,7 @@ class ListenBrainzModule(_ModuleBase):
_album_detail_url = "https://musicbrainz.org/release-group"
_release_cover_url = "https://coverartarchive.org/release"
_release_group_cover_url = "https://coverartarchive.org/release-group"
_source = "musicbrainz"
_source = MediaSource.MusicBrainz
# 全站统计按实体分为不同接口,键为音乐实体类型,值为接口路径与数据字段
_chart_entities = {
MUSIC_ENTITY_RECORDING: ("recordings", "recordings"),
@@ -215,7 +216,7 @@ class ListenBrainzModule(_ModuleBase):
release_name = str(recording.get("release_name") or "").strip()
release_mbid = recording.get("caa_release_mbid") or recording.get("release_mbid")
return MusicInfo(
source=cls._source,
media_source=cls._source,
media_id=str(media_id),
music_type=MUSIC_ENTITY_RECORDING,
title=str(title),
@@ -238,7 +239,7 @@ class ListenBrainzModule(_ModuleBase):
return None
artist_name = str(release_group.get("artist_name") or "").strip()
return MusicInfo(
source=cls._source,
media_source=cls._source,
media_id=str(media_id),
music_type=MUSIC_ENTITY_ALBUM,
title=str(title),
@@ -269,7 +270,7 @@ class ListenBrainzModule(_ModuleBase):
release.get("release_group_secondary_type"),
]
return MusicInfo(
source=cls._source,
media_source=cls._source,
media_id=str(media_id),
music_type=MUSIC_ENTITY_ALBUM,
title=str(title),

View File

@@ -23,6 +23,7 @@ from app.schemas.types import (
MUSIC_ENTITY_ALBUM,
MUSIC_ENTITY_RECORDING,
MediaRecognizeType,
MediaSource,
MediaType,
ModuleType,
)
@@ -34,7 +35,7 @@ from app.utils.zhconv import convert as zhconv_convert
class MusicBrainzModule(_ModuleBase):
"""通过 MusicBrainz 提供音乐元数据搜索和详情识别。"""
_source = "musicbrainz"
_source = MediaSource.MusicBrainz
_base_url = "https://musicbrainz.org/ws/2"
_detail_url = "https://musicbrainz.org/recording"
_album_detail_url = "https://musicbrainz.org/release-group"
@@ -145,10 +146,10 @@ class MusicBrainzModule(_ModuleBase):
self,
meta: MetaMusic,
limit: int = 20,
source: Optional[str] = None,
media_source: Optional[str] = None,
) -> Optional[list[MusicInfo]]:
"""搜索单曲、专辑和艺术家,并交错返回可浏览的 MusicBrainz 候选。"""
if not is_media_source_selected(source, self._source):
if not is_media_source_selected(media_source, self._source):
return None
normalized_limit = max(1, min(limit, 100))
recordings = self._search_recordings(meta, limit=normalized_limit)
@@ -720,7 +721,7 @@ class MusicBrainzModule(_ModuleBase):
group_id = release_group.get("id")
artists, artist_ids = cls._artist_credits(detail.get("artist-credit"))
album = MusicAlbumInfo(
source=cls._source,
media_source=cls._source,
# 优先使用 Release Group ID与专辑详情和封面入口保持一致
media_id=str(group_id or release_id),
title=str(title),
@@ -746,31 +747,31 @@ class MusicBrainzModule(_ModuleBase):
self,
meta: MetaBase = None,
mtype: MediaType = None,
source: Optional[str] = None,
mediaid: Optional[str] = None,
media_source: Optional[str] = None,
media_id: Optional[str] = None,
**kwargs,
) -> Optional[MusicInfo]:
"""跟随统一媒体识别分发,仅在音乐类型请求下返回 MusicBrainz 识别结果。"""
music_type = kwargs.get("music_type")
# 显式选择其它音乐源时必须让出识别管线,且不能复用 MusicBrainz 缓存。
if source and source != self._source:
if media_source and media_source != self._source:
return None
# 非音乐请求交给影视识别模块,不占用识别管线
if not isinstance(meta, MetaMusic) and mtype != MediaType.MUSIC and source != self._source:
if not isinstance(meta, MetaMusic) and mtype != MediaType.MUSIC and media_source != self._source:
return None
# 无 MetaMusic 元数据时仅响应本数据源的详情识别请求
if not isinstance(meta, MetaMusic):
if source == self._source and mediaid:
if media_source == self._source and media_id:
detail_kwargs = (
{"music_type": music_type} if music_type is not None else {}
)
return self.recognize_music(
source, str(mediaid), **detail_kwargs
media_source, str(media_id), **detail_kwargs
)
return None
# 显式身份只允许按该 ID 和实体类型读取,失败后不能按标题替换成其它目标。
resolved_source = source or meta.media_source
resolved_media_id = mediaid or meta.media_id
resolved_source = media_source or meta.media_source
resolved_media_id = media_id or meta.media_id
if resolved_source and resolved_media_id:
detail_kwargs = (
{"music_type": music_type} if music_type is not None else {}
@@ -801,7 +802,7 @@ class MusicBrainzModule(_ModuleBase):
# 无身份时按标题搜索并挑选可信候选,检索不到时返回元数据兑底
# 文件识别只能从 Recording 中挑选,专辑或艺术家同名结果不能成为音轨身份。
candidates = self._search_recordings(meta, limit=10)
matched = self._select_candidate(meta, candidates, source=resolved_source or self._source)
matched = self._select_candidate(meta, candidates, media_source=resolved_source or self._source)
# 整专/单曲发行类资源在 Recording 检索无果时,回退按专辑实体识别;
# 专辑挑选要求标题与艺术家同时命中,无艺术家线索时回退检索必然无果,
# 直接跳过避免浪费限流配额(批量识别场景可减少约半数请求)
@@ -828,7 +829,7 @@ class MusicBrainzModule(_ModuleBase):
return None
if not isinstance(meta, MetaMusic) or not isinstance(mediainfo, MusicInfo):
return None
if mediainfo.source != self._source:
if mediainfo.media_source != self._source:
return None
self._update_recognize_cache(meta, mediainfo)
return True
@@ -845,26 +846,26 @@ class MusicBrainzModule(_ModuleBase):
self,
meta: MetaBase = None,
mtype: MediaType = None,
source: Optional[str] = None,
mediaid: Optional[str] = None,
media_source: Optional[str] = None,
media_id: Optional[str] = None,
**kwargs,
) -> Optional[MusicInfo]:
"""异步识别 MusicBrainz 音乐详情或按元数据匹配单曲。"""
music_type = kwargs.get("music_type")
if source and source != self._source:
if media_source and media_source != self._source:
return None
if not isinstance(meta, MetaMusic) and mtype != MediaType.MUSIC and source != self._source:
if not isinstance(meta, MetaMusic) and mtype != MediaType.MUSIC and media_source != self._source:
return None
if not isinstance(meta, MetaMusic):
if source == self._source and mediaid:
if media_source == self._source and media_id:
return await self.async_recognize_music(
source,
str(mediaid),
media_source,
str(media_id),
music_type=music_type,
)
return None
resolved_source = source or meta.media_source
resolved_media_id = mediaid or meta.media_id
resolved_source = media_source or meta.media_source
resolved_media_id = media_id or meta.media_id
if resolved_source and resolved_media_id:
info = await self.async_recognize_music(
resolved_source,
@@ -891,7 +892,7 @@ class MusicBrainzModule(_ModuleBase):
matched = self._select_candidate(
meta,
candidates,
source=resolved_source or self._source,
media_source=resolved_source or self._source,
)
if not matched and meta.artists and music_type != MUSIC_ENTITY_RECORDING:
albums = await self._async_search_albums(meta, limit=10)
@@ -901,9 +902,9 @@ class MusicBrainzModule(_ModuleBase):
return result
@classmethod
def _select_candidate(cls, meta: MetaMusic, candidates: Iterable[MusicInfo], source: str) -> Optional[MusicInfo]:
def _select_candidate(cls, meta: MetaMusic, candidates: Iterable[MusicInfo], media_source: str) -> Optional[MusicInfo]:
"""按标题、艺术家和专辑匹配度选择最可信的搜索候选。"""
normalized_source = cls._normalize_text(source).casefold()
normalized_source = cls._normalize_text(media_source).casefold()
# 资源标题携带的音质标记先剥离,再与候选曲名比对;
# 曲名开头的艺术家署名前缀是命名习惯,用主体名比对
clean_title = cls._strip_artist_prefix(cls._search_title(meta.title), meta.artists)
@@ -913,7 +914,7 @@ class MusicBrainzModule(_ModuleBase):
bare_title = cls._strip_volume_suffix(cls._strip_parenthetical(clean_title))
ranked: list[tuple[int, MusicInfo]] = []
for candidate in candidates:
if normalized_source and (candidate.source or "").casefold() != normalized_source:
if normalized_source and str(candidate.media_source or "").casefold() != normalized_source:
continue
score = 0
title_match = False
@@ -1143,12 +1144,12 @@ class MusicBrainzModule(_ModuleBase):
def recognize_music(
self,
source: str,
media_source: str,
media_id: str,
music_type: Optional[str] = None,
) -> Optional[MusicInfo]:
"""按 MusicBrainz 标准 ID 和实体类型获取详情;空类型保留旧版探测顺序。"""
if source != self._source or not media_id:
if media_source != self._source or not media_id:
return None
if music_type != MUSIC_ENTITY_ALBUM:
payload = self._request_json(
@@ -1163,17 +1164,17 @@ class MusicBrainzModule(_ModuleBase):
if music_type == MUSIC_ENTITY_RECORDING:
return None
# MusicBrainz 各实体共用 UUID 形式,统一详情入口在 Recording 未命中后继续探测专辑。
album = self.music_album(source, media_id)
album = self.music_album(media_source, media_id)
return album.to_music_info() if album else None
async def async_recognize_music(
self,
source: str,
media_source: str,
media_id: str,
music_type: Optional[str] = None,
) -> Optional[MusicInfo]:
"""异步按 MusicBrainz 标准 ID 和实体类型获取详情。"""
if source != self._source or not media_id:
if media_source != self._source or not media_id:
return None
if music_type != MUSIC_ENTITY_ALBUM:
payload = await self._async_request_json(
@@ -1187,16 +1188,16 @@ class MusicBrainzModule(_ModuleBase):
return self._recording_to_info(payload)
if music_type == MUSIC_ENTITY_RECORDING:
return None
album = await self._async_music_album(source, media_id)
album = await self._async_music_album(media_source, media_id)
return album.to_music_info() if album else None
async def _async_music_album(
self,
source: str,
media_source: str,
media_id: str,
) -> Optional[MusicAlbumInfo]:
"""异步按 MusicBrainz Release Group ID 获取专辑详情及曲目。"""
if source != self._source or not media_id:
if media_source != self._source or not media_id:
return None
payload = await self._async_request_json(
f"/release-group/{media_id}",
@@ -1217,9 +1218,9 @@ class MusicBrainzModule(_ModuleBase):
)
return album
def music_album(self, source: str, media_id: str) -> Optional[MusicAlbumInfo]:
def music_album(self, media_source: str, media_id: str) -> Optional[MusicAlbumInfo]:
"""按 MusicBrainz Release Group ID 获取标准化专辑详情及曲目。"""
if source != self._source or not media_id:
if media_source != self._source or not media_id:
return None
payload = self._request_json(
f"/release-group/{media_id}",
@@ -1237,9 +1238,9 @@ class MusicBrainzModule(_ModuleBase):
album.tracks = self._album_tracks(album, payload.get("releases") or [])
return album
def music_artist(self, source: str, media_id: str) -> Optional[MusicArtistInfo]:
def music_artist(self, media_source: str, media_id: str) -> Optional[MusicArtistInfo]:
"""按 MusicBrainz Artist ID 获取标准化艺术家详情。"""
if source != self._source or not media_id:
if media_source != self._source or not media_id:
return None
payload = self._request_json(
f"/artist/{media_id}",
@@ -1249,14 +1250,14 @@ class MusicBrainzModule(_ModuleBase):
def music_artist_albums(
self,
source: str,
media_source: str,
media_id: str,
page: int = 1,
count: int = 30,
album_type: Optional[str] = None,
) -> list[MusicInfo]:
"""按 MusicBrainz Artist ID 分页浏览该艺术家的专辑、EP 和单曲。"""
if source != self._source or not media_id:
if media_source != self._source or not media_id:
return []
limit = max(1, min(count, 100))
params: dict[str, Any] = {
@@ -1280,12 +1281,12 @@ class MusicBrainzModule(_ModuleBase):
def music_artist_related(
self,
source: str,
media_source: str,
media_id: str,
count: int = 24,
) -> list[MusicArtistInfo]:
"""按 MusicBrainz 艺术家关系返回可继续浏览的关联艺术家。"""
if source != self._source or not media_id:
if media_source != self._source or not media_id:
return []
payload = self._request_json(
f"/artist/{media_id}",
@@ -1374,7 +1375,7 @@ class MusicBrainzModule(_ModuleBase):
category_parts = [release_group.get("primary-type")]
category_parts.extend(release_group.get("secondary-types") or [])
return MusicInfo(
source=cls._source,
media_source=cls._source,
media_id=str(media_id),
title=str(title),
artists=artists,
@@ -1406,7 +1407,7 @@ class MusicBrainzModule(_ModuleBase):
artists, artist_ids = cls._artist_credits(release_group.get("artist-credit"))
rating = release_group.get("rating") or {}
return MusicAlbumInfo(
source=cls._source,
media_source=cls._source,
media_id=str(media_id),
title=str(title),
artists=artists,
@@ -1549,7 +1550,7 @@ class MusicBrainzModule(_ModuleBase):
track.get("artist-credit") or recording.get("artist-credit")
)
return MusicInfo(
source=cls._source,
media_source=cls._source,
media_id=str(media_id),
title=str(title),
artists=artists or list(album.artists),
@@ -1636,7 +1637,7 @@ class MusicBrainzModule(_ModuleBase):
begin_area = artist.get("begin-area") or {}
relations = artist.get("relations") or []
return MusicArtistInfo(
source=cls._source,
media_source=cls._source,
media_id=str(media_id),
name=str(name),
sort_name=artist.get("sort-name") or None,

View File

@@ -187,7 +187,8 @@ class PlexModule(_ModuleBase, _MediaServerBase[Plex]):
movies = s.get_movies(title=mediainfo.title,
original_title=mediainfo.original_title,
year=mediainfo.year,
tmdb_id=mediainfo.tmdb_id)
media_source=mediainfo.media_source,
media_id=mediainfo.media_id)
if not movies:
logger.info(f"{mediainfo.title_year} 没有在媒体库 {name}")
continue
@@ -203,7 +204,8 @@ class PlexModule(_ModuleBase, _MediaServerBase[Plex]):
item_id, tvs = s.get_tv_episodes(title=mediainfo.title,
original_title=mediainfo.original_title,
year=mediainfo.year,
tmdb_id=mediainfo.tmdb_id,
media_source=mediainfo.media_source,
media_id=mediainfo.media_id,
item_id=itemid)
if not tvs:
logger.info(f"{mediainfo.title_year} 没有在媒体库 {name}")

View File

@@ -10,8 +10,10 @@ from requests import Response, Session
from app import schemas
from app.core.cache import cached
from app.helper.mediaserver import MediaServerIdentityHelper
from app.log import logger
from app.schemas import MediaType
from app.schemas.types import MediaSource
from app.utils.http import RequestUtils
from app.utils.url import UrlUtils
from app.schemas import MediaServerItem
@@ -196,13 +198,15 @@ class Plex:
title: str,
original_title: Optional[str] = None,
year: Optional[str] = None,
tmdb_id: Optional[int] = None) -> Optional[List[schemas.MediaServerItem]]:
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None) -> Optional[List[schemas.MediaServerItem]]:
"""
根据标题和年份检查电影是否在Plex中存在存在则返回列表
:param title: 标题
:param original_title: 原产地标题
:param year: 年份,为空则不过滤
:param tmdb_id: TMDB ID
:param media_source: 媒体来源
:param media_id: 媒体来源原生ID
:return: 含title、year属性的字典列表
"""
if not self._plex:
@@ -225,9 +229,11 @@ class Plex:
libtype="movie"))
for item in set(movies):
ids = self.__get_ids(item.guids)
if tmdb_id and ids['tmdb_id']:
if str(ids['tmdb_id']) != str(tmdb_id):
continue
item_source, item_media_id = MediaServerIdentityHelper.from_provider_ids(ids)
if not MediaServerIdentityHelper.are_compatible(
item_source, item_media_id, media_source, media_id
):
continue
path = None
if item.locations:
path = item.locations[0]
@@ -240,9 +246,8 @@ class Plex:
title=item.title,
original_title=item.originalTitle,
year=item.year,
tmdbid=ids['tmdb_id'],
imdbid=ids['imdb_id'],
tvdbid=ids['tvdb_id'],
media_source=item_source,
media_id=item_media_id,
path=path,
)
)
@@ -295,7 +300,8 @@ class Plex:
title: Optional[str] = None,
original_title: Optional[str] = None,
year: Optional[str] = None,
tmdb_id: Optional[int] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
season: Optional[int] = None) -> Tuple[Optional[str], Optional[Dict[int, list]]]:
"""
根据标题、年份、季查询电视剧所有集信息
@@ -303,7 +309,8 @@ class Plex:
:param title: 标题
:param original_title: 原产地标题
:param year: 年份,可以为空,为空时不按年份过滤
:param tmdb_id: TMDB ID
:param media_source: 媒体来源
:param media_id: 媒体来源原生ID
:param season: 季号,数字
:return: 所有集的列表
"""
@@ -327,10 +334,13 @@ class Plex:
return None, {}
if isinstance(videos, list):
videos = videos[0]
video_tmdbid = self.__get_ids(videos.guids).get('tmdb_id')
if tmdb_id and video_tmdbid:
if str(video_tmdbid) != str(tmdb_id):
return None, {}
video_source, video_media_id = MediaServerIdentityHelper.from_provider_ids(
self.__get_ids(videos.guids)
)
if not MediaServerIdentityHelper.are_compatible(
video_source, video_media_id, media_source, media_id
):
return None, {}
episodes = videos.episodes()
season_episodes = {}
for episode in episodes:
@@ -642,9 +652,8 @@ class Plex:
title=item.title,
original_title=item.originalTitle,
year=item.year,
tmdbid=ids.get("tmdb_id"),
imdbid=ids.get("imdb_id"),
tvdbid=ids.get("tvdb_id"),
media_source=MediaServerIdentityHelper.from_provider_ids(ids)[0],
media_id=MediaServerIdentityHelper.from_provider_ids(ids)[1],
path=path,
user_state=user_state,
)

View File

@@ -14,6 +14,7 @@ from app.schemas.types import (
MUSIC_ENTITY_ALBUM,
MUSIC_ENTITY_RECORDING,
MediaRecognizeType,
MediaSource,
MediaType,
ModuleType,
)
@@ -24,7 +25,7 @@ from app.utils.media import is_media_source_selected
class TheAudioDbModule(_ModuleBase):
"""通过 TheAudioDB V1 API 提供音乐搜索、详情和手动识别能力。"""
_source = "theaudiodb"
_source = MediaSource.TheAudioDB
_base_url = "https://www.theaudiodb.com/api/v1/json"
_detail_url = "https://www.theaudiodb.com"
@@ -72,10 +73,10 @@ class TheAudioDbModule(_ModuleBase):
self,
meta: MetaMusic,
limit: int = 20,
source: Optional[str] = None,
media_source: Optional[str] = None,
) -> Optional[list[MusicInfo]]:
"""按请求来源搜索 TheAudioDB 单曲、专辑和艺术家。"""
if not is_media_source_selected(source, self._source):
if not is_media_source_selected(media_source, self._source):
return None
normalized_limit = max(1, min(limit, 100))
tracks = self._search_tracks(meta)
@@ -92,30 +93,30 @@ class TheAudioDbModule(_ModuleBase):
self,
meta: MetaBase = None,
mtype: MediaType = None,
source: Optional[str] = None,
mediaid: Optional[str] = None,
media_source: Optional[str] = None,
media_id: Optional[str] = None,
**kwargs,
) -> Optional[MusicInfo]:
"""仅响应显式 TheAudioDB 音乐请求,并返回带原生 ID 的标准音乐信息。"""
music_type = kwargs.get("music_type")
if source != self._source:
if media_source != self._source:
return None
if not isinstance(meta, MetaMusic):
if mtype == MediaType.MUSIC and mediaid:
if mtype == MediaType.MUSIC and media_id:
detail_kwargs = (
{"music_type": music_type} if music_type is not None else {}
)
return self.recognize_music(
source, str(mediaid), **detail_kwargs
media_source, str(media_id), **detail_kwargs
)
return None
resolved_media_id = mediaid or meta.media_id
resolved_media_id = media_id or meta.media_id
if resolved_media_id:
detail_kwargs = (
{"music_type": music_type} if music_type is not None else {}
)
return self.recognize_music(
source, str(resolved_media_id), **detail_kwargs
media_source, str(resolved_media_id), **detail_kwargs
)
if music_type != MUSIC_ENTITY_ALBUM:
matched = self._select_track(meta, self._search_tracks(meta))
@@ -130,26 +131,26 @@ class TheAudioDbModule(_ModuleBase):
self,
meta: MetaBase = None,
mtype: MediaType = None,
source: Optional[str] = None,
mediaid: Optional[str] = None,
media_source: Optional[str] = None,
media_id: Optional[str] = None,
**kwargs,
) -> Optional[MusicInfo]:
"""异步识别 TheAudioDB 音乐详情或按元数据匹配单曲。"""
music_type = kwargs.get("music_type")
if source != self._source:
if media_source != self._source:
return None
if not isinstance(meta, MetaMusic):
if mtype == MediaType.MUSIC and mediaid:
if mtype == MediaType.MUSIC and media_id:
return await self.async_recognize_music(
source,
str(mediaid),
media_source,
str(media_id),
music_type=music_type,
)
return None
resolved_media_id = mediaid or meta.media_id
resolved_media_id = media_id or meta.media_id
if resolved_media_id:
return await self.async_recognize_music(
source,
media_source,
str(resolved_media_id),
music_type=music_type,
)
@@ -170,12 +171,12 @@ class TheAudioDbModule(_ModuleBase):
def recognize_music(
self,
source: str,
media_source: str,
media_id: str,
music_type: Optional[str] = None,
) -> Optional[MusicInfo]:
"""按 TheAudioDB 原生 ID 和实体类型获取详情;空类型保留旧版探测顺序。"""
if source != self._source or not media_id:
if media_source != self._source or not media_id:
return None
if music_type != MUSIC_ENTITY_ALBUM:
payload = self._request_json("track.php", {"h": media_id})
@@ -184,17 +185,17 @@ class TheAudioDbModule(_ModuleBase):
return self._track_to_info(track)
if music_type == MUSIC_ENTITY_RECORDING:
return None
album = self.music_album(source, media_id)
album = self.music_album(media_source, media_id)
return album.to_music_info() if album else None
async def async_recognize_music(
self,
source: str,
media_source: str,
media_id: str,
music_type: Optional[str] = None,
) -> Optional[MusicInfo]:
"""异步按 TheAudioDB 原生 ID 和实体类型获取详情。"""
if source != self._source or not media_id:
if media_source != self._source or not media_id:
return None
if music_type != MUSIC_ENTITY_ALBUM:
payload = await self._async_request_json("track.php", {"h": media_id})
@@ -203,16 +204,16 @@ class TheAudioDbModule(_ModuleBase):
return self._track_to_info(track)
if music_type == MUSIC_ENTITY_RECORDING:
return None
album = await self._async_music_album(source, media_id)
album = await self._async_music_album(media_source, media_id)
return album.to_music_info() if album else None
async def _async_music_album(
self,
source: str,
media_source: str,
media_id: str,
) -> Optional[MusicAlbumInfo]:
"""异步按 TheAudioDB 专辑 ID 获取标准化专辑详情和曲目。"""
if source != self._source or not media_id:
if media_source != self._source or not media_id:
return None
payload = await self._async_request_json("album.php", {"m": media_id})
item = self._first_entity(payload, "album", "albums")
@@ -227,9 +228,9 @@ class TheAudioDbModule(_ModuleBase):
]
return album
def music_album(self, source: str, media_id: str) -> Optional[MusicAlbumInfo]:
def music_album(self, media_source: str, media_id: str) -> Optional[MusicAlbumInfo]:
"""按 TheAudioDB 专辑 ID 获取标准化专辑详情和曲目。"""
if source != self._source or not media_id:
if media_source != self._source or not media_id:
return None
payload = self._request_json("album.php", {"m": media_id})
item = self._first_entity(payload, "album", "albums")
@@ -244,9 +245,9 @@ class TheAudioDbModule(_ModuleBase):
]
return album
def music_artist(self, source: str, media_id: str) -> Optional[MusicArtistInfo]:
def music_artist(self, media_source: str, media_id: str) -> Optional[MusicArtistInfo]:
"""按 TheAudioDB 艺术家 ID 获取标准化艺术家详情。"""
if source != self._source or not media_id:
if media_source != self._source or not media_id:
return None
payload = self._request_json("artist.php", {"i": media_id})
item = self._first_entity(payload, "artists", "artist")
@@ -254,14 +255,14 @@ class TheAudioDbModule(_ModuleBase):
def music_artist_albums(
self,
source: str,
media_source: str,
media_id: str,
page: int = 1,
count: int = 30,
album_type: Optional[str] = None,
) -> list[MusicInfo]:
"""按 TheAudioDB 艺术家 ID 分页返回专辑列表。"""
if source != self._source or not media_id:
if media_source != self._source or not media_id:
return []
payload = self._request_json("album.php", {"i": media_id})
albums = [self._album_to_info(item) for item in self._entities(payload, "album", "albums")]
@@ -274,56 +275,14 @@ class TheAudioDbModule(_ModuleBase):
start = max(page - 1, 0) * max(1, count)
return [album.to_music_info() for album in albums[start:start + max(1, count)]]
def music_discover(
self,
source: str,
page: int = 1,
count: int = 30,
entity: str = MUSIC_ENTITY_ALBUM,
country: str = "us",
) -> Optional[list[MusicInfo]]:
"""读取 TheAudioDB iTunes 趋势榜并转换为可继续浏览的音乐实体。"""
if source != self._source:
return None
payload = self._request_json(
"trending.php",
{
"country": country.casefold(),
"type": "itunes",
"format": "singles" if entity == MUSIC_ENTITY_RECORDING else "albums",
},
)
items = self._entities(payload, "trending")
items.sort(
key=lambda item: self._optional_int(item.get("intChartPlace")) or 10_000
)
candidates = []
for item in items:
if entity == MUSIC_ENTITY_RECORDING:
info = self._track_to_info(item)
else:
info = self._album_to_info(item).to_music_info()
if not info.media_id or not info.title:
continue
info.category = self._text(item.get("strType")) or "iTunes"
info.raw_data.update(
{
"chart_position": self._optional_int(item.get("intChartPlace")),
"chart_country": self._text(item.get("strCountry")),
}
)
candidates.append(info)
start = max(page - 1, 0) * max(1, count)
return candidates[start:start + max(1, count)]
def music_album_related(
self,
source: str,
media_source: str,
media_id: str,
count: int = 24,
) -> Optional[list[MusicInfo]]:
"""按专辑主艺术家返回 TheAudioDB 同艺人专辑,供详情页关联浏览。"""
if source != self._source or not media_id:
if media_source != self._source or not media_id:
return None
payload = self._request_json("album.php", {"m": media_id})
album_item = self._first_entity(payload, "album", "albums")
@@ -466,7 +425,7 @@ class TheAudioDbModule(_ModuleBase):
duration_ms = cls._optional_int(item.get("intDuration"))
genres = cls._unique_texts([item.get("strGenre"), item.get("strStyle")])
return MusicInfo(
source=cls._source,
media_source=cls._source,
media_id=media_id,
title=title,
artists=[artist] if artist else list(album.artists if album else []),
@@ -508,7 +467,7 @@ class TheAudioDbModule(_ModuleBase):
if not release_date:
release_date = cls._text(item.get("intYearReleased"))
return MusicAlbumInfo(
source=cls._source,
media_source=cls._source,
media_id=media_id,
title=title,
artists=[artist] if artist else [],
@@ -540,7 +499,7 @@ class TheAudioDbModule(_ModuleBase):
if website:
links["official homepage"] = website
return MusicArtistInfo(
source=cls._source,
media_source=cls._source,
media_id=media_id,
name=name,
disambiguation=cls._text(item.get("strArtistAlternate")),

View File

@@ -14,7 +14,13 @@ from app.modules.themoviedb.scraper import TmdbScraper
from app.modules.themoviedb.tmdb_cache import TmdbCache
from app.modules.themoviedb.tmdbapi import TmdbApi
from app.schemas.category import CategoryConfig
from app.schemas.types import MediaType, MediaImageType, ModuleType, MediaRecognizeType
from app.schemas.types import (
MediaImageType,
MediaRecognizeType,
MediaSource,
MediaType,
ModuleType,
)
from app.utils.http import RequestUtils
from app.utils.media import is_media_source_enabled, is_media_source_selected
from app.utils.zhconv import convert as zhconv_convert
@@ -96,20 +102,20 @@ class TheMovieDbModule(_ModuleBase):
def _validate_recognize_params(
meta: MetaBase,
tmdbid: Optional[int],
source: Optional[str] = None,
media_source: Optional[str] = None,
) -> bool:
"""
验证识别参数
:param meta: 标题解析元数据
:param tmdbid: TMDB ID
:param source: 请求级识别数据源
:param media_source: 请求级识别数据源
:return: 参数是否可用于TMDB识别
"""
if not tmdbid and not meta:
return False
if meta and not tmdbid and (source or settings.RECOGNIZE_SOURCE) != "themoviedb":
if meta and not tmdbid and (media_source or settings.RECOGNIZE_SOURCE) != "themoviedb":
return False
if meta and not meta.name and not tmdbid:
@@ -463,15 +469,17 @@ class TheMovieDbModule(_ModuleBase):
def recognize_media(self, meta: MetaBase = None,
mtype: MediaType = None,
tmdbid: Optional[int] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
episode_group: Optional[str] = None,
cache: Optional[bool] = True,
**kwargs) -> Optional[MediaInfo]:
"""
识别媒体信息
:param meta: 识别的元数据
:param mtype: 识别的媒体类型与tmdbid配套
:param tmdbid: tmdbid
:param mtype: 识别的媒体类型
:param media_source: 媒体来源
:param media_id: 媒体来源原生ID
:param episode_group: 剧集组
:param cache: 是否使用缓存
:return: 识别的媒体信息,包括剧集信息
@@ -479,8 +487,15 @@ class TheMovieDbModule(_ModuleBase):
# TMDB 只处理影视;音乐识别模块异常时也不能把音乐请求回退成电视剧搜索。
if mtype == MediaType.MUSIC or getattr(meta, "type", None) == MediaType.MUSIC:
return None
if media_source and media_source != MediaSource.TMDB:
return None
if media_id is not None and (
media_source != MediaSource.TMDB or not str(media_id).isdigit()
):
return None
tmdbid = int(media_id) if media_id is not None else None
# 验证参数
if not self._validate_recognize_params(meta, tmdbid, kwargs.get("source")):
if not self._validate_recognize_params(meta, tmdbid, media_source):
return None
if not meta:
@@ -491,7 +506,8 @@ class TheMovieDbModule(_ModuleBase):
if mtype:
meta.type = mtype
if tmdbid:
meta.tmdbid = tmdbid
meta.media_source = MediaSource.TMDB
meta.media_id = str(tmdbid)
cache_info = self.cache.get(meta) if cache else {}
# 查询剧集组
@@ -552,15 +568,17 @@ class TheMovieDbModule(_ModuleBase):
async def async_recognize_media(self, meta: MetaBase = None,
mtype: MediaType = None,
tmdbid: Optional[int] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
episode_group: Optional[str] = None,
cache: Optional[bool] = True,
**kwargs) -> Optional[MediaInfo]:
"""
识别媒体信息(异步版本)
:param meta: 识别的元数据
:param mtype: 识别的媒体类型与tmdbid配套
:param tmdbid: tmdbid
:param mtype: 识别的媒体类型
:param media_source: 媒体来源
:param media_id: 媒体来源原生ID
:param episode_group: 剧集组
:param cache: 是否使用缓存
:return: 识别的媒体信息,包括剧集信息
@@ -568,8 +586,15 @@ class TheMovieDbModule(_ModuleBase):
# 与同步入口保持同一类型边界,音乐请求不得进入 TMDB。
if mtype == MediaType.MUSIC or getattr(meta, "type", None) == MediaType.MUSIC:
return None
if media_source and media_source != MediaSource.TMDB:
return None
if media_id is not None and (
media_source != MediaSource.TMDB or not str(media_id).isdigit()
):
return None
tmdbid = int(media_id) if media_id is not None else None
# 验证参数
if not self._validate_recognize_params(meta, tmdbid, kwargs.get("source")):
if not self._validate_recognize_params(meta, tmdbid, media_source):
return None
if not meta:
@@ -580,7 +605,8 @@ class TheMovieDbModule(_ModuleBase):
if mtype:
meta.type = mtype
if tmdbid:
meta.tmdbid = tmdbid
meta.media_source = MediaSource.TMDB
meta.media_id = str(tmdbid)
cache_info = self.cache.get(meta) if cache else {}
# 查询剧集组
@@ -717,7 +743,7 @@ class TheMovieDbModule(_ModuleBase):
"""
if not meta or not mediainfo:
return None
if mediainfo.source != "themoviedb" or not mediainfo.tmdb_info:
if mediainfo.media_source != "themoviedb" or not mediainfo.tmdb_info:
return None
self.cache.update(meta, mediainfo.tmdb_info)
return True
@@ -743,15 +769,15 @@ class TheMovieDbModule(_ModuleBase):
}
def search_medias(
self, meta: MetaBase, source: Optional[str] = None
self, meta: MetaBase, media_source: Optional[MediaSource] = None
) -> Optional[List[MediaInfo]]:
"""
搜索媒体信息
:param meta: 识别的元数据
:param source: 请求级搜索数据源
:param media_source: 请求级搜索数据源
:return: 媒体信息列表
"""
if not is_media_source_enabled(source, "themoviedb"):
if not is_media_source_enabled(media_source, "themoviedb"):
return None
if not meta.name:
return []
@@ -775,15 +801,15 @@ class TheMovieDbModule(_ModuleBase):
return self._build_search_medias_result(meta, results)
def search_persons(
self, name: str, source: Optional[str] = None
self, name: str, media_source: Optional[MediaSource] = None
) -> Optional[List[schemas.MediaPerson]]:
"""
搜索人物信息
:param name: 人物名称
:param source: 请求级搜索数据源
:param media_source: 请求级搜索数据源
:return: 人物信息列表
"""
if not is_media_source_enabled(source, "themoviedb"):
if not is_media_source_enabled(media_source, "themoviedb"):
return None
if not name:
return []
@@ -793,15 +819,15 @@ class TheMovieDbModule(_ModuleBase):
return []
async def async_search_persons(
self, name: str, source: Optional[str] = None
self, name: str, media_source: Optional[MediaSource] = None
) -> Optional[List[schemas.MediaPerson]]:
"""
异步搜索人物信息
:param name: 人物名称
:param source: 请求级搜索数据源
:param media_source: 请求级搜索数据源
:return: 人物信息列表
"""
if not is_media_source_enabled(source, "themoviedb"):
if not is_media_source_enabled(media_source, "themoviedb"):
return None
if not name:
return []
@@ -811,15 +837,15 @@ class TheMovieDbModule(_ModuleBase):
return []
def search_collections(
self, name: str, source: Optional[str] = None
self, name: str, media_source: Optional[MediaSource] = None
) -> Optional[List[MediaInfo]]:
"""
搜索集合信息
:param name: 合集名称
:param source: 请求级搜索数据源
:param media_source: 请求级搜索数据源
:return: 合集信息列表
"""
if source and not is_media_source_selected(source, "themoviedb"):
if media_source and not is_media_source_selected(media_source, "themoviedb"):
return None
if not name:
return []
@@ -829,15 +855,15 @@ class TheMovieDbModule(_ModuleBase):
return []
async def async_search_collections(
self, name: str, source: Optional[str] = None
self, name: str, media_source: Optional[MediaSource] = None
) -> Optional[List[MediaInfo]]:
"""
异步搜索集合信息
:param name: 合集名称
:param source: 请求级搜索数据源
:param media_source: 请求级搜索数据源
:return: 合集信息列表
"""
if source and not is_media_source_selected(source, "themoviedb"):
if media_source and not is_media_source_selected(media_source, "themoviedb"):
return None
if not name:
return []
@@ -998,7 +1024,7 @@ class TheMovieDbModule(_ModuleBase):
:param mediainfo: 媒体信息
:return: None 表示不处理MediaInfo 表示继续处理
"""
if mediainfo.source != "themoviedb" and settings.RECOGNIZE_SOURCE != "themoviedb":
if mediainfo.media_source != "themoviedb" and settings.RECOGNIZE_SOURCE != "themoviedb":
return None
if not mediainfo.tmdb_id:
return mediainfo
@@ -1225,15 +1251,15 @@ class TheMovieDbModule(_ModuleBase):
# 异步方法
async def async_search_medias(
self, meta: MetaBase, source: Optional[str] = None
self, meta: MetaBase, media_source: Optional[MediaSource] = None
) -> Optional[List[MediaInfo]]:
"""
搜索媒体信息(异步版本)
:param meta: 识别的元数据
:param source: 请求级搜索数据源
:param media_source: 请求级搜索数据源
:return: 媒体信息列表
"""
if not is_media_source_enabled(source, "themoviedb"):
if not is_media_source_enabled(media_source, "themoviedb"):
return None
if not meta.name:
return []

View File

@@ -8,7 +8,7 @@ from app.core.cache import FileCache, TTLCache
from app.core.config import settings
from app.core.meta import MetaBase
from app.log import logger
from app.schemas.types import MediaType
from app.schemas.types import MediaSource, MediaType
from app.utils.singleton import WeakSingleton
lock = RLock()
@@ -141,7 +141,8 @@ class TmdbCache(metaclass=WeakSingleton):
"""
获取缓存KEY
"""
return f"[{meta.type.value if meta.type else '未知'}][{settings.TMDB_LOCALE}]{meta.tmdbid or meta.name}-{meta.year}-{meta.begin_season}"
media_id = meta.media_id if meta.media_source == MediaSource.TMDB else None
return f"[{meta.type.value if meta.type else '未知'}][{settings.TMDB_LOCALE}]{media_id or meta.name}-{meta.year}-{meta.begin_season}"
def get(self, meta: MetaBase):
"""

View File

@@ -208,7 +208,8 @@ class TrimeMediaModule(_ModuleBase, _MediaServerBase[TrimeMedia]):
movies = s.get_movies(
title=mediainfo.title,
year=mediainfo.year,
tmdb_id=mediainfo.tmdb_id,
media_source=mediainfo.media_source,
media_id=mediainfo.media_id,
)
if not movies:
logger.info(f"{mediainfo.title_year} 没有在媒体库 {name}")
@@ -225,7 +226,8 @@ class TrimeMediaModule(_ModuleBase, _MediaServerBase[TrimeMedia]):
itemid, tvs = s.get_tv_episodes(
title=mediainfo.title,
year=mediainfo.year,
tmdb_id=mediainfo.tmdb_id,
media_source=mediainfo.media_source,
media_id=mediainfo.media_id,
item_id=itemid,
)
if not tvs:

View File

@@ -3,8 +3,10 @@ from typing import Any, Dict, Generator, List, Optional, Tuple, Union
import app.modules.trimemedia.api as fnapi
from app import schemas
from app.helper.mediaserver import MediaServerIdentityHelper
from app.log import logger
from app.schemas import MediaType
from app.schemas.types import MediaSource
from app.utils.security import SecurityUtils
from app.utils.url import UrlUtils
@@ -265,14 +267,17 @@ class TrimeMedia:
result.api.close()
def get_movies(
self, title: str, year: Optional[str] = None, tmdb_id: Optional[int] = None
self, title: str, year: Optional[str] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
) -> Optional[List[schemas.MediaServerItem]]:
"""
根据标题和年份,检查电影是否在飞牛中存在,存在则返回列表
:param title: 标题
:param year: 年份,为空则不过滤
:param tmdb_id: TMDB ID
:param media_source: 媒体来源
:param media_id: 媒体来源原生ID
:return: 含title、year属性的字典列表
"""
if not self.is_authenticated():
@@ -282,8 +287,13 @@ class TrimeMedia:
for item in items:
if item.type != fnapi.Type.MOVIE:
continue
item_source, item_media_id = MediaServerIdentityHelper.from_provider_ids(
{"tmdb_id": item.tmdb_id, "imdb_id": item.imdb_id}
)
if (
(not tmdb_id or tmdb_id == item.tmdb_id)
MediaServerIdentityHelper.are_compatible(
item_source, item_media_id, media_source, media_id
)
and title in [item.title, item.original_title]
and (not year or (item.release_date and item.release_date[:4] == year))
):
@@ -306,7 +316,8 @@ class TrimeMedia:
item_id: Optional[str] = None,
title: Optional[str] = None,
year: Optional[str] = None,
tmdb_id: Optional[int] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
season: Optional[int] = None,
) -> Tuple[Optional[str], Optional[Dict[int, list]]]:
"""
@@ -315,7 +326,8 @@ class TrimeMedia:
:param item_id: 飞牛影视中的guid
:param title: 标题
:param year: 年份
:param tmdb_id: TMDBID
:param media_source: 媒体来源
:param media_id: 媒体来源原生ID
:param season: 季
:return: 集号的列表
"""
@@ -339,9 +351,8 @@ class TrimeMedia:
if not item_info:
return None, {}
if tmdb_id and item_info.tmdbid:
if tmdb_id != item_info.tmdbid:
return None, {}
if not MediaServerIdentityHelper.is_compatible(item_info, media_source, media_id):
return None, {}
seasons = self._api.season_list(item_id)
if not seasons:
@@ -465,6 +476,11 @@ class TrimeMedia:
else:
# 将飞牛的媒体类型转为MP能识别的
item_type = "Series" if item.type == fnapi.Type.TV else item.type.value
media_source, media_id = MediaServerIdentityHelper.from_provider_ids({
"tmdb_id": item.tmdb_id,
"imdb_id": item.imdb_id,
"douban_id": item.douban_id,
})
return schemas.MediaServerItem(
server="trimemedia",
library=item.ancestor_guid,
@@ -473,8 +489,8 @@ class TrimeMedia:
title=item.title,
original_title=item.original_title,
year=year,
tmdbid=item.tmdb_id,
imdbid=item.imdb_id,
media_source=media_source,
media_id=media_id,
user_state=user_state,
)

View File

@@ -190,7 +190,8 @@ class UgreenModule(_ModuleBase, _MediaServerBase[Ugreen]):
movies = s.get_movies(
title=mediainfo.title,
year=mediainfo.year,
tmdb_id=mediainfo.tmdb_id,
media_source=mediainfo.media_source,
media_id=mediainfo.media_id,
)
if not movies:
logger.info(f"{mediainfo.title_year} 没有在媒体库 {name}")
@@ -206,7 +207,8 @@ class UgreenModule(_ModuleBase, _MediaServerBase[Ugreen]):
itemid, tvs = s.get_tv_episodes(
title=mediainfo.title,
year=mediainfo.year,
tmdb_id=mediainfo.tmdb_id,
media_source=mediainfo.media_source,
media_id=mediainfo.media_id,
item_id=itemid,
)
if not tvs:

View File

@@ -7,11 +7,11 @@ from urllib.parse import parse_qs, urlparse
from app import schemas
from app.db.systemconfig_oper import SystemConfigOper
from app.helper.mediaserver import MusicMediaServerHelper
from app.helper.mediaserver import MediaServerIdentityHelper, MusicMediaServerHelper
from app.log import logger
from app.modules.ugreen.api import Api
from app.schemas import MediaType
from app.schemas.types import SystemConfigKey
from app.schemas.types import MediaSource, SystemConfigKey
from app.utils.url import UrlUtils
@@ -354,7 +354,8 @@ class Ugreen:
title=video_info.get("name"),
original_title=video_info.get("original_name"),
year=Ugreen.__parse_year(video_info),
tmdbid=tmdb_id,
media_source=MediaSource.TMDB if tmdb_id else None,
media_id=str(tmdb_id) if tmdb_id else None,
user_state=user_state,
)
@@ -635,7 +636,9 @@ class Ugreen:
return result
def get_movies(
self, title: str, year: Optional[str] = None, tmdb_id: Optional[int] = None
self, title: str, year: Optional[str] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
) -> Optional[List[schemas.MediaServerItem]]:
if not self.is_authenticated() or not self._api or not title:
return None
@@ -647,7 +650,12 @@ class Ugreen:
movies = []
for info in self.__extract_video_info_list(data.get("movies_list")):
info_tmdb = info.get("tmdb_id")
if tmdb_id and tmdb_id != info_tmdb:
if not MediaServerIdentityHelper.are_compatible(
MediaSource.TMDB if info_tmdb else None,
str(info_tmdb) if info_tmdb else None,
media_source,
media_id,
):
continue
if title not in [info.get("name"), info.get("original_name")]:
continue
@@ -687,7 +695,12 @@ class Ugreen:
results.append(media_item)
return results
def __search_tv_item(self, title: str, year: Optional[str] = None, tmdb_id: Optional[int] = None) -> Optional[dict]:
def __search_tv_item(
self, title: str, year: Optional[str] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
) -> Optional[dict]:
"""按标题、年份与统一媒体身份查找电视剧条目。"""
if not self._api:
return None
data = self._api.search(title)
@@ -695,7 +708,13 @@ class Ugreen:
return None
for info in self.__extract_video_info_list(data.get("tv_list")):
if tmdb_id and tmdb_id != info.get("tmdb_id"):
info_tmdb = info.get("tmdb_id")
if not MediaServerIdentityHelper.are_compatible(
MediaSource.TMDB if info_tmdb else None,
str(info_tmdb) if info_tmdb else None,
media_source,
media_id,
):
continue
if title not in [info.get("name"), info.get("original_name")]:
continue
@@ -710,15 +729,17 @@ class Ugreen:
item_id: Optional[str] = None,
title: Optional[str] = None,
year: Optional[str] = None,
tmdb_id: Optional[int] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
season: Optional[int] = None,
) -> tuple[Optional[str], Optional[Dict[int, list]]]:
"""
根据标题、年份、TMDB ID和季号查询绿联媒体库中的电视剧已入库集数。
根据标题、年份、媒体身份和季号查询绿联媒体库中的电视剧已入库集数。
:param item_id: 绿联媒体库中的剧集ID存在缓存ID时优先使用
:param title: 标题
:param year: 年份
:param tmdb_id: TMDB ID
:param media_source: 媒体来源
:param media_id: 媒体来源原生ID
:param season: 季号
:return: 命中的剧集ID及每季已入库集数
"""
@@ -729,7 +750,9 @@ class Ugreen:
if not item_id:
if not title:
return None, None
if not (tv_info := self.__search_tv_item(title, year, tmdb_id)):
if not (tv_info := self.__search_tv_item(
title, year, media_source, media_id
)):
return None, None
found_item_id = tv_info.get("ug_video_info_id")
if found_item_id is None:
@@ -742,7 +765,9 @@ class Ugreen:
if not item_info and cached_item_id and title:
# 媒体删除后重新入库会导致缓存ID失效回退到标题搜索避免误判整部剧缺失。
logger.warning(f"绿联缓存的电视剧媒体ID {cached_item_id} 已失效,尝试按标题重新搜索:{title}")
if not (tv_info := self.__search_tv_item(title, year, tmdb_id)):
if not (tv_info := self.__search_tv_item(
title, year, media_source, media_id
)):
return None, {}
found_item_id = tv_info.get("ug_video_info_id")
if found_item_id is None:
@@ -751,7 +776,7 @@ class Ugreen:
item_info = self.get_iteminfo(item_id)
if not item_info:
return None, {}
if tmdb_id and item_info.tmdbid and tmdb_id != item_info.tmdbid:
if not MediaServerIdentityHelper.is_compatible(item_info, media_source, media_id):
return None, {}
tv_detail = self._api.get_tv(item_id, folder_path="ALL")

View File

@@ -172,7 +172,8 @@ class ZSpaceModule(_ModuleBase, _MediaServerBase[ZSpace]):
)
movies = s.get_movies(title=mediainfo.title,
year=mediainfo.year,
tmdb_id=mediainfo.tmdb_id)
media_source=mediainfo.media_source,
media_id=mediainfo.media_id)
if not movies:
logger.info(f"{mediainfo.title_year} 没有在媒体库 {name}")
continue
@@ -187,7 +188,8 @@ class ZSpaceModule(_ModuleBase, _MediaServerBase[ZSpace]):
else:
itemid, tvs = s.get_tv_episodes(title=mediainfo.title,
year=mediainfo.year,
tmdb_id=mediainfo.tmdb_id,
media_source=mediainfo.media_source,
media_id=mediainfo.media_id,
item_id=itemid)
if not tvs:
logger.info(f"{mediainfo.title_year} 没有在媒体库 {name}")

View File

@@ -8,10 +8,10 @@ from typing import List, Optional, Union, Dict, Generator, Tuple, Any
from requests import Response
from app import schemas
from app.helper.mediaserver import MusicMediaServerHelper
from app.helper.mediaserver import MediaServerIdentityHelper, MusicMediaServerHelper
from app.log import logger
from app.schemas import MediaServerItem
from app.schemas.types import MediaType
from app.schemas.types import MediaSource, MediaType
from app.utils.http import RequestUtils
from app.utils.url import UrlUtils
@@ -514,12 +514,14 @@ class ZSpace:
def get_movies(self,
title: str,
year: Optional[str] = None,
tmdb_id: Optional[int] = None) -> Optional[List[schemas.MediaServerItem]]:
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None) -> Optional[List[schemas.MediaServerItem]]:
"""
根据标题和年份,检查电影是否在极影视中存在,存在则返回列表
:param title: 标题
:param year: 年份,可以为空,为空时不按年份过滤
:param tmdb_id: TMDB ID
:param media_source: 媒体来源
:param media_id: 媒体来源原生ID
:return: 含title、year属性的字典列表
"""
if not self._host or not self._apikey:
@@ -545,7 +547,9 @@ class ZSpace:
continue
mediaserver_item = self.__format_item_info(item)
if mediaserver_item:
if (not tmdb_id or mediaserver_item.tmdbid == tmdb_id) and \
if MediaServerIdentityHelper.is_compatible(
mediaserver_item, media_source, media_id
) and \
mediaserver_item.title == title and \
(not year or str(mediaserver_item.year) == str(year)):
ret_movies.append(mediaserver_item)
@@ -588,7 +592,8 @@ class ZSpace:
item_id: Optional[str] = None,
title: Optional[str] = None,
year: Optional[str] = None,
tmdb_id: Optional[int] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
season: Optional[int] = None
) -> Tuple[Optional[str], Optional[Dict[int, List[int]]]]:
"""
@@ -596,7 +601,8 @@ class ZSpace:
:param item_id: 极影视中的ID
:param title: 标题
:param year: 年份
:param tmdb_id: TMDBID
:param media_source: 媒体来源
:param media_id: 媒体来源原生ID
:param season: 季
:return: 每一季的已有集数
"""
@@ -620,9 +626,8 @@ class ZSpace:
item_info = self.get_iteminfo(item_id)
if not item_info:
return None, {}
if item_info and tmdb_id and item_info.tmdbid:
if str(tmdb_id) != str(item_info.tmdbid):
return None, {}
if not MediaServerIdentityHelper.is_compatible(item_info, media_source, media_id):
return None, {}
if season is None:
season = None
try:
@@ -823,7 +828,9 @@ class ZSpace:
play_count=item.get("UserData", {}).get("PlayCount"),
percentage=item.get("UserData", {}).get("PlayedPercentage"),
)
tmdbid = item.get("ProviderIds", {}).get("Tmdb")
media_source, media_id = MediaServerIdentityHelper.from_provider_ids(
item.get("ProviderIds")
)
return schemas.MediaServerItem(
server="zspace",
library=item.get("ParentId"),
@@ -832,9 +839,8 @@ class ZSpace:
title=item.get("Name"),
original_title=item.get("OriginalTitle"),
year=item.get("ProductionYear"),
tmdbid=int(tmdbid) if tmdbid else None,
imdbid=item.get("ProviderIds", {}).get("Imdb"),
tvdbid=item.get("ProviderIds", {}).get("Tvdb"),
media_source=media_source,
media_id=media_id,
path=item.get("Path"),
note=MusicMediaServerHelper.build_note(item)
if item.get("Type") in {"MusicAlbum", "Audio"} else None,