diff --git a/app/agent/prompt/System Core Prompt.txt b/app/agent/prompt/System Core Prompt.txt index 1f754b245..11ebe1c2d 100644 --- a/app/agent/prompt/System Core Prompt.txt +++ b/app/agent/prompt/System Core Prompt.txt @@ -48,7 +48,7 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel 1. Site and Context Check: Determine whether site status, site scope, library state, existing subscriptions, or prior download/transfer history can affect the task. -2. Media Identity Resolution: Confirm an exact source-native identity. Video normally uses TMDB/Douban/Bangumi/AniList IDs plus title, year, type, season, or episode. Music uses `media_type=music`, MusicBrainz `media_source` + `media_id`, and `music_type=recording|album|artist`, plus artist/title/album when available. Use `search_media`, `query_media_detail`, or `recognize_media` as needed. +2. Media Identity Resolution: Confirm an exact source-native identity and pass it only as the fixed `media_source` enum plus `media_id`. Video and music share this pair; music also uses `media_type=music` and `music_type=recording|album|artist`. Use `search_media`, `query_media_detail`, or `recognize_media` as needed. 3. Resource Discovery: Use the appropriate search path for the task. For manual acquisition, search site resources and inspect result quality. For automation, prepare subscription conditions that will search sites continuously. 4. Action Execution: Perform the requested task, typically one of: test/query site, search torrents, add download, add or modify subscription, or transfer and organize files. 5. Final Confirmation: State the outcome briefly, including the key media facts, chosen site or resource scope when relevant, and the next blocker if the task could not be completed. diff --git a/app/agent/prompt/System Tasks.yaml b/app/agent/prompt/System Tasks.yaml index 16a7d0963..ceca65122 100644 --- a/app/agent/prompt/System Tasks.yaml +++ b/app/agent/prompt/System Tasks.yaml @@ -42,7 +42,7 @@ task_types: - "Delete the failed history record using `delete_transfer_history` with history_id={history_id}." - "Re-identify the media using `recognize_media` with the source file path. For audio files, set media_type='music' and preserve artist/title/album context." - "If recognition fails, try `search_media` with keywords from the filename. For music, distinguish recording, album, and browse-only artist results." - - "Re-transfer using `transfer_file` with the source path and exact identity fields. Reuse media_source + media_id + media_type + music_type for music, or the applicable video IDs." + - "Re-transfer using `transfer_file` with the source path and exact identity fields. Reuse media_source + media_id for every media type, plus media_type + music_type for music." - "Report the final result." batch_transfer_failed_retry: header: "[System Task - Batch Transfer Failed Retry]" @@ -92,7 +92,7 @@ task_types: - "Only continue when you have high confidence in the target media." - "Before re-organizing, delete the old transfer history record with `delete_transfer_history` so the system will not skip the source file." - "Then use `transfer_file` to organize the source path directly." - - "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, all known media IDs, media_source, media_id, media_type, and music_type. For an album, retry the album directory once with the album identity when the records share that directory." + - "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, media_source, media_id, media_type, and music_type. For an album, retry the album directory once with the album identity when the records share that directory." - "If this record is already correct and no re-organize is needed, do not perform destructive actions; simply report that no change is necessary." task_rules: - "Do NOT rely on previous chat context. Work only from the record above." @@ -118,7 +118,7 @@ task_types: - "If a source file no longer exists or cannot be safely processed, skip that record and note the reason." - "Before re-organizing a record, delete the old transfer history record with `delete_transfer_history` so the system will not skip the source file." - "Then use `transfer_file` to organize the source path directly." - - "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, all known media IDs, media_source, media_id, media_type, and music_type. Prefer one directory transfer for a verified complete album instead of treating each track as an unrelated media item." + - "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, media_source, media_id, media_type, and music_type. Prefer one directory transfer for a verified complete album instead of treating each track as an unrelated media item." - "If a record is already correct and no re-organize is needed, do not perform destructive actions; simply mark it as skipped." - "Report only the aggregate outcome, including how many records succeeded, skipped, and failed." task_rules: diff --git a/app/agent/tools/impl/get_recommendations.py b/app/agent/tools/impl/get_recommendations.py index 13768d08b..5fa7f66c2 100644 --- a/app/agent/tools/impl/get_recommendations.py +++ b/app/agent/tools/impl/get_recommendations.py @@ -7,7 +7,6 @@ from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag -from app.chain.music import MusicChain from app.chain.recommend import RecommendChain from app.log import logger from app.modules.listenbrainz import ( @@ -168,13 +167,13 @@ class GetRecommendationsTool(MoviePilotTool): f"错误:无效的音乐实体类型 '{music_type}'," "支持的类型:'recording', 'album'" ) - music_chain = MusicChain() + recommend_chain = RecommendChain() if source == "listenbrainz_chart": if range_name not in LISTENBRAINZ_CHART_RANGES: return f"错误:无效的榜单周期 '{range_name}'" if sort_by not in {"listen_count.desc", "listen_count.asc"}: return f"错误:无效的榜单排序 '{sort_by}'" - results = await music_chain.async_chart( + results = await recommend_chain.async_music_chart( range_name=range_name, page=page, count=page_size, @@ -191,7 +190,7 @@ class GetRecommendationsTool(MoviePilotTool): if not past and not future: return "错误:past 和 future 不能同时为 false" normalized_days = max(1, min(days or 14, LISTENBRAINZ_FRESH_MAX_DAYS)) - results = await music_chain.async_fresh_releases( + results = await recommend_chain.async_music_fresh_releases( days=normalized_days, sort=fresh_sort, past=bool(past), @@ -324,7 +323,7 @@ class GetRecommendationsTool(MoviePilotTool): "douban_id": r.get("douban_id"), "bangumi_id": r.get("bangumi_id"), "anilist_id": r.get("anilist_id"), - "media_source": r.get("source"), + "media_source": r.get("media_source"), "media_id": r.get("media_id"), "vote_average": r.get("vote_average"), "poster_path": r.get("poster_path"), diff --git a/app/agent/tools/impl/query_media_detail.py b/app/agent/tools/impl/query_media_detail.py index 6b5ea6f0a..4fa3c36f4 100644 --- a/app/agent/tools/impl/query_media_detail.py +++ b/app/agent/tools/impl/query_media_detail.py @@ -9,7 +9,6 @@ from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag from app.chain.media import MediaChain -from app.chain.music import MusicChain from app.log import logger from app.schemas.types import ( MUSIC_ENTITY_ALBUM, @@ -125,9 +124,11 @@ class QueryMediaDetailTool(MoviePilotTool): "message": "查询音乐详情必须同时提供 media_source 和 media_id", }, ensure_ascii=False) - music_chain = MusicChain() + media_chain = MediaChain() if normalized_music_type == MUSIC_ENTITY_ALBUM: - album_info = await music_chain.async_album(media_source, media_id) + album_info = await media_chain.async_get_music_album( + media_source, media_id + ) if not album_info: return json.dumps({ "success": False, @@ -140,7 +141,9 @@ class QueryMediaDetailTool(MoviePilotTool): ) if normalized_music_type == MUSIC_ENTITY_ARTIST: - artist_info = await music_chain.async_artist(media_source, media_id) + artist_info = await media_chain.async_get_music_artist( + media_source, media_id + ) if not artist_info: return json.dumps({ "success": False, @@ -153,7 +156,7 @@ class QueryMediaDetailTool(MoviePilotTool): if include_artist_albums: pending.append(( "albums", - music_chain.async_artist_albums( + media_chain.async_get_music_artist_albums( media_source=media_source, media_id=media_id, page=normalized_page, @@ -164,7 +167,7 @@ class QueryMediaDetailTool(MoviePilotTool): if include_related_artists: pending.append(( "related_artists", - music_chain.async_artist_related( + media_chain.async_get_music_artist_related( media_source=media_source, media_id=media_id, count=normalized_count, @@ -181,7 +184,6 @@ class QueryMediaDetailTool(MoviePilotTool): ] return json.dumps(result, ensure_ascii=False, indent=2) - media_chain = MediaChain() mediainfo = await media_chain.async_recognize_media( media_source=media_source, media_id=media_id, diff --git a/app/agent/tools/impl/query_popular_subscribes.py b/app/agent/tools/impl/query_popular_subscribes.py index 497ca16bf..8dbb150ff 100644 --- a/app/agent/tools/impl/query_popular_subscribes.py +++ b/app/agent/tools/impl/query_popular_subscribes.py @@ -170,23 +170,17 @@ class QueryPopularSubscribesTool(MoviePilotTool): # 跳过无法识别类型的数据,避免单条脏数据导致整批失败 logger.warning(f"跳过未知媒体类型: {sub.get('type')}") continue - media.tmdb_id = sub.get("tmdbid") # 处理标题 title = sub.get("name") season = sub.get("season") - if season not in (None, "") and int(season) != 1 and media.tmdb_id: + if season not in (None, "") and int(season) != 1: # 小写数据转大写 season_str = cn2an.an2cn(season, "low") title = f"{title} 第{season_str}季" media.title = title media.year = sub.get("year") - media.douban_id = sub.get("doubanid") - media.bangumi_id = sub.get("bangumiid") - media.anilist_id = sub.get("anilistid") media.media_source = sub.get("media_source") media.media_id = sub.get("media_id") - media.tvdb_id = sub.get("tvdbid") - media.imdb_id = sub.get("imdbid") media.season = sub.get("season") media.vote_average = sub.get("vote") media.poster_path = sub.get("poster") @@ -208,14 +202,8 @@ class QueryPopularSubscribesTool(MoviePilotTool): "type": media_type_to_agent(media_dict.get("type")), "title": media_dict.get("title"), "year": media_dict.get("year"), - "tmdb_id": media_dict.get("tmdb_id"), - "douban_id": media_dict.get("douban_id"), - "bangumi_id": media_dict.get("bangumi_id"), - "anilist_id": media_dict.get("anilist_id"), - "media_source": media_dict.get("source"), + "media_source": media_dict.get("media_source"), "media_id": media_dict.get("media_id"), - "tvdb_id": media_dict.get("tvdb_id"), - "imdb_id": media_dict.get("imdb_id"), "season": media_dict.get("season"), "vote_average": media_dict.get("vote_average"), "poster_path": media_dict.get("poster_path"), diff --git a/app/agent/tools/impl/query_subscribe_shares.py b/app/agent/tools/impl/query_subscribe_shares.py index a38ebcb45..04c7dccc5 100644 --- a/app/agent/tools/impl/query_subscribe_shares.py +++ b/app/agent/tools/impl/query_subscribe_shares.py @@ -111,10 +111,6 @@ class QuerySubscribeSharesTool(MoviePilotTool): "year": share.get("year"), "type": normalized_type, "season": share.get("season"), - "tmdbid": share.get("tmdbid"), - "doubanid": share.get("doubanid"), - "bangumiid": share.get("bangumiid"), - "anilistid": share.get("anilistid"), "media_source": share.get("media_source"), "media_id": share.get("media_id"), "music_type": normalized_music_type, diff --git a/app/agent/tools/impl/recognize_media.py b/app/agent/tools/impl/recognize_media.py index 2b69521b6..90fc9838c 100644 --- a/app/agent/tools/impl/recognize_media.py +++ b/app/agent/tools/impl/recognize_media.py @@ -8,10 +8,10 @@ from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag -from app.chain.music import MusicChain from app.chain.media import MediaChain from app.core.config import settings from app.core.context import Context +from app.core.meta import MetaMusic from app.core.metainfo import MetaInfo from app.log import logger from app.schemas.types import MediaType, media_type_to_agent @@ -108,7 +108,6 @@ class RecognizeMediaTool(MoviePilotTool): media_type_enum is None and is_audio_path ) if recognize_music: - music_chain = MusicChain() if path: if not is_audio_path: return json.dumps({ @@ -129,7 +128,7 @@ class RecognizeMediaTool(MoviePilotTool): "path": path, }, ensure_ascii=False) if title: - metainfo = music_chain.parse_query(title) + metainfo = MetaMusic.parse_query(title) if artist: metainfo.artists = [artist] if album: diff --git a/app/agent/tools/impl/scrape_metadata.py b/app/agent/tools/impl/scrape_metadata.py index 84326b0e3..55ea2a1b7 100644 --- a/app/agent/tools/impl/scrape_metadata.py +++ b/app/agent/tools/impl/scrape_metadata.py @@ -9,10 +9,17 @@ from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag from app.chain.media import MediaChain +from app.chain.scraping import ScrapingChain from app.core.config import settings from app.log import logger from app.schemas import FileItem -from app.schemas.types import MUSIC_ENTITY_ARTIST, MediaType, media_type_to_agent +from app.schemas.types import ( + MUSIC_ENTITY_ARTIST, + MediaSource, + MediaType, + media_type_to_agent, +) +from app.utils.media import normalize_media_source from ._music_utils import normalize_music_type, simplify_music_info @@ -39,7 +46,7 @@ class ScrapeMetadataInput(BaseModel): None, description="For an explicit music ID: recording for one file or album for a complete album directory", ) - media_source: Optional[str] = Field( + media_source: Optional[MediaSource] = Field( None, description=( "Music metadata source: musicbrainz, theaudiodb, or doubanmusic. " @@ -97,7 +104,7 @@ class ScrapeMetadataTool(MoviePilotTool): overwrite: Optional[bool] = False, media_type: Optional[str] = None, music_type: Optional[str] = None, - media_source: Optional[str] = None, + media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, **kwargs, ) -> str: @@ -127,11 +134,18 @@ class ScrapeMetadataTool(MoviePilotTool): "支持的类型:'movie', 'tv', 'music'" ), }, ensure_ascii=False) - if bool(media_source) != bool(media_id): + explicit_identity = media_source is not None or media_id is not None + normalized_source = normalize_media_source(media_source) + normalized_media_id = str(media_id).strip() if media_id is not None else "" + if explicit_identity and ( + not normalized_source or not normalized_media_id + ): return json.dumps({ "success": False, - "message": "media_source 和 media_id 必须同时提供", + "message": "必须同时提供有效的 media_source 和 media_id", }, ensure_ascii=False) + media_source = normalized_source + media_id = normalized_media_id or None local_path = Path(path) is_local_directory = (storage or "local") == "local" and local_path.is_dir() @@ -151,6 +165,7 @@ class ScrapeMetadataTool(MoviePilotTool): ) media_chain = MediaChain() + scraping_chain = ScrapingChain() is_audio_file = ( fileitem.type == "file" and Path(path).suffix.lower() in settings.RMT_AUDIOEXT @@ -179,8 +194,8 @@ class ScrapeMetadataTool(MoviePilotTool): mediainfo = None if media_source and media_id: recognize_kwargs = { - "source": media_source, - "mediaid": media_id, + "media_source": media_source, + "media_id": media_id, "mtype": MediaType.MUSIC, } if normalized_music_type: @@ -205,11 +220,11 @@ class ScrapeMetadataTool(MoviePilotTool): success, message = await self.run_blocking( "storage", - media_chain.scrape_music_metadata, + scraping_chain.scrape_music_metadata, fileitem=fileitem, mediainfo=mediainfo, overwrite=bool(overwrite), - source=media_source, + media_source=media_source, ) result = { "success": success, @@ -246,7 +261,7 @@ class ScrapeMetadataTool(MoviePilotTool): # 刮削会包含磁盘写入和外部图片/元数据访问,统一放到 storage 线程池。 await self.run_blocking( "storage", - media_chain.scrape_metadata, + scraping_chain.scrape_metadata, fileitem=fileitem, meta=context.meta_info, mediainfo=context.media_info, @@ -262,7 +277,8 @@ class ScrapeMetadataTool(MoviePilotTool): "title": context.media_info.title, "year": context.media_info.year, "type": media_type_to_agent(context.media_info.type), - "tmdb_id": context.media_info.tmdb_id, + "media_source": context.media_info.media_source, + "media_id": context.media_info.media_id, "season": context.media_info.season, }, }, diff --git a/app/agent/tools/impl/search_media.py b/app/agent/tools/impl/search_media.py index c2b8a8389..5d3571018 100644 --- a/app/agent/tools/impl/search_media.py +++ b/app/agent/tools/impl/search_media.py @@ -8,7 +8,6 @@ from pydantic import BaseModel, Field from app.agent.tools.base import MoviePilotTool from app.agent.tools.tags import ToolTag from app.chain.media import MediaChain -from app.chain.music import MusicChain from app.log import logger from app.schemas.types import MediaType, media_type_to_agent from app.utils.media import resolve_media_identity @@ -93,7 +92,7 @@ class SearchMediaTool(MoviePilotTool): f"错误:无效的音乐实体类型 '{music_type}'," "支持的类型:'recording', 'album', 'artist'" ) - results = await MusicChain().async_search(query=title, limit=100) + results = await MediaChain().async_search_music(query=title, limit=100) filtered_music = [ item for item in results or [] diff --git a/app/api/endpoints/download.py b/app/api/endpoints/download.py index 3044ba859..3b10cf83e 100644 --- a/app/api/endpoints/download.py +++ b/app/api/endpoints/download.py @@ -5,8 +5,8 @@ from fastapi import APIRouter, Depends, Body from app import schemas from app.chain.download import DownloadChain from app.chain.media import MediaChain -from app.chain.music import MusicChain from app.core.context import Context, MediaInfo, MusicInfo, SubtitleInfo, TorrentInfo +from app.core.meta import MetaMusic from app.core.metainfo import MetaInfo from app.core.security import verify_token from app.db.models.user import User @@ -75,7 +75,7 @@ def download( """ if isinstance(media_in, schemas.MusicInfo): mediainfo = MusicInfo.from_dict(media_in.model_dump()) - metainfo = MusicChain.to_meta(mediainfo) + metainfo = MetaMusic.from_music_info(mediainfo) metainfo.org_string = torrent_in.title else: metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description) @@ -142,7 +142,7 @@ def add( normalized_music_type = MUSIC_ENTITY_RECORDING # 元数据 metainfo = ( - MusicChain.parse_query(torrent_in.title) + MetaMusic.parse_query(torrent_in.title) if is_music else MetaInfo(title=torrent_in.title, subtitle=torrent_in.description) ) diff --git a/app/api/endpoints/media.py b/app/api/endpoints/media.py index 8ce878add..38a40665f 100644 --- a/app/api/endpoints/media.py +++ b/app/api/endpoints/media.py @@ -2,11 +2,12 @@ from pathlib import Path from typing import Annotated, Any, List, Optional, Union from uuid import UUID -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Query +from pydantic import BeforeValidator from app import schemas from app.chain.media import MediaChain -from app.chain.music import MusicChain +from app.chain.scraping import ScrapingChain from app.chain.tmdb import TmdbChain from app.core.config import settings from app.core.context import Context, MusicInfo @@ -20,28 +21,64 @@ from app.schemas.category import CategoryConfig from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource from app.utils.media import ( is_music_media_source, + normalize_media_source, normalize_music_type, + parse_media_source_selection, + resolve_media_identity, ) router = APIRouter() +def _split_media_source_query(value: object) -> tuple[str, ...]: + """展开重复或逗号分隔的来源参数,并在枚举校验前规范历史别名。""" + if value in (None, ""): + return () + values = value if isinstance(value, (list, tuple)) else (value,) + sources = tuple( + source.strip() + for item in values + for source in str(item).split(",") + if source.strip() + ) + return tuple( + normalized.value if (normalized := normalize_media_source(source)) else source + for source in sources + ) + + +MediaSourceQuery = Annotated[ + tuple[MediaSource, ...], + BeforeValidator(_split_media_source_query), + Query(), +] + + def _is_valid_source_media_id( media_source: Optional[MediaSource], media_id: str, ) -> bool: """按媒体数据源校验原生 ID,并兼容豆瓣音乐的曲目复合 ID。""" - if media_source == MediaSource.MusicBrainz: + normalized_source, normalized_media_id = resolve_media_identity( + media_source=media_source, + media_id=media_id, + ) + if not normalized_source or not normalized_media_id: + return False + if normalized_source == MediaSource.MusicBrainz: try: - UUID(media_id) + UUID(normalized_media_id) return True except (TypeError, ValueError): return False - if media_source == MediaSource.DoubanMusic and ":" in media_id: - album_id, track_number = media_id.split(":", 1) + if normalized_source == MediaSource.DoubanMusic and ":" in normalized_media_id: + album_id, track_number = normalized_media_id.split(":", 1) return album_id.isdigit() and track_number.isdigit() - if media_source == MediaSource.IMDb: - return media_id.startswith("tt") and media_id[2:].isdigit() - return bool(media_id.strip()) + if normalized_source == MediaSource.IMDb: + return ( + normalized_media_id.startswith("tt") + and normalized_media_id[2:].isdigit() + ) + return True def _build_recognize_metainfo( @@ -132,7 +169,7 @@ async def recognize( metainfo = _build_recognize_metainfo(title, subtitle, custom_words) # 显式音乐来源需要按音乐元数据解析,避免名称测试误入影视识别。 if is_music_media_source(media_source) and not isinstance(metainfo, MetaMusic): - metainfo = MusicChain.parse_query(title) + metainfo = MetaMusic.parse_query(title) mediainfo = await MediaChain().async_recognize_by_meta( metainfo, media_source=media_source, @@ -204,7 +241,7 @@ async def search( type: Optional[str] = "media", page: int = 1, count: int = 8, - media_source: Optional[str] = None, + media_source: MediaSourceQuery = (), _: schemas.TokenPayload = Depends(verify_token), ) -> Any: """ @@ -214,7 +251,7 @@ async def search( :param type: 搜索类型,支持 media、music、collection、person :param page: 页码 :param count: 每页数量 - :param media_source: 请求级搜索数据源,支持逗号分隔 + :param media_source: 请求级搜索数据源枚举;可重复传入,逗号格式仅用于兼容旧客户端 :param _: Token校验 :return: 搜索结果列表 """ @@ -227,33 +264,43 @@ async def search( return obj.get("media_source") return obj.media_source + # 直接函数调用也可能绕过 FastAPI/Pydantic,仅在该测试与内部兼容边界补一次规范化。 + selected_sources = ( + media_source + if isinstance(media_source, tuple) + and all(isinstance(source, MediaSource) for source in media_source) + else parse_media_source_selection(",".join(_split_media_source_query(media_source))) + ) + selected_sources = tuple(dict.fromkeys(selected_sources)) + source_selection = selected_sources or None + media_chain = MediaChain() - if type == "music" or is_music_media_source(media_source): + if type == "music" or any(is_music_media_source(source) for source in selected_sources): # 音乐搜索统一入口,与影视搜索共用 /media/search music_search_params = {"query": title, "limit": count} - # 未指定来源时保留既有调用契约,由 MusicChain 选择默认音乐源。 - if media_source: - music_search_params["media_source"] = media_source - music_infos = await MusicChain().async_search(**music_search_params) + # 未指定来源时由 MediaChain 使用默认 MusicBrainz 来源。 + if source_selection: + music_search_params["media_source"] = source_selection + music_infos = await media_chain.async_search_music(**music_search_params) return [ info.to_dict() for info in music_infos ] if music_infos else [] if type == "media": _, medias = await media_chain.async_search( - title=title, media_source=media_source + title=title, media_source=source_selection ) result = [media.to_dict() for media in medias] if medias else [] elif type == "collection": collections = await media_chain.async_search_collections( - name=title, media_source=media_source + name=title, media_source=source_selection ) result = ( [collection.to_dict() for collection in collections] if collections else [] ) else: # person persons = await media_chain.async_search_persons( - name=title, media_source=media_source + name=title, media_source=source_selection ) result = [person.model_dump() for person in persons] if persons else [] @@ -293,7 +340,10 @@ def scrape( """ if not fileitem or not fileitem.path: return schemas.Response(success=False, message="刮削路径无效") - normalized_media_id = media_id.strip() if media_id else None + has_explicit_media_id = media_id is not None + normalized_media_id = str(media_id).strip() if has_explicit_media_id else None + if has_explicit_media_id and not normalized_media_id: + return schemas.Response(success=False, message="媒体ID格式无效") if normalized_media_id and not media_source: return schemas.Response( success=False, message="指定媒体ID时必须同时指定媒体数据源" @@ -329,7 +379,7 @@ def scrape( ) if not music_info: return schemas.Response(success=False, message="刮削失败,无法识别音乐信息") - success, message = MediaChain().scrape_music_metadata( + success, message = ScrapingChain().scrape_music_metadata( fileitem=fileitem, mediainfo=music_info, overwrite=True, @@ -366,7 +416,7 @@ def scrape( if not Path(fileitem.path).exists(): return schemas.Response(success=False, message="刮削路径不存在") # 手动刮削 (暂时使用同步版本,可以后续优化为异步) - chain.scrape_metadata( + ScrapingChain().scrape_metadata( fileitem=fileitem, meta=meta_info, mediainfo=media_info, @@ -420,7 +470,13 @@ async def group_seasons( """ 查询剧集组季信息(themoviedb) """ - return await TmdbChain().async_tmdb_group_seasons(group_id=episode_group) + _, normalized_group_id = resolve_media_identity( + media_source=MediaSource.TMDB, + media_id=episode_group, + ) + if not normalized_group_id: + return [] + return await TmdbChain().async_tmdb_group_seasons(group_id=normalized_group_id) @router.get("/groups/{tmdbid}", summary="查询媒体剧集组", response_model=List[dict]) @@ -428,9 +484,15 @@ async def groups(tmdbid: int, _: schemas.TokenPayload = Depends(verify_token)) - """ 查询媒体剧集组列表(themoviedb) """ - mediainfo = await MediaChain().async_recognize_media( + media_source, media_id = resolve_media_identity( media_source=MediaSource.TMDB, - media_id=str(tmdbid), + media_id=tmdbid, + ) + if not media_source or not media_id: + return [] + mediainfo = await MediaChain().async_recognize_media( + media_source=media_source, + media_id=media_id, mtype=MediaType.TV, ) if not mediainfo: @@ -452,11 +514,15 @@ async def seasons( """ 查询媒体季信息 """ - if media_source or media_id: - if not media_source or not media_id: + if media_source is not None or media_id is not None: + normalized_source, normalized_media_id = resolve_media_identity( + media_source=media_source, + media_id=media_id, + ) + if not normalized_source or not normalized_media_id: return [] - if media_source == MediaSource.TMDB and media_id.isdigit(): - tmdbid = int(media_id) + if normalized_source == MediaSource.TMDB and normalized_media_id.isdigit(): + tmdbid = int(normalized_media_id) seasons_info = await TmdbChain().async_tmdb_seasons(tmdbid=tmdbid) if seasons_info: if season is not None: @@ -464,8 +530,8 @@ async def seasons( return seasons_info else: mediainfo = await MediaChain().async_recognize_media( - media_source=media_source, - media_id=media_id, + media_source=normalized_source, + media_id=normalized_media_id, mtype=MediaType.TV, cache=False, ) @@ -483,13 +549,16 @@ async def seasons( obtain_images=False, ) if mediainfo: + recognized_source, recognized_media_id = resolve_media_identity( + media=mediainfo + ) if ( - mediainfo.media_source == MediaSource.TMDB - and mediainfo.media_id - and mediainfo.media_id.isdigit() + recognized_source == MediaSource.TMDB + and recognized_media_id + and recognized_media_id.isdigit() ): seasons_info = await TmdbChain().async_tmdb_seasons( - tmdbid=int(mediainfo.media_id) + tmdbid=int(recognized_media_id) ) if seasons_info: if season is not None: @@ -512,10 +581,16 @@ async def detail( 根据媒体来源和原生 ID 查询媒体信息,type_name: 电影/电视剧 """ mtype = MediaType(type_name) - mediachain = MediaChain() - mediainfo = await mediachain.async_recognize_media( + normalized_source, normalized_media_id = resolve_media_identity( media_source=media_source, media_id=media_id, + ) + if not normalized_source or not normalized_media_id: + return schemas.MediaInfo() + mediachain = MediaChain() + mediainfo = await mediachain.async_recognize_media( + media_source=normalized_source, + media_id=normalized_media_id, mtype=mtype, ) # 识别 diff --git a/app/api/endpoints/music.py b/app/api/endpoints/music.py index 105f01db8..a915afe6c 100644 --- a/app/api/endpoints/music.py +++ b/app/api/endpoints/music.py @@ -4,8 +4,8 @@ from fastapi import APIRouter, Depends, HTTPException, Query from app import schemas from app.chain.media import MediaChain -from app.chain.music import MusicChain -from app.schemas.types import MediaType +from app.chain.recommend import RecommendChain +from app.schemas.types import MediaSource, MediaType from app.core.context import MusicAlbumInfo, MusicArtistInfo, MusicInfo from app.core.security import verify_token from app.db.models.user import User @@ -22,12 +22,12 @@ router = APIRouter() CountParam = Annotated[int, Query(ge=1, le=100)] PageParam = Annotated[int, Query(ge=1)] MusicSourceParam = Annotated[ - str, - Query(pattern="^(musicbrainz|theaudiodb|doubanmusic)$"), + MediaSource, + Query(), ] MusicExploreSourceParam = Annotated[ - str, - Query(pattern="^(musicbrainz|doubanmusic)$"), + MediaSource, + Query(), ] MusicModeParam = Annotated[str, Query(pattern="^(chart|fresh)$")] MusicEntityParam = Annotated[str, Query(pattern="^(recording|album)$")] @@ -41,6 +41,29 @@ MusicAlbumTypeParam = Annotated[ Optional[str], Query(pattern="^(album|single|ep|broadcast|other|compilation|soundtrack|live|remix)$"), ] +_MUSIC_DETAIL_SOURCES = frozenset({ + MediaSource.MusicBrainz, + MediaSource.TheAudioDB, + MediaSource.DoubanMusic, +}) +_MUSIC_EXPLORE_SOURCES = frozenset({ + MediaSource.MusicBrainz, + MediaSource.DoubanMusic, +}) + + +def _validate_music_source( + media_source: MediaSource, + allowed_sources: frozenset[MediaSource], +) -> MediaSource: + """将 HTTP 或直接调用参数规范为音乐来源枚举,并拒绝不支持的来源。""" + try: + normalized_source = MediaSource(media_source) + except (TypeError, ValueError) as err: + raise HTTPException(status_code=422, detail="无效的媒体来源") from err + if normalized_source not in allowed_sources: + raise HTTPException(status_code=422, detail="该媒体来源不支持此音乐接口") + return normalized_source def _serialize_music(info: MusicInfo) -> schemas.MusicInfo: @@ -138,7 +161,7 @@ async def clear_music_recognition_cache( async def explore_music( page: PageParam = 1, count: CountParam = 30, - media_source: MusicExploreSourceParam = "musicbrainz", + media_source: MusicExploreSourceParam = MediaSource.MusicBrainz, mode: MusicModeParam = "chart", entity: MusicEntityParam = "recording", range_name: MusicRangeParam = "this_month", @@ -154,9 +177,10 @@ async def explore_music( _: schemas.TokenPayload = Depends(verify_token), ) -> list[schemas.MusicInfo]: """MusicBrainz 返回榜单或新发行,豆瓣音乐固定按官方标签分类浏览。""" - chain = MusicChain() - if media_source != "musicbrainz": - results = await chain.async_discover( + media_source = _validate_music_source(media_source, _MUSIC_EXPLORE_SOURCES) + chain = RecommendChain() + if media_source != MediaSource.MusicBrainz: + results = await chain.async_music_discover( media_source=media_source, page=page, count=count, @@ -166,7 +190,7 @@ async def explore_music( sort=douban_sort, ) elif mode == "fresh": - results = await chain.async_fresh_releases( + results = await chain.async_music_fresh_releases( days=days, sort=sort, past=past, @@ -176,7 +200,7 @@ async def explore_music( with_cover=with_cover, ) else: - results = await chain.async_chart( + results = await chain.async_music_chart( range_name=range_name, page=page, count=count, @@ -185,7 +209,7 @@ async def explore_music( with_cover=with_cover, entity=entity, ) - if media_source != "musicbrainz" and with_cover: + if media_source != MediaSource.MusicBrainz and with_cover: results = [info for info in results if info.cover_url or info.poster_path] return [_serialize_music(info) for info in results] @@ -197,11 +221,14 @@ async def explore_music( ) async def music_album( album_id: str, - media_source: MusicSourceParam = "musicbrainz", + media_source: MusicSourceParam = MediaSource.MusicBrainz, _: schemas.TokenPayload = Depends(verify_token), ) -> schemas.MusicAlbumInfo: """按专辑标准 ID 返回专辑详情、曲目列表和发行版本。""" - info = await MusicChain().async_album(media_source=media_source, media_id=album_id) + media_source = _validate_music_source(media_source, _MUSIC_DETAIL_SOURCES) + info = await MediaChain().async_get_music_album( + media_source=media_source, media_id=album_id + ) if not info: raise HTTPException(status_code=404, detail="未识别到专辑信息") return _serialize_album(info) @@ -215,11 +242,12 @@ async def music_album( async def music_album_related( album_id: str, count: CountParam = 24, - media_source: MusicSourceParam = "musicbrainz", + media_source: MusicSourceParam = MediaSource.MusicBrainz, _: schemas.TokenPayload = Depends(verify_token), ) -> list[schemas.MusicInfo]: """按来源和专辑 ID 返回可继续浏览的关联专辑。""" - results = await MusicChain().async_album_related( + media_source = _validate_music_source(media_source, _MUSIC_DETAIL_SOURCES) + results = await MediaChain().async_get_music_album_related( media_source=media_source, media_id=album_id, count=count, @@ -237,11 +265,12 @@ async def music_artist_albums( page: PageParam = 1, count: CountParam = 30, album_type: MusicAlbumTypeParam = None, - media_source: MusicSourceParam = "musicbrainz", + media_source: MusicSourceParam = MediaSource.MusicBrainz, _: schemas.TokenPayload = Depends(verify_token), ) -> list[schemas.MusicInfo]: """按艺术家标准 ID 分页返回其专辑、EP 和单曲。""" - results = await MusicChain().async_artist_albums( + media_source = _validate_music_source(media_source, _MUSIC_DETAIL_SOURCES) + results = await MediaChain().async_get_music_artist_albums( media_source=media_source, media_id=artist_id, page=page, @@ -259,11 +288,12 @@ async def music_artist_albums( async def music_artist_related( artist_id: str, count: CountParam = 24, - media_source: MusicSourceParam = "musicbrainz", + media_source: MusicSourceParam = MediaSource.MusicBrainz, _: schemas.TokenPayload = Depends(verify_token), ) -> list[schemas.MusicArtistInfo]: """按艺术家关系返回可继续浏览的关联艺术家。""" - results = await MusicChain().async_artist_related( + media_source = _validate_music_source(media_source, _MUSIC_DETAIL_SOURCES) + results = await MediaChain().async_get_music_artist_related( media_source=media_source, media_id=artist_id, count=count, @@ -278,11 +308,14 @@ async def music_artist_related( ) async def music_artist( artist_id: str, - media_source: MusicSourceParam = "musicbrainz", + media_source: MusicSourceParam = MediaSource.MusicBrainz, _: schemas.TokenPayload = Depends(verify_token), ) -> schemas.MusicArtistInfo: """按艺术家标准 ID 返回艺术家详情。""" - info = await MusicChain().async_artist(media_source=media_source, media_id=artist_id) + media_source = _validate_music_source(media_source, _MUSIC_DETAIL_SOURCES) + info = await MediaChain().async_get_music_artist( + media_source=media_source, media_id=artist_id + ) if not info: raise HTTPException(status_code=404, detail="未识别到艺术家信息") return _serialize_artist(info) diff --git a/app/api/endpoints/search.py b/app/api/endpoints/search.py index 498d47270..c6c27202b 100644 --- a/app/api/endpoints/search.py +++ b/app/api/endpoints/search.py @@ -13,7 +13,7 @@ from app.core.security import verify_resource_token, verify_token from app.helper.locale import LocaleHelper from app.log import logger from app.schemas.types import MediaSource, MediaType -from app.utils.media import normalize_music_type +from app.utils.media import normalize_music_type, resolve_media_identity from app.utils.security import SecurityUtils router = APIRouter() @@ -59,9 +59,12 @@ async def _resolve_media_search_params( music_type: Optional[str] = None, ) -> tuple[Optional[dict], str]: """校验统一媒体身份并构造 SearchChain 精确搜索参数。""" - normalized_media_id = str(media_id or "").strip() - if not normalized_media_id: - return None, "媒体 ID 不能为空" + normalized_source, normalized_media_id = resolve_media_identity( + media_source=media_source, + media_id=media_id, + ) + if not normalized_source or not normalized_media_id: + return None, "媒体ID格式无效" normalized_music_type = None if music_type: normalized_music_type = normalize_music_type(music_type, allow_artist=False) @@ -71,7 +74,7 @@ async def _resolve_media_search_params( return None, "music_type 仅能用于音乐资源搜索" params = { - "media_source": media_source, + "media_source": normalized_source, "media_id": normalized_media_id, } if normalized_music_type: @@ -368,7 +371,6 @@ async def search_by_id_stream( media_type = _parse_media_type(mtype) media_season = int(season) if season else None site_list = _parse_site_list(sites) - search_chain = SearchChain() async def event_source(): """解析媒体身份并输出精确搜索流事件。""" @@ -381,7 +383,7 @@ async def search_by_id_stream( if not search_params: yield {"type": "error", "success": False, "message": message} return - torrents = search_chain.async_search_by_id_stream( + torrents = SearchChain().async_search_by_id_stream( **search_params, mtype=media_type, area=area, @@ -551,7 +553,6 @@ async def _build_subtitle_search_source( media_season = int(season) if season else None media_episode = int(episode) if episode else None site_list = _parse_site_list(sites) - search_chain = SearchChain() def call_search(**kwargs): """ @@ -576,6 +577,7 @@ async def _build_subtitle_search_source( ) if not search_params: return None, message + search_chain = SearchChain() return call_search(**search_params), "" diff --git a/app/api/endpoints/subscribe.py b/app/api/endpoints/subscribe.py index d4b3375cf..e24574437 100644 --- a/app/api/endpoints/subscribe.py +++ b/app/api/endpoints/subscribe.py @@ -30,7 +30,7 @@ from app.schemas.types import ( EventType, SystemConfigKey, ) -from app.utils.media import normalize_media_source +from app.utils.media import normalize_media_source, resolve_media_identity router = APIRouter() @@ -211,6 +211,26 @@ async def create_subscribe( else: title = None subscribe_dict = subscribe_in.to_public_write_payload() + identity_fields = {"media_source", "media_id"}.intersection( + subscribe_in.model_fields_set + ) + if identity_fields: + media_source, media_id = resolve_media_identity( + media_source=subscribe_in.media_source, + media_id=subscribe_in.media_id, + ) + if media_source and media_id: + subscribe_dict["media_source"] = media_source + subscribe_dict["media_id"] = media_id + elif subscribe_in.media_source is None and subscribe_in.media_id is None: + # 完整空对表示订阅暂无可用身份,与只提交其中一个字段语义不同。 + subscribe_dict["media_source"] = None + subscribe_dict["media_id"] = None + else: + return schemas.Response( + success=False, + message="新增订阅时必须同时提供有效的 media_source 和 media_id", + ) subscribe_dict["username"] = current_user.name sid, message = await SubscribeChain().async_add( mtype=mtype, @@ -236,7 +256,27 @@ async def update_subscribe( if not subscribe: return schemas.Response(success=False, message="订阅不存在") old_subscribe_dict = subscribe.to_dict() - subscribe_dict = subscribe_in.to_public_write_payload() + subscribe_dict = subscribe_in.to_public_write_payload(exclude_unset=True) + identity_fields = {"media_source", "media_id"}.intersection( + subscribe_in.model_fields_set + ) + if identity_fields: + media_source, media_id = resolve_media_identity( + media_source=subscribe_in.media_source, + media_id=subscribe_in.media_id, + ) + if media_source and media_id: + subscribe_dict["media_source"] = media_source + subscribe_dict["media_id"] = media_id + elif subscribe_in.media_source is None and subscribe_in.media_id is None: + # 只有两个身份键都显式为空时才清空;全部省略则保留存量身份。 + subscribe_dict["media_source"] = None + subscribe_dict["media_id"] = None + else: + return schemas.Response( + success=False, + message="更新媒体身份时必须同时提供有效的 media_source 和 media_id", + ) subscribe_dict["username"] = subscribe.username if getattr(subscribe, "type", None) == MediaType.MUSIC.value: # 音乐实体与曲目总数来自识别链,编辑接口不得把专辑改成单曲而提前完成订阅。 @@ -244,13 +284,18 @@ async def update_subscribe( subscribe_dict["music_type"] = subscribe.music_type subscribe_dict["total_tracks"] = subscribe.total_tracks \ if subscribe.music_type == MUSIC_ENTITY_ALBUM else None - if subscribe_in.total_episode and subscribe_in.total_episode > (subscribe.total_episode or 0): + total_episode_updated = "total_episode" in subscribe_in.model_fields_set + if ( + total_episode_updated + and subscribe_in.total_episode + and subscribe_in.total_episode > (subscribe.total_episode or 0) + ): # 扩大目标范围时,新增加的集数尚无下载事实,应同步计入缺失集数。 subscribe_dict["lack_episode"] = (subscribe.lack_episode or 0) + ( subscribe_in.total_episode - (subscribe.total_episode or 0) ) # 是否手动修改过总集数 - if subscribe_in.total_episode != subscribe.total_episode: + if total_episode_updated and subscribe_in.total_episode != subscribe.total_episode: subscribe_dict["manual_total_episode"] = 1 # 更新到数据库 await subscribe.async_update(db, subscribe_dict) diff --git a/app/api/endpoints/torrent.py b/app/api/endpoints/torrent.py index 7dc756106..0f5c1e3ae 100644 --- a/app/api/endpoints/torrent.py +++ b/app/api/endpoints/torrent.py @@ -4,7 +4,6 @@ from fastapi import APIRouter, Depends from app import schemas from app.chain.media import MediaChain -from app.chain.music import MusicChain from app.chain.torrents import TorrentsChain from app.core.config import settings from app.core.context import MediaInfo, MusicInfo @@ -266,7 +265,7 @@ async def reidentify_cache( meta = ( target_context.meta_info if isinstance(target_context.meta_info, MetaMusic) - else MusicChain.parse_query(target_context.torrent_info.title) + else MetaMusic.parse_query(target_context.torrent_info.title) ) else: meta = MetaInfo( diff --git a/app/chain/__init__.py b/app/chain/__init__.py index e05d72c4b..fab9ac52a 100644 --- a/app/chain/__init__.py +++ b/app/chain/__init__.py @@ -47,6 +47,7 @@ from app.schemas.category import CategoryConfig from app.schemas.types import ( TorrentStatus, MediaType, + MediaSourceSelection, MediaImageType, EventType, ChainEventType, @@ -637,7 +638,7 @@ class ChainBase(metaclass=ABCMeta): return None if not episode_group and hasattr(meta, "episode_group"): episode_group = meta.episode_group - if not mtype and meta and meta.type in [ + if not mtype and not (media_source and media_id) and meta and meta.type in [ MediaType.TV, MediaType.MOVIE, MediaType.MUSIC ]: mtype = meta.type @@ -747,7 +748,7 @@ class ChainBase(metaclass=ABCMeta): return None if not episode_group and hasattr(meta, "episode_group"): episode_group = meta.episode_group - if not mtype and meta and meta.type in [ + if not mtype and not (media_source and media_id) and meta and meta.type in [ MediaType.TV, MediaType.MOVIE, MediaType.MUSIC ]: mtype = meta.type @@ -1263,7 +1264,7 @@ class ChainBase(metaclass=ABCMeta): return self.run_module("webhook_parser", body=body, form=form, args=args) def search_medias( - self, meta: MetaBase, media_source: Optional[MediaSource] = None + self, meta: MetaBase, media_source: Optional[MediaSourceSelection] = None ) -> Optional[List[MediaInfo]]: """ 搜索媒体信息 @@ -1276,7 +1277,7 @@ class ChainBase(metaclass=ABCMeta): ) async def async_search_medias( - self, meta: MetaBase, media_source: Optional[MediaSource] = None + self, meta: MetaBase, media_source: Optional[MediaSourceSelection] = None ) -> Optional[List[MediaInfo]]: """ 搜索媒体信息(异步版本) @@ -1289,7 +1290,7 @@ class ChainBase(metaclass=ABCMeta): ) def search_persons( - self, name: str, media_source: Optional[MediaSource] = None + self, name: str, media_source: Optional[MediaSourceSelection] = None ) -> Optional[List[MediaPerson]]: """ 搜索人物信息 @@ -1302,7 +1303,7 @@ class ChainBase(metaclass=ABCMeta): ) async def async_search_persons( - self, name: str, media_source: Optional[MediaSource] = None + self, name: str, media_source: Optional[MediaSourceSelection] = None ) -> Optional[List[MediaPerson]]: """ 搜索人物信息(异步版本) @@ -1315,7 +1316,7 @@ class ChainBase(metaclass=ABCMeta): ) def search_collections( - self, name: str, media_source: Optional[MediaSource] = None + self, name: str, media_source: Optional[MediaSourceSelection] = None ) -> Optional[List[MediaInfo]]: """ 搜索集合信息 @@ -1328,7 +1329,7 @@ class ChainBase(metaclass=ABCMeta): ) async def async_search_collections( - self, name: str, media_source: Optional[MediaSource] = None + self, name: str, media_source: Optional[MediaSourceSelection] = None ) -> Optional[List[MediaInfo]]: """ 搜索集合信息(异步版本) diff --git a/app/chain/acoustid.py b/app/chain/acoustid.py new file mode 100644 index 000000000..9d8a88d08 --- /dev/null +++ b/app/chain/acoustid.py @@ -0,0 +1,30 @@ +from pathlib import Path +from typing import Optional, Union + +from app.chain import ChainBase + + +class AcoustIdChain(ChainBase): + """AcoustID 音频指纹识别来源链。""" + + def identify_music_by_fingerprint( + self, + path: Union[str, Path], + ) -> Optional[str]: + """根据本地音频指纹返回 MusicBrainz Recording ID。""" + result = self.run_module( + "identify_music_by_fingerprint", + path=Path(path), + ) + return str(result).strip() if result else None + + async def async_identify_music_by_fingerprint( + self, + path: Union[str, Path], + ) -> Optional[str]: + """异步根据本地音频指纹返回 MusicBrainz Recording ID。""" + result = await self.async_run_module( + "async_identify_music_by_fingerprint", + path=Path(path), + ) + return str(result).strip() if result else None diff --git a/app/chain/douban.py b/app/chain/douban.py index 15fcada90..83e328af3 100644 --- a/app/chain/douban.py +++ b/app/chain/douban.py @@ -1,9 +1,11 @@ -from typing import Optional, List +from typing import Any, List, Optional from app import schemas from app.chain import ChainBase -from app.core.context import MediaInfo +from app.core.context import MediaInfo, MusicAlbumInfo, MusicInfo +from app.core.meta import MetaMusic from app.schemas import MediaType +from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaSource class DoubanChain(ChainBase): @@ -11,6 +13,216 @@ class DoubanChain(ChainBase): 豆瓣处理链 """ + music_source = MediaSource.DoubanMusic + + def search_music(self, meta: MetaMusic, limit: int = 20) -> list[MusicInfo]: + """按音乐元数据搜索豆瓣音乐候选。""" + result = self.run_module( + "search_music", + meta=meta, + limit=limit, + media_source=self.music_source, + ) + return self._music_infos(result, limit=limit) + + async def async_search_music( + self, + meta: MetaMusic, + limit: int = 20, + ) -> list[MusicInfo]: + """异步按音乐元数据搜索豆瓣音乐候选。""" + result = await self.async_run_module( + "search_music", + meta=meta, + limit=limit, + media_source=self.music_source, + ) + return self._music_infos(result, limit=limit) + + def recognize_music( + self, + meta: Optional[MetaMusic] = None, + media_id: Optional[str] = None, + cache: bool = True, + music_type: Optional[str] = None, + ) -> Optional[MusicInfo]: + """按豆瓣音乐身份或音乐元数据识别标准音乐信息。""" + normalized_id = self._normalize_music_id(media_id) + result = self.run_module( + "recognize_media", + meta=meta, + mtype=MediaType.MUSIC, + media_source=self.music_source, + media_id=normalized_id, + cache=cache, + music_type=music_type, + ) + return self._music_info(result, media_id=normalized_id) + + async def async_recognize_music( + self, + meta: Optional[MetaMusic] = None, + media_id: Optional[str] = None, + cache: bool = True, + music_type: Optional[str] = None, + ) -> Optional[MusicInfo]: + """异步按豆瓣音乐身份或音乐元数据识别标准音乐信息。""" + normalized_id = self._normalize_music_id(media_id) + result = await self.async_run_module( + "async_recognize_media", + meta=meta, + mtype=MediaType.MUSIC, + media_source=self.music_source, + media_id=normalized_id, + cache=cache, + music_type=music_type, + ) + return self._music_info(result, media_id=normalized_id) + + def get_music_album(self, media_id: str) -> Optional[MusicAlbumInfo]: + """按豆瓣音乐专辑 ID 获取标准化专辑详情。""" + normalized_id = self._normalize_music_id(media_id) + if not normalized_id: + return None + result = self.run_module( + "music_album", + media_source=self.music_source, + media_id=normalized_id, + ) + return self._music_album(result, media_id=normalized_id) + + async def async_get_music_album(self, media_id: str) -> Optional[MusicAlbumInfo]: + """异步按豆瓣音乐专辑 ID 获取标准化专辑详情。""" + normalized_id = self._normalize_music_id(media_id) + if not normalized_id: + return None + result = await self.async_run_module( + "music_album", + media_source=self.music_source, + media_id=normalized_id, + ) + return self._music_album(result, media_id=normalized_id) + + async def async_get_music_album_related( + self, + media_id: str, + count: int = 24, + ) -> list[MusicInfo]: + """异步按豆瓣音乐专辑 ID 获取相关推荐。""" + normalized_id = self._normalize_music_id(media_id) + if not normalized_id: + return [] + result = await self.async_run_module( + "music_album_related", + media_source=self.music_source, + media_id=normalized_id, + count=count, + ) + return self._music_infos(result, limit=count) + + def music_discover( + self, + page: int = 1, + count: int = 30, + entity: str = MUSIC_ENTITY_ALBUM, + mode: str = "chart", + tags: str = "", + sort: str = "U", + ) -> list[MusicInfo]: + """按豆瓣音乐官方榜单或标签浏览标准音乐条目。""" + result = self.run_module( + "music_discover", + media_source=self.music_source, + page=page, + count=count, + entity=entity, + mode=mode, + tags=tags, + sort=sort, + ) + return self._music_infos(result, limit=count) + + async def async_music_discover( + self, + page: int = 1, + count: int = 30, + entity: str = MUSIC_ENTITY_ALBUM, + mode: str = "chart", + tags: str = "", + sort: str = "U", + ) -> list[MusicInfo]: + """异步按豆瓣音乐官方榜单或标签浏览标准音乐条目。""" + result = await self.async_run_module( + "music_discover", + media_source=self.music_source, + page=page, + count=count, + entity=entity, + mode=mode, + tags=tags, + sort=sort, + ) + return self._music_infos(result, limit=count) + + @staticmethod + def _normalize_music_id(media_id: Optional[str]) -> Optional[str]: + """清理豆瓣音乐原生 ID,空值和历史零哨兵按无身份处理。""" + normalized = str(media_id).strip() if media_id is not None else "" + return normalized if normalized and normalized != "0" else None + + @classmethod + def _music_infos(cls, result: Any, limit: Optional[int] = None) -> list[MusicInfo]: + """将模块或插件结果转换为豆瓣音乐候选列表。""" + candidates = result if isinstance(result, list) else [] + infos = [ + item if isinstance(item, MusicInfo) else MusicInfo.from_dict(item) + for item in candidates + if isinstance(item, (MusicInfo, dict)) + ] + infos = [info for info in infos if info.media_source == cls.music_source] + return infos[:limit] if limit else infos + + @classmethod + def _music_info( + cls, + result: Any, + media_id: Optional[str] = None, + ) -> Optional[MusicInfo]: + """校验豆瓣音乐识别结果的来源与显式身份。""" + if isinstance(result, MusicInfo): + info = result + elif isinstance(result, dict): + info = MusicInfo.from_dict(result) + else: + return None + if info.media_source and info.media_source != cls.music_source: + return None + if media_id and ( + info.media_source != cls.music_source + or info.media_id != media_id + ): + return None + return info + + @classmethod + def _music_album( + cls, + result: Any, + media_id: Optional[str] = None, + ) -> Optional[MusicAlbumInfo]: + """将模块或插件结果转换为豆瓣音乐专辑详情。""" + if isinstance(result, MusicAlbumInfo): + album = result + elif isinstance(result, dict): + album = MusicAlbumInfo.from_dict(result) + else: + return None + if album.media_source != cls.music_source: + return None + if media_id and album.media_id != media_id: + return None + return album + def person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]: """ 根据人物ID查询豆瓣人物详情 diff --git a/app/chain/download.py b/app/chain/download.py index c08701f80..3fd770c55 100644 --- a/app/chain/download.py +++ b/app/chain/download.py @@ -166,19 +166,16 @@ class DownloadChain(ChainBase): @staticmethod def _media_identity_keys(media: Optional[MediaInfo]) -> Set[str]: - """返回媒体的统一身份键及全部兼容 ID,用于临时缺失集映射匹配。""" + """返回媒体的统一身份键,用于临时缺失集映射匹配。""" if not media: return set() source, media_id = resolve_media_identity(media=media) - values = { - media.tmdb_id, media.douban_id, media.bangumi_id, media.anilist_id, - build_media_key(source, media_id), - } - return {str(value) for value in values if value is not None and str(value)} + media_key = build_media_key(source, media_id) + return {media_key} if media_key else set() @classmethod def _matches_media_identity(cls, media: Optional[MediaInfo], media_key: object) -> bool: - """判断媒体是否命中统一身份键或任一兼容 ID。""" + """判断媒体是否命中来源与原生 ID 组成的统一身份键。""" return media_key is not None and str(media_key) in cls._media_identity_keys(media) @staticmethod @@ -603,12 +600,8 @@ class DownloadChain(ChainBase): media_type = getattr(getattr(media, "type", None), "value", getattr(media, "type", None)) media_source, media_id = resolve_media_identity(media=media) - media_key = ( - f"{media_source}:{media_id}" - if media_source and media_id - else getattr(media, "imdb_id", None) - or getattr(media, "tvdb_id", None) - or f"{getattr(media, 'title', '')}:{getattr(media, 'year', '')}" + media_key = build_media_key(media_source, media_id) or ( + f"{getattr(media, 'title', '')}:{getattr(media, 'year', '')}" ) meta = getattr(context, "meta_info", None) site = getattr(torrent, "site", None) or getattr(torrent, "site_name", None) @@ -1126,7 +1119,7 @@ class DownloadChain(ChainBase): def batch_download(self, contexts: List[Context], - no_exists: Dict[Union[int, str], Dict[int, NotExistMediaInfo]] = None, + no_exists: Dict[str, Dict[int, NotExistMediaInfo]] = None, save_path: Optional[str] = None, channel: MessageChannel = None, source: Optional[str] = None, @@ -1134,7 +1127,7 @@ class DownloadChain(ChainBase): username: Optional[str] = None, downloader: Optional[str] = None, custom_words: Optional[str] = None - ) -> Tuple[List[Context], Dict[Union[int, str], Dict[int, NotExistMediaInfo]]]: + ) -> Tuple[List[Context], Dict[str, Dict[int, NotExistMediaInfo]]]: """ 根据缺失数据,自动种子列表中组合择优下载 :param contexts: 资源上下文列表 @@ -1146,16 +1139,16 @@ class DownloadChain(ChainBase): :param username: 调用下载的用户名/插件名 :param downloader: 下载器 :param custom_words: 下载来源(如订阅)的完整自定义识别词文本,随下载记录存档,供整理时原样复现识别 - :return: 已经下载的资源列表、剩余未下载到的剧集 no_exists[tmdb_id/douban_id] = {season: NotExistMediaInfo} + :return: 已下载资源列表及剩余缺集,键格式为 no_exists[source:id] """ # 已下载的项目 downloaded_list: List[Context] = [] custom_word_list = custom_words.splitlines() if custom_words else None - def __update_seasons(_mid: Union[int, str], _need: list, _current: list) -> list: + def __update_seasons(_mid: str, _need: list, _current: list) -> list: """ 更新need_tvs季数,返回剩余季数 - :param _mid: TMDBID + :param _mid: 统一媒体身份键 :param _need: 需要下载的季数 :param _current: 已经下载的季数 """ @@ -1172,10 +1165,10 @@ class DownloadChain(ChainBase): break return need - def __update_episodes(_mid: Union[int, str], _sea: int, _need: list, _current: set) -> list: + def __update_episodes(_mid: str, _sea: int, _need: list, _current: set) -> list: """ 更新need_tvs集数,返回剩余集数 - :param _mid: TMDBID + :param _mid: 统一媒体身份键 :param _sea: 季数 :param _need: 需要下载的集数 :param _current: 已经下载的集数 @@ -1197,7 +1190,7 @@ class DownloadChain(ChainBase): no_exists.pop(_mid) return need - def __get_season_episodes(_mid: Union[int, str], season: int) -> int: + def __get_season_episodes(_mid: str, season: int) -> int: """ 获取需要的季的集数 """ @@ -1208,7 +1201,7 @@ class DownloadChain(ChainBase): return 9999 return no_exist[season].total_episode - def __get_no_exist_media(_mid: Union[int, str], season: int) -> Optional[NotExistMediaInfo]: + def __get_no_exist_media(_mid: str, season: int) -> Optional[NotExistMediaInfo]: """ 获取指定媒体和季的缺失信息。 """ @@ -1216,7 +1209,7 @@ class DownloadChain(ChainBase): return None return no_exists.get(_mid).get(season) - def __get_required_episodes(_mid: Union[int, str], season: int) -> Set[int]: + def __get_required_episodes(_mid: str, season: int) -> Set[int]: """ 获取整季候选必须覆盖的目标集范围。 """ @@ -1349,8 +1342,8 @@ class DownloadChain(ChainBase): # 电视剧整季匹配 if no_exists: logger.info(f"开始匹配电视剧整季:{no_exists}") - # 先把整季缺失的拿出来,看是否刚好有所有季都满足的种子 {tmdbid: [seasons]} - need_seasons: Dict[int, list] = {} + # 先把整季缺失的拿出来,看是否刚好有所有季都满足的种子 {source:id: [seasons]} + need_seasons: Dict[str, list] = {} for need_mid, need_tv in no_exists.items(): for tv in need_tv.values(): if not tv: @@ -1691,9 +1684,9 @@ class DownloadChain(ChainBase): def get_no_exists_info(self, meta: MetaBase, mediainfo: MediaInfo | MusicInfo, - no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None, + no_exists: Dict[str, Dict[int, NotExistMediaInfo]] = None, totals: Dict[int, int] = None - ) -> Tuple[bool, Dict[Union[int, str], Dict[int, NotExistMediaInfo]]]: + ) -> Tuple[bool, Dict[str, Dict[int, NotExistMediaInfo]]]: """ 检查媒体库,查询电影或音乐是否存在;对于剧集同时返回不存在的季集信息 :param meta: 元数据 @@ -1703,11 +1696,14 @@ class DownloadChain(ChainBase): :return: 当前媒体是否缺失,各标题总的季集和缺失的季集 """ media_source, media_id = resolve_media_identity(media=mediainfo) + if mediainfo.type == MediaType.TV and not build_media_key(media_source, media_id): + logger.error("电视剧缺集检查需要有效的 media_source 和 media_id") + return False, no_exists or {} def __append_no_exists(_season: int, _episodes: list, _total: int, _start: int): """ 添加不存在的季集信息 - {tmdbid: [ + {source:id: [ "season": int, "episodes": list, "total_episode": int, diff --git a/app/chain/listenbrainz.py b/app/chain/listenbrainz.py new file mode 100644 index 000000000..f559bc2d3 --- /dev/null +++ b/app/chain/listenbrainz.py @@ -0,0 +1,77 @@ +from typing import Any + +from app.chain import ChainBase +from app.core.context import MusicInfo +from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource + + +class ListenBrainzChain(ChainBase): + """ListenBrainz 音乐榜单与新发行来源链。""" + + result_source = MediaSource.MusicBrainz + + def music_chart( + self, + range_name: str, + page: int = 1, + count: int = 30, + entity: str = MUSIC_ENTITY_RECORDING, + ) -> list[MusicInfo]: + """分页读取 ListenBrainz 全站音乐榜单。""" + result = self.run_module( + "music_chart", + range_name=range_name, + offset=max(page - 1, 0) * max(1, count), + count=count, + entity=entity, + ) + return self._music_infos(result, limit=count) + + async def async_music_chart( + self, + range_name: str, + page: int = 1, + count: int = 30, + entity: str = MUSIC_ENTITY_RECORDING, + ) -> list[MusicInfo]: + """异步分页读取 ListenBrainz 全站音乐榜单。""" + result = await self.async_run_module( + "music_chart", + range_name=range_name, + offset=max(page - 1, 0) * max(1, count), + count=count, + entity=entity, + ) + return self._music_infos(result, limit=count) + + async def async_music_fresh_releases( + self, + days: int = 14, + sort: str = "release_date", + past: bool = True, + future: bool = True, + page: int = 1, + count: int = 30, + ) -> list[MusicInfo]: + """异步分页读取 ListenBrainz 官方新发行专辑。""" + result = await self.async_run_module( + "music_fresh_releases", + days=days, + sort=sort, + past=past, + future=future, + offset=max(page - 1, 0) * max(1, count), + count=count, + ) + return self._music_infos(result, limit=count) + + @classmethod + def _music_infos(cls, result: Any, limit: int) -> list[MusicInfo]: + """将榜单模块结果转换为带 MusicBrainz 身份的音乐列表。""" + candidates = result if isinstance(result, list) else [] + infos = [ + item if isinstance(item, MusicInfo) else MusicInfo.from_dict(item) + for item in candidates + if isinstance(item, (MusicInfo, dict)) + ] + return [info for info in infos if info.media_source == cls.result_source][:limit] diff --git a/app/chain/lrclib.py b/app/chain/lrclib.py new file mode 100644 index 000000000..5b973250a --- /dev/null +++ b/app/chain/lrclib.py @@ -0,0 +1,29 @@ +from typing import Optional, Union + +from app.chain import ChainBase +from app.core.context import MusicInfo, MusicLyrics +from app.core.meta import MetaMusic + + +class LrclibChain(ChainBase): + """LRCLIB 音乐歌词来源链。""" + + def get_music_lyrics( + self, + music: Union[MetaMusic, MusicInfo], + ) -> Optional[MusicLyrics]: + """按单曲元数据获取标准化歌词。""" + result = self.run_module("music_lyrics", music=music) + if isinstance(result, MusicLyrics): + return result + return MusicLyrics.from_dict(result) if isinstance(result, dict) else None + + async def async_get_music_lyrics( + self, + music: Union[MetaMusic, MusicInfo], + ) -> Optional[MusicLyrics]: + """异步按单曲元数据获取标准化歌词。""" + result = await self.async_run_module("music_lyrics", music=music) + if isinstance(result, MusicLyrics): + return result + return MusicLyrics.from_dict(result) if isinstance(result, dict) else None diff --git a/app/chain/media.py b/app/chain/media.py index 7cf537347..1c21b17bb 100644 --- a/app/chain/media.py +++ b/app/chain/media.py @@ -1,9 +1,6 @@ -import os -import re +import asyncio from copy import deepcopy -from dataclasses import dataclass from pathlib import Path -from tempfile import NamedTemporaryFile, TemporaryDirectory from threading import Lock from typing import Any, Iterable, List, Optional, Tuple, Union @@ -11,21 +8,22 @@ from fastapi.concurrency import run_in_threadpool from app import schemas from app.chain import ChainBase -from app.chain.music import MusicChain -from app.chain.storage import StorageChain -from app.core.cache import async_fresh, cached, fresh +from app.chain.acoustid import AcoustIdChain +from app.chain.douban import DoubanChain +from app.chain.musicbrainz import MusicBrainzChain +from app.chain.theaudiodb import TheAudioDbChain +from app.core.cache import async_fresh, fresh from app.core.config import settings from app.core.context import ( Context, MediaInfo, MusicAlbumInfo, + MusicArtistInfo, MusicInfo, - MusicLyrics, ) from app.core.event import eventmanager, Event from app.core.meta import MetaBase, MetaMusic from app.core.metainfo import MetaInfo, MetaInfoPath -from app.db.systemconfig_oper import SystemConfigOper from app.helper.audio import AudioMetadataHelper from app.log import logger from app.schemas import FileItem @@ -35,185 +33,310 @@ from app.schemas.types import ( ChainEventType, EventType, MediaSource, + MediaSourceSelection, MediaType, - ScrapingTarget, - ScrapingMetadata, - ScrapingPolicy, - SystemConfigKey, ) -from app.utils.http import RequestUtils from app.utils.media import ( is_music_media_source, normalize_media_source, + resolve_media_identity, ) -from app.utils.mixins import ConfigReloadMixin from app.utils.singleton import Singleton from app.utils.string import StringUtils recognize_lock = Lock() -scraping_lock = Lock() - -current_umask = os.umask(0) -os.umask(current_umask) -@dataclass -class _MusicScrapeFileResult: - """记录单个音轨的标签刮削结果和歌词处理状态。""" - - metadata_success: bool = True - lyrics_status: str = "disabled" - - -class ScrapingOption: - """刮削选项""" - - type: ScrapingTarget = ScrapingTarget.TV - metadata: ScrapingMetadata = ScrapingMetadata.NFO - policy: ScrapingPolicy = ScrapingPolicy.MISSINGONLY - - def __init__( - self, - type: Union[str, ScrapingTarget], - metadata: Union[str, ScrapingMetadata], - value: Union[ScrapingPolicy, bool, str], - ): - if isinstance(type, ScrapingTarget): - self.type = type - elif isinstance(type, str): - self.type = ScrapingTarget(type) - if isinstance(metadata, ScrapingMetadata): - self.metadata = metadata - elif isinstance(metadata, str): - self.metadata = ScrapingMetadata(metadata) - if isinstance(value, bool): - # 兼容旧的布尔值格式 - self.policy = ScrapingPolicy.MISSINGONLY if value else ScrapingPolicy.SKIP - elif isinstance(value, ScrapingPolicy): - self.policy = value - elif isinstance(value, str): - self.policy = ScrapingPolicy(value) - else: - logger.error( - f"无效的刮削选项:type={type}, metadata={metadata}, value={value}" - ) - - @property - def is_skip(self) -> bool: - """是否跳过""" - return self.policy == ScrapingPolicy.SKIP - - @property - def is_overwrite(self) -> bool: - """是否覆盖模式""" - return self.policy == ScrapingPolicy.OVERWRITE - - -class ScrapingConfig: - """媒体刮削配置""" - - def __init__(self, config_dict: dict[str, str] = None): - """ - 初始化配置对象 - :param config_dict: 用户配置字典(扁平化格式),为 None 时使用默认配置 - """ - self._policies: dict[tuple[str, str], ScrapingOption] = {} - # 合并用户配置和默认配置 - if config_dict is None: - config_dict = {} - - # 以默认配置为基础,用用户配置覆盖 - _config = self.get_default_config() - for key, value in config_dict.items(): - _config[key] = value - - for key, value in _config.items(): - if "_" in key: - items = key.split("_", 1) - self._policies[tuple(items)] = ScrapingOption(*items, value) - - def option( - self, item: Union[str, ScrapingTarget], metadata: Union[str, ScrapingMetadata] - ) -> ScrapingOption: - - if isinstance(item, ScrapingTarget): - item = item.name.lower() - if isinstance(metadata, ScrapingMetadata): - metadata = metadata.name.lower() - - return self._policies.get( - (item, metadata), ScrapingOption(item, metadata, ScrapingPolicy.SKIP) - ) - - @classmethod - def from_system_config(cls) -> "ScrapingConfig": - """ - 从系统配置加载 - - :return: MediaScrapingConfig 实例 - """ - user_config = SystemConfigOper().get(SystemConfigKey.ScrapingSwitchs) or {} - return cls(user_config) - - @staticmethod - def get_default_config() -> dict[str, str]: - """获取默认配置字典""" - config_items = [ - f"{mt}_{md}" - for mt, mds in [ - ( - "movie", - ["nfo", "poster", "backdrop", "logo", "disc", "banner", "thumb", "clearart", "landscape"], - ), - ("tv", ["nfo", "poster", "backdrop", "logo", "banner", "thumb", "clearart", "landscape"]), - ("season", ["nfo", "poster", "backdrop", "banner", "thumb", "landscape"]), - ("episode", ["nfo", "thumb"]), - ("music", ["nfo", "poster", "lyrics"]), - ] - for md in mds - ] - return {item: ScrapingPolicy.MISSINGONLY for item in config_items} - - -class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): +class MediaChain(ChainBase, metaclass=Singleton): """ 媒体信息处理链,单例运行 """ - CONFIG_WATCH = {SystemConfigKey.ScrapingSwitchs.value} - - IMAGE_METADATA_MAP = { - "poster": ScrapingMetadata.POSTER, - "backdrop": ScrapingMetadata.BACKDROP, - "fanart": ScrapingMetadata.BACKDROP, - "background": ScrapingMetadata.BACKDROP, - "logo": ScrapingMetadata.LOGO, - "disc": ScrapingMetadata.DISC, - "cdart": ScrapingMetadata.DISC, - "banner": ScrapingMetadata.BANNER, - "thumb": ScrapingMetadata.THUMB, - "landscape": ScrapingMetadata.LANDSCAPE, - "clearart": ScrapingMetadata.CLEARART, - } - - IMAGE_ALIASES = { - "backdrop": ["fanart"], - "fanart": ["backdrop"], - "thumb": ["landscape"], - "landscape": ["thumb"], - } - - MUSIC_LYRICS_EXTENSIONS = (".lrc", ".txt") - _music_track_prefix_pattern = re.compile( - r"^\s*(?:(?:cd|disc)\s*\d+\s*[-_. ]+)?(?:\d+\s*[-_. ]+)+", - flags=re.IGNORECASE, - ) _video_primary_source = MediaSource.TMDB + _music_primary_source = MediaSource.MusicBrainz + _album_dir_cache: dict[str, tuple[tuple[str, ...], dict[str, MusicInfo]]] = {} + _album_dir_cache_max = 128 + _album_match_min_files = 2 - def __init__(self): - super().__init__() - self.storagechain = StorageChain() - self.scraping_policies = ScrapingConfig.from_system_config() + @staticmethod + def _music_source_chain( + media_source: MediaSource, + ) -> Optional[MusicBrainzChain | TheAudioDbChain | DoubanChain]: + """按固定音乐来源返回对应来源链。""" + source = normalize_media_source(media_source) + chains = { + MediaSource.MusicBrainz: MusicBrainzChain, + MediaSource.TheAudioDB: TheAudioDbChain, + MediaSource.DoubanMusic: DoubanChain, + } + chain_type = chains.get(source) + return chain_type() if chain_type else None + + @classmethod + def _music_search_sources( + cls, + media_source: Optional[MediaSourceSelection], + ) -> list[MediaSource]: + """解析有序音乐搜索来源集合,忽略未知来源和重复项。""" + if not media_source: + return [cls._music_primary_source] + raw_sources = ( + (media_source,) + if isinstance(media_source, MediaSource) + else media_source + ) + sources: list[MediaSource] = [] + for raw_source in raw_sources: + if is_music_media_source(raw_source) and raw_source not in sources: + sources.append(raw_source) + return sources + + @staticmethod + async def _async_search_music_source( + chain: MusicBrainzChain | TheAudioDbChain | DoubanChain, + source: MediaSource, + meta: MetaMusic, + limit: int, + ) -> list[MusicInfo]: + """异步搜索单个音乐来源,来源失败时保留其它来源的候选。""" + try: + return await chain.async_search_music(meta, limit=limit) + except Exception as err: + logger.warning(f"音乐来源 {source} 搜索失败:{str(err)}") + return [] + + @classmethod + def normalize_music_candidates( + cls, + candidates: Optional[Iterable[MusicInfo | dict[str, Any]]], + limit: Optional[int] = None, + ) -> list[MusicInfo]: + """标准化并按来源身份或元数据去重音乐候选。""" + results: list[MusicInfo] = [] + identities: set[tuple[str, ...]] = set() + for candidate in candidates or []: + info = candidate if isinstance(candidate, MusicInfo) else MusicInfo.from_dict(candidate) + if info.media_source and info.media_id: + identity = ( + "id", str(info.media_source).casefold(), + str(info.music_type).casefold(), str(info.media_id).casefold(), + ) + else: + identity = ( + "metadata", str(info.music_type).casefold(), + MetaMusic.compact_text(info.title), + MetaMusic.compact_text(info.artist), + MetaMusic.compact_text(info.album), + ) + if identity in identities: + continue + identities.add(identity) + results.append(info) + if limit and len(results) >= limit: + break + return results + + def search_music( + self, + query: str, + limit: int = 20, + media_source: Optional[MediaSourceSelection] = None, + ) -> list[MusicInfo]: + """按一个或多个音乐来源搜索候选,未指定时使用 MusicBrainz。""" + meta = MetaMusic.parse_query(query) + candidates: list[MusicInfo] = [] + for source in self._music_search_sources(media_source): + chain = self._music_source_chain(source) + if not chain: + continue + try: + candidates.extend(chain.search_music(meta, limit=limit)) + except Exception as err: + logger.warning(f"音乐来源 {source} 搜索失败:{str(err)}") + return self.normalize_music_candidates( + candidates, + limit=limit, + ) + + async def async_search_music( + self, + query: str, + limit: int = 20, + media_source: Optional[MediaSourceSelection] = None, + ) -> list[MusicInfo]: + """并行搜索一个或多个音乐来源,单一来源失败不影响其它结果。""" + meta = MetaMusic.parse_query(query) + searches = [] + for source in self._music_search_sources(media_source): + chain = self._music_source_chain(source) + if chain: + searches.append( + self._async_search_music_source(chain, source, meta, limit) + ) + source_results = await asyncio.gather(*searches) if searches else [] + return self.normalize_music_candidates( + [candidate for results in source_results for candidate in results], + limit=limit, + ) + + @staticmethod + def _validate_music_result( + result: Optional[MusicInfo], + media_source: MediaSource, + media_id: Optional[str], + music_type: Optional[str], + ) -> Optional[MusicInfo]: + """校验来源链返回的音乐身份和实体类型。""" + if not isinstance(result, MusicInfo): + return None + if result.media_source and result.media_source != media_source: + return None + if music_type and result.music_type != music_type: + return None + if media_id and ( + result.media_source != media_source + or str(result.media_id or "") != media_id + ): + return None + return result + + def recognize_music_from_source( + self, + media_source: MediaSource, + meta: Optional[MetaMusic] = None, + media_id: Optional[str] = None, + cache: bool = True, + music_type: Optional[str] = None, + ) -> Optional[MusicInfo]: + """通过固定来源链同步识别音乐实体。""" + source = normalize_media_source(media_source) + chain = self._music_source_chain(source) + normalized_id = str(media_id).strip() if media_id is not None else None + if not chain or normalized_id == "0": + return None + result = chain.recognize_music( + meta=meta, + media_id=normalized_id, + cache=cache, + music_type=music_type, + ) + return self._validate_music_result(result, source, normalized_id, music_type) + + async def async_recognize_music_from_source( + self, + media_source: MediaSource, + meta: Optional[MetaMusic] = None, + media_id: Optional[str] = None, + cache: bool = True, + music_type: Optional[str] = None, + ) -> Optional[MusicInfo]: + """通过固定来源链异步识别音乐实体。""" + source = normalize_media_source(media_source) + chain = self._music_source_chain(source) + normalized_id = str(media_id).strip() if media_id is not None else None + if not chain or normalized_id == "0": + return None + result = await chain.async_recognize_music( + meta=meta, + media_id=normalized_id, + cache=cache, + music_type=music_type, + ) + return self._validate_music_result(result, source, normalized_id, music_type) + + def get_music_album( + self, + media_source: MediaSource, + media_id: str, + ) -> Optional[MusicAlbumInfo]: + """按音乐来源和原生 ID 同步获取专辑详情。""" + source, normalized_id = resolve_media_identity( + media_source=media_source, media_id=media_id + ) + chain = self._music_source_chain(source) + return chain.get_music_album(normalized_id) if chain and normalized_id else None + + async def async_get_music_album( + self, + media_source: MediaSource, + media_id: str, + ) -> Optional[MusicAlbumInfo]: + """按音乐来源和原生 ID 异步获取专辑详情。""" + source, normalized_id = resolve_media_identity( + media_source=media_source, media_id=media_id + ) + chain = self._music_source_chain(source) + return await chain.async_get_music_album(normalized_id) \ + if chain and normalized_id else None + + async def async_get_music_album_related( + self, + media_source: MediaSource, + media_id: str, + count: int = 24, + ) -> list[MusicInfo]: + """按音乐来源读取指定专辑的关联条目。""" + source, normalized_id = resolve_media_identity( + media_source=media_source, media_id=media_id + ) + chain = self._music_source_chain(source) + if not chain or not normalized_id: + return [] + return self.normalize_music_candidates( + await chain.async_get_music_album_related(normalized_id, count=count), + limit=count, + ) + + async def async_get_music_artist( + self, + media_source: MediaSource, + media_id: str, + ) -> Optional[MusicArtistInfo]: + """按音乐来源读取艺术家详情。""" + source, normalized_id = resolve_media_identity( + media_source=media_source, media_id=media_id + ) + chain = self._music_source_chain(source) + if not chain or not normalized_id or not hasattr(chain, "async_get_music_artist"): + return None + return await chain.async_get_music_artist(normalized_id) + + async def async_get_music_artist_albums( + self, + media_source: MediaSource, + media_id: str, + page: int = 1, + count: int = 30, + album_type: Optional[str] = None, + ) -> list[MusicInfo]: + """按音乐来源分页读取艺术家的专辑目录。""" + source, normalized_id = resolve_media_identity( + media_source=media_source, media_id=media_id + ) + chain = self._music_source_chain(source) + if not chain or not normalized_id or not hasattr(chain, "async_get_music_artist_albums"): + return [] + return self.normalize_music_candidates( + await chain.async_get_music_artist_albums( + normalized_id, page=page, count=count, album_type=album_type + ), + limit=count, + ) + + async def async_get_music_artist_related( + self, + media_source: MediaSource, + media_id: str, + count: int = 24, + ) -> list[MusicArtistInfo]: + """按音乐来源读取关联艺术家。""" + source, normalized_id = resolve_media_identity( + media_source=media_source, media_id=media_id + ) + chain = self._music_source_chain(source) + if not chain or not normalized_id or not hasattr(chain, "async_get_music_artist_related"): + return [] + return await chain.async_get_music_artist_related(normalized_id, count=count) def _run_native_media_recognize( self, @@ -229,7 +352,6 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): or mtype == MediaType.MUSIC or is_music_media_source(media_source) ): - music_chain = MusicChain() if media_source: recognize_kwargs = { "media_source": media_source, @@ -240,9 +362,14 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): if "music_type" in module_kwargs: recognize_kwargs["music_type"] = module_kwargs["music_type"] with fresh(not cache): - return music_chain.recognize_from_source(**recognize_kwargs) + return self.recognize_music_from_source(**recognize_kwargs) if isinstance(meta, MetaMusic): - return music_chain.recognize_best(meta=meta, cache=cache) + return self.recognize_music_from_source( + media_source=self._music_primary_source, + meta=meta, + cache=cache, + music_type=MUSIC_ENTITY_RECORDING, + ) return None if not media_source and isinstance(meta, MetaBase): module_kwargs = { @@ -265,7 +392,6 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): or mtype == MediaType.MUSIC or is_music_media_source(media_source) ): - music_chain = MusicChain() if media_source: recognize_kwargs = { "media_source": media_source, @@ -276,11 +402,16 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): if "music_type" in module_kwargs: recognize_kwargs["music_type"] = module_kwargs["music_type"] async with async_fresh(not cache): - return await music_chain.async_recognize_from_source( + return await self.async_recognize_music_from_source( **recognize_kwargs ) if isinstance(meta, MetaMusic): - return await music_chain.async_recognize_best(meta=meta, cache=cache) + return await self.async_recognize_music_from_source( + media_source=self._music_primary_source, + meta=meta, + cache=cache, + music_type=MUSIC_ENTITY_RECORDING, + ) return None if not media_source and isinstance(meta, MetaBase): module_kwargs = { @@ -289,381 +420,15 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): } return await super()._async_run_native_media_recognize(module_kwargs, cache) - def on_config_changed(self): - self.scraping_policies = ScrapingConfig.from_system_config() - @staticmethod - def _cleanup_temp_file(path: Optional[Path]): - """ - 清理临时刮削文件 - :param path: 临时文件路径 - """ - if not path or not path.exists(): - return - try: - path.unlink() - except OSError as err: - logger.warn(f"临时文件清理失败:{path} - {err}") - @staticmethod - def _should_scrape( - scraping_option: ScrapingOption, - file_exists: bool, - global_overwrite: bool = False, - ) -> bool: - """ - 判断是否应该执行刮削操作 - :param scraping_option: 刮削选项对象 - :param file_exists: 文件是否已存在 - :param global_overwrite: 全局覆盖标志 - :return bool: 是否应该刮削 - """ - if scraping_option.is_skip: - logger.info( - f"{scraping_option.type.value} {scraping_option.metadata.value} 刮削策略 {scraping_option.policy.value}" - ) - return False - if not file_exists: - # 文件不存在 - return True - # 文件存在的情况 - if scraping_option.is_overwrite or global_overwrite: - logger.info( - f"{scraping_option.type.value} {scraping_option.metadata.value} 文件存在," - f"{'配置为覆盖' if scraping_option.is_overwrite else '配置为全局覆盖'}" - ) - return True - else: - logger.info( - f"{scraping_option.type.value} {scraping_option.metadata.value} 文件已存在,跳过" - ) - return False - def _save_file( - self, fileitem: schemas.FileItem, path: Path, content: Union[bytes, str] - ): - """ - 保存或上传文件 - :param fileitem: 关联的媒体文件项 - :param path: 元数据文件路径 - :param content: 文件内容 - """ - if not fileitem or not content or not path: - return - tmp_file_path = None - try: - # delete_on_close 是 Python 3.12 才支持的参数,使用 delete=False 后手动清理以兼容低版本。 - with NamedTemporaryFile(delete=False, suffix=path.suffix) as tmp_file: - tmp_file_path = Path(tmp_file.name) - # 写入内容 - if isinstance(content, bytes): - tmp_file.write(content) - else: - tmp_file.write(content.encode("utf-8")) - tmp_file.flush() - # 刮削文件只需要读写权限 - tmp_file_path.chmod(0o666 & ~current_umask) - - # 上传文件 - item = self.storagechain.upload_file( - fileitem=fileitem, path=tmp_file_path, new_name=path.name - ) - if item: - logger.info(f"已保存文件:{item.path}") - else: - logger.warn(f"文件保存失败:{path}") - finally: - self._cleanup_temp_file(tmp_file_path) - - def _download_and_save_image( - self, fileitem: schemas.FileItem, path: Path, url: str - ): - """ - 流式下载图片并保存到文件 - - :param fileitem: 关联的媒体文件项 - :param path: 图片文件路径 - :param url: 图片下载URL - """ - if not fileitem or not url or not path: - return - try: - logger.info(f"正在下载图片:{url} ...") - request_utils = RequestUtils( - proxies=settings.PROXY, ua=settings.NORMAL_USER_AGENT - ) - with request_utils.get_stream(url=url) as r: - if r and r.status_code == 200: - tmp_file_path = None - try: - # delete_on_close 是 Python 3.12 才支持的参数,使用 delete=False 后手动清理以兼容低版本。 - with NamedTemporaryFile(delete=False, suffix=path.suffix) as tmp_file: - tmp_file_path = Path(tmp_file.name) - # 流式写入文件 - for chunk in r.iter_content(chunk_size=8192): - if chunk: - tmp_file.write(chunk) - tmp_file.flush() - - # 刮削的图片只需要读写权限 - tmp_file_path.chmod(0o666 & ~current_umask) - - # 上传文件 - item = self.storagechain.upload_file( - fileitem=fileitem, path=tmp_file_path, new_name=path.name - ) - if item: - logger.info(f"已保存图片:{item.path}") - else: - logger.warn(f"图片保存失败:{path}") - finally: - self._cleanup_temp_file(tmp_file_path) - else: - logger.info(f"{url} 图片下载失败") - except Exception as err: - logger.error(f"{url} 图片下载失败:{str(err)}!") - - def _get_target_fileitem_and_path( - self, - current_fileitem: schemas.FileItem, - item_type: ScrapingTarget, - metadata_type: ScrapingMetadata, - filename_hint: Optional[str] = None, - parent_fileitem: Optional[schemas.FileItem] = None, - ) -> Tuple[schemas.FileItem, Optional[Path]]: - """ - 根据当前上下文、刮削项类型和元数据类型生成目标 FileItem 和 Path - 处理 NFO 和图片文件的命名约定及存储位置 - """ - # 默认保存的目录是当前文件项的目录 - target_dir_item = current_fileitem - target_dir_path = Path(current_fileitem.path) - final_filename = filename_hint # 如果提供了 filename_hint,优先使用 - - # 针对 NFO 文件的特殊命名和存储逻辑 - if metadata_type == ScrapingMetadata.NFO: - if item_type == ScrapingTarget.MOVIE: - if current_fileitem.type == "file": - # 电影文件NFO: 放在电影文件同级目录,名称与电影文件主体一致,后缀.nfo - final_filename = f"{target_dir_path.stem}.nfo" - target_dir_item = ( - parent_fileitem - or self.storagechain.get_parent_item(current_fileitem) - ) - if not target_dir_item: - logger.error( - f"无法获取文件 {current_fileitem.path} 的父目录项。" - ) - return ( - current_fileitem, - None, - ) # 返回一个表示失败的FileItem和None - target_dir_path = Path(target_dir_item.path) - else: # current_fileitem.type == "dir" - # 电影目录NFO (例如蓝光原盘): 放在电影目录内,名称与目录名主体一致,后缀.nfo - final_filename = f"{target_dir_path.name}.nfo" - # target_dir_item 保持为 current_fileitem - # target_dir_path 保持为 Path(current_fileitem.path) - elif item_type == ScrapingTarget.TV: - # 电视剧根目录NFO: 放在剧集根目录内,命名为 tvshow.nfo - final_filename = "tvshow.nfo" - elif item_type == ScrapingTarget.SEASON: - # 电视剧季目录NFO: 放在季目录内,命名为 season.nfo - final_filename = "season.nfo" - elif item_type == ScrapingTarget.EPISODE: - # 电视剧集文件NFO: 放在集文件同级目录,名称与集文件主体一致,后缀.nfo - final_filename = f"{target_dir_path.stem}.nfo" - target_dir_item = parent_fileitem or self.storagechain.get_parent_item( - current_fileitem - ) - if not target_dir_item: - logger.error(f"无法获取文件 {current_fileitem.path} 的父目录项。") - return current_fileitem, None # 返回一个表示失败的FileItem和None - target_dir_path = Path(target_dir_item.path) - # 图片通常是放在当前目录 (current_fileitem) 下 - # Jellyfin/Kodi 等在季目录内使用通用图片名,而不是 season01-poster.jpg - elif item_type == ScrapingTarget.SEASON: - season_image_name_map = { - ScrapingMetadata.POSTER: "poster", - ScrapingMetadata.BANNER: "banner", - ScrapingMetadata.THUMB: "thumb", - ScrapingMetadata.BACKDROP: "backdrop", - ScrapingMetadata.LANDSCAPE: "landscape", - } - if season_image_name := season_image_name_map.get(metadata_type): - hint_ext = Path(filename_hint).suffix if filename_hint else ".jpg" - final_filename = f"{season_image_name}{hint_ext}" - elif item_type == ScrapingTarget.MOVIE and current_fileitem.type == "file": - # 电影文件的图片应与视频文件同级保存,避免把图片路径拼到文件名下面。 - target_dir_item = parent_fileitem or self.storagechain.get_parent_item( - current_fileitem - ) - if not target_dir_item: - logger.error(f"无法获取文件 {current_fileitem.path} 的父目录项。") - return current_fileitem, None - target_dir_path = Path(target_dir_item.path) - # 如果是 EPISODE 类型的图片(如thumb),通常也是放在文件同级目录,文件名与视频文件一致 - elif ( - metadata_type in [ScrapingMetadata.THUMB] - and item_type == ScrapingTarget.EPISODE - ): - hint_ext = Path(filename_hint).suffix if filename_hint else ".jpg" - final_filename = f"{target_dir_path.stem}{hint_ext}" - target_dir_item = parent_fileitem or self.storagechain.get_parent_item( - current_fileitem - ) - if not target_dir_item: - logger.error(f"无法获取文件 {current_fileitem.path} 的父目录项。") - return current_fileitem, None # 返回一个表示失败的FileItem和None - target_dir_path = Path(target_dir_item.path) - # TODO: 考虑其他图片类型是否也需要保存到父目录 - - # 确保最终有文件名 - if not final_filename: - logger.error( - f"无法为 {item_type.value} - {metadata_type.value} 确定文件名。filename_hint: {filename_hint}" - ) - # 返回一个表示失败的FileItem和None - return current_fileitem, None - - target_full_path = target_dir_path / final_filename - return target_dir_item, target_full_path - - def _get_target_fileitems_and_paths( - self, - current_fileitem: schemas.FileItem, - item_type: ScrapingTarget, - metadata_type: ScrapingMetadata, - filename_hint: Optional[str] = None, - parent_fileitem: Optional[schemas.FileItem] = None, - ) -> List[Tuple[schemas.FileItem, Path]]: - """ - 根据刮削上下文生成一个或多个保存目标。 - 季图片需要同时兼容根目录 seasonxx-poster 和季目录 poster 两种命名。 - """ - target_item, target_path = self._get_target_fileitem_and_path( - current_fileitem=current_fileitem, - item_type=item_type, - metadata_type=metadata_type, - filename_hint=filename_hint, - parent_fileitem=parent_fileitem, - ) - targets = [(target_item, target_path)] if target_path else [] - - if ( - item_type != ScrapingTarget.SEASON - or not filename_hint - or not filename_hint.lower().startswith("season") - or metadata_type not in { - ScrapingMetadata.POSTER, - ScrapingMetadata.BANNER, - ScrapingMetadata.THUMB, - ScrapingMetadata.BACKDROP, - ScrapingMetadata.LANDSCAPE, - } - ): - return targets - - season_parent_item = parent_fileitem or self.storagechain.get_parent_item( - current_fileitem - ) - if not season_parent_item: - logger.warn(f"无法获取季目录 {current_fileitem.path} 的父目录项,跳过根目录季图片") - return targets - - season_root_path = Path(current_fileitem.path).with_name(filename_hint) - root_target = (season_parent_item, season_root_path) - if root_target not in targets: - targets.insert(0, root_target) - return targets - - def _expand_with_aliases( - self, - targets: List[Tuple[schemas.FileItem, Path]], - item_type: ScrapingTarget, - ) -> List[Tuple[schemas.FileItem, Path]]: - """ - 为兼容多媒体服务器,扩展图片保存目标列表,添加别名文件。 - 例如 backdrop.jpg 同时保存为 fanart.jpg,thumb.jpg 同时保存为 landscape.jpg。 - """ - expanded = list(targets) - for base_item, image_path in list(targets): - if not image_path: - continue - stem = image_path.stem.lower() - ext = image_path.suffix - # 跳过 season 前缀文件(如 season01-poster.jpg) - if stem.startswith("season"): - continue - aliases = self.IMAGE_ALIASES.get(stem) - if not aliases: - continue - for alias in aliases: - alias_meta_type = self.IMAGE_METADATA_MAP.get(alias) - if alias_meta_type: - alias_option = self.scraping_policies.option(item_type, alias_meta_type) - if alias_option.is_skip: - continue - alias_path = image_path.with_name(f"{alias}{ext}") - alias_target = (base_item, alias_path) - if alias_target not in expanded: - expanded.append(alias_target) - return expanded - - def metadata_nfo( - self, - meta: MetaBase, - mediainfo: MediaInfo, - season: Optional[int] = None, - episode: Optional[int] = None, - ) -> Optional[str]: - """ - 获取NFO文件内容文本 - - :param meta: 元数据 - :param mediainfo: 媒体信息 - :param season: 季号 - :param episode: 集号 - """ - return self.run_module( - "metadata_nfo", - meta=meta, - mediainfo=mediainfo, - season=season, - episode=episode, - ) - - def metadata_img( - self, - mediainfo: MediaInfo, - season: Optional[int] = None, - episode: Optional[int] = None, - ) -> Optional[dict]: - """ - 获取图片名称和url,合并所有模块的结果。 - 优先使用高优先级模块的图片,低优先级模块补充缺失的图片类型。 - """ - merged = {} - for module in sorted( - self.modulemanager.get_running_modules("metadata_img"), - key=lambda x: x.get_priority(), - ): - try: - result = module.metadata_img( - mediainfo=mediainfo, season=season, episode=episode - ) - if result and isinstance(result, dict): - for name, url in result.items(): - merged.setdefault(name, url) - except Exception as err: - logger.error(f"获取 {module.get_name()} 图片失败:{str(err)}") - return merged or None @staticmethod def select_recognize_source( @@ -798,7 +563,11 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): :param tmdb_media: TMDB 辅助识别结果 :return: 已补充 TMDB 兼容字段的主媒体信息 """ - if not tmdb_media or tmdb_media.media_source != "themoviedb" or not tmdb_media.tmdb_id: + if ( + not tmdb_media + or tmdb_media.media_source != MediaSource.TMDB + or not tmdb_media.tmdb_id + ): return mediainfo mediainfo.tmdb_id = tmdb_media.tmdb_id @@ -1133,8 +902,11 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): @classmethod def read_path_meta(cls, path: Union[str, Path]) -> MetaMusic: - """委托音乐领域链读取路径元数据,保持跨域入口与目录匹配语义一致。""" - return MusicChain.read_path_meta(path) + """读取本地音频标签,不可访问时回退到文件名和目录线索。""" + file_path = Path(path) + if file_path.exists() and file_path.is_file(): + return AudioMetadataHelper.read(file_path) + return AudioMetadataHelper.read_filename(file_path) @classmethod def _music_info_from_path_meta(cls, meta: MetaMusic) -> MusicInfo: @@ -1163,33 +935,33 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): """判断音乐识别结果是否携带可复用的远程身份。""" return bool(info and info.media_source and info.media_id) - @staticmethod def _recognize_musicbrainz_recording( + self, meta: MetaMusic, recording_id: str, ) -> Optional[MusicInfo]: """按已知 MusicBrainz Recording ID 直接读取单曲详情。""" identity_meta = MetaMusic.from_dict(meta.to_dict()) - identity_meta.media_source = "musicbrainz" + identity_meta.media_source = MediaSource.MusicBrainz identity_meta.media_id = recording_id - return MusicChain().recognize_from_source( - media_source="musicbrainz", + return self.recognize_music_from_source( + media_source=MediaSource.MusicBrainz, meta=identity_meta, media_id=recording_id, music_type=MUSIC_ENTITY_RECORDING, ) - @staticmethod async def _async_recognize_musicbrainz_recording( + self, meta: MetaMusic, recording_id: str, ) -> Optional[MusicInfo]: """异步按已知 MusicBrainz Recording ID 直接读取单曲详情。""" identity_meta = MetaMusic.from_dict(meta.to_dict()) - identity_meta.media_source = "musicbrainz" + identity_meta.media_source = MediaSource.MusicBrainz identity_meta.media_id = recording_id - return await MusicChain().async_recognize_from_source( - media_source="musicbrainz", + return await self.async_recognize_music_from_source( + media_source=MediaSource.MusicBrainz, meta=identity_meta, media_id=recording_id, music_type=MUSIC_ENTITY_RECORDING, @@ -1206,8 +978,8 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): return None normalized_source = normalize_media_source(media_source) search_meta = meta - if meta.media_source == "musicbrainz" and meta.media_id: - if normalized_source in (None, "musicbrainz"): + if meta.media_source == MediaSource.MusicBrainz and meta.media_id: + if normalized_source in (None, MediaSource.MusicBrainz): direct = self._recognize_musicbrainz_recording( meta=meta, recording_id=str(meta.media_id), @@ -1239,8 +1011,8 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): return None normalized_source = normalize_media_source(media_source) search_meta = meta - if meta.media_source == "musicbrainz" and meta.media_id: - if normalized_source in (None, "musicbrainz"): + if meta.media_source == MediaSource.MusicBrainz and meta.media_id: + if normalized_source in (None, MediaSource.MusicBrainz): direct = await self._async_recognize_musicbrainz_recording( meta=meta, recording_id=str(meta.media_id), @@ -1261,21 +1033,23 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): return result return None - @staticmethod - def _music_album_dir_fallback(path: Union[str, Path]) -> Optional[MusicInfo]: + def _music_album_dir_fallback( + self, + path: Union[str, Path], + ) -> Optional[MusicInfo]: """单曲识别无远端身份时,查找所在目录专辑匹配中属于当前文件的结果。""" file_path = Path(path) if not file_path.exists() or not file_path.is_file(): return None try: - matched = MusicChain().recognize_album_directory(file_path.parent) + matched = self.recognize_music_album_directory(file_path.parent) except Exception as err: logger.debug(f"专辑目录匹配失败:{file_path.parent} - {err}") return None return matched.get(str(file_path.resolve())) - @staticmethod async def _async_music_album_dir_fallback( + self, path: Union[str, Path], ) -> Optional[MusicInfo]: """异步查找所在目录专辑匹配中属于当前文件的结果。""" @@ -1283,7 +1057,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): if not file_path.exists() or not file_path.is_file(): return None try: - matched = await MusicChain().async_recognize_album_directory( + matched = await self.async_recognize_music_album_directory( file_path.parent ) except Exception as err: @@ -1291,17 +1065,166 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): return None return matched.get(str(file_path.resolve())) + @classmethod + def _directory_audio_files(cls, directory: Path) -> list[Path]: + """收集专辑目录及其一级碟片子目录中的音频文件。""" + files: list[Path] = [] + + def collect(current: Path) -> None: + """收集单层目录中的可见音频文件。""" + try: + entries = sorted(current.iterdir()) + except OSError: + return + files.extend( + item for item in entries + if not item.name.startswith(".") + and item.is_file() + and item.suffix.lower() in settings.RMT_AUDIOEXT + ) + + collect(directory) + try: + subdirectories = sorted( + item for item in directory.iterdir() + if item.is_dir() and not item.name.startswith(".") + ) + except OSError: + subdirectories = [] + for subdirectory in subdirectories: + collect(subdirectory) + return files + + @staticmethod + def _album_directory_signature( + directory: Path, + files: list[Path], + ) -> tuple[str, ...]: + """按相对音频路径生成目录匹配缓存签名。""" + return tuple(str(path.relative_to(directory)).casefold() for path in files) + + @staticmethod + def _align_music_album_tracks( + files: list[Path], + metas: list[MetaMusic], + tracks: list[MusicInfo], + ) -> dict[Path, MusicInfo]: + """优先按碟号和曲序把专辑曲目对位到本地文件。""" + matched: dict[Path, MusicInfo] = {} + used: set[tuple[int, int]] = set() + by_position = { + (track.disc_number or 1, track.track_number): track + for track in tracks if track.track_number + } + pending: list[tuple[Path, MetaMusic]] = [] + for file, meta in zip(files, metas): + key = (meta.disc_number or 1, meta.track_number or 0) + track = by_position.get(key) if meta.track_number else None + if track and key not in used: + matched[file] = track + used.add(key) + else: + pending.append((file, meta)) + remaining = [ + track for track in tracks + if (track.disc_number or 1, track.track_number or 0) not in used + ] + pending.sort(key=lambda item: (item[1].disc_number or 1, item[0].name.casefold())) + for (file, _), track in zip(pending, remaining): + matched[file] = track + return matched + + def _match_music_album_directory( + self, + directory: Path, + files: list[Path], + ) -> dict[str, MusicInfo]: + """同步汇总本地专辑证据并委托 MusicBrainz 来源链匹配。""" + metas = AudioMetadataHelper.read_many(files) + album_meta = MetaMusic.from_album_context(directory.name, metas) + album = MusicBrainzChain().match_music_album(album_meta, metas) + if not album or not album.tracks: + return {} + return { + str(file.resolve()): info + for file, info in self._align_music_album_tracks( + files, metas, album.tracks + ).items() + } + + async def _async_match_music_album_directory( + self, + directory: Path, + files: list[Path], + ) -> dict[str, MusicInfo]: + """异步汇总本地专辑证据并委托 MusicBrainz 来源链匹配。""" + metas = await run_in_threadpool(AudioMetadataHelper.read_many, files) + album_meta = MetaMusic.from_album_context(directory.name, metas) + album = await MusicBrainzChain().async_match_music_album(album_meta, metas) + if not album or not album.tracks: + return {} + return { + str(file.resolve()): info + for file, info in self._align_music_album_tracks( + files, metas, album.tracks + ).items() + } + + def recognize_music_album_directory( + self, + path: Union[str, Path], + ) -> dict[str, MusicInfo]: + """按目录级线索批量识别整张专辑并返回文件到曲目的映射。""" + directory = Path(path) + if not directory.is_dir(): + return {} + files = self._directory_audio_files(directory) + if len(files) < self._album_match_min_files: + return {} + key = str(directory) + signature = self._album_directory_signature(directory, files) + cached = self._album_dir_cache.get(key) + if cached and cached[0] == signature: + return cached[1] + matched = self._match_music_album_directory(directory, files) + if len(self._album_dir_cache) >= self._album_dir_cache_max: + self._album_dir_cache.clear() + self._album_dir_cache[key] = signature, matched + return matched + + async def async_recognize_music_album_directory( + self, + path: Union[str, Path], + ) -> dict[str, MusicInfo]: + """异步按目录级线索批量识别整张专辑。""" + directory = Path(path) + if not directory.is_dir(): + return {} + files = await run_in_threadpool(self._directory_audio_files, directory) + if len(files) < self._album_match_min_files: + return {} + key = str(directory) + signature = self._album_directory_signature(directory, files) + cached = self._album_dir_cache.get(key) + if cached and cached[0] == signature: + return cached[1] + matched = await self._async_match_music_album_directory(directory, files) + if len(self._album_dir_cache) >= self._album_dir_cache_max: + self._album_dir_cache.clear() + self._album_dir_cache[key] = signature, matched + return matched + def recognize_music_by_path( self, path: Union[str, Path], media_source: Optional[MediaSource] = None, ) -> Tuple[MetaMusic, MusicInfo]: """按指纹、文件标签、文件名三级顺序识别本地音乐。""" - meta, tag_meta, filename_meta = MusicChain.read_path_evidence(path) + meta, tag_meta, filename_meta = AudioMetadataHelper.read_evidence(Path(path)) info = None normalized_source = normalize_media_source(media_source) - if normalized_source in (None, "musicbrainz"): - recording_id = MusicChain().identify_by_fingerprint(path) + if normalized_source in (None, MediaSource.MusicBrainz): + recording_id = AcoustIdChain().identify_music_by_fingerprint(path) if recording_id: info = self._recognize_musicbrainz_recording(meta, recording_id) if self._is_remote_music_info(info): @@ -1321,7 +1244,9 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): result = self._merge_music_audio_quality( info or self._music_info_from_path_meta(meta), meta ) - if not result.media_source and media_source in (None, "musicbrainz"): + if not result.media_source and media_source in ( + None, MediaSource.MusicBrainz + ): # 单曲搜索未命中时,按所在目录做专辑级匹配兑底 matched = self._music_album_dir_fallback(path) if matched: @@ -1335,13 +1260,13 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): ) -> Tuple[MetaMusic, MusicInfo]: """异步按指纹、文件标签、文件名三级顺序识别本地音乐。""" meta, tag_meta, filename_meta = await run_in_threadpool( - MusicChain.read_path_evidence, - path, + AudioMetadataHelper.read_evidence, + Path(path), ) info = None normalized_source = normalize_media_source(media_source) - if normalized_source in (None, "musicbrainz"): - recording_id = await MusicChain().async_identify_by_fingerprint(path) + if normalized_source in (None, MediaSource.MusicBrainz): + recording_id = await AcoustIdChain().async_identify_music_by_fingerprint(path) if recording_id: info = await self._async_recognize_musicbrainz_recording( meta, @@ -1364,7 +1289,9 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): result = self._merge_music_audio_quality( info or self._music_info_from_path_meta(meta), meta ) - if not result.media_source and media_source in (None, "musicbrainz"): + if not result.media_source and media_source in ( + None, MediaSource.MusicBrainz + ): # 单曲搜索未命中时,按所在目录做专辑级匹配兑底 matched = await self._async_music_album_dir_fallback(path) if matched: @@ -1414,7 +1341,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): return Context(meta_info=file_meta, media_info=mediainfo) def search( - self, title: str, media_source: Optional[MediaSource] = None + self, title: str, media_source: Optional[MediaSourceSelection] = None ) -> Tuple[Optional[MetaBase], List[MediaInfo]]: """ 搜索媒体/人物信息 @@ -1450,1406 +1377,111 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): # 识别的元数据,媒体信息列表 return meta, medias - def get_tmdbinfo_by_doubanid( - self, doubanid: str, mtype: MediaType = None + def convert_media_identity( + self, + target_source: MediaSource, + media_source: MediaSource, + media_id: str, + mtype: Optional[MediaType] = None, + season: Optional[int] = None, ) -> Optional[dict]: """ - 根据豆瓣ID获取TMDB信息 + 使用统一媒体身份在影视来源之间转换,返回目标来源的原始详情。 + + :param target_source: 目标媒体来源 + :param media_source: 当前媒体来源 + :param media_id: 当前来源原生 ID + :param mtype: 可选媒体类型 + :param season: 可选季号,用于电视剧年份匹配 + :return: 目标来源详情;身份无效或转换组合不受支持时返回 None """ - tmdbinfo = None - doubaninfo = self.douban_info(doubanid=doubanid, mtype=mtype) - if doubaninfo: - # 优先使用原标题匹配 - if doubaninfo.get("original_title"): - meta = MetaInfo(title=doubaninfo.get("title")) - meta_org = MetaInfo(title=doubaninfo.get("original_title")) + target_source = normalize_media_source(target_source) + media_source, media_id = resolve_media_identity( + media_source=media_source, + media_id=media_id, + ) + if not target_source or not media_source or not media_id: + return None + + if target_source == MediaSource.TMDB and media_source == MediaSource.Douban: + source_info = self.douban_info(doubanid=media_id, mtype=mtype) + if not source_info: + return None + if source_info.get("original_title"): + meta = MetaInfo(title=source_info.get("title")) + original_meta = MetaInfo(title=source_info.get("original_title")) else: - meta_org = meta = MetaInfo(title=doubaninfo.get("title")) - # 年份 - if doubaninfo.get("year"): - meta.year = doubaninfo.get("year") - # 处理类型 - if isinstance(doubaninfo.get("media_type"), MediaType): - meta.type = doubaninfo.get("media_type") - else: - meta.type = ( - MediaType.MOVIE - if doubaninfo.get("type") == "movie" - else MediaType.TV - ) - # 匹配TMDB信息 - meta_names = list( - dict.fromkeys( - [k for k in [meta_org.name, meta.cn_name, meta.en_name] if k] - ) + original_meta = meta = MetaInfo(title=source_info.get("title")) + if source_info.get("year"): + meta.year = source_info.get("year") + meta.type = ( + source_info.get("media_type") + if isinstance(source_info.get("media_type"), MediaType) + else MediaType.MOVIE if source_info.get("type") == "movie" else MediaType.TV ) - tmdbinfo = self._match_tmdb_with_names( - meta_names=meta_names, + target_info = self._match_tmdb_with_names( + meta_names=list(dict.fromkeys( + name for name in (original_meta.name, meta.cn_name, meta.en_name) if name + )), year=meta.year, mtype=mtype or meta.type, - season=meta.begin_season, + season=season if season is not None else meta.begin_season, ) - if tmdbinfo: - # 合季季后返回 - tmdbinfo["season"] = meta.begin_season - return tmdbinfo + if target_info: + target_info["season"] = season if season is not None else meta.begin_season + return target_info - def get_tmdbinfo_by_bangumiid(self, bangumiid: int) -> Optional[dict]: - """ - 根据BangumiID获取TMDB信息 - """ - bangumiinfo = self.bangumi_info(bangumiid=bangumiid) - if bangumiinfo: - # 优先使用原标题匹配 - if bangumiinfo.get("name_cn"): - meta = MetaInfo(title=bangumiinfo.get("name")) - meta_cn = MetaInfo(title=bangumiinfo.get("name_cn")) + if target_source == MediaSource.TMDB and media_source == MediaSource.Bangumi: + if not media_id.isdigit(): + return None + source_info = self.bangumi_info(bangumiid=int(media_id)) + if not source_info: + return None + if source_info.get("name_cn"): + meta = MetaInfo(title=source_info.get("name")) + localized_meta = MetaInfo(title=source_info.get("name_cn")) else: - meta_cn = meta = MetaInfo(title=bangumiinfo.get("name")) - # 年份 - year = self._extract_year_from_bangumi(bangumiinfo) - # 识别TMDB媒体信息 - meta_names = list( - dict.fromkeys([k for k in [meta_cn.name, meta.name] if k]) + localized_meta = meta = MetaInfo(title=source_info.get("name")) + return self._match_tmdb_with_names( + meta_names=list(dict.fromkeys( + name for name in (localized_meta.name, meta.name) if name + )), + year=self._extract_year_from_bangumi(source_info), + mtype=mtype or MediaInfo.get_bangumi_media_type(source_info), + season=season if season is not None else meta.begin_season, ) - tmdbinfo = self._match_tmdb_with_names( - meta_names=meta_names, - year=year, - mtype=MediaInfo.get_bangumi_media_type(bangumiinfo), - season=meta.begin_season, - ) - return tmdbinfo - return None - def get_doubaninfo_by_tmdbid( - self, tmdbid: int, mtype: MediaType = None, season: Optional[int] = None - ) -> Optional[dict]: - """ - 根据TMDBID获取豆瓣信息 - """ - tmdbinfo = self.tmdb_info(tmdbid=tmdbid, mtype=mtype) - if tmdbinfo: - # 名称 - name = tmdbinfo.get("title") or tmdbinfo.get("name") - # 年份 - year = self._extract_year_from_tmdb(tmdbinfo, season) - # IMDBID - imdbid = tmdbinfo.get("external_ids", {}).get("imdb_id") + if target_source == MediaSource.Douban and media_source == MediaSource.TMDB: + if not media_id.isdigit(): + return None + source_info = self.tmdb_info( + tmdbid=int(media_id), + mtype=mtype, + ) + if not source_info: + return None return self.match_doubaninfo( - name=name, year=year, mtype=mtype, imdbid=imdbid + name=source_info.get("title") or source_info.get("name"), + year=self._extract_year_from_tmdb(source_info, season), + mtype=mtype, + imdbid=source_info.get("external_ids", {}).get("imdb_id"), ) - return None - def get_doubaninfo_by_bangumiid(self, bangumiid: int) -> Optional[dict]: - """ - 根据BangumiID获取豆瓣信息 - """ - bangumiinfo = self.bangumi_info(bangumiid=bangumiid) - if bangumiinfo: - # 优先使用中文标题匹配 - if bangumiinfo.get("name_cn"): - meta = MetaInfo(title=bangumiinfo.get("name_cn")) - else: - meta = MetaInfo(title=bangumiinfo.get("name")) - # 年份 - year = self._extract_year_from_bangumi(bangumiinfo) - # 使用名称识别豆瓣媒体信息 + if target_source == MediaSource.Douban and media_source == MediaSource.Bangumi: + if not media_id.isdigit(): + return None + source_info = self.bangumi_info(bangumiid=int(media_id)) + if not source_info: + return None + meta = MetaInfo(title=source_info.get("name_cn") or source_info.get("name")) return self.match_doubaninfo( name=meta.name, - year=year, - mtype=MediaInfo.get_bangumi_media_type(bangumiinfo), - season=meta.begin_season, + year=self._extract_year_from_bangumi(source_info), + mtype=mtype or MediaInfo.get_bangumi_media_type(source_info), + season=season if season is not None else meta.begin_season, ) return None - @eventmanager.register(EventType.MetadataScrape) - def scrape_metadata_event(self, event: Event): - """ - 监控手动刮削事件 - """ - if not event: - return - event_data = event.event_data or {} - # 媒体根目录 - fileitem: FileItem = event_data.get("fileitem") - # 媒体文件列表 - file_list: List[str] = list(dict.fromkeys(event_data.get("file_list") or [])) - # 媒体元数据 - meta: MetaBase = event_data.get("meta") - # 媒体信息 - mediainfo: MediaInfo = event_data.get("mediainfo") - # 是否覆盖 - overwrite = event_data.get("overwrite", False) - # 检查媒体根目录 - if not fileitem: - return - - # 刮削锁 - with scraping_lock: - # 音乐刮削与影视共用 MediaChain 入口,按 ScrapingConfig 的音乐项写入标签与封面 - if getattr(mediainfo, "type", None) == MediaType.MUSIC: - scrape_kwargs: dict[str, Any] = {} - if file_list: - scrape_kwargs["audio_files"] = self._music_event_audio_fileitems( - root=fileitem, - file_list=file_list, - ) - scrape_kwargs["media_by_path"] = { - Path(context.get("path")).as_posix(): context.get("mediainfo") - for context in event_data.get("file_contexts") or [] - if ( - isinstance(context, dict) - and context.get("path") - and isinstance(context.get("mediainfo"), MusicInfo) - ) - } - _, message = self.scrape_metadata( - fileitem=fileitem, - mediainfo=mediainfo, - overwrite=overwrite, - **scrape_kwargs, - ) - if message: - logger.info(f"音乐刮削:{message}") - return - # 检查文件项是否存在 - if not self.storagechain.get_item(fileitem): - logger.warn(f"文件项不存在:{fileitem.path}") - return - # 检查是否为目录 - if fileitem.type == "file": - # 单个文件刮削 - self.scrape_metadata( - fileitem=fileitem, - mediainfo=mediainfo, - init_folder=True, - parent=self.storagechain.get_parent_item(fileitem), - overwrite=overwrite, - ) - else: - if file_list: - # 如果是BDMV原盘目录,只对根目录进行刮削,不处理子目录 - if self.storagechain.is_bluray_folder(fileitem): - logger.info( - f"检测到BDMV原盘目录,只对根目录进行刮削:{fileitem.path}" - ) - self.scrape_metadata( - fileitem=fileitem, - mediainfo=mediainfo, - init_folder=True, - recursive=False, - overwrite=overwrite, - ) - else: - # 1. 收集fileitem和file_list中每个文件之间所有子目录 - all_dirs: set[Path] = set() - root_path = Path(fileitem.path) - - logger.debug(f"开始收集目录,根目录:{root_path}") - # 收集根目录 - all_dirs.add(root_path) - - # 收集所有目录(包括所有层级) - for sub_file in file_list: - sub_path = Path(sub_file) - # 收集从根目录到文件的所有父目录 - current_path = sub_path.parent - while ( - current_path != root_path - and current_path.is_relative_to(root_path) - ): - all_dirs.add(current_path) - current_path = current_path.parent - - logger.debug(f"共收集到 {len(all_dirs)} 个目录") - - # 2. 初始化一遍子目录,但不处理文件 - for sub_dir in sorted( - all_dirs, - key=lambda item: (len(item.parts), item.as_posix()), - ): - sub_dir_item = self.storagechain.get_file_item( - storage=fileitem.storage, path=sub_dir - ) - if sub_dir_item: - logger.info(f"为目录生成海报和nfo:{sub_dir}") - # 初始化目录元数据,但不处理文件 - self.scrape_metadata( - fileitem=sub_dir_item, - mediainfo=mediainfo, - init_folder=True, - recursive=False, - overwrite=overwrite, - ) - else: - logger.warn(f"无法获取目录项:{sub_dir}") - - # 3. 刮削每个文件 - logger.info(f"开始刮削 {len(file_list)} 个文件") - for sub_file_path in sorted(file_list): - sub_file_item = self.storagechain.get_file_item( - storage=fileitem.storage, path=Path(sub_file_path) - ) - if sub_file_item: - self.scrape_metadata( - fileitem=sub_file_item, - mediainfo=mediainfo, - init_folder=False, - overwrite=overwrite, - ) - else: - logger.warn(f"无法获取文件项:{sub_file_path}") - else: - # 执行全量刮削 - logger.info(f"开始刮削目录 {fileitem.path} ...") - self.scrape_metadata( - fileitem=fileitem, - meta=meta, - init_folder=True, - mediainfo=mediainfo, - overwrite=overwrite, - ) - - def _scrape_nfo_generic( - self, - current_fileitem: schemas.FileItem, - meta: MetaBase, - mediainfo: MediaInfo, - item_type: ScrapingTarget, - parent_fileitem: Optional[schemas.FileItem] = None, - overwrite: bool = False, - season_number: Optional[int] = None, - episode_number: Optional[int] = None, - ): - """ - NFO 刮削 - """ - # 获取刮削选项 - nfo_option = self.scraping_policies.option(item_type, ScrapingMetadata.NFO) - - # 检查刮削开关 - if nfo_option.is_skip: - logger.info( - f"{item_type.value} {ScrapingMetadata.NFO.value} 刮削策略 {nfo_option.policy.value}" - ) - return - - # 获取目标 FileItem (`base_item`) 和 Path (`nfo_path`) - base_item, nfo_path = self._get_target_fileitem_and_path( - current_fileitem=current_fileitem, - item_type=item_type, - metadata_type=ScrapingMetadata.NFO, - parent_fileitem=parent_fileitem, - ) - - if not nfo_path: # _get_target_fileitem_and_path 内部错误处理返回None - return - - # 文件存在检查 - file_exists = self.storagechain.get_file_item( - storage=base_item.storage, path=nfo_path - ) - - # 刮削决策 - if self._should_scrape(nfo_option, bool(file_exists), overwrite): - # 生成 NFO 内容 - nfo_content = self.metadata_nfo( - meta=meta, - mediainfo=mediainfo, - season=season_number, - episode=episode_number, - ) - if nfo_content: - self._save_file(fileitem=base_item, path=nfo_path, content=nfo_content) - else: - logger.warn(f"{nfo_path.name} NFO 文件生成失败!") - - def _scrape_images_generic( - self, - current_fileitem: schemas.FileItem, - mediainfo: MediaInfo, - item_type: ScrapingTarget, - parent_fileitem: Optional[schemas.FileItem] = None, - overwrite: bool = False, - season_number: Optional[int] = None, - episode_number: Optional[int] = None, - ): - """ - 图片刮削 - """ - # 获取图片 URL - if item_type == ScrapingTarget.EPISODE: - image_dict = self.metadata_img( - mediainfo=mediainfo, season=season_number, episode=episode_number - ) - elif item_type == ScrapingTarget.SEASON: - image_dict = self.metadata_img(mediainfo=mediainfo, season=season_number) - else: - image_dict = self.metadata_img(mediainfo=mediainfo) - - if not image_dict: - logger.info(f"未获取到 {item_type.value} 的图片信息,跳过图片刮削。") - return - - # 遍历图片 image_name 和 image_url - for image_name, image_url in image_dict.items(): - metadata_type = None - # 对每个 image_name 查找匹配的 ScrapingMetadata - for keyword, meta_type in self.IMAGE_METADATA_MAP.items(): - if keyword in image_name.lower(): - metadata_type = meta_type - break - - if metadata_type: - # 获取对应的 ScrapingOption - option = self.scraping_policies.option(item_type, metadata_type) - - if option.is_skip: - logger.info( - f"{item_type.value} {option.metadata.value} 刮削策略 {option.policy.value}" - ) - continue - - # 判断是否匹配当前刮削的季号 - if item_type == ScrapingTarget.TV and image_name.lower().startswith( - "season" - ): - logger.info(f"当前为电视剧根目录刮削,跳过季图片:{image_name}") - continue - if ( - item_type == ScrapingTarget.SEASON - and season_number is not None - and image_name.lower().startswith("season") - ): - # 检查是否只下载当前刮削季的图片 - image_season_str = ( - "00" if "specials" in image_name.lower() else image_name[6:8] - ) - - if image_season_str is not None and image_season_str != str( - season_number - ).rjust(2, "0"): - logger.info( - f"当前刮削季为:{season_number},跳过非本季图片:{image_name}" - ) - continue - - # 获取目标 FileItem 和 Path,季图片会同时写根目录和季目录。 - image_targets = self._get_target_fileitems_and_paths( - current_fileitem=current_fileitem, - item_type=item_type, - metadata_type=metadata_type, - filename_hint=image_name, - parent_fileitem=parent_fileitem, - ) - - # 扩展别名目标(如 backdrop→fanart, thumb→landscape) - image_targets = self._expand_with_aliases(image_targets, item_type) - - for base_item, image_path in image_targets: - if not image_path: - continue - - # 文件存在检查 - file_exists = self.storagechain.get_file_item( - storage=base_item.storage, path=image_path - ) - - # 刮削决策 - if self._should_scrape(option, bool(file_exists), overwrite): - self._download_and_save_image( - fileitem=base_item, path=image_path, url=image_url - ) - else: - logger.debug( - f"未找到图片类型 {image_name} 对应的 ScrapingMetadata,跳过。" - ) - - def scrape_metadata( - self, - fileitem: schemas.FileItem, - meta: MetaBase = None, - mediainfo: Union[MediaInfo, MusicInfo] = None, - init_folder: bool = True, - parent: schemas.FileItem = None, - overwrite: bool = False, - recursive: bool = True, - audio_files: Optional[list[schemas.FileItem]] = None, - media_by_path: Optional[dict[str, MusicInfo]] = None, - ) -> tuple[bool, str]: - """ - 手动刮削媒体信息 - - :param fileitem: 刮削目录或文件 - :param meta: 元数据 - :param mediainfo: 媒体信息 - :param init_folder: 是否刮削根目录 - :param parent: 上级目录 - :param overwrite: 是否覆盖已有文件 - :param recursive: 是否递归处理目录内文件 - :param audio_files: 音乐批次已确认成功的音频文件清单 - :param media_by_path: 音乐批次每个音频文件对应的标准身份 - """ - if not fileitem: - return False, "未提供刮削文件" - - # 当前文件路径 - filepath = Path(fileitem.path) - is_music = ( - getattr(mediainfo, "type", None) == MediaType.MUSIC - or isinstance(meta, MetaMusic) - or ( - fileitem.type == "file" - and filepath.suffix.lower() in settings.RMT_AUDIOEXT - ) - ) - if is_music: - music_info = ( - mediainfo - if getattr(mediainfo, "type", None) == MediaType.MUSIC - else None - ) - music_kwargs: dict[str, Any] = {} - if audio_files is not None: - music_kwargs["audio_files"] = audio_files - if media_by_path is not None: - music_kwargs["media_by_path"] = media_by_path - return self.scrape_music_metadata( - fileitem=fileitem, - mediainfo=music_info, - overwrite=overwrite, - **music_kwargs, - ) - if fileitem.type == "file" and ( - not filepath.suffix or filepath.suffix.lower() not in settings.RMT_MEDIAEXT - ): - return False, "刮削路径不是支持的媒体文件" - - # 准备元数据和媒体信息 - if not meta: - meta = MetaInfoPath(filepath) - if not mediainfo: - mediainfo = self.recognize_by_meta(meta) - if not mediainfo: - logger.warn(f"{filepath} 无法识别文件媒体信息!") - return False, "未识别到媒体信息" - - logger.info(f"开始刮削:{filepath} ...") - - # 根据媒体类型分发处理逻辑 - if mediainfo.type == MediaType.MOVIE: - self._handle_movie_scraping( - fileitem=fileitem, - meta=meta, - mediainfo=mediainfo, - init_folder=init_folder, - parent=parent, - overwrite=overwrite, - recursive=recursive, - ) - elif mediainfo.type == MediaType.TV: - self._handle_tv_scraping( - fileitem=fileitem, - meta=meta, - mediainfo=mediainfo, - init_folder=init_folder, - parent=parent, - overwrite=overwrite, - recursive=recursive, - ) - else: - logger.warn(f"{filepath} 媒体类型不支持刮削:{mediainfo.type}") - return False, "媒体类型不支持刮削" - - logger.info(f"{filepath.name} 刮削完成") - return True, f"{filepath.name} 刮削完成" - - def scrape_music_metadata( - self, - fileitem: schemas.FileItem, - mediainfo: Optional[MusicInfo] = None, - overwrite: bool = True, - media_source: Optional[MediaSource] = None, - audio_files: Optional[list[schemas.FileItem]] = None, - media_by_path: Optional[dict[str, MusicInfo]] = None, - ) -> tuple[bool, str]: - """为音频文件或目录写入音乐标签和封面,应用系统刮削策略,复用现有存储下载上传能力。 - - 音乐刮削被收拢到 MediaChain 统一分发,避免音乐链反向依赖媒体链造成嵌套。 - - :param audio_files: 已确认属于本批次的音频文件;为空时按 fileitem 展开 - :param media_by_path: 每个音频文件对应的音乐身份,避免批次内不同单曲互相覆盖 - """ - files = self._normalize_music_audio_fileitems( - audio_files if audio_files is not None else self._music_audio_fileitems(fileitem) - ) - if not files: - return False, "刮削路径中没有支持的音频文件" - normalized_media_by_path = { - Path(path).as_posix(): info - for path, info in (media_by_path or {}).items() - if isinstance(info, MusicInfo) - } - file_media = [ - normalized_media_by_path.get(Path(item.path).as_posix(), mediainfo) - for item in files - ] - distinct_recordings = { - self._music_scrape_identity(info) - for info in file_media - if info and info.music_type != MUSIC_ENTITY_ALBUM - } - if len(files) > 1 and len(distinct_recordings) == 1 and all(file_media): - return False, "单曲音乐 ID 仅支持刮削单个音频文件,整目录请选择专辑" - - # 三类音乐产物使用独立策略,允许只下载歌词而不改写音频标签。 - nfo_option = self.scraping_policies.option("music", "nfo") - poster_option = self.scraping_policies.option("music", "poster") - lyrics_option = self.scraping_policies.option("music", "lyrics") - if nfo_option.is_skip and poster_option.is_skip and lyrics_option.is_skip: - return False, "音乐标签、封面和歌词刮削策略均为跳过" - - with_cover = not poster_option.is_skip - music_chain = None - if not lyrics_option.is_skip: - music_chain = MusicChain() - cover_cache: dict[str, tuple[Optional[bytes], str]] = {} - album_cache: dict[tuple[str, str], Optional[MusicAlbumInfo]] = {} - - failures: list[str] = [] - lyrics_counts = { - "saved": 0, - "existing": 0, - "missing": 0, - "failed": 0, - } - metadata_failure_label = ( - "音乐标签和封面" - if not nfo_option.is_skip and with_cover - else "音乐标签" if not nfo_option.is_skip else "封面" - ) - for audio_item, item_media in zip(files, file_media): - item_cover = None - cover_url = item_media.cover_url if item_media else None - if with_cover and cover_url: - if cover_url not in cover_cache: - cover_cache[cover_url] = self._download_music_cover(cover_url) - item_cover = cover_cache[cover_url] - album_info = None - if ( - music_chain - and item_media - and item_media.music_type == MUSIC_ENTITY_ALBUM - and item_media.media_source - and item_media.media_id - ): - album_key = (item_media.media_source, item_media.media_id) - if album_key not in album_cache: - album_cache[album_key] = music_chain.album( - media_source=item_media.media_source, - media_id=item_media.media_id, - ) - album_info = album_cache[album_key] - result = self._scrape_music_file( - audio_item, - item_media, - write_tags=not nfo_option.is_skip, - tag_overwrite=overwrite or nfo_option.is_overwrite, - with_cover=with_cover, - cover_overwrite=overwrite or poster_option.is_overwrite, - cover=item_cover, - lyrics_option=lyrics_option, - lyrics_overwrite=overwrite or lyrics_option.is_overwrite, - music_chain=music_chain, - album_info=album_info, - media_source=media_source, - ) - if not result.metadata_success: - failures.append( - f"{audio_item.name or audio_item.path} {metadata_failure_label}写入失败" - ) - if result.lyrics_status in lyrics_counts: - lyrics_counts[result.lyrics_status] += 1 - if result.lyrics_status == "failed": - failures.append(f"{audio_item.name or audio_item.path} 歌词保存失败") - - message = f"已刮削 {len(files)} 个音频文件" - if not lyrics_option.is_skip: - message += ( - f",歌词新增 {lyrics_counts['saved']} 首" - f"、已存在 {lyrics_counts['existing']} 首" - f"、未匹配 {lyrics_counts['missing']} 首" - ) - if lyrics_counts["failed"]: - message += f"、失败 {lyrics_counts['failed']} 首" - if failures: - return False, f"{message};{';'.join(failures[:3])}" - return True, message - - @staticmethod - def _music_scrape_identity(info: MusicInfo) -> tuple: - """构造音乐刮削身份键,用于识别同一单曲被错误套用到多个文件。""" - return ( - info.media_source, - info.media_id, - info.music_type, - info.title, - info.disc_number, - info.track_number, - ) - - @staticmethod - @cached(maxsize=64, ttl=settings.CONF.meta, skip_none=True) - def _request_music_cover(url: str) -> Optional[tuple[Optional[bytes], str]]: - """下载并缓存音乐封面;仅稳定 404 与成功响应进入有界缓存。""" - response = RequestUtils( - proxies=settings.PROXY, - ua=settings.NORMAL_USER_AGENT, - timeout=20, - ).get_res(url) - if response is None: - return None - try: - if response.status_code == 404: - return None, "image/jpeg" - if response.status_code != 200: - logger.warning(f"音乐封面下载失败:{response.status_code} {url}") - return None - mime = (response.headers.get("Content-Type") or "image/jpeg").split(";", 1)[0] - return response.content, mime - finally: - response.close() - - @staticmethod - def _download_music_cover(url: Optional[str]) -> tuple[Optional[bytes], str]: - """通过有界缓存下载音乐封面,并统一返回图片内容与 MIME 类型。""" - if not url: - return None, "image/jpeg" - return MediaChain._request_music_cover(url) or (None, "image/jpeg") - - @staticmethod - def _is_music_audio_file(path: str) -> bool: - """判断路径是否指向系统支持的音频文件。""" - return Path(path).suffix.lower() in settings.RMT_AUDIOEXT - - def _music_audio_fileitems(self, fileitem: schemas.FileItem) -> list[schemas.FileItem]: - """展开待刮削目录并过滤系统支持的音频文件。""" - if fileitem.type != "dir": - return [fileitem] if self._is_music_audio_file(fileitem.path or "") else [] - return [ - item - for item in self.storagechain.list_files(fileitem, recursion=True) or [] - if item.type == "file" and self._is_music_audio_file(item.path or "") - ] - - @classmethod - def _normalize_music_audio_fileitems( - cls, - fileitems: Iterable[schemas.FileItem], - ) -> list[schemas.FileItem]: - """过滤并按存储路径去重已选音频文件,保持调用方给出的顺序。""" - normalized: list[schemas.FileItem] = [] - seen: set[tuple[str, str]] = set() - for item in fileitems or []: - if ( - not item - or item.type != "file" - or not cls._is_music_audio_file(item.path or "") - ): - continue - key = (item.storage or "local", Path(item.path).as_posix()) - if key in seen: - continue - seen.add(key) - normalized.append(item) - return normalized - - def _music_event_audio_fileitems( - self, - root: schemas.FileItem, - file_list: Iterable[str], - ) -> list[schemas.FileItem]: - """把刮削事件中的成功路径恢复为文件项,并限制在事件媒体根目录内。""" - root_path = Path(root.path) - selected: list[schemas.FileItem] = [] - for raw_path in file_list or []: - audio_path = Path(raw_path) - if not self._is_music_audio_file(audio_path.as_posix()): - continue - if root.type == "dir" and not audio_path.is_relative_to(root_path): - logger.warning(f"忽略媒体根目录外的音乐刮削路径:{audio_path}") - continue - item = self.storagechain.get_file_item( - storage=root.storage, - path=audio_path, - ) - selected.append(item or schemas.FileItem( - storage=root.storage, - path=audio_path.as_posix(), - type="file", - name=audio_path.name, - basename=audio_path.stem, - extension=audio_path.suffix.lstrip("."), - )) - return self._normalize_music_audio_fileitems(selected) - - def _scrape_music_file( - self, - fileitem: schemas.FileItem, - mediainfo: Optional[MusicInfo], - write_tags: bool, - tag_overwrite: bool, - with_cover: bool = True, - cover_overwrite: bool = True, - cover: Optional[tuple[Optional[bytes], str]] = None, - lyrics_option: Optional[ScrapingOption] = None, - lyrics_overwrite: bool = False, - music_chain: Optional[MusicChain] = None, - album_info: Optional[MusicAlbumInfo] = None, - media_source: Optional[MediaSource] = None, - ) -> _MusicScrapeFileResult: - """下载单个音轨并执行标签、封面和歌词刮削,远端产物写回原目录。""" - storage = self.storagechain - download_failure = _MusicScrapeFileResult( - metadata_success=not (write_tags or with_cover), - lyrics_status=( - "failed" - if lyrics_option and not lyrics_option.is_skip and music_chain - else "disabled" - ), - ) - if fileitem.storage == "local": - local_path = storage.download_file(fileitem) - if not local_path: - return download_failure - return self._apply_music_file_scrape( - fileitem=fileitem, - local_path=local_path, - mediainfo=mediainfo, - write_tags=write_tags, - tag_overwrite=tag_overwrite, - with_cover=with_cover, - cover_overwrite=cover_overwrite, - cover=cover, - lyrics_option=lyrics_option, - lyrics_overwrite=lyrics_overwrite, - music_chain=music_chain, - album_info=album_info, - media_source=media_source, - ) - - with TemporaryDirectory(prefix="moviepilot-music-scrape-") as temp_dir: - local_path = storage.download_file(fileitem, path=Path(temp_dir)) - if not local_path: - return download_failure - return self._apply_music_file_scrape( - fileitem=fileitem, - local_path=local_path, - mediainfo=mediainfo, - write_tags=write_tags, - tag_overwrite=tag_overwrite, - with_cover=with_cover, - cover_overwrite=cover_overwrite, - cover=cover, - lyrics_option=lyrics_option, - lyrics_overwrite=lyrics_overwrite, - music_chain=music_chain, - album_info=album_info, - media_source=media_source, - ) - - def _apply_music_file_scrape( - self, - fileitem: schemas.FileItem, - local_path: Path, - mediainfo: Optional[MusicInfo], - write_tags: bool, - tag_overwrite: bool, - with_cover: bool, - cover_overwrite: bool, - cover: Optional[tuple[Optional[bytes], str]], - lyrics_option: Optional[ScrapingOption], - lyrics_overwrite: bool, - music_chain: Optional[MusicChain], - album_info: Optional[MusicAlbumInfo], - media_source: Optional[MediaSource], - ) -> _MusicScrapeFileResult: - """在本地音轨副本上执行刮削,并将变更后的音频和歌词写回目标存储。""" - scrape_info = self._resolve_music_scrape_info(local_path, mediainfo, media_source=media_source) - metadata_requested = write_tags or with_cover - metadata_success = True - if metadata_requested: - metadata_success = self._write_music_metadata( - local_path=local_path, - mediainfo=mediainfo, - tag_overwrite=tag_overwrite, - write_tags=write_tags, - with_cover=with_cover, - cover_overwrite=cover_overwrite, - cover=cover, - scrape_info=scrape_info, - media_source=media_source, - ) - - lyrics_status = self._scrape_music_lyrics( - fileitem=fileitem, - local_path=local_path, - scrape_info=scrape_info, - lyrics_option=lyrics_option, - overwrite=lyrics_overwrite, - music_chain=music_chain, - album_info=album_info, - ) - - if fileitem.storage != "local" and metadata_requested and metadata_success: - parent = self.storagechain.get_parent_item(fileitem) - if not parent: - logger.warning(f"无法获取远端音频父目录:{fileitem.path}") - metadata_success = False - elif not self.storagechain.upload_file( - parent, - local_path, - new_name=fileitem.name or local_path.name, - ): - metadata_success = False - return _MusicScrapeFileResult( - metadata_success=metadata_success, - lyrics_status=lyrics_status, - ) - - @staticmethod - def _merge_music_album_metadata(local_meta: MetaMusic, album: MusicInfo) -> MetaMusic: - """把专辑级字段合并到单个音轨标签,同时保留该文件自己的标题、艺术家和曲序。""" - merged = deepcopy(local_meta) - merged.artists = list(local_meta.artists or album.artists) - merged.album = album.album or album.title or local_meta.album - merged.album_artist = album.album_artist or album.artist or local_meta.album_artist - merged.year = album.year or local_meta.year - merged.total_tracks = album.total_tracks or local_meta.total_tracks - merged.media_source = album.media_source or local_meta.media_source - merged.media_id = album.media_id or local_meta.media_id - return merged - - @classmethod - def _match_music_album_track( - cls, - local_meta: MetaMusic, - album_info: Optional[MusicAlbumInfo], - ) -> Optional[MusicInfo]: - """按碟号、曲序、标题、艺术家和时长为本地文件匹配专辑中的单个音轨。""" - if not album_info or not album_info.tracks: - return None - local_title = cls._normalize_music_track_title(local_meta.title) - local_artists = { - cls._normalize_music_track_title(artist) - for artist in local_meta.artists - if artist - } - ranked: list[tuple[int, MusicInfo]] = [] - for track in album_info.tracks: - score = 0 - if local_meta.track_number and track.track_number: - if local_meta.track_number == track.track_number: - score += 6 - else: - continue - if local_meta.disc_number and track.disc_number: - if local_meta.disc_number == track.disc_number: - score += 3 - else: - continue - if local_title and local_title == cls._normalize_music_track_title(track.title): - score += 8 - track_artists = { - cls._normalize_music_track_title(artist) - for artist in track.artists - if artist - } - if local_artists and track_artists and local_artists.intersection(track_artists): - score += 3 - if local_meta.duration and track.duration: - duration_delta = abs(local_meta.duration - track.duration) - if duration_delta <= 2: - score += 4 - elif duration_delta > 5: - score -= 3 - if score >= 8: - ranked.append((score, track)) - if not ranked: - return None - ranked.sort(key=lambda pair: pair[0], reverse=True) - if len(ranked) > 1 and ranked[0][0] == ranked[1][0]: - return None - matched = deepcopy(ranked[0][1]) - matched.duration = matched.duration or local_meta.duration - return matched - - @classmethod - def _normalize_music_track_title(cls, value: Optional[str]) -> str: - """规范化音轨标题并移除常见文件名前置碟号和曲序。""" - title = cls._music_track_prefix_pattern.sub("", str(value or "").strip()) - return re.sub(r"[^\w]+", "", title.casefold(), flags=re.UNICODE) - - @classmethod - def _resolve_music_scrape_info( - cls, - local_path: Path, - mediainfo: Optional[MusicInfo], - media_source: Optional[MediaSource] = None, - ) -> Optional[MetaMusic | MusicInfo]: - """在文件已下载到本地后解析刮削信息,专辑场景只覆盖专辑级标签。""" - if mediainfo and mediainfo.music_type == MUSIC_ENTITY_ALBUM: - return cls._merge_music_album_metadata( - AudioMetadataHelper.read(local_path), - mediainfo, - ) - if mediainfo and mediainfo.music_type in (MUSIC_ENTITY_RECORDING, None, ""): - return mediainfo - if mediainfo: - return None - - _, recognized = cls.recognize_music_by_path(local_path, media_source=media_source) - return recognized - - def _write_music_metadata( - self, - local_path: Path, - mediainfo: Optional[MusicInfo], - tag_overwrite: bool, - write_tags: bool, - with_cover: bool, - cover_overwrite: bool, - cover: Optional[tuple[Optional[bytes], str]] = None, - scrape_info: Optional[MetaMusic | MusicInfo] = None, - media_source: Optional[MediaSource] = None, - ) -> bool: - """解析单个本地音轨并按独立策略写入标签和封面。""" - scrape_info = scrape_info or self._resolve_music_scrape_info( - local_path, - mediainfo, - media_source=media_source, - ) - if not scrape_info or not scrape_info.title: - logger.warning(f"无法识别音乐信息:{local_path}") - return False - if not with_cover: - cover_data, cover_mime = None, "image/jpeg" - elif cover is not None: - cover_data, cover_mime = cover - else: - cover_url = getattr(mediainfo, "cover_url", None) or getattr(scrape_info, "cover_url", None) - cover_data, cover_mime = self._download_music_cover(cover_url) - return AudioMetadataHelper.write( - local_path, - scrape_info, - cover_data=cover_data, - cover_mime=cover_mime, - overwrite=tag_overwrite, - write_tags=write_tags, - cover_overwrite=cover_overwrite, - ) - - def _scrape_music_lyrics( - self, - fileitem: schemas.FileItem, - local_path: Path, - scrape_info: Optional[MetaMusic | MusicInfo], - lyrics_option: Optional[ScrapingOption], - overwrite: bool, - music_chain: Optional[MusicChain], - album_info: Optional[MusicAlbumInfo], - ) -> str: - """按歌词策略查询单个音轨并保存同名旁挂歌词文件。""" - if not lyrics_option or lyrics_option.is_skip or not music_chain: - return "disabled" - existing = self._find_music_lyrics_sidecar(fileitem) - if existing and not overwrite: - return "existing" - if not scrape_info: - return "missing" - - lookup_info: MetaMusic | MusicInfo = scrape_info - if album_info: - local_meta = AudioMetadataHelper.read(local_path) - lookup_info = self._match_music_album_track(local_meta, album_info) or scrape_info - lyrics = music_chain.lyrics(lookup_info) - if not lyrics or lyrics.instrumental or not lyrics.content or not lyrics.extension: - return "missing" - return ( - "saved" - if self._write_music_lyrics_sidecar( - fileitem=fileitem, - local_path=local_path, - lyrics=lyrics, - overwrite=overwrite, - ) - else "failed" - ) - - def _find_music_lyrics_sidecar( - self, - fileitem: schemas.FileItem, - ) -> Optional[schemas.FileItem]: - """查找音轨旁已存在的同步或纯文本歌词文件。""" - audio_path = Path(fileitem.path) - for extension in self.MUSIC_LYRICS_EXTENSIONS: - item = self.storagechain.get_file_item( - storage=fileitem.storage, - path=audio_path.with_suffix(extension), - ) - if item: - return item - return None - - def _write_music_lyrics_sidecar( - self, - fileitem: schemas.FileItem, - local_path: Path, - lyrics: MusicLyrics, - overwrite: bool, - ) -> bool: - """原子写入本地歌词或上传远端歌词,并在覆盖时清理旧格式旁挂文件。""" - extension = lyrics.extension - content = lyrics.content - if not extension or not content: - return False - target_path = Path(fileitem.path).with_suffix(extension) - target_name = target_path.name - temp_path: Optional[Path] = None - try: - if fileitem.storage == "local": - with NamedTemporaryFile( - mode="w", - encoding="utf-8", - dir=target_path.parent, - prefix=f".{target_name}.", - delete=False, - ) as temp_file: - temp_file.write(f"{content.rstrip()}\n") - temp_path = Path(temp_file.name) - temp_path.replace(target_path) - else: - parent = self.storagechain.get_parent_item(fileitem) - if not parent: - logger.warning(f"无法获取远端歌词父目录:{fileitem.path}") - return False - temp_path = local_path.with_suffix(extension) - temp_path.write_text(f"{content.rstrip()}\n", encoding="utf-8") - if not self.storagechain.upload_file( - parent, - temp_path, - new_name=target_name, - ): - return False - - if overwrite: - self._remove_alternate_music_lyrics(fileitem, keep_extension=extension) - return True - except OSError as err: - logger.warning(f"保存音乐歌词失败:{target_path} - {err}") - return False - finally: - if temp_path and temp_path.exists() and temp_path != target_path: - self._cleanup_temp_file(temp_path) - - def _remove_alternate_music_lyrics( - self, - fileitem: schemas.FileItem, - keep_extension: str, - ) -> None: - """覆盖歌词格式后删除同音轨的旧扩展名文件,避免播放器优先读取过期内容。""" - audio_path = Path(fileitem.path) - for extension in self.MUSIC_LYRICS_EXTENSIONS: - if extension == keep_extension: - continue - item = self.storagechain.get_file_item( - storage=fileitem.storage, - path=audio_path.with_suffix(extension), - ) - if item and not self.storagechain.delete_file(item): - logger.warning(f"删除旧歌词文件失败:{item.path}") - - def _handle_movie_scraping( - self, - fileitem: schemas.FileItem, - meta: MetaBase, - mediainfo: MediaInfo, - init_folder: bool, - parent: schemas.FileItem, - overwrite: bool, - recursive: bool, - ): - """ - 处理电影刮削 - """ - if fileitem.type == "file": - # 电影文件始终处理 NFO,直接初始化文件时再补同级目录图片。 - self._scrape_nfo_generic( - current_fileitem=fileitem, - meta=meta, - mediainfo=mediainfo, - item_type=ScrapingTarget.MOVIE, - parent_fileitem=parent, - overwrite=overwrite, - ) - if init_folder: - self._scrape_images_generic( - current_fileitem=fileitem, - mediainfo=mediainfo, - item_type=ScrapingTarget.MOVIE, - parent_fileitem=parent, - overwrite=overwrite, - ) - else: - # 电影目录:递归处理文件并初始化目录 - self._handle_movie_directory( - fileitem=fileitem, - meta=meta, - mediainfo=mediainfo, - init_folder=init_folder, - overwrite=overwrite, - recursive=recursive, - ) - - def _handle_movie_directory( - self, - fileitem: schemas.FileItem, - meta: MetaBase, - mediainfo: MediaInfo, - init_folder: bool, - overwrite: bool, - recursive: bool, - ): - """ - 处理电影目录刮削 - """ - files = self.storagechain.list_files(fileitem=fileitem) or [] - is_bluray_folder = self.storagechain.contains_bluray_subdirectories(files) - - # 递归处理文件(非蓝光原盘) - if recursive and not is_bluray_folder: - for file in files: - if file.type == "dir": - continue - self.scrape_metadata( - fileitem=file, - mediainfo=mediainfo, - init_folder=False, - parent=fileitem, - overwrite=overwrite, - ) - - # 初始化目录元数据 - if init_folder: - if is_bluray_folder: - # 蓝光原盘目录:仅处理 NFO - self._scrape_nfo_generic( - current_fileitem=fileitem, - meta=meta, - mediainfo=mediainfo, - item_type=ScrapingTarget.MOVIE, - overwrite=overwrite, - ) - # 电影目录:处理图片 - self._scrape_images_generic( - current_fileitem=fileitem, - mediainfo=mediainfo, - item_type=ScrapingTarget.MOVIE, - overwrite=overwrite, - ) - - def _handle_tv_scraping( - self, - fileitem: schemas.FileItem, - meta: MetaBase, - mediainfo: MediaInfo, - init_folder: bool, - parent: schemas.FileItem, - overwrite: bool, - recursive: bool, - ): - """ - 处理电视剧刮削 - """ - filepath = Path(fileitem.path) - - if fileitem.type == "file": - # 电视剧集文件:重新识别季集信息并刮削 - self._handle_tv_episode_file( - fileitem=fileitem, - filepath=filepath, - mediainfo=mediainfo, - parent=parent, - overwrite=overwrite, - ) - else: - # 电视剧目录:递归处理并初始化目录 - self._handle_tv_directory( - fileitem=fileitem, - filepath=filepath, - meta=meta, - mediainfo=mediainfo, - init_folder=init_folder, - parent=parent, - overwrite=overwrite, - recursive=recursive, - ) - - def _handle_tv_episode_file( - self, - fileitem: schemas.FileItem, - filepath: Path, - mediainfo: MediaInfo, - parent: schemas.FileItem, - overwrite: bool, - ): - """ - 处理电视剧集文件刮削 - """ - # 重新识别季集信息 - file_meta = MetaInfoPath(filepath) - if not file_meta.begin_episode: - logger.warn(f"{filepath.name} 无法识别文件集数!") - return - - file_mediainfo = self.recognize_media( - meta=file_meta, - media_source=MediaSource.TMDB, - media_id=str(mediainfo.tmdb_id), - episode_group=mediainfo.episode_group, - ) - if not file_mediainfo: - logger.warn(f"{filepath.name} 无法识别文件媒体信息!") - return - - # 处理 NFO - self._scrape_nfo_generic( - current_fileitem=fileitem, - meta=file_meta, - mediainfo=file_mediainfo, - item_type=ScrapingTarget.EPISODE, - parent_fileitem=parent, - overwrite=overwrite, - season_number=file_meta.begin_season, - episode_number=file_meta.begin_episode, - ) - - # 处理图片 - self._scrape_images_generic( - current_fileitem=fileitem, - mediainfo=file_mediainfo, - item_type=ScrapingTarget.EPISODE, - parent_fileitem=parent, - overwrite=overwrite, - season_number=file_meta.begin_season, - episode_number=file_meta.begin_episode, - ) - - def _handle_tv_directory( - self, - fileitem: schemas.FileItem, - filepath: Path, - meta: MetaBase, - mediainfo: MediaInfo, - init_folder: bool, - parent: schemas.FileItem, - overwrite: bool, - recursive: bool, - ): - """ - 处理电视剧目录刮削 - """ - # 递归处理子目录和文件 - if recursive: - files = self.storagechain.list_files(fileitem=fileitem) or [] - for file in files: - if ( - file.type == "dir" - and file.name not in settings.RENAME_FORMAT_S0_NAMES - and MetaInfo(file.name).begin_season is None - ): - # 电视剧不处理非季子目录 - continue - self.scrape_metadata( - fileitem=file, - mediainfo=mediainfo, - parent=fileitem if file.type == "file" else None, - init_folder=True if file.type == "dir" else False, - overwrite=overwrite, - ) - - # 初始化目录元数据 - if init_folder: - self._initialize_tv_directory_metadata( - fileitem=fileitem, - filepath=filepath, - meta=meta, - mediainfo=mediainfo, - parent=parent, - overwrite=overwrite, - ) - - def _initialize_tv_directory_metadata( - self, - fileitem: schemas.FileItem, - filepath: Path, - meta: MetaBase, - mediainfo: MediaInfo, - parent: schemas.FileItem, - overwrite: bool, - ): - """ - 初始化电视剧目录元数据(识别季号并刮削) - """ - # 识别文件夹名称 - season_meta = MetaInfo(filepath.name) - - # 特殊季目录处理(Specials/SPs) - if filepath.name in settings.RENAME_FORMAT_S0_NAMES: - season_meta.begin_season = 0 - elif season_meta.name and season_meta.begin_season is not None: - # 排除辅助词重新识别,避免误判根目录 (issue https://github.com/jxxghp/MoviePilot/issues/5501) - season_meta_no_custom = MetaInfo(filepath.name, custom_words=["#"]) - if season_meta_no_custom.begin_season is None: - # 季号由辅助词指定,按剧集根目录处理 (issue https://github.com/jxxghp/MoviePilot/issues/5373) - season_meta.begin_season = None - - # 根据季号判断目录类型并刮削 - if season_meta.begin_season is not None: - # 季目录:处理季 NFO 和图片 - self._scrape_nfo_generic( - current_fileitem=fileitem, - meta=meta, - mediainfo=mediainfo, - item_type=ScrapingTarget.SEASON, - overwrite=overwrite, - season_number=season_meta.begin_season, - ) - self._scrape_images_generic( - current_fileitem=fileitem, - mediainfo=mediainfo, - item_type=ScrapingTarget.SEASON, - parent_fileitem=parent, - overwrite=overwrite, - season_number=season_meta.begin_season, - ) - elif season_meta.name: - # 剧集根目录:处理电视剧 NFO 和图片 - self._scrape_nfo_generic( - current_fileitem=fileitem, - meta=meta, - mediainfo=mediainfo, - item_type=ScrapingTarget.TV, - overwrite=overwrite, - ) - self._scrape_images_generic( - current_fileitem=fileitem, - mediainfo=mediainfo, - item_type=ScrapingTarget.TV, - overwrite=overwrite, - ) - else: - logger.warn("无法识别元数据,跳过") @staticmethod async def async_select_recognize_source( @@ -3178,7 +1810,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): return Context(meta_info=file_meta, media_info=mediainfo) async def async_search( - self, title: str, media_source: Optional[MediaSource] = None + self, title: str, media_source: Optional[MediaSourceSelection] = None ) -> Tuple[Optional[MetaBase], List[MediaInfo]]: """ 搜索媒体/人物信息(异步版本) @@ -3283,114 +1915,107 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): return tmdbinfo return None - async def async_get_tmdbinfo_by_doubanid( - self, doubanid: str, mtype: MediaType = None + async def async_convert_media_identity( + self, + target_source: MediaSource, + media_source: MediaSource, + media_id: str, + mtype: Optional[MediaType] = None, + season: Optional[int] = None, ) -> Optional[dict]: """ - 根据豆瓣ID获取TMDB信息(异步版本) + 异步使用统一媒体身份在影视来源之间转换,返回目标来源原始详情。 + + :param target_source: 目标媒体来源 + :param media_source: 当前媒体来源 + :param media_id: 当前来源原生 ID + :param mtype: 可选媒体类型 + :param season: 可选季号,用于电视剧年份匹配 + :return: 目标来源详情;身份无效或转换组合不受支持时返回 None """ - tmdbinfo = None - doubaninfo = await self.async_douban_info(doubanid=doubanid, mtype=mtype) - if doubaninfo: - # 优先使用原标题匹配 - if doubaninfo.get("original_title"): - meta = MetaInfo(title=doubaninfo.get("title")) - meta_org = MetaInfo(title=doubaninfo.get("original_title")) + target_source = normalize_media_source(target_source) + media_source, media_id = resolve_media_identity( + media_source=media_source, + media_id=media_id, + ) + if not target_source or not media_source or not media_id: + return None + + if target_source == MediaSource.TMDB and media_source == MediaSource.Douban: + source_info = await self.async_douban_info(doubanid=media_id, mtype=mtype) + if not source_info: + return None + if source_info.get("original_title"): + meta = MetaInfo(title=source_info.get("title")) + original_meta = MetaInfo(title=source_info.get("original_title")) else: - meta_org = meta = MetaInfo(title=doubaninfo.get("title")) - # 年份 - if doubaninfo.get("year"): - meta.year = doubaninfo.get("year") - # 处理类型 - if isinstance(doubaninfo.get("media_type"), MediaType): - meta.type = doubaninfo.get("media_type") - else: - meta.type = ( - MediaType.MOVIE - if doubaninfo.get("type") == "movie" - else MediaType.TV - ) - # 匹配TMDB信息 - meta_names = list( - dict.fromkeys( - [k for k in [meta_org.name, meta.cn_name, meta.en_name] if k] - ) + original_meta = meta = MetaInfo(title=source_info.get("title")) + if source_info.get("year"): + meta.year = source_info.get("year") + meta.type = ( + source_info.get("media_type") + if isinstance(source_info.get("media_type"), MediaType) + else MediaType.MOVIE if source_info.get("type") == "movie" else MediaType.TV ) - tmdbinfo = await self._async_match_tmdb_with_names( - meta_names=meta_names, + target_info = await self._async_match_tmdb_with_names( + meta_names=list(dict.fromkeys( + name for name in (original_meta.name, meta.cn_name, meta.en_name) if name + )), year=meta.year, mtype=mtype or meta.type, - season=meta.begin_season, + season=season if season is not None else meta.begin_season, ) - if tmdbinfo: - # 合季季后返回 - tmdbinfo["season"] = meta.begin_season - return tmdbinfo + if target_info: + target_info["season"] = season if season is not None else meta.begin_season + return target_info - async def async_get_tmdbinfo_by_bangumiid(self, bangumiid: int) -> Optional[dict]: - """ - 根据BangumiID获取TMDB信息(异步版本) - """ - bangumiinfo = await self.async_bangumi_info(bangumiid=bangumiid) - if bangumiinfo: - # 优先使用原标题匹配 - if bangumiinfo.get("name_cn"): - meta = MetaInfo(title=bangumiinfo.get("name")) - meta_cn = MetaInfo(title=bangumiinfo.get("name_cn")) + if target_source == MediaSource.TMDB and media_source == MediaSource.Bangumi: + if not media_id.isdigit(): + return None + source_info = await self.async_bangumi_info(bangumiid=int(media_id)) + if not source_info: + return None + if source_info.get("name_cn"): + meta = MetaInfo(title=source_info.get("name")) + localized_meta = MetaInfo(title=source_info.get("name_cn")) else: - meta_cn = meta = MetaInfo(title=bangumiinfo.get("name")) - # 年份 - year = self._extract_year_from_bangumi(bangumiinfo) - # 识别TMDB媒体信息 - meta_names = list( - dict.fromkeys([k for k in [meta_cn.name, meta.name] if k]) + localized_meta = meta = MetaInfo(title=source_info.get("name")) + return await self._async_match_tmdb_with_names( + meta_names=list(dict.fromkeys( + name for name in (localized_meta.name, meta.name) if name + )), + year=self._extract_year_from_bangumi(source_info), + mtype=mtype or MediaInfo.get_bangumi_media_type(source_info), + season=season if season is not None else meta.begin_season, ) - tmdbinfo = await self._async_match_tmdb_with_names( - meta_names=meta_names, - year=year, - mtype=MediaInfo.get_bangumi_media_type(bangumiinfo), - season=meta.begin_season, - ) - return tmdbinfo - return None - async def async_get_doubaninfo_by_tmdbid( - self, tmdbid: int, mtype: MediaType = None, season: Optional[int] = None - ) -> Optional[dict]: - """ - 根据TMDBID获取豆瓣信息(异步版本) - """ - tmdbinfo = await self.async_tmdb_info(tmdbid=tmdbid, mtype=mtype) - if tmdbinfo: - # 名称 - name = tmdbinfo.get("title") or tmdbinfo.get("name") - # 年份 - year = self._extract_year_from_tmdb(tmdbinfo, season) - # IMDBID - imdbid = tmdbinfo.get("external_ids", {}).get("imdb_id") + if target_source == MediaSource.Douban and media_source == MediaSource.TMDB: + if not media_id.isdigit(): + return None + source_info = await self.async_tmdb_info( + tmdbid=int(media_id), + mtype=mtype, + ) + if not source_info: + return None return await self.async_match_doubaninfo( - name=name, year=year, mtype=mtype, imdbid=imdbid + name=source_info.get("title") or source_info.get("name"), + year=self._extract_year_from_tmdb(source_info, season), + mtype=mtype, + imdbid=source_info.get("external_ids", {}).get("imdb_id"), ) - return None - async def async_get_doubaninfo_by_bangumiid(self, bangumiid: int) -> Optional[dict]: - """ - 根据BangumiID获取豆瓣信息(异步版本) - """ - bangumiinfo = await self.async_bangumi_info(bangumiid=bangumiid) - if bangumiinfo: - # 优先使用中文标题匹配 - if bangumiinfo.get("name_cn"): - meta = MetaInfo(title=bangumiinfo.get("name_cn")) - else: - meta = MetaInfo(title=bangumiinfo.get("name")) - # 年份 - year = self._extract_year_from_bangumi(bangumiinfo) - # 使用名称识别豆瓣媒体信息 + if target_source == MediaSource.Douban and media_source == MediaSource.Bangumi: + if not media_id.isdigit(): + return None + source_info = await self.async_bangumi_info(bangumiid=int(media_id)) + if not source_info: + return None + meta = MetaInfo(title=source_info.get("name_cn") or source_info.get("name")) return await self.async_match_doubaninfo( name=meta.name, - year=year, - mtype=MediaInfo.get_bangumi_media_type(bangumiinfo), - season=meta.begin_season, + year=self._extract_year_from_bangumi(source_info), + mtype=mtype or MediaInfo.get_bangumi_media_type(source_info), + season=season if season is not None else meta.begin_season, ) return None diff --git a/app/chain/music.py b/app/chain/music.py deleted file mode 100644 index 35ef46032..000000000 --- a/app/chain/music.py +++ /dev/null @@ -1,883 +0,0 @@ -import re -from pathlib import Path -from typing import Any, Iterable, Optional, Union - -from fastapi.concurrency import run_in_threadpool - -from app.chain import ChainBase -from app.core.cache import async_fresh, fresh -from app.core.config import settings -from app.core.context import ( - MusicAlbumInfo, - MusicArtistInfo, - MusicInfo, - MusicLyrics, -) -from app.core.meta import MetaMusic -from app.helper.audio import AudioMetadataHelper -from app.log import logger -from app.schemas.types import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MediaType -from app.utils.media import ( - is_music_media_source, - normalize_media_source, - normalize_music_type, -) - - -class MusicChain(ChainBase): - """音乐元数据搜索、探索与站点搜索参数编排链;媒体识别统一入口见 MediaChain。""" - - # 专辑目录匹配结果缓存:目录内相对路径变化时失效,标签写回不触发重复远端匹配。 - _album_dir_cache: dict[ - str, - tuple[tuple[str, ...], dict[str, MusicInfo]], - ] = {} - _album_dir_cache_max = 128 - # 目录级匹配至少需要两个音频文件,单文件由单曲搜索链路处理 - _album_match_min_files = 2 - # 自动识别只使用 MusicBrainz;其它来源仅响应显式来源请求。 - _primary_recognize_source = "musicbrainz" - - @classmethod - def parse_query(cls, query: str) -> MetaMusic: - """将用户输入的搜索关键词解析为音乐元数据,解析核心在 MetaMusic.apply_title。""" - return MetaMusic(org_string=query, title=query, parse_title=True) - - @classmethod - def build_site_keywords(cls, music: MetaMusic | MusicInfo) -> list[str]: - """按单曲或专辑实体生成站点关键词,避免单曲订阅优先搜到所属整专。""" - artists = music.artists or [] - artist = artists[0] if artists else music.album_artist - keywords = [] - if getattr(music, "music_type", None) == MUSIC_ENTITY_ALBUM: - album = music.album or music.title - if artist and album: - keywords.append(f"{artist} {album}") - if album: - keywords.append(album) - else: - if artist and music.title: - keywords.append(f"{artist} {music.title}") - if music.title: - keywords.append(music.title) - return cls._unique_texts(keywords) - - @classmethod - def matches_site_resource( - cls, - music: MusicInfo, - resource_title: str, - resource_description: Optional[str] = None, - ) -> bool: - """判断站点资源标题与副标题是否包含订阅目标,避免串专辑或串单曲。""" - resource_text = f"{resource_title or ''} {resource_description or ''}" - normalized_resource = cls._normalize_match_text(resource_text) - if not normalized_resource: - return False - if music.music_type == MUSIC_ENTITY_ALBUM: - candidates = cls._unique_texts([ - music.album or music.title, - *(music.names or []), - ]) - else: - # Recording 的 names 兼容字段会包含所属专辑名;单曲匹配只能使用曲名, - # 否则整专资源会被当成单曲下载并在首个任务后误销订阅。 - candidates = cls._unique_texts([music.title]) - title_matches = any( - normalized_target and normalized_target in normalized_resource - for normalized_target in ( - cls._normalize_match_text(candidate) for candidate in candidates - ) - ) - if not title_matches: - return False - artists = cls._unique_texts([ - music.artist, - music.album_artist, - *(music.artists or []), - ]) - if not artists: - return True - # 同名歌曲和专辑十分常见,已知艺术家时必须同时出现在资源标题中。 - return any( - normalized_artist and normalized_artist in normalized_resource - for normalized_artist in ( - cls._normalize_match_text(artist) for artist in artists - ) - ) - - @classmethod - def normalize_candidates( - cls, - candidates: Optional[Iterable[MusicInfo | dict[str, Any]]], - limit: Optional[int] = None, - ) -> list[MusicInfo]: - """标准化并去重来自一个或多个音乐元数据模块的候选。""" - results: list[MusicInfo] = [] - identities: set[tuple[str, ...]] = set() - for candidate in candidates or []: - info = candidate if isinstance(candidate, MusicInfo) else MusicInfo.from_dict(candidate) - identity = cls._candidate_identity(info) - if identity in identities: - continue - identities.add(identity) - results.append(info) - if limit and len(results) >= limit: - break - return results - - def search( - self, - query: str, - limit: int = 20, - media_source: Optional[str] = None, - ) -> list[MusicInfo]: - """按请求来源调用音乐元数据模块搜索候选,未指定时默认使用 MusicBrainz。""" - meta = self.parse_query(query) - candidates = self.run_module( - "search_music", - meta=meta, - limit=limit, - media_source=media_source or "musicbrainz", - ) - return self.normalize_candidates(candidates, limit=limit) - - def recognize_best( - self, - meta: MetaMusic, - cache: bool = True, - ) -> Optional[MusicInfo]: - """执行自动音乐识别,仅调用 MusicBrainz 主数据源。""" - with fresh(not cache): - return self.recognize_from_source( - media_source=self._primary_recognize_source, - meta=meta, - cache=cache, - music_type=MUSIC_ENTITY_RECORDING, - ) - - async def async_recognize_best( - self, - meta: MetaMusic, - cache: bool = True, - ) -> Optional[MusicInfo]: - """异步执行自动音乐识别,仅调用 MusicBrainz 主数据源。""" - async with async_fresh(not cache): - return await self.async_recognize_from_source( - media_source=self._primary_recognize_source, - meta=meta, - cache=cache, - music_type=MUSIC_ENTITY_RECORDING, - ) - - def recognize_from_source( - self, - media_source: str, - meta: Optional[MetaMusic] = None, - media_id: Optional[str] = None, - cache: bool = True, - music_type: Optional[str] = None, - ) -> Optional[MusicInfo]: - """只调用指定音乐数据源识别指定实体,拒绝影视、未知来源和跨实体结果。""" - normalized_source = normalize_media_source(media_source) - if not is_music_media_source(normalized_source): - return None - normalized_music_type = normalize_music_type( - music_type, allow_artist=False - ) - if music_type is not None and not normalized_music_type: - return None - result = self._recognize_from_source( - meta=meta, - media_source=normalized_source, - cache=cache, - media_id=media_id, - music_type=normalized_music_type, - ) - return self._validate_source_recognize_result( - result=result, - media_source=normalized_source, - media_id=media_id, - music_type=normalized_music_type, - ) - - async def async_recognize_from_source( - self, - media_source: str, - meta: Optional[MetaMusic] = None, - media_id: Optional[str] = None, - cache: bool = True, - music_type: Optional[str] = None, - ) -> Optional[MusicInfo]: - """异步只调用指定音乐数据源识别指定实体,拒绝影视、未知来源和跨实体结果。""" - normalized_source = normalize_media_source(media_source) - if not is_music_media_source(normalized_source): - return None - normalized_music_type = normalize_music_type( - music_type, allow_artist=False - ) - if music_type is not None and not normalized_music_type: - return None - result = await self._async_recognize_from_source( - meta=meta, - media_source=normalized_source, - cache=cache, - media_id=media_id, - music_type=normalized_music_type, - ) - return self._validate_source_recognize_result( - result=result, - media_source=normalized_source, - media_id=media_id, - music_type=normalized_music_type, - ) - - async def async_search( - self, - query: str, - limit: int = 20, - media_source: Optional[str] = None, - ) -> list[MusicInfo]: - """异步按请求来源搜索音乐候选,未指定时默认使用 MusicBrainz。""" - meta = self.parse_query(query) - candidates = await self.async_run_module( - "search_music", - meta=meta, - limit=limit, - media_source=media_source or "musicbrainz", - ) - return self.normalize_candidates(candidates, limit=limit) - - def chart(self, range_name: str, page: int = 1, count: int = 30) -> list[MusicInfo]: - """读取 ListenBrainz 全站音乐榜单并标准化分页结果。""" - candidates = self.run_module( - "music_chart", - range_name=range_name, - offset=max(page - 1, 0) * count, - count=count, - ) - return self.normalize_candidates(candidates, limit=count) - - async def async_chart( - self, - range_name: str, - page: int = 1, - count: int = 30, - sort_by: str = "listen_count.desc", - min_listen_count: int = 0, - with_cover: bool = False, - entity: str = MUSIC_ENTITY_RECORDING, - ) -> list[MusicInfo]: - """异步读取 ListenBrainz 热门榜单,并应用音乐探索筛选和排序。""" - candidates = await self.async_run_module( - "music_chart", - range_name=range_name, - offset=max(page - 1, 0) * count, - count=count, - entity=entity, - ) - results = self._filter_candidates( - self.normalize_candidates(candidates), - min_listen_count=min_listen_count, - with_cover=with_cover, - ) - results.sort( - key=lambda info: info.listen_count or 0, - reverse=sort_by != "listen_count.asc", - ) - return results[:count] - - async def async_fresh_releases( - self, - days: int = 14, - sort: str = "release_date", - past: bool = True, - future: bool = True, - page: int = 1, - count: int = 30, - with_cover: bool = False, - ) -> list[MusicInfo]: - """异步读取 ListenBrainz 官方新发行专辑,排序由官方接口决定。""" - candidates = await self.async_run_module( - "music_fresh_releases", - days=days, - sort=sort, - past=past, - future=future, - offset=max(page - 1, 0) * count, - count=count, - ) - results = self._filter_candidates( - self.normalize_candidates(candidates), - min_listen_count=0, - with_cover=with_cover, - ) - return results[:count] - - def discover( - self, - media_source: str, - page: int = 1, - count: int = 30, - entity: str = MUSIC_ENTITY_ALBUM, - mode: str = "chart", - tags: str = "", - sort: str = "U", - ) -> list[MusicInfo]: - """按指定音乐源读取推荐榜单,并统一分页候选结构。""" - candidates = self.run_module( - "music_discover", - media_source=media_source, - page=page, - count=count, - entity=entity, - mode=mode, - tags=tags, - sort=sort, - ) - return self.normalize_candidates(candidates, limit=count) - - async def async_discover( - self, - media_source: str, - page: int = 1, - count: int = 30, - entity: str = MUSIC_ENTITY_ALBUM, - mode: str = "chart", - tags: str = "", - sort: str = "U", - ) -> list[MusicInfo]: - """异步按指定音乐源读取推荐榜单,并统一分页候选结构。""" - candidates = await self.async_run_module( - "music_discover", - media_source=media_source, - page=page, - count=count, - entity=entity, - mode=mode, - tags=tags, - sort=sort, - ) - return self.normalize_candidates(candidates, limit=count) - - async def async_album(self, media_source: str, media_id: str) -> Optional[MusicAlbumInfo]: - """异步按来源和专辑 ID 获取标准化专辑详情及曲目。""" - result = await self.async_run_module( - "music_album", - media_source=media_source, - media_id=media_id, - ) - if isinstance(result, MusicAlbumInfo): - return result - if isinstance(result, dict): - return MusicAlbumInfo.from_dict(result) - return None - - def album(self, media_source: str, media_id: str) -> Optional[MusicAlbumInfo]: - """同步按来源和专辑 ID 获取标准化专辑详情及曲目。""" - result = self.run_module( - "music_album", - media_source=media_source, - media_id=media_id, - ) - if isinstance(result, MusicAlbumInfo): - return result - if isinstance(result, dict): - return MusicAlbumInfo.from_dict(result) - return None - - async def async_album_related( - self, - media_source: str, - media_id: str, - count: int = 24, - ) -> list[MusicInfo]: - """异步读取指定来源的关联专辑,供专辑详情继续浏览。""" - candidates = await self.async_run_module( - "music_album_related", - media_source=media_source, - media_id=media_id, - count=count, - ) - return self.normalize_candidates(candidates, limit=count) - - def lyrics(self, music: MetaMusic | MusicInfo) -> Optional[MusicLyrics]: - """按单曲元数据调用已启用的歌词模块并返回标准歌词。""" - result = self.run_module("music_lyrics", music=music) - if isinstance(result, MusicLyrics): - return result - if isinstance(result, dict): - return MusicLyrics.from_dict(result) - return None - - async def async_artist(self, media_source: str, media_id: str) -> Optional[MusicArtistInfo]: - """异步按来源和艺术家 ID 获取标准化艺术家详情。""" - result = await self.async_run_module( - "music_artist", - media_source=media_source, - media_id=media_id, - ) - if isinstance(result, MusicArtistInfo): - return result - if isinstance(result, dict): - return MusicArtistInfo.from_dict(result) - return None - - async def async_artist_albums( - self, - media_source: str, - media_id: str, - page: int = 1, - count: int = 30, - album_type: Optional[str] = None, - ) -> list[MusicInfo]: - """异步分页读取艺术家名下的专辑、EP 和单曲。""" - candidates = await self.async_run_module( - "music_artist_albums", - media_source=media_source, - media_id=media_id, - page=page, - count=count, - album_type=album_type, - ) - return self.normalize_candidates(candidates, limit=count) - - async def async_artist_related( - self, - media_source: str, - media_id: str, - count: int = 24, - ) -> list[MusicArtistInfo]: - """异步读取关联艺术家,供详情页继续浏览。""" - candidates = await self.async_run_module( - "music_artist_related", - media_source=media_source, - media_id=media_id, - count=count, - ) - results: list[MusicArtistInfo] = [] - identities: set[str] = set() - for candidate in candidates or []: - info = ( - candidate - if isinstance(candidate, MusicArtistInfo) - else MusicArtistInfo.from_dict(candidate) - ) - identity = (info.media_id or info.name or "").casefold() - if not identity or identity in identities: - continue - identities.add(identity) - results.append(info) - return results[:count] - - @staticmethod - def _filter_candidates( - candidates: list[MusicInfo], - min_listen_count: int, - with_cover: bool, - ) -> list[MusicInfo]: - """按热度和封面条件过滤音乐探索候选。""" - results = candidates - if min_listen_count > 0: - results = [ - info for info in results - if (info.listen_count or 0) >= min_listen_count - ] - if with_cover: - results = [info for info in results if info.cover_url] - return list(results) - - @staticmethod - def _normalize_match_text(value: Optional[str]) -> str: - """移除大小写、空白和标点差异,生成站点标题匹配使用的紧凑文本。""" - return MetaMusic.compact_text(value) - - @staticmethod - def _validate_source_recognize_result( - result: Optional[MusicInfo], - media_source: str, - media_id: Optional[str], - music_type: Optional[str], - ) -> Optional[MusicInfo]: - """校验指定来源的识别结果,显式 ID 不允许被另一实体或另一身份替代。""" - if not isinstance(result, MusicInfo): - return None - if result.media_source and result.media_source != media_source: - return None - if music_type and result.music_type != music_type: - return None - if media_id and ( - not result.media_source - or not result.media_id - or str(result.media_id) != str(media_id) - ): - return None - return result - - def _recognize_from_source( - self, - meta: Optional[MetaMusic], - media_source: str, - cache: bool, - media_id: Optional[str] = None, - music_type: Optional[str] = None, - ) -> Optional[MusicInfo]: - """调用声明了指定音乐来源的系统模块,隔离单个来源的查询失败。""" - module = self._music_recognize_module(media_source) - if not module: - return None - try: - recognize_kwargs = { - "meta": meta, - "mtype": MediaType.MUSIC, - "media_source": media_source, - "media_id": media_id, - "cache": cache, - } - if music_type is not None: - recognize_kwargs["music_type"] = music_type - return module.recognize_media( - **recognize_kwargs, - ) - except Exception as err: - logger.warning(f"{media_source} 音乐自动识别失败:{err}") - return None - - async def _async_recognize_from_source( - self, - meta: Optional[MetaMusic], - media_source: str, - cache: bool, - media_id: Optional[str] = None, - music_type: Optional[str] = None, - ) -> Optional[MusicInfo]: - """异步调用指定音乐来源模块,单个来源失败不影响其它候选。""" - module = self._music_recognize_module(media_source) - if not module: - return None - try: - recognize_kwargs = { - "meta": meta, - "mtype": MediaType.MUSIC, - "media_source": media_source, - "media_id": media_id, - "cache": cache, - } - if music_type is not None: - recognize_kwargs["music_type"] = music_type - return await module.async_recognize_media(**recognize_kwargs) - except Exception as err: - logger.warning(f"{media_source} 音乐自动识别失败:{err}") - return None - - def _music_recognize_module(self, media_source: str) -> Optional[Any]: - """枚举运行中的系统模块并返回声明了指定音乐来源的实现。""" - for module in self.modulemanager.get_running_modules("recognize_media"): - get_music_source = getattr(module, "get_music_source", None) - if get_music_source and get_music_source() == media_source: - return module - return None - - def recognize_album_directory(self, path: str | Path) -> dict[str, MusicInfo]: - """按目录级线索批量识别整目录音频,返回 文件路径 到标准音乐信息的映射。 - - 适用于 WAV 无标签或标签不全的整专目录:先用目录名和文件标签构造专辑线索, - 再交给音乐元数据模块用曲目数、时长等特征对位到具体发行版本。 - """ - dir_path = Path(path) - if not dir_path.is_dir(): - return {} - files = self._directory_audio_files(dir_path) - if len(files) < self._album_match_min_files: - return {} - cache_key = str(dir_path) - signature = self._album_directory_signature(dir_path, files) - cached = self._album_dir_cache.get(cache_key) - # 新增、删除或重命名音频时重新匹配;标签写回不会改变相对路径签名。 - if cached and cached[0] == signature: - return cached[1] - matched = self._match_album_directory(dir_path, files) - if len(self._album_dir_cache) >= self._album_dir_cache_max: - self._album_dir_cache.clear() - self._album_dir_cache[cache_key] = (signature, matched) - return matched - - async def async_recognize_album_directory(self, path: str | Path) -> dict[str, MusicInfo]: - """异步按目录级线索批量识别整目录音频。""" - dir_path = Path(path) - if not dir_path.is_dir(): - return {} - files = await run_in_threadpool(self._directory_audio_files, dir_path) - if len(files) < self._album_match_min_files: - return {} - cache_key = str(dir_path) - signature = self._album_directory_signature(dir_path, files) - cached = self._album_dir_cache.get(cache_key) - if cached and cached[0] == signature: - return cached[1] - matched = await self._async_match_album_directory(dir_path, files) - if len(self._album_dir_cache) >= self._album_dir_cache_max: - self._album_dir_cache.clear() - self._album_dir_cache[cache_key] = (signature, matched) - return matched - - @classmethod - def _directory_audio_files(cls, dir_path: Path) -> list[Path]: - """收集目录及其一级子目录(如 CD1/CD2)内的音频文件。""" - audio_exts = settings.RMT_AUDIOEXT - files: list[Path] = [] - - def collect(current: Path) -> None: - try: - entries = sorted(current.iterdir()) - except OSError: - return - for entry in entries: - if entry.name.startswith("."): - continue - if entry.is_file() and entry.suffix.lower() in audio_exts: - files.append(entry) - - collect(dir_path) - try: - subdirs = sorted(entry for entry in dir_path.iterdir() - if entry.is_dir() and not entry.name.startswith(".")) - except OSError: - subdirs = [] - for subdir in subdirs: - collect(subdir) - return files - - @staticmethod - def _album_directory_signature(dir_path: Path, files: list[Path]) -> tuple[str, ...]: - """按相对文件路径生成专辑目录缓存签名,兼容多碟子目录。""" - return tuple( - str(file.relative_to(dir_path)).casefold() - for file in files - ) - - @classmethod - def read_path_meta(cls, path: Union[str, Path]) -> MetaMusic: - """读取本地音频标签,标签缺失时用文件名和目录线索补齐。""" - file_path = Path(path) - if file_path.exists() and file_path.is_file(): - return AudioMetadataHelper.read(file_path) - return AudioMetadataHelper.read_filename(file_path) - - @classmethod - def read_path_evidence( - cls, - path: Union[str, Path], - ) -> tuple[MetaMusic, Optional[MetaMusic], MetaMusic]: - """分别返回合并元数据、纯标签元数据和纯文件名元数据。""" - file_path = Path(path) - filename_meta = AudioMetadataHelper.read_filename(file_path) - tag_meta = None - if file_path.exists() and file_path.is_file(): - tag_meta = AudioMetadataHelper.read_tags(file_path) - if not tag_meta: - return filename_meta, None, filename_meta - merged_meta = MetaMusic.from_dict(tag_meta.to_dict()).apply_path_context(file_path) - return merged_meta, tag_meta, filename_meta - - def identify_by_fingerprint(self, path: Union[str, Path]) -> Optional[str]: - """调用音频指纹模块识别 MusicBrainz Recording ID。""" - result = self.run_module( - "identify_music_by_fingerprint", - path=Path(path), - ) - return str(result) if result else None - - async def async_identify_by_fingerprint( - self, - path: Union[str, Path], - ) -> Optional[str]: - """异步调用音频指纹模块识别 MusicBrainz Recording ID。""" - result = await self.async_run_module( - "async_identify_music_by_fingerprint", - path=Path(path), - ) - return str(result) if result else None - - def _match_album_directory( - self, - dir_path: Path, - files: list[Path], - ) -> dict[str, MusicInfo]: - """执行目录级专辑匹配,并把专辑曲目对位到具体音频文件。""" - # 标签读取属于音乐领域;MediaChain 只负责编排识别、刮削等跨领域流程。 - metas = [self.read_path_meta(file) for file in files] - album_meta = self._album_meta_from_context(dir_path, metas) - if not (album_meta.album or album_meta.title or album_meta.artists): - logger.debug(f"目录缺少专辑识别线索,跳过专辑匹配:{dir_path}") - return {} - candidates = self.run_module("match_music_album", meta=album_meta, tracks=metas) - candidate_items = candidates if isinstance(candidates, list) else [candidates] - album = next( - (item for item in candidate_items if isinstance(item, MusicAlbumInfo) and item.tracks), - None, - ) - if not album: - return {} - logger.info(f"目录 {dir_path.name} 匹配到专辑:{album.title_year}({album.media_source})") - matched: dict[str, MusicInfo] = {} - for file, info in self._align_album_tracks(files, metas, album.tracks).items(): - matched[str(file.resolve())] = info - return matched - - async def _async_match_album_directory( - self, - dir_path: Path, - files: list[Path], - ) -> dict[str, MusicInfo]: - """异步执行目录级专辑匹配,本地标签读取保持在线程池中。""" - metas = await run_in_threadpool(self._read_album_path_metas, files) - album_meta = self._album_meta_from_context(dir_path, metas) - if not (album_meta.album or album_meta.title or album_meta.artists): - logger.debug(f"目录缺少专辑识别线索,跳过专辑匹配:{dir_path}") - return {} - candidates = await self.async_run_module( - "async_match_music_album", - meta=album_meta, - tracks=metas, - ) - candidate_items = candidates if isinstance(candidates, list) else [candidates] - album = next( - (item for item in candidate_items if isinstance(item, MusicAlbumInfo) and item.tracks), - None, - ) - if not album: - return {} - logger.info(f"目录 {dir_path.name} 匹配到专辑:{album.title_year}({album.media_source})") - matched: dict[str, MusicInfo] = {} - for file, info in self._align_album_tracks(files, metas, album.tracks).items(): - matched[str(file.resolve())] = info - return matched - - @classmethod - def _read_album_path_metas(cls, files: list[Path]) -> list[MetaMusic]: - """批量读取专辑目录中的本地音频元数据。""" - return [cls.read_path_meta(file) for file in files] - - @classmethod - def _album_meta_from_context(cls, dir_path: Path, metas: list[MetaMusic]) -> MetaMusic: - """汇总目录名和文件标签中的专辑线索,作为专辑搜索条件。""" - dir_info = MetaMusic.parse_album_dir(dir_path.name) - # 文件标签中的专辑信息比目录名更可靠,多数文件一致时优先采用 - album_votes: dict[str, int] = {} - artist_votes: dict[str, int] = {} - for meta in metas: - if meta.album: - album_votes[meta.album] = album_votes.get(meta.album, 0) + 1 - if meta.album_artist: - artist_votes[meta.album_artist] = artist_votes.get(meta.album_artist, 0) + 1 - elif meta.artists: - artist_votes[meta.artists[0]] = artist_votes.get(meta.artists[0], 0) + 1 - majority_album = max(album_votes, key=album_votes.get) if album_votes else None - majority_artist = max(artist_votes, key=artist_votes.get) if artist_votes else None - # 多数文件共享同一专辑标签才可信,避免杂集目录的个别错误标签带偏搜索 - album = majority_album if majority_album and album_votes[majority_album] >= max(2, len(metas) // 2) else None - artist = majority_artist if majority_artist and artist_votes[majority_artist] >= max(2, - len(metas) // 2) else None - return MetaMusic( - org_string=dir_path.name, - title=album or dir_info.get("album") or dir_path.name, - album=album or dir_info.get("album"), - artists=[artist or dir_info.get("artist")] if (artist or dir_info.get("artist")) else [], - album_artist=artist or dir_info.get("artist"), - year=dir_info.get("year"), - ) - - @classmethod - def _align_album_tracks( - cls, - files: list[Path], - metas: list[MetaMusic], - tracks: list[MusicInfo], - ) -> dict[Path, MusicInfo]: - """把专辑曲目对位到目录内的音频文件。 - - 带曲序标签的文件按(碟号, 曲序)精确对位,其余文件按排序顺序依次补齐。 - """ - matched: dict[Path, MusicInfo] = {} - used_keys: set[tuple[int, int]] = set() - by_position: dict[tuple[int, int], MusicInfo] = {} - for track in tracks: - if track.track_number: - by_position[(track.disc_number or 1, track.track_number)] = track - pending: list[tuple[Path, MetaMusic]] = [] - for file, meta in zip(files, metas): - key = (meta.disc_number or 1, meta.track_number) - track = by_position.get(key) if meta.track_number else None - if track and key not in used_keys: - matched[file] = track - used_keys.add(key) - else: - pending.append((file, meta)) - if not pending: - return matched - remaining = [ - track for track in tracks - if (track.disc_number or 1, track.track_number or 0) not in used_keys - ] - # 无曲序线索的文件按碟号和文件名排序,与剩余曲目顺序对位 - pending.sort(key=lambda item: (item[1].disc_number or 1, item[0].name.casefold())) - for (file, _meta), track in zip(pending, remaining): - matched[file] = track - return matched - - @classmethod - def to_meta(cls, info: MusicInfo) -> MetaMusic: - """将用户选中的标准音乐信息转换为下载和整理上下文元数据。""" - return MetaMusic( - title=info.title, - artists=list(info.artists), - album=info.album, - album_artist=info.album_artist, - year=info.year, - disc_number=info.disc_number, - track_number=info.track_number, - total_tracks=info.total_tracks, - version=info.version, - audio_format=info.audio_format, - audio_lossless=info.audio_lossless, - bit_depth=info.bit_depth, - sample_rate=info.sample_rate, - bitrate=info.bitrate, - duration=info.duration, - isrc=info.isrc, - media_source=info.media_source, - media_id=info.media_id, - ) - - @classmethod - def _candidate_identity(cls, info: MusicInfo) -> tuple[str, ...]: - """构造跨来源稳定的候选去重键。""" - if info.media_source and info.media_id: - return "id", info.media_source.casefold(), info.music_type.casefold(), info.media_id.casefold() - return ( - "metadata", - info.music_type.casefold(), - cls._normalize_text(info.title).casefold(), - cls._normalize_text(info.artist).casefold(), - cls._normalize_text(info.album).casefold(), - ) - - @classmethod - def _unique_texts(cls, values: Iterable[Optional[str]]) -> list[str]: - """按规范化文本去重并保留原始顺序。""" - results = [] - seen = set() - for value in values: - normalized = cls._normalize_text(value) - identity = normalized.casefold() - if not normalized or identity in seen: - continue - seen.add(identity) - results.append(normalized) - return results - - @classmethod - def _normalize_text(cls, value: Optional[str]) -> str: - """清理音乐检索文本中的多余空白。""" - return re.sub(r"\s+", " ", str(value or "")).strip() diff --git a/app/chain/musicbrainz.py b/app/chain/musicbrainz.py new file mode 100644 index 000000000..049b1f37e --- /dev/null +++ b/app/chain/musicbrainz.py @@ -0,0 +1,290 @@ +from typing import Any, Optional + +from app.chain import ChainBase +from app.core.context import MusicAlbumInfo, MusicArtistInfo, MusicInfo +from app.core.meta import MetaMusic +from app.schemas.types import MediaSource, MediaType + + +class _MusicMetadataSourceChain(ChainBase): + """固定音乐元数据来源链的共用端口适配。""" + + source: MediaSource + + def search_music(self, meta: MetaMusic, limit: int = 20) -> list[MusicInfo]: + """按音乐元数据搜索当前来源候选。""" + result = self.run_module( + "search_music", + meta=meta, + limit=limit, + media_source=self.source, + ) + return self._music_infos(result, limit=limit) + + async def async_search_music(self, meta: MetaMusic, limit: int = 20) -> list[MusicInfo]: + """异步按音乐元数据搜索当前来源候选。""" + result = await self.async_run_module( + "search_music", + meta=meta, + limit=limit, + media_source=self.source, + ) + return self._music_infos(result, limit=limit) + + def recognize_music( + self, + meta: Optional[MetaMusic] = None, + media_id: Optional[str] = None, + cache: bool = True, + music_type: Optional[str] = None, + ) -> Optional[MusicInfo]: + """按当前来源身份或音乐元数据识别标准音乐信息。""" + normalized_id = self._normalize_media_id(media_id) + result = self.run_module( + "recognize_media", + meta=meta, + mtype=MediaType.MUSIC, + media_source=self.source, + media_id=normalized_id, + cache=cache, + music_type=music_type, + ) + return self._music_info(result, media_id=normalized_id) + + async def async_recognize_music( + self, + meta: Optional[MetaMusic] = None, + media_id: Optional[str] = None, + cache: bool = True, + music_type: Optional[str] = None, + ) -> Optional[MusicInfo]: + """异步按当前来源身份或音乐元数据识别标准音乐信息。""" + normalized_id = self._normalize_media_id(media_id) + result = await self.async_run_module( + "async_recognize_media", + meta=meta, + mtype=MediaType.MUSIC, + media_source=self.source, + media_id=normalized_id, + cache=cache, + music_type=music_type, + ) + return self._music_info(result, media_id=normalized_id) + + def get_music_album(self, media_id: str) -> Optional[MusicAlbumInfo]: + """按当前来源原生 ID 获取专辑详情。""" + normalized_id = self._normalize_media_id(media_id) + if not normalized_id: + return None + result = self.run_module( + "music_album", + media_source=self.source, + media_id=normalized_id, + ) + return self._music_album(result, media_id=normalized_id) + + async def async_get_music_album(self, media_id: str) -> Optional[MusicAlbumInfo]: + """异步按当前来源原生 ID 获取专辑详情。""" + normalized_id = self._normalize_media_id(media_id) + if not normalized_id: + return None + result = await self.async_run_module( + "music_album", + media_source=self.source, + media_id=normalized_id, + ) + return self._music_album(result, media_id=normalized_id) + + async def async_get_music_album_related( + self, + media_id: str, + count: int = 24, + ) -> list[MusicInfo]: + """异步获取当前来源的专辑关联条目。""" + normalized_id = self._normalize_media_id(media_id) + if not normalized_id: + return [] + result = await self.async_run_module( + "music_album_related", + media_source=self.source, + media_id=normalized_id, + count=count, + ) + return self._music_infos(result, limit=count) + + async def async_get_music_artist(self, media_id: str) -> Optional[MusicArtistInfo]: + """异步按当前来源原生 ID 获取艺术家详情。""" + normalized_id = self._normalize_media_id(media_id) + if not normalized_id: + return None + result = await self.async_run_module( + "music_artist", + media_source=self.source, + media_id=normalized_id, + ) + return self._music_artist(result, media_id=normalized_id) + + async def async_get_music_artist_albums( + self, + media_id: str, + page: int = 1, + count: int = 30, + album_type: Optional[str] = None, + ) -> list[MusicInfo]: + """异步分页获取当前来源艺术家的专辑目录。""" + normalized_id = self._normalize_media_id(media_id) + if not normalized_id: + return [] + result = await self.async_run_module( + "music_artist_albums", + media_source=self.source, + media_id=normalized_id, + page=page, + count=count, + album_type=album_type, + ) + return self._music_infos(result, limit=count) + + async def async_get_music_artist_related( + self, + media_id: str, + count: int = 24, + ) -> list[MusicArtistInfo]: + """异步获取当前来源艺术家的关联艺术家。""" + normalized_id = self._normalize_media_id(media_id) + if not normalized_id: + return [] + result = await self.async_run_module( + "music_artist_related", + media_source=self.source, + media_id=normalized_id, + count=count, + ) + return self._music_artists(result, limit=count) + + @staticmethod + def _normalize_media_id(media_id: Optional[str]) -> Optional[str]: + """清理来源原生 ID,空值和历史零哨兵按无身份处理。""" + normalized = str(media_id).strip() if media_id is not None else "" + return normalized if normalized and normalized != "0" else None + + @classmethod + def _music_infos(cls, result: Any, limit: Optional[int] = None) -> list[MusicInfo]: + """将模块或插件结果统一转换为音乐候选列表。""" + candidates = result if isinstance(result, list) else [] + infos = [ + item if isinstance(item, MusicInfo) else MusicInfo.from_dict(item) + for item in candidates + if isinstance(item, (MusicInfo, dict)) + ] + infos = [info for info in infos if info.media_source == cls.source] + return infos[:limit] if limit else infos + + @classmethod + def _music_info( + cls, + result: Any, + media_id: Optional[str] = None, + ) -> Optional[MusicInfo]: + """校验单条识别结果的来源与显式身份。""" + if isinstance(result, MusicInfo): + info = result + elif isinstance(result, dict): + info = MusicInfo.from_dict(result) + else: + return None + if info.media_source and info.media_source != cls.source: + return None + if media_id and (info.media_source != cls.source or info.media_id != media_id): + return None + return info + + @classmethod + def _music_album( + cls, + result: Any, + media_id: Optional[str] = None, + ) -> Optional[MusicAlbumInfo]: + """将模块或插件结果统一转换为专辑详情。""" + if isinstance(result, MusicAlbumInfo): + album = result + elif isinstance(result, dict): + album = MusicAlbumInfo.from_dict(result) + else: + return None + if album.media_source != cls.source: + return None + if media_id and album.media_id != media_id: + return None + return album + + @classmethod + def _music_artist( + cls, + result: Any, + media_id: Optional[str] = None, + ) -> Optional[MusicArtistInfo]: + """将模块或插件结果统一转换为艺术家详情。""" + if isinstance(result, MusicArtistInfo): + artist = result + elif isinstance(result, dict): + artist = MusicArtistInfo.from_dict(result) + else: + return None + if artist.media_source != cls.source: + return None + if media_id and artist.media_id != media_id: + return None + return artist + + @classmethod + def _music_artists( + cls, + result: Any, + limit: Optional[int] = None, + ) -> list[MusicArtistInfo]: + """将模块或插件结果统一转换为艺术家列表。""" + candidates = result if isinstance(result, list) else [] + artists = [ + item if isinstance(item, MusicArtistInfo) else MusicArtistInfo.from_dict(item) + for item in candidates + if isinstance(item, (MusicArtistInfo, dict)) + ] + artists = [artist for artist in artists if artist.media_source == cls.source] + return artists[:limit] if limit else artists + + +class MusicBrainzChain(_MusicMetadataSourceChain): + """MusicBrainz 音乐搜索、识别与详情来源链。""" + + source = MediaSource.MusicBrainz + + def match_music_album( + self, + meta: MetaMusic, + tracks: list[MetaMusic], + limit: int = 5, + ) -> Optional[MusicAlbumInfo]: + """按目录元数据与曲目证据匹配 MusicBrainz 发行版本。""" + result = self.run_module( + "match_music_album", + meta=meta, + tracks=tracks, + limit=limit, + ) + return self._music_album(result) + + async def async_match_music_album( + self, + meta: MetaMusic, + tracks: list[MetaMusic], + limit: int = 5, + ) -> Optional[MusicAlbumInfo]: + """异步按目录元数据与曲目证据匹配 MusicBrainz 发行版本。""" + result = await self.async_run_module( + "async_match_music_album", + meta=meta, + tracks=tracks, + limit=limit, + ) + return self._music_album(result) diff --git a/app/chain/recommend.py b/app/chain/recommend.py index 250a7f002..8128edaaf 100644 --- a/app/chain/recommend.py +++ b/app/chain/recommend.py @@ -5,15 +5,21 @@ import pillow_avif # noqa 用于自动注册AVIF支持 from app.chain import ChainBase from app.chain.bangumi import BangumiChain from app.chain.douban import DoubanChain -from app.chain.music import MusicChain +from app.chain.listenbrainz import ListenBrainzChain from app.chain.tmdb import TmdbChain from app.core.cache import cached, fresh from app.core.config import settings, global_vars +from app.core.context import MusicInfo from app.helper.image import ImageHelper from app.log import logger from app.schemas import MediaType -from app.schemas.types import MUSIC_ENTITY_ALBUM +from app.schemas.types import ( + MUSIC_ENTITY_ALBUM, + MUSIC_ENTITY_RECORDING, + MediaSource, +) from app.utils.common import log_execution_time +from app.utils.media import normalize_media_source from app.utils.singleton import Singleton @@ -29,6 +35,146 @@ class RecommendChain(ChainBase, metaclass=Singleton): # 推荐缓存区域 recommend_cache_region = "recommend" + def music_chart( + self, + range_name: str, + page: int = 1, + count: int = 30, + sort_by: str = "listen_count.desc", + min_listen_count: int = 0, + with_cover: bool = False, + entity: str = MUSIC_ENTITY_RECORDING, + ) -> list[MusicInfo]: + """读取 ListenBrainz 音乐榜单并应用推荐筛选与排序。""" + results = ListenBrainzChain().music_chart( + range_name=range_name, + page=page, + count=count, + entity=entity, + ) + return self._filter_music_candidates( + results, + count=count, + sort_by=sort_by, + min_listen_count=min_listen_count, + with_cover=with_cover, + ) + + async def async_music_chart( + self, + range_name: str, + page: int = 1, + count: int = 30, + sort_by: str = "listen_count.desc", + min_listen_count: int = 0, + with_cover: bool = False, + entity: str = MUSIC_ENTITY_RECORDING, + ) -> list[MusicInfo]: + """异步读取 ListenBrainz 音乐榜单并应用推荐筛选与排序。""" + results = await ListenBrainzChain().async_music_chart( + range_name=range_name, + page=page, + count=count, + entity=entity, + ) + return self._filter_music_candidates( + results, + count=count, + sort_by=sort_by, + min_listen_count=min_listen_count, + with_cover=with_cover, + ) + + async def async_music_fresh_releases( + self, + days: int = 14, + sort: str = "release_date", + past: bool = True, + future: bool = True, + page: int = 1, + count: int = 30, + with_cover: bool = False, + ) -> list[MusicInfo]: + """异步读取 ListenBrainz 新发行专辑并应用封面筛选。""" + results = await ListenBrainzChain().async_music_fresh_releases( + days=days, + sort=sort, + past=past, + future=future, + page=page, + count=count, + ) + return self._filter_music_candidates( + results, + count=count, + with_cover=with_cover, + ) + + def music_discover( + self, + media_source: MediaSource, + page: int = 1, + count: int = 30, + entity: str = MUSIC_ENTITY_ALBUM, + mode: str = "chart", + tags: str = "", + sort: str = "U", + ) -> list[MusicInfo]: + """按固定音乐来源读取发现内容,当前支持豆瓣音乐。""" + if normalize_media_source(media_source) != MediaSource.DoubanMusic: + return [] + return DoubanChain().music_discover( + page=page, + count=count, + entity=entity, + mode=mode, + tags=tags, + sort=sort, + ) + + async def async_music_discover( + self, + media_source: MediaSource, + page: int = 1, + count: int = 30, + entity: str = MUSIC_ENTITY_ALBUM, + mode: str = "chart", + tags: str = "", + sort: str = "U", + ) -> list[MusicInfo]: + """异步按固定音乐来源读取发现内容,当前支持豆瓣音乐。""" + if normalize_media_source(media_source) != MediaSource.DoubanMusic: + return [] + return await DoubanChain().async_music_discover( + page=page, + count=count, + entity=entity, + mode=mode, + tags=tags, + sort=sort, + ) + + @staticmethod + def _filter_music_candidates( + candidates: list[MusicInfo], + count: int, + sort_by: Optional[str] = None, + min_listen_count: int = 0, + with_cover: bool = False, + ) -> list[MusicInfo]: + """按热度和封面条件筛选音乐推荐,并限制返回数量。""" + results = [ + info for info in candidates + if (info.listen_count or 0) >= max(0, min_listen_count) + and (not with_cover or bool(info.cover_url)) + ] + if sort_by: + results.sort( + key=lambda info: info.listen_count or 0, + reverse=sort_by != "listen_count.asc", + ) + return results[:max(1, count)] + def refresh_recommend( self, manual: bool = False, @@ -182,7 +328,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) def music_weekly(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """返回 ListenBrainz 本周全站热门音乐。""" - medias = MusicChain().chart( + medias = self.music_chart( range_name="this_week", page=page or 1, count=count or 30, @@ -197,8 +343,8 @@ class RecommendChain(ChainBase, metaclass=Singleton): count: Optional[int] = 30, ) -> List[dict]: """返回豆瓣音乐官方新碟榜。""" - medias = MusicChain().discover( - media_source="doubanmusic", + medias = self.music_discover( + media_source=MediaSource.DoubanMusic, page=page or 1, count=count or 30, entity=MUSIC_ENTITY_ALBUM, @@ -427,7 +573,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) async def async_music_weekly(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: """异步返回 ListenBrainz 本周全站热门音乐。""" - medias = await MusicChain().async_chart( + medias = await self.async_music_chart( range_name="this_week", page=page or 1, count=count or 30, @@ -442,8 +588,8 @@ class RecommendChain(ChainBase, metaclass=Singleton): count: Optional[int] = 30, ) -> List[dict]: """异步返回豆瓣音乐官方新碟榜。""" - medias = await MusicChain().async_discover( - media_source="doubanmusic", + medias = await self.async_music_discover( + media_source=MediaSource.DoubanMusic, page=page or 1, count=count or 30, entity=MUSIC_ENTITY_ALBUM, diff --git a/app/chain/scraping.py b/app/chain/scraping.py new file mode 100644 index 000000000..668b06f02 --- /dev/null +++ b/app/chain/scraping.py @@ -0,0 +1,1886 @@ +import os +import re +from copy import deepcopy +from dataclasses import dataclass +from pathlib import Path +from tempfile import NamedTemporaryFile, TemporaryDirectory +from threading import Lock +from typing import Any, Iterable, List, Optional, Tuple, Union + +from fastapi.concurrency import run_in_threadpool + +from app import schemas +from app.chain import ChainBase +from app.chain.lrclib import LrclibChain +from app.chain.storage import StorageChain +from app.core.cache import async_fresh, cached, fresh +from app.core.config import settings +from app.core.context import ( + Context, + MediaInfo, + MusicAlbumInfo, + MusicInfo, + MusicLyrics, +) +from app.core.event import eventmanager, Event +from app.core.meta import MetaBase, MetaMusic +from app.core.metainfo import MetaInfo, MetaInfoPath +from app.db.systemconfig_oper import SystemConfigOper +from app.helper.audio import AudioMetadataHelper +from app.log import logger +from app.schemas import FileItem +from app.schemas.types import ( + MUSIC_ENTITY_ALBUM, + MUSIC_ENTITY_RECORDING, + ChainEventType, + EventType, + MediaSource, + MediaType, + ScrapingTarget, + ScrapingMetadata, + ScrapingPolicy, + SystemConfigKey, +) +from app.utils.http import RequestUtils +from app.utils.media import ( + is_music_media_source, + normalize_media_source, + resolve_media_identity, +) +from app.utils.mixins import ConfigReloadMixin +from app.utils.singleton import Singleton +from app.utils.string import StringUtils + + +from app.chain.media import MediaChain + +scraping_lock = Lock() + +current_umask = os.umask(0) +os.umask(current_umask) + + +@dataclass +class _MusicScrapeFileResult: + """记录单个音轨的标签刮削结果和歌词处理状态。""" + + metadata_success: bool = True + lyrics_status: str = "disabled" + +class ScrapingOption: + """刮削选项""" + + type: ScrapingTarget = ScrapingTarget.TV + metadata: ScrapingMetadata = ScrapingMetadata.NFO + policy: ScrapingPolicy = ScrapingPolicy.MISSINGONLY + + def __init__( + self, + type: Union[str, ScrapingTarget], + metadata: Union[str, ScrapingMetadata], + value: Union[ScrapingPolicy, bool, str], + ): + if isinstance(type, ScrapingTarget): + self.type = type + elif isinstance(type, str): + self.type = ScrapingTarget(type) + if isinstance(metadata, ScrapingMetadata): + self.metadata = metadata + elif isinstance(metadata, str): + self.metadata = ScrapingMetadata(metadata) + if isinstance(value, bool): + # 兼容旧的布尔值格式 + self.policy = ScrapingPolicy.MISSINGONLY if value else ScrapingPolicy.SKIP + elif isinstance(value, ScrapingPolicy): + self.policy = value + elif isinstance(value, str): + self.policy = ScrapingPolicy(value) + else: + logger.error( + f"无效的刮削选项:type={type}, metadata={metadata}, value={value}" + ) + + @property + def is_skip(self) -> bool: + """是否跳过""" + return self.policy == ScrapingPolicy.SKIP + + @property + def is_overwrite(self) -> bool: + """是否覆盖模式""" + return self.policy == ScrapingPolicy.OVERWRITE + +class ScrapingConfig: + """媒体刮削配置""" + + def __init__(self, config_dict: dict[str, str] = None): + """ + 初始化配置对象 + :param config_dict: 用户配置字典(扁平化格式),为 None 时使用默认配置 + """ + self._policies: dict[tuple[str, str], ScrapingOption] = {} + # 合并用户配置和默认配置 + if config_dict is None: + config_dict = {} + + # 以默认配置为基础,用用户配置覆盖 + _config = self.get_default_config() + for key, value in config_dict.items(): + _config[key] = value + + for key, value in _config.items(): + if "_" in key: + items = key.split("_", 1) + self._policies[tuple(items)] = ScrapingOption(*items, value) + + def option( + self, item: Union[str, ScrapingTarget], metadata: Union[str, ScrapingMetadata] + ) -> ScrapingOption: + + if isinstance(item, ScrapingTarget): + item = item.name.lower() + if isinstance(metadata, ScrapingMetadata): + metadata = metadata.name.lower() + + return self._policies.get( + (item, metadata), ScrapingOption(item, metadata, ScrapingPolicy.SKIP) + ) + + @classmethod + def from_system_config(cls) -> "ScrapingConfig": + """ + 从系统配置加载 + + :return: MediaScrapingConfig 实例 + """ + user_config = SystemConfigOper().get(SystemConfigKey.ScrapingSwitchs) or {} + return cls(user_config) + + @staticmethod + def get_default_config() -> dict[str, str]: + """获取默认配置字典""" + config_items = [ + f"{mt}_{md}" + for mt, mds in [ + ( + "movie", + ["nfo", "poster", "backdrop", "logo", "disc", "banner", "thumb", "clearart", "landscape"], + ), + ("tv", ["nfo", "poster", "backdrop", "logo", "banner", "thumb", "clearart", "landscape"]), + ("season", ["nfo", "poster", "backdrop", "banner", "thumb", "landscape"]), + ("episode", ["nfo", "thumb"]), + ("music", ["nfo", "poster", "lyrics"]), + ] + for md in mds + ] + return {item: ScrapingPolicy.MISSINGONLY for item in config_items} + + +class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): + """统一处理影视和音乐元数据刮削,不承担媒体识别职责。""" + + CONFIG_WATCH = {SystemConfigKey.ScrapingSwitchs.value} + + IMAGE_METADATA_MAP = { + "poster": ScrapingMetadata.POSTER, + "backdrop": ScrapingMetadata.BACKDROP, + "fanart": ScrapingMetadata.BACKDROP, + "background": ScrapingMetadata.BACKDROP, + "logo": ScrapingMetadata.LOGO, + "disc": ScrapingMetadata.DISC, + "cdart": ScrapingMetadata.DISC, + "banner": ScrapingMetadata.BANNER, + "thumb": ScrapingMetadata.THUMB, + "landscape": ScrapingMetadata.LANDSCAPE, + "clearart": ScrapingMetadata.CLEARART, + } + + IMAGE_ALIASES = { + "backdrop": ["fanart"], + "fanart": ["backdrop"], + "thumb": ["landscape"], + "landscape": ["thumb"], + } + + MUSIC_LYRICS_EXTENSIONS = (".lrc", ".txt") + _music_track_prefix_pattern = re.compile( + r"^\s*(?:(?:cd|disc)\s*\d+\s*[-_. ]+)?(?:\d+\s*[-_. ]+)+", + flags=re.IGNORECASE, + ) + + def __init__(self): + """初始化存储访问和当前刮削策略。""" + super().__init__() + self.storagechain = StorageChain() + self.scraping_policies = ScrapingConfig.from_system_config() + + def on_config_changed(self): + self.scraping_policies = ScrapingConfig.from_system_config() + + @staticmethod + def _cleanup_temp_file(path: Optional[Path]): + """ + 清理临时刮削文件 + + :param path: 临时文件路径 + """ + if not path or not path.exists(): + return + try: + path.unlink() + except OSError as err: + logger.warn(f"临时文件清理失败:{path} - {err}") + + @staticmethod + def _should_scrape( + scraping_option: ScrapingOption, + file_exists: bool, + global_overwrite: bool = False, + ) -> bool: + """ + 判断是否应该执行刮削操作 + + :param scraping_option: 刮削选项对象 + :param file_exists: 文件是否已存在 + :param global_overwrite: 全局覆盖标志 + :return bool: 是否应该刮削 + """ + if scraping_option.is_skip: + logger.info( + f"{scraping_option.type.value} {scraping_option.metadata.value} 刮削策略 {scraping_option.policy.value}" + ) + return False + + if not file_exists: + # 文件不存在 + return True + + # 文件存在的情况 + if scraping_option.is_overwrite or global_overwrite: + logger.info( + f"{scraping_option.type.value} {scraping_option.metadata.value} 文件存在," + f"{'配置为覆盖' if scraping_option.is_overwrite else '配置为全局覆盖'}" + ) + return True + else: + logger.info( + f"{scraping_option.type.value} {scraping_option.metadata.value} 文件已存在,跳过" + ) + return False + + def _save_file( + self, fileitem: schemas.FileItem, path: Path, content: Union[bytes, str] + ): + """ + 保存或上传文件 + + :param fileitem: 关联的媒体文件项 + :param path: 元数据文件路径 + :param content: 文件内容 + """ + if not fileitem or not content or not path: + return + tmp_file_path = None + try: + # delete_on_close 是 Python 3.12 才支持的参数,使用 delete=False 后手动清理以兼容低版本。 + with NamedTemporaryFile(delete=False, suffix=path.suffix) as tmp_file: + tmp_file_path = Path(tmp_file.name) + # 写入内容 + if isinstance(content, bytes): + tmp_file.write(content) + else: + tmp_file.write(content.encode("utf-8")) + tmp_file.flush() + + # 刮削文件只需要读写权限 + tmp_file_path.chmod(0o666 & ~current_umask) + + # 上传文件 + item = self.storagechain.upload_file( + fileitem=fileitem, path=tmp_file_path, new_name=path.name + ) + if item: + logger.info(f"已保存文件:{item.path}") + else: + logger.warn(f"文件保存失败:{path}") + finally: + self._cleanup_temp_file(tmp_file_path) + + def _download_and_save_image( + self, fileitem: schemas.FileItem, path: Path, url: str + ): + """ + 流式下载图片并保存到文件 + + :param fileitem: 关联的媒体文件项 + :param path: 图片文件路径 + :param url: 图片下载URL + """ + if not fileitem or not url or not path: + return + try: + logger.info(f"正在下载图片:{url} ...") + request_utils = RequestUtils( + proxies=settings.PROXY, ua=settings.NORMAL_USER_AGENT + ) + with request_utils.get_stream(url=url) as r: + if r and r.status_code == 200: + tmp_file_path = None + try: + # delete_on_close 是 Python 3.12 才支持的参数,使用 delete=False 后手动清理以兼容低版本。 + with NamedTemporaryFile(delete=False, suffix=path.suffix) as tmp_file: + tmp_file_path = Path(tmp_file.name) + # 流式写入文件 + for chunk in r.iter_content(chunk_size=8192): + if chunk: + tmp_file.write(chunk) + tmp_file.flush() + + # 刮削的图片只需要读写权限 + tmp_file_path.chmod(0o666 & ~current_umask) + + # 上传文件 + item = self.storagechain.upload_file( + fileitem=fileitem, path=tmp_file_path, new_name=path.name + ) + if item: + logger.info(f"已保存图片:{item.path}") + else: + logger.warn(f"图片保存失败:{path}") + finally: + self._cleanup_temp_file(tmp_file_path) + else: + logger.info(f"{url} 图片下载失败") + except Exception as err: + logger.error(f"{url} 图片下载失败:{str(err)}!") + + def _get_target_fileitem_and_path( + self, + current_fileitem: schemas.FileItem, + item_type: ScrapingTarget, + metadata_type: ScrapingMetadata, + filename_hint: Optional[str] = None, + parent_fileitem: Optional[schemas.FileItem] = None, + ) -> Tuple[schemas.FileItem, Optional[Path]]: + """ + 根据当前上下文、刮削项类型和元数据类型生成目标 FileItem 和 Path + 处理 NFO 和图片文件的命名约定及存储位置 + """ + # 默认保存的目录是当前文件项的目录 + target_dir_item = current_fileitem + target_dir_path = Path(current_fileitem.path) + final_filename = filename_hint # 如果提供了 filename_hint,优先使用 + + # 针对 NFO 文件的特殊命名和存储逻辑 + if metadata_type == ScrapingMetadata.NFO: + if item_type == ScrapingTarget.MOVIE: + if current_fileitem.type == "file": + # 电影文件NFO: 放在电影文件同级目录,名称与电影文件主体一致,后缀.nfo + final_filename = f"{target_dir_path.stem}.nfo" + target_dir_item = ( + parent_fileitem + or self.storagechain.get_parent_item(current_fileitem) + ) + if not target_dir_item: + logger.error( + f"无法获取文件 {current_fileitem.path} 的父目录项。" + ) + return ( + current_fileitem, + None, + ) # 返回一个表示失败的FileItem和None + target_dir_path = Path(target_dir_item.path) + else: # current_fileitem.type == "dir" + # 电影目录NFO (例如蓝光原盘): 放在电影目录内,名称与目录名主体一致,后缀.nfo + final_filename = f"{target_dir_path.name}.nfo" + # target_dir_item 保持为 current_fileitem + # target_dir_path 保持为 Path(current_fileitem.path) + elif item_type == ScrapingTarget.TV: + # 电视剧根目录NFO: 放在剧集根目录内,命名为 tvshow.nfo + final_filename = "tvshow.nfo" + elif item_type == ScrapingTarget.SEASON: + # 电视剧季目录NFO: 放在季目录内,命名为 season.nfo + final_filename = "season.nfo" + elif item_type == ScrapingTarget.EPISODE: + # 电视剧集文件NFO: 放在集文件同级目录,名称与集文件主体一致,后缀.nfo + final_filename = f"{target_dir_path.stem}.nfo" + target_dir_item = parent_fileitem or self.storagechain.get_parent_item( + current_fileitem + ) + if not target_dir_item: + logger.error(f"无法获取文件 {current_fileitem.path} 的父目录项。") + return current_fileitem, None # 返回一个表示失败的FileItem和None + target_dir_path = Path(target_dir_item.path) + # 图片通常是放在当前目录 (current_fileitem) 下 + # Jellyfin/Kodi 等在季目录内使用通用图片名,而不是 season01-poster.jpg + elif item_type == ScrapingTarget.SEASON: + season_image_name_map = { + ScrapingMetadata.POSTER: "poster", + ScrapingMetadata.BANNER: "banner", + ScrapingMetadata.THUMB: "thumb", + ScrapingMetadata.BACKDROP: "backdrop", + ScrapingMetadata.LANDSCAPE: "landscape", + } + if season_image_name := season_image_name_map.get(metadata_type): + hint_ext = Path(filename_hint).suffix if filename_hint else ".jpg" + final_filename = f"{season_image_name}{hint_ext}" + elif item_type == ScrapingTarget.MOVIE and current_fileitem.type == "file": + # 电影文件的图片应与视频文件同级保存,避免把图片路径拼到文件名下面。 + target_dir_item = parent_fileitem or self.storagechain.get_parent_item( + current_fileitem + ) + if not target_dir_item: + logger.error(f"无法获取文件 {current_fileitem.path} 的父目录项。") + return current_fileitem, None + target_dir_path = Path(target_dir_item.path) + # 如果是 EPISODE 类型的图片(如thumb),通常也是放在文件同级目录,文件名与视频文件一致 + elif ( + metadata_type in [ScrapingMetadata.THUMB] + and item_type == ScrapingTarget.EPISODE + ): + hint_ext = Path(filename_hint).suffix if filename_hint else ".jpg" + final_filename = f"{target_dir_path.stem}{hint_ext}" + target_dir_item = parent_fileitem or self.storagechain.get_parent_item( + current_fileitem + ) + if not target_dir_item: + logger.error(f"无法获取文件 {current_fileitem.path} 的父目录项。") + return current_fileitem, None # 返回一个表示失败的FileItem和None + target_dir_path = Path(target_dir_item.path) + # TODO: 考虑其他图片类型是否也需要保存到父目录 + + # 确保最终有文件名 + if not final_filename: + logger.error( + f"无法为 {item_type.value} - {metadata_type.value} 确定文件名。filename_hint: {filename_hint}" + ) + # 返回一个表示失败的FileItem和None + return current_fileitem, None + + target_full_path = target_dir_path / final_filename + return target_dir_item, target_full_path + + def _get_target_fileitems_and_paths( + self, + current_fileitem: schemas.FileItem, + item_type: ScrapingTarget, + metadata_type: ScrapingMetadata, + filename_hint: Optional[str] = None, + parent_fileitem: Optional[schemas.FileItem] = None, + ) -> List[Tuple[schemas.FileItem, Path]]: + """ + 根据刮削上下文生成一个或多个保存目标。 + 季图片需要同时兼容根目录 seasonxx-poster 和季目录 poster 两种命名。 + """ + target_item, target_path = self._get_target_fileitem_and_path( + current_fileitem=current_fileitem, + item_type=item_type, + metadata_type=metadata_type, + filename_hint=filename_hint, + parent_fileitem=parent_fileitem, + ) + targets = [(target_item, target_path)] if target_path else [] + + if ( + item_type != ScrapingTarget.SEASON + or not filename_hint + or not filename_hint.lower().startswith("season") + or metadata_type not in { + ScrapingMetadata.POSTER, + ScrapingMetadata.BANNER, + ScrapingMetadata.THUMB, + ScrapingMetadata.BACKDROP, + ScrapingMetadata.LANDSCAPE, + } + ): + return targets + + season_parent_item = parent_fileitem or self.storagechain.get_parent_item( + current_fileitem + ) + if not season_parent_item: + logger.warn(f"无法获取季目录 {current_fileitem.path} 的父目录项,跳过根目录季图片") + return targets + + season_root_path = Path(current_fileitem.path).with_name(filename_hint) + root_target = (season_parent_item, season_root_path) + if root_target not in targets: + targets.insert(0, root_target) + return targets + + def _expand_with_aliases( + self, + targets: List[Tuple[schemas.FileItem, Path]], + item_type: ScrapingTarget, + ) -> List[Tuple[schemas.FileItem, Path]]: + """ + 为兼容多媒体服务器,扩展图片保存目标列表,添加别名文件。 + 例如 backdrop.jpg 同时保存为 fanart.jpg,thumb.jpg 同时保存为 landscape.jpg。 + """ + expanded = list(targets) + for base_item, image_path in list(targets): + if not image_path: + continue + stem = image_path.stem.lower() + ext = image_path.suffix + # 跳过 season 前缀文件(如 season01-poster.jpg) + if stem.startswith("season"): + continue + aliases = self.IMAGE_ALIASES.get(stem) + if not aliases: + continue + for alias in aliases: + alias_meta_type = self.IMAGE_METADATA_MAP.get(alias) + if alias_meta_type: + alias_option = self.scraping_policies.option(item_type, alias_meta_type) + if alias_option.is_skip: + continue + alias_path = image_path.with_name(f"{alias}{ext}") + alias_target = (base_item, alias_path) + if alias_target not in expanded: + expanded.append(alias_target) + return expanded + + def metadata_nfo( + self, + meta: MetaBase, + mediainfo: MediaInfo, + season: Optional[int] = None, + episode: Optional[int] = None, + ) -> Optional[str]: + """ + 获取NFO文件内容文本 + + :param meta: 元数据 + :param mediainfo: 媒体信息 + :param season: 季号 + :param episode: 集号 + """ + return self.run_module( + "metadata_nfo", + meta=meta, + mediainfo=mediainfo, + season=season, + episode=episode, + ) + + def metadata_img( + self, + mediainfo: MediaInfo, + season: Optional[int] = None, + episode: Optional[int] = None, + ) -> Optional[dict]: + """ + 获取图片名称和url,合并所有模块的结果。 + 优先使用高优先级模块的图片,低优先级模块补充缺失的图片类型。 + """ + merged = {} + for module in sorted( + self.modulemanager.get_running_modules("metadata_img"), + key=lambda x: x.get_priority(), + ): + try: + result = module.metadata_img( + mediainfo=mediainfo, season=season, episode=episode + ) + if result and isinstance(result, dict): + for name, url in result.items(): + merged.setdefault(name, url) + except Exception as err: + logger.error(f"获取 {module.get_name()} 图片失败:{str(err)}") + return merged or None + + @eventmanager.register(EventType.MetadataScrape) + def scrape_metadata_event(self, event: Event): + """ + 监控手动刮削事件 + """ + if not event: + return + event_data = event.event_data or {} + # 媒体根目录 + fileitem: FileItem = event_data.get("fileitem") + # 媒体文件列表 + file_list: List[str] = list(dict.fromkeys(event_data.get("file_list") or [])) + # 媒体元数据 + meta: MetaBase = event_data.get("meta") + # 媒体信息 + mediainfo: MediaInfo = event_data.get("mediainfo") + # 是否覆盖 + overwrite = event_data.get("overwrite", False) + # 检查媒体根目录 + if not fileitem: + return + + # 刮削锁 + with scraping_lock: + # 音乐刮削与影视共用 MediaChain 入口,按 ScrapingConfig 的音乐项写入标签与封面 + if getattr(mediainfo, "type", None) == MediaType.MUSIC: + scrape_kwargs: dict[str, Any] = {} + if file_list: + scrape_kwargs["audio_files"] = self._music_event_audio_fileitems( + root=fileitem, + file_list=file_list, + ) + scrape_kwargs["media_by_path"] = { + Path(context.get("path")).as_posix(): context.get("mediainfo") + for context in event_data.get("file_contexts") or [] + if ( + isinstance(context, dict) + and context.get("path") + and isinstance(context.get("mediainfo"), MusicInfo) + ) + } + _, message = self.scrape_metadata( + fileitem=fileitem, + mediainfo=mediainfo, + overwrite=overwrite, + **scrape_kwargs, + ) + if message: + logger.info(f"音乐刮削:{message}") + return + # 检查文件项是否存在 + if not self.storagechain.get_item(fileitem): + logger.warn(f"文件项不存在:{fileitem.path}") + return + # 检查是否为目录 + if fileitem.type == "file": + # 单个文件刮削 + self.scrape_metadata( + fileitem=fileitem, + mediainfo=mediainfo, + init_folder=True, + parent=self.storagechain.get_parent_item(fileitem), + overwrite=overwrite, + ) + else: + if file_list: + # 如果是BDMV原盘目录,只对根目录进行刮削,不处理子目录 + if self.storagechain.is_bluray_folder(fileitem): + logger.info( + f"检测到BDMV原盘目录,只对根目录进行刮削:{fileitem.path}" + ) + self.scrape_metadata( + fileitem=fileitem, + mediainfo=mediainfo, + init_folder=True, + recursive=False, + overwrite=overwrite, + ) + else: + # 1. 收集fileitem和file_list中每个文件之间所有子目录 + all_dirs: set[Path] = set() + root_path = Path(fileitem.path) + + logger.debug(f"开始收集目录,根目录:{root_path}") + # 收集根目录 + all_dirs.add(root_path) + + # 收集所有目录(包括所有层级) + for sub_file in file_list: + sub_path = Path(sub_file) + # 收集从根目录到文件的所有父目录 + current_path = sub_path.parent + while ( + current_path != root_path + and current_path.is_relative_to(root_path) + ): + all_dirs.add(current_path) + current_path = current_path.parent + + logger.debug(f"共收集到 {len(all_dirs)} 个目录") + + # 2. 初始化一遍子目录,但不处理文件 + for sub_dir in sorted( + all_dirs, + key=lambda item: (len(item.parts), item.as_posix()), + ): + sub_dir_item = self.storagechain.get_file_item( + storage=fileitem.storage, path=sub_dir + ) + if sub_dir_item: + logger.info(f"为目录生成海报和nfo:{sub_dir}") + # 初始化目录元数据,但不处理文件 + self.scrape_metadata( + fileitem=sub_dir_item, + mediainfo=mediainfo, + init_folder=True, + recursive=False, + overwrite=overwrite, + ) + else: + logger.warn(f"无法获取目录项:{sub_dir}") + + # 3. 刮削每个文件 + logger.info(f"开始刮削 {len(file_list)} 个文件") + for sub_file_path in sorted(file_list): + sub_file_item = self.storagechain.get_file_item( + storage=fileitem.storage, path=Path(sub_file_path) + ) + if sub_file_item: + self.scrape_metadata( + fileitem=sub_file_item, + mediainfo=mediainfo, + init_folder=False, + overwrite=overwrite, + ) + else: + logger.warn(f"无法获取文件项:{sub_file_path}") + else: + # 执行全量刮削 + logger.info(f"开始刮削目录 {fileitem.path} ...") + self.scrape_metadata( + fileitem=fileitem, + meta=meta, + init_folder=True, + mediainfo=mediainfo, + overwrite=overwrite, + ) + + def _scrape_nfo_generic( + self, + current_fileitem: schemas.FileItem, + meta: MetaBase, + mediainfo: MediaInfo, + item_type: ScrapingTarget, + parent_fileitem: Optional[schemas.FileItem] = None, + overwrite: bool = False, + season_number: Optional[int] = None, + episode_number: Optional[int] = None, + ): + """ + NFO 刮削 + """ + # 获取刮削选项 + nfo_option = self.scraping_policies.option(item_type, ScrapingMetadata.NFO) + + # 检查刮削开关 + if nfo_option.is_skip: + logger.info( + f"{item_type.value} {ScrapingMetadata.NFO.value} 刮削策略 {nfo_option.policy.value}" + ) + return + + # 获取目标 FileItem (`base_item`) 和 Path (`nfo_path`) + base_item, nfo_path = self._get_target_fileitem_and_path( + current_fileitem=current_fileitem, + item_type=item_type, + metadata_type=ScrapingMetadata.NFO, + parent_fileitem=parent_fileitem, + ) + + if not nfo_path: # _get_target_fileitem_and_path 内部错误处理返回None + return + + # 文件存在检查 + file_exists = self.storagechain.get_file_item( + storage=base_item.storage, path=nfo_path + ) + + # 刮削决策 + if self._should_scrape(nfo_option, bool(file_exists), overwrite): + # 生成 NFO 内容 + nfo_content = self.metadata_nfo( + meta=meta, + mediainfo=mediainfo, + season=season_number, + episode=episode_number, + ) + if nfo_content: + self._save_file(fileitem=base_item, path=nfo_path, content=nfo_content) + else: + logger.warn(f"{nfo_path.name} NFO 文件生成失败!") + + def _scrape_images_generic( + self, + current_fileitem: schemas.FileItem, + mediainfo: MediaInfo, + item_type: ScrapingTarget, + parent_fileitem: Optional[schemas.FileItem] = None, + overwrite: bool = False, + season_number: Optional[int] = None, + episode_number: Optional[int] = None, + ): + """ + 图片刮削 + """ + # 获取图片 URL + if item_type == ScrapingTarget.EPISODE: + image_dict = self.metadata_img( + mediainfo=mediainfo, season=season_number, episode=episode_number + ) + elif item_type == ScrapingTarget.SEASON: + image_dict = self.metadata_img(mediainfo=mediainfo, season=season_number) + else: + image_dict = self.metadata_img(mediainfo=mediainfo) + + if not image_dict: + logger.info(f"未获取到 {item_type.value} 的图片信息,跳过图片刮削。") + return + + # 遍历图片 image_name 和 image_url + for image_name, image_url in image_dict.items(): + metadata_type = None + # 对每个 image_name 查找匹配的 ScrapingMetadata + for keyword, meta_type in self.IMAGE_METADATA_MAP.items(): + if keyword in image_name.lower(): + metadata_type = meta_type + break + + if metadata_type: + # 获取对应的 ScrapingOption + option = self.scraping_policies.option(item_type, metadata_type) + + if option.is_skip: + logger.info( + f"{item_type.value} {option.metadata.value} 刮削策略 {option.policy.value}" + ) + continue + + # 判断是否匹配当前刮削的季号 + if item_type == ScrapingTarget.TV and image_name.lower().startswith( + "season" + ): + logger.info(f"当前为电视剧根目录刮削,跳过季图片:{image_name}") + continue + if ( + item_type == ScrapingTarget.SEASON + and season_number is not None + and image_name.lower().startswith("season") + ): + # 检查是否只下载当前刮削季的图片 + image_season_str = ( + "00" if "specials" in image_name.lower() else image_name[6:8] + ) + + if image_season_str is not None and image_season_str != str( + season_number + ).rjust(2, "0"): + logger.info( + f"当前刮削季为:{season_number},跳过非本季图片:{image_name}" + ) + continue + + # 获取目标 FileItem 和 Path,季图片会同时写根目录和季目录。 + image_targets = self._get_target_fileitems_and_paths( + current_fileitem=current_fileitem, + item_type=item_type, + metadata_type=metadata_type, + filename_hint=image_name, + parent_fileitem=parent_fileitem, + ) + + # 扩展别名目标(如 backdrop→fanart, thumb→landscape) + image_targets = self._expand_with_aliases(image_targets, item_type) + + for base_item, image_path in image_targets: + if not image_path: + continue + + # 文件存在检查 + file_exists = self.storagechain.get_file_item( + storage=base_item.storage, path=image_path + ) + + # 刮削决策 + if self._should_scrape(option, bool(file_exists), overwrite): + self._download_and_save_image( + fileitem=base_item, path=image_path, url=image_url + ) + else: + logger.debug( + f"未找到图片类型 {image_name} 对应的 ScrapingMetadata,跳过。" + ) + + def scrape_metadata( + self, + fileitem: schemas.FileItem, + meta: MetaBase = None, + mediainfo: Union[MediaInfo, MusicInfo] = None, + init_folder: bool = True, + parent: schemas.FileItem = None, + overwrite: bool = False, + recursive: bool = True, + audio_files: Optional[list[schemas.FileItem]] = None, + media_by_path: Optional[dict[str, MusicInfo]] = None, + ) -> tuple[bool, str]: + """ + 手动刮削媒体信息 + + :param fileitem: 刮削目录或文件 + :param meta: 元数据 + :param mediainfo: 媒体信息 + :param init_folder: 是否刮削根目录 + :param parent: 上级目录 + :param overwrite: 是否覆盖已有文件 + :param recursive: 是否递归处理目录内文件 + :param audio_files: 音乐批次已确认成功的音频文件清单 + :param media_by_path: 音乐批次每个音频文件对应的标准身份 + """ + if not fileitem: + return False, "未提供刮削文件" + + # 当前文件路径 + filepath = Path(fileitem.path) + is_music = ( + getattr(mediainfo, "type", None) == MediaType.MUSIC + or isinstance(meta, MetaMusic) + or ( + fileitem.type == "file" + and filepath.suffix.lower() in settings.RMT_AUDIOEXT + ) + ) + if is_music: + music_info = ( + mediainfo + if getattr(mediainfo, "type", None) == MediaType.MUSIC + else None + ) + music_kwargs: dict[str, Any] = {} + if audio_files is not None: + music_kwargs["audio_files"] = audio_files + if media_by_path is not None: + music_kwargs["media_by_path"] = media_by_path + return self.scrape_music_metadata( + fileitem=fileitem, + mediainfo=music_info, + overwrite=overwrite, + **music_kwargs, + ) + if fileitem.type == "file" and ( + not filepath.suffix or filepath.suffix.lower() not in settings.RMT_MEDIAEXT + ): + return False, "刮削路径不是支持的媒体文件" + + # 准备元数据和媒体信息 + if not meta: + meta = MetaInfoPath(filepath) + if not mediainfo: + mediainfo = MediaChain().recognize_by_meta(meta) + if not mediainfo: + logger.warn(f"{filepath} 无法识别文件媒体信息!") + return False, "未识别到媒体信息" + + logger.info(f"开始刮削:{filepath} ...") + + # 根据媒体类型分发处理逻辑 + if mediainfo.type == MediaType.MOVIE: + self._handle_movie_scraping( + fileitem=fileitem, + meta=meta, + mediainfo=mediainfo, + init_folder=init_folder, + parent=parent, + overwrite=overwrite, + recursive=recursive, + ) + elif mediainfo.type == MediaType.TV: + self._handle_tv_scraping( + fileitem=fileitem, + meta=meta, + mediainfo=mediainfo, + init_folder=init_folder, + parent=parent, + overwrite=overwrite, + recursive=recursive, + ) + else: + logger.warn(f"{filepath} 媒体类型不支持刮削:{mediainfo.type}") + return False, "媒体类型不支持刮削" + + logger.info(f"{filepath.name} 刮削完成") + return True, f"{filepath.name} 刮削完成" + + def scrape_music_metadata( + self, + fileitem: schemas.FileItem, + mediainfo: Optional[MusicInfo] = None, + overwrite: bool = True, + media_source: Optional[MediaSource] = None, + audio_files: Optional[list[schemas.FileItem]] = None, + media_by_path: Optional[dict[str, MusicInfo]] = None, + ) -> tuple[bool, str]: + """为音频文件或目录写入音乐标签和封面,应用系统刮削策略,复用现有存储下载上传能力。 + + 音乐识别由 MediaChain 提供,标签、封面和歌词写入仅由当前刮削链编排。 + + :param audio_files: 已确认属于本批次的音频文件;为空时按 fileitem 展开 + :param media_by_path: 每个音频文件对应的音乐身份,避免批次内不同单曲互相覆盖 + """ + files = self._normalize_music_audio_fileitems( + audio_files if audio_files is not None else self._music_audio_fileitems(fileitem) + ) + if not files: + return False, "刮削路径中没有支持的音频文件" + normalized_media_by_path = { + Path(path).as_posix(): info + for path, info in (media_by_path or {}).items() + if isinstance(info, MusicInfo) + } + file_media = [ + normalized_media_by_path.get(Path(item.path).as_posix(), mediainfo) + for item in files + ] + distinct_recordings = { + self._music_scrape_identity(info) + for info in file_media + if info and info.music_type != MUSIC_ENTITY_ALBUM + } + if len(files) > 1 and len(distinct_recordings) == 1 and all(file_media): + return False, "单曲音乐 ID 仅支持刮削单个音频文件,整目录请选择专辑" + + # 三类音乐产物使用独立策略,允许只下载歌词而不改写音频标签。 + nfo_option = self.scraping_policies.option("music", "nfo") + poster_option = self.scraping_policies.option("music", "poster") + lyrics_option = self.scraping_policies.option("music", "lyrics") + if nfo_option.is_skip and poster_option.is_skip and lyrics_option.is_skip: + return False, "音乐标签、封面和歌词刮削策略均为跳过" + + with_cover = not poster_option.is_skip + lyrics_chain = None + if not lyrics_option.is_skip: + lyrics_chain = LrclibChain() + cover_cache: dict[str, tuple[Optional[bytes], str]] = {} + album_cache: dict[tuple[str, str], Optional[MusicAlbumInfo]] = {} + + failures: list[str] = [] + lyrics_counts = { + "saved": 0, + "existing": 0, + "missing": 0, + "failed": 0, + } + metadata_failure_label = ( + "音乐标签和封面" + if not nfo_option.is_skip and with_cover + else "音乐标签" if not nfo_option.is_skip else "封面" + ) + for audio_item, item_media in zip(files, file_media): + item_cover = None + cover_url = item_media.cover_url if item_media else None + if with_cover and cover_url: + if cover_url not in cover_cache: + cover_cache[cover_url] = self._download_music_cover(cover_url) + item_cover = cover_cache[cover_url] + album_info = None + if ( + item_media + and item_media.music_type == MUSIC_ENTITY_ALBUM + and item_media.media_source + and item_media.media_id + ): + album_key = (item_media.media_source, item_media.media_id) + if album_key not in album_cache: + album_cache[album_key] = MediaChain().get_music_album( + media_source=item_media.media_source, + media_id=item_media.media_id, + ) + album_info = album_cache[album_key] + result = self._scrape_music_file( + audio_item, + item_media, + write_tags=not nfo_option.is_skip, + tag_overwrite=overwrite or nfo_option.is_overwrite, + with_cover=with_cover, + cover_overwrite=overwrite or poster_option.is_overwrite, + cover=item_cover, + lyrics_option=lyrics_option, + lyrics_overwrite=overwrite or lyrics_option.is_overwrite, + lyrics_chain=lyrics_chain, + album_info=album_info, + media_source=media_source, + ) + if not result.metadata_success: + failures.append( + f"{audio_item.name or audio_item.path} {metadata_failure_label}写入失败" + ) + if result.lyrics_status in lyrics_counts: + lyrics_counts[result.lyrics_status] += 1 + if result.lyrics_status == "failed": + failures.append(f"{audio_item.name or audio_item.path} 歌词保存失败") + + message = f"已刮削 {len(files)} 个音频文件" + if not lyrics_option.is_skip: + message += ( + f",歌词新增 {lyrics_counts['saved']} 首" + f"、已存在 {lyrics_counts['existing']} 首" + f"、未匹配 {lyrics_counts['missing']} 首" + ) + if lyrics_counts["failed"]: + message += f"、失败 {lyrics_counts['failed']} 首" + if failures: + return False, f"{message};{';'.join(failures[:3])}" + return True, message + + @staticmethod + def _music_scrape_identity(info: MusicInfo) -> tuple: + """构造音乐刮削身份键,用于识别同一单曲被错误套用到多个文件。""" + return ( + info.media_source, + info.media_id, + info.music_type, + info.title, + info.disc_number, + info.track_number, + ) + + @staticmethod + @cached(maxsize=64, ttl=settings.CONF.meta, skip_none=True) + def _request_music_cover(url: str) -> Optional[tuple[Optional[bytes], str]]: + """下载并缓存音乐封面;仅稳定 404 与成功响应进入有界缓存。""" + response = RequestUtils( + proxies=settings.PROXY, + ua=settings.NORMAL_USER_AGENT, + timeout=20, + ).get_res(url) + if response is None: + return None + try: + if response.status_code == 404: + return None, "image/jpeg" + if response.status_code != 200: + logger.warning(f"音乐封面下载失败:{response.status_code} {url}") + return None + mime = (response.headers.get("Content-Type") or "image/jpeg").split(";", 1)[0] + return response.content, mime + finally: + response.close() + + @staticmethod + def _download_music_cover(url: Optional[str]) -> tuple[Optional[bytes], str]: + """通过有界缓存下载音乐封面,并统一返回图片内容与 MIME 类型。""" + if not url: + return None, "image/jpeg" + return ScrapingChain._request_music_cover(url) or (None, "image/jpeg") + + @staticmethod + def _is_music_audio_file(path: str) -> bool: + """判断路径是否指向系统支持的音频文件。""" + return Path(path).suffix.lower() in settings.RMT_AUDIOEXT + + def _music_audio_fileitems(self, fileitem: schemas.FileItem) -> list[schemas.FileItem]: + """展开待刮削目录并过滤系统支持的音频文件。""" + if fileitem.type != "dir": + return [fileitem] if self._is_music_audio_file(fileitem.path or "") else [] + return [ + item + for item in self.storagechain.list_files(fileitem, recursion=True) or [] + if item.type == "file" and self._is_music_audio_file(item.path or "") + ] + + @classmethod + def _normalize_music_audio_fileitems( + cls, + fileitems: Iterable[schemas.FileItem], + ) -> list[schemas.FileItem]: + """过滤并按存储路径去重已选音频文件,保持调用方给出的顺序。""" + normalized: list[schemas.FileItem] = [] + seen: set[tuple[str, str]] = set() + for item in fileitems or []: + if ( + not item + or item.type != "file" + or not cls._is_music_audio_file(item.path or "") + ): + continue + key = (item.storage or "local", Path(item.path).as_posix()) + if key in seen: + continue + seen.add(key) + normalized.append(item) + return normalized + + def _music_event_audio_fileitems( + self, + root: schemas.FileItem, + file_list: Iterable[str], + ) -> list[schemas.FileItem]: + """把刮削事件中的成功路径恢复为文件项,并限制在事件媒体根目录内。""" + root_path = Path(root.path) + selected: list[schemas.FileItem] = [] + for raw_path in file_list or []: + audio_path = Path(raw_path) + if not self._is_music_audio_file(audio_path.as_posix()): + continue + if root.type == "dir" and not audio_path.is_relative_to(root_path): + logger.warning(f"忽略媒体根目录外的音乐刮削路径:{audio_path}") + continue + item = self.storagechain.get_file_item( + storage=root.storage, + path=audio_path, + ) + selected.append(item or schemas.FileItem( + storage=root.storage, + path=audio_path.as_posix(), + type="file", + name=audio_path.name, + basename=audio_path.stem, + extension=audio_path.suffix.lstrip("."), + )) + return self._normalize_music_audio_fileitems(selected) + + def _scrape_music_file( + self, + fileitem: schemas.FileItem, + mediainfo: Optional[MusicInfo], + write_tags: bool, + tag_overwrite: bool, + with_cover: bool = True, + cover_overwrite: bool = True, + cover: Optional[tuple[Optional[bytes], str]] = None, + lyrics_option: Optional[ScrapingOption] = None, + lyrics_overwrite: bool = False, + lyrics_chain: Optional[LrclibChain] = None, + album_info: Optional[MusicAlbumInfo] = None, + media_source: Optional[MediaSource] = None, + ) -> _MusicScrapeFileResult: + """下载单个音轨并执行标签、封面和歌词刮削,远端产物写回原目录。""" + storage = self.storagechain + download_failure = _MusicScrapeFileResult( + metadata_success=not (write_tags or with_cover), + lyrics_status=( + "failed" + if lyrics_option and not lyrics_option.is_skip and lyrics_chain + else "disabled" + ), + ) + if fileitem.storage == "local": + local_path = storage.download_file(fileitem) + if not local_path: + return download_failure + return self._apply_music_file_scrape( + fileitem=fileitem, + local_path=local_path, + mediainfo=mediainfo, + write_tags=write_tags, + tag_overwrite=tag_overwrite, + with_cover=with_cover, + cover_overwrite=cover_overwrite, + cover=cover, + lyrics_option=lyrics_option, + lyrics_overwrite=lyrics_overwrite, + lyrics_chain=lyrics_chain, + album_info=album_info, + media_source=media_source, + ) + + with TemporaryDirectory(prefix="moviepilot-music-scrape-") as temp_dir: + local_path = storage.download_file(fileitem, path=Path(temp_dir)) + if not local_path: + return download_failure + return self._apply_music_file_scrape( + fileitem=fileitem, + local_path=local_path, + mediainfo=mediainfo, + write_tags=write_tags, + tag_overwrite=tag_overwrite, + with_cover=with_cover, + cover_overwrite=cover_overwrite, + cover=cover, + lyrics_option=lyrics_option, + lyrics_overwrite=lyrics_overwrite, + lyrics_chain=lyrics_chain, + album_info=album_info, + media_source=media_source, + ) + + def _apply_music_file_scrape( + self, + fileitem: schemas.FileItem, + local_path: Path, + mediainfo: Optional[MusicInfo], + write_tags: bool, + tag_overwrite: bool, + with_cover: bool, + cover_overwrite: bool, + cover: Optional[tuple[Optional[bytes], str]], + lyrics_option: Optional[ScrapingOption], + lyrics_overwrite: bool, + lyrics_chain: Optional[LrclibChain], + album_info: Optional[MusicAlbumInfo], + media_source: Optional[MediaSource], + ) -> _MusicScrapeFileResult: + """在本地音轨副本上执行刮削,并将变更后的音频和歌词写回目标存储。""" + scrape_info = self._resolve_music_scrape_info(local_path, mediainfo, media_source=media_source) + metadata_requested = write_tags or with_cover + metadata_success = True + if metadata_requested: + metadata_success = self._write_music_metadata( + local_path=local_path, + mediainfo=mediainfo, + tag_overwrite=tag_overwrite, + write_tags=write_tags, + with_cover=with_cover, + cover_overwrite=cover_overwrite, + cover=cover, + scrape_info=scrape_info, + media_source=media_source, + ) + + lyrics_status = self._scrape_music_lyrics( + fileitem=fileitem, + local_path=local_path, + scrape_info=scrape_info, + lyrics_option=lyrics_option, + overwrite=lyrics_overwrite, + lyrics_chain=lyrics_chain, + album_info=album_info, + ) + + if fileitem.storage != "local" and metadata_requested and metadata_success: + parent = self.storagechain.get_parent_item(fileitem) + if not parent: + logger.warning(f"无法获取远端音频父目录:{fileitem.path}") + metadata_success = False + elif not self.storagechain.upload_file( + parent, + local_path, + new_name=fileitem.name or local_path.name, + ): + metadata_success = False + return _MusicScrapeFileResult( + metadata_success=metadata_success, + lyrics_status=lyrics_status, + ) + + @staticmethod + def _merge_music_album_metadata(local_meta: MetaMusic, album: MusicInfo) -> MetaMusic: + """把专辑级字段合并到单个音轨标签,同时保留该文件自己的标题、艺术家和曲序。""" + merged = deepcopy(local_meta) + merged.artists = list(local_meta.artists or album.artists) + merged.album = album.album or album.title or local_meta.album + merged.album_artist = album.album_artist or album.artist or local_meta.album_artist + merged.year = album.year or local_meta.year + merged.total_tracks = album.total_tracks or local_meta.total_tracks + merged.media_source = album.media_source or local_meta.media_source + merged.media_id = album.media_id or local_meta.media_id + return merged + + @classmethod + def _match_music_album_track( + cls, + local_meta: MetaMusic, + album_info: Optional[MusicAlbumInfo], + ) -> Optional[MusicInfo]: + """按碟号、曲序、标题、艺术家和时长为本地文件匹配专辑中的单个音轨。""" + if not album_info or not album_info.tracks: + return None + local_title = cls._normalize_music_track_title(local_meta.title) + local_artists = { + cls._normalize_music_track_title(artist) + for artist in local_meta.artists + if artist + } + ranked: list[tuple[int, MusicInfo]] = [] + for track in album_info.tracks: + score = 0 + if local_meta.track_number and track.track_number: + if local_meta.track_number == track.track_number: + score += 6 + else: + continue + if local_meta.disc_number and track.disc_number: + if local_meta.disc_number == track.disc_number: + score += 3 + else: + continue + if local_title and local_title == cls._normalize_music_track_title(track.title): + score += 8 + track_artists = { + cls._normalize_music_track_title(artist) + for artist in track.artists + if artist + } + if local_artists and track_artists and local_artists.intersection(track_artists): + score += 3 + if local_meta.duration and track.duration: + duration_delta = abs(local_meta.duration - track.duration) + if duration_delta <= 2: + score += 4 + elif duration_delta > 5: + score -= 3 + if score >= 8: + ranked.append((score, track)) + if not ranked: + return None + ranked.sort(key=lambda pair: pair[0], reverse=True) + if len(ranked) > 1 and ranked[0][0] == ranked[1][0]: + return None + matched = deepcopy(ranked[0][1]) + matched.duration = matched.duration or local_meta.duration + return matched + + @classmethod + def _normalize_music_track_title(cls, value: Optional[str]) -> str: + """规范化音轨标题并移除常见文件名前置碟号和曲序。""" + title = cls._music_track_prefix_pattern.sub("", str(value or "").strip()) + return re.sub(r"[^\w]+", "", title.casefold(), flags=re.UNICODE) + + @classmethod + def _resolve_music_scrape_info( + cls, + local_path: Path, + mediainfo: Optional[MusicInfo], + media_source: Optional[MediaSource] = None, + ) -> Optional[MetaMusic | MusicInfo]: + """在文件已下载到本地后解析刮削信息,专辑场景只覆盖专辑级标签。""" + if mediainfo and mediainfo.music_type == MUSIC_ENTITY_ALBUM: + return cls._merge_music_album_metadata( + AudioMetadataHelper.read(local_path), + mediainfo, + ) + if mediainfo and mediainfo.music_type in (MUSIC_ENTITY_RECORDING, None, ""): + return mediainfo + if mediainfo: + return None + + _, recognized = MediaChain().recognize_music_by_path( + local_path, + media_source=media_source, + ) + return recognized + + def _write_music_metadata( + self, + local_path: Path, + mediainfo: Optional[MusicInfo], + tag_overwrite: bool, + write_tags: bool, + with_cover: bool, + cover_overwrite: bool, + cover: Optional[tuple[Optional[bytes], str]] = None, + scrape_info: Optional[MetaMusic | MusicInfo] = None, + media_source: Optional[MediaSource] = None, + ) -> bool: + """解析单个本地音轨并按独立策略写入标签和封面。""" + scrape_info = scrape_info or self._resolve_music_scrape_info( + local_path, + mediainfo, + media_source=media_source, + ) + if not scrape_info or not scrape_info.title: + logger.warning(f"无法识别音乐信息:{local_path}") + return False + if not with_cover: + cover_data, cover_mime = None, "image/jpeg" + elif cover is not None: + cover_data, cover_mime = cover + else: + cover_url = getattr(mediainfo, "cover_url", None) or getattr(scrape_info, "cover_url", None) + cover_data, cover_mime = self._download_music_cover(cover_url) + return AudioMetadataHelper.write( + local_path, + scrape_info, + cover_data=cover_data, + cover_mime=cover_mime, + overwrite=tag_overwrite, + write_tags=write_tags, + cover_overwrite=cover_overwrite, + ) + + def _scrape_music_lyrics( + self, + fileitem: schemas.FileItem, + local_path: Path, + scrape_info: Optional[MetaMusic | MusicInfo], + lyrics_option: Optional[ScrapingOption], + overwrite: bool, + lyrics_chain: Optional[LrclibChain], + album_info: Optional[MusicAlbumInfo], + ) -> str: + """按歌词策略查询单个音轨并保存同名旁挂歌词文件。""" + if not lyrics_option or lyrics_option.is_skip or not lyrics_chain: + return "disabled" + existing = self._find_music_lyrics_sidecar(fileitem) + if existing and not overwrite: + return "existing" + if not scrape_info: + return "missing" + + lookup_info: MetaMusic | MusicInfo = scrape_info + if album_info: + local_meta = AudioMetadataHelper.read(local_path) + lookup_info = self._match_music_album_track(local_meta, album_info) or scrape_info + lyrics = lyrics_chain.get_music_lyrics(lookup_info) + if not lyrics or lyrics.instrumental or not lyrics.content or not lyrics.extension: + return "missing" + return ( + "saved" + if self._write_music_lyrics_sidecar( + fileitem=fileitem, + local_path=local_path, + lyrics=lyrics, + overwrite=overwrite, + ) + else "failed" + ) + + def _find_music_lyrics_sidecar( + self, + fileitem: schemas.FileItem, + ) -> Optional[schemas.FileItem]: + """查找音轨旁已存在的同步或纯文本歌词文件。""" + audio_path = Path(fileitem.path) + for extension in self.MUSIC_LYRICS_EXTENSIONS: + item = self.storagechain.get_file_item( + storage=fileitem.storage, + path=audio_path.with_suffix(extension), + ) + if item: + return item + return None + + def _write_music_lyrics_sidecar( + self, + fileitem: schemas.FileItem, + local_path: Path, + lyrics: MusicLyrics, + overwrite: bool, + ) -> bool: + """原子写入本地歌词或上传远端歌词,并在覆盖时清理旧格式旁挂文件。""" + extension = lyrics.extension + content = lyrics.content + if not extension or not content: + return False + target_path = Path(fileitem.path).with_suffix(extension) + target_name = target_path.name + temp_path: Optional[Path] = None + try: + if fileitem.storage == "local": + with NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=target_path.parent, + prefix=f".{target_name}.", + delete=False, + ) as temp_file: + temp_file.write(f"{content.rstrip()}\n") + temp_path = Path(temp_file.name) + temp_path.replace(target_path) + else: + parent = self.storagechain.get_parent_item(fileitem) + if not parent: + logger.warning(f"无法获取远端歌词父目录:{fileitem.path}") + return False + temp_path = local_path.with_suffix(extension) + temp_path.write_text(f"{content.rstrip()}\n", encoding="utf-8") + if not self.storagechain.upload_file( + parent, + temp_path, + new_name=target_name, + ): + return False + + if overwrite: + self._remove_alternate_music_lyrics(fileitem, keep_extension=extension) + return True + except OSError as err: + logger.warning(f"保存音乐歌词失败:{target_path} - {err}") + return False + finally: + if temp_path and temp_path.exists() and temp_path != target_path: + self._cleanup_temp_file(temp_path) + + def _remove_alternate_music_lyrics( + self, + fileitem: schemas.FileItem, + keep_extension: str, + ) -> None: + """覆盖歌词格式后删除同音轨的旧扩展名文件,避免播放器优先读取过期内容。""" + audio_path = Path(fileitem.path) + for extension in self.MUSIC_LYRICS_EXTENSIONS: + if extension == keep_extension: + continue + item = self.storagechain.get_file_item( + storage=fileitem.storage, + path=audio_path.with_suffix(extension), + ) + if item and not self.storagechain.delete_file(item): + logger.warning(f"删除旧歌词文件失败:{item.path}") + + def _handle_movie_scraping( + self, + fileitem: schemas.FileItem, + meta: MetaBase, + mediainfo: MediaInfo, + init_folder: bool, + parent: schemas.FileItem, + overwrite: bool, + recursive: bool, + ): + """ + 处理电影刮削 + """ + if fileitem.type == "file": + # 电影文件始终处理 NFO,直接初始化文件时再补同级目录图片。 + self._scrape_nfo_generic( + current_fileitem=fileitem, + meta=meta, + mediainfo=mediainfo, + item_type=ScrapingTarget.MOVIE, + parent_fileitem=parent, + overwrite=overwrite, + ) + if init_folder: + self._scrape_images_generic( + current_fileitem=fileitem, + mediainfo=mediainfo, + item_type=ScrapingTarget.MOVIE, + parent_fileitem=parent, + overwrite=overwrite, + ) + else: + # 电影目录:递归处理文件并初始化目录 + self._handle_movie_directory( + fileitem=fileitem, + meta=meta, + mediainfo=mediainfo, + init_folder=init_folder, + overwrite=overwrite, + recursive=recursive, + ) + + def _handle_movie_directory( + self, + fileitem: schemas.FileItem, + meta: MetaBase, + mediainfo: MediaInfo, + init_folder: bool, + overwrite: bool, + recursive: bool, + ): + """ + 处理电影目录刮削 + """ + files = self.storagechain.list_files(fileitem=fileitem) or [] + is_bluray_folder = self.storagechain.contains_bluray_subdirectories(files) + + # 递归处理文件(非蓝光原盘) + if recursive and not is_bluray_folder: + for file in files: + if file.type == "dir": + continue + self.scrape_metadata( + fileitem=file, + mediainfo=mediainfo, + init_folder=False, + parent=fileitem, + overwrite=overwrite, + ) + + # 初始化目录元数据 + if init_folder: + if is_bluray_folder: + # 蓝光原盘目录:仅处理 NFO + self._scrape_nfo_generic( + current_fileitem=fileitem, + meta=meta, + mediainfo=mediainfo, + item_type=ScrapingTarget.MOVIE, + overwrite=overwrite, + ) + # 电影目录:处理图片 + self._scrape_images_generic( + current_fileitem=fileitem, + mediainfo=mediainfo, + item_type=ScrapingTarget.MOVIE, + overwrite=overwrite, + ) + + def _handle_tv_scraping( + self, + fileitem: schemas.FileItem, + meta: MetaBase, + mediainfo: MediaInfo, + init_folder: bool, + parent: schemas.FileItem, + overwrite: bool, + recursive: bool, + ): + """ + 处理电视剧刮削 + """ + filepath = Path(fileitem.path) + + if fileitem.type == "file": + # 电视剧集文件:重新识别季集信息并刮削 + self._handle_tv_episode_file( + fileitem=fileitem, + filepath=filepath, + mediainfo=mediainfo, + parent=parent, + overwrite=overwrite, + ) + else: + # 电视剧目录:递归处理并初始化目录 + self._handle_tv_directory( + fileitem=fileitem, + filepath=filepath, + meta=meta, + mediainfo=mediainfo, + init_folder=init_folder, + parent=parent, + overwrite=overwrite, + recursive=recursive, + ) + + def _handle_tv_episode_file( + self, + fileitem: schemas.FileItem, + filepath: Path, + mediainfo: MediaInfo, + parent: schemas.FileItem, + overwrite: bool, + ): + """ + 处理电视剧集文件刮削 + """ + # 重新识别季集信息 + file_meta = MetaInfoPath(filepath) + if not file_meta.begin_episode: + logger.warn(f"{filepath.name} 无法识别文件集数!") + return + + media_source, media_id = resolve_media_identity(media=mediainfo) + if not media_source or not media_id: + logger.warn(f"{filepath.name} 缺少完整媒体身份,无法识别剧集信息!") + return + file_mediainfo = MediaChain().recognize_media( + meta=file_meta, + media_source=media_source, + media_id=media_id, + episode_group=mediainfo.episode_group, + ) + if not file_mediainfo: + logger.warn(f"{filepath.name} 无法识别文件媒体信息!") + return + + # 处理 NFO + self._scrape_nfo_generic( + current_fileitem=fileitem, + meta=file_meta, + mediainfo=file_mediainfo, + item_type=ScrapingTarget.EPISODE, + parent_fileitem=parent, + overwrite=overwrite, + season_number=file_meta.begin_season, + episode_number=file_meta.begin_episode, + ) + + # 处理图片 + self._scrape_images_generic( + current_fileitem=fileitem, + mediainfo=file_mediainfo, + item_type=ScrapingTarget.EPISODE, + parent_fileitem=parent, + overwrite=overwrite, + season_number=file_meta.begin_season, + episode_number=file_meta.begin_episode, + ) + + def _handle_tv_directory( + self, + fileitem: schemas.FileItem, + filepath: Path, + meta: MetaBase, + mediainfo: MediaInfo, + init_folder: bool, + parent: schemas.FileItem, + overwrite: bool, + recursive: bool, + ): + """ + 处理电视剧目录刮削 + """ + # 递归处理子目录和文件 + if recursive: + files = self.storagechain.list_files(fileitem=fileitem) or [] + for file in files: + if ( + file.type == "dir" + and file.name not in settings.RENAME_FORMAT_S0_NAMES + and MetaInfo(file.name).begin_season is None + ): + # 电视剧不处理非季子目录 + continue + self.scrape_metadata( + fileitem=file, + mediainfo=mediainfo, + parent=fileitem if file.type == "file" else None, + init_folder=True if file.type == "dir" else False, + overwrite=overwrite, + ) + + # 初始化目录元数据 + if init_folder: + self._initialize_tv_directory_metadata( + fileitem=fileitem, + filepath=filepath, + meta=meta, + mediainfo=mediainfo, + parent=parent, + overwrite=overwrite, + ) + + def _initialize_tv_directory_metadata( + self, + fileitem: schemas.FileItem, + filepath: Path, + meta: MetaBase, + mediainfo: MediaInfo, + parent: schemas.FileItem, + overwrite: bool, + ): + """ + 初始化电视剧目录元数据(识别季号并刮削) + """ + # 识别文件夹名称 + season_meta = MetaInfo(filepath.name) + + # 特殊季目录处理(Specials/SPs) + if filepath.name in settings.RENAME_FORMAT_S0_NAMES: + season_meta.begin_season = 0 + elif season_meta.name and season_meta.begin_season is not None: + # 排除辅助词重新识别,避免误判根目录 (issue https://github.com/jxxghp/MoviePilot/issues/5501) + season_meta_no_custom = MetaInfo(filepath.name, custom_words=["#"]) + if season_meta_no_custom.begin_season is None: + # 季号由辅助词指定,按剧集根目录处理 (issue https://github.com/jxxghp/MoviePilot/issues/5373) + season_meta.begin_season = None + + # 根据季号判断目录类型并刮削 + if season_meta.begin_season is not None: + # 季目录:处理季 NFO 和图片 + self._scrape_nfo_generic( + current_fileitem=fileitem, + meta=meta, + mediainfo=mediainfo, + item_type=ScrapingTarget.SEASON, + overwrite=overwrite, + season_number=season_meta.begin_season, + ) + self._scrape_images_generic( + current_fileitem=fileitem, + mediainfo=mediainfo, + item_type=ScrapingTarget.SEASON, + parent_fileitem=parent, + overwrite=overwrite, + season_number=season_meta.begin_season, + ) + elif season_meta.name: + # 剧集根目录:处理电视剧 NFO 和图片 + self._scrape_nfo_generic( + current_fileitem=fileitem, + meta=meta, + mediainfo=mediainfo, + item_type=ScrapingTarget.TV, + overwrite=overwrite, + ) + self._scrape_images_generic( + current_fileitem=fileitem, + mediainfo=mediainfo, + item_type=ScrapingTarget.TV, + overwrite=overwrite, + ) + else: + logger.warn("无法识别元数据,跳过") diff --git a/app/chain/search.py b/app/chain/search.py index 7b80a6ead..061813f81 100644 --- a/app/chain/search.py +++ b/app/chain/search.py @@ -6,14 +6,13 @@ import re import time from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, as_completed, wait from datetime import datetime -from typing import AsyncIterator, Any, Dict, Tuple +from typing import AsyncIterator, Any, Dict, Iterable, Tuple from typing import List, Optional from fastapi.concurrency import run_in_threadpool from app.chain import ChainBase from app.chain.media import MediaChain -from app.chain.music import MusicChain from app.core.config import global_vars, settings from app.core.context import Context from app.core.context import MediaInfo, SubtitleInfo, TorrentInfo @@ -28,13 +27,18 @@ from app.helper.torrent import TorrentHelper from app.log import logger from app.schemas import NotExistMediaInfo from app.schemas.types import ( + MUSIC_ENTITY_ALBUM, EventType, MediaSource, MediaType, ProgressKey, SystemConfigKey, ) -from app.utils.media import build_media_key, resolve_media_identity +from app.utils.media import ( + build_media_key, + parse_media_key, + resolve_media_identity, +) from app.utils.string import StringUtils @@ -54,6 +58,73 @@ class SearchChain(ChainBase): _ai_recommend_result: Optional[List[int]] = None _ai_recommend_error: Optional[str] = None + @classmethod + def music_site_keywords(cls, music: MetaMusic | MusicInfo) -> list[str]: + """按单曲或专辑实体生成站点关键词,避免单曲优先命中所属整专。""" + artists = music.artists or [] + artist = artists[0] if artists else music.album_artist + values: list[Optional[str]] = [] + if getattr(music, "music_type", None) == MUSIC_ENTITY_ALBUM: + album = music.album or music.title + values.extend([f"{artist} {album}" if artist and album else None, album]) + else: + values.extend([ + f"{artist} {music.title}" if artist and music.title else None, + music.title, + ]) + return cls._unique_music_texts(values) + + @classmethod + def matches_music_resource( + cls, + music: MusicInfo, + resource_title: str, + resource_description: Optional[str] = None, + ) -> bool: + """校验站点资源标题同时包含目标音乐名称和已知艺术家。""" + normalized_resource = MetaMusic.compact_text( + f"{resource_title or ''} {resource_description or ''}" + ) + if not normalized_resource: + return False + if music.music_type == MUSIC_ENTITY_ALBUM: + candidates = cls._unique_music_texts([ + music.album or music.title, + *(music.names or []), + ]) + else: + candidates = cls._unique_music_texts([music.title]) + if not any( + MetaMusic.compact_text(candidate) in normalized_resource + for candidate in candidates + if MetaMusic.compact_text(candidate) + ): + return False + artists = cls._unique_music_texts([ + music.artist, + music.album_artist, + *(music.artists or []), + ]) + return not artists or any( + MetaMusic.compact_text(artist) in normalized_resource + for artist in artists + if MetaMusic.compact_text(artist) + ) + + @staticmethod + def _unique_music_texts(values: Iterable[Optional[str]]) -> list[str]: + """按清理后的文本去重,并保留站点搜索词原始顺序。""" + results: list[str] = [] + seen: set[str] = set() + for value in values: + normalized = re.sub(r"\s+", " ", str(value or "")).strip() + identity = normalized.casefold() + if not normalized or identity in seen: + continue + seen.add(identity) + results.append(normalized) + return results + @staticmethod def _get_search_resource_pages() -> int: """ @@ -207,13 +278,26 @@ class SearchChain(ChainBase): @staticmethod def _normalize_search_params(params: Optional[Dict[str, Any]]) -> Optional[Dict[str, str]]: """ - 规范化上次搜索参数,供前端结果页重新搜索使用。 + 规范化上次搜索参数,供前端结果页重新搜索使用;旧复合关键字仅在 + 缓存读取边界转换为独立的媒体来源和原生 ID。 """ if not isinstance(params, dict): return None + media_source, media_id = resolve_media_identity( + media_source=params.get("media_source"), + media_id=params.get("media_id"), + ) + keyword = str(params.get("keyword") or "") + if not media_source and keyword: + media_source, media_id = parse_media_key(keyword) + if media_source and media_id: + keyword = "" + normalized = { - "keyword": str(params.get("keyword") or ""), + "keyword": keyword, + "media_source": str(media_source) if media_source else "", + "media_id": media_id or "", "type": str(params.get("type") or ""), "area": str(params.get("area") or ""), "title": str(params.get("title") or ""), @@ -225,12 +309,14 @@ class SearchChain(ChainBase): } if params.get("music_type"): normalized["music_type"] = str(params["music_type"]) - return normalized if normalized["keyword"] else None + return normalized if normalized["keyword"] or media_id else None def save_last_search_params( self, *, - keyword: Optional[str], + keyword: Optional[str] = None, + media_source: Optional[MediaSource] = None, + media_id: Optional[str] = None, mtype: Optional[MediaType] = None, area: Optional[str] = "title", title: Optional[str] = None, @@ -242,11 +328,13 @@ class SearchChain(ChainBase): result_type: Optional[str] = "torrent", ) -> None: """ - 保存最后一次资源搜索参数。 + 保存最后一次资源搜索参数,标题搜索与精确身份搜索使用互斥字段。 """ params = self._normalize_search_params( { "keyword": keyword, + "media_source": media_source, + "media_id": media_id, "type": mtype.value if isinstance(mtype, MediaType) else mtype, "area": area, "title": title, @@ -264,7 +352,9 @@ class SearchChain(ChainBase): async def async_save_last_search_params( self, *, - keyword: Optional[str], + keyword: Optional[str] = None, + media_source: Optional[MediaSource] = None, + media_id: Optional[str] = None, mtype: Optional[MediaType] = None, area: Optional[str] = "title", title: Optional[str] = None, @@ -276,11 +366,13 @@ class SearchChain(ChainBase): result_type: Optional[str] = "torrent", ) -> None: """ - 异步保存最后一次资源搜索参数。 + 异步保存最后一次资源搜索参数,标题搜索与精确身份搜索使用互斥字段。 """ params = self._normalize_search_params( { "keyword": keyword, + "media_source": media_source, + "media_id": media_id, "type": mtype.value if isinstance(mtype, MediaType) else mtype, "area": area, "title": title, @@ -530,16 +622,15 @@ class SearchChain(ChainBase): if cache_local: self.cancel_ai_recommend() self.save_last_search_params( - keyword=self._build_search_keyword( - media_source, media_id - ), + media_source=media_source, + media_id=media_id, mtype=mtype, area=area, season=season, sites=sites, music_type=music_type, ) - # 音乐统一在 recognize_media 内路由到 MusicChain + # 音乐统一在 MediaChain.recognize_media 内按固定来源路由 mediainfo = MediaChain().recognize_media( media_source=media_source, media_id=media_id, mtype=mtype, music_type=music_type, @@ -724,9 +815,8 @@ class SearchChain(ChainBase): if cache_local: self.cancel_ai_recommend() await self.async_save_last_search_params( - keyword=self._build_search_keyword( - media_source, media_id - ), + media_source=media_source, + media_id=media_id, mtype=mtype, area="title", season=season, @@ -771,9 +861,8 @@ class SearchChain(ChainBase): if cache_local: self.cancel_ai_recommend() await self.async_save_last_search_params( - keyword=self._build_search_keyword( - media_source, media_id - ), + media_source=media_source, + media_id=media_id, mtype=mtype, area="title", season=season, @@ -837,16 +926,15 @@ class SearchChain(ChainBase): if cache_local: self.cancel_ai_recommend() await self.async_save_last_search_params( - keyword=self._build_search_keyword( - media_source, media_id - ), + media_source=media_source, + media_id=media_id, mtype=mtype, area=area, season=season, sites=sites, music_type=music_type, ) - # 音乐统一在 async_recognize_media 内路由到 MusicChain + # 音乐统一在 MediaChain.async_recognize_media 内按固定来源路由 mediainfo = await MediaChain().async_recognize_media( media_source=media_source, media_id=media_id, mtype=mtype, music_type=music_type, @@ -1041,16 +1129,15 @@ class SearchChain(ChainBase): if cache_local: self.cancel_ai_recommend() await self.async_save_last_search_params( - keyword=self._build_search_keyword( - media_source, media_id - ), + media_source=media_source, + media_id=media_id, mtype=mtype, area=area, season=season, sites=sites, music_type=music_type, ) - # 音乐统一在 async_recognize_media 内路由到 MusicChain + # 音乐统一在 MediaChain.async_recognize_media 内按固定来源路由 mediainfo = await MediaChain().async_recognize_media( media_source=media_source, media_id=media_id, mtype=mtype, music_type=music_type, @@ -1361,7 +1448,7 @@ class SearchChain(ChainBase): contexts = [] for torrent in torrents: - meta = MusicChain.to_meta(mediainfo) + meta = MetaMusic.from_music_info(mediainfo) meta.org_string = torrent.title meta.apply_audio_quality(f"{torrent.title} {torrent.description or ''}", overwrite=True) contexts.append( @@ -1387,7 +1474,7 @@ class SearchChain(ChainBase): torrent for torrent in torrents or [] if torrent.category in (MediaType.MUSIC, MediaType.MUSIC.value) - and MusicChain.matches_site_resource( + and SearchChain.matches_music_resource( mediainfo, torrent.title, torrent.description, @@ -1403,7 +1490,7 @@ class SearchChain(ChainBase): filter_params: Optional[Dict[str, str]] = None, ) -> List[Context]: """按音乐元数据生成站点关键词并执行同步资源搜索。""" - keywords = [keyword] if keyword else MusicChain.build_site_keywords(mediainfo) + keywords = [keyword] if keyword else SearchChain.music_site_keywords(mediainfo) torrents: List[TorrentInfo] = [] for index, search_word in enumerate(keywords or [mediainfo.title]): if index: @@ -1436,7 +1523,7 @@ class SearchChain(ChainBase): filter_params: Optional[Dict[str, str]] = None, ) -> List[Context]: """按音乐元数据生成站点关键词并执行异步资源搜索。""" - keywords = [keyword] if keyword else MusicChain.build_site_keywords(mediainfo) + keywords = [keyword] if keyword else SearchChain.music_site_keywords(mediainfo) torrents: List[TorrentInfo] = [] for index, search_word in enumerate(keywords or [mediainfo.title]): if index: diff --git a/app/chain/subscribe.py b/app/chain/subscribe.py index 393e3dabd..40d9fc28d 100644 --- a/app/chain/subscribe.py +++ b/app/chain/subscribe.py @@ -12,7 +12,6 @@ from app.chain import ChainBase from app.chain.download import DownloadChain from app.chain.media import MediaChain from app.chain.mediaserver import MediaServerChain -from app.chain.music import MusicChain from app.chain.search import SearchChain from app.chain.tmdb import TmdbChain from app.chain.torrents import TorrentsChain @@ -24,8 +23,7 @@ from app.core.context import ( TorrentInfo, ) from app.core.event import eventmanager, Event -from app.core.meta import MetaBase -from app.core.meta import MetaMusic +from app.core.meta import MetaBase, MetaMusic from app.core.meta.words import WordsMatcher from app.core.metainfo import MetaInfo from app.db.downloadhistory_oper import DownloadHistoryOper @@ -877,7 +875,7 @@ class SubscribeChain(ChainBase): mediainfo = None requested_music_type = kwargs.get("music_type") - metainfo = MusicChain.parse_query(title) if mtype == MediaType.MUSIC else MetaInfo(title) + metainfo = MetaMusic.parse_query(title) if mtype == MediaType.MUSIC else MetaInfo(title) if year: metainfo.year = year if mtype: @@ -1081,7 +1079,7 @@ class SubscribeChain(ChainBase): mediainfo = None requested_music_type = kwargs.get("music_type") - metainfo = MusicChain.parse_query(title) if mtype == MediaType.MUSIC else MetaInfo(title) + metainfo = MetaMusic.parse_query(title) if mtype == MediaType.MUSIC else MetaInfo(title) if year: metainfo.year = year if mtype: @@ -1462,7 +1460,7 @@ class SubscribeChain(ChainBase): logger.warning(f"音乐订阅 {subscribe.name} 无法继续:{validation_error}") return None self._sync_music_subscribe_target(subscribe, mediainfo) - meta = MusicChain.to_meta(mediainfo) + meta = MetaMusic.from_music_info(mediainfo) exists, _ = self.check_and_handle_existing_media( subscribe=subscribe, meta=meta, @@ -1494,7 +1492,7 @@ class SubscribeChain(ChainBase): torrent = copy.copy(source_torrent) if sites and torrent.site not in sites: continue - if not MusicChain.matches_site_resource( + if not SearchChain.matches_music_resource( mediainfo, torrent.title, torrent.description, @@ -1514,7 +1512,7 @@ class SubscribeChain(ChainBase): context = copy.copy(source_context) context.torrent_info = torrent - meta = MusicChain.to_meta(mediainfo) + meta = MetaMusic.from_music_info(mediainfo) meta.org_string = torrent.title meta.apply_audio_quality(f"{torrent.title} {torrent.description or ''}", overwrite=True) if subscribe.best_version: @@ -1582,7 +1580,7 @@ class SubscribeChain(ChainBase): if current_subscribe: self.finish_subscribe_or_not( subscribe=current_subscribe, - meta=MusicChain.to_meta(mediainfo), + meta=MetaMusic.from_music_info(mediainfo), mediainfo=mediainfo, downloads=downloads, ) @@ -1598,7 +1596,7 @@ class SubscribeChain(ChainBase): default_rule_key = SystemConfigKey.BestVersionFilterRuleGroups \ if subscribe.best_version else SystemConfigKey.SubscribeFilterRuleGroups rule_groups = subscribe.filter_groups or SystemConfigOper().get(default_rule_key) or [] - keywords = [subscribe.keyword] if subscribe.keyword else MusicChain.build_site_keywords(mediainfo) + keywords = [subscribe.keyword] if subscribe.keyword else SearchChain.music_site_keywords(mediainfo) if not keywords: keywords = [subscribe.name] diff --git a/app/chain/theaudiodb.py b/app/chain/theaudiodb.py new file mode 100644 index 000000000..a45e8307e --- /dev/null +++ b/app/chain/theaudiodb.py @@ -0,0 +1,17 @@ +from app.chain.musicbrainz import _MusicMetadataSourceChain +from app.schemas.types import MediaSource + + +class TheAudioDbChain(_MusicMetadataSourceChain): + """TheAudioDB 音乐搜索、识别与详情来源链。""" + + source = MediaSource.TheAudioDB + + async def async_get_music_artist_related( + self, + media_id: str, + count: int = 24, + ) -> list: + """返回 TheAudioDB 关联艺术家;当前来源模块未提供该能力。""" + del media_id, count + return [] diff --git a/app/chain/torrents.py b/app/chain/torrents.py index e3dbfd947..b65cb2a33 100644 --- a/app/chain/torrents.py +++ b/app/chain/torrents.py @@ -7,10 +7,10 @@ from app.helper.sites import SitesHelper # noqa from app.chain import ChainBase from app.chain.media import MediaChain -from app.chain.music import MusicChain from app.core.config import settings, global_vars from app.core.context import TorrentInfo, Context, MediaInfo from app.core.context import MusicInfo +from app.core.meta import MetaMusic from app.core.metainfo import MetaInfo from app.db.site_oper import SiteOper from app.db.systemconfig_oper import SystemConfigOper @@ -670,7 +670,7 @@ class TorrentsChain(ChainBase): continue logger.info(f'处理资源:{torrent.title} ...') if torrent.category == MediaType.MUSIC.value: - meta = MusicChain.parse_query(torrent.title) + meta = MetaMusic.parse_query(torrent.title) mediainfo = MusicInfo( title=meta.title, artists=list(meta.artists), diff --git a/app/chain/transfer.py b/app/chain/transfer.py index 4f57ef7d8..729c73707 100755 --- a/app/chain/transfer.py +++ b/app/chain/transfer.py @@ -1103,15 +1103,14 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): ) -> tuple[MetaMusic, Optional[MusicInfo]]: """为缺少远端身份的本地音频尝试目录级专辑匹配,命中后回填文件元数据。 - WAV 等无标签文件只能依靠目录结构和曲目特征识别;匹配结果在 MusicChain - 内按目录缓存,同一专辑目录内的后续文件不会重复请求远端。 + WAV 等无标签文件只能依靠目录结构和曲目特征识别;匹配结果由 MediaChain + 按目录缓存,同一专辑目录内的后续文件不会重复请求远端。 """ # 目录级匹配需要读取本地音频时长,远端存储文件无法参与 if file_meta.media_id or getattr(file_item, "storage", "local") != "local": return file_meta, None try: - from app.chain.music import MusicChain - matched = MusicChain().recognize_album_directory(file_path.parent) + matched = MediaChain().recognize_music_album_directory(file_path.parent) except Exception as err: logger.debug(f"音乐专辑目录匹配失败:{file_path} - {err}") return file_meta, None @@ -2122,7 +2121,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): ) recognize_kwargs = {"obtain_images": True} if task.media_source: - recognize_kwargs["source"] = task.media_source + recognize_kwargs["media_source"] = task.media_source if task.mtype: recognize_kwargs["mtype"] = task.mtype mediainfo = MediaChain().recognize_by_meta( @@ -2134,7 +2133,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): # 识别媒体信息 recognize_kwargs = {"obtain_images": True} if task.media_source: - recognize_kwargs["source"] = task.media_source + recognize_kwargs["media_source"] = task.media_source if task.mtype: recognize_kwargs["mtype"] = task.mtype mediainfo = MediaChain().recognize_by_meta( @@ -3237,7 +3236,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): meta: MetaBase = None, mediainfo: MediaInfo = None, mtype: Optional[MediaType] = None, - media_source: Optional[str] = None, + media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, target_directory: TransferDirectoryConf = None, target_storage: Optional[str] = None, @@ -3290,6 +3289,34 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): :param continue_callback: 继续处理回调 返回:成功标识,错误信息 """ + explicit_identity = media_source is not None or media_id is not None + normalized_source, normalized_media_id = resolve_media_identity( + media_source=media_source, + media_id=media_id, + ) + if explicit_identity and ( + not normalized_source or not normalized_media_id + ): + return False, "整理任务需要同时提供有效的 media_source 和 media_id" + if not explicit_identity and mediainfo: + normalized_source, normalized_media_id = resolve_media_identity( + media=mediainfo + ) + media_source = normalized_source + media_id = normalized_media_id + if explicit_identity and not mediainfo: + mediainfo = MediaChain().recognize_media( + mtype=mtype, + media_source=media_source, + media_id=media_id, + music_type=getattr(meta, "music_type", None), + ) + if not mediainfo: + return False, ( + "未识别到媒体信息," + f"media_source:{media_source},media_id:{media_id}" + ) + # 是否全部成功 all_success = True transfer_batch_id = str(uuid.uuid4()) diff --git a/app/core/context.py b/app/core/context.py index 10df95bd6..d7b2f37ec 100644 --- a/app/core/context.py +++ b/app/core/context.py @@ -20,7 +20,7 @@ from app.schemas.types import ( MediaSource, MediaType, ) -from app.utils.media import normalize_media_source +from app.utils.media import normalize_media_source, resolve_media_identity from app.utils.string import StringUtils BANGUMI_MOVIE_PLATFORMS = frozenset({"movie", "电影", "剧场版"}) @@ -168,8 +168,8 @@ class MusicInfo: raw_data: dict[str, Any] = field(default_factory=dict) def __post_init__(self) -> None: - """将构造参数中的数据源规范化为统一枚举。""" - self.media_source = normalize_media_source(self.media_source) + """将构造参数中的媒体身份规范化为统一成对字段。""" + self.media_source, self.media_id = resolve_media_identity(media=self) @property def artist(self) -> str: @@ -394,8 +394,8 @@ class MusicAlbumInfo: raw_data: dict[str, Any] = field(default_factory=dict) def __post_init__(self) -> None: - """将构造参数中的数据源规范化为统一枚举。""" - self.media_source = normalize_media_source(self.media_source) + """将构造参数中的媒体身份规范化为统一成对字段。""" + self.media_source, self.media_id = resolve_media_identity(media=self) @property def artist(self) -> str: @@ -558,8 +558,8 @@ class MusicArtistInfo: raw_data: dict[str, Any] = field(default_factory=dict) def __post_init__(self) -> None: - """将构造参数中的数据源规范化为统一枚举。""" - self.media_source = normalize_media_source(self.media_source) + """将构造参数中的媒体身份规范化为统一成对字段。""" + self.media_source, self.media_id = resolve_media_identity(media=self) @property def title(self) -> str | None: @@ -703,6 +703,10 @@ class TorrentInfo: # 种子分类 电影/电视剧/音乐 category: str = None + def __post_init__(self) -> None: + """将种子声明的媒体身份规范化为统一成对字段。""" + self.media_source, self.media_id = resolve_media_identity(media=self) + def __setattr__(self, name: str, value: Any): self.__dict__[name] = value @@ -726,9 +730,7 @@ class TorrentInfo: if key in properties: continue setattr(self, key, value) - self.media_source = normalize_media_source(self.media_source) - if self.media_id is not None: - self.media_id = str(self.media_id) + self.media_source, self.media_id = resolve_media_identity(media=self) @staticmethod def get_free_string(upload_volume_factor: float, download_volume_factor: float) -> str: @@ -1022,7 +1024,7 @@ class MediaInfo: def __post_init__(self): """规范化媒体来源,并从各来源原始数据初始化统一字段。""" - self.media_source = normalize_media_source(self.media_source) + self.media_source, self.media_id = resolve_media_identity(media=self) # 设置媒体信息 if self.tmdb_info: self.set_tmdb_info(self.tmdb_info) @@ -1032,7 +1034,7 @@ class MediaInfo: self.set_bangumi_info(self.bangumi_info) if self.anilist_info: self.set_anilist_info(self.anilist_info) - self.media_source = normalize_media_source(self.media_source) + self.media_source, self.media_id = resolve_media_identity(media=self) def __setattr__(self, name: str, value: Any): self.__dict__[name] = value @@ -1057,7 +1059,7 @@ class MediaInfo: if key in properties: continue setattr(self, key, value) - self.media_source = normalize_media_source(self.media_source) + self.media_source, self.media_id = resolve_media_identity(media=self) if isinstance(self.type, str): self.type = MediaType(self.type) diff --git a/app/core/meta/metabase.py b/app/core/meta/metabase.py index 3ca0682ea..1d1859db5 100644 --- a/app/core/meta/metabase.py +++ b/app/core/meta/metabase.py @@ -7,6 +7,7 @@ import regex as re from app.log import logger from app.schemas.types import MediaSource, MediaType +from app.utils.media import resolve_media_identity from app.utils.string import StringUtils @@ -678,12 +679,11 @@ class MetaBase(object): if not self.part: self.part = meta.part # 媒体身份必须原子合并,不能将不同目录层级的来源和ID拼成一对 - if not (self.media_source and self.media_id) and meta.media_source and meta.media_id: - try: - self.media_source = MediaSource(meta.media_source) - self.media_id = str(meta.media_id) - except ValueError: - pass + current_source, current_id = resolve_media_identity(media=self) + if current_source and current_id: + self.media_source, self.media_id = current_source, current_id + else: + self.media_source, self.media_id = resolve_media_identity(media=meta) # 剧集组 if not self.episode_group and meta.episode_group: self.episode_group = meta.episode_group diff --git a/app/core/meta/metamusic.py b/app/core/meta/metamusic.py index 8a9aeb4b3..fe22a8b58 100644 --- a/app/core/meta/metamusic.py +++ b/app/core/meta/metamusic.py @@ -5,7 +5,9 @@ from threading import RLock from typing import Any, Callable, Optional from app.core.meta.metabase import MetaBase -from app.schemas.types import MediaType +from app.schemas.types import MediaSource, MediaType +from app.utils import rust_accel +from app.utils.media import resolve_media_identity _AUDIO_FORMAT_PATTERN = re.compile( @@ -539,6 +541,8 @@ class MusicNameRegistry: _patterns: dict[str, MusicNamePattern] = {} _parsers: dict[str, MusicNameParser] = {} + _default_patterns: dict[str, MusicNamePattern] = {} + _default_parsers: dict[str, MusicNameParser] = {} _lock = RLock() @classmethod @@ -613,6 +617,32 @@ class MusicNameRegistry: return None return parser.handler(context, matched) + @classmethod + def _capture_default_components(cls) -> None: + """保存内置命名组件的对象快照,供 Rust 快路判断兼容性。""" + with cls._lock: + cls._default_patterns = dict(cls._patterns) + cls._default_parsers = dict(cls._parsers) + + @classmethod + def _uses_default_components(cls) -> bool: + """判断当前注册表是否仍为未替换的内置命名组件。""" + with cls._lock: + if not cls._default_patterns or not cls._default_parsers: + return False + if ( + cls._patterns.keys() != cls._default_patterns.keys() + or cls._parsers.keys() != cls._default_parsers.keys() + ): + return False + return all( + component is cls._default_patterns[name] + for name, component in cls._patterns.items() + ) and all( + component is cls._default_parsers[name] + for name, component in cls._parsers.items() + ) + class MetaMusic(MetaBase): """音乐文件名及音频标签解析结果,作为 MetaBase 的音乐分支实现。""" @@ -637,7 +667,7 @@ class MetaMusic(MetaBase): bitrate: Optional[int] = None, duration: Optional[int] = None, isrc: Optional[str] = None, - media_source: Optional[str] = None, + media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, parse_title: bool = False, ): @@ -662,12 +692,75 @@ class MetaMusic(MetaBase): self.bitrate = bitrate self.duration = duration self.isrc = isrc - self.media_source = media_source - self.media_id = media_id + self.media_source, self.media_id = resolve_media_identity( + media_source=media_source, + media_id=media_id, + ) if parse_title: # 种子/文件名字符串场景:解析艺术家、曲名、年份并补充音质参数 self.apply_title(self.title or org_string or "") + @classmethod + def parse_query(cls, query: str) -> "MetaMusic": + """把用户输入或资源标题解析为音乐元数据。""" + return cls(org_string=query, title=query, parse_title=True) + + @classmethod + def from_music_info(cls, info: Any) -> "MetaMusic": + """把标准音乐信息转换为下载、整理和站点搜索使用的元数据。""" + return cls( + title=info.title, + artists=list(info.artists), + album=info.album, + album_artist=info.album_artist, + year=info.year, + disc_number=info.disc_number, + track_number=info.track_number, + total_discs=getattr(info, "total_discs", None), + total_tracks=info.total_tracks, + version=info.version, + audio_format=info.audio_format, + audio_lossless=info.audio_lossless, + bit_depth=info.bit_depth, + sample_rate=info.sample_rate, + bitrate=info.bitrate, + duration=info.duration, + isrc=info.isrc, + media_source=info.media_source, + media_id=info.media_id, + ) + + @classmethod + def from_album_context( + cls, + directory_name: str, + tracks: list["MetaMusic"], + ) -> "MetaMusic": + """按目录名和多数音轨标签汇总专辑识别条件。""" + directory = cls.parse_album_dir(directory_name) + album_votes: dict[str, int] = {} + artist_votes: dict[str, int] = {} + for track in tracks: + if track.album: + album_votes[track.album] = album_votes.get(track.album, 0) + 1 + artist = track.album_artist or (track.artists[0] if track.artists else None) + if artist: + artist_votes[artist] = artist_votes.get(artist, 0) + 1 + majority_album = max(album_votes, key=album_votes.get) if album_votes else None + majority_artist = max(artist_votes, key=artist_votes.get) if artist_votes else None + threshold = max(2, len(tracks) // 2) + album = majority_album if majority_album and album_votes[majority_album] >= threshold else None + artist = majority_artist if majority_artist and artist_votes[majority_artist] >= threshold else None + return cls( + org_string=directory_name, + title=album or directory.get("album") or directory_name, + album=album or directory.get("album"), + artists=[artist or directory.get("artist")] + if artist or directory.get("artist") else [], + album_artist=artist or directory.get("artist"), + year=directory.get("year"), + ) + @property def name(self) -> str: """返回搜索和展示使用的音乐名称,优先专辑名其次标题。""" @@ -725,6 +818,14 @@ class MetaMusic(MetaBase): 命名模式和对应解析器,最后统一回填结构化字段并提取曲序前缀。 """ raw = str(value or "") + if MusicNameRegistry._uses_default_components(): + rust_result = rust_accel.parse_metamusic( + raw, + artists=list(self.artists) or None, + year=self.year, + ) + if rust_result and self._apply_rust_title_result(rust_result): + return self.apply_audio_quality(raw) context = self._prepare_name_context( raw=raw, @@ -743,6 +844,30 @@ class MetaMusic(MetaBase): self._apply_name_result(context, parsed) self._apply_track_prefix() + def _apply_rust_title_result(self, parsed: dict[str, Any]) -> bool: + """回填 Rust 音乐解析结果,并保留调用方已有的高可信字段。""" + if "title" not in parsed: + return False + parsed_meta = type(self).from_dict(parsed) + self.title = parsed_meta.title + for field_name in ( + "artists", + "album", + "year", + "disc_number", + "track_number", + "audio_format", + "audio_lossless", + "bit_depth", + "sample_rate", + "bitrate", + ): + current_value = getattr(self, field_name, None) + parsed_value = getattr(parsed_meta, field_name, None) + if current_value in (None, "", []) and parsed_value not in (None, "", []): + setattr(self, field_name, parsed_value) + return True + @classmethod def _prepare_name_context( cls, @@ -1778,6 +1903,7 @@ def _register_default_name_components() -> None: MusicNameRegistry.register_pattern(pattern) for parser in parsers: MusicNameRegistry.register_parser(parser) + MusicNameRegistry._capture_default_components() _register_default_name_components() diff --git a/app/core/metainfo.py b/app/core/metainfo.py index c23f7f512..51091e6b7 100644 --- a/app/core/metainfo.py +++ b/app/core/metainfo.py @@ -110,8 +110,9 @@ def _normalize_metainfo_identity(metainfo: dict) -> dict: if not media_source: for source, key in _LEGACY_ID_KEYS: value = normalized.get(key) - if value is not None and str(value).strip(): - media_source, media_id = source, str(value).strip() + normalized_id = str(value).strip() if value is not None else "" + if normalized_id and normalized_id != "0": + media_source, media_id = source, normalized_id break for _, key in _LEGACY_ID_KEYS: normalized.pop(key, None) @@ -177,9 +178,11 @@ def _find_metainfo_python(title: str) -> Tuple[str, dict]: legacy_matches = [] for source, pattern in _LEGACY_BRACED_ID_PATTERNS: legacy_match = pattern.search(result) - if legacy_match and legacy_match.group(0).isdigit(): - legacy_identities[source] = legacy_match.group(0) + if legacy_match: legacy_matches.append(legacy_match) + normalized_id = legacy_match.group(0) + if normalized_id.isdigit() and normalized_id != "0": + legacy_identities[source] = normalized_id # 查找媒体类型 mtype = _BRACED_TYPE_RE.search(result) if mtype: @@ -223,14 +226,16 @@ def _find_metainfo_python(title: str) -> Tuple[str, dict]: # 支持Emby格式的ID标签;第一个 [tmdbid] 历史上始终优先处理,用于覆盖前面 {[...]} 中的旧标签。 tmdb_match = _EMBY_TMDB_RE_LIST[0].search(title) if tmdb_match: - legacy_identities[MediaSource.TMDB] = tmdb_match.group(1) + if tmdb_match.group(1) != "0": + legacy_identities[MediaSource.TMDB] = tmdb_match.group(1) title = _EMBY_TMDB_RE_LIST[0].sub('', title).strip() elif MediaSource.TMDB not in legacy_identities: # 保持原有优先级:[tmdbid] > [tmdb] > {tmdbid} > {tmdb} for tmdb_re in _EMBY_TMDB_RE_LIST[1:]: tmdb_match = tmdb_re.search(title) if tmdb_match: - legacy_identities[MediaSource.TMDB] = tmdb_match.group(1) + if tmdb_match.group(1) != "0": + legacy_identities[MediaSource.TMDB] = tmdb_match.group(1) title = tmdb_re.sub('', title).strip() break @@ -242,7 +247,8 @@ def _find_metainfo_python(title: str) -> Tuple[str, dict]: media_id_match = media_id_re.search(title) if not media_id_match: continue - legacy_identities[source] = media_id_match.group(1) + if media_id_match.group(1) != "0": + legacy_identities[source] = media_id_match.group(1) title = media_id_re.sub('', title).strip() break @@ -426,14 +432,17 @@ def _requires_python_metainfo( custom_words: Optional[List[str]] = None, ) -> bool: """ - 判断标题或临时识别词是否包含当前Rust扩展尚未支持的数据源ID标签。 + 判断标题或临时识别词是否包含当前 Rust 扩展尚未支持的媒体身份标签。 :param title: 原始标题 :param custom_words: 临时识别词 :return: 是否必须使用Python解析器 """ candidates = [title or "", *(custom_words or [])] - if any(_GENERIC_MEDIA_ID_TAG_RE.search(candidate) for candidate in candidates): + contains_generic_id = any( + _GENERIC_MEDIA_ID_TAG_RE.search(candidate) for candidate in candidates + ) + if contains_generic_id and not rust_accel.supports_unified_media_identity(): return True contains_extended_id = any( _EXTENDED_MEDIA_ID_TAG_RE.search(candidate) for candidate in candidates @@ -486,14 +495,11 @@ def MetaInfoPath(path: Path, custom_words: List[str] = None, force_video: bool = # 音频文件直接构造音乐元数据,不参与父目录季集合并,影视附加音轨强制走视频解析 audio_suffix = path.suffix.lower() if not force_video and audio_suffix in settings.RMT_AUDIOEXT: - music_meta = MetaMusic( + return MetaMusic( org_string=path.name, title=path.stem, audio_format=audio_suffix.lstrip(".").upper() or None, - parse_title=True, - ) - # 无标签音频只能依靠文件名和目录结构,补充曲序、碟号、歌手和专辑线索 - return music_meta.apply_path_context(path) + ).apply_path_context(path) path_context = " ".join( [path.name, path.parent.name, path.parent.parent.name] ) diff --git a/app/core/plugin.py b/app/core/plugin.py index 1409ddb2b..ff5101a4c 100644 --- a/app/core/plugin.py +++ b/app/core/plugin.py @@ -1336,7 +1336,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): if not settings.PLUGIN_MARKET: return [] - # 当前版本及向后兼容的低版本标识,按优先级降序,均作为高版本来源拉取 + # 拉取当前索引及可扫描的旧索引;旧条目可用当前版本 false 显式排除。 compatible_flags = ( [settings.VERSION_FLAG] + VERSION_BACKWARD_COMPATIBLE_FLAGS.get(settings.VERSION_FLAG, []) if settings.VERSION_FLAG else [] @@ -1348,10 +1348,10 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): # future -> (market_index, is_higher, flag_priority) futures_meta: Dict[concurrent.futures.Future, Tuple[int, bool, int]] = {} for market_index, m in enumerate(markets): - # 提交任务获取 v1 版本插件 + # 默认索引只展示声明 V2 或当前版本兼容的共享实现。 base_future = executor.submit(self.get_plugins_from_market, m, None, force) futures_meta[base_future] = (market_index, False, 0) - # 提交任务获取高版本插件(如 v3)及向后兼容版本(如 v2) + # 提交当前专用索引(如 v3)及可扫描的旧索引(如 v2)。 for flag_priority, flag in enumerate(compatible_flags): higher_future = executor.submit(self.get_plugins_from_market, m, flag, force) futures_meta[higher_future] = (market_index, True, flag_priority) @@ -1628,8 +1628,9 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): return None plugin_info = PluginHelper.annotate_plugin_system_version(plugin_info.copy()) - # 如 package_version 为空(package.json 来源),则需要判断插件是否兼容当前版本或任一向后兼容版本 - if not package_version and not PluginHelper.is_plugin_info_compatible(plugin_info): + if not PluginHelper.is_package_plugin_compatible( + plugin_info, package_version or "" + ): # 插件当前版本不兼容 return None @@ -1762,7 +1763,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): base_version_plugins = [] tasks = [] - # 当前版本及向后兼容的低版本标识,按优先级降序,均作为高版本来源拉取 + # 拉取当前索引及可扫描的旧索引;旧条目可用当前版本 false 显式排除。 compatible_flags = ( [settings.VERSION_FLAG] + VERSION_BACKWARD_COMPATIBLE_FLAGS.get(settings.VERSION_FLAG, []) if settings.VERSION_FLAG else [] diff --git a/app/db/downloadfailure_oper.py b/app/db/downloadfailure_oper.py index 42faa3d26..22bb5e828 100644 --- a/app/db/downloadfailure_oper.py +++ b/app/db/downloadfailure_oper.py @@ -2,6 +2,7 @@ from typing import Dict, List, Optional from app.db import DbOper from app.db.models.downloadfailure import DownloadFailure +from app.utils.media import normalize_media_identity_payload class DownloadFailureOper(DbOper): @@ -38,6 +39,7 @@ class DownloadFailureOper(DbOper): """ 新增或更新资源失败记录。 """ + kwargs = normalize_media_identity_payload(kwargs) return DownloadFailure.record_failure( self._db, fingerprint=fingerprint, diff --git a/app/db/downloadhistory_oper.py b/app/db/downloadhistory_oper.py index c6d96d6df..f15b51b1b 100644 --- a/app/db/downloadhistory_oper.py +++ b/app/db/downloadhistory_oper.py @@ -2,6 +2,8 @@ from typing import Dict, List, Optional from app.db import DbOper from app.db.models.downloadhistory import DownloadHistory, DownloadFiles +from app.schemas.types import MediaSource +from app.utils.media import normalize_media_identity_payload class DownloadHistoryOper(DbOper): @@ -35,7 +37,7 @@ class DownloadHistoryOper(DbOper): } def get_by_media_identity( - self, media_source: str, media_id: str, + self, media_source: MediaSource, media_id: str, music_type: Optional[str] = None, ) -> List[DownloadHistory]: """ @@ -55,6 +57,7 @@ class DownloadHistoryOper(DbOper): """ 新增下载历史 """ + kwargs = normalize_media_identity_payload(kwargs) DownloadHistory(**kwargs).create(self._db) def add_files(self, file_items: List[dict]): @@ -137,7 +140,7 @@ class DownloadHistoryOper(DbOper): def get_last_by(self, mtype=None, title: Optional[str] = None, year: Optional[str] = None, season: Optional[str] = None, episode: Optional[str] = None, - media_source: Optional[str] = None, + media_source: Optional[MediaSource] = None, media_id: Optional[str] = None) -> List[DownloadHistory]: """ 按类型、标题、年份、季集查询下载记录 @@ -161,7 +164,7 @@ class DownloadHistoryOper(DbOper): username=username) def list_by_date( - self, date: str, type: str, media_source: str, media_id: str, + self, date: str, type: str, media_source: MediaSource, media_id: str, seasons: Optional[str] = None, ) -> List[DownloadHistory]: """ diff --git a/app/db/mediaserver_oper.py b/app/db/mediaserver_oper.py index 62e0cf2db..3b14bccec 100644 --- a/app/db/mediaserver_oper.py +++ b/app/db/mediaserver_oper.py @@ -4,6 +4,7 @@ from sqlalchemy.orm import Session from app.db import DbOper from app.db.models.mediaserver import MediaServerItem +from app.utils.media import normalize_media_identity_payload class MediaServerOper(DbOper): @@ -19,10 +20,11 @@ class MediaServerOper(DbOper): """ 过滤数据库模型不存在或不应由远端覆盖的字段 """ - return { + payload = { k: v for k, v in kwargs.items() if hasattr(MediaServerItem, k) and k != "id" } + return normalize_media_identity_payload(payload) def add(self, **kwargs) -> bool: """ diff --git a/app/db/models/downloadfailure.py b/app/db/models/downloadfailure.py index 61858bb95..695a15008 100644 --- a/app/db/models/downloadfailure.py +++ b/app/db/models/downloadfailure.py @@ -4,6 +4,7 @@ from sqlalchemy import Column, Float, Index, Integer, String from sqlalchemy.orm import Session from app.db import Base, db_query, db_update, get_id_column +from app.db.models.media_identity import media_identity_constraint class DownloadFailure(Base): @@ -53,6 +54,7 @@ class DownloadFailure(Base): next_retry_at = Column(String) __table_args__ = ( + media_identity_constraint("downloadfailure"), Index("ux_downloadfailure_fingerprint", "fingerprint", unique=True), Index("ix_downloadfailure_next_retry_at", "next_retry_at"), Index("ix_downloadfailure_media_identity_site", "type", "media_source", "media_id", "site"), diff --git a/app/db/models/downloadhistory.py b/app/db/models/downloadhistory.py index 0cb04eab6..6e9db79de 100644 --- a/app/db/models/downloadhistory.py +++ b/app/db/models/downloadhistory.py @@ -6,6 +6,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session from app.db import db_query, db_update, get_id_column, Base, async_db_query +from app.db.models.media_identity import media_identity_constraint +from app.schemas.types import MediaSource def _title_like(column, title: str): @@ -67,6 +69,7 @@ class DownloadHistory(Base): custom_words = Column(String) __table_args__ = ( + media_identity_constraint("downloadhistory"), Index('ix_downloadhistory_download_hash_date', 'download_hash', 'date'), Index('ix_downloadhistory_date_id', 'date', 'id'), Index('ix_downloadhistory_media_identity', 'media_source', 'media_id'), @@ -119,7 +122,7 @@ class DownloadHistory(Base): @classmethod @db_query def get_by_media_identity( - cls, db: Session, media_source: str, media_id: str, + cls, db: Session, media_source: MediaSource, media_id: str, music_type: Optional[str] = None, ): """按规范媒体身份查询下载历史。""" @@ -205,7 +208,7 @@ class DownloadHistory(Base): year: Optional[str] = None, season: Optional[str] = None, episode: Optional[str] = None, - media_source: Optional[str] = None, + media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, ): """ @@ -321,7 +324,7 @@ class DownloadHistory(Base): db: Session, date: str, type: str, - media_source: str, + media_source: MediaSource, media_id: str, seasons: Optional[str] = None, ): diff --git a/app/db/models/media_identity.py b/app/db/models/media_identity.py new file mode 100644 index 000000000..57e72e411 --- /dev/null +++ b/app/db/models/media_identity.py @@ -0,0 +1,22 @@ +from sqlalchemy import CheckConstraint + +from app.schemas.types import MediaSource + + +MEDIA_SOURCE_SQL_VALUES = ", ".join( + f"'{media_source.value}'" for media_source in MediaSource +) +MEDIA_IDENTITY_CHECK_SQL = ( + "(media_source IS NULL AND media_id IS NULL) OR " + "(media_source IS NOT NULL AND " + f"media_source IN ({MEDIA_SOURCE_SQL_VALUES}) AND " + "media_id IS NOT NULL AND trim(media_id) <> '' AND trim(media_id) <> '0')" +) + + +def media_identity_constraint(table_name: str) -> CheckConstraint: + """构造通用媒体表使用的来源枚举与身份成对数据库约束。""" + return CheckConstraint( + MEDIA_IDENTITY_CHECK_SQL, + name=f"ck_{table_name}_media_identity", + ) diff --git a/app/db/models/mediaserver.py b/app/db/models/mediaserver.py index 1ab562da0..4a9d54be5 100644 --- a/app/db/models/mediaserver.py +++ b/app/db/models/mediaserver.py @@ -7,6 +7,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session from app.db import db_query, db_update, get_id_column, async_db_query, Base +from app.db.models.media_identity import media_identity_constraint +from app.schemas.types import MediaSource class MediaServerItem(Base): @@ -41,6 +43,7 @@ class MediaServerItem(Base): lst_mod_date = Column(String, default=datetime.now().strftime("%Y-%m-%d %H:%M:%S")) __table_args__ = ( + media_identity_constraint("mediaserveritem"), Index('ux_mediaserveritem_server_item_id', 'server', 'item_id', unique=True), Index( 'ix_mediaserveritem_media_identity_type', @@ -85,7 +88,7 @@ class MediaServerItem(Base): @classmethod @db_query def exist_by_media_identity( - cls, db: Session, media_source: str, media_id: str, mtype: str, + cls, db: Session, media_source: MediaSource, media_id: str, mtype: str, ): """按规范媒体身份和类型查询媒体服务器条目。""" return db.query(cls).filter( @@ -118,7 +121,7 @@ class MediaServerItem(Base): @classmethod @async_db_query async def async_exist_by_media_identity( - cls, db: AsyncSession, media_source: str, media_id: str, mtype: str, + cls, db: AsyncSession, media_source: MediaSource, media_id: str, mtype: str, ): """异步按规范媒体身份和类型查询媒体服务器条目。""" result = await db.execute(select(cls).filter( diff --git a/app/db/models/subscribe.py b/app/db/models/subscribe.py index f55c603f2..814161475 100644 --- a/app/db/models/subscribe.py +++ b/app/db/models/subscribe.py @@ -6,7 +6,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session from app.db import db_query, db_update, get_id_column, Base, async_db_query, async_db_update -from app.schemas.types import MUSIC_ENTITY_RECORDING +from app.db.models.media_identity import media_identity_constraint +from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource class Subscribe(Base): @@ -112,6 +113,7 @@ class Subscribe(Base): episode_group = Column(String) __table_args__ = ( + media_identity_constraint("subscribe"), Index('ix_subscribe_type_date', 'type', 'date'), Index('ix_subscribe_media_identity', 'media_source', 'media_id'), ) @@ -119,7 +121,7 @@ class Subscribe(Base): @classmethod def _identity_condition( cls, - media_source: Optional[str] = None, + media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, music_type: Optional[str] = None, ): @@ -139,7 +141,7 @@ class Subscribe(Base): @classmethod @db_query def exists( - cls, db: Session, media_source: str, media_id: str, + cls, db: Session, media_source: MediaSource, media_id: str, season: Optional[int] = None, episode_group: Optional[str] = None, music_type: Optional[str] = None, @@ -159,7 +161,7 @@ class Subscribe(Base): @classmethod @async_db_query async def async_exists( - cls, db: AsyncSession, media_source: str, media_id: str, + cls, db: AsyncSession, media_source: MediaSource, media_id: str, season: Optional[int] = None, episode_group: Optional[str] = None, music_type: Optional[str] = None, @@ -180,7 +182,7 @@ class Subscribe(Base): @classmethod @db_query def exists_by_username( - cls, db: Session, username: str, media_source: str, media_id: str, + cls, db: Session, username: str, media_source: MediaSource, media_id: str, season: Optional[int] = None, episode_group: Optional[str] = None, music_type: Optional[str] = None, @@ -204,7 +206,7 @@ class Subscribe(Base): @classmethod @async_db_query async def async_exists_by_username( - cls, db: AsyncSession, username: str, media_source: str, + cls, db: AsyncSession, username: str, media_source: MediaSource, media_id: str, season: Optional[int] = None, episode_group: Optional[str] = None, music_type: Optional[str] = None, @@ -289,7 +291,7 @@ class Subscribe(Base): @classmethod @db_query def list_by_media_identity( - cls, db: Session, media_source: str, media_id: str, + cls, db: Session, media_source: MediaSource, media_id: str, music_type: Optional[str] = None, ): """同步按统一媒体身份查询候选订阅列表。""" @@ -305,7 +307,7 @@ class Subscribe(Base): @classmethod @async_db_query async def async_list_by_media_identity( - cls, db: AsyncSession, media_source: str, media_id: str, + cls, db: AsyncSession, media_source: MediaSource, media_id: str, music_type: Optional[str] = None, ): """异步按统一媒体身份查询候选订阅列表。""" @@ -322,7 +324,7 @@ class Subscribe(Base): @classmethod @db_query def get_by( - cls, db: Session, type: str, media_source: str, media_id: str, + cls, db: Session, type: str, media_source: MediaSource, media_id: str, season: Optional[str] = None, music_type: Optional[str] = None, ): @@ -342,7 +344,7 @@ class Subscribe(Base): @classmethod @async_db_query async def async_get_by( - cls, db: AsyncSession, type: str, media_source: str, media_id: str, + cls, db: AsyncSession, type: str, media_source: MediaSource, media_id: str, season: Optional[str] = None, music_type: Optional[str] = None, ): @@ -362,7 +364,7 @@ class Subscribe(Base): @db_update def delete_by_media_identity( - self, db: Session, media_source: str, media_id: str, + self, db: Session, media_source: MediaSource, media_id: str, season: Optional[int] = None, ) -> bool: """按规范媒体身份删除订阅。""" @@ -377,7 +379,7 @@ class Subscribe(Base): @async_db_update async def async_delete_by_media_identity( - self, db: AsyncSession, media_source: str, media_id: str, + self, db: AsyncSession, media_source: MediaSource, media_id: str, season: Optional[int] = None, ) -> bool: """异步按规范媒体身份删除订阅。""" diff --git a/app/db/models/subscribehistory.py b/app/db/models/subscribehistory.py index 7e882aca2..f0ecab1cb 100644 --- a/app/db/models/subscribehistory.py +++ b/app/db/models/subscribehistory.py @@ -5,7 +5,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session from app.db import db_query, Base, get_id_column, async_db_query -from app.schemas.types import MUSIC_ENTITY_RECORDING +from app.db.models.media_identity import media_identity_constraint +from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource class SubscribeHistory(Base): @@ -99,6 +100,7 @@ class SubscribeHistory(Base): episode_group = Column(String) __table_args__ = ( + media_identity_constraint("subscribehistory"), Index('ix_subscribehistory_type_date', 'type', 'date'), Index('ix_subscribehistory_media_identity', 'media_source', 'media_id'), ) @@ -152,7 +154,7 @@ class SubscribeHistory(Base): @classmethod def _identity_condition( cls, - media_source: Optional[str] = None, + media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, music_type: Optional[str] = None, ): @@ -172,7 +174,7 @@ class SubscribeHistory(Base): @classmethod @db_query def exists( - cls, db: Session, media_source: str, media_id: str, + cls, db: Session, media_source: MediaSource, media_id: str, season: Optional[int] = None, episode_group: Optional[str] = None, music_type: Optional[str] = None, @@ -192,7 +194,7 @@ class SubscribeHistory(Base): @classmethod @async_db_query async def async_exists( - cls, db: AsyncSession, media_source: str, media_id: str, + cls, db: AsyncSession, media_source: MediaSource, media_id: str, season: Optional[int] = None, episode_group: Optional[str] = None, music_type: Optional[str] = None, diff --git a/app/db/models/transferhistory.py b/app/db/models/transferhistory.py index 50ac68338..b0b9d97a8 100644 --- a/app/db/models/transferhistory.py +++ b/app/db/models/transferhistory.py @@ -8,7 +8,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session from app.db import db_query, db_update, get_id_column, Base, async_db_query -from app.schemas.types import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MediaType +from app.db.models.media_identity import media_identity_constraint +from app.schemas.types import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MediaSource, MediaType def _text_like(column, pattern: str, wildcard: bool = False): @@ -84,6 +85,7 @@ class TransferHistory(Base): episode_group = Column(String) __table_args__ = ( + media_identity_constraint("transferhistory"), Index('ix_transferhistory_status_date', 'status', 'date'), Index('ix_transferhistory_date_id', 'date', 'id'), Index('ix_transferhistory_media_identity', 'media_source', 'media_id'), @@ -476,7 +478,7 @@ class TransferHistory(Base): def list_by(cls, db: Session, mtype: Optional[str] = None, title: Optional[str] = None, year: Optional[str] = None, season: Optional[str] = None, episode: Optional[str] = None, - media_source: Optional[str] = None, + media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, dest: Optional[str] = None): """ @@ -544,7 +546,7 @@ class TransferHistory(Base): @classmethod @db_query def get_by_media_identity( - cls, db: Session, media_source: str, media_id: str, + cls, db: Session, media_source: MediaSource, media_id: str, mtype: Optional[str] = None, ): """按规范媒体身份和类型查询整理记录。""" diff --git a/app/db/subscribe_oper.py b/app/db/subscribe_oper.py index 83745d19d..211987a91 100644 --- a/app/db/subscribe_oper.py +++ b/app/db/subscribe_oper.py @@ -5,8 +5,8 @@ from app.core.context import MediaInfo, MusicInfo from app.db import DbOper from app.db.models.subscribe import Subscribe from app.db.models.subscribehistory import SubscribeHistory -from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaType -from app.utils.media import resolve_media_identity +from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaSource, MediaType +from app.utils.media import normalize_media_identity_payload, resolve_media_identity INTEGER_FLAG_FIELDS = ("best_version", "best_version_full", "search_imdbid", "manual_total_episode") @@ -164,7 +164,7 @@ class SubscribeOper(DbOper): return subscribe.id, "订阅已存在" def exists( - self, media_source: str, media_id: str, + self, media_source: MediaSource, media_id: str, season: Optional[int] = None, episode_group: Optional[str] = None, music_type: Optional[str] = None, ) -> bool: @@ -193,7 +193,7 @@ class SubscribeOper(DbOper): return await Subscribe.async_get(self._db, rid=sid) def get_by( - self, type: str, media_source: str, media_id: str, + self, type: str, media_source: MediaSource, media_id: str, season: Optional[str] = None, music_type: Optional[str] = None, ) -> Optional[Subscribe]: @@ -205,7 +205,7 @@ class SubscribeOper(DbOper): ) async def async_get_by( - self, type: str, media_source: str, media_id: str, + self, type: str, media_source: MediaSource, media_id: str, season: Optional[str] = None, music_type: Optional[str] = None, ) -> Optional[Subscribe]: @@ -289,6 +289,7 @@ class SubscribeOper(DbOper): """ # 去除kwargs中 SubscribeHistory 没有的字段 kwargs = {k: v for k, v in kwargs.items() if hasattr(SubscribeHistory, k)} + kwargs = normalize_media_identity_payload(kwargs) kwargs = _normalize_integer_flags(kwargs) # 更新完成订阅时间 kwargs.update({"date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())}) @@ -299,7 +300,7 @@ class SubscribeOper(DbOper): subscribe.create(self._db) def exist_history( - self, media_source: str, media_id: str, + self, media_source: MediaSource, media_id: str, season: Optional[int] = None, episode_group: Optional[str] = None, music_type: Optional[str] = None, ) -> bool: diff --git a/app/db/transferhistory_oper.py b/app/db/transferhistory_oper.py index 6fe97790c..392c2bc53 100644 --- a/app/db/transferhistory_oper.py +++ b/app/db/transferhistory_oper.py @@ -6,7 +6,8 @@ from app.core.meta import MetaBase, MetaMusic from app.db import DbOper from app.db.models.transferhistory import TransferHistory from app.schemas import TransferInfo, FileItem -from app.schemas.types import MUSIC_ENTITY_RECORDING +from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource +from app.utils.media import normalize_media_identity_payload, resolve_media_identity class TransferHistoryOper(DbOper): @@ -153,6 +154,7 @@ class TransferHistoryOper(DbOper): """ 新增转移历史 """ + kwargs = normalize_media_identity_payload(kwargs) kwargs.update({ "date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) }) @@ -166,7 +168,7 @@ class TransferHistoryOper(DbOper): def get_by(self, title: Optional[str] = None, year: Optional[str] = None, mtype: Optional[str] = None, season: Optional[str] = None, episode: Optional[str] = None, - media_source: Optional[str] = None, media_id: Optional[str] = None, + media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, dest: Optional[str] = None) -> List[TransferHistory]: """ 按类型、标题、年份、季集查询转移记录 @@ -182,7 +184,7 @@ class TransferHistoryOper(DbOper): media_id=media_id) def get_by_media_identity( - self, media_source: str, media_id: str, + self, media_source: MediaSource, media_id: str, mtype: Optional[str] = None, ) -> TransferHistory: """按规范媒体身份和类型查询整理记录。""" @@ -215,6 +217,7 @@ class TransferHistoryOper(DbOper): """ 新增转移历史,相同源目录的记录会被删除 """ + kwargs = normalize_media_identity_payload(kwargs) if kwargs.get("src"): transferhistory = TransferHistory.get_by_src(self._db, kwargs.get("src")) if transferhistory: @@ -248,6 +251,7 @@ class TransferHistoryOper(DbOper): """ 新增转移成功历史记录 """ + media_source, media_id = resolve_media_identity(media=mediainfo) return self.add_force( src=fileitem.path, src_storage=fileitem.storage, @@ -260,8 +264,8 @@ class TransferHistoryOper(DbOper): category=mediainfo.category, title=self._history_title(meta, mediainfo), year=mediainfo.year, - media_source=str(mediainfo.media_source), - media_id=mediainfo.media_id, + media_source=media_source, + media_id=media_id, music_type=getattr(mediainfo, "music_type", None), total_tracks=getattr(mediainfo, "total_tracks", None), audio_format=getattr(meta, "audio_format", None), @@ -284,6 +288,7 @@ class TransferHistoryOper(DbOper): 新增转移失败历史记录 """ if mediainfo and transferinfo: + media_source, media_id = resolve_media_identity(media=mediainfo) his = self.add_force( src=fileitem.path, src_storage=fileitem.storage, @@ -296,8 +301,8 @@ class TransferHistoryOper(DbOper): category=mediainfo.category, title=self._history_title(meta, mediainfo), year=mediainfo.year or meta.year, - media_source=str(mediainfo.media_source), - media_id=mediainfo.media_id, + media_source=media_source, + media_id=media_id, music_type=getattr(mediainfo, "music_type", None), total_tracks=getattr(mediainfo, "total_tracks", None), audio_format=getattr(meta, "audio_format", None), @@ -316,12 +321,13 @@ class TransferHistoryOper(DbOper): files=transferinfo.file_list ) else: + media_source, media_id = resolve_media_identity(media=meta) his = self.add_force( type=meta.type.value if meta.type else None, title=self._history_title(meta), year=meta.year, - media_source=str(meta.media_source) if meta.media_source else None, - media_id=meta.media_id, + media_source=media_source, + media_id=media_id, music_type=MUSIC_ENTITY_RECORDING if isinstance(meta, MetaMusic) else None, audio_format=getattr(meta, "audio_format", None), audio_lossless=getattr(meta, "audio_lossless", None), diff --git a/app/helper/audio.py b/app/helper/audio.py index 448a5d482..b45471b19 100644 --- a/app/helper/audio.py +++ b/app/helper/audio.py @@ -10,7 +10,7 @@ from mutagen.mp4 import MP4, MP4Cover from app.core.context import MusicInfo from app.core.meta import MetaMusic from app.log import logger -from app.schemas.types import MUSIC_ENTITY_RECORDING +from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource class AudioMetadataHelper: @@ -24,6 +24,24 @@ class AudioMetadataHelper: return tag_meta.apply_path_context(path) return cls.read_filename(path) + @classmethod + def read_evidence( + cls, + path: Path, + ) -> tuple[MetaMusic, Optional[MetaMusic], MetaMusic]: + """分别返回合并元数据、纯标签元数据和纯文件名元数据。""" + filename_meta = cls.read_filename(path) + tag_meta = cls.read_tags(path) if path.exists() and path.is_file() else None + if not tag_meta: + return filename_meta, None, filename_meta + merged_meta = MetaMusic.from_dict(tag_meta.to_dict()).apply_path_context(path) + return merged_meta, tag_meta, filename_meta + + @classmethod + def read_many(cls, paths: list[Path]) -> list[MetaMusic]: + """批量读取一组音频路径的标签与文件名元数据。""" + return [cls.read(path) for path in paths] + @classmethod def read_tags(cls, path: Path) -> Optional[MetaMusic]: """只读取本地音频标签和流参数,不使用文件名或目录补齐。""" @@ -64,7 +82,7 @@ class AudioMetadataHelper: bitrate=cls._optional_int(getattr(info, "bitrate", None)), duration=round(info.length) if info and getattr(info, "length", None) else None, isrc=cls._first(tags, "isrc"), - media_source="musicbrainz" if musicbrainz_id else None, + media_source=MediaSource.MusicBrainz if musicbrainz_id else None, media_id=musicbrainz_id, ) @@ -151,11 +169,8 @@ class AudioMetadataHelper: music: Union[MetaMusic, MusicInfo], ) -> Optional[str]: """仅将 MusicBrainz 单曲身份写入 recording 标签,避免误写专辑 ID。""" - if getattr(music, "media_source", None) == "musicbrainz": - media_id = getattr(music, "media_id", None) - return str(media_id) if media_id else None if ( - getattr(music, "media_source", None) == "musicbrainz" + getattr(music, "media_source", None) == MediaSource.MusicBrainz and getattr(music, "music_type", MUSIC_ENTITY_RECORDING) == MUSIC_ENTITY_RECORDING ): diff --git a/app/helper/plugin.py b/app/helper/plugin.py index caff7f35a..b811da9d6 100644 --- a/app/helper/plugin.py +++ b/app/helper/plugin.py @@ -43,8 +43,7 @@ from version import APP_VERSION PLUGIN_DIR = Path(settings.ROOT_PATH) / "app" / "plugins" LOCAL_REPO_PREFIX = "local://" PLUGIN_SYSTEM_VERSION_FIELD = "system_version" -# 主程序重大版本向后兼容声明:键为当前 VERSION_FLAG,值为该版本可向下兼容的更低版本标识列表(按优先级降序)。 -# 例如 v3 兼容 v2,则 package.json 中声明 "v2": true 的插件、package.v2.json 中的插件均视为可用。 +# 主程序重大版本可扫描的旧索引;V3 临时默认兼容 V2,条目可用 v3:false 排除。 VERSION_BACKWARD_COMPATIBLE_FLAGS: Dict[str, List[str]] = { "v3": ["v2"], } @@ -178,18 +177,50 @@ class PluginHelper(metaclass=WeakSingleton): """ 判断 package.json 中的插件元数据是否兼容当前主程序版本。 - 兼容条件:未启用 VERSION_FLAG(v1)时默认全部兼容;否则需声明当前 VERSION_FLAG 为 True, - 或声明任一向后兼容的低版本标识为 True。 + 默认索引需要声明当前版本;V3 临时兼容已声明 V2 的共享实现, + 但显式 ``v3: false`` 始终优先拒绝。 """ if not isinstance(plugin_info, dict): return False if not settings.VERSION_FLAG: return True - if plugin_info.get(settings.VERSION_FLAG) is True: + current_flag = settings.VERSION_FLAG + if plugin_info.get(current_flag) is False: + return False + if plugin_info.get(current_flag) is True: return True - for flag in VERSION_BACKWARD_COMPATIBLE_FLAGS.get(settings.VERSION_FLAG, []): - if plugin_info.get(flag) is True: - return True + return any( + plugin_info.get(flag) is True + for flag in VERSION_BACKWARD_COMPATIBLE_FLAGS.get( + current_flag, [] + ) + ) + + @classmethod + def is_package_plugin_compatible( + cls, + plugin_info: Optional[dict], + package_version: Optional[str], + ) -> bool: + """ + 判断指定索引中的插件条目能否在当前主程序版本使用。 + + 当前代专用索引直接兼容。V3 临时默认兼容 V2 专用索引, + 除非条目显式声明 ``v3: false``;默认索引仍需先声明 ``v2: true``。 + """ + if not isinstance(plugin_info, dict): + return False + current_flag = settings.VERSION_FLAG + if not current_flag: + return not package_version + if package_version == current_flag: + return True + if package_version in VERSION_BACKWARD_COMPATIBLE_FLAGS.get( + current_flag, [] + ): + return plugin_info.get(current_flag) is not False + if not package_version: + return cls.is_plugin_info_compatible(plugin_info) return False @classmethod @@ -310,8 +341,9 @@ class PluginHelper(metaclass=WeakSingleton): for pid, plugin_info in local_plugins.items(): if not isinstance(plugin_info, dict): continue - # package.json 中的旧结构需要声明兼容当前版本或任一向后兼容版本。 - if not package_version and not self.is_plugin_info_compatible(plugin_info): + if not self.is_package_plugin_compatible( + plugin_info, package_version + ): continue plugin_dir = self.__get_local_plugin_dir(repo_path, pid, package_version) @@ -374,10 +406,9 @@ class PluginHelper(metaclass=WeakSingleton): for candidate_pid, plugin_info in local_plugins.items(): if candidate_pid.lower() != pid.lower() or not isinstance(plugin_info, dict): continue - # 指定版本 package 文件视为可用;package.json 需声明当前版本或任一向后兼容版本。 - is_compatible = ( - bool(current_package_version) - or self.is_plugin_info_compatible(plugin_info) + is_compatible = self.is_package_plugin_compatible( + plugin_info, + current_package_version or "", ) if not is_compatible and strict_compat: continue @@ -394,7 +425,7 @@ class PluginHelper(metaclass=WeakSingleton): if not is_compatible: candidate["compatible"] = False candidate["skip_reason"] = ( - f"package.json 未声明 {settings.VERSION_FLAG} 或向后兼容版本" + f"插件索引条目不兼容 {settings.VERSION_FLAG}" ) self.annotate_plugin_system_version(candidate) if strict_system_version and candidate.get("system_version_compatible") is False: @@ -613,8 +644,8 @@ class PluginHelper(metaclass=WeakSingleton): 检查并获取指定插件的可用版本,支持多版本优先级加载和版本兼容性检测 1. 如果未指定版本,则使用系统配置的默认版本(通过 settings.VERSION_FLAG 设置) 2. 优先检查指定版本的插件(如 `package.v2.json`) - 3. 向后兼容:检查更低版本的 package 文件,安装对应版本代码 - 4. 检查 `package.json` 文件,插件声明当前版本或任一向后兼容版本均视为可用 + 3. 检查更低版本的 package 文件,并应用版本兼容标志 + 4. 检查 `package.json` 文件,并应用共享实现兼容标志 5. 如果插件不存在或不兼容指定版本,返回 `None` :param pid: 插件 ID,用于在插件列表中查找 :param repo_url: 插件仓库的 URL,指定用于获取插件信息的 GitHub 仓库地址 @@ -625,18 +656,24 @@ class PluginHelper(metaclass=WeakSingleton): if not package_version: package_version = settings.VERSION_FLAG - # 优先检查指定版本的插件,即 package.v(x).json 文件中是否存在该插件,如果存在,返回该版本号 - if pid in (self.get_plugins(repo_url, package_version) or []): + # 优先检查指定索引;即使显式指定 V2,也必须尊重 v3:false 排除标志。 + plugin = (self.get_plugins(repo_url, package_version) or {}).get(pid) + if plugin and self.is_package_plugin_compatible( + plugin, package_version + ): return package_version - # 向后兼容:检查更低版本的 package 文件,命中则安装对应版本代码 + # V3 临时默认接纳 V2 专用索引,v3:false 的 V3 专用副本旧条目除外。 for backward_flag in VERSION_BACKWARD_COMPATIBLE_FLAGS.get(package_version, []): - if pid in (self.get_plugins(repo_url, backward_flag) or []): + plugin = (self.get_plugins(repo_url, backward_flag) or {}).get(pid) + if plugin and self.is_package_plugin_compatible( + plugin, backward_flag + ): return backward_flag - # 检查全局 package.json 文件,插件声明当前版本或任一向后兼容版本均视为可用,安装基础代码 + # 默认索引只接纳声明 v2:true 或当前版本兼容的共享实现。 plugin = (self.get_plugins(repo_url) or {}).get(pid, None) - if plugin and self.is_plugin_info_compatible(plugin): + if plugin and self.is_package_plugin_compatible(plugin, ""): return "" # 如果所有版本都不存在或插件不兼容,返回 None,表示插件不可用 @@ -2177,16 +2214,26 @@ class PluginHelper(metaclass=WeakSingleton): if not package_version: package_version = settings.VERSION_FLAG - if pid in (await self.async_get_plugins(repo_url, package_version) or []): + plugin = ( + await self.async_get_plugins(repo_url, package_version) or {} + ).get(pid) + if plugin and self.is_package_plugin_compatible( + plugin, package_version + ): return package_version - # 向后兼容:检查更低版本的 package 文件,命中则安装对应版本代码 + # 异步安装链路与同步链路使用相同的 V2 默认兼容规则。 for backward_flag in VERSION_BACKWARD_COMPATIBLE_FLAGS.get(package_version, []): - if pid in (await self.async_get_plugins(repo_url, backward_flag) or []): + plugin = ( + await self.async_get_plugins(repo_url, backward_flag) or {} + ).get(pid) + if plugin and self.is_package_plugin_compatible( + plugin, backward_flag + ): return backward_flag plugin = (await self.async_get_plugins(repo_url) or {}).get(pid, None) - if plugin and self.is_plugin_info_compatible(plugin): + if plugin and self.is_package_plugin_compatible(plugin, ""): return "" return None diff --git a/app/helper/server.py b/app/helper/server.py index 2637b270c..4973c62bb 100644 --- a/app/helper/server.py +++ b/app/helper/server.py @@ -50,6 +50,19 @@ class MoviePilotServerHelper: _RECOGNIZE_SHARE_PATH = "/recognize/share" _USER_PERMISSIONS_PATH = "/user/permissions" _LOCAL_REPO_PREFIX = "local://" + _SUBSCRIBE_STATISTIC_FIELDS = frozenset({ + "name", "year", "type", "media_source", "media_id", "music_type", + "total_tracks", "genre_ids", "season", "poster", "backdrop", "vote", + "description", + }) + _SUBSCRIBE_SHARE_FIELDS = frozenset({ + "share_title", "share_comment", "share_user", "share_uid", "name", + "year", "type", "keyword", "media_source", "media_id", "music_type", + "total_tracks", "season", "poster", "backdrop", "vote", "description", + "genre_ids", "include", "exclude", "quality", "resolution", "effect", + "total_episode", "custom_words", "media_category", "episode_group", + "date", + }) _user_uid: Optional[str] = None _github_user: Optional[str] = None @@ -821,7 +834,10 @@ class MoviePilotServerHelper: """ if not settings.SUBSCRIBE_STATISTIC_SHARE: return False - res = cls.subscribe_add(sub) + payload = cls._build_subscribe_statistic_payload(sub) + if not payload: + return False + res = cls.subscribe_add(payload) return bool(res is not None and res.status_code == 200) @classmethod @@ -831,7 +847,10 @@ class MoviePilotServerHelper: """ if not settings.SUBSCRIBE_STATISTIC_SHARE: return False - res = await cls.async_subscribe_add(sub) + payload = cls._build_subscribe_statistic_payload(sub) + if not payload: + return False + res = await cls.async_subscribe_add(payload) return bool(res is not None and res.status_code == 200) @classmethod @@ -841,7 +860,10 @@ class MoviePilotServerHelper: """ if not settings.SUBSCRIBE_STATISTIC_SHARE: return False - res = cls.subscribe_done(sub) + payload = cls._build_subscribe_statistic_payload(sub) + if not payload: + return False + res = cls.subscribe_done(payload) return bool(res is not None and res.status_code == 200) @classmethod @@ -870,7 +892,14 @@ class MoviePilotServerHelper: subscribes = SubscribeOper().list() if not subscribes: return True - res = cls.subscribe_report([sub.to_dict() for sub in subscribes]) + payloads = [ + payload + for sub in subscribes + if (payload := cls._build_subscribe_statistic_payload(sub.to_dict())) + ] + if not payloads: + return True + res = cls.subscribe_report(payloads) return bool(res is not None and res.status_code == 200) @classmethod @@ -889,15 +918,15 @@ class MoviePilotServerHelper: subscribe = SubscribeOper().get(subscribe_id) if not subscribe: return False, "订阅不存在" - subscribe_dict = subscribe.to_dict() - subscribe_dict.pop("id", None) - payload = { + payload = cls._build_subscribe_share_payload({ "share_title": share_title, "share_comment": share_comment, "share_user": share_user, "share_uid": cls.get_user_uuid(), - **subscribe_dict, - } + **subscribe.to_dict(), + }) + if not payload: + return False, "订阅媒体身份不完整" return cls._handle_response(cls.subscribe_share(payload), cls._clear_subscribe_share_cache) @classmethod @@ -916,20 +945,58 @@ class MoviePilotServerHelper: subscribe = await SubscribeOper().async_get(subscribe_id) if not subscribe: return False, "订阅不存在" - subscribe_dict = subscribe.to_dict() - subscribe_dict.pop("id", None) - payload = { + payload = cls._build_subscribe_share_payload({ "share_title": share_title, "share_comment": share_comment, "share_user": share_user, "share_uid": cls.get_user_uuid(), - **subscribe_dict, - } + **subscribe.to_dict(), + }) + if not payload: + return False, "订阅媒体身份不完整" return cls._handle_response( await cls.async_subscribe_share(payload), cls._clear_subscribe_share_cache, ) + @classmethod + def _build_subscribe_statistic_payload( + cls, item: Optional[dict] + ) -> Optional[dict]: + """构造中心服务订阅统计载荷,只保留统一身份和公开统计字段。""" + if not isinstance(item, dict): + return None + media_source, media_id = resolve_media_identity(media=item) + if not media_source or not media_id: + return None + payload = { + key: value + for key, value in item.items() + if key in cls._SUBSCRIBE_STATISTIC_FIELDS + } + payload["media_source"] = str(media_source) + payload["media_id"] = media_id + return payload + + @classmethod + def _build_subscribe_share_payload( + cls, item: Optional[dict] + ) -> Optional[dict]: + """构造中心服务订阅分享载荷,隔离本地运行字段和旧专用 ID。""" + if not isinstance(item, dict): + return None + media_source, media_id = resolve_media_identity(media=item) + if not media_source or not media_id: + return None + payload = { + key: value + for key, value in item.items() + if key in cls._SUBSCRIBE_SHARE_FIELDS + } + payload["media_source"] = str(media_source) + payload["media_id"] = media_id + return payload + @classmethod def share_delete(cls, share_id: int) -> Tuple[bool, str]: """ diff --git a/app/locales/en-US.json b/app/locales/en-US.json index 753ffab37..a79c18333 100644 --- a/app/locales/en-US.json +++ b/app/locales/en-US.json @@ -97,8 +97,12 @@ } }, "messages": { + "无效的媒体来源": "Invalid media source", + "该媒体来源不支持此音乐接口": "This media source is not supported by this music endpoint", "媒体来源和媒体 ID 必须同时提供": "Media source and media ID must be provided together", "media_source 和 media_id 必须同时提供": "media_source and media_id must be provided together", + "新增订阅时必须同时提供有效的 media_source 和 media_id": "A valid media_source and media_id must be provided together when creating a subscription", + "更新媒体身份时必须同时提供有效的 media_source 和 media_id": "A valid media_source and media_id must be provided together when updating media identity", "模块不支持测试": "Module does not support testing", "网络请求失败": "Network request failed", "TMDB请求失败": "TMDB request failed", diff --git a/app/modules/anilist/__init__.py b/app/modules/anilist/__init__.py index 1975f932a..bb9554be9 100644 --- a/app/modules/anilist/__init__.py +++ b/app/modules/anilist/__init__.py @@ -8,7 +8,13 @@ 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, MediaSource, MediaType, ModuleType +from app.schemas.types import ( + MediaRecognizeType, + MediaSource, + MediaSourceSelection, + MediaType, + ModuleType, +) from app.utils.media import is_media_source_enabled @@ -327,7 +333,7 @@ class AniListModule(_ModuleBase): return None def search_medias( - self, meta: MetaBase, media_source: Optional[MediaSource] = None + self, meta: MetaBase, media_source: Optional[MediaSourceSelection] = None ) -> Optional[List[MediaInfo]]: """ 搜索 AniList 动画媒体信息。 @@ -336,7 +342,7 @@ class AniListModule(_ModuleBase): :param media_source: 请求级搜索数据源 :return: 统一媒体信息列表 """ - if not is_media_source_enabled(media_source, "anilist"): + if not is_media_source_enabled(media_source, MediaSource.AniList): return None if not meta or not meta.name: return [] @@ -347,7 +353,7 @@ class AniListModule(_ModuleBase): ] async def async_search_medias( - self, meta: MetaBase, media_source: Optional[MediaSource] = None + self, meta: MetaBase, media_source: Optional[MediaSourceSelection] = None ) -> Optional[List[MediaInfo]]: """ 异步搜索 AniList 动画媒体信息。 @@ -356,7 +362,7 @@ class AniListModule(_ModuleBase): :param media_source: 请求级搜索数据源 :return: 统一媒体信息列表 """ - if not is_media_source_enabled(media_source, "anilist"): + if not is_media_source_enabled(media_source, MediaSource.AniList): return None if not meta or not meta.name: return [] diff --git a/app/modules/bangumi/__init__.py b/app/modules/bangumi/__init__.py index 52d76917a..b138b6a8e 100644 --- a/app/modules/bangumi/__init__.py +++ b/app/modules/bangumi/__init__.py @@ -8,7 +8,13 @@ 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, MediaSource, MediaType, ModuleType +from app.schemas.types import ( + MediaRecognizeType, + MediaSource, + MediaSourceSelection, + MediaType, + ModuleType, +) from app.utils.http import RequestUtils from app.utils.media import is_media_source_enabled @@ -221,7 +227,7 @@ class BangumiModule(_ModuleBase): return None def search_medias( - self, meta: MetaBase, media_source: Optional[MediaSource] = None + self, meta: MetaBase, media_source: Optional[MediaSourceSelection] = None ) -> Optional[List[MediaInfo]]: """ 搜索媒体信息 @@ -229,7 +235,7 @@ class BangumiModule(_ModuleBase): :param media_source: 请求级搜索数据源 :return: 媒体信息 """ - if not is_media_source_enabled(media_source, "bangumi"): + if not is_media_source_enabled(media_source, MediaSource.Bangumi): return None if not meta.name: return [] @@ -241,7 +247,7 @@ class BangumiModule(_ModuleBase): return [] async def async_search_medias( - self, meta: MetaBase, media_source: Optional[MediaSource] = None + self, meta: MetaBase, media_source: Optional[MediaSourceSelection] = None ) -> Optional[List[MediaInfo]]: """ 搜索媒体信息(异步版本) @@ -249,7 +255,7 @@ class BangumiModule(_ModuleBase): :param media_source: 请求级搜索数据源 :return: 媒体信息 """ - if not is_media_source_enabled(media_source, "bangumi"): + if not is_media_source_enabled(media_source, MediaSource.Bangumi): return None if not meta.name: return [] diff --git a/app/modules/douban/__init__.py b/app/modules/douban/__init__.py index 0d72314e3..9f7fa851a 100644 --- a/app/modules/douban/__init__.py +++ b/app/modules/douban/__init__.py @@ -21,6 +21,7 @@ from app.schemas.types import ( MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MediaSource, + MediaSourceSelection, MediaType, ModuleType, MediaRecognizeType, @@ -63,7 +64,7 @@ class DoubanModule(_ModuleBase): return "豆瓣" @staticmethod - def get_music_source() -> str: + def get_music_source() -> MediaSource: """返回音乐识别使用的数据源标识。""" return DoubanModule._music_source @@ -92,7 +93,7 @@ class DoubanModule(_ModuleBase): self, meta: MetaMusic, limit: int = 20, - media_source: Optional[str] = None, + media_source: Optional[MediaSourceSelection] = None, ) -> Optional[List[MusicInfo]]: """按请求来源搜索豆瓣音乐专辑,并转换为统一音乐候选。""" if not is_media_source_selected(media_source, self._music_source): @@ -105,7 +106,7 @@ class DoubanModule(_ModuleBase): def recognize_music( self, - media_source: str, + media_source: MediaSource, media_id: str, music_type: Optional[str] = None, ) -> Optional[MusicInfo]: @@ -130,7 +131,11 @@ class DoubanModule(_ModuleBase): ) return album.to_music_info() - def music_album(self, media_source: str, media_id: str) -> Optional[MusicAlbumInfo]: + def music_album( + self, + media_source: MediaSource, + media_id: str, + ) -> Optional[MusicAlbumInfo]: """按豆瓣音乐专辑 ID 获取标准化专辑详情和曲目。""" if media_source != self._music_source or not media_id: return None @@ -139,7 +144,7 @@ class DoubanModule(_ModuleBase): def music_discover( self, - media_source: str, + media_source: MediaSource, page: int = 1, count: int = 30, entity: str = MUSIC_ENTITY_ALBUM, @@ -187,7 +192,7 @@ class DoubanModule(_ModuleBase): def music_album_related( self, - media_source: str, + media_source: MediaSource, media_id: str, count: int = 24, ) -> Optional[List[MusicInfo]]: @@ -204,7 +209,7 @@ class DoubanModule(_ModuleBase): def _recognize_music_media( self, meta: Optional[MetaMusic], - media_source: Optional[str], + media_source: Optional[MediaSource], media_id: Optional[str], music_type: Optional[str] = None, ) -> Optional[MusicInfo]: @@ -248,7 +253,7 @@ class DoubanModule(_ModuleBase): async def _async_recognize_music_media( self, meta: Optional[MetaMusic], - media_source: Optional[str], + media_source: Optional[MediaSource], media_id: Optional[str], music_type: Optional[str] = None, ) -> Optional[MusicInfo]: @@ -385,7 +390,7 @@ class DoubanModule(_ModuleBase): title = cls._douban_music_text(target.get("title") or target.get("name")) if not media_id or not title: continue - artists = cls._douban_music_artists(target) + artists = cls._douban_music_search_artists(target) release_date = cls._douban_music_date(target) cover_url = cls._douban_music_cover(target) candidate = MusicInfo( @@ -551,6 +556,20 @@ class DoubanModule(_ModuleBase): artists.append(text) return artists + @classmethod + def _douban_music_search_artists(cls, info: dict[str, Any]) -> List[str]: + """提取搜索候选艺术家,缺少结构化字段时回退到卡片副标题首段。""" + artists = cls._douban_music_artists(info) + if artists: + return artists + subtitle = cls._douban_music_text(info.get("card_subtitle")) + if not subtitle: + return [] + artist = re.split(r"\s+/\s+", subtitle, maxsplit=1)[0].strip() + if not artist or re.fullmatch(r"\d{4}(?:-\d{1,2}(?:-\d{1,2})?)?", artist): + return [] + return [artist] + @classmethod def _douban_music_cover(cls, info: dict[str, Any]) -> Optional[str]: """从豆瓣多种图片字段中提取清晰封面。""" @@ -1425,7 +1444,7 @@ class DoubanModule(_ModuleBase): return [] def search_medias( - self, meta: MetaBase, media_source: Optional[MediaSource] = None + self, meta: MetaBase, media_source: Optional[MediaSourceSelection] = None ) -> Optional[List[MediaInfo]]: """ 搜索媒体信息 @@ -1433,7 +1452,7 @@ class DoubanModule(_ModuleBase): :param media_source: 请求级搜索数据源 :return: 媒体信息 """ - if not is_media_source_enabled(media_source, "douban"): + if not is_media_source_enabled(media_source, MediaSource.Douban): return None if not meta.name: return [] @@ -1444,7 +1463,7 @@ class DoubanModule(_ModuleBase): return self._build_search_medias_result(meta, result.get("items")) async def async_search_medias( - self, meta: MetaBase, media_source: Optional[MediaSource] = None + self, meta: MetaBase, media_source: Optional[MediaSourceSelection] = None ) -> Optional[List[MediaInfo]]: """ 搜索媒体信息(异步版本) @@ -1452,7 +1471,7 @@ class DoubanModule(_ModuleBase): :param media_source: 请求级搜索数据源 :return: 媒体信息 """ - if not is_media_source_enabled(media_source, "douban"): + if not is_media_source_enabled(media_source, MediaSource.Douban): return None if not meta.name: return [] @@ -1463,7 +1482,7 @@ class DoubanModule(_ModuleBase): return self._build_search_medias_result(meta, result.get("items")) def search_persons( - self, name: str, media_source: Optional[MediaSource] = None + self, name: str, media_source: Optional[MediaSourceSelection] = None ) -> Optional[List[MediaPerson]]: """ 搜索人物信息 @@ -1471,7 +1490,7 @@ class DoubanModule(_ModuleBase): :param media_source: 请求级搜索数据源 :return: 人物信息列表 """ - if not is_media_source_enabled(media_source, "douban"): + if not is_media_source_enabled(media_source, MediaSource.Douban): return None if not name: return [] @@ -1488,7 +1507,7 @@ class DoubanModule(_ModuleBase): return [] async def async_search_persons( - self, name: str, media_source: Optional[MediaSource] = None + self, name: str, media_source: Optional[MediaSourceSelection] = None ) -> Optional[List[MediaPerson]]: """ 搜索人物信息(异步版本) @@ -1496,7 +1515,7 @@ class DoubanModule(_ModuleBase): :param media_source: 请求级搜索数据源 :return: 人物信息列表 """ - if not is_media_source_enabled(media_source, "douban"): + if not is_media_source_enabled(media_source, MediaSource.Douban): return None if not name: return [] diff --git a/app/modules/emby/emby.py b/app/modules/emby/emby.py index 85acc2acf..8191726c7 100644 --- a/app/modules/emby/emby.py +++ b/app/modules/emby/emby.py @@ -1150,7 +1150,9 @@ class Emby: eventItem.item_id = message.get('Item', {}).get('Id') eventItem.item_path = message.get('Item', {}).get('Path') - eventItem.tmdb_id = message.get('Item', {}).get('ProviderIds', {}).get('Tmdb') + eventItem.media_source, eventItem.media_id = MediaServerIdentityHelper.from_provider_ids( + message.get('Item', {}).get('ProviderIds') + ) if message.get('Item', {}).get('Overview') and len(message.get('Item', {}).get('Overview')) > 100: eventItem.overview = str(message.get('Item', {}).get('Overview'))[:100] + "..." else: @@ -1171,7 +1173,9 @@ class Emby: eventItem.item_type = message.get("item_type") eventItem.item_name = message.get("item_name") eventItem.item_path = message.get("item_path") - eventItem.tmdb_id = message.get("tmdb_id") + eventItem.media_source, eventItem.media_id = MediaServerIdentityHelper.from_provider_ids({ + "tmdb_id": message.get("tmdb_id"), + }) eventItem.season_id = message.get("season_id") eventItem.episode_id = message.get("episode_id") diff --git a/app/modules/jellyfin/jellyfin.py b/app/modules/jellyfin/jellyfin.py index 6788c2c66..83f9c0626 100644 --- a/app/modules/jellyfin/jellyfin.py +++ b/app/modules/jellyfin/jellyfin.py @@ -825,7 +825,11 @@ class Jellyfin: channel="jellyfin" ) eventItem.item_id = message.get('ItemId') - eventItem.tmdb_id = message.get('Provider_tmdb') + eventItem.media_source, eventItem.media_id = MediaServerIdentityHelper.from_provider_ids({ + key.removeprefix("Provider_"): value + for key, value in message.items() + if key.startswith("Provider_") + }) eventItem.overview = message.get('Overview') eventItem.item_favorite = message.get('Favorite') eventItem.save_reason = message.get('SaveReason') diff --git a/app/modules/musicbrainz/__init__.py b/app/modules/musicbrainz/__init__.py index a05cba237..b4a5a7434 100644 --- a/app/modules/musicbrainz/__init__.py +++ b/app/modules/musicbrainz/__init__.py @@ -24,6 +24,7 @@ from app.schemas.types import ( MUSIC_ENTITY_RECORDING, MediaRecognizeType, MediaSource, + MediaSourceSelection, MediaType, ModuleType, ) @@ -123,7 +124,7 @@ class MusicBrainzModule(_ModuleBase): return "MusicBrainz" @staticmethod - def get_music_source() -> str: + def get_music_source() -> MediaSource: """返回音乐识别使用的数据源标识。""" return MusicBrainzModule._source @@ -146,7 +147,7 @@ class MusicBrainzModule(_ModuleBase): self, meta: MetaMusic, limit: int = 20, - media_source: Optional[str] = None, + media_source: Optional[MediaSourceSelection] = None, ) -> Optional[list[MusicInfo]]: """搜索单曲、专辑和艺术家,并交错返回可浏览的 MusicBrainz 候选。""" if not is_media_source_selected(media_source, self._source): @@ -747,7 +748,7 @@ class MusicBrainzModule(_ModuleBase): self, meta: MetaBase = None, mtype: MediaType = None, - media_source: Optional[str] = None, + media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, **kwargs, ) -> Optional[MusicInfo]: @@ -846,7 +847,7 @@ class MusicBrainzModule(_ModuleBase): self, meta: MetaBase = None, mtype: MediaType = None, - media_source: Optional[str] = None, + media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, **kwargs, ) -> Optional[MusicInfo]: @@ -902,7 +903,12 @@ class MusicBrainzModule(_ModuleBase): return result @classmethod - def _select_candidate(cls, meta: MetaMusic, candidates: Iterable[MusicInfo], media_source: str) -> Optional[MusicInfo]: + def _select_candidate( + cls, + meta: MetaMusic, + candidates: Iterable[MusicInfo], + media_source: MediaSource, + ) -> Optional[MusicInfo]: """按标题、艺术家和专辑匹配度选择最可信的搜索候选。""" normalized_source = cls._normalize_text(media_source).casefold() # 资源标题携带的音质标记先剥离,再与候选曲名比对; @@ -1144,7 +1150,7 @@ class MusicBrainzModule(_ModuleBase): def recognize_music( self, - media_source: str, + media_source: MediaSource, media_id: str, music_type: Optional[str] = None, ) -> Optional[MusicInfo]: @@ -1169,7 +1175,7 @@ class MusicBrainzModule(_ModuleBase): async def async_recognize_music( self, - media_source: str, + media_source: MediaSource, media_id: str, music_type: Optional[str] = None, ) -> Optional[MusicInfo]: @@ -1193,7 +1199,7 @@ class MusicBrainzModule(_ModuleBase): async def _async_music_album( self, - media_source: str, + media_source: MediaSource, media_id: str, ) -> Optional[MusicAlbumInfo]: """异步按 MusicBrainz Release Group ID 获取专辑详情及曲目。""" @@ -1218,7 +1224,11 @@ class MusicBrainzModule(_ModuleBase): ) return album - def music_album(self, media_source: str, media_id: str) -> Optional[MusicAlbumInfo]: + def music_album( + self, + media_source: MediaSource, + media_id: str, + ) -> Optional[MusicAlbumInfo]: """按 MusicBrainz Release Group ID 获取标准化专辑详情及曲目。""" if media_source != self._source or not media_id: return None @@ -1238,7 +1248,11 @@ class MusicBrainzModule(_ModuleBase): album.tracks = self._album_tracks(album, payload.get("releases") or []) return album - def music_artist(self, media_source: str, media_id: str) -> Optional[MusicArtistInfo]: + def music_artist( + self, + media_source: MediaSource, + media_id: str, + ) -> Optional[MusicArtistInfo]: """按 MusicBrainz Artist ID 获取标准化艺术家详情。""" if media_source != self._source or not media_id: return None @@ -1250,7 +1264,7 @@ class MusicBrainzModule(_ModuleBase): def music_artist_albums( self, - media_source: str, + media_source: MediaSource, media_id: str, page: int = 1, count: int = 30, @@ -1281,7 +1295,7 @@ class MusicBrainzModule(_ModuleBase): def music_artist_related( self, - media_source: str, + media_source: MediaSource, media_id: str, count: int = 24, ) -> list[MusicArtistInfo]: diff --git a/app/modules/theaudiodb/__init__.py b/app/modules/theaudiodb/__init__.py index 6183b12c3..b960d8b80 100644 --- a/app/modules/theaudiodb/__init__.py +++ b/app/modules/theaudiodb/__init__.py @@ -15,6 +15,7 @@ from app.schemas.types import ( MUSIC_ENTITY_RECORDING, MediaRecognizeType, MediaSource, + MediaSourceSelection, MediaType, ModuleType, ) @@ -50,7 +51,7 @@ class TheAudioDbModule(_ModuleBase): return "TheAudioDB" @staticmethod - def get_music_source() -> str: + def get_music_source() -> MediaSource: """返回音乐识别使用的数据源标识。""" return TheAudioDbModule._source @@ -73,7 +74,7 @@ class TheAudioDbModule(_ModuleBase): self, meta: MetaMusic, limit: int = 20, - media_source: Optional[str] = None, + media_source: Optional[MediaSourceSelection] = None, ) -> Optional[list[MusicInfo]]: """按请求来源搜索 TheAudioDB 单曲、专辑和艺术家。""" if not is_media_source_selected(media_source, self._source): @@ -93,7 +94,7 @@ class TheAudioDbModule(_ModuleBase): self, meta: MetaBase = None, mtype: MediaType = None, - media_source: Optional[str] = None, + media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, **kwargs, ) -> Optional[MusicInfo]: @@ -131,7 +132,7 @@ class TheAudioDbModule(_ModuleBase): self, meta: MetaBase = None, mtype: MediaType = None, - media_source: Optional[str] = None, + media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, **kwargs, ) -> Optional[MusicInfo]: @@ -171,7 +172,7 @@ class TheAudioDbModule(_ModuleBase): def recognize_music( self, - media_source: str, + media_source: MediaSource, media_id: str, music_type: Optional[str] = None, ) -> Optional[MusicInfo]: @@ -190,7 +191,7 @@ class TheAudioDbModule(_ModuleBase): async def async_recognize_music( self, - media_source: str, + media_source: MediaSource, media_id: str, music_type: Optional[str] = None, ) -> Optional[MusicInfo]: @@ -209,7 +210,7 @@ class TheAudioDbModule(_ModuleBase): async def _async_music_album( self, - media_source: str, + media_source: MediaSource, media_id: str, ) -> Optional[MusicAlbumInfo]: """异步按 TheAudioDB 专辑 ID 获取标准化专辑详情和曲目。""" @@ -228,7 +229,11 @@ class TheAudioDbModule(_ModuleBase): ] return album - def music_album(self, media_source: str, media_id: str) -> Optional[MusicAlbumInfo]: + def music_album( + self, + media_source: MediaSource, + media_id: str, + ) -> Optional[MusicAlbumInfo]: """按 TheAudioDB 专辑 ID 获取标准化专辑详情和曲目。""" if media_source != self._source or not media_id: return None @@ -245,7 +250,11 @@ class TheAudioDbModule(_ModuleBase): ] return album - def music_artist(self, media_source: str, media_id: str) -> Optional[MusicArtistInfo]: + def music_artist( + self, + media_source: MediaSource, + media_id: str, + ) -> Optional[MusicArtistInfo]: """按 TheAudioDB 艺术家 ID 获取标准化艺术家详情。""" if media_source != self._source or not media_id: return None @@ -255,7 +264,7 @@ class TheAudioDbModule(_ModuleBase): def music_artist_albums( self, - media_source: str, + media_source: MediaSource, media_id: str, page: int = 1, count: int = 30, @@ -277,7 +286,7 @@ class TheAudioDbModule(_ModuleBase): def music_album_related( self, - media_source: str, + media_source: MediaSource, media_id: str, count: int = 24, ) -> Optional[list[MusicInfo]]: @@ -304,11 +313,10 @@ class TheAudioDbModule(_ModuleBase): def _search_tracks(self, meta: MetaMusic) -> list[MusicInfo]: """使用曲名和艺术家搜索 TheAudioDB 单曲。""" title = meta.title - if not title: + artist = meta.artists[0] if meta.artists else meta.album_artist + if not title or not artist: return [] - params = {"t": title} - if meta.artists: - params["s"] = meta.artists[0] + params = {"t": title, "s": artist} payload = self._request_json("searchtrack.php", params) return [ info @@ -319,11 +327,10 @@ class TheAudioDbModule(_ModuleBase): async def _async_search_tracks(self, meta: MetaMusic) -> list[MusicInfo]: """异步使用曲名和艺术家搜索 TheAudioDB 单曲。""" title = meta.title - if not title: + artist = meta.artists[0] if meta.artists else meta.album_artist + if not title or not artist: return [] - params = {"t": title} - if meta.artists: - params["s"] = meta.artists[0] + params = {"t": title, "s": artist} payload = await self._async_request_json("searchtrack.php", params) return [ info @@ -334,11 +341,10 @@ class TheAudioDbModule(_ModuleBase): def _search_albums(self, meta: MetaMusic) -> list[MusicAlbumInfo]: """使用专辑名和艺术家搜索 TheAudioDB 专辑。""" album_name = meta.album or meta.title - if not album_name: + artist = meta.artists[0] if meta.artists else meta.album_artist + if not album_name or not artist: return [] - params = {"a": album_name} - if meta.artists: - params["s"] = meta.artists[0] + params = {"a": album_name, "s": artist} payload = self._request_json("searchalbum.php", params) return [self._album_to_info(item) for item in self._entities(payload, "album", "albums")] @@ -348,11 +354,10 @@ class TheAudioDbModule(_ModuleBase): ) -> list[MusicAlbumInfo]: """异步使用专辑名和艺术家搜索 TheAudioDB 专辑。""" album_name = meta.album or meta.title - if not album_name: + artist = meta.artists[0] if meta.artists else meta.album_artist + if not album_name or not artist: return [] - params = {"a": album_name} - if meta.artists: - params["s"] = meta.artists[0] + params = {"a": album_name, "s": artist} payload = await self._async_request_json("searchalbum.php", params) return [ self._album_to_info(item) @@ -564,14 +569,25 @@ class TheAudioDbModule(_ModuleBase): url=f"{cls._base_url}/{api_key}/{endpoint}", params=params or {}, ) - if not response or response.status_code != 200: + if response is None: return None try: - payload = response.json() - except ValueError as err: - logger.error(f"TheAudioDB 响应解析失败:{str(err)}") - return None - return payload if isinstance(payload, dict) else None + if response.status_code != 200: + return None + diagnostic = cls._response_diagnostic(response, endpoint) + if getattr(response, "content", None) in (b"", ""): + logger.warning(f"TheAudioDB 返回空响应:{diagnostic}") + return None + try: + payload = response.json() + except (TypeError, ValueError) as err: + logger.warning( + f"TheAudioDB 响应解析失败:{diagnostic},错误:{str(err)}" + ) + return None + return payload if isinstance(payload, dict) else None + finally: + response.close() @classmethod @cached( @@ -603,13 +619,31 @@ class TheAudioDbModule(_ModuleBase): try: if response.status_code != 200: return None - payload = response.json() - except ValueError as err: - logger.error(f"TheAudioDB 响应解析失败:{str(err)}") - return None + diagnostic = cls._response_diagnostic(response, endpoint) + if getattr(response, "content", None) in (b"", ""): + logger.warning(f"TheAudioDB 返回空响应:{diagnostic}") + return None + try: + payload = response.json() + except (TypeError, ValueError) as err: + logger.warning( + f"TheAudioDB 响应解析失败:{diagnostic},错误:{str(err)}" + ) + return None + return payload if isinstance(payload, dict) else None finally: await response.aclose() - return payload if isinstance(payload, dict) else None + + @staticmethod + def _response_diagnostic(response: Any, endpoint: str) -> str: + """生成不包含 API Key 的 TheAudioDB 响应诊断摘要。""" + headers = getattr(response, "headers", {}) or {} + content_type = headers.get("Content-Type", "") if hasattr(headers, "get") else "" + body = str(getattr(response, "text", "") or "").replace("\n", " ")[:200] + return ( + f"endpoint={endpoint}, HTTP={getattr(response, 'status_code', '')}, " + f"Content-Type={content_type}, body={body!r}" + ) @staticmethod def _entities( diff --git a/app/modules/themoviedb/__init__.py b/app/modules/themoviedb/__init__.py index 153d8e4c4..fbbffd6ce 100644 --- a/app/modules/themoviedb/__init__.py +++ b/app/modules/themoviedb/__init__.py @@ -18,11 +18,16 @@ from app.schemas.types import ( MediaImageType, MediaRecognizeType, MediaSource, + MediaSourceSelection, MediaType, ModuleType, ) from app.utils.http import RequestUtils -from app.utils.media import is_media_source_enabled, is_media_source_selected +from app.utils.media import ( + is_media_source_enabled, + is_media_source_selected, + normalize_media_source, +) from app.utils.zhconv import convert as zhconv_convert @@ -102,7 +107,7 @@ class TheMovieDbModule(_ModuleBase): def _validate_recognize_params( meta: MetaBase, tmdbid: Optional[int], - media_source: Optional[str] = None, + media_source: Optional[MediaSource] = None, ) -> bool: """ 验证识别参数 @@ -115,7 +120,8 @@ class TheMovieDbModule(_ModuleBase): if not tmdbid and not meta: return False - if meta and not tmdbid and (media_source or settings.RECOGNIZE_SOURCE) != "themoviedb": + selected_source = normalize_media_source(media_source or settings.RECOGNIZE_SOURCE) + if meta and not tmdbid and selected_source != MediaSource.TMDB: return False if meta and not meta.name and not tmdbid: @@ -769,7 +775,7 @@ class TheMovieDbModule(_ModuleBase): } def search_medias( - self, meta: MetaBase, media_source: Optional[MediaSource] = None + self, meta: MetaBase, media_source: Optional[MediaSourceSelection] = None ) -> Optional[List[MediaInfo]]: """ 搜索媒体信息 @@ -777,7 +783,7 @@ class TheMovieDbModule(_ModuleBase): :param media_source: 请求级搜索数据源 :return: 媒体信息列表 """ - if not is_media_source_enabled(media_source, "themoviedb"): + if not is_media_source_enabled(media_source, MediaSource.TMDB): return None if not meta.name: return [] @@ -801,7 +807,7 @@ class TheMovieDbModule(_ModuleBase): return self._build_search_medias_result(meta, results) def search_persons( - self, name: str, media_source: Optional[MediaSource] = None + self, name: str, media_source: Optional[MediaSourceSelection] = None ) -> Optional[List[schemas.MediaPerson]]: """ 搜索人物信息 @@ -809,7 +815,7 @@ class TheMovieDbModule(_ModuleBase): :param media_source: 请求级搜索数据源 :return: 人物信息列表 """ - if not is_media_source_enabled(media_source, "themoviedb"): + if not is_media_source_enabled(media_source, MediaSource.TMDB): return None if not name: return [] @@ -819,7 +825,7 @@ class TheMovieDbModule(_ModuleBase): return [] async def async_search_persons( - self, name: str, media_source: Optional[MediaSource] = None + self, name: str, media_source: Optional[MediaSourceSelection] = None ) -> Optional[List[schemas.MediaPerson]]: """ 异步搜索人物信息 @@ -827,7 +833,7 @@ class TheMovieDbModule(_ModuleBase): :param media_source: 请求级搜索数据源 :return: 人物信息列表 """ - if not is_media_source_enabled(media_source, "themoviedb"): + if not is_media_source_enabled(media_source, MediaSource.TMDB): return None if not name: return [] @@ -837,7 +843,7 @@ class TheMovieDbModule(_ModuleBase): return [] def search_collections( - self, name: str, media_source: Optional[MediaSource] = None + self, name: str, media_source: Optional[MediaSourceSelection] = None ) -> Optional[List[MediaInfo]]: """ 搜索集合信息 @@ -845,7 +851,7 @@ class TheMovieDbModule(_ModuleBase): :param media_source: 请求级搜索数据源 :return: 合集信息列表 """ - if media_source and not is_media_source_selected(media_source, "themoviedb"): + if media_source and not is_media_source_selected(media_source, MediaSource.TMDB): return None if not name: return [] @@ -855,7 +861,7 @@ class TheMovieDbModule(_ModuleBase): return [] async def async_search_collections( - self, name: str, media_source: Optional[MediaSource] = None + self, name: str, media_source: Optional[MediaSourceSelection] = None ) -> Optional[List[MediaInfo]]: """ 异步搜索集合信息 @@ -863,7 +869,7 @@ class TheMovieDbModule(_ModuleBase): :param media_source: 请求级搜索数据源 :return: 合集信息列表 """ - if media_source and not is_media_source_selected(media_source, "themoviedb"): + if media_source and not is_media_source_selected(media_source, MediaSource.TMDB): return None if not name: return [] @@ -1251,7 +1257,7 @@ class TheMovieDbModule(_ModuleBase): # 异步方法 async def async_search_medias( - self, meta: MetaBase, media_source: Optional[MediaSource] = None + self, meta: MetaBase, media_source: Optional[MediaSourceSelection] = None ) -> Optional[List[MediaInfo]]: """ 搜索媒体信息(异步版本) @@ -1259,7 +1265,7 @@ class TheMovieDbModule(_ModuleBase): :param media_source: 请求级搜索数据源 :return: 媒体信息列表 """ - if not is_media_source_enabled(media_source, "themoviedb"): + if not is_media_source_enabled(media_source, MediaSource.TMDB): return None if not meta.name: return [] diff --git a/app/modules/zspace/zspace.py b/app/modules/zspace/zspace.py index b1554a4bd..e0ec21cae 100644 --- a/app/modules/zspace/zspace.py +++ b/app/modules/zspace/zspace.py @@ -1026,7 +1026,9 @@ class ZSpace: event_item.item_id = message.get('Item', {}).get('Id') event_item.item_path = message.get('Item', {}).get('Path') - event_item.tmdb_id = message.get('Item', {}).get('ProviderIds', {}).get('Tmdb') + event_item.media_source, event_item.media_id = MediaServerIdentityHelper.from_provider_ids( + message.get('Item', {}).get('ProviderIds') + ) if message.get('Item', {}).get('Overview') and len(message.get('Item', {}).get('Overview')) > 100: event_item.overview = str(message.get('Item', {}).get('Overview'))[:100] + "..." else: @@ -1047,7 +1049,9 @@ class ZSpace: event_item.item_type = message.get("item_type") event_item.item_name = message.get("item_name") event_item.item_path = message.get("item_path") - event_item.tmdb_id = message.get("tmdb_id") + event_item.media_source, event_item.media_id = MediaServerIdentityHelper.from_provider_ids({ + "tmdb_id": message.get("tmdb_id"), + }) event_item.season_id = message.get("season_id") event_item.episode_id = message.get("episode_id") diff --git a/app/schemas/context.py b/app/schemas/context.py index f399b8621..47c3a60d0 100644 --- a/app/schemas/context.py +++ b/app/schemas/context.py @@ -3,10 +3,11 @@ from typing import Optional, Dict, List, Union, Any from pydantic import BaseModel, Field from app.schemas.music import MusicInfo, MusicMeta +from app.schemas.media import OptionalMediaIdentityMixin from app.schemas.types import MediaSource -class MetaInfo(BaseModel): +class MetaInfo(OptionalMediaIdentityMixin, BaseModel): """ 识别元数据 """ @@ -67,17 +68,17 @@ class MetaInfo(BaseModel): # 剧集组 episode_group: Optional[str] = None # 显式媒体数据源 - media_source: Optional[Union[MediaSource, str]] = None + media_source: Optional[MediaSource] = None # 显式媒体数据源原生ID media_id: Optional[str] = None -class MediaInfo(BaseModel): +class MediaInfo(OptionalMediaIdentityMixin, BaseModel): """ 识别媒体信息 """ # 媒体主身份来源 - media_source: Optional[Union[MediaSource, str]] = None + media_source: Optional[MediaSource] = None # 请求级刮削来源 scrape_source: Optional[str] = None # 类型 电影、电视剧、合集 @@ -188,7 +189,7 @@ class MediaInfo(BaseModel): episode_group: Optional[str] = None -class TorrentInfo(BaseModel): +class TorrentInfo(OptionalMediaIdentityMixin, BaseModel): """ 搜索种子信息 """ diff --git a/app/schemas/event.py b/app/schemas/event.py index 5f9f9b814..1a54f2715 100644 --- a/app/schemas/event.py +++ b/app/schemas/event.py @@ -5,6 +5,7 @@ from pydantic import BaseModel, Field, field_validator, model_validator from app.schemas.message import MessageChannel from app.schemas.file import FileItem +from app.schemas.media import OptionalMediaIdentityMixin, RequiredMediaIdentityMixin from app.schemas.types import MediaSource @@ -532,7 +533,7 @@ class RecommendSourceEventData(ChainEventData): ) -class MediaRecognizeConvertEventData(ChainEventData): +class MediaRecognizeConvertEventData(RequiredMediaIdentityMixin, ChainEventData): """ MediaRecognizeConvert 事件的数据模型 @@ -576,7 +577,7 @@ class StorageOperSelectionEventData(ChainEventData): storage_oper: Optional[Callable] = Field(default=None, description="存储操作对象") -class SubscribeEpisodesRefreshEventData(ChainEventData): +class SubscribeEpisodesRefreshEventData(OptionalMediaIdentityMixin, ChainEventData): """ SubscribeEpisodesRefresh 事件的数据模型 diff --git a/app/schemas/history.py b/app/schemas/history.py index 4d1ee38ed..d18143141 100644 --- a/app/schemas/history.py +++ b/app/schemas/history.py @@ -2,10 +2,11 @@ from typing import Optional, Any from pydantic import BaseModel, ConfigDict, Field +from app.schemas.media import OptionalMediaIdentityMixin from app.schemas.types import MediaSource -class DownloadHistory(BaseModel): +class DownloadHistory(OptionalMediaIdentityMixin, BaseModel): """ 下载历史记录 """ @@ -60,7 +61,7 @@ class DownloadHistory(BaseModel): model_config = ConfigDict(from_attributes=True) -class TransferHistory(BaseModel): +class TransferHistory(OptionalMediaIdentityMixin, BaseModel): """ 文件整理历史记录 """ diff --git a/app/schemas/media.py b/app/schemas/media.py new file mode 100644 index 000000000..f9e9d6476 --- /dev/null +++ b/app/schemas/media.py @@ -0,0 +1,38 @@ +from pydantic import model_validator + + +class OptionalMediaIdentityMixin: + """为可选媒体身份模型统一校验来源枚举与原生 ID 的成对约束。""" + + @model_validator(mode="after") + def _validate_optional_media_identity(self): + """规范化 ID,并拒绝显式半对、空白或零值身份。""" + source_provided = "media_source" in self.model_fields_set + id_provided = "media_id" in self.model_fields_set + if source_provided != id_provided: + raise ValueError("media_source 和 media_id 必须同时提供") + normalized_id = ( + str(self.media_id).strip() + if self.media_id is not None + else None + ) + if bool(self.media_source) != bool(normalized_id): + raise ValueError("media_source 和 media_id 必须同时提供") + if normalized_id == "0": + raise ValueError("media_id 不能为 0") + # 校验器内部的规范化不能伪装成请求显式提交字段,否则 PATCH 会误清空存量身份。 + object.__setattr__(self, "media_id", normalized_id) + return self + + +class RequiredMediaIdentityMixin: + """为必填媒体身份模型统一校验来源枚举与原生 ID。""" + + @model_validator(mode="after") + def _validate_required_media_identity(self): + """去除 ID 两端空白,并拒绝空白或零值身份。""" + normalized_id = str(self.media_id).strip() + if not normalized_id or normalized_id == "0": + raise ValueError("media_id 必须是非零的来源原生 ID") + self.media_id = normalized_id + return self diff --git a/app/schemas/mediaserver.py b/app/schemas/mediaserver.py index 2eb861d7c..379fba767 100644 --- a/app/schemas/mediaserver.py +++ b/app/schemas/mediaserver.py @@ -1,8 +1,9 @@ from pathlib import Path from typing import Optional, Dict, Union, List, Any -from pydantic import BaseModel, Field, ConfigDict +from pydantic import BaseModel, Field, ConfigDict, model_validator +from app.schemas.media import OptionalMediaIdentityMixin from app.schemas.types import MediaSource, MediaType @@ -99,7 +100,7 @@ class MediaServerItemUserState(BaseModel): percentage: Optional[float] = None -class MediaServerItem(BaseModel): +class MediaServerItem(OptionalMediaIdentityMixin, BaseModel): """ 媒体服务器媒体信息 """ @@ -158,7 +159,8 @@ class WebhookEventInfo(BaseModel): item_path: Optional[str] = None season_id: Optional[str] = None episode_id: Optional[str] = None - tmdb_id: Optional[str] = None + media_source: Optional[MediaSource] = None + media_id: Optional[str] = None overview: Optional[str] = None percentage: Optional[float] = None ip: Optional[str] = None @@ -172,6 +174,54 @@ class WebhookEventInfo(BaseModel): media_type: Optional[str] = None json_object: Optional[dict] = Field(default_factory=dict) + @model_validator(mode="before") + @classmethod + def _migrate_legacy_tmdb_identity(cls, data: Any) -> Any: + """在旧事件输入边界把 tmdb_id 迁移为统一媒体身份。""" + if not isinstance(data, dict): + return data + if data.get("media_source") is not None or data.get("media_id") is not None: + migrated = dict(data) + migrated.pop("tmdb_id", None) + return migrated + legacy_tmdb_id = data.get("tmdb_id") + if legacy_tmdb_id in (None, ""): + return data + migrated = dict(data) + migrated["media_source"] = MediaSource.TMDB + migrated["media_id"] = str(legacy_tmdb_id) + migrated.pop("tmdb_id", None) + return migrated + + @model_validator(mode="after") + def _validate_media_identity(self) -> "WebhookEventInfo": + """确保 webhook 媒体身份始终完整成对且不接受零值。""" + normalized_id = str(self.media_id).strip() if self.media_id is not None else None + if bool(self.media_source) != bool(normalized_id): + raise ValueError("media_source 和 media_id 必须同时提供") + if normalized_id == "0": + raise ValueError("media_id 不能为 0") + self.media_id = normalized_id + return self + + @property + def tmdb_id(self) -> Optional[str]: + """兼容旧插件读取 TMDB 身份;新事件输出不再包含该字段。""" + if self.media_source == MediaSource.TMDB: + return self.media_id + return None + + @tmdb_id.setter + def tmdb_id(self, value: Optional[Union[str, int]]) -> None: + """兼容旧插件写入 TMDB 身份,并同步为统一字段。""" + if value in (None, ""): + if self.media_source == MediaSource.TMDB: + self.media_source = None + self.media_id = None + return + self.media_source = MediaSource.TMDB + self.media_id = str(value) + class MediaServerPlayItem(BaseModel): """ diff --git a/app/schemas/music.py b/app/schemas/music.py index 9a5b08595..bd36e5e4e 100644 --- a/app/schemas/music.py +++ b/app/schemas/music.py @@ -2,10 +2,11 @@ from typing import Any, Literal, Optional, Union from pydantic import BaseModel, Field +from app.schemas.media import OptionalMediaIdentityMixin, RequiredMediaIdentityMixin from app.schemas.types import MediaSource, MusicEntityType, MusicTargetEntityType -class MusicMeta(BaseModel): +class MusicMeta(OptionalMediaIdentityMixin, BaseModel): """音乐名称及音频文件解析结果。""" type: Literal["音乐"] = "音乐" @@ -31,17 +32,17 @@ class MusicMeta(BaseModel): bitrate: Optional[int] = None duration: Optional[int] = None isrc: Optional[str] = None - media_source: Optional[Union[MediaSource, str]] = None + media_source: Optional[MediaSource] = None media_id: Optional[str] = None -class MusicInfo(BaseModel): +class MusicInfo(OptionalMediaIdentityMixin, BaseModel): """标准化音乐元数据信息。""" type: Literal["音乐"] = "音乐" # 音乐实体类型:recording 单曲、album 专辑、artist 艺术家 music_type: MusicEntityType = "recording" - media_source: Optional[Union[MediaSource, str]] = None + media_source: Optional[MediaSource] = None media_id: Optional[str] = None title: Optional[str] = None artists: list[str] = Field(default_factory=list) @@ -97,7 +98,7 @@ class MusicRelease(BaseModel): cover_url: Optional[str] = None -class MusicAlbumInfo(BaseModel): +class MusicAlbumInfo(OptionalMediaIdentityMixin, BaseModel): """标准化音乐专辑信息。""" type: Literal["音乐"] = "音乐" @@ -132,7 +133,7 @@ class MusicAlbumInfo(BaseModel): vote_average: float = 0.0 -class MusicArtistInfo(BaseModel): +class MusicArtistInfo(OptionalMediaIdentityMixin, BaseModel): """标准化音乐艺术家信息。""" type: Literal["音乐"] = "音乐" @@ -164,7 +165,7 @@ class MusicArtistInfo(BaseModel): overview: Optional[str] = None -class MusicRecognizeRequest(BaseModel): +class MusicRecognizeRequest(RequiredMediaIdentityMixin, BaseModel): """音乐元数据详情识别请求。""" media_source: MediaSource diff --git a/app/schemas/subscribe.py b/app/schemas/subscribe.py index 05bce12a9..9aaed08d0 100644 --- a/app/schemas/subscribe.py +++ b/app/schemas/subscribe.py @@ -2,6 +2,7 @@ from typing import Optional, List, Dict, Any, ClassVar from pydantic import BaseModel, Field, ConfigDict, model_validator +from app.schemas.media import OptionalMediaIdentityMixin from app.schemas.types import MediaSource, MediaType @@ -43,7 +44,9 @@ def compute_subscribe_completed_episode(subscribe: "Subscribe") -> Optional[int] return min(max(start_episode - 1, 0), total_episode) + priority_completed -class Subscribe(BaseModel): +class Subscribe(OptionalMediaIdentityMixin, BaseModel): + """订阅输入与响应模型,媒体身份必须为空对或完整有效对。""" + # 公共创建和更新接口不得接收系统字段和运行事实;其余字段默认作为订阅输入透传。 PUBLIC_WRITE_EXCLUDED_FIELDS: ClassVar[frozenset[str]] = frozenset({ "id", "poster", "backdrop", "vote", "description", "lack_episode", "completed_episode", @@ -155,17 +158,22 @@ class Subscribe(BaseModel): @classmethod def _normalize_empty_strings(cls, data: Any) -> Any: """ - 将前端清空输入框后残留的空字符串视为未提供,移除该键由字段默认值兜底。 + 将前端清空输入框后残留的空字符串视为空值。 音乐等媒体类型的 season、total_episode、episode_priority 等数值或容器字段 在表单中常以空字符串提交,而 Pydantic 不会把空字符串自动转为 None,会直接抛出 校验异常导致接口返回 422。这里把空字符串键移除,等价于该字段未提供,从而复用字段 - 默认值(如 ``total_episode`` 回退为 0、``sites`` 回退为空列表)。 + 默认值(如 ``total_episode`` 回退为 0、``sites`` 回退为空列表)。媒体身份键保留为 + None,以便更新接口区分“未提交”与“显式清空完整身份对”。 """ if isinstance(data, dict): + data = dict(data) for key, value in list(data.items()): if isinstance(value, str) and value == "": - data.pop(key) + if key in {"media_source", "media_id"}: + data[key] = None + else: + data.pop(key) return data @model_validator(mode="after") @@ -180,12 +188,15 @@ class Subscribe(BaseModel): self.completed_episode = compute_subscribe_completed_episode(self) return self - def to_public_write_payload(self) -> Dict[str, Any]: - """裁剪公共订阅写入字段,避免请求体覆盖下载事实和运行状态。""" - return self.model_dump(exclude=self.PUBLIC_WRITE_EXCLUDED_FIELDS) + def to_public_write_payload(self, *, exclude_unset: bool = False) -> Dict[str, Any]: + """裁剪公共订阅写入字段,可仅保留更新请求显式提交的字段。""" + return self.model_dump( + exclude=self.PUBLIC_WRITE_EXCLUDED_FIELDS, + exclude_unset=exclude_unset, + ) -class SubscribeShare(BaseModel): +class SubscribeShare(OptionalMediaIdentityMixin, BaseModel): # 分享ID id: Optional[int] = None # 订阅ID diff --git a/app/schemas/transfer.py b/app/schemas/transfer.py index 1c4bfec7f..d2d74bebf 100644 --- a/app/schemas/transfer.py +++ b/app/schemas/transfer.py @@ -3,6 +3,7 @@ from typing import Any, Callable, List, Optional, Union from pydantic import BaseModel, Field +from app.schemas.media import OptionalMediaIdentityMixin from app.schemas.types import MediaSource, MusicTargetEntityType from app.schemas.context import MetaInfo, MediaInfo @@ -58,7 +59,7 @@ class DownloadingTorrent(DownloaderTorrent): """ -class TransferTask(BaseModel): +class TransferTask(OptionalMediaIdentityMixin, BaseModel): """ 文件整理任务 """ @@ -199,7 +200,7 @@ class EpisodeFormatRecommendItem(BaseModel): fileitems: Optional[List[FileItem]] = None -class ManualTransferItem(BaseModel): +class ManualTransferItem(OptionalMediaIdentityMixin, BaseModel): """手动整理请求,媒体身份只接受来源枚举与原生 ID。""" # 文件项 diff --git a/app/schemas/types.py b/app/schemas/types.py index f30be0239..704891c41 100644 --- a/app/schemas/types.py +++ b/app/schemas/types.py @@ -1,5 +1,5 @@ from enum import Enum -from typing import Literal, Optional +from typing import Literal, Optional, Tuple, Union # 音乐实体命名空间由公共类型模块统一持有,避免模型、接口和工具层重复定义。 @@ -68,6 +68,10 @@ class MediaSource(str, Enum): return self.value +# 搜索可以选择一个或多个来源,但集合中的每一项都必须是固定枚举。 +MediaSourceSelection = Union[MediaSource, Tuple[MediaSource, ...]] + + def media_type_to_agent(value) -> Optional[str]: """将枚举、Agent 键或数据库枚举值统一转换为 Agent 媒体类型。""" if isinstance(value, MediaType): diff --git a/app/testing/bootstrap.py b/app/testing/bootstrap.py index 4bb47961c..4995f928d 100644 --- a/app/testing/bootstrap.py +++ b/app/testing/bootstrap.py @@ -182,6 +182,18 @@ def prepare_v2_backend(plugins_repo: Path) -> None: _prepend_sys_path(Path(plugins_repo) / "plugins.v2") +def prepare_v3_backend(plugins_repo: Path) -> None: + """v3 插件单测引导:``prepare_backend`` + 把 ``/plugins.v3`` 注入 ``sys.path``。 + + v3 插件与旧代插件可能存在同名包,必须在独立 pytest 会话中加载,避免 Python 模块 + 缓存把其它代际实现复用到当前测试进程。 + + :param plugins_repo: 插件仓根目录(由调用方 shim 传入) + """ + prepare_backend() + _prepend_sys_path(Path(plugins_repo) / "plugins.v3") + + def prepare_v1_backend(plugins_repo: Path) -> None: """v1 插件单测引导:``prepare_backend`` + 把 ``/plugins`` 注入 ``sys.path``(与 v2 互斥)。 @@ -192,10 +204,10 @@ def prepare_v1_backend(plugins_repo: Path) -> None: def mark_plugin_generation(items, pytest_module) -> None: - """按用例所在目录自动给其打 ``v1`` / ``v2`` marker,供按代筛选与分会话运行。 + """按用例所在目录自动给其打 ``v1`` / ``v2`` / ``v3`` marker,供按代筛选与分会话运行。 优先读取 pytest 7+ 的 ``item.path``,旧版 pytest 缺失该属性时回退到 ``item.fspath``。用 - 「不带前导斜杠」的子串匹配(``tests/v2/`` / ``tests/v1/``),兼容相对路径与绝对路径两种 + 「不带前导斜杠」的子串匹配,兼容相对路径与绝对路径两种 运行方式:以 ``pytest tests/v2`` 等相对路径运行时收集路径可能不含前导斜杠。 ``pytest`` 模块由各仓 conftest 传入,避免本模块在非测试态强依赖 pytest。 @@ -205,7 +217,9 @@ def mark_plugin_generation(items, pytest_module) -> None: for item in items: item_path = getattr(item, "path", None) path = str(item_path if item_path is not None else item.fspath).replace("\\", "/") - if "tests/v2/" in path: + if "tests/v3/" in path: + item.add_marker(pytest_module.mark.v3) + elif "tests/v2/" in path: item.add_marker(pytest_module.mark.v2) elif "tests/v1/" in path: item.add_marker(pytest_module.mark.v1) diff --git a/app/utils/media.py b/app/utils/media.py index d3bdf370f..5fe545a67 100644 --- a/app/utils/media.py +++ b/app/utils/media.py @@ -5,6 +5,7 @@ from app.schemas.types import ( MUSIC_ENTITY_TYPES, MUSIC_SUBSCRIBABLE_TYPES, MediaSource, + MediaSourceSelection, ) MEDIA_SOURCE_ALIASES = { @@ -83,48 +84,73 @@ def normalize_media_source( return MEDIA_SOURCE_ALIASES.get(normalized) +def parse_media_source_selection(value: Optional[str]) -> Tuple[MediaSource, ...]: + """ + 解析 HTTP 查询参数中的逗号分隔来源,并转换为有序枚举集合。 + + :param value: 逗号分隔的来源值;空值表示未显式选择来源 + :return: 去重后的媒体来源枚举元组 + :raises ValueError: 包含固定枚举之外的来源 + """ + if not value: + return () + sources: list[MediaSource] = [] + invalid_sources: list[str] = [] + for item in str(value).split(","): + raw_source = item.strip() + if not raw_source: + continue + source = normalize_media_source(raw_source) + if not source: + invalid_sources.append(raw_source) + elif source not in sources: + sources.append(source) + if invalid_sources: + raise ValueError(f"不支持的媒体数据源:{', '.join(invalid_sources)}") + return tuple(sources) + + def is_media_source_selected( - media_source: Optional[Union[MediaSource, str]], - source_key: Union[MediaSource, str], + media_source: Optional[MediaSourceSelection], + source_key: MediaSource, ) -> bool: """ 判断请求级媒体数据源集合是否包含当前模块。 - :param media_source: 请求级媒体数据源,支持逗号分隔,空表示不作限制 + :param media_source: 请求级媒体数据源枚举或枚举元组,空表示不作限制 :param source_key: 当前模块对应的数据源标识 :return: 是否包含 """ if not media_source: return True - normalized_key = normalize_media_source(source_key) - selected_sources = { - normalize_media_source(item) - for item in str(media_source).split(",") - } - return bool(normalized_key and normalized_key in selected_sources) + selected_sources = ( + (media_source,) + if isinstance(media_source, MediaSource) + else media_source + ) + return source_key in selected_sources def is_media_source_enabled( - media_source: Optional[Union[MediaSource, str]], - source_key: Union[MediaSource, str], + media_source: Optional[MediaSourceSelection], + source_key: MediaSource, ) -> bool: """ 判断媒体搜索时数据源是否启用:请求级来源集合优先,未指定时回退到 全局 SEARCH_SOURCE 多来源配置,两者均未配置时全部启用。 - :param media_source: 请求级媒体数据源,支持逗号分隔 + :param media_source: 请求级媒体数据源枚举或枚举元组 :param source_key: 当前模块对应的数据源标识 :return: 是否启用 """ if media_source: return is_media_source_selected(media_source, source_key) if settings.SEARCH_SOURCE: - normalized_key = normalize_media_source(source_key) configured_sources = { normalize_media_source(item) for item in str(settings.SEARCH_SOURCE).split(",") } - return normalized_key in configured_sources + return source_key in configured_sources return True @@ -137,7 +163,7 @@ def parse_media_key( prefix, media_id = str(media_key).split(":", 1) source = normalize_media_source(prefix) media_id = media_id.strip() - if not source or not media_id: + if not source or not media_id or media_id == "0": return None, None return source, media_id @@ -157,8 +183,9 @@ def resolve_media_identity( """ normalized_source = normalize_media_source(media_source) if media_source is not None or media_id is not None: - if normalized_source and media_id is not None and str(media_id).strip(): - return normalized_source, str(media_id).strip() + normalized_id = str(media_id).strip() if media_id is not None else "" + if normalized_source and normalized_id and normalized_id != "0": + return normalized_source, normalized_id return None, None if media is None: @@ -175,18 +202,44 @@ def resolve_media_identity( ) if normalized_source and object_media_id is not None: normalized_id = str(object_media_id).strip() - if normalized_id: + if normalized_id and normalized_id != "0": return normalized_source, normalized_id return None, None +def normalize_media_identity_payload( + payload: dict[str, Any], + *, + include_empty: bool = False, +) -> dict[str, Any]: + """ + 规范化字典中的媒体身份,保证来源与 ID 始终成对写入。 + + :param payload: 待写入或传输的字段字典 + :param include_empty: 字典未声明身份字段时,是否仍补充空身份 + :return: 复制后的规范字典;非法、半对或零值身份会被清空 + """ + normalized = dict(payload) + has_identity = "media_source" in normalized or "media_id" in normalized + if not has_identity and not include_empty: + return normalized + media_source, media_id = resolve_media_identity( + media_source=normalized.get("media_source"), + media_id=normalized.get("media_id"), + ) + normalized["media_source"] = media_source.value if media_source else None + normalized["media_id"] = media_id + return normalized + + def build_media_key( media_source: Optional[Union[MediaSource, str]], media_id: Optional[Any], ) -> str: """构造 API 使用的带来源前缀媒体键。""" normalized_source = normalize_media_source(media_source) - if not normalized_source or media_id is None or not str(media_id).strip(): + normalized_id = str(media_id).strip() if media_id is not None else "" + if not normalized_source or not normalized_id or normalized_id == "0": return "" prefix = MEDIA_SOURCE_PREFIXES[normalized_source] - return f"{prefix}:{str(media_id).strip()}" + return f"{prefix}:{normalized_id}" diff --git a/app/utils/rust_accel.py b/app/utils/rust_accel.py index f269cd533..2ba8ba48e 100644 --- a/app/utils/rust_accel.py +++ b/app/utils/rust_accel.py @@ -201,6 +201,32 @@ def parse_metainfo_path(path: str, options: Optional[dict] = None) -> Optional[d return None +def parse_metamusic( + title: str, + artists: Optional[List[str]] = None, + year: Optional[int] = None, +) -> Optional[dict]: + """使用 Rust 解析音乐资源标题,旧扩展不支持时返回 None。 + + :param title: 音乐资源标题或文件主干名 + :param artists: 调用方已有的高可信艺术家列表 + :param year: 调用方已有的高可信发行年份 + :return: Rust 解析字段,不可用、不支持或异常时返回 None + """ + if not is_enabled(): + return None + parser = getattr(_moviepilot_rust, "parse_metamusic_fast", None) + if not callable(parser): + return None + try: + result = parser(title, artists, year) + except BaseException as err: + _raise_non_rust_panic(err) + logger.debug(f"Rust MetaMusic解析失败,使用 Python 解析兜底:{err}") + return None + return result if isinstance(result, dict) and result else None + + def find_metainfo(title: str) -> Optional[dict]: """ 使用 Rust 提取标题中的显式媒体标签,不可用或异常时返回 None。 @@ -238,6 +264,27 @@ def supports_extended_media_ids() -> bool: ) +@lru_cache(maxsize=1) +def supports_unified_media_identity() -> bool: + """判断当前 Rust 扩展是否支持固定来源的通用媒体身份标签。""" + if not is_enabled(): + return False + try: + result = _moviepilot_rust.find_metainfo_fast( + "test {[media_source=musicbrainz;media_id=recording-1]}" + ) + except BaseException as err: + _raise_non_rust_panic(err) + logger.debug(f"检测 Rust 通用媒体身份能力失败:{err}") + return False + metainfo = result.get("metainfo") if isinstance(result, dict) else None + return bool( + metainfo + and metainfo.get("media_source") == "musicbrainz" + and metainfo.get("media_id") == "recording-1" + ) + + def _raise_non_rust_panic(err: BaseException) -> None: """ 只吞掉 Rust 扩展 panic/异常,保留用户中断和进程退出语义。 diff --git a/app/workflow/actions/scrape_file.py b/app/workflow/actions/scrape_file.py index fe82e8cc5..e240a4780 100644 --- a/app/workflow/actions/scrape_file.py +++ b/app/workflow/actions/scrape_file.py @@ -1,4 +1,5 @@ from app.chain.media import MediaChain +from app.chain.scraping import ScrapingChain from app.chain.storage import StorageChain from app.core.config import global_vars from app.log import logger @@ -74,7 +75,7 @@ class ScrapeFileAction(BaseAction): _failed_count += 1 logger.info(f"{fileitem.path} 未识别到媒体信息,无法刮削") continue - scrape_result = mediachain.scrape_metadata( + scrape_result = ScrapingChain().scrape_metadata( fileitem=fileitem, meta=media_context.meta_info, mediainfo=media_context.media_info diff --git a/database/versions/8a4c7e1d2f90_3_0_1.py b/database/versions/8a4c7e1d2f90_3_0_0.py similarity index 70% rename from database/versions/8a4c7e1d2f90_3_0_1.py rename to database/versions/8a4c7e1d2f90_3_0_0.py index deecd1461..342779101 100644 --- a/database/versions/8a4c7e1d2f90_3_0_1.py +++ b/database/versions/8a4c7e1d2f90_3_0_0.py @@ -1,4 +1,4 @@ -"""3.0.1 +"""3.0.0 统一通用媒体表的来源与原生 ID Revision ID: 8a4c7e1d2f90 @@ -61,7 +61,24 @@ SOURCE_ALIASES = { "audio_db": "theaudiodb", "doubanmusic": "doubanmusic", "douban_music": "doubanmusic", + "bilibili": "bilibili", + "mangguodiscover": "mangguodiscover", + "mango_tv": "mangguodiscover", + "migu": "migu", + "migu_video": "migu", + "tencentvideodiscover": "tencentvideodiscover", + "tencent_video": "tencentvideodiscover", } +MEDIA_SOURCE_VALUES = frozenset(SOURCE_ALIASES.values()) +MEDIA_SOURCE_SQL_VALUES = ", ".join( + f"'{source}'" for source in sorted(MEDIA_SOURCE_VALUES) +) +MEDIA_IDENTITY_CHECK_SQL = ( + "(media_source IS NULL AND media_id IS NULL) OR " + "(media_source IS NOT NULL AND " + f"media_source IN ({MEDIA_SOURCE_SQL_VALUES}) AND " + "media_id IS NOT NULL AND trim(media_id) <> '' AND trim(media_id) <> '0')" +) def _inspector() -> sa.Inspector: @@ -108,13 +125,45 @@ def _normalize_existing_sources(table_name: str) -> None: ) connection = op.get_bind() for alias, source in SOURCE_ALIASES.items(): - if alias == source: - continue connection.execute( table.update() - .where(sa.func.lower(table.c.media_source) == alias) + .where(sa.func.lower(sa.func.trim(table.c.media_source)) == alias) .values(media_source=source) ) + connection.execute( + table.update() + .where(table.c.media_source.is_not(None)) + .values(media_source=sa.func.trim(table.c.media_source)) + ) + + +def _clear_invalid_or_partial_identity(table_name: str) -> None: + """清空无效或仅有一半的身份,允许后续从旧字段重新回填。""" + table = sa.table( + table_name, + sa.column("media_source", sa.String()), + sa.column("media_id", sa.String()), + ) + invalid_identity = sa.or_( + table.c.media_source.is_(None), + sa.func.trim(table.c.media_source) == "", + sa.func.lower(sa.func.trim(table.c.media_source)).not_in( + MEDIA_SOURCE_VALUES + ), + table.c.media_id.is_(None), + sa.func.trim(table.c.media_id) == "", + sa.func.trim(table.c.media_id) == "0", + ) + op.get_bind().execute( + table.update() + .where(invalid_identity) + .values(media_source=None, media_id=None) + ) + op.get_bind().execute( + table.update() + .where(table.c.media_id.is_not(None)) + .values(media_id=sa.func.trim(table.c.media_id)) + ) def _backfill_prefixed_media_id(table_name: str, columns: set[str]) -> None: @@ -127,22 +176,32 @@ def _backfill_prefixed_media_id(table_name: str, columns: set[str]) -> None: sa.column("media_source", sa.String()), sa.column("media_id", sa.String()), ) - for prefix, source in ( - ("tmdb", "themoviedb"), - ("themoviedb", "themoviedb"), - ("douban", "douban"), - ("bangumi", "bangumi"), - ("anilist", "anilist"), - ("imdb", "imdb"), - ("tvdb", "tvdb"), - ): + for prefix, source in SOURCE_ALIASES.items(): op.get_bind().execute( table.update() .where(_identity_missing(table)) - .where(table.c.mediaid.like(f"{prefix}:%")) + .where( + sa.func.lower( + sa.func.substr( + sa.func.trim(table.c.mediaid), 1, len(prefix) + 1 + ) + ) == f"{prefix}:" + ) + .where( + sa.func.trim( + sa.func.substr(table.c.mediaid, len(prefix) + 2) + ) != "" + ) + .where( + sa.func.trim( + sa.func.substr(table.c.mediaid, len(prefix) + 2) + ) != "0" + ) .values( media_source=source, - media_id=sa.func.substr(table.c.mediaid, len(prefix) + 2), + media_id=sa.func.trim( + sa.func.substr(table.c.mediaid, len(prefix) + 2) + ), ) ) @@ -166,10 +225,11 @@ def _backfill_source_columns(table_name: str, columns: set[str]) -> None: table.update() .where(_identity_missing(table)) .where(identity_column.is_not(None)) - .where(sa.cast(identity_column, sa.String()) != "") + .where(sa.func.trim(sa.cast(identity_column, sa.String())) != "") + .where(sa.func.trim(sa.cast(identity_column, sa.String())) != "0") .values( media_source=source, - media_id=sa.cast(identity_column, sa.String()), + media_id=sa.func.trim(sa.cast(identity_column, sa.String())), ) ) @@ -241,6 +301,41 @@ def _ensure_identity_indexes() -> None: ) +def _ensure_identity_constraints() -> None: + """为六张通用媒体表建立来源枚举与身份成对数据库约束。""" + for table_name in LEGACY_COLUMNS: + if not _has_table(table_name): + continue + constraint_name = f"ck_{table_name}_media_identity" + existing = { + constraint.get("name") + for constraint in _inspector().get_check_constraints(table_name) + } + if constraint_name in existing: + continue + with op.batch_alter_table(table_name) as batch_op: + batch_op.create_check_constraint( + constraint_name, + MEDIA_IDENTITY_CHECK_SQL, + ) + + +def _drop_identity_constraints() -> None: + """降级时移除本次迁移新增的媒体身份数据库约束。""" + for table_name in LEGACY_COLUMNS: + if not _has_table(table_name): + continue + constraint_name = f"ck_{table_name}_media_identity" + existing = { + constraint.get("name") + for constraint in _inspector().get_check_constraints(table_name) + } + if constraint_name not in existing: + continue + with op.batch_alter_table(table_name) as batch_op: + batch_op.drop_constraint(constraint_name, type_="check") + + def upgrade() -> None: """回填规范媒体身份,并删除通用表中的全部来源专用 ID 字段。""" for table_name, legacy_columns in LEGACY_COLUMNS.items(): @@ -248,11 +343,13 @@ def upgrade() -> None: continue _ensure_identity_columns(table_name) _normalize_existing_sources(table_name) + _clear_invalid_or_partial_identity(table_name) columns = _column_names(table_name) _backfill_prefixed_media_id(table_name, columns) _backfill_source_columns(table_name, columns) _drop_legacy_columns(table_name, legacy_columns) _ensure_identity_indexes() + _ensure_identity_constraints() def _restore_legacy_columns(table_name: str, columns: Iterable[str]) -> None: @@ -291,6 +388,7 @@ def _restore_legacy_columns(table_name: str, columns: Iterable[str]) -> None: def downgrade() -> None: """恢复旧列;已被规范身份舍弃的辅助来源 ID 无法无损恢复。""" + _drop_identity_constraints() for table_name, legacy_columns in LEGACY_COLUMNS.items(): _restore_legacy_columns(table_name, legacy_columns) if _has_table("mediaserveritem"): diff --git a/docs/cli.md b/docs/cli.md index a24516df1..b81ea2fcf 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -481,7 +481,7 @@ moviepilot tool show search_torrents ```shell moviepilot tool run query_schedulers -moviepilot tool run search_torrents media_type=movie tmdb_id=12345 +moviepilot tool run search_torrents media_type=movie media_source=themoviedb media_id=12345 ``` 说明: @@ -489,6 +489,7 @@ moviepilot tool run search_torrents media_type=movie tmdb_id=12345 - `tool list` 用于动态发现当前服务可调用的工具 - `tool show` 会输出参数名、类型和描述 - `tool run` 参数格式固定为 `key=value` +- 涉及精确媒体身份的通用工具统一使用 `media_source` + `media_id`;`media_source` 必须是工具 Schema 列出的 `MediaSource` 枚举值,两个字段必须成对传递并复用搜索结果。TMDB 等单数据源专属工具按各自 Schema 保留原生 ID 参数 - `read_file`、`write_file`、`edit_file` 和 `execute_command` 属于内置 Agent 的本地敏感能力,不通过 MCP/`moviepilot tool` 暴露;插件开发时 由 Agent 按当前用户权限直接调用这些工具。 diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 424d70ea5..148763eec 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -132,20 +132,20 @@ FastAPI 的 HTTP 异常在 v1、v2 均统一使用 `message`,不再返回顶 #### 媒体识别 / 整理 -媒体识别、搜索和手动整理内置支持 `themoviedb`、`douban`、`bangumi`、`anilist` 四种影视数据源,也允许插件处理自定义来源。影视自动识别在未指定来源时只使用 TMDB,未命中时不会继续查询其它影视源。音乐路径识别严格按 AcoustID 音频指纹、文件标签、文件名三级依次执行;指纹或标签直接提供 MusicBrainz Recording ID 时,会直接查询 MusicBrainz 详情,标签和文件名标题识别也只使用 MusicBrainz。其它元数据源仅在手动操作通过请求级 `source` 或 `media_source` + `media_id` 明确指定时使用,不修改系统默认值,也不会跨来源兜底。 +媒体识别、搜索和手动整理统一使用 `media_source` + `media_id` 表示媒体主身份。`media_source` 必须是 `MediaSource` 枚举值:`themoviedb`、`douban`、`bangumi`、`anilist`、`imdb`、`tvdb`、`musicbrainz`、`theaudiodb`、`doubanmusic`、`bilibili`、`mangguodiscover`、`migu` 或 `tencentvideodiscover`;`media_id` 是该来源的原生 ID,不添加 `tmdb:` 等前缀。需要精确身份时两个字段必须同时提供,不能只传其中一个。 -涉及媒体身份的请求统一以 `media_source` + `media_id` 表示本次选定的主身份,同时保留 `tmdbid`、`doubanid`、`bangumiid`、`anilistid` 作为跨数据源映射和旧客户端兼容字段。两者并非两套独立数据流:显式通用主身份优先,专用 ID 用于补全映射和兼容回退。 +影视自动识别在未指定来源时只使用 TMDB,未命中时不会继续查询其它影视源。音乐路径识别严格按 AcoustID 音频指纹、文件标签、文件名三级依次执行;指纹或标签直接提供 MusicBrainz Recording ID 时,会直接查询 MusicBrainz 详情,标签和文件名标题识别也只使用 MusicBrainz。其它元数据源仅在手动操作通过请求级 `media_source`,或通过完整的 `media_source` + `media_id` 精确指定时使用,不修改系统默认值,也不会跨来源兜底。`MediaInfo` 响应仍可能包含 `tmdb_id`、`douban_id`、`bangumi_id`、`anilist_id` 等跨源映射辅助字段,但这些字段不是通用请求入口。明确归属 `/tmdb`、`/douban`、`/bangumi`、`/anilist` 的接口,以及固定使用 TMDB 的剧集组和排期接口,仍可按其单数据源契约接收原生 ID。 | 方法 | 路径 | 说明 | | :--- | :--- | :--- | -| GET | `/api/v1/media/search` | 按标题搜索媒体、合集或人物,参数:`title`、`type`、`page`、`count`,可选 `source`;`media` 支持 `themoviedb`、`douban`、`bangumi`、`anilist`,`collection` 支持 `themoviedb`,`person` 支持 `themoviedb`、`douban` | -| GET | `/api/v1/media/recognize` | 识别标题,参数:`title`、`subtitle`、`custom_words`,可选 `source`;当 `title` 为含目录的媒体文件路径时,会合并父目录中的名称、年份等信息 | -| GET | `/api/v1/media/recognize_file` | 识别文件路径,参数:`path`,可选 `source` | -| GET | `/api/v1/media/{mediaid}` | 查询媒体详情,`mediaid` 支持 `tmdb:`、`douban:`、`bangumi:`、`anilist:` 及插件自定义来源前缀 | +| GET | `/api/v1/media/search` | 按标题搜索媒体、合集、人物或音乐,参数:`title`、`type`、`page`、`count`,可重复传入可选 `media_source`;不同搜索类型仅接受其支持的 `MediaSource` 枚举值,旧客户端的逗号格式仅在输入边界兼容 | +| GET | `/api/v1/media/recognize` | 识别标题,参数:`title`、`subtitle`、`custom_words`,可选 `media_source`;当 `title` 为含目录的媒体文件路径时,会合并父目录中的名称、年份等信息 | +| GET | `/api/v1/media/recognize_file` | 识别文件路径,参数:`path`,可选 `media_source` | +| GET | `/api/v1/media/{media_id}` | 按原生 ID 查询媒体详情;必填参数:`media_source`、`type_name`,其中 `media_source` 与路径中的 `media_id` 组成统一媒体身份 | | POST | `/api/v1/media/scrape/{storage}` | 刮削媒体元数据;请求体为 `FileItem`,可选查询参数 `media_source`、`media_id`、`type_name`(电影/电视剧/音乐)。音乐会按策略处理音频标签、封面和歌词 | -| POST | `/api/v1/transfer/manual/target-path` | 匹配手动整理目标路径;请求体可用 `media_source` + `media_id` 指定数据源原生ID | +| POST | `/api/v1/transfer/manual/target-path` | 按源文件与目录配置匹配手动整理目标路径;请求体为 `ManualTransferItem`,该接口不执行媒体识别 | | POST | `/api/v1/transfer/manual/history` | 查询文件、批量文件或目录命中的成功整理历史摘要,用于进入手动整理界面时显示重新整理状态 | -| POST | `/api/v1/transfer/manual` | 手动整理;请求体可用 `media_source` + `media_id` 指定本次识别与刮削数据源,同时兼容 `tmdbid`、`doubanid`、`bangumiid`、`anilistid`;命中失败历史时自动清理旧目标和记录后重试,`reorganize=true` 时清理命中的成功历史和非移动模式旧目标后重新整理 | +| POST | `/api/v1/transfer/manual` | 手动整理;请求体可用 `media_source` + `media_id` 指定本次识别与刮削数据源;命中失败历史时自动清理旧目标和记录后重试,`reorganize=true` 时清理命中的成功历史和非移动模式旧目标后重新整理 | #### 站点 @@ -157,14 +157,14 @@ FastAPI 的 HTTP 异常在 v1、v2 均统一使用 `message`,不再返回顶 | 方法 | 路径 | 说明 | | :--- | :--- | :--- | -| GET | `/api/v1/search/media/{mediaid}` | 按媒体 ID 搜索站点种子资源,`mediaid` 支持 `tmdb:123`、`douban:123`、`bangumi:123`、`anilist:123`、`musicbrainz:` 及插件来源前缀,参数:`mtype`、`area`、`title`、`year`、`season`、`sites` | -| GET | `/api/v1/search/media/{mediaid}/stream` | 按媒体 ID 渐进式搜索站点种子资源,返回 SSE,参数同上 | +| GET | `/api/v1/search/media/{media_id}` | 按统一媒体身份搜索站点种子资源;必填参数:`media_source`,其它参数:`mtype`、`area`、`season`、`sites`、`music_type` | +| GET | `/api/v1/search/media/{media_id}/stream` | 按统一媒体身份渐进式搜索站点种子资源,返回 SSE,参数同上 | | GET | `/api/v1/search/title` | 按关键字模糊搜索站点种子资源,参数:`keyword`、`page`、`sites`,可选 `mtype=音乐` 仅搜索音乐分类 | | GET | `/api/v1/search/title/stream` | 按关键字渐进式搜索站点种子资源,返回 SSE,参数:`keyword`、`page`、`sites`,可选 `mtype=音乐` | | GET | `/api/v1/search/subtitle/title` | 按关键字搜索站点字幕资源,参数:`keyword`、`page`、`sites` | | GET | `/api/v1/search/subtitle/title/stream` | 按关键字渐进式搜索站点字幕资源,返回 SSE,参数:`keyword`、`page`、`sites` | -| GET | `/api/v1/search/subtitle/media/{mediaid}` | 按媒体 ID 精确搜索站点字幕资源,`mediaid` 支持四种内置来源及插件来源前缀,参数:`mtype`、`title`、`year`、`season`、`episode`、`sites` | -| GET | `/api/v1/search/subtitle/media/{mediaid}/stream` | 按媒体 ID 渐进式精确搜索站点字幕资源,返回 SSE,参数同上 | +| GET | `/api/v1/search/subtitle/media/{media_id}` | 按统一媒体身份精确搜索站点字幕资源;必填参数:`media_source`,其它参数:`mtype`、`season`、`episode`、`sites` | +| GET | `/api/v1/search/subtitle/media/{media_id}/stream` | 按统一媒体身份渐进式精确搜索站点字幕资源,返回 SSE,参数同上 | | GET | `/api/v1/search/last` | 获取上一次种子搜索结果 | | GET | `/api/v1/search/last/context` | 获取上一次搜索结果及可复用搜索参数,`params.result_type` 为 `torrent` 或 `subtitle` | | POST | `/api/v1/search/recommend` | 获取 AI 推荐资源,请求体:`filtered_indices`、`check_only`、`force` | @@ -194,7 +194,7 @@ AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-ch | 方法 | 路径 | 说明 | | :--- | :--- | :--- | -| GET | `/api/v1/media/search` | 当 `type=music` 或指定音乐 `media_source` 时按歌曲、专辑或歌手关键词搜索音乐元数据,参数:`title`、`type`、`count`、`media_source` | +| GET | `/api/v1/media/search` | 当 `type=music` 或指定音乐 `media_source` 时按歌曲、专辑或歌手关键词搜索音乐元数据,参数:`title`、`type`、`count`、可重复的 `media_source` 枚举 | | POST | `/api/v1/music/recognize` | 按 `media_source` + `media_id` 识别音乐详情,请求体:`MusicRecognizeRequest` | | GET | `/api/v1/music/explore` | 按来源浏览音乐;`media_source=musicbrainz` 支持 `mode=chart|fresh` 榜单与新发行,`media_source=doubanmusic` 固定按官方标签分类浏览,使用 `tags` 和 `douban_sort=U|S|R|O` 筛选。其它参数:`entity=recording|album`、`range_name`、`sort_by`、`sort`、`days`、`past`、`future`、`min_listen_count`、`with_cover`、`page`、`count` | | GET | `/api/v1/music/album/{album_id}` | 按来源专辑 ID 查询专辑详情、完整曲目和发行版本,参数:`media_source` | @@ -215,8 +215,8 @@ AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-ch | :--- | :--- | :--- | | GET | `/api/v1/download/` | 查询正在下载的任务,参数:`name`;关联下载历史时返回媒体类型、来源站点 `site_name`,以及 `media.poster` 海报和 `media.backdrop` 背景图;兼容字段 `media.image` 与 `media.poster` 相同 | | POST | `/api/v1/download/` | 添加含媒体信息的下载任务,请求体包含媒体信息和种子信息 | -| POST | `/api/v1/download/add` | 添加不含媒体信息的下载任务,请求体包含 `torrent_in`,可选 `media_source` + `media_id`;继续兼容四种专用 ID,并支持 `downloader`、`save_path` | -| POST | `/api/v1/download/subtitle` | 下载字幕到识别出的媒体下载目录,请求体包含 `subtitle_in`,可选 `media_source` + `media_id`;继续兼容四种专用 ID,并支持 `save_path` | +| POST | `/api/v1/download/add` | 添加不含媒体信息的下载任务,请求体包含 `torrent_in`,可选且必须成对提供 `media_source` + `media_id`,并支持 `music_type`、`downloader`、`save_path` | +| POST | `/api/v1/download/subtitle` | 下载字幕到识别出的媒体下载目录,请求体包含 `subtitle_in`,并必须提供 `media_source` + `media_id`;可选 `save_path` | | GET | `/api/v1/download/start/{hashString}` | 恢复下载任务,参数:`name` | | GET | `/api/v1/download/stop/{hashString}` | 暂停下载任务,参数:`name` | | GET | `/api/v1/download/clients` | 查询可用下载器 | @@ -297,7 +297,7 @@ TMDB 缓存查询响应的 `data` 包含 `count`、`recognized`、`unrecognized` 其中 `read_file` 单次最多返回 50KB 文件内容;超出时会截断并提示 Agent 使用 `start_line`、`end_line` 指定更小的行号范围继续读取。 -媒体相关 MCP 工具(如 `search_media`、`query_media_detail`、`search_torrents`、`query_library_exists`、`add_subscribe`、`transfer_file`、`scrape_metadata`)接受 `tmdb_id`/`tmdbid`、`douban_id`/`doubanid`、`bangumi_id`/`bangumiid`、`anilist_id`/`anilistid`,也接受 `media_source` + `media_id`。音乐调用还使用 `media_type=music` 与 `music_type=recording|album|artist`;其中艺术家只允许搜索和详情浏览。工具返回的媒体、订阅、下载和整理记录会带回可复用的专用 ID、通用主身份以及音乐实体字段。 +媒体相关 MCP 工具以 `MediaSource` 枚举 `media_source` + 来源原生 `media_id` 传递精确身份。`query_media_detail`、`search_torrents`、`query_library_exists` 必须提供完整字段对;`add_subscribe`、`transfer_file`、`scrape_metadata` 在显式指定身份时也必须成对提供。`search_media` 和 `recognize_media` 是按标题或路径发现身份的入口,其结果中的字段对可直接用于后续工具。音乐调用还使用 `media_type=music` 与 `music_type=recording|album|artist`;其中艺术家只允许搜索和详情浏览。工具响应中的专用 ID 仅是跨源映射辅助输出,不应再作为上述通用工具的输入。TMDB 专用的 `query_episode_schedule` 仍使用 `tmdb_id`,因为它直接调用单一 TMDB 剧集接口。 Agent 音乐流程与影视共用同一采集管线,但实体边界不同:单曲通过 `music_type=recording` 按一个文件处理;专辑通过 `music_type=album` 类似电视剧整季包,按一个目录/资源处理并校验总曲目数;艺术家不是采集目标。`add_subscribe` / `update_subscribe` 支持音乐音质筛选字段和 `best_version` 音质洗版;`query_subscribes` 会返回筛选条件及当前音质快照。`scrape_metadata(media_type="music")` 会按策略写音频标签、封面和歌词,并返回歌词新增、已存在、未匹配和失败数量。 diff --git a/docs/rules/03-commands.md b/docs/rules/03-commands.md index 5580db870..32e9ec2af 100644 --- a/docs/rules/03-commands.md +++ b/docs/rules/03-commands.md @@ -219,7 +219,7 @@ moviepilot tool show search_torrents # Run a tool directly moviepilot tool run query_schedulers -moviepilot tool run search_torrents media_type=movie tmdb_id=12345 +moviepilot tool run search_torrents media_type=movie media_source=themoviedb media_id=12345 # List scheduled tasks moviepilot scheduler list @@ -228,6 +228,11 @@ moviepilot scheduler list moviepilot scheduler run subscribe_refresh ``` +**Media identity rule:** Generic media tools use the complete `media_source` + +`media_id` pair returned by media search. `media_source` must be a `MediaSource` +enum value. A source-owned tool such as `query_episode_schedule` may retain its +native ID parameter because its schema and implementation are single-source. + --- ## Local CLI — Agent diff --git a/docs/testing.md b/docs/testing.md index 4b2e2d5d7..d1f6c573b 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -75,16 +75,23 @@ with patch.object(SomeModule, "fetch", new=AsyncMock(return_value=FAKE)): ```python import pytest +from app.schemas.types import MediaSource + @pytest.fixture def sample_meta(): """构造一条可复用的识别元数据。""" return MetaInfo(title="示例 (2020)") -def test_recognize_prefers_explicit_id(sample_meta, monkeypatch): - """显式 tmdbid 时应优先按 ID 识别,而非回退标题搜索。""" +def test_recognize_prefers_explicit_identity(sample_meta, monkeypatch): + """显式媒体来源与原生 ID 时应优先精确识别,而非回退标题搜索。""" monkeypatch.setattr(SomeClient, "fetch", lambda *a, **k: FAKE_MOVIE) - result = recognize(sample_meta, tmdbid=123) - assert result.tmdb_id == 123 + result = recognize( + sample_meta, + media_source=MediaSource.TMDB, + media_id="123", + ) + assert result.media_source == MediaSource.TMDB + assert result.media_id == "123" ``` ## `unittest → pytest` 演进路线:改到即转 diff --git a/requirements.in b/requirements.in index b9500b48b..e540ffee6 100644 --- a/requirements.in +++ b/requirements.in @@ -1,4 +1,4 @@ -moviepilot-rust~=0.2.6 +moviepilot-rust~=0.2.7 pydantic>=2.13.4,<3.0.0 pydantic-settings>=2.14.2,<3.0.0 SQLAlchemy~=2.0.50 diff --git a/scripts/benchmark_metainfo_rust.py b/scripts/benchmark_metainfo_rust.py index bafce77d5..cc5dd8c20 100644 --- a/scripts/benchmark_metainfo_rust.py +++ b/scripts/benchmark_metainfo_rust.py @@ -4,20 +4,58 @@ import sys import time from contextlib import contextmanager from pathlib import Path +from typing import Any, Callable, Optional PROJECT_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(PROJECT_ROOT)) from app.core import metainfo as metainfo_module +from app.core.meta import MetaAnime, MetaMusic from app.core.metainfo import MetaInfo, MetaInfoPath from tests.cases.meta import meta_cases -def build_inputs(repeat: int): - """ - 构造覆盖 MetaInfo 和 MetaInfoPath 的基准输入。 - """ - inputs = [] +BenchmarkInput = tuple[str, str, Optional[str]] +ResultProjector = Callable[[Any], dict[str, Any]] + +_MUSIC_CASES: tuple[BenchmarkInput, ...] = ( + ("music_query", "毛阿敏 - 永遠是朋友(2000) - ALAC [16B-44.1kHz]", None), + ( + "music_query", + "VA-Once.Upon.a.Time.in.Hollywood.Original.Motion.Picture.Soundtrack." + "2019.FLAC.24bit.96kHz", + None, + ), + ("music_query", "李宗盛《理性与感性作品音乐会-CD2》2006-FLAC-分轨", None), + ("music_query", "天国的情人-邓丽君作品全集1967-1995", None), + ( + "music_query", + "S H E - S H E十七音乐会 2018 WEB-DL 1080P AVC AAC-FHDMv", + None, + ), + ("title", "周杰伦 - 晴天.flac", None), + ("title", "01.我的地盘.wav", None), + ( + "path", + "/benchmark/music/周杰伦 - 七里香 (2004) [FLAC 24bit-96kHz]/01.我的地盘.flac", + None, + ), + ( + "path", + "/benchmark/music/Daft Punk - Discovery (2001)/CD1/01 - One More Time.flac", + None, + ), + ( + "path", + "/benchmark/music/喜多郎 - 古事记 (1990) [SACD]/1-02 古事记.dsf", + None, + ), +) + + +def build_video_inputs(repeat: int) -> list[BenchmarkInput]: + """构造覆盖影视 MetaInfo 和 MetaInfoPath 生产入口的基准输入。""" + inputs: list[BenchmarkInput] = [] for _ in range(repeat): for item in meta_cases: if item.get("path"): @@ -27,88 +65,277 @@ def build_inputs(repeat: int): return inputs +def build_music_inputs(repeat: int) -> list[BenchmarkInput]: + """构造覆盖音乐查询、音频文件名和目录路径生产入口的基准输入。""" + return list(_MUSIC_CASES) * repeat + + def disabled_rust_parse(*_args, **_kwargs): - """ - 关闭 Rust MetaInfo 快路径,用于测量旧 Python 链路。 - """ + """关闭一个 Rust 快路径,使生产入口自然回退到 Python 实现。""" return None @contextmanager def selected_meta_parser(use_rust: bool): - """ - 在 Rust 入口和 Python 旧实现之间切换。 - """ - original_parse = metainfo_module.rust_accel.parse_metainfo - original_parse_path = metainfo_module.rust_accel.parse_metainfo_path - original_find = metainfo_module.rust_accel.find_metainfo + """在 Rust 入口和 Python 回退链路之间切换,并在退出时恢复适配器。""" + parser_names = ( + "parse_metainfo", + "parse_metainfo_path", + "find_metainfo", + "parse_metamusic", + ) + rust_accel = metainfo_module.rust_accel + original_parsers = { + name: getattr(rust_accel, name) + for name in parser_names + } if not use_rust: - metainfo_module.rust_accel.parse_metainfo = disabled_rust_parse - metainfo_module.rust_accel.parse_metainfo_path = disabled_rust_parse - metainfo_module.rust_accel.find_metainfo = disabled_rust_parse + for name in parser_names: + setattr(rust_accel, name, disabled_rust_parse) try: yield finally: - metainfo_module.rust_accel.parse_metainfo = original_parse - metainfo_module.rust_accel.parse_metainfo_path = original_parse_path - metainfo_module.rust_accel.find_metainfo = original_find + for name, parser in original_parsers.items(): + setattr(rust_accel, name, parser) -def parse_all(inputs, use_rust: bool): - """ - 执行一轮完整 MetaInfo/MetaInfoPath 入口解析。 - """ - with selected_meta_parser(use_rust): - parsed = [] - for kind, value, subtitle in inputs: - if kind == "path": - parsed.append(MetaInfoPath(Path(value))) - else: - parsed.append(MetaInfo(title=value, subtitle=subtitle, custom_words=["#"])) - return parsed +def parse_input(item: BenchmarkInput): + """按输入类型调用应用实际使用的公开识别入口。""" + kind, value, subtitle = item + if kind == "path": + return MetaInfoPath(Path(value)) + if kind == "music_query": + return MetaMusic.parse_query(value) + if kind == "title": + return MetaInfo(title=value, subtitle=subtitle, custom_words=["#"]) + raise ValueError(f"未知基准输入类型:{kind}") -def measure(inputs, use_rust: bool, loops: int, repeats: int): - """ - 多轮测量 MetaInfo 入口解析耗时。 - """ +def parse_all(inputs: list[BenchmarkInput]) -> list[Any]: + """通过生产入口解析一轮完整输入。""" + return [parse_input(item) for item in inputs] + + +def _enum_value(value: Any) -> Any: + """把枚举值归一为稳定的可比较值。""" + return getattr(value, "value", value) + + +def project_video_result(meta: Any) -> dict[str, Any]: + """提取影视识别对外契约字段,排除 Python 解析器的临时内部状态。""" + return { + "kind": "anime" if isinstance(meta, MetaAnime) else "video", + "type": _enum_value(meta.type), + "cn_name": meta.cn_name or "", + "en_name": meta.en_name or "", + "year": meta.year or "", + "part": meta.part or "", + "season": meta.season, + "episode": meta.episode, + "resource_type": meta.edition, + "resource_pix": meta.resource_pix or "", + "video_encode": meta.video_encode or "", + "audio_encode": meta.audio_encode or "", + "fps": meta.fps or None, + "media_source": _enum_value(meta.media_source), + "media_id": meta.media_id, + } + + +def project_music_result(meta: Any) -> dict[str, Any]: + """提取音乐识别持久字段和派生音质字段,用于 Rust/Python 等价校验。""" + return { + "type": _enum_value(meta.type), + "org_string": meta.org_string, + "title": meta.title, + "artists": list(meta.artists), + "album": meta.album, + "album_artist": meta.album_artist, + "year": meta.year, + "disc_number": meta.disc_number, + "track_number": meta.track_number, + "total_discs": meta.total_discs, + "total_tracks": meta.total_tracks, + "version": meta.version, + "audio_format": meta.audio_format, + "audio_lossless": meta.audio_lossless, + "bit_depth": meta.bit_depth, + "sample_rate": meta.sample_rate, + "bitrate": meta.bitrate, + "duration": meta.duration, + "isrc": meta.isrc, + "media_source": _enum_value(meta.media_source), + "media_id": meta.media_id, + "audio_quality": meta.audio_quality, + "audio_quality_score": meta.audio_quality_score, + "audio_specs": meta.audio_specs, + } + + +def assert_projected_results_equal( + inputs: list[BenchmarkInput], + rust_results: list[Any], + python_results: list[Any], + projector: ResultProjector, +) -> None: + """逐项校验 Rust/Python 稳定输出,首个差异携带输入和字段明细。""" + if len(rust_results) != len(python_results): + raise AssertionError( + f"Rust/Python 结果数量不一致:{len(rust_results)} != {len(python_results)}" + ) + for index, (rust_result, python_result) in enumerate(zip(rust_results, python_results)): + rust_projection = projector(rust_result) + python_projection = projector(python_result) + if rust_projection == python_projection: + continue + differences = { + key: (rust_projection.get(key), python_projection.get(key)) + for key in sorted(set(rust_projection) | set(python_projection)) + if rust_projection.get(key) != python_projection.get(key) + } + raise AssertionError( + f"Rust/Python 输出不等价:index={index} input={inputs[index]!r} " + f"differences={differences!r}" + ) + + +def validate_equivalent_results( + inputs: list[BenchmarkInput], + projector: ResultProjector, +) -> None: + """分别通过 Rust/Python 生产链路解析并校验稳定输出等价。""" + with selected_meta_parser(use_rust=True): + rust_results = parse_all(inputs) + with selected_meta_parser(use_rust=False): + python_results = parse_all(inputs) + assert_projected_results_equal(inputs, rust_results, python_results, projector) + + +def measure( + inputs: list[BenchmarkInput], + use_rust: bool, + loops: int, + repeats: int, +) -> tuple[float, int]: + """在一次解析器切换上下文中预热并多轮测量生产入口耗时。""" samples = [] parsed_count = 0 - for _ in range(repeats): - start = time.perf_counter() - for _ in range(loops): - parsed = parse_all(inputs, use_rust) - parsed_count = len(parsed) - samples.append((time.perf_counter() - start) * 1000 / loops) + with selected_meta_parser(use_rust): + parse_all(inputs) + for _ in range(repeats): + start = time.perf_counter() + for _ in range(loops): + parsed_count = len(parse_all(inputs)) + samples.append((time.perf_counter() - start) * 1000 / loops) return statistics.median(samples), parsed_count +def benchmark_suite( + inputs: list[BenchmarkInput], + projector: ResultProjector, + loops: int, + repeats: int, +) -> dict[str, float | int]: + """先校验一个媒体域的结果等价,再返回 Rust/Python 独立性能指标。""" + validate_equivalent_results(inputs, projector) + rust_ms, rust_count = measure(inputs, use_rust=True, loops=loops, repeats=repeats) + python_ms, python_count = measure(inputs, use_rust=False, loops=loops, repeats=repeats) + return { + "rust_ms": rust_ms, + "python_ms": python_ms, + "rust_count": rust_count, + "python_count": python_count, + "speedup": python_ms / rust_ms if rust_ms else 0, + } + + +def validate_rust_runtime() -> None: + """确认 Rust 总开关和音乐扩展入口可用,拒绝静默回退形成伪基准。""" + rust_accel = metainfo_module.rust_accel + if not rust_accel.is_enabled(): + raise RuntimeError("Rust 加速未启用或 moviepilot-rust 扩展不可用") + if not callable(getattr(rust_accel, "parse_metamusic", None)): + raise RuntimeError("MoviePilot 后端缺少 rust_accel.parse_metamusic 适配器") + extension = getattr(rust_accel, "_moviepilot_rust", None) + if not callable(getattr(extension, "parse_metamusic_fast", None)): + raise RuntimeError("moviepilot-rust 版本过旧,缺少 parse_metamusic_fast") + probe = rust_accel.parse_metamusic("Daft Punk - Get Lucky 2013 FLAC") + if not isinstance(probe, dict): + raise RuntimeError("Rust 音乐解析探针未返回有效结果,拒绝测量 Python 回退") + + +def positive_int(value: str) -> int: + """解析命令行正整数,拒绝空循环和空样本配置。""" + parsed = int(value) + if parsed <= 0: + raise argparse.ArgumentTypeError("必须为正整数") + return parsed + + def parse_args(): - """ - 解析命令行参数。 - """ - parser = argparse.ArgumentParser(description="Benchmark MetaInfo parsing through public entries") - parser.add_argument("--repeat-inputs", type=int, default=20, help="Repeat meta cases per loop") - parser.add_argument("--loops", type=int, default=10, help="Loops per repeat") - parser.add_argument("--repeats", type=int, default=5, help="Repeat count") + """解析命令行参数。""" + parser = argparse.ArgumentParser( + description="Benchmark video and music metadata through public entries" + ) + parser.add_argument( + "--repeat-inputs", + type=positive_int, + default=20, + help="Repeat video and music cases per loop", + ) + parser.add_argument("--loops", type=positive_int, default=10, help="Loops per repeat") + parser.add_argument("--repeats", type=positive_int, default=5, help="Repeat count") return parser.parse_args() -def main() -> int: - """ - 运行 MetaInfo Rust 与 Python 入口链路基准测试。 - """ - args = parse_args() - inputs = build_inputs(args.repeat_inputs) - rust_ms, rust_count = measure(inputs, use_rust=True, loops=args.loops, repeats=args.repeats) - python_ms, python_count = measure(inputs, use_rust=False, loops=args.loops, repeats=args.repeats) - speedup = python_ms / rust_ms if rust_ms else 0 +def print_suite_result( + name: str, + inputs: list[BenchmarkInput], + result: dict[str, float | int], + loops: int, + repeats: int, +) -> None: + """按媒体域输出等价状态、耗时、单项耗时和性能提升倍数。""" + rust_ms = float(result["rust_ms"]) + python_ms = float(result["python_ms"]) + print(f"{name}_items_per_loop={len(inputs)} loops={loops} repeats={repeats}") + print( + f"{name}_rust_items={result['rust_count']} " + f"{name}_python_items={result['python_count']}" + ) + print(f"{name}_equivalent=true") + print(f"{name}_rust_ms_per_loop={rust_ms:.3f}") + print(f"{name}_python_ms_per_loop={python_ms:.3f}") + print(f"{name}_rust_us_per_item={rust_ms * 1000 / len(inputs):.3f}") + print(f"{name}_python_us_per_item={python_ms * 1000 / len(inputs):.3f}") + print(f"{name}_speedup={float(result['speedup']):.2f}x") - print(f"items_per_loop={len(inputs)} loops={args.loops} repeats={args.repeats}") - print(f"rust_items={rust_count} python_items={python_count}") - print(f"rust_chain_ms_per_loop={rust_ms:.3f}") - print(f"python_chain_ms_per_loop={python_ms:.3f}") - print(f"speedup={speedup:.2f}x") + +def main() -> int: + """运行影视与音乐 Rust/Python 生产入口基准测试。""" + args = parse_args() + try: + validate_rust_runtime() + video_inputs = build_video_inputs(args.repeat_inputs) + music_inputs = build_music_inputs(args.repeat_inputs) + video_result = benchmark_suite( + video_inputs, + project_video_result, + loops=args.loops, + repeats=args.repeats, + ) + music_result = benchmark_suite( + music_inputs, + project_music_result, + loops=args.loops, + repeats=args.repeats, + ) + except (AssertionError, RuntimeError) as err: + print(f"benchmark_error={err}", file=sys.stderr) + return 2 + + print_suite_result("video", video_inputs, video_result, args.loops, args.repeats) + print_suite_result("music", music_inputs, music_result, args.loops, args.repeats) return 0 diff --git a/scripts/music_recognize_batch_test.py b/scripts/music_recognize_batch_test.py index 2acb8fb22..fa6ff6326 100644 --- a/scripts/music_recognize_batch_test.py +++ b/scripts/music_recognize_batch_test.py @@ -148,11 +148,11 @@ def fetch_titles(site_keys: list[str], max_pages: int) -> list[tuple[str, str]]: def recognize_one(module, title: str) -> dict: """对单条标题执行与识别测试页相同的解析+识别链路,并给出失败归因。""" - from app.chain.music import MusicChain + from app.core.meta import MetaMusic row = {"title": title} try: - meta = MusicChain.parse_query(title) + meta = MetaMusic.parse_query(title) row.update({ "parsed_title": meta.title, "parsed_artists": " / ".join(meta.artists or []), diff --git a/skills/database-operation/SKILL.md b/skills/database-operation/SKILL.md index a36f94bc6..f68a063f2 100644 --- a/skills/database-operation/SKILL.md +++ b/skills/database-operation/SKILL.md @@ -1,6 +1,6 @@ --- name: database-operation -version: 3 +version: 4 description: >- Use this skill when you need to inspect, query, maintain, or carefully modify the MoviePilot database. This skill uses the bundled scripts/mp-db.py helper, @@ -100,7 +100,7 @@ python scripts/mp-db.py write "UPDATE subscribe SET state = 'S' WHERE id = 123" ## Core Tables ### downloadhistory -Key columns: `id`, `path`, `type`, `title`, `year`, `tmdbid`, `imdbid`, `doubanid`, `seasons`, `episodes`, `downloader`, `download_hash`, `torrent_name`, `torrent_site`, `userid`, `username`, `date`, `media_category` +Key columns: `id`, `path`, `type`, `title`, `year`, `media_source`, `media_id`, `music_type`, `seasons`, `episodes`, `downloader`, `download_hash`, `torrent_name`, `torrent_site`, `userid`, `username`, `date`, `media_category` ### downloadfiles Key columns: `id`, `downloader`, `download_hash`, `fullpath`, `savepath`, `filepath`, `torrentname`, `state` @@ -108,17 +108,21 @@ Key columns: `id`, `downloader`, `download_hash`, `fullpath`, `savepath`, `filep ### transferhistory Music rows persist actual `audio_format`, `audio_lossless`, `bit_depth`, `sample_rate`, and `bitrate` values read during organization. Bitrate uses bps and sample rate uses Hz. -Key columns: `id`, `src`, `dest`, `mode`, `type`, `category`, `title`, `year`, `tmdbid`, `seasons`, `episodes`, `download_hash`, `status`, `errmsg`, `date` +Key columns: `id`, `src`, `dest`, `mode`, `type`, `category`, `title`, `year`, `media_source`, `media_id`, `music_type`, `seasons`, `episodes`, `download_hash`, `status`, `errmsg`, `date` + +### downloadfailure + +Key columns: `id`, `fingerprint`, `type`, `title`, `year`, `media_source`, `media_id`, `seasons`, `episodes`, `site`, `torrent_id`, `downloader`, `error_message`, `retry_count`, `next_retry_at` ### subscribe Music filters use `audio_quality`, `audio_format`, `min_bitrate`, `min_bit_depth`, and `min_sample_rate`. Quality upgrades reuse `current_priority` and persist the current exact values in `current_audio_format`, `current_bitrate`, `current_bit_depth`, and `current_sample_rate`. -Key columns: `id`, `name`, `year`, `type`, `tmdbid`, `doubanid`, `season`, `total_episode`, `start_episode`, `lack_episode`, `state`, `filter`, `include`, `exclude`, `quality`, `resolution`, `sites`, `best_version`, `best_version_full`, `date`, `username` +Key columns: `id`, `name`, `year`, `type`, `media_source`, `media_id`, `music_type`, `season`, `total_episode`, `start_episode`, `lack_episode`, `state`, `filter`, `include`, `exclude`, `quality`, `resolution`, `sites`, `best_version`, `best_version_full`, `date`, `username` ### subscribehistory Completed music subscriptions retain both audio filters and the final current-quality snapshot for auditing. -Key columns: `id`, `name`, `year`, `type`, `tmdbid`, `doubanid`, `season`, `total_episode`, `start_episode`, `date`, `username` +Key columns: `id`, `name`, `year`, `type`, `media_source`, `media_id`, `music_type`, `season`, `total_episode`, `start_episode`, `date`, `username` ### user Key columns: `id`, `name`, `email`, `is_active`, `is_superuser`, `permissions`, `settings` @@ -133,7 +137,12 @@ Key columns: `id`, `domain`, `name`, `username`, `user_level`, `bonus`, `upload` Key columns: `id`, `domain`, `success`, `fail`, `seconds`, `lst_state`, `lst_mod_date` ### mediaserveritem -Key columns: `id`, `server`, `library`, `item_id`, `item_type`, `title`, `original_title`, `year`, `tmdbid`, `imdbid`, `tvdbid`, `path` +Key columns: `id`, `server`, `library`, `item_id`, `item_type`, `title`, `original_title`, `year`, `media_source`, `media_id`, `path` + +The media-bearing tables above store one primary identity only. Treat +`media_source` and `media_id` as an atomic pair: both are null for an unknown +identity, or both contain a valid source enum value and its native ID. Do not +write source-specific identity columns back into these tables. ### systemconfig Key columns: `id`, `key`, `value` diff --git a/skills/generate-identifiers/SKILL.md b/skills/generate-identifiers/SKILL.md index 7c24aab28..da07d4c63 100644 --- a/skills/generate-identifiers/SKILL.md +++ b/skills/generate-identifiers/SKILL.md @@ -1,6 +1,6 @@ --- name: generate-identifiers -version: 2 +version: 3 description: >- Use this skill when a user provides a torrent name or file name and wants to fix recognition issues, or asks to add/manage custom identifiers (自定义识别词). @@ -12,7 +12,7 @@ description: >- 1) A torrent or file name is incorrectly recognized (wrong title, season, episode, etc.); 2) The user wants to block unwanted keywords from torrent names; 3) The user needs episode offset rules for series with non-standard numbering; - 4) The user wants to force recognition of a specific media by TMDB/Douban ID; + 4) The user wants to force recognition of a specific media by source-native ID; 5) The user wants TV recognition to use a specific TMDB episode group. allowed-tools: query_custom_identifiers update_custom_identifiers recognize_media --- @@ -52,13 +52,15 @@ Regex substitution. The left side is a regex pattern, the right side is the repl **Special replacement for direct ID specification:** ``` -被替换词 => {[tmdbid=xxx;type=movie/tv;s=xxx;e=xxx]} -被替换词 => {[doubanid=xxx;type=movie/tv;s=xxx;e=xxx]} +被替换词 => {[media_source=themoviedb;media_id=xxx;type=movie/tv;s=xxx;e=xxx]} +被替换词 => {[media_source=douban;media_id=xxx;type=movie/tv;s=xxx;e=xxx]} ``` -Where `s` (season) and `e` (episode) are optional. For TMDB TV recognition, add `g=xxx` to specify an episode group: +`media_source` must use a `MediaSource` enum value and `media_id` must be that +source's native ID. Where `s` (season) and `e` (episode) are optional. For TMDB +TV recognition, add `g=xxx` to specify an episode group: ``` -被替换词 => {[tmdbid=xxx;type=tv;g=xxx;s=xxx;e=xxx]} +被替换词 => {[media_source=themoviedb;media_id=xxx;type=tv;g=xxx;s=xxx;e=xxx]} ``` ### 3. Episode Offset (集偏移) @@ -105,7 +107,7 @@ When generating a new rule, default to **the narrowest regex that still fixes th - Avoid generic global rules such as bare `1080p`, `WEB-DL`, `中字`, `国配`, `REPACK`, `S01E01`, or pure numbers unless the user explicitly wants a global cleanup rule. - If the rule only needs to fix one specific naming pattern, prefer a **contextual replacement** with capture groups/backreferences over a bare block word. - For episode offset rules, the `前定位词` and `后定位词` should use sample-specific context so the offset only runs on the intended naming pattern. -- For direct TMDB/Douban binding, the left side should match the user's specific wrong alias or naming pattern, not a broad season/episode pattern that could hit other media. +- For direct media binding, the left side should match the user's specific wrong alias or naming pattern, not a broad season/episode pattern that could hit other media. ### Narrow vs Broad Examples @@ -113,13 +115,13 @@ Bad (too broad for a global rule): ``` REPACK 1080p -S01E01 => {[tmdbid=12345;type=tv;s=1;e=1]} +S01E01 => {[media_source=themoviedb;media_id=12345;type=tv;s=1;e=1]} ``` Better (scoped to the user's sample pattern): ``` (\[SubGroup\].*?My\.Show.*?2024.*?)REPACK => \1 -Some\.Weird\.Name(?:\.2024)?(?:\.S01E\d+)? => {[tmdbid=12345;type=tv;s=1]} +Some\.Weird\.Name(?:\.2024)?(?:\.S01E\d+)? => {[media_source=themoviedb;media_id=12345;type=tv;s=1]} \[Baha\] <> \[1080P\] >> EP-12 ``` @@ -226,7 +228,7 @@ Tell the user: **Solution**: Direct ID specification with a sample-specific alias pattern: ``` # 仅在 Some.Weird.Name 这一命名模式下强制绑定 TMDB ID 12345 -Some\.Weird\.Name(?:\.S01E\d+)?(?:\.1080p)? => {[tmdbid=12345;type=tv;s=1]} +Some\.Weird\.Name(?:\.S01E\d+)?(?:\.1080p)? => {[media_source=themoviedb;media_id=12345;type=tv;s=1]} ``` ### Force TMDB Episode Group Recognition @@ -236,7 +238,7 @@ Some\.Weird\.Name(?:\.S01E\d+)?(?:\.1080p)? => {[tmdbid=12345;type=tv;s=1]} **Solution**: Direct TMDB ID specification with `g=...`: ``` # 仅在 Some.Weird.Name 命名模式下绑定 TMDB ID 12345 并指定剧集组 -Some\.Weird\.Name(?:\.S01E\d+)?(?:\.1080p)? => {[tmdbid=12345;type=tv;g=5ad0ec240e0a26303f00d84d;s=1]} +Some\.Weird\.Name(?:\.S01E\d+)?(?:\.1080p)? => {[media_source=themoviedb;media_id=12345;type=tv;g=5ad0ec240e0a26303f00d84d;s=1]} ``` ### Combined Fix diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index 1579abebd..61bce1d6e 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -1,6 +1,6 @@ --- name: moviepilot-api -version: 11 +version: 12 description: >- Use this skill when you need to call MoviePilot REST API endpoints directly with the bundled Python client. Covers MoviePilot HTTP endpoints across media @@ -17,6 +17,14 @@ description: >- Use `scripts/mp-api.py` to call any MoviePilot REST API endpoint directly. +Generic media requests use one stable identity contract: `media_source` is a +`MediaSource` enum value and `media_id` is that source's native ID. Supply the +pair together and keep it unchanged across detail, search, subscription, +download, transfer, scraping, and library checks. Source-specific IDs exposed +by `MediaInfo` are mapping metadata, not alternate generic request parameters. +Native IDs remain valid on explicitly source-owned endpoints under `/tmdb`, +`/douban`, `/bangumi`, and `/anilist`. + ## Scope And Boundaries This skill is the REST API bridge. It is implemented as a Python script and is @@ -88,10 +96,10 @@ changing its method, parameters, request body, or authentication. ```bash # GET with query params -python scripts/mp-api.py GET /api/v1/media/search title="Avatar" type="movie" +python scripts/mp-api.py GET /api/v1/media/search title="Avatar" type="media" # POST with JSON body -python scripts/mp-api.py POST /api/v1/download/add --json '{"torrent_url":"abc1234:1"}' +python scripts/mp-api.py POST /api/v1/download/add --json '{"torrent_in":{"title":"Avatar.2009","enclosure":"abc1234:1"},"media_source":"themoviedb","media_id":"19995"}' # DELETE python scripts/mp-api.py DELETE /api/v1/subscribe/123 @@ -111,23 +119,23 @@ All endpoints are under the base URL `{MP_HOST}`. Path parameters are shown as ` ### Media Search (13 endpoints) -When recognition omits `source`, MoviePilot uses TMDB exclusively for video and MusicBrainz exclusively for music. A miss does not trigger another metadata source. Providing `source` or a source-native ID keeps recognition strict to that manually selected source. +When recognition omits `media_source`, MoviePilot uses TMDB exclusively for video and MusicBrainz exclusively for music. A miss does not trigger another metadata source. Providing `media_source`, or the complete `media_source` + `media_id` pair, keeps recognition strict to that manually selected source. | Method | Path | Description | |--------|------|-------------| -| GET | `/api/v1/media/search` | Search media, collections, or people by title. Params: `title` (required), `type`, `page`, `count`, optional `source`. Supported sources: `media` = `themoviedb`, `douban`, `bangumi`, `anilist`; `collection` = `themoviedb`; `person` = `themoviedb`, `douban` | -| GET | `/api/v1/media/recognize` | Recognize media from a torrent title or a media file path. Params: `title` (required), `subtitle`, `custom_words`, optional `source`; media file paths also use parent-directory metadata such as title and year | -| GET | `/api/v1/media/recognize2` | Recognize media from a torrent title or media file path (API_TOKEN auth, use `--token-param`). Params: `title`, `subtitle`, `custom_words`, optional `source`; media file paths also use parent-directory metadata | -| GET | `/api/v1/media/recognize_file` | Recognize media from file path. Params: `path` (required), optional `source` | -| GET | `/api/v1/media/recognize_file2` | Recognize file (API_TOKEN auth). Params: `path`, optional `source` | -| POST | `/api/v1/media/scrape/{storage}` | Scrape media metadata. Body: FileItem JSON. Optional params: `media_source`, `media_id`, `type_name` (`电影`/`电视剧`) | +| GET | `/api/v1/media/search` | Search by title. Params: `title` (required), `type=media|music|collection|person`, `page`, `count`, optional repeated `media_source`; each type accepts only its supported `MediaSource` values. Comma-separated input is legacy compatibility only | +| GET | `/api/v1/media/recognize` | Recognize media from a torrent title or a media file path. Params: `title` (required), `subtitle`, `custom_words`, optional `media_source`; media file paths also use parent-directory metadata such as title and year | +| GET | `/api/v1/media/recognize2` | Recognize media from a torrent title or media file path (API_TOKEN auth, use `--token-param`). Params: `title`, `subtitle`, `custom_words`, optional `media_source`; media file paths also use parent-directory metadata | +| GET | `/api/v1/media/recognize_file` | Recognize media from file path. Params: `path` (required), optional `media_source` | +| GET | `/api/v1/media/recognize_file2` | Recognize file (API_TOKEN auth). Params: `path`, optional `media_source` | +| POST | `/api/v1/media/scrape/{storage}` | Scrape media metadata. Body: FileItem JSON. Optional params: paired `media_source` + `media_id`, `type_name` (`电影`/`电视剧`/`音乐`), `music_type` | | GET | `/api/v1/media/category/config` | Get category strategy config | | POST | `/api/v1/media/category/config` | Save category strategy config. Body: CategoryConfig | | GET | `/api/v1/media/category` | Get auto-categorization config | -| GET | `/api/v1/media/group/seasons/{episode_group}` | Get episode group seasons | -| GET | `/api/v1/media/groups/{tmdbid}` | Get media episode groups | -| GET | `/api/v1/media/seasons` | Get media season info. Params: `mediaid`, `title`, `year`, `season` | -| GET | `/api/v1/media/{mediaid}` | Get media detail. `mediaid` supports `tmdb:`, `douban:`, `bangumi:`, `anilist:`, and plugin-defined source prefixes. Params: `type_name` (required: movie/tv), `title`, `year` | +| GET | `/api/v1/media/group/seasons/{episode_group}` | Get episode group seasons. TMDB-only endpoint | +| GET | `/api/v1/media/groups/{tmdbid}` | Get media episode groups. TMDB-only endpoint, so the native ID parameter is intentional | +| GET | `/api/v1/media/seasons` | Get media season info. Use `media_source` + `media_id`, or title discovery with `title` and optional `year`; optional `season` narrows the result | +| GET | `/api/v1/media/{media_id}` | Get media detail by native ID. Required params: `media_source`, `type_name` (`电影`/`电视剧`) | ### TMDB (8 endpoints) @@ -187,7 +195,7 @@ music on configured music-capable media servers; it does not manage playlists. | Method | Path | Description | |--------|------|-------------| -| GET | `/api/v1/media/search` | Search tracks, albums, or artists with `type=music` or a music `media_source`. Params: `title`, `type`, `count`, `media_source` | +| GET | `/api/v1/media/search` | Search tracks, albums, or artists with `type=music` or a music `media_source`. Params: `title`, `type`, `count`, repeated enum `media_source` | | POST | `/api/v1/music/recognize` | Resolve music metadata. Body: `media_source`, `media_id` | | GET | `/api/v1/music/explore` | Explore by `media_source`: MusicBrainz supports `mode=chart|fresh`; Douban Music always uses official tag categories with `tags` and `douban_sort=U|S|R|O`. Other params: `entity`, `range_name`, `sort_by`, `sort`, `days`, `past`, `future`, `min_listen_count`, `with_cover`, `page`, `count` | | GET | `/api/v1/music/album/{album_id}` | Album detail with tracks and releases. Params: `media_source` | @@ -208,14 +216,14 @@ Music acquisition rules: | Method | Path | Description | |--------|------|-------------| -| GET | `/api/v1/search/media/{mediaid}` | Search torrents by media ID (video prefixes, `musicbrainz:`, or a plugin-defined source prefix). Params: `mtype`, `area`, `title`, `year`, `season`, `sites` | -| GET | `/api/v1/search/media/{mediaid}/stream` | Stream torrent search by media ID with SSE. Params: `mtype`, `area`, `title`, `year`, `season`, `sites` | +| GET | `/api/v1/search/media/{media_id}` | Search torrents by native ID. Required param: `media_source`; other params: `mtype`, `area`, `season`, `sites`, `music_type` | +| GET | `/api/v1/search/media/{media_id}/stream` | Stream torrent search by native ID with SSE. Required param: `media_source`; other params match the non-streaming endpoint | | GET | `/api/v1/search/title` | Fuzzy search torrents by keyword. Params: `keyword`, `page`, `sites`, optional `mtype=音乐` | | GET | `/api/v1/search/title/stream` | Stream fuzzy torrent search with SSE. Params: `keyword`, `page`, `sites`, optional `mtype=音乐` | | GET | `/api/v1/search/subtitle/title` | Fuzzy search site subtitles by keyword. Params: `keyword`, `page`, `sites` | | GET | `/api/v1/search/subtitle/title/stream` | Stream fuzzy site subtitle search with SSE. Params: `keyword`, `page`, `sites` | -| GET | `/api/v1/search/subtitle/media/{mediaid}` | Exact subtitle search by media ID (four built-in prefixes or a plugin-defined source prefix). Params: `mtype`, `title`, `year`, `season`, `episode`, `sites` | -| GET | `/api/v1/search/subtitle/media/{mediaid}/stream` | Stream exact subtitle search by media ID with SSE. Params: `mtype`, `title`, `year`, `season`, `episode`, `sites` | +| GET | `/api/v1/search/subtitle/media/{media_id}` | Exact subtitle search by native ID. Required param: `media_source`; other params: `mtype`, `season`, `episode`, `sites` | +| GET | `/api/v1/search/subtitle/media/{media_id}/stream` | Stream exact subtitle search by native ID with SSE. Required param: `media_source`; other params match the non-streaming endpoint | | GET | `/api/v1/search/last` | Get latest search results | | GET | `/api/v1/search/last/context` | Get latest search results with replayable params. `params.result_type` is `torrent` or `subtitle` | | POST | `/api/v1/search/recommend` | AI recommended resources. Body: `filtered_indices`, `check_only`, `force` | @@ -228,8 +236,8 @@ Streaming search sends `{"type":"heartbeat"}` every 15 seconds without business |--------|------|-------------| | GET | `/api/v1/download/` | List active downloads. Params: `name` (downloader name); linked history adds media type and source `site_name` | | POST | `/api/v1/download/` | Add download (with media info). Body: JSON | -| POST | `/api/v1/download/add` | Add download without media info. Body: `torrent_in`, optional `media_source` + `media_id` (all four dedicated IDs remain supported), `downloader`, `save_path` | -| POST | `/api/v1/download/subtitle` | Download subtitle file to the recognized media download directory. Body: `subtitle_in`, optional `media_source` + `media_id` (all four dedicated IDs remain supported), `save_path` | +| POST | `/api/v1/download/add` | Add download without media info. Body: `torrent_in`, optional paired `media_source` + `media_id`, `music_type`, `downloader`, `save_path` | +| POST | `/api/v1/download/subtitle` | Download subtitle file to the recognized media download directory. Body: `subtitle_in`, required `media_source` + `media_id`, optional `save_path` | | GET | `/api/v1/download/start/{hashString}` | Resume download task | | GET | `/api/v1/download/stop/{hashString}` | Pause download task | | GET | `/api/v1/download/clients` | List available download clients | @@ -240,14 +248,14 @@ Streaming search sends `{"type":"heartbeat"}` every 15 seconds without business | Method | Path | Description | |--------|------|-------------| | GET | `/api/v1/subscribe/` | List all subscriptions | -| POST | `/api/v1/subscribe/` | Add subscription. Music requires `type=music`, `music_type=recording|album`, and exact `media_source` + `media_id`; video also accepts compatible dedicated IDs | +| POST | `/api/v1/subscribe/` | Add subscription. An explicit identity is always `media_source` + `media_id`; music also requires `type=音乐` and `music_type=recording|album` | | PUT | `/api/v1/subscribe/` | Update subscription. Body: Subscribe JSON | | GET | `/api/v1/subscribe/list` | List subscriptions (API_TOKEN auth, use `--token-param`) | | GET | `/api/v1/subscribe/{subscribe_id}` | Subscription detail | | DELETE | `/api/v1/subscribe/{subscribe_id}` | Delete subscription | | PUT | `/api/v1/subscribe/status/{subid}` | Update subscription status. Params: `state` (required) | -| GET | `/api/v1/subscribe/media/{mediaid}` | Query subscription by a built-in or plugin-prefixed media ID. Params: `season`, `title` | -| DELETE | `/api/v1/subscribe/media/{mediaid}` | Delete subscription by a built-in or plugin-prefixed media ID. Params: `season` | +| GET | `/api/v1/subscribe/media/{media_id}` | Query subscription by native ID. Required param: `media_source`; optional params: `season`, `title`, `music_type` | +| DELETE | `/api/v1/subscribe/media/{media_id}` | Delete subscription by native ID. Required param: `media_source`; optional params: `season`, `music_type` | | GET | `/api/v1/subscribe/refresh` | Refresh all subscriptions | | GET | `/api/v1/subscribe/reset/{subid}` | Reset subscription | | GET | `/api/v1/subscribe/check` | Refresh subscription TMDB info | @@ -314,7 +322,7 @@ Streaming search sends `{"type":"heartbeat"}` every 15 seconds without business | Method | Path | Description | |--------|------|-------------| | GET | `/api/v1/mediaserver/play/{itemid}` | Play media online | -| GET | `/api/v1/mediaserver/exists` | Check if media exists in library. Params: `title`, `year`, `mtype`, `tmdbid`, `season` | +| GET | `/api/v1/mediaserver/exists` | Check if media exists in the local library database. Params: `media_source` + `media_id`, or `title` discovery; optional `year`, `mtype`, `season` | | POST | `/api/v1/mediaserver/exists_remote` | Check existing episodes (remote). Body: MediaInfo JSON | | POST | `/api/v1/mediaserver/notexists` | Check missing episodes (remote). Body: MediaInfo JSON | | GET | `/api/v1/mediaserver/latest` | Latest library items. Params: `server` (required), `count` | @@ -347,7 +355,7 @@ Streaming search sends `{"type":"heartbeat"}` every 15 seconds without business | GET | `/api/v1/transfer/name` | Preview transfer name. Params: `path` (required), `filetype` (required) | | GET | `/api/v1/transfer/queue` | Transfer queue | | DELETE | `/api/v1/transfer/queue` | Remove from transfer queue. Body: FileItem JSON | -| POST | `/api/v1/transfer/manual/target-path` | Match manual transfer target path. Body: ManualTransferItem JSON; optional `media_source` + `media_id` select the recognition source | +| POST | `/api/v1/transfer/manual/target-path` | Match the manual transfer target from source path and directory configuration. Body: ManualTransferItem JSON; this endpoint does not recognize media | | POST | `/api/v1/transfer/manual/history` | Query successful transfer-history summary for selected files or directories. Body: ManualTransferItem JSON | | POST | `/api/v1/transfer/manual` | Manual transfer. Params: `background`. Body: ManualTransferItem JSON; optional `media_source` + `media_id` select recognition and scraping source; matching failed history is cleared automatically, while `reorganize=true` removes matched successful history and old non-move targets before retrying | | GET | `/api/v1/transfer/now` | Run immediate transfer | @@ -496,7 +504,7 @@ Streaming search sends `{"type":"heartbeat"}` every 15 seconds without business | DELETE | `/api/v1/torrent/cache` | Clear torrent cache | | DELETE | `/api/v1/torrent/cache/{domain}/{torrent_hash}` | Delete specific torrent cache | | POST | `/api/v1/torrent/cache/refresh` | Refresh torrent cache | -| POST | `/api/v1/torrent/cache/reidentify/{domain}/{torrent_hash}` | Re-identify torrent. Params: `tmdbid`, `doubanid` | +| POST | `/api/v1/torrent/cache/reidentify/{domain}/{torrent_hash}` | Re-identify torrent. Optional paired params: `media_source`, `media_id`; music may also pass `music_type` | ### Recognition Cache (3 endpoints) @@ -617,19 +625,19 @@ Radarr/Sonarr compatible API for integration with external tools. ```bash # 1. Search TMDB for the movie -python scripts/mp-api.py GET /api/v1/media/search title="Inception" type="movie" +python scripts/mp-api.py GET /api/v1/media/search title="Inception" type="media" -# 2. Get media detail (replace {tmdbid} with actual ID) -python scripts/mp-api.py GET /api/v1/media/27205 type_name="movie" +# 2. Get media detail with the exact identity returned by search +python scripts/mp-api.py GET /api/v1/media/27205 media_source="themoviedb" type_name="电影" # 3. Search torrents -python scripts/mp-api.py GET /api/v1/search/media/tmdb:27205 mtype="movie" +python scripts/mp-api.py GET /api/v1/search/media/27205 media_source="themoviedb" mtype="movie" # 4. Get latest search results python scripts/mp-api.py GET /api/v1/search/last # 5. Add download -python scripts/mp-api.py POST /api/v1/download/add --json '{"torrent_url":""}' +python scripts/mp-api.py POST /api/v1/download/add --json '{"torrent_in":{"title":"","enclosure":""},"media_source":"themoviedb","media_id":"27205"}' ``` ### Search and subscribe to one recording or complete album @@ -639,10 +647,10 @@ python scripts/mp-api.py POST /api/v1/download/add --json '{"torrent_url":" source="musicbrainz" +python scripts/mp-api.py GET /api/v1/music/album/ media_source="musicbrainz" # 2b. Check the exact entity subscription separately; music_type prevents recording/album ambiguity -python scripts/mp-api.py GET /api/v1/subscribe/media/musicbrainz: music_type="album" +python scripts/mp-api.py GET /api/v1/subscribe/media/ media_source="musicbrainz" music_type="album" # 3. Add one exact album subscription. REST enum values use the localized MediaType value. python scripts/mp-api.py POST /api/v1/subscribe/ --json '{"name":"Album Title","type":"音乐","music_type":"album","media_source":"musicbrainz","media_id":""}' @@ -662,23 +670,23 @@ python scripts/mp-api.py GET /api/v1/search/subtitle/title keyword="Inception" s python scripts/mp-api.py GET /api/v1/search/last/context # 3. Download a subtitle result to the recognized media directory -python scripts/mp-api.py POST /api/v1/download/subtitle --json '{"subtitle_in":{"title":"Inception.2010.1080p.chs","enclosure":"https://example.com/downloadsubs.php?torrentid=1&subid=2","site_name":"Example"},"tmdbid":27205}' +python scripts/mp-api.py POST /api/v1/download/subtitle --json '{"subtitle_in":{"title":"Inception.2010.1080p.chs","enclosure":"https://example.com/downloadsubs.php?torrentid=1&subid=2","site_name":"Example"},"media_source":"themoviedb","media_id":"27205"}' ``` ### Add a subscription ```bash # 1. Search for the show -python scripts/mp-api.py GET /api/v1/media/search title="Breaking Bad" type="tv" +python scripts/mp-api.py GET /api/v1/media/search title="Breaking Bad" type="media" # 2. Check if already subscribed -python scripts/mp-api.py GET /api/v1/subscribe/media/tmdb:1396 +python scripts/mp-api.py GET /api/v1/subscribe/media/1396 media_source="themoviedb" # 3. Check if already in library -python scripts/mp-api.py GET /api/v1/mediaserver/exists tmdbid=1396 mtype="tv" +python scripts/mp-api.py GET /api/v1/mediaserver/exists media_source="themoviedb" media_id=1396 mtype="tv" # 4. Add subscription -python scripts/mp-api.py POST /api/v1/subscribe/ --json '{"name":"Breaking Bad","year":"2008","type":"tv","tmdbid":1396}' +python scripts/mp-api.py POST /api/v1/subscribe/ --json '{"name":"Breaking Bad","year":"2008","type":"电视剧","media_source":"themoviedb","media_id":"1396"}' ``` ### System monitoring diff --git a/skills/moviepilot-cli/SKILL.md b/skills/moviepilot-cli/SKILL.md index 070b12044..8803f312f 100644 --- a/skills/moviepilot-cli/SKILL.md +++ b/skills/moviepilot-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: moviepilot-cli -version: 6 +version: 7 description: >- Use this skill when the user asks to operate MoviePilot through the local `moviepilot tool` MCP CLI for normal product workflows: media search, torrent @@ -77,16 +77,18 @@ If the user specifies a TV season, run Season Validation step first — the seas #### 2. Search torrents -Prefer `tmdb_id`; use `douban_id` only when `tmdb_id` is unavailable. +Reuse the exact `media_source` and `media_id` returned by `search_media`. Do not +replace the selected primary identity with an auxiliary TMDB, Douban, Bangumi, +or AniList mapping ID. Omitting `sites=` uses the user's default sites. If the user specifies sites, first retrieve site IDs: `moviepilot tool run query_sites` Search torrents using default sites: -`moviepilot tool run search_torrents tmdb_id=791373 media_type="movie"` +`moviepilot tool run search_torrents media_source="themoviedb" media_id=791373 media_type="movie"` Search torrents using user-specified sites (pass site IDs from `query_sites`): -`moviepilot tool run search_torrents tmdb_id=791373 media_type="movie" sites='1,3'` +`moviepilot tool run search_torrents media_source="themoviedb" media_id=791373 media_type="movie" sites='1,3'` When `search_torrents` returns: 1. **Stop** — do not call `get_search_results` yet. @@ -137,25 +139,25 @@ Download one or more torrents (`torrent_url` comes from `get_search_results` out | Step | Action | |---|---| -| `search_media` empty | Retry with alternative title (English/original), inform user. Still empty → ask for title or TMDB ID. | +| `search_media` empty | Retry with an alternative title (English/original), then ask for the title or exact `media_source` + `media_id`. | | `search_torrents` empty | Inform user, ask whether to retry with different sites. | | `get_search_results` empty | Do not silently broaden filters. Suggest which filter to relax, ask before retrying. | | `add_download_tasks` fails | Run `query_downloaders` + `query_download_tasks` to diagnose, then report to user. | ### Add Subscription -1. Search for the media to get `tmdb_id`: Run `search_media`. +1. Run `search_media` and keep the returned `media_source` + `media_id` pair. 2. Run **Check Library and Subscriptions** step, if media already exists or is subscribed, **stop** and report to user. 3. If the user specifies a TV season, run Season Validation step first. Subscribe to a movie or TV show: -`moviepilot tool run add_subscribe title="..." year="2011" media_type="tv" tmdb_id=42009` +`moviepilot tool run add_subscribe title="..." year="2011" media_type="tv" media_source="themoviedb" media_id=42009` Subscribe to a specific season: -`moviepilot tool run add_subscribe title="..." year="2011" media_type="tv" tmdb_id=42009 season=4` +`moviepilot tool run add_subscribe title="..." year="2011" media_type="tv" media_source="themoviedb" media_id=42009 season=4` Subscribe starting from a specific episode: -`moviepilot tool run add_subscribe title="..." year="2024" media_type="tv" tmdb_id=12345 season=1 start_episode=13` +`moviepilot tool run add_subscribe title="..." year="2024" media_type="tv" media_source="themoviedb" media_id=12345 season=1 start_episode=13` Subscribe to a complete lossless album and keep upgrading its audio quality: `moviepilot tool run add_subscribe title="..." media_type="music" music_type="album" media_source="musicbrainz" media_id="" audio_quality="hires|lossless" audio_format="DSD|FLAC|ALAC" min_bit_depth=24 best_version=1` @@ -244,10 +246,10 @@ Delete a task only after confirming permanent removal with the user: Run before any download or subscription to avoid duplicates. Check if the media already exists in the library: -`moviepilot tool run query_library_exists tmdb_id=123456 media_type="movie"` +`moviepilot tool run query_library_exists media_source="themoviedb" media_id=123456 media_type="movie"` Check if the media is already subscribed: -`moviepilot tool run query_subscribes tmdb_id=123456` +`moviepilot tool run query_subscribes media_source="themoviedb" media_id=123456` ### Season Validation @@ -256,7 +258,7 @@ Mandatory when user specifies a season. Productions sometimes release a show in #### 1. Verify season exists Fetch media detail to check available seasons: -`moviepilot tool run query_media_detail tmdb_id= media_type="tv"` +`moviepilot tool run query_media_detail media_source="themoviedb" media_id= media_type="tv"` Compare `season_info` with the user's requested season: 1. If the season exists in `season_info` → use that season number directly and return to the calling workflow. @@ -264,7 +266,8 @@ Compare `season_info` with the user's requested season: #### 2. Identify the correct episode range -Fetch episode schedule for the latest season from `season_info`: +Fetch the episode schedule for the latest season from `season_info`. This is a +TMDB-only tool, so its native `tmdb_id` parameter is intentional: `moviepilot tool run query_episode_schedule tmdb_id= season=` Use `air_date` to find a block of recently-aired episodes that likely corresponds to what the user calls the missing season. Look for a gap in `air_date` between episodes — the gap indicates a part break, and the episodes after the gap are what the user likely refers to as the next "season". For example, if TMDB Season 1 has episodes 1–24 and there is a multi-month gap between episode 12 and 13, then episodes 13–24 correspond to the user's "Season 2". If no such gap exists, tell user content is unavailable. Otherwise confirm the episode range with user. diff --git a/skills/organize-files/SKILL.md b/skills/organize-files/SKILL.md index 3e6435475..60b97ecd9 100644 --- a/skills/organize-files/SKILL.md +++ b/skills/organize-files/SKILL.md @@ -1,6 +1,6 @@ --- name: organize-files -version: 2 +version: 3 description: >- Use this skill when the user asks the MoviePilot agent to identify and organize downloaded/local video or music files that automatic transfer cannot handle. Typical triggers include manually organizing a file or folder, a TV season pack, one music recording, or a complete album directory. If the user gives failed transfer history IDs, prefer transfer-failed-retry instead. allowed-tools: list_directory query_directory_settings query_download_tasks query_transfer_history delete_transfer_history recognize_media search_media query_media_detail query_library_exists transfer_file scrape_metadata ask_user_choice send_message @@ -59,8 +59,8 @@ If recognition fails or looks wrong: 1. Extract likely title, year, media type, season/episode range, or music artist/track/album from filenames and audio tags. 2. For video, call `search_media(title="...", year="...", media_type="movie|tv")`. For music, call `search_media(title=" - ", media_type="music", music_type="recording|album")`. -3. If several results are plausible, use `ask_user_choice` when available, or ask the user directly to choose the correct title/TMDB ID. -4. For TV season confusion, use `query_media_detail(tmdb_id=<id>, media_type="tv")` before deciding the season number. For an album, use `query_media_detail(media_type="music", music_type="album", media_source="musicbrainz", media_id="<album_id>")` and verify `total_tracks` before treating the directory as complete. +3. If several results are plausible, use `ask_user_choice` when available, or ask the user directly to choose the correct title and `media_source` + `media_id` pair. +4. For TV season confusion, use `query_media_detail(media_source="themoviedb", media_id="<id>", media_type="tv")` before deciding the season number. For an album, use `query_media_detail(media_type="music", music_type="album", media_source="musicbrainz", media_id="<album_id>")` and verify `total_tracks` before treating the directory as complete. Never invent an ID. Preserve the exact source-native entity returned by search: a recording is one track, an album is a multi-track collection, and an artist is browse-only and cannot be organized. @@ -83,7 +83,8 @@ transfer_file( file_path="<source path>", storage="local", media_type="movie|tv", - tmdbid=<tmdb_id>, + media_source="<source>", + media_id="<native_id>", season=<season_number_if_tv> ) ``` @@ -107,7 +108,7 @@ Rules: - Set `target_path` or `transfer_type` only when the user explicitly asks or the default directory configuration cannot handle the file. - For a single movie or a single TV season folder, transfer the folder once with the shared identity. - For mixed folders, split by media and transfer each file/subfolder separately. -- For episode packs, identify the media once, then reuse `tmdbid`, `media_type="tv"`, and the confirmed `season` for each item. +- For episode packs, identify the media once, then reuse the exact `media_source` + `media_id`, `media_type="tv"`, and the confirmed `season` for each item. - For one recording, transfer only that audio file with the recording ID. - For one album, verify the directory belongs to the selected album, then transfer the directory once with the album ID. Do not submit every track as an unrelated recording. - Never transfer an artist search result. Select a recording or album first. @@ -118,7 +119,7 @@ Rules: After each transfer batch, report: - source path(s) processed; -- recognized media title, type, TMDB/Douban ID, season/episode range when relevant; +- recognized media title, type, `media_source` + `media_id`, season/episode range when relevant; - success/failure count; - any failed message exactly enough for the user to act, such as missing media library directory, unsupported storage, existing history, or no media recognized. @@ -130,14 +131,14 @@ If the result creates failed history records, tell the user they can retry with 1. `recognize_media(path=...)` 2. If needed, `search_media(...)` and confirm the result. -3. `transfer_file(file_path=..., media_type=..., tmdbid=..., season=...)` +3. `transfer_file(file_path=..., media_type=..., media_source=..., media_id=..., season=...)` ### User Gives A Season Folder 1. `list_directory(path=...)` 2. Pick a representative episode and run `recognize_media(path=...)`. -3. Confirm `tmdbid`, `media_type="tv"`, and season. -4. `transfer_file(file_path="<folder>/", media_type="tv", tmdbid=<id>, season=<season>)` +3. Confirm `media_source`, `media_id`, `media_type="tv"`, and season. +4. `transfer_file(file_path="<folder>/", media_type="tv", media_source="<source>", media_id="<native_id>", season=<season>)` ### User Gives One Music Track diff --git a/skills/transfer-failed-retry/SKILL.md b/skills/transfer-failed-retry/SKILL.md index 4c269f11d..2c2ecec35 100644 --- a/skills/transfer-failed-retry/SKILL.md +++ b/skills/transfer-failed-retry/SKILL.md @@ -1,6 +1,6 @@ --- name: transfer-failed-retry -version: 3 +version: 4 description: Use this skill when you need to retry failed video or music transfers/organizations. Given failed transfer history IDs, query the exact records, group by trustworthy movie/series/recording/album identity, delete only the old records being retried, then re-identify and re-organize through MoviePilot. This skill is automatically triggered when transfer failures occur and AI retry is enabled. allowed-tools: query_transfer_history delete_transfer_history recognize_media transfer_file search_media --- @@ -36,11 +36,10 @@ From each record, extract the following key information: - **title**: The recognized title (may be incorrect) - **errmsg**: The error message explaining why the transfer failed - **type**: Media type (movie/tv/music) -- **tmdbid**: TMDB ID (if available) +- **media_source/media_id**: Exact source-native identity; preserve the pair together for every retry - **seasons/episodes**: Season/episode info (if TV show) - **downloader**: Which downloader was used - **download_hash**: The torrent hash -- **media_source/media_id**: Exact source-native identity; required to preserve selected music entities ### Step 2: Analyze the Failure Reason @@ -48,7 +47,7 @@ Common failure reasons and how to handle them: | Error Message | Cause | Solution | |---------------|-------|----------| -| 未识别到媒体信息 | File name or audio tags could not be matched | Use `search_media` to find the exact video ID or music recording/album identity, then transfer with explicit IDs | +| 未识别到媒体信息 | File name or audio tags could not be matched | Use `search_media` to find the exact `media_source` + `media_id`, then transfer with that pair | | 源目录不存在 | Source file was moved or deleted | Cannot retry - skip this record | | 目标路径不存在 | Target directory issue | Retry transfer - the directory config may have been fixed | | 文件已存在 | Target file already exists | May need to use `force` mode or skip | @@ -83,7 +82,7 @@ Based on the failure analysis in Step 2: 3. Once you have the exact identity, re-transfer with explicit identification: ``` - transfer_file(file_path="<source_path>", tmdbid=<tmdb_id>, media_type="movie" or "tv") + transfer_file(file_path="<source_path>", media_source="<source>", media_id="<native_id>", media_type="movie" or "tv") # or for music transfer_file(file_path="<source_path>", media_type="music", music_type="recording" or "album", media_source="musicbrainz", media_id="<recording_or_album_id>") ``` @@ -101,7 +100,7 @@ For TV shows where episode info couldn't be determined: 1. Use `recognize_media` to get better metadata 2. Re-transfer with explicit season info: ``` - transfer_file(file_path="<source_path>", tmdbid=<tmdb_id>, media_type="tv", season=<season_number>) + transfer_file(file_path="<source_path>", media_source="<source>", media_id="<native_id>", media_type="tv", season=<season_number>) ``` #### Case D: Music Recording Or Album @@ -145,20 +144,20 @@ query_transfer_history(status="failed") # 2. Identify media ONCE using the first file recognize_media(path="/downloads/Show.Name.S01E01.1080p.mkv") -# Found: tmdb_id=789, media_type="tv" +# Found: media_source="themoviedb", media_id="789", media_type="tv" # 3. For each record: delete history, then re-transfer delete_transfer_history(history_id=42) -transfer_file(file_path="/downloads/Show.Name.S01E01.1080p.mkv", tmdbid=789, media_type="tv") +transfer_file(file_path="/downloads/Show.Name.S01E01.1080p.mkv", media_source="themoviedb", media_id="789", media_type="tv") delete_transfer_history(history_id=43) -transfer_file(file_path="/downloads/Show.Name.S01E02.1080p.mkv", tmdbid=789, media_type="tv") +transfer_file(file_path="/downloads/Show.Name.S01E02.1080p.mkv", media_source="themoviedb", media_id="789", media_type="tv") delete_transfer_history(history_id=44) -transfer_file(file_path="/downloads/Show.Name.S01E03.1080p.mkv", tmdbid=789, media_type="tv") +transfer_file(file_path="/downloads/Show.Name.S01E03.1080p.mkv", media_source="themoviedb", media_id="789", media_type="tv") delete_transfer_history(history_id=45) -transfer_file(file_path="/downloads/Show.Name.S01E04.1080p.mkv", tmdbid=789, media_type="tv") +transfer_file(file_path="/downloads/Show.Name.S01E04.1080p.mkv", media_source="themoviedb", media_id="789", media_type="tv") # 4. Report summary: "重试完成:4/4 成功" ``` @@ -187,12 +186,12 @@ recognize_media(path="/downloads/Movie.Name.2024.1080p.mkv") # 3. Search TMDB search_media(title="Movie Name", year="2024", media_type="movie") -# Found: tmdb_id=123456 +# Found: media_source="themoviedb", media_id="123456" # 4. Delete old history record delete_transfer_history(history_id=42) # 5. Re-transfer with correct identification -transfer_file(file_path="/downloads/Movie.Name.2024.1080p.mkv", tmdbid=123456, media_type="movie") +transfer_file(file_path="/downloads/Movie.Name.2024.1080p.mkv", media_source="themoviedb", media_id="123456", media_type="movie") # Success! ``` diff --git a/tests/test_agent_music_tools.py b/tests/test_agent_music_tools.py index 0589e599f..c75f07c74 100644 --- a/tests/test_agent_music_tools.py +++ b/tests/test_agent_music_tools.py @@ -3,7 +3,7 @@ import asyncio import json from types import SimpleNamespace -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest @@ -126,7 +126,7 @@ def test_search_media_filters_music_entities_and_returns_stable_identity(): tool = SearchMediaTool(session_id="session-1", user_id="10001") with patch( - "app.agent.tools.impl.search_media.MusicChain.async_search", + "app.agent.tools.impl.search_media.MediaChain.async_search_music", new=async_search, ): result = asyncio.run( @@ -215,7 +215,7 @@ def test_query_album_detail_exposes_complete_track_contract(): tool = QueryMediaDetailTool(session_id="session-1", user_id="10001") with patch( - "app.agent.tools.impl.query_media_detail.MusicChain.async_album", + "app.agent.tools.impl.query_media_detail.MediaChain.async_get_music_album", new=async_album, ): result = asyncio.run( @@ -237,11 +237,13 @@ def test_query_album_detail_exposes_complete_track_contract(): def test_query_recording_detail_forwards_recording_namespace(): """Agent 查询单曲详情时必须把 Recording 实体传给统一识别入口。""" async_recognize = AsyncMock(return_value=_recording()) + media_chain = Mock() + media_chain.async_recognize_media = async_recognize tool = QueryMediaDetailTool(session_id="session-1", user_id="10001") with patch( - "app.agent.tools.impl.query_media_detail.MediaChain.async_recognize_media", - new=async_recognize, + "app.agent.tools.impl.query_media_detail.MediaChain", + return_value=media_chain, ): result = asyncio.run(tool.run( media_type="music", @@ -256,18 +258,22 @@ def test_query_recording_detail_forwards_recording_namespace(): def test_scrape_album_uses_unified_entity_recognition(tmp_path): - """Agent 专辑刮削应通过 MediaChain 识别,不再单独编排 MusicChain 专辑查询。""" + """Agent 专辑刮削应通过 MediaChain 识别,再交给 ScrapingChain 执行。""" album_dir = tmp_path / "叶惠美" album_dir.mkdir() async_recognize = AsyncMock(return_value=_album()) + media_chain = Mock() + media_chain.async_recognize_media = async_recognize + scraping_chain = Mock() + scraping_chain.scrape_music_metadata.return_value = (True, "已刮削专辑") tool = ScrapeMetadataTool(session_id="session-1", user_id="10001") with patch( - "app.agent.tools.impl.scrape_metadata.MediaChain.async_recognize_media", - new=async_recognize, + "app.agent.tools.impl.scrape_metadata.MediaChain", + return_value=media_chain, ), patch( - "app.agent.tools.impl.scrape_metadata.MediaChain.scrape_music_metadata", - return_value=(True, "已刮削专辑"), + "app.agent.tools.impl.scrape_metadata.ScrapingChain", + return_value=scraping_chain, ): result = asyncio.run(tool.run( path=str(album_dir), @@ -278,9 +284,31 @@ def test_scrape_album_uses_unified_entity_recognition(tmp_path): )) assert json.loads(result)["success"] is True + assert async_recognize.await_args.kwargs["media_source"] == MediaSource.MusicBrainz + assert async_recognize.await_args.kwargs["media_id"] == "release-group-1" + assert "source" not in async_recognize.await_args.kwargs + assert "mediaid" not in async_recognize.await_args.kwargs assert async_recognize.await_args.kwargs["music_type"] == "album" +def test_scrape_metadata_rejects_unknown_media_source_before_file_access(tmp_path): + """Agent 直接调用工具时也必须拒绝固定枚举之外的媒体来源。""" + audio_file = tmp_path / "unknown-source.flac" + audio_file.write_bytes(b"audio") + tool = ScrapeMetadataTool(session_id="session-1", user_id="10001") + + result = asyncio.run(tool.run( + path=str(audio_file), + media_type="music", + media_source="plugin-source", + media_id="recording-1", + )) + + payload = json.loads(result) + assert payload["success"] is False + assert "media_source" in payload["message"] + + def test_query_artist_detail_marks_entity_as_non_subscribable(): """艺术家详情应明确标记为不可订阅,避免 Agent 混入获取流程。""" artist = MusicArtistInfo( @@ -293,7 +321,7 @@ def test_query_artist_detail_marks_entity_as_non_subscribable(): tool = QueryMediaDetailTool(session_id="session-1", user_id="10001") with patch( - "app.agent.tools.impl.query_media_detail.MusicChain.async_artist", + "app.agent.tools.impl.query_media_detail.MediaChain.async_get_music_artist", new=async_artist, ): result = asyncio.run( @@ -479,7 +507,7 @@ def test_music_scrape_routes_audio_to_tag_cover_and_lyrics_pipeline(tmp_path): tool = ScrapeMetadataTool(session_id="session-1", user_id="10001") with patch( - "app.agent.tools.impl.scrape_metadata.MediaChain.scrape_music_metadata", + "app.agent.tools.impl.scrape_metadata.ScrapingChain.scrape_music_metadata", return_value=(True, "音乐刮削完成,歌词新增 1 首"), ) as scrape_music: result = asyncio.run( @@ -543,12 +571,12 @@ def test_agent_identity_schemas_only_expose_media_source_and_media_id(): def test_listenbrainz_album_chart_preserves_entity_and_bounded_page_size(): - """音乐榜单应把专辑实体与有界分页参数传递给缓存后的 MusicChain。""" + """音乐榜单应把专辑实体与有界分页参数传递给推荐链。""" async_chart = AsyncMock(return_value=[_album()]) tool = GetRecommendationsTool(session_id="session-1", user_id="10001") with patch( - "app.agent.tools.impl.get_recommendations.MusicChain.async_chart", + "app.agent.tools.impl.get_recommendations.RecommendChain.async_music_chart", new=async_chart, ): result = asyncio.run( @@ -562,6 +590,8 @@ def test_listenbrainz_album_chart_preserves_entity_and_bounded_page_size(): payload = json.loads(result) assert payload[0]["music_type"] == "album" + assert payload[0]["media_source"] == "musicbrainz" + assert payload[0]["media_id"] == "release-group-1" assert async_chart.await_args.kwargs["entity"] == "album" assert async_chart.await_args.kwargs["page"] == 2 assert async_chart.await_args.kwargs["count"] == 20 @@ -589,3 +619,30 @@ def test_subscribe_shares_normalize_legacy_music_type(): payload = json.loads(result.split("\n\n", 1)[1]) assert payload[0]["type"] == "music" assert payload[0]["music_type"] == "recording" + + +def test_subscribe_shares_hide_server_legacy_identity_fields(): + """V3 Agent 只输出统一身份,中心服务为旧客户端合成的字段不得继续传播。""" + async_shares = AsyncMock(return_value=[{ + "id": 2, + "name": "测试电影", + "type": MediaType.MOVIE.value, + "media_source": "themoviedb", + "media_id": "123", + "tmdbid": 123, + "doubanid": "legacy-douban", + }]) + tool = QuerySubscribeSharesTool(session_id="session-1", user_id="10001") + + with patch( + "app.agent.tools.impl.query_subscribe_shares." + "MoviePilotServerHelper.async_get_subscribe_shares", + new=async_shares, + ): + result = asyncio.run(tool.run()) + + payload = json.loads(result.split("\n\n", 1)[1]) + assert payload[0]["media_source"] == "themoviedb" + assert payload[0]["media_id"] == "123" + assert "tmdbid" not in payload[0] + assert "doubanid" not in payload[0] diff --git a/tests/test_agent_query_popular_subscribes_tool.py b/tests/test_agent_query_popular_subscribes_tool.py index 778901b72..f68453328 100644 --- a/tests/test_agent_query_popular_subscribes_tool.py +++ b/tests/test_agent_query_popular_subscribes_tool.py @@ -11,7 +11,8 @@ def test_popular_subscribe_title_distinguishes_special_season_zero(monkeypatch): "type": "tv", "name": "Demo Show", "season": 0, - "tmdbid": 1, + "media_source": "themoviedb", + "media_id": "1", "count": 5, }] @@ -27,6 +28,10 @@ def test_popular_subscribe_title_distinguishes_special_season_zero(monkeypatch): assert payload[0]["title"] == "Demo Show 第零季" assert payload[0]["season"] == 0 + assert payload[0]["media_source"] == "themoviedb" + assert payload[0]["media_id"] == "1" + assert "tmdb_id" not in payload[0] + assert "tmdbid" not in payload[0] def test_popular_music_subscribes_can_filter_complete_albums(monkeypatch): diff --git a/tests/test_agent_query_subscribes.py b/tests/test_agent_query_subscribes.py index 2bb7fa670..ae2104b96 100644 --- a/tests/test_agent_query_subscribes.py +++ b/tests/test_agent_query_subscribes.py @@ -4,7 +4,7 @@ from unittest.mock import patch from app.agent.tools.impl.query_subscribes import QuerySubscribesTool from app.db.models.subscribe import Subscribe -from app.schemas.types import MediaType +from app.schemas.types import MediaSource, MediaType def test_agent_query_subscribes_returns_manual_total_episode(): @@ -13,7 +13,8 @@ def test_agent_query_subscribes_returns_manual_total_episode(): id=160, name="测试剧集", type=MediaType.TV.value, - tmdbid=224839, + media_source=MediaSource.TMDB.value, + media_id="224839", season=1, total_episode=175, manual_total_episode=1, diff --git a/tests/test_anilist_media_source.py b/tests/test_anilist_media_source.py index 892166517..01ea2a395 100644 --- a/tests/test_anilist_media_source.py +++ b/tests/test_anilist_media_source.py @@ -9,7 +9,7 @@ from app.core.meta import MetaBase from app.helper.scraper import MediaScraperHelper from app.modules.anilist import AniListModule from app.modules.anilist.anilist import AniListApi -from app.schemas.types import MediaType +from app.schemas.types import MediaSource, MediaType @pytest.fixture @@ -77,10 +77,13 @@ def test_anilist_id_recognition_normalizes_media_info(anilist_info: dict) -> Non module.anilist_api = Mock() module.anilist_api.detail.return_value = anilist_info - media = module.recognize_media(anilistid=154587) + media = module.recognize_media( + media_source=MediaSource.AniList, media_id="154587" + ) assert media is not None - assert media.source == "anilist" + assert media.media_source == MediaSource.AniList + assert media.media_id == "154587" assert media.anilist_id == 154587 assert media.title == "葬送的芙莉莲" assert media.anidb_id == 17617 @@ -108,8 +111,8 @@ def test_anilist_title_recognition_respects_request_source(anilist_info: dict) - meta.type = MediaType.TV meta.year = "2023" - media = module.recognize_media(meta=meta, source="anilist") - skipped = module.recognize_media(meta=meta, source="douban") + media = module.recognize_media(meta=meta, media_source=MediaSource.AniList) + skipped = module.recognize_media(meta=meta, media_source=MediaSource.Douban) assert media is not None assert media.anilist_id == 154587 @@ -127,7 +130,7 @@ def test_async_anilist_title_recognition(anilist_info: dict) -> None: meta.type = MediaType.TV media = asyncio.run( - module.async_recognize_media(meta=meta, source="anilist") + module.async_recognize_media(meta=meta, media_source=MediaSource.AniList) ) assert media is not None diff --git a/tests/test_anime_source_metainfo.py b/tests/test_anime_source_metainfo.py index 0cf5b0b54..75fdb281c 100644 --- a/tests/test_anime_source_metainfo.py +++ b/tests/test_anime_source_metainfo.py @@ -69,3 +69,61 @@ def test_extended_ids_fall_back_when_installed_rust_is_old() -> None: assert metainfo["media_source"] == "anilist" assert metainfo["media_id"] == "154587" + + +def test_generic_identity_falls_back_when_installed_rust_is_old() -> None: + """旧 Rust 扩展缺少通用字段时应直接使用 Python 解析器。""" + with patch( + "app.core.metainfo.rust_accel.supports_unified_media_identity", + return_value=False, + ), patch( + "app.core.metainfo.rust_accel.find_metainfo", + side_effect=AssertionError("旧 Rust 扩展不应处理通用媒体身份"), + ): + _, metainfo = find_metainfo( + "Frieren {[media_source=anilist;media_id=154587]}" + ) + + assert metainfo["media_source"] == "anilist" + assert metainfo["media_id"] == "154587" + + +@pytest.mark.parametrize( + "title", + [ + "Movie {[media_source=themoviedb;media_id=0;type=movies]}", + "Movie {[tmdbid=0;type=movies]}", + "Movie [tmdbid=0]", + "Anime [anilist=0]", + ], +) +def test_python_metainfo_rejects_zero_identity_and_removes_tag(title: str) -> None: + """Python 标签解析器应移除零值标签,但不得生成媒体身份。""" + with patch("app.core.metainfo.rust_accel.find_metainfo", return_value=None): + parsed_title, metainfo = find_metainfo(title) + + assert metainfo["media_source"] is None + assert metainfo["media_id"] is None + assert "=0" not in parsed_title + + +def test_metainfo_normalizes_zero_identity_from_old_rust_extension() -> None: + """旧 Rust 扩展返回零值身份时,主程序边界仍应将统一对清空。""" + rust_result = { + "title": "Movie", + "metainfo": { + "media_source": "themoviedb", + "media_id": "0", + "tmdbid": 0, + }, + } + with patch( + "app.core.metainfo.rust_accel.find_metainfo", + return_value=rust_result, + ): + parsed_title, metainfo = find_metainfo("Movie [tmdbid=0]") + + assert parsed_title == "Movie" + assert metainfo["media_source"] is None + assert metainfo["media_id"] is None + assert "tmdbid" not in metainfo diff --git a/tests/test_audio_metadata.py b/tests/test_audio_metadata.py index 2fd8067f0..a9af0db6b 100644 --- a/tests/test_audio_metadata.py +++ b/tests/test_audio_metadata.py @@ -3,7 +3,6 @@ from types import SimpleNamespace from unittest.mock import Mock from app.chain.media import MediaChain -from app.chain.music import MusicChain from app.core.context import MusicInfo from app.core.meta.metamusic import ( audio_quality_score, @@ -12,6 +11,7 @@ from app.core.meta.metamusic import ( parse_audio_quality, ) from app.helper.audio import AudioMetadataHelper +from app.schemas.types import MUSIC_ENTITY_ALBUM RECORDING_ID = "38035858-f990-4fbb-b3b2-f2f8b958eeba" @@ -186,15 +186,13 @@ def test_remote_path_meta_parses_track_prefix_once(tmp_path): """远程或尚未落盘的音频路径应先剥离曲序,不能把 08 误识别成艺术家。""" audio_path = tmp_path / "Daft Punk - Random Access Memories (2013)" / "08 - Get Lucky.flac" - music_meta = MusicChain.read_path_meta(audio_path) - media_meta = MediaChain.read_path_meta(audio_path) + music_meta = MediaChain.read_path_meta(audio_path) assert music_meta.title == "Get Lucky" assert music_meta.artists == ["Daft Punk"] assert music_meta.album == "Random Access Memories" assert music_meta.track_number == 8 assert music_meta.audio_format == "FLAC" - assert media_meta.to_dict() == music_meta.to_dict() def test_read_audio_metadata_fallback_uses_dynamic_filename_parser(tmp_path, monkeypatch): @@ -304,6 +302,39 @@ def test_write_audio_metadata_maps_music_info_to_easy_tags(monkeypatch): assert audio.tags["musicbrainz_trackid"] == [RECORDING_ID] +def test_write_audio_metadata_does_not_write_album_id_as_recording_tag(monkeypatch): + """MusicBrainz 专辑身份不得写入只接受 recording ID 的曲目标签。""" + class FakeAudio: + """记录专辑元数据写入结果。""" + + def __init__(self): + """初始化空标签容器。""" + self.tags = {} + + def __setitem__(self, key, value): + """记录 Easy 标签赋值。""" + self.tags[key] = value + + def save(self): + """模拟 Mutagen 保存。""" + + audio = FakeAudio() + monkeypatch.setattr("app.helper.audio.MutagenFile", lambda *_args, **_kwargs: audio) + + success = AudioMetadataHelper.write( + Path("/music/Random Access Memories.flac"), + MusicInfo( + media_source="musicbrainz", + media_id="release-group-1", + music_type=MUSIC_ENTITY_ALBUM, + title="Random Access Memories", + ), + ) + + assert success is True + assert "musicbrainz_trackid" not in audio.tags + + def test_write_audio_metadata_can_embed_cover_without_rewriting_tags(monkeypatch): """音乐封面策略应能在标签策略关闭时独立执行。""" audio = SimpleNamespace(tags={"title": ["Original"]}) diff --git a/tests/test_bangumi_media_type.py b/tests/test_bangumi_media_type.py index 5f313b54c..7e62e2fc8 100644 --- a/tests/test_bangumi_media_type.py +++ b/tests/test_bangumi_media_type.py @@ -5,7 +5,7 @@ import pytest from app.chain.media import MediaChain from app.core.context import MediaInfo -from app.schemas.types import MediaType +from app.schemas.types import MediaSource, MediaType @pytest.mark.parametrize( @@ -78,8 +78,18 @@ def test_bangumi_movie_conversion_uses_movie_type() -> None: """Bangumi剧场版转TMDB和豆瓣时均应按电影匹配。""" chain = _SyncBangumiMediaChain() - tmdb_info = MediaChain.get_tmdbinfo_by_bangumiid(chain, 1) - douban_info = MediaChain.get_doubaninfo_by_bangumiid(chain, 1) + tmdb_info = MediaChain.convert_media_identity( + chain, + target_source=MediaSource.TMDB, + media_source=MediaSource.Bangumi, + media_id="1", + ) + douban_info = MediaChain.convert_media_identity( + chain, + target_source=MediaSource.Douban, + media_source=MediaSource.Bangumi, + media_id="1", + ) assert tmdb_info == {"id": 100} assert douban_info == {"id": "200"} @@ -87,6 +97,26 @@ def test_bangumi_movie_conversion_uses_movie_type() -> None: assert chain.douban_mtype == MediaType.MOVIE +def test_media_identity_conversion_rejects_invalid_or_unsupported_pairs() -> None: + """跨源转换只接受完整非零 pair 和受支持的来源组合。""" + chain = _SyncBangumiMediaChain() + + assert MediaChain.convert_media_identity( + chain, + target_source=MediaSource.TMDB, + media_source=MediaSource.Bangumi, + media_id="0", + ) is None + assert MediaChain.convert_media_identity( + chain, + target_source=MediaSource.TheAudioDB, + media_source=MediaSource.Bangumi, + media_id="1", + ) is None + assert chain.tmdb_mtype is None + assert chain.douban_mtype is None + + class _AsyncBangumiMediaChain: """异步Bangumi跨数据源转换测试桩。""" @@ -125,8 +155,22 @@ def test_async_bangumi_movie_conversion_uses_movie_type() -> None: """异步Bangumi电影转换应向TMDB和豆瓣传递电影类型。""" chain = _AsyncBangumiMediaChain() - tmdb_info = asyncio.run(MediaChain.async_get_tmdbinfo_by_bangumiid(chain, 1)) - douban_info = asyncio.run(MediaChain.async_get_doubaninfo_by_bangumiid(chain, 1)) + tmdb_info = asyncio.run( + MediaChain.async_convert_media_identity( + chain, + target_source=MediaSource.TMDB, + media_source=MediaSource.Bangumi, + media_id="1", + ) + ) + douban_info = asyncio.run( + MediaChain.async_convert_media_identity( + chain, + target_source=MediaSource.Douban, + media_source=MediaSource.Bangumi, + media_id="1", + ) + ) assert tmdb_info == {"id": 100} assert douban_info == {"id": "200"} diff --git a/tests/test_bangumi_recognize_scrape.py b/tests/test_bangumi_recognize_scrape.py index 2d7dc8e23..e7d59453c 100644 --- a/tests/test_bangumi_recognize_scrape.py +++ b/tests/test_bangumi_recognize_scrape.py @@ -4,7 +4,7 @@ from xml.dom import minidom from app.core.meta import MetaBase from app.helper.scraper import MediaScraperHelper from app.modules.bangumi import BangumiModule -from app.schemas.types import MediaType +from app.schemas.types import MediaSource, MediaType def _bangumi_info() -> dict: @@ -43,10 +43,11 @@ def test_bangumi_title_recognition_loads_detail_and_people() -> None: meta.type = MediaType.TV meta.year = "2023" - media = module.recognize_media(meta=meta, source="bangumi") + media = module.recognize_media(meta=meta, media_source=MediaSource.Bangumi) assert media is not None - assert media.source == "bangumi" + assert media.media_source == MediaSource.Bangumi + assert media.media_id == "400602" assert media.bangumi_id == 400602 assert media.number_of_episodes == 28 assert media.genres == [ @@ -65,7 +66,9 @@ def test_bangumi_scraper_generates_source_nfo() -> None: module.scraper = MediaScraperHelper() module.bangumiapi.detail.return_value = _bangumi_info() module.bangumiapi.credits.return_value = [] - media = module.recognize_media(bangumiid=400602) + media = module.recognize_media( + media_source=MediaSource.Bangumi, media_id="400602" + ) media.scrape_source = "bangumi" nfo = module.metadata_nfo(media) diff --git a/tests/test_benchmark_metainfo_rust.py b/tests/test_benchmark_metainfo_rust.py new file mode 100644 index 000000000..9ea72fa65 --- /dev/null +++ b/tests/test_benchmark_metainfo_rust.py @@ -0,0 +1,223 @@ +from contextlib import contextmanager +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +from scripts import benchmark_metainfo_rust as benchmark + + +def test_build_inputs_separates_video_and_music_domains(): + """影视与音乐输入应独立扩展,并按 repeat 稳定重复。""" + video_once = benchmark.build_video_inputs(1) + music_once = benchmark.build_music_inputs(1) + + assert benchmark.build_video_inputs(2) == video_once * 2 + assert benchmark.build_music_inputs(2) == music_once * 2 + assert {kind for kind, _value, _subtitle in music_once} == { + "music_query", + "title", + "path", + } + assert all(not value.lower().endswith(tuple(benchmark.metainfo_module.settings.RMT_AUDIOEXT)) + for kind, value, _subtitle in music_once if kind == "music_query") + + +def test_parse_input_uses_public_production_entries(monkeypatch): + """输入分发应调用 MetaInfo、MetaInfoPath 和 MetaMusic.parse_query 公开入口。""" + title_result = object() + path_result = object() + music_result = object() + title_parser = Mock(return_value=title_result) + path_parser = Mock(return_value=path_result) + music_parser = Mock(return_value=music_result) + monkeypatch.setattr(benchmark, "MetaInfo", title_parser) + monkeypatch.setattr(benchmark, "MetaInfoPath", path_parser) + monkeypatch.setattr(benchmark.MetaMusic, "parse_query", music_parser) + + assert benchmark.parse_input(("title", "Movie 2026", "subtitle")) is title_result + assert benchmark.parse_input(("path", "/media/Movie 2026/movie.mkv", None)) is path_result + assert benchmark.parse_input(("music_query", "Artist - Track", None)) is music_result + title_parser.assert_called_once_with( + title="Movie 2026", + subtitle="subtitle", + custom_words=["#"], + ) + path_parser.assert_called_once_with(benchmark.Path("/media/Movie 2026/movie.mkv")) + music_parser.assert_called_once_with("Artist - Track") + + +def test_selected_meta_parser_disables_and_restores_all_fast_paths(monkeypatch): + """Python 对照上下文应屏蔽影视和音乐 Rust 入口,并完整恢复原函数。""" + rust_accel = benchmark.metainfo_module.rust_accel + parser_names = ( + "parse_metainfo", + "parse_metainfo_path", + "find_metainfo", + "parse_metamusic", + ) + originals = {} + for name in parser_names: + parser = Mock(name=name) + monkeypatch.setattr(rust_accel, name, parser, raising=False) + originals[name] = parser + + with benchmark.selected_meta_parser(use_rust=False): + for name in parser_names: + assert getattr(rust_accel, name)("sample") is None + + for name, parser in originals.items(): + assert getattr(rust_accel, name) is parser + + +def test_measure_switches_once_and_warms_up_outside_samples(monkeypatch): + """一次测量只应切换一次解析器,并额外执行一轮不计时预热。""" + context_calls = [] + parse_calls = [] + + @contextmanager + def fake_selected_meta_parser(use_rust: bool): + """记录测试中的解析器上下文进入次数。""" + context_calls.append(use_rust) + yield + + def fake_parse_all(inputs): + """记录测试中的每轮解析调用。""" + parse_calls.append(inputs) + return [object()] * len(inputs) + + monkeypatch.setattr(benchmark, "selected_meta_parser", fake_selected_meta_parser) + monkeypatch.setattr(benchmark, "parse_all", fake_parse_all) + + elapsed, parsed_count = benchmark.measure( + [("title", "Movie", None)], + use_rust=False, + loops=2, + repeats=3, + ) + + assert context_calls == [False] + assert len(parse_calls) == 7 + assert parsed_count == 1 + assert elapsed >= 0 + + +def test_assert_projected_results_equal_reports_first_field_difference(): + """等价校验失败时应报告首个输入及稳定字段差异。""" + inputs = [("music_query", "Artist - Track", None)] + rust_result = SimpleNamespace(title="Track", artists=["Artist"]) + python_result = SimpleNamespace(title="Other", artists=["Artist"]) + + with pytest.raises(AssertionError) as error: + benchmark.assert_projected_results_equal( + inputs, + [rust_result], + [python_result], + lambda result: { + "title": result.title, + "artists": list(result.artists), + }, + ) + + message = str(error.value) + assert "Artist - Track" in message + assert "title" in message + assert "Track" in message + assert "Other" in message + + +def test_video_projection_ignores_python_parser_internal_state(): + """影视等价投影不应纳入 Python 解析器的临时私有字段。""" + rust_result = benchmark.MetaInfo("Marty Supreme 2025 2160p WEB-DL") + python_result = benchmark.MetaInfo("Marty Supreme 2025 2160p WEB-DL") + rust_result._index = 1 + python_result._index = 99 + rust_result._effect = [] + python_result._effect = ["temporary"] + + assert benchmark.project_video_result(rust_result) == benchmark.project_video_result( + python_result + ) + + +def test_validate_rust_runtime_rejects_disabled_and_old_extensions(monkeypatch): + """运行前检查应拒绝关闭的 Rust 和缺少音乐入口的旧扩展。""" + rust_accel = benchmark.metainfo_module.rust_accel + monkeypatch.setattr(rust_accel, "is_enabled", Mock(return_value=False)) + + with pytest.raises(RuntimeError, match="未启用"): + benchmark.validate_rust_runtime() + + monkeypatch.setattr(rust_accel, "is_enabled", Mock(return_value=True)) + monkeypatch.setattr(rust_accel, "parse_metamusic", Mock(return_value={}), raising=False) + monkeypatch.setattr(rust_accel, "_moviepilot_rust", SimpleNamespace()) + + with pytest.raises(RuntimeError, match="版本过旧"): + benchmark.validate_rust_runtime() + + +def test_validate_rust_runtime_requires_successful_music_probe(monkeypatch): + """音乐 Rust 入口存在但实际回退 Python 时也必须拒绝执行基准。""" + rust_accel = benchmark.metainfo_module.rust_accel + extension = SimpleNamespace(parse_metamusic_fast=Mock()) + monkeypatch.setattr(rust_accel, "is_enabled", Mock(return_value=True)) + monkeypatch.setattr(rust_accel, "_moviepilot_rust", extension) + monkeypatch.setattr(rust_accel, "parse_metamusic", Mock(return_value=None), raising=False) + + with pytest.raises(RuntimeError, match="探针"): + benchmark.validate_rust_runtime() + + +def test_main_outputs_independent_video_and_music_metrics(monkeypatch, capsys): + """主程序应分别输出影视和音乐等价状态、耗时及性能提升。""" + monkeypatch.setattr(benchmark, "validate_rust_runtime", Mock()) + monkeypatch.setattr(benchmark, "build_video_inputs", Mock(return_value=[("title", "V", None)])) + monkeypatch.setattr( + benchmark, + "build_music_inputs", + Mock(return_value=[("music_query", "M", None), ("title", "M.flac", None)]), + ) + results = [ + { + "rust_ms": 1.0, + "python_ms": 2.0, + "rust_count": 1, + "python_count": 1, + "speedup": 2.0, + }, + { + "rust_ms": 2.0, + "python_ms": 6.0, + "rust_count": 2, + "python_count": 2, + "speedup": 3.0, + }, + ] + monkeypatch.setattr(benchmark, "benchmark_suite", Mock(side_effect=results)) + monkeypatch.setattr( + benchmark.sys, + "argv", + ["benchmark_metainfo_rust.py", "--repeat-inputs", "1", "--loops", "1", "--repeats", "1"], + ) + + assert benchmark.main() == 0 + output = capsys.readouterr().out + assert "video_equivalent=true" in output + assert "video_speedup=2.00x" in output + assert "music_equivalent=true" in output + assert "music_speedup=3.00x" in output + assert "video_rust_us_per_item=1000.000" in output + assert "music_rust_us_per_item=1000.000" in output + + +def test_main_reports_runtime_failure_with_nonzero_exit(monkeypatch, capsys): + """Rust 未就绪时主程序应明确报错并返回非零状态。""" + monkeypatch.setattr( + benchmark, + "validate_rust_runtime", + Mock(side_effect=RuntimeError("old extension")), + ) + monkeypatch.setattr(benchmark.sys, "argv", ["benchmark_metainfo_rust.py"]) + + assert benchmark.main() == 2 + assert "benchmark_error=old extension" in capsys.readouterr().err diff --git a/tests/test_bluray.py b/tests/test_bluray.py index e9e8e3019..3ea24f66f 100644 --- a/tests/test_bluray.py +++ b/tests/test_bluray.py @@ -6,7 +6,7 @@ from unittest import TestCase from unittest.mock import patch from app import schemas -from app.chain.media import MediaChain +from app.chain.scraping import ScrapingChain from app.chain.storage import StorageChain from app.chain.transfer import TransferChain from app.core.context import MediaInfo @@ -143,7 +143,7 @@ class BluRayTest(TestCase): # 测试手动刮削 logger.debug(f"测试手动刮削 {path}") mock_metadata_nfo.call_count = 0 - MediaChain().scrape_metadata( + ScrapingChain().scrape_metadata( fileitem=fileitem, meta=meta, mediainfo=mediainfo, overwrite=True ) # 确保调用了指定次数的metadata_nfo @@ -152,7 +152,7 @@ class BluRayTest(TestCase): # 测试自动刮削 logger.debug(f"测试自动刮削 {path}") mock_metadata_nfo.call_count = 0 - MediaChain().scrape_metadata_event( + ScrapingChain().scrape_metadata_event( Event( event_type=EventType.MetadataScrape, event_data={ @@ -174,7 +174,7 @@ class BluRayTest(TestCase): # 刮削电影目录 __test_scrape_metadata("/FOLDER", excepted_nfo_count=2) - @patch("app.chain.media.MediaChain.metadata_img", return_value=None) # 避免获取图片 + @patch("app.chain.scraping.ScrapingChain.metadata_img", return_value=None) # 避免获取图片 @patch("app.chain.ChainBase.__init__", return_value=None) # 避免不必要的模块初始化 @patch("app.db.transferhistory_oper.TransferHistoryOper.get_by_src") @patch("app.chain.storage.StorageChain.list_files") @@ -222,6 +222,6 @@ class BluRayTest(TestCase): self._test_do_transfer() with patch( - "app.chain.media.MediaChain.metadata_nfo", return_value=None + "app.chain.scraping.ScrapingChain.metadata_nfo", return_value=None ) as mock: self._test_scrape_metadata(mock_metadata_nfo=mock) diff --git a/tests/test_builtin_skill_boundaries.py b/tests/test_builtin_skill_boundaries.py index 7eb668ce7..b1183ca4b 100644 --- a/tests/test_builtin_skill_boundaries.py +++ b/tests/test_builtin_skill_boundaries.py @@ -22,12 +22,13 @@ def _frontmatter_value(content: str, key: str) -> str: def test_modified_builtin_skills_have_incremented_versions() -> None: """本次修改过的内置技能必须递增版本,确保用户端同步更新。""" expected_versions = { - "database-operation": "3", - "moviepilot-api": "11", - "moviepilot-cli": "6", + "database-operation": "4", + "moviepilot-api": "12", + "moviepilot-cli": "7", "moviepilot-update": "3", - "organize-files": "2", - "transfer-failed-retry": "3", + "organize-files": "3", + "transfer-failed-retry": "4", + "generate-identifiers": "3", "create-moviepilot-plugin": "3", } diff --git a/tests/test_chain_layering.py b/tests/test_chain_layering.py index a1e5e0ec3..9ec365a57 100644 --- a/tests/test_chain_layering.py +++ b/tests/test_chain_layering.py @@ -6,11 +6,23 @@ from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parents[1] CHAIN_ROOT = PROJECT_ROOT / "app" / "chain" +LEGACY_MUSIC_SCAN_ROOTS = ( + PROJECT_ROOT / "app", + PROJECT_ROOT / "scripts", +) +MUSIC_SOURCE_CHAIN_FILES = ( + "acoustid.py", + "douban.py", + "listenbrainz.py", + "lrclib.py", + "musicbrainz.py", + "theaudiodb.py", +) def _imported_modules(path: Path) -> set[str]: """解析源码中的导入模块,包含函数内部的延迟导入。""" - tree = ast.parse(path.read_text(encoding="utf-8")) + tree = ast.parse(path.read_text(encoding="utf-8-sig")) modules: set[str] = set() for node in ast.walk(tree): if isinstance(node, ast.ImportFrom) and node.module: @@ -47,14 +59,68 @@ def test_chain_base_does_not_import_concrete_chains() -> None: } -def test_music_chain_only_depends_on_chain_base() -> None: - """音乐领域链不得反向依赖媒体编排链或其他业务链。""" - imports = _imported_modules(CHAIN_ROOT / "music.py") - - assert not { - module for module in imports - if module.startswith("app.chain.") +def test_legacy_music_chain_is_removed() -> None: + """聚合全部音乐职责的旧 MusicChain 文件和导入不得重新出现。""" + assert not (CHAIN_ROOT / "music.py").exists() + violations = { + str(path.relative_to(PROJECT_ROOT)): sorted( + module for module in _imported_modules(path) if module == "app.chain.music" + ) + for root in LEGACY_MUSIC_SCAN_ROOTS + for path in root.rglob("*.py") + if "app.chain.music" in _imported_modules(path) } + assert not violations + + +def test_music_source_chains_do_not_depend_on_public_orchestration_chains() -> None: + """音乐数据源链不得反向依赖识别、刮削、搜索或推荐编排链。""" + forbidden = { + "app.chain.media", + "app.chain.recommend", + "app.chain.scraping", + "app.chain.search", + } + violations = { + filename: sorted(_imported_modules(CHAIN_ROOT / filename).intersection(forbidden)) + for filename in MUSIC_SOURCE_CHAIN_FILES + if _imported_modules(CHAIN_ROOT / filename).intersection(forbidden) + } + assert not violations + + +def test_media_chain_excludes_scraping_and_music_exploration_methods() -> None: + """MediaChain 只保留公共识别与详情路由,不得重新承接刮削或音乐探索职责。""" + path = CHAIN_ROOT / "media.py" + tree = ast.parse(path.read_text(encoding="utf-8")) + method_names = { + node.name + for node in ast.walk(tree) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + forbidden_methods = { + "async_get_doubaninfo_by_bangumiid", + "async_get_doubaninfo_by_tmdbid", + "async_get_tmdbinfo_by_bangumiid", + "async_get_tmdbinfo_by_doubanid", + "scrape_metadata", + "scrape_metadata_event", + "scrape_music_metadata", + "get_doubaninfo_by_bangumiid", + "get_doubaninfo_by_tmdbid", + "get_music_lyrics", + "get_tmdbinfo_by_bangumiid", + "get_tmdbinfo_by_doubanid", + "async_get_music_lyrics", + "music_chart", + "async_music_chart", + "music_discover", + "async_music_discover", + "async_music_fresh_releases", + } + + assert not method_names.intersection(forbidden_methods) + assert "app.chain.scraping" not in _imported_modules(path) def test_business_chains_delegate_recognition_to_media_chain() -> None: diff --git a/tests/test_dashboard_system_info.py b/tests/test_dashboard_system_info.py index 4d39eed5e..87419d81c 100644 --- a/tests/test_dashboard_system_info.py +++ b/tests/test_dashboard_system_info.py @@ -1,6 +1,6 @@ from app.db import SessionFactory from app.db.models.transferhistory import TransferHistory -from app.schemas.types import MediaType +from app.schemas.types import MediaSource, MediaType from app.utils import system as system_module from app.utils.system import SystemUtils @@ -70,13 +70,13 @@ def test_monthly_media_statistics_counts_successful_unique_media(): """本月新增统计应只计算成功记录,并按媒体去重。""" month = system_module.time.strftime("%Y-%m-", system_module.time.localtime()) histories = [ - TransferHistory(status=True, date=f"{month}01 10:00:00", type=MediaType.MOVIE.value, tmdbid=1, title="电影"), - TransferHistory(status=True, date=f"{month}02 10:00:00", type=MediaType.MOVIE.value, tmdbid=1, title="电影"), - TransferHistory(status=True, date=f"{month}03 10:00:00", type=MediaType.TV.value, tmdbid=2, title="剧集", episodes="E01-E03"), - TransferHistory(status=False, date=f"{month}04 10:00:00", type=MediaType.TV.value, tmdbid=3, title="失败剧集"), + TransferHistory(status=True, date=f"{month}01 10:00:00", type=MediaType.MOVIE.value, media_source=MediaSource.TMDB.value, media_id="1", title="电影"), + TransferHistory(status=True, date=f"{month}02 10:00:00", type=MediaType.MOVIE.value, media_source=MediaSource.TMDB.value, media_id="1", title="电影"), + TransferHistory(status=True, date=f"{month}03 10:00:00", type=MediaType.TV.value, media_source=MediaSource.TMDB.value, media_id="2", title="剧集", episodes="E01-E03"), + TransferHistory(status=False, date=f"{month}04 10:00:00", type=MediaType.TV.value, media_source=MediaSource.TMDB.value, media_id="3", title="失败剧集"), # 同一首歌多次整理只计一首,无媒体 ID 的曲目按记录计数 - TransferHistory(status=True, date=f"{month}05 10:00:00", type=MediaType.MUSIC.value, title="晴天", media_id="musicbrainz:r1"), - TransferHistory(status=True, date=f"{month}06 10:00:00", type=MediaType.MUSIC.value, title="晴天", media_id="musicbrainz:r1"), + TransferHistory(status=True, date=f"{month}05 10:00:00", type=MediaType.MUSIC.value, title="晴天", media_source=MediaSource.MusicBrainz.value, media_id="r1"), + TransferHistory(status=True, date=f"{month}06 10:00:00", type=MediaType.MUSIC.value, title="晴天", media_source=MediaSource.MusicBrainz.value, media_id="r1"), TransferHistory(status=True, date=f"{month}07 10:00:00", type=MediaType.MUSIC.value, title="未知曲目"), TransferHistory(status=False, date=f"{month}08 10:00:00", type=MediaType.MUSIC.value, title="失败曲目"), # 整专内不同目标曲目共享专辑 ID,但应分别计入音乐数量 diff --git a/tests/test_douban_recognition.py b/tests/test_douban_recognition.py index 1793c236c..ce3763847 100644 --- a/tests/test_douban_recognition.py +++ b/tests/test_douban_recognition.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock from app.core.meta import MetaBase from app.modules.douban import DoubanModule -from app.schemas.types import MediaType +from app.schemas.types import MediaSource, MediaType def test_douban_recognize_does_not_keep_dedicated_mapping_cache(): @@ -24,13 +24,13 @@ def test_douban_recognize_does_not_keep_dedicated_mapping_cache(): first_result = module._recognize_media_core( meta=meta, - source="douban", + media_source=MediaSource.Douban, match_doubaninfo_func=match_doubaninfo, douban_info_func=douban_info, ) second_result = module._recognize_media_core( meta=meta, - source="douban", + media_source=MediaSource.Douban, match_doubaninfo_func=match_doubaninfo, douban_info_func=douban_info, ) @@ -60,13 +60,13 @@ def test_async_douban_recognize_does_not_keep_dedicated_mapping_cache(): """连续执行两次异步豆瓣识别。""" first_result = await module._async_recognize_media_core( meta=meta, - source="douban", + media_source=MediaSource.Douban, async_match_doubaninfo_func=match_doubaninfo, async_douban_info_func=douban_info, ) second_result = await module._async_recognize_media_core( meta=meta, - source="douban", + media_source=MediaSource.Douban, async_match_doubaninfo_func=match_doubaninfo, async_douban_info_func=douban_info, ) diff --git a/tests/test_download_chain.py b/tests/test_download_chain.py index 6e28509fa..6db33b778 100644 --- a/tests/test_download_chain.py +++ b/tests/test_download_chain.py @@ -10,7 +10,7 @@ from app.core.config import settings from app.core.context import Context, MediaInfo, SubtitleInfo, TorrentInfo from app.core.metainfo import MetaInfo from app.schemas import DownloaderTorrent, FileItem, NotExistMediaInfo, TransferDirectoryConf -from app.schemas.types import MediaType +from app.schemas.types import MediaSource, MediaType @pytest.fixture(autouse=True) @@ -216,7 +216,7 @@ def test_download_single_supplements_category_before_download_event(monkeypatch) monkeypatch.setattr(download_module, "MediaChain", _FakeMediaChain) monkeypatch.setattr(download_module.eventmanager, "send_event", cancel_download) media = MediaInfo( - source="bangumi", + media_source=MediaSource.Bangumi, media_id="40000", bangumi_id=40000, type=MediaType.TV, @@ -236,7 +236,7 @@ def test_download_single_supplements_category_before_download_event(monkeypatch) assert result == (None, "下载被事件取消") assert captured["event_data"].options["media_category"] == "日本动画" - assert context.media_info.source == "bangumi" + assert context.media_info.media_source == MediaSource.Bangumi assert context.media_info.media_id == "40000" assert context.media_info.tmdb_id == 12345 @@ -467,6 +467,8 @@ def _build_tv_context(episode_list=None): media_info=SimpleNamespace( type=MediaType.TV, title_year="Test Show (2026)", + media_source=MediaSource.TMDB, + media_id="1", tmdb_id=1, douban_id=None, bangumi_id=None, @@ -486,6 +488,55 @@ def _build_tv_context(episode_list=None): ) +def _tv_no_exists(season: int, info: NotExistMediaInfo) -> dict: + """以统一媒体身份键构造单季缺集测试数据。""" + return {"tmdb:1": {season: info}} + + +def test_download_identity_matching_uses_only_media_source_and_media_id(): + """通用下载匹配不得再把来源特定辅助 ID 当作主身份键。""" + media = SimpleNamespace( + media_source=MediaSource.Douban, + media_id="douban-1", + tmdb_id=1, + douban_id="douban-1", + bangumi_id=2, + anilist_id=3, + ) + + assert DownloadChain._media_identity_keys(media) == {"douban:douban-1"} + assert DownloadChain._matches_media_identity(media, "douban:douban-1") is True + assert DownloadChain._matches_media_identity(media, 1) is False + + +def test_download_failure_fingerprint_ignores_auxiliary_provider_ids(): + """缺少主身份时失败指纹应按标题年份降级,不得依赖 IMDb 或 TVDB 辅助 ID。""" + torrent = SimpleNamespace(site=1, torrent_id="torrent-1") + + def build_context(imdb_id: str, tvdb_id: int): + """构造只有辅助 Provider ID 的失败上下文。""" + return SimpleNamespace( + media_info=SimpleNamespace( + type=MediaType.MOVIE, + media_source=None, + media_id=None, + title="Demo Movie", + year="2026", + imdb_id=imdb_id, + tvdb_id=tvdb_id, + season=None, + ), + meta_info=SimpleNamespace(season=None, episode=None, episode_list=[]), + torrent_info=torrent, + ) + + assert DownloadChain._build_download_failure_fingerprint( + build_context("tt0000001", 1) + ) == DownloadChain._build_download_failure_fingerprint( + build_context("tt9999999", 999) + ) + + def test_batch_download_rejects_complete_coverage_when_files_do_not_cover_target(monkeypatch): """ 完整覆盖要求不能让 1-13 这种局部包冒充 1-143 的目标范围。 @@ -499,17 +550,16 @@ def test_batch_download_rejects_complete_coverage_when_files_do_not_cover_target chain.download_single = MagicMock(return_value="hash") context = _build_tv_context() - no_exists = { - 1: { - 1: NotExistMediaInfo( + no_exists = _tv_no_exists( + 1, + NotExistMediaInfo( season=1, episodes=[], total_episode=143, start_episode=1, require_complete_coverage=True, - ) - } - } + ), + ) downloads, lefts = chain.batch_download(contexts=[context], no_exists=no_exists) @@ -534,17 +584,16 @@ def test_batch_download_preserves_special_season_zero(monkeypatch): context.meta_info.season_episode = "S00" context.meta_info.org_string = "Test Show S00 2160p" context.torrent_info.title = "Test Show S00 2160p" - no_exists = { - 1: { - 0: NotExistMediaInfo( + no_exists = _tv_no_exists( + 0, + NotExistMediaInfo( season=0, episodes=[], total_episode=6, start_episode=1, require_complete_coverage=True, - ) - } - } + ), + ) downloads, lefts = chain.batch_download(contexts=[context], no_exists=no_exists) @@ -566,17 +615,16 @@ def test_batch_download_rejects_complete_coverage_when_only_missing_episodes_mat chain.download_single = MagicMock(return_value="hash") context = _build_tv_context() - no_exists = { - 1: { - 1: NotExistMediaInfo( + no_exists = _tv_no_exists( + 1, + NotExistMediaInfo( season=1, episodes=[4, 5], total_episode=5, start_episode=1, require_complete_coverage=True, - ) - } - } + ), + ) downloads, lefts = chain.batch_download(contexts=[context], no_exists=no_exists) @@ -601,16 +649,15 @@ def test_batch_download_tries_next_episode_candidate_when_first_download_fails(m first_context.torrent_info.title = "Test Show S01E01 First" second_context = _build_tv_context(episode_list=[1]) second_context.torrent_info.title = "Test Show S01E01 Second" - no_exists = { - 1: { - 1: NotExistMediaInfo( + no_exists = _tv_no_exists( + 1, + NotExistMediaInfo( season=1, episodes=[1], total_episode=1, start_episode=1, - ) - } - } + ), + ) downloads, lefts = chain.batch_download( contexts=[first_context, second_context], @@ -691,16 +738,15 @@ def test_batch_download_applies_custom_words_to_torrent_file_episodes(monkeypatc chain.download_single = MagicMock(return_value="hash") context = _build_tv_context() - no_exists = { - 1: { - 1: NotExistMediaInfo( + no_exists = _tv_no_exists( + 1, + NotExistMediaInfo( season=1, episodes=[170], total_episode=200, start_episode=155, - ) - } - } + ), + ) custom_words = ( "A.Will.Eternal.S04 => 一念永恒{[tmdbid=107371;type=tv]}S01 " "&& S01 <> 2160p >> EP+165" @@ -899,17 +945,16 @@ def test_batch_download_accepts_complete_coverage_when_files_cover_target_range( chain.download_single = MagicMock(return_value="hash") context = _build_tv_context() - no_exists = { - 1: { - 1: NotExistMediaInfo( + no_exists = _tv_no_exists( + 1, + NotExistMediaInfo( season=1, episodes=[], total_episode=143, start_episode=100, require_complete_coverage=True, - ) - } - } + ), + ) downloads, lefts = chain.batch_download(contexts=[context], no_exists=no_exists) @@ -932,17 +977,16 @@ def test_batch_download_rejects_complete_coverage_when_files_have_same_count_but chain.download_single = MagicMock(return_value="hash") context = _build_tv_context() - no_exists = { - 1: { - 1: NotExistMediaInfo( + no_exists = _tv_no_exists( + 1, + NotExistMediaInfo( season=1, episodes=[], total_episode=143, start_episode=100, require_complete_coverage=True, - ) - } - } + ), + ) downloads, lefts = chain.batch_download(contexts=[context], no_exists=no_exists) @@ -965,17 +1009,16 @@ def test_batch_download_accepts_complete_coverage_when_title_episodes_cover_targ chain.download_single = MagicMock(return_value="hash") context = _build_tv_context(episode_list=list(range(1, 144))) - no_exists = { - 1: { - 1: NotExistMediaInfo( + no_exists = _tv_no_exists( + 1, + NotExistMediaInfo( season=1, episodes=[], total_episode=143, start_episode=1, require_complete_coverage=True, - ) - } - } + ), + ) downloads, lefts = chain.batch_download(contexts=[context], no_exists=no_exists) @@ -999,17 +1042,16 @@ def test_batch_download_rejects_complete_coverage_when_title_episodes_are_partia chain.download_single = MagicMock(return_value="hash") context = _build_tv_context(episode_list=list(range(1, 14))) - no_exists = { - 1: { - 1: NotExistMediaInfo( + no_exists = _tv_no_exists( + 1, + NotExistMediaInfo( season=1, episodes=[], total_episode=143, start_episode=1, require_complete_coverage=True, - ) - } - } + ), + ) downloads, lefts = chain.batch_download(contexts=[context], no_exists=no_exists) @@ -1034,17 +1076,16 @@ def test_batch_download_complete_coverage_ignores_allowed_episode_narrowing(monk context = _build_tv_context(episode_list=[1, 2]) context.allowed_episodes = {1, 2} - no_exists = { - 1: { - 1: NotExistMediaInfo( + no_exists = _tv_no_exists( + 1, + NotExistMediaInfo( season=1, episodes=[], total_episode=12, start_episode=1, require_complete_coverage=True, - ) - } - } + ), + ) downloads, lefts = chain.batch_download(contexts=[context], no_exists=no_exists) @@ -1068,16 +1109,15 @@ def test_batch_download_keeps_count_check_without_complete_coverage(monkeypatch) chain.download_single = MagicMock(return_value="hash") context = _build_tv_context() - no_exists = { - 1: { - 1: NotExistMediaInfo( + no_exists = _tv_no_exists( + 1, + NotExistMediaInfo( season=1, episodes=[], total_episode=143, start_episode=1, - ) - } - } + ), + ) downloads, lefts = chain.batch_download(contexts=[context], no_exists=no_exists) @@ -1098,7 +1138,8 @@ def test_downloading_includes_media_type_and_source_site(monkeypatch): poster="https://images.example.com/poster.jpg", seasons="S01", title="示例剧集", - tmdbid=1001, + media_source=MediaSource.TMDB.value, + media_id="1001", torrent_site="示例站点", type="电视剧", userid="user-1", diff --git a/tests/test_download_media_source.py b/tests/test_download_media_source.py index 7b84b3c0b..07c596c94 100644 --- a/tests/test_download_media_source.py +++ b/tests/test_download_media_source.py @@ -3,7 +3,7 @@ from types import SimpleNamespace from app import schemas from app.api.endpoints import download as download_endpoint from app.core.context import MediaInfo -from app.schemas.types import MediaType +from app.schemas.types import MediaSource, MediaType def test_download_add_passes_generic_media_source(monkeypatch) -> None: @@ -44,44 +44,21 @@ def test_download_add_passes_generic_media_source(monkeypatch) -> None: ) assert response.success is True - assert captured["recognize"]["source"] == "anilist" - assert captured["recognize"]["mediaid"] == "154587" + assert captured["recognize"]["media_source"] == MediaSource.AniList + assert captured["recognize"]["media_id"] == "154587" assert captured["download"]["context"].media_info is media -def test_download_add_uses_selected_source_for_title_recognition(monkeypatch) -> None: - """只选择来源而未填写ID时应在该来源内按标题识别。""" - captured = {} - media = MediaInfo(title="测试动画", type=MediaType.TV, bangumi_id=1) - - class FakeMediaChain: - """记录按标题识别的请求级来源。""" - - def recognize_by_meta(self, metainfo, **kwargs): - """返回固定媒体信息并保存来源。""" - captured["metainfo"] = metainfo - captured["kwargs"] = kwargs - return media - - class FakeDownloadChain: - """模拟下载任务提交。""" - - @staticmethod - def download_single(**kwargs): - """返回固定下载任务ID。""" - return "download-2" - - monkeypatch.setattr(download_endpoint, "MediaChain", FakeMediaChain) - monkeypatch.setattr(download_endpoint, "DownloadChain", FakeDownloadChain) - +def test_download_add_rejects_source_without_media_id() -> None: + """显式媒体来源必须和原生 ID 成对提供,不能退回标题猜测。""" response = download_endpoint.add( torrent_in=schemas.TorrentInfo(title="测试动画 S01E01"), media_source="bangumi", current_user=SimpleNamespace(name="tester"), ) - assert response.success is True - assert captured["kwargs"]["source"] == "bangumi" + assert response.success is False + assert response.message == "媒体来源和媒体 ID 必须同时提供" def test_subtitle_download_passes_generic_media_source(monkeypatch) -> None: diff --git a/tests/test_download_save_path_allowlist.py b/tests/test_download_save_path_allowlist.py index 73bd64976..3ea1bd66b 100644 --- a/tests/test_download_save_path_allowlist.py +++ b/tests/test_download_save_path_allowlist.py @@ -15,7 +15,7 @@ from app.core.context import Context, MediaInfo, SubtitleInfo, TorrentInfo from app.core.metainfo import MetaInfo from app.helper.directory import validate_download_save_path from app.schemas import DownloaderTorrent, TransferDirectoryConf -from app.schemas.types import MediaType +from app.schemas.types import MediaSource, MediaType @pytest.fixture(autouse=True) @@ -547,6 +547,8 @@ def test_download_subtitle_returns_specific_error_for_bad_save_path(monkeypatch) success, message, saved_files = chain.download_subtitle( subtitle=subtitle, + media_source=MediaSource.TMDB, + media_id="1", save_path="/etc", ) diff --git a/tests/test_episode_group_recognition.py b/tests/test_episode_group_recognition.py index dcc90c47d..fba4877fe 100644 --- a/tests/test_episode_group_recognition.py +++ b/tests/test_episode_group_recognition.py @@ -11,7 +11,7 @@ sys.modules.setdefault("psutil", ModuleType("psutil")) from app.chain import ChainBase from app.core.context import MediaInfo from app.core.meta import MetaBase -from app.schemas.types import MediaType +from app.schemas.types import MediaSource, MediaType def test_recognize_media_uses_meta_episode_group(): @@ -24,7 +24,14 @@ def test_recognize_media_uses_meta_episode_group(): meta.name = "测试剧集" meta.type = MediaType.TV meta.episode_group = group_id - mediainfo = MediaInfo(title="测试剧集", year="2024", tmdb_id=100, type=MediaType.TV) + mediainfo = MediaInfo( + media_source=MediaSource.TMDB, + media_id="100", + title="测试剧集", + year="2024", + tmdb_id=100, + type=MediaType.TV, + ) with patch.object(chain, "run_module", return_value=mediainfo) as run_module, patch( "app.chain.MoviePilotServerHelper.report_recognize_share", diff --git a/tests/test_listenbrainz_module.py b/tests/test_listenbrainz_module.py index cc06ab78d..89e7e672a 100644 --- a/tests/test_listenbrainz_module.py +++ b/tests/test_listenbrainz_module.py @@ -15,7 +15,7 @@ def test_recording_to_info_maps_listenbrainz_payload(): ) assert info is not None - assert info.source == "musicbrainz" + assert info.media_source.value == "musicbrainz" assert info.media_id == "recording-1" assert info.artists == ["Daft Punk"] assert info.album == "Random Access Memories" diff --git a/tests/test_media_identity_cleanup_migration.py b/tests/test_media_identity_cleanup_migration.py index 3b99d7790..6a60c2361 100644 --- a/tests/test_media_identity_cleanup_migration.py +++ b/tests/test_media_identity_cleanup_migration.py @@ -1,5 +1,6 @@ import importlib +import pytest import sqlalchemy as sa from alembic.migration import MigrationContext from alembic.operations import Operations @@ -10,6 +11,28 @@ LEGACY_COLUMNS = { "anilistid", "mediaid", } +PREFIXED_IDENTITIES = ( + ("tmdb", "themoviedb"), + ("themoviedb", "themoviedb"), + ("douban", "douban"), + ("bangumi", "bangumi"), + ("anilist", "anilist"), + ("imdb", "imdb"), + ("tvdb", "tvdb"), + ("musicbrainz", "musicbrainz"), + ("theaudiodb", "theaudiodb"), + ("audio_db", "theaudiodb"), + ("doubanmusic", "doubanmusic"), + ("douban_music", "doubanmusic"), + ("bilibili", "bilibili"), + ("mangguodiscover", "mangguodiscover"), + ("mango_tv", "mangguodiscover"), + ("migu", "migu"), + ("migu_video", "migu"), + ("tencentvideodiscover", "tencentvideodiscover"), + ("tencent_video", "tencentvideodiscover"), +) + def _legacy_identity_columns(include_mediaid: bool = False) -> list[sa.Column]: """构造升级前来源专用媒体身份字段。""" @@ -27,7 +50,7 @@ def _legacy_identity_columns(include_mediaid: bool = False) -> list[sa.Column]: def _create_tables(connection) -> dict[str, sa.Table]: - """创建 3.0.1 升级前六张通用媒体表的最小结构。""" + """创建媒体身份清理升级前六张通用媒体表的最小结构。""" metadata = sa.MetaData() tables = { "subscribe": sa.Table( @@ -93,7 +116,7 @@ def test_cleanup_migration_keeps_one_complete_identity_and_drops_legacy_columns( ) -> None: """升级应修复半对身份、保留完整身份并删除六表全部专用列。""" migration = importlib.import_module( - "database.versions.8a4c7e1d2f90_3_0_1" + "database.versions.8a4c7e1d2f90_3_0_0" ) engine = sa.create_engine("sqlite://") @@ -124,6 +147,30 @@ def test_cleanup_migration_keeps_one_complete_identity_and_drops_legacy_columns( "tmdbid": 1396, "doubanid": "999999", }, + { + "id": 4, + "media_source": "plugin_source", + "media_id": "custom-1", + "tmdbid": None, + "doubanid": "35209731", + "mediaid": None, + }, + { + "id": 5, + "media_source": "themoviedb", + "media_id": "0", + "tmdbid": 0, + "doubanid": "1295644", + "mediaid": None, + }, + { + "id": 6, + "media_source": " MusicBrainz ", + "media_id": " release-group-1 ", + "tmdbid": None, + "doubanid": None, + "mediaid": None, + }, ]) connection.execute(tables["subscribehistory"].insert(), { "id": 1, "mediaid": "bangumi:400602", @@ -161,6 +208,12 @@ def test_cleanup_migration_keeps_one_complete_identity_and_drops_legacy_columns( for table in migrated.values(): assert {"media_source", "media_id"}.issubset(table.c.keys()) assert LEGACY_COLUMNS.isdisjoint(table.c.keys()) + for table_name in migrated: + constraint_names = { + constraint["name"] + for constraint in sa.inspect(connection).get_check_constraints(table_name) + } + assert f"ck_{table_name}_media_identity" in constraint_names assert (subscribe_rows[0]["media_source"], subscribe_rows[0]["media_id"]) == ( "themoviedb", "550", @@ -171,6 +224,15 @@ def test_cleanup_migration_keeps_one_complete_identity_and_drops_legacy_columns( assert (subscribe_rows[2]["media_source"], subscribe_rows[2]["media_id"]) == ( "themoviedb", "1396", ) + assert (subscribe_rows[3]["media_source"], subscribe_rows[3]["media_id"]) == ( + "douban", "35209731", + ) + assert (subscribe_rows[4]["media_source"], subscribe_rows[4]["media_id"]) == ( + "douban", "1295644", + ) + assert (subscribe_rows[5]["media_source"], subscribe_rows[5]["media_id"]) == ( + "musicbrainz", "release-group-1", + ) assert identities["subscribehistory"]["media_source"] == "bangumi" assert identities["downloadhistory"]["media_source"] == "douban" assert identities["transferhistory"]["media_source"] == "anilist" @@ -178,10 +240,93 @@ def test_cleanup_migration_keeps_one_complete_identity_and_drops_legacy_columns( assert identities["mediaserveritem"]["media_source"] == "themoviedb" +def test_cleanup_migration_backfills_every_supported_mediaid_prefix(monkeypatch) -> None: + """升级应从历史组合字段回填全部规范来源及仍受支持的别名。""" + migration = importlib.import_module( + "database.versions.8a4c7e1d2f90_3_0_0" + ) + engine = sa.create_engine("sqlite://") + + with engine.begin() as connection: + tables = _create_tables(connection) + connection.execute( + tables["subscribe"].insert(), + [ + { + "id": index, + "mediaid": f"{prefix}:native-{index}", + } + for index, (prefix, _) in enumerate(PREFIXED_IDENTITIES, start=1) + ] + [{ + "id": len(PREFIXED_IDENTITIES) + 1, + "mediaid": "audioXdb:must-not-match-alias", + }], + ) + + monkeypatch.setattr(migration, "op", _operations(connection)) + migration.upgrade() + migrated = sa.Table( + "subscribe", sa.MetaData(), autoload_with=connection, + ) + rows = connection.execute( + sa.select( + migrated.c.id, + migrated.c.media_source, + migrated.c.media_id, + ).order_by(migrated.c.id) + ).mappings().all() + + assert [ + (row["media_source"], row["media_id"]) + for row in rows[:-1] + ] == [ + (source, f"native-{index}") + for index, (_, source) in enumerate(PREFIXED_IDENTITIES, start=1) + ] + assert (rows[-1]["media_source"], rows[-1]["media_id"]) == (None, None) + + +def test_cleanup_migration_rejects_invalid_database_identity_pairs(monkeypatch) -> None: + """升级后的数据库应拒绝半对、未知来源和零值身份。""" + migration = importlib.import_module( + "database.versions.8a4c7e1d2f90_3_0_0" + ) + engine = sa.create_engine("sqlite://") + + with engine.begin() as connection: + _create_tables(connection) + monkeypatch.setattr(migration, "op", _operations(connection)) + migration.upgrade() + subscribe = sa.Table( + "subscribe", sa.MetaData(), autoload_with=connection, + ) + for identity in ( + {"media_source": "themoviedb", "media_id": None}, + {"media_source": None, "media_id": "550"}, + {"media_source": "plugin_source", "media_id": "550"}, + {"media_source": "themoviedb", "media_id": "0"}, + ): + with pytest.raises(sa.exc.IntegrityError): + with connection.begin_nested(): + connection.execute(subscribe.insert(), { + "name": "invalid", + **identity, + }) + + connection.execute(subscribe.insert(), [ + {"name": "empty", "media_source": None, "media_id": None}, + { + "name": "valid", + "media_source": "musicbrainz", + "media_id": "release-group-1", + }, + ]) + + def test_cleanup_migration_downgrade_restores_legacy_schema(monkeypatch) -> None: """降级应恢复旧列并移除仅由本次迁移引入的媒体库身份列。""" migration = importlib.import_module( - "database.versions.8a4c7e1d2f90_3_0_1" + "database.versions.8a4c7e1d2f90_3_0_0" ) engine = sa.create_engine("sqlite://") diff --git a/tests/test_media_interaction.py b/tests/test_media_interaction.py index b5af3db55..b0972f48d 100644 --- a/tests/test_media_interaction.py +++ b/tests/test_media_interaction.py @@ -9,7 +9,7 @@ from app.core.context import Context, MediaInfo, TorrentInfo from app.core.meta import MetaBase from app.helper.interaction import media_interaction_manager, plugin_input_interaction_manager from app.schemas import CommingMessage, TransferDirectoryConf -from app.schemas.types import EventType, MediaType, MessageChannel +from app.schemas.types import EventType, MediaSource, MediaType, MessageChannel @pytest.fixture(autouse=True) @@ -143,6 +143,8 @@ def test_rebuild_download_scope_keeps_special_season_zero(): meta = _build_meta("测试剧") meta.begin_season = 0 mediainfo = MediaInfo( + media_source=MediaSource.TMDB, + media_id="1", type=MediaType.TV, title="测试剧", year="2026", diff --git a/tests/test_media_recognize_endpoint.py b/tests/test_media_recognize_endpoint.py index 2e0fb97c5..e8684181c 100644 --- a/tests/test_media_recognize_endpoint.py +++ b/tests/test_media_recognize_endpoint.py @@ -73,7 +73,7 @@ def test_recognize_musicbrainz_source_parses_music_meta() -> None: assert isinstance(metainfo, MetaMusic) assert metainfo.title == "晴天" assert metainfo.artists == ["周杰伦"] - assert chain.async_recognize_by_meta.await_args.kwargs.get("source") == "musicbrainz" + assert chain.async_recognize_by_meta.await_args.kwargs.get("media_source") == "musicbrainz" def test_recognize_audio_file_title_uses_music_meta_without_source() -> None: diff --git a/tests/test_media_recognize_modules.py b/tests/test_media_recognize_modules.py index cbcbafbd3..9c924b516 100644 --- a/tests/test_media_recognize_modules.py +++ b/tests/test_media_recognize_modules.py @@ -8,7 +8,7 @@ from app.modules.douban import DoubanModule from app.modules.themoviedb import TheMovieDbModule from app.modules.themoviedb.scraper import TmdbScraper from app.modules.themoviedb.tmdbapi import TmdbApi -from app.schemas.types import MediaType +from app.schemas.types import MediaSource, MediaType class MediaRecognizeModulesTest(TestCase): @@ -29,7 +29,12 @@ class MediaRecognizeModulesTest(TestCase): module.category = Mock() module.category.get_movie_category.return_value = None - result = module.recognize_media(meta=meta, tmdbid=100, cache=False) + result = module.recognize_media( + meta=meta, + media_source=MediaSource.TMDB, + media_id="100", + cache=False, + ) self.assertIsInstance(result, MediaInfo) self.assertEqual(result.tmdb_id, 100) @@ -57,7 +62,12 @@ class MediaRecognizeModulesTest(TestCase): module.category = Mock() module.category.get_movie_category.return_value = None - result = asyncio.run(module.async_recognize_media(meta=meta, tmdbid=101, cache=False)) + result = asyncio.run(module.async_recognize_media( + meta=meta, + media_source=MediaSource.TMDB, + media_id="101", + cache=False, + )) self.assertIsInstance(result, MediaInfo) self.assertEqual(result.tmdb_id, 101) diff --git a/tests/test_media_recognize_share.py b/tests/test_media_recognize_share.py index 7ab75155d..d7bb27172 100644 --- a/tests/test_media_recognize_share.py +++ b/tests/test_media_recognize_share.py @@ -12,7 +12,7 @@ from app.core.context import MediaInfo, MusicInfo from app.core.meta import MetaBase, MetaMusic from app.core.metainfo import MetaInfo from app.helper.server import MoviePilotServerHelper -from app.schemas.types import MediaType +from app.schemas.types import MediaSource, MediaType def _build_meta(name: str, media_type: MediaType = MediaType.UNKNOWN) -> MetaBase: @@ -23,11 +23,28 @@ def _build_meta(name: str, media_type: MediaType = MediaType.UNKNOWN) -> MetaBas return meta +def _tmdb_media( + title: str, + media_id: int, + media_type: MediaType, + **kwargs, +) -> MediaInfo: + """构造同时带规范主身份和 TMDB 辅助元数据的测试媒体。""" + return MediaInfo( + media_source=MediaSource.TMDB, + media_id=str(media_id), + tmdb_id=media_id, + title=title, + type=media_type, + **kwargs, + ) + + def test_report_shared_result_after_local_recognize_success(): """本地识别成功后应上报共享识别结果。""" chain = ChainBase() meta = _build_meta("测试电影", MediaType.MOVIE) - mediainfo = MediaInfo(title="测试电影", year="2024", tmdb_id=100, type=MediaType.MOVIE) + mediainfo = _tmdb_media("测试电影", 100, MediaType.MOVIE, year="2024") with patch.object(chain, "run_module", return_value=mediainfo) as run_module, patch( "app.chain.MoviePilotServerHelper.report_recognize_share", @@ -47,7 +64,7 @@ def test_query_shared_result_when_local_recognize_failed(): """本地识别失败后应回查共享识别结果,并按共享ID再次识别。""" chain = ChainBase() meta = _build_meta("测试剧集") - shared_media = MediaInfo(title="测试剧集", year="2024", tmdb_id=200, type=MediaType.TV) + shared_media = _tmdb_media("测试剧集", 200, MediaType.TV, year="2024") with patch.object( chain, @@ -55,14 +72,18 @@ def test_query_shared_result_when_local_recognize_failed(): side_effect=[None, shared_media], ) as run_module, patch( "app.chain.MoviePilotServerHelper.query_recognize_share", - return_value={"type": "tv", "tmdbid": 200, "season": 1}, + return_value={ + "type": "tv", + "media_source": "themoviedb", + "media_id": "200", + "season": 1, + }, ) as query_mock, patch( "app.chain.MoviePilotServerHelper.to_recognize_params", return_value={ "mtype": MediaType.TV, - "tmdbid": 200, - "doubanid": None, - "bangumiid": None, + "media_source": MediaSource.TMDB, + "media_id": "200", "season": 1, }, ), patch( @@ -78,7 +99,8 @@ def test_query_shared_result_when_local_recognize_failed(): assert run_module.call_count == 2 query_mock.assert_called_once_with(meta=meta, mtype=None, keyword_meta=meta) second_call = run_module.call_args_list[1] - assert second_call.kwargs["tmdbid"] == 200 + assert second_call.kwargs["media_source"] == MediaSource.TMDB + assert second_call.kwargs["media_id"] == "200" assert second_call.kwargs["mtype"] == MediaType.TV assert meta.begin_season is None @@ -87,7 +109,7 @@ def test_async_query_shared_result_when_local_recognize_failed(): """异步识别失败后也应回查共享识别结果。""" chain = ChainBase() meta = _build_meta("测试异步剧集") - shared_media = MediaInfo(title="测试异步剧集", year="2025", tmdb_id=300, type=MediaType.TV) + shared_media = _tmdb_media("测试异步剧集", 300, MediaType.TV, year="2025") async_run_module = AsyncMock(side_effect=[None, shared_media]) async def runner(): @@ -97,14 +119,18 @@ def test_async_query_shared_result_when_local_recognize_failed(): async_run_module, ), patch( "app.chain.MoviePilotServerHelper.async_query_recognize_share", - AsyncMock(return_value={"type": "tv", "tmdbid": 300, "season": 2}), + AsyncMock(return_value={ + "type": "tv", + "media_source": "themoviedb", + "media_id": "300", + "season": 2, + }), ) as query_mock, patch( "app.chain.MoviePilotServerHelper.to_recognize_params", return_value={ "mtype": MediaType.TV, - "tmdbid": 300, - "doubanid": None, - "bangumiid": None, + "media_source": MediaSource.TMDB, + "media_id": "300", "season": 2, }, ), patch( @@ -132,11 +158,12 @@ def test_backfill_local_cache_after_shared_recognize_success(): chain = ChainBase() meta = _build_meta("测试缓存回填", MediaType.MOVIE) shared_media = MediaInfo( + media_source=MediaSource.TMDB, + media_id="700", title="测试缓存回填", year="2024", tmdb_id=700, type=MediaType.MOVIE, - source="themoviedb", tmdb_info={"id": 700, "media_type": MediaType.MOVIE, "title": "测试缓存回填"}, ) @@ -146,14 +173,17 @@ def test_backfill_local_cache_after_shared_recognize_success(): side_effect=[None, shared_media, None], ) as run_module_mock, patch( "app.chain.MoviePilotServerHelper.query_recognize_share", - return_value={"type": "movie", "tmdbid": 700}, + return_value={ + "type": "movie", + "media_source": "themoviedb", + "media_id": "700", + }, ), patch( "app.chain.MoviePilotServerHelper.to_recognize_params", return_value={ "mtype": MediaType.MOVIE, - "tmdbid": 700, - "doubanid": None, - "bangumiid": None, + "media_source": MediaSource.TMDB, + "media_id": "700", "season": None, }, ), patch( @@ -178,12 +208,8 @@ def test_query_and_report_prefer_original_name_keyword(): meta.original_name = "未应用识别词的名称" meta.year = "2024" meta.begin_season = 1 - mediainfo = MediaInfo( - title="测试剧集", - year="2024", - tmdb_id=400, - type=MediaType.TV, - season=1, + mediainfo = _tmdb_media( + "测试剧集", 400, MediaType.TV, year="2024", season=1 ) query_params = MoviePilotServerHelper._build_recognize_query_params(meta=meta) @@ -202,12 +228,8 @@ def test_query_and_report_can_use_distinct_keyword_meta(): keyword_meta = _build_meta("辅助识别前的名称", MediaType.UNKNOWN) keyword_meta.original_name = "辅助识别前的名称" - mediainfo = MediaInfo( - title="测试剧集", - year="2024", - tmdb_id=401, - type=MediaType.TV, - season=2, + mediainfo = _tmdb_media( + "测试剧集", 401, MediaType.TV, year="2024", season=2 ) query_params = MoviePilotServerHelper._build_recognize_query_params( @@ -233,7 +255,7 @@ def test_query_and_report_preserve_special_season_zero(): """共享识别查询和上报都必须保留显式特别季。""" meta = _build_meta("测试剧特别篇", MediaType.TV) meta.begin_season = 0 - mediainfo = MediaInfo(title="测试剧", tmdb_id=402, type=MediaType.TV, season=0) + mediainfo = _tmdb_media("测试剧", 402, MediaType.TV, season=0) query_params = MoviePilotServerHelper._build_recognize_query_params(meta=meta) report_payload = MoviePilotServerHelper._build_recognize_report_payload( @@ -262,7 +284,7 @@ def test_report_shared_result_with_distinct_keyword_meta(): meta.begin_season = 1 share_meta = _build_meta("辅助识别前的名称", MediaType.UNKNOWN) share_meta.original_name = "辅助识别前的名称" - mediainfo = MediaInfo(title="测试剧集", year="2024", tmdb_id=402, type=MediaType.TV) + mediainfo = _tmdb_media("测试剧集", 402, MediaType.TV, year="2024") with patch.object(chain, "run_module", return_value=mediainfo), patch( "app.chain.MoviePilotServerHelper.report_recognize_share", @@ -285,7 +307,7 @@ def test_query_shared_result_with_distinct_keyword_meta(): meta.year = "2024" share_meta = _build_meta("辅助识别前的名称", MediaType.UNKNOWN) share_meta.original_name = "辅助识别前的名称" - shared_media = MediaInfo(title="测试剧集", year="2024", tmdb_id=403, type=MediaType.TV) + shared_media = _tmdb_media("测试剧集", 403, MediaType.TV, year="2024") with patch.object( chain, @@ -293,14 +315,18 @@ def test_query_shared_result_with_distinct_keyword_meta(): side_effect=[None, shared_media], ), patch( "app.chain.MoviePilotServerHelper.query_recognize_share", - return_value={"type": "tv", "tmdbid": 403, "season": 1}, + return_value={ + "type": "tv", + "media_source": "themoviedb", + "media_id": "403", + "season": 1, + }, ) as query_mock, patch( "app.chain.MoviePilotServerHelper.to_recognize_params", return_value={ "mtype": MediaType.TV, - "tmdbid": 403, - "doubanid": None, - "bangumiid": None, + "media_source": MediaSource.TMDB, + "media_id": "403", "season": 1, }, ), patch( @@ -328,7 +354,7 @@ def test_skip_report_when_local_recognize_hits_cache(): """本地识别命中缓存时不应上报共享识别。""" chain = ChainBase() meta = _build_meta("缓存电影", MediaType.MOVIE) - mediainfo = MediaInfo(title="缓存电影", year="2024", tmdb_id=500, type=MediaType.MOVIE) + mediainfo = _tmdb_media("缓存电影", 500, MediaType.MOVIE, year="2024") mediainfo.recognize_cache_hit = True with patch.object(chain, "run_module", return_value=mediainfo) as run_module, patch( @@ -349,7 +375,7 @@ def test_async_skip_report_when_local_recognize_hits_cache(): """异步本地识别命中缓存时不应上报共享识别。""" chain = ChainBase() meta = _build_meta("缓存剧集", MediaType.TV) - mediainfo = MediaInfo(title="缓存剧集", year="2025", tmdb_id=600, type=MediaType.TV) + mediainfo = _tmdb_media("缓存剧集", 600, MediaType.TV, year="2025") mediainfo.recognize_cache_hit = True async def runner(): @@ -379,7 +405,7 @@ def test_recognize_by_meta_can_skip_obtain_images(): """标题识别可显式关闭图片拉取。""" media_chain = MediaChain() meta = MetaInfo("测试电影") - mediainfo = MediaInfo(title="测试电影", year="2024", tmdb_id=404, type=MediaType.MOVIE) + mediainfo = _tmdb_media("测试电影", 404, MediaType.MOVIE, year="2024") with patch.object( media_chain, @@ -400,7 +426,7 @@ def test_recognize_by_meta_reports_with_original_keyword_after_plugin_help(): """辅助识别后应继续使用辅助前关键字进行共享上报。""" media_chain = MediaChain() meta = MetaInfo("辅助前名称") - plugin_media = MediaInfo(title="辅助后名称", year="2024", tmdb_id=405, type=MediaType.TV) + plugin_media = _tmdb_media("辅助后名称", 405, MediaType.TV, year="2024") with patch.object( media_chain, @@ -424,7 +450,7 @@ def test_async_recognize_by_meta_can_skip_obtain_images(): """异步标题识别可显式关闭图片拉取。""" media_chain = MediaChain() meta = MetaInfo("测试异步电影") - mediainfo = MediaInfo(title="测试异步电影", year="2025", tmdb_id=406, type=MediaType.MOVIE) + mediainfo = _tmdb_media("测试异步电影", 406, MediaType.MOVIE, year="2025") async def runner(): with patch.object( @@ -462,7 +488,7 @@ def _music_info() -> MusicInfo: def test_music_report_payload_includes_source_and_media_id(): - """音乐上报载荷应携带 music 类型与数据源原生身份,其余媒体 ID 恒为 None。""" + """音乐上报载荷应只携带统一来源、原生身份和音乐实体类型。""" meta = MetaMusic(title="晴天", artists=["周杰伦"], album="叶惠美", year=2003) payload = MoviePilotServerHelper._build_recognize_report_payload( meta=meta, @@ -474,8 +500,8 @@ def test_music_report_payload_includes_source_and_media_id(): assert payload["title"] == "晴天" assert payload["year"] == "2003" assert payload["season"] is None - assert payload["tmdbid"] is None - assert payload["doubanid"] is None + assert "tmdbid" not in payload + assert "doubanid" not in payload assert payload["media_source"] == "musicbrainz" assert payload["media_id"] == "recording-1" assert payload["music_type"] == "recording" @@ -529,8 +555,8 @@ def test_music_shared_identity_keeps_entity_type(): }) assert params["mtype"] == MediaType.MUSIC - assert params["source"] == "musicbrainz" - assert params["mediaid"] == "release-group-1" + assert params["media_source"] == MediaSource.MusicBrainz + assert params["media_id"] == "release-group-1" assert params["music_type"] == "album" @@ -551,7 +577,7 @@ def test_chain_recognize_media_reports_music_share_result(): meta = MetaMusic(title="晴天", artists=["周杰伦"], year=2003) music = _music_info() - with patch("app.chain.music.MusicChain.recognize_best", return_value=music), patch( + with patch.object(chain, "recognize_music_from_source", return_value=music), patch( "app.chain.MoviePilotServerHelper.report_recognize_share", return_value=True, ) as report_mock, patch( @@ -570,12 +596,10 @@ def test_chain_recognize_media_queries_music_share_when_local_failed(): meta = MetaMusic(title="晴天", artists=["周杰伦"]) music = _music_info() - with patch( - "app.chain.music.MusicChain.recognize_best", - return_value=None, - ) as recognize_best, patch( - "app.chain.music.MusicChain.recognize_from_source", - return_value=music, + with patch.object( + chain, + "recognize_music_from_source", + side_effect=[None, music], ) as recognize_source, patch( "app.chain.MoviePilotServerHelper.query_recognize_share", return_value={ @@ -588,13 +612,9 @@ def test_chain_recognize_media_queries_music_share_when_local_failed(): "app.chain.MoviePilotServerHelper.to_recognize_params", return_value={ "mtype": MediaType.MUSIC, - "source": "musicbrainz", - "mediaid": "recording-1", + "media_source": MediaSource.MusicBrainz, + "media_id": "recording-1", "music_type": "recording", - "tmdbid": None, - "doubanid": None, - "bangumiid": None, - "anilistid": None, "season": None, }, ), patch( @@ -607,11 +627,11 @@ def test_chain_recognize_media_queries_music_share_when_local_failed(): result = chain.recognize_media(meta=meta, cache=False) assert result is music - recognize_best.assert_called_once_with(meta=meta, cache=False) - recognize_source.assert_called_once_with( + assert recognize_source.call_count == 2 + recognize_source.assert_called_with( media_source="musicbrainz", meta=meta, - mediaid="recording-1", + media_id="recording-1", cache=False, music_type="recording", ) @@ -624,12 +644,10 @@ def test_chain_recognize_media_queries_music_share_after_local_fallback(): fallback = MusicInfo(title="晴天", artists=["周杰伦"]) music = _music_info() - with patch( - "app.chain.music.MusicChain.recognize_best", - return_value=fallback, - ), patch( - "app.chain.music.MusicChain.recognize_from_source", - return_value=music, + with patch.object( + chain, + "recognize_music_from_source", + side_effect=[fallback, music], ) as recognize_source, patch( "app.chain.MoviePilotServerHelper.query_recognize_share", return_value={ @@ -642,13 +660,9 @@ def test_chain_recognize_media_queries_music_share_after_local_fallback(): "app.chain.MoviePilotServerHelper.to_recognize_params", return_value={ "mtype": MediaType.MUSIC, - "source": "musicbrainz", - "mediaid": "recording-1", + "media_source": MediaSource.MusicBrainz, + "media_id": "recording-1", "music_type": "recording", - "tmdbid": None, - "doubanid": None, - "bangumiid": None, - "anilistid": None, "season": None, }, ), patch.object( @@ -666,7 +680,7 @@ def test_chain_recognize_media_queries_music_share_after_local_fallback(): mtype=MediaType.MUSIC, keyword_meta=meta, ) - recognize_source.assert_called_once() + assert recognize_source.call_count == 2 def test_chain_async_recognize_media_queries_music_share_after_local_fallback(): @@ -677,12 +691,10 @@ def test_chain_async_recognize_media_queries_music_share_after_local_fallback(): music = _music_info() async def runner(): - with patch( - "app.chain.music.MusicChain.async_recognize_best", - new=AsyncMock(return_value=fallback), - ), patch( - "app.chain.music.MusicChain.async_recognize_from_source", - new=AsyncMock(return_value=music), + with patch.object( + chain, + "async_recognize_music_from_source", + new=AsyncMock(side_effect=[fallback, music]), ) as recognize_source, patch( "app.chain.MoviePilotServerHelper.async_query_recognize_share", new=AsyncMock(return_value={ @@ -695,13 +707,9 @@ def test_chain_async_recognize_media_queries_music_share_after_local_fallback(): "app.chain.MoviePilotServerHelper.to_recognize_params", return_value={ "mtype": MediaType.MUSIC, - "source": "musicbrainz", - "mediaid": "recording-1", + "media_source": MediaSource.MusicBrainz, + "media_id": "recording-1", "music_type": "recording", - "tmdbid": None, - "doubanid": None, - "bangumiid": None, - "anilistid": None, "season": None, }, ), patch.object( @@ -723,10 +731,11 @@ def test_chain_async_recognize_media_queries_music_share_after_local_fallback(): mtype=MediaType.MUSIC, keyword_meta=meta, ) - recognize_source.assert_awaited_once_with( + assert recognize_source.await_count == 2 + recognize_source.assert_awaited_with( media_source="musicbrainz", meta=meta, - mediaid="recording-1", + media_id="recording-1", cache=False, music_type="recording", ) @@ -738,7 +747,7 @@ def test_chain_recognize_media_skips_music_report_for_fallback_result(): meta = MetaMusic(title="未知曲目", artists=["未知艺术家"]) fallback = MusicInfo(title="未知曲目", artists=["未知艺术家"]) - with patch("app.chain.music.MusicChain.recognize_best", return_value=fallback), patch( + with patch.object(chain, "recognize_music_from_source", return_value=fallback), patch( "app.chain.MoviePilotServerHelper.query_recognize_share", return_value=None, ) as query_mock, patch( diff --git a/tests/test_media_recognize_share_statistics.py b/tests/test_media_recognize_share_statistics.py index 0d36e69a2..d42201b95 100644 --- a/tests/test_media_recognize_share_statistics.py +++ b/tests/test_media_recognize_share_statistics.py @@ -6,7 +6,7 @@ from app.chain import ChainBase from app.core.context import MediaInfo from app.core.meta import MetaBase from app.helper.server import MoviePilotServerHelper -from app.schemas.types import MediaType, SystemConfigKey +from app.schemas.types import MediaSource, MediaType, SystemConfigKey def _build_meta(name: str) -> MetaBase: @@ -21,12 +21,8 @@ def _shared_params(tmdb_id: int) -> dict: """构造共享识别结果转换后的模块参数。""" return { "mtype": MediaType.MOVIE, - "source": "themoviedb", - "mediaid": str(tmdb_id), - "tmdbid": tmdb_id, - "doubanid": None, - "bangumiid": None, - "anilistid": None, + "media_source": MediaSource.TMDB, + "media_id": str(tmdb_id), } @@ -52,6 +48,8 @@ def test_sync_shared_recognize_success_increments_persisted_count(monkeypatch): chain = _bare_chain() meta = _build_meta("共享识别电影") media = MediaInfo( + media_source=MediaSource.TMDB, + media_id="101", title="共享识别电影", year="2026", tmdb_id=101, @@ -64,7 +62,11 @@ def test_sync_shared_recognize_success_increments_persisted_count(monkeypatch): monkeypatch.setattr( MoviePilotServerHelper, "query_recognize_share", - Mock(return_value={"type": "movie", "tmdbid": 101}), + Mock(return_value={ + "type": "movie", + "media_source": "themoviedb", + "media_id": "101", + }), ) monkeypatch.setattr( MoviePilotServerHelper, @@ -88,7 +90,11 @@ def test_sync_shared_result_without_local_match_does_not_increment(monkeypatch): monkeypatch.setattr( MoviePilotServerHelper, "query_recognize_share", - Mock(return_value={"type": "movie", "tmdbid": 102}), + Mock(return_value={ + "type": "movie", + "media_source": "themoviedb", + "media_id": "102", + }), ) monkeypatch.setattr( MoviePilotServerHelper, @@ -107,6 +113,8 @@ def test_async_shared_recognize_success_increments_persisted_count(monkeypatch): chain = _bare_chain() meta = _build_meta("异步共享识别电影") media = MediaInfo( + media_source=MediaSource.TMDB, + media_id="103", title="异步共享识别电影", year="2026", tmdb_id=103, @@ -127,7 +135,11 @@ def test_async_shared_recognize_success_increments_persisted_count(monkeypatch): monkeypatch.setattr( MoviePilotServerHelper, "async_query_recognize_share", - AsyncMock(return_value={"type": "movie", "tmdbid": 103}), + AsyncMock(return_value={ + "type": "movie", + "media_source": "themoviedb", + "media_id": "103", + }), ) monkeypatch.setattr( MoviePilotServerHelper, diff --git a/tests/test_media_scrape_endpoint.py b/tests/test_media_scrape_endpoint.py index fb8d35a28..b4a54a085 100644 --- a/tests/test_media_scrape_endpoint.py +++ b/tests/test_media_scrape_endpoint.py @@ -1,10 +1,14 @@ from unittest.mock import AsyncMock, Mock, patch +import pytest + +from app.api.endpoints import media as media_endpoint from app.api.endpoints.media import recognize_file, scrape from app.core.context import Context, MediaInfo from app.core.meta import MetaBase, MetaMusic from app.core.context import MusicInfo from app.schemas import FileItem, MediaType +from app.schemas.types import MediaSource def test_scrape_uses_explicit_media_source_and_id() -> None: @@ -14,13 +18,15 @@ def test_scrape_uses_explicit_media_source_and_id() -> None: chain = Mock() chain.recognize_media.return_value = media_info - with patch("app.api.endpoints.media.MediaChain", return_value=chain) as mock_chain: + scraping_chain = Mock() + with patch("app.api.endpoints.media.MediaChain", return_value=chain) as mock_chain, \ + patch("app.api.endpoints.media.ScrapingChain", return_value=scraping_chain): # mkv 非音频文件,需显式关闭 Mock 的 is_audio_path 避免误入音乐分支 mock_chain.is_audio_path.return_value = False result = scrape( fileitem=fileitem, storage="alist", - media_source="douban", + media_source=MediaSource.Douban, media_id="123456", type_name=MediaType.MOVIE, _=Mock(), @@ -29,12 +35,12 @@ def test_scrape_uses_explicit_media_source_and_id() -> None: assert result.success is True chain.recognize_by_path.assert_not_called() recognize_kwargs = chain.recognize_media.call_args.kwargs - assert recognize_kwargs["source"] == "douban" - assert recognize_kwargs["mediaid"] == "123456" + assert recognize_kwargs["media_source"] == MediaSource.Douban + assert recognize_kwargs["media_id"] == "123456" assert recognize_kwargs["mtype"] == MediaType.MOVIE chain.obtain_images.assert_called_once_with(mediainfo=media_info) - assert media_info.scrape_source == "douban" - scrape_kwargs = chain.scrape_metadata.call_args.kwargs + assert media_info.scrape_source == MediaSource.Douban + scrape_kwargs = scraping_chain.scrape_metadata.call_args.kwargs assert scrape_kwargs["fileitem"] is fileitem assert scrape_kwargs["mediainfo"] is media_info assert scrape_kwargs["overwrite"] is True @@ -48,25 +54,27 @@ def test_scrape_keeps_automatic_recognition_compatible() -> None: chain = Mock() chain.recognize_by_path.return_value = Context(meta_info=meta_info, media_info=media_info) - with patch("app.api.endpoints.media.MediaChain", return_value=chain) as mock_chain: + scraping_chain = Mock() + with patch("app.api.endpoints.media.MediaChain", return_value=chain) as mock_chain, \ + patch("app.api.endpoints.media.ScrapingChain", return_value=scraping_chain): # mkv 非音频文件,需显式关闭 Mock 的 is_audio_path 避免误入音乐分支 mock_chain.is_audio_path.return_value = False result = scrape( fileitem=fileitem, storage="alist", - media_source="bangumi", + media_source=MediaSource.Bangumi, _=Mock(), ) assert result.success is True chain.recognize_by_path.assert_called_once_with( fileitem.path, - source="bangumi", + media_source=MediaSource.Bangumi, obtain_images=True, ) chain.recognize_media.assert_not_called() - assert media_info.scrape_source == "bangumi" - chain.scrape_metadata.assert_called_once_with( + assert media_info.scrape_source == MediaSource.Bangumi + scraping_chain.scrape_metadata.assert_called_once_with( fileitem=fileitem, meta=meta_info, mediainfo=media_info, @@ -87,6 +95,37 @@ def test_scrape_rejects_media_id_without_source() -> None: assert result.message == "指定媒体ID时必须同时指定媒体数据源" +def test_scrape_rejects_zero_media_id_before_recognition() -> None: + """刮削入口收到零值身份时不得进入识别或刮削链。""" + fileitem = FileItem(storage="local", path="/tmp/test.mkv", type="file") + media_chain = Mock(side_effect=AssertionError("零值身份不应创建媒体链")) + scraping_chain = Mock(side_effect=AssertionError("零值身份不应创建刮削链")) + + with patch("app.api.endpoints.media.MediaChain", media_chain), patch( + "app.api.endpoints.media.ScrapingChain", scraping_chain + ): + result = scrape( + fileitem=fileitem, + media_source=MediaSource.TMDB, + media_id="0", + type_name=MediaType.MOVIE, + _=Mock(), + ) + + assert result.success is False + assert result.message == "媒体ID格式无效" + media_chain.assert_not_called() + scraping_chain.assert_not_called() + + +@pytest.mark.parametrize("media_source", list(MediaSource)) +def test_source_media_id_validator_rejects_zero_for_every_source( + media_source: MediaSource, +) -> None: + """全部固定媒体来源都应把零值原生 ID 视为无效身份。""" + assert not media_endpoint._is_valid_source_media_id(media_source, "0") + + def test_recognize_file_routes_audio_to_music_chain() -> None: """文件管理识别音频文件时应经统一路径识别入口返回音乐专属上下文。""" chain = Mock() @@ -110,7 +149,7 @@ def test_recognize_file_routes_audio_to_music_chain() -> None: assert result["meta_info"]["type"] == "音乐" assert result["media_info"]["title"] == "晴天" chain.async_recognize_by_path.assert_awaited_once_with( - "/music/晴天.flac", source=None + "/music/晴天.flac", media_source=None ) @@ -124,9 +163,11 @@ def test_scrape_music_uses_musicbrainz_uuid_and_music_scraper() -> None: ) media_chain = Mock() media_chain.recognize_media.return_value = info - media_chain.scrape_music_metadata.return_value = (True, "已刮削 1 个音频文件") + scraping_chain = Mock() + scraping_chain.scrape_music_metadata.return_value = (True, "已刮削 1 个音频文件") - with patch("app.api.endpoints.media.MediaChain", return_value=media_chain): + with patch("app.api.endpoints.media.MediaChain", return_value=media_chain), \ + patch("app.api.endpoints.media.ScrapingChain", return_value=scraping_chain): result = scrape( fileitem=fileitem, storage="local", @@ -140,11 +181,11 @@ def test_scrape_music_uses_musicbrainz_uuid_and_music_scraper() -> None: assert result.success is True media_chain.recognize_media.assert_called_once_with( media_source="musicbrainz", - mediaid="977e6978-139d-425c-bb98-6b0c62d1e45e", + media_id="977e6978-139d-425c-bb98-6b0c62d1e45e", mtype=MediaType.MUSIC, music_type="recording", ) - media_chain.scrape_music_metadata.assert_called_once_with( + scraping_chain.scrape_music_metadata.assert_called_once_with( fileitem=fileitem, mediainfo=info, overwrite=True, @@ -156,9 +197,11 @@ def test_scrape_music_without_source_keeps_automatic_recognition() -> None: """未选择音乐源时刮削入口应传递空来源,让底层比较全部识别源。""" fileitem = FileItem(storage="local", path="/music/晴天.flac", type="file") media_chain = Mock() - media_chain.scrape_music_metadata.return_value = (True, "已刮削 1 个音频文件") + scraping_chain = Mock() + scraping_chain.scrape_music_metadata.return_value = (True, "已刮削 1 个音频文件") - with patch("app.api.endpoints.media.MediaChain", return_value=media_chain): + with patch("app.api.endpoints.media.MediaChain", return_value=media_chain), \ + patch("app.api.endpoints.media.ScrapingChain", return_value=scraping_chain): result = scrape( fileitem=fileitem, storage="local", @@ -167,11 +210,11 @@ def test_scrape_music_without_source_keeps_automatic_recognition() -> None: ) assert result.success is True - media_chain.scrape_music_metadata.assert_called_once_with( + scraping_chain.scrape_music_metadata.assert_called_once_with( fileitem=fileitem, mediainfo=None, overwrite=True, - source=None, + media_source=None, ) @@ -186,9 +229,11 @@ def test_scrape_music_album_forwards_album_namespace() -> None: ) media_chain = Mock() media_chain.recognize_media.return_value = info - media_chain.scrape_music_metadata.return_value = (True, "已刮削专辑") + scraping_chain = Mock() + scraping_chain.scrape_music_metadata.return_value = (True, "已刮削专辑") - with patch("app.api.endpoints.media.MediaChain", return_value=media_chain): + with patch("app.api.endpoints.media.MediaChain", return_value=media_chain), \ + patch("app.api.endpoints.media.ScrapingChain", return_value=scraping_chain): result = scrape( fileitem=fileitem, storage="local", @@ -214,9 +259,11 @@ def test_scrape_music_accepts_douban_recording_composite_id() -> None: ) media_chain = Mock() media_chain.recognize_media.return_value = info - media_chain.scrape_music_metadata.return_value = (True, "已刮削 1 个音频文件") + scraping_chain = Mock() + scraping_chain.scrape_music_metadata.return_value = (True, "已刮削 1 个音频文件") - with patch("app.api.endpoints.media.MediaChain", return_value=media_chain): + with patch("app.api.endpoints.media.MediaChain", return_value=media_chain), \ + patch("app.api.endpoints.media.ScrapingChain", return_value=scraping_chain): result = scrape( fileitem=fileitem, storage="local", @@ -228,4 +275,4 @@ def test_scrape_music_accepts_douban_recording_composite_id() -> None: ) assert result.success is True - assert media_chain.recognize_media.call_args.kwargs["mediaid"] == "1401853:3" + assert media_chain.recognize_media.call_args.kwargs["media_id"] == "1401853:3" diff --git a/tests/test_media_search_source_selection.py b/tests/test_media_search_source_selection.py index bdf88e1f5..622d0c26e 100644 --- a/tests/test_media_search_source_selection.py +++ b/tests/test_media_search_source_selection.py @@ -11,18 +11,18 @@ from app.chain import ChainBase from app.core.security import verify_token from app.modules.douban import DoubanModule from app.modules.themoviedb import TheMovieDbModule -from app.schemas.types import MediaType +from app.schemas.types import MediaSource, MediaType @pytest.mark.parametrize( ("search_type", "method_name", "source"), [ - ("collection", "async_search_collections", "themoviedb"), - ("person", "async_search_persons", "douban"), + ("collection", "async_search_collections", MediaSource.TMDB), + ("person", "async_search_persons", MediaSource.Douban), ], ) def test_media_search_endpoint_forwards_source( - search_type: str, method_name: str, source: str + search_type: str, method_name: str, source: MediaSource ) -> None: """媒体搜索接口应将合集和人物的数据源下传到处理链。""" chain = Mock() @@ -34,17 +34,19 @@ def test_media_search_endpoint_forwards_source( search( title="测试", type=search_type, - media_source=source, + media_source=source.value, _=Mock(), ) ) assert result == [] - search_method.assert_awaited_once_with(name="测试", media_source=source) + search_method.assert_awaited_once_with( + name="测试", media_source=(source,) + ) def test_media_search_endpoint_forwards_multi_source() -> None: - """媒体搜索接口应将逗号分隔的多数据源原样下传到处理链。""" + """媒体搜索接口应将逗号分隔来源解析为固定枚举元组。""" chain = Mock() chain.async_search = AsyncMock(return_value=(Mock(), [])) @@ -60,20 +62,21 @@ def test_media_search_endpoint_forwards_multi_source() -> None: assert result == [] chain.async_search.assert_awaited_once_with( - title="测试", media_source="themoviedb,douban" + title="测试", + media_source=(MediaSource.TMDB, MediaSource.Douban), ) @pytest.mark.anyio async def test_media_search_route_accepts_comma_separated_music_sources() -> None: - """真实 FastAPI 路由应接受逗号分隔的数据源,而不能在参数校验阶段返回 422。""" + """真实路由在旧逗号格式兼容边界后应把每项转换为固定枚举。""" chain = Mock() - chain.async_search = AsyncMock(return_value=[]) + chain.async_search_music = AsyncMock(return_value=[]) app = FastAPI() app.include_router(media_endpoints.router, prefix="/api/v1/media") app.dependency_overrides[verify_token] = lambda: Mock() - with patch("app.api.endpoints.media.MusicChain", return_value=chain): + with patch("app.api.endpoints.media.MediaChain", return_value=chain): async with httpx.AsyncClient( transport=httpx.ASGITransport(app=app), base_url="http://testserver", @@ -90,13 +93,104 @@ async def test_media_search_route_accepts_comma_separated_music_sources() -> Non assert response.status_code == 200 assert response.json() == [] - chain.async_search.assert_awaited_once_with( + chain.async_search_music.assert_awaited_once_with( query="周杰伦", limit=30, - media_source="musicbrainz,theaudiodb,doubanmusic", + media_source=( + MediaSource.MusicBrainz, + MediaSource.TheAudioDB, + MediaSource.DoubanMusic, + ), ) +@pytest.mark.anyio +async def test_media_search_route_accepts_repeated_enum_sources() -> None: + """新客户端可用重复查询参数传入多个媒体来源枚举。""" + chain = Mock() + chain.async_search = AsyncMock(return_value=(Mock(), [])) + app = FastAPI() + app.include_router(media_endpoints.router, prefix="/api/v1/media") + app.dependency_overrides[verify_token] = lambda: Mock() + + with patch("app.api.endpoints.media.MediaChain", return_value=chain): + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://testserver", + ) as client: + response = await client.get( + "/api/v1/media/search", + params=[ + ("title", "测试"), + ("media_source", MediaSource.TMDB.value), + ("media_source", MediaSource.Douban.value), + ], + ) + + assert response.status_code == 200 + chain.async_search.assert_awaited_once_with( + title="测试", + media_source=(MediaSource.TMDB, MediaSource.Douban), + ) + + +@pytest.mark.anyio +async def test_media_search_route_deduplicates_repeated_sources() -> None: + """重复来源参数应按首次出现顺序去重,避免同一模块重复搜索。""" + chain = Mock() + chain.async_search = AsyncMock(return_value=(Mock(), [])) + app = FastAPI() + app.include_router(media_endpoints.router, prefix="/api/v1/media") + app.dependency_overrides[verify_token] = lambda: Mock() + + with patch("app.api.endpoints.media.MediaChain", return_value=chain): + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://testserver", + ) as client: + response = await client.get( + "/api/v1/media/search", + params=[ + ("title", "测试"), + ("media_source", MediaSource.Douban.value), + ("media_source", MediaSource.TMDB.value), + ("media_source", MediaSource.Douban.value), + ], + ) + + assert response.status_code == 200 + chain.async_search.assert_awaited_once_with( + title="测试", + media_source=(MediaSource.Douban, MediaSource.TMDB), + ) + + +@pytest.mark.anyio +async def test_media_search_route_rejects_unknown_source() -> None: + """真实搜索路由应在进入处理链前拒绝固定枚举之外的数据源。""" + chain = Mock() + chain.async_search = AsyncMock(return_value=(Mock(), [])) + app = FastAPI() + app.include_router(media_endpoints.router, prefix="/api/v1/media") + app.dependency_overrides[verify_token] = lambda: Mock() + + with patch("app.api.endpoints.media.MediaChain", return_value=chain): + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://testserver", + ) as client: + response = await client.get( + "/api/v1/media/search", + params={"title": "测试", "media_source": "plugin-source"}, + ) + + assert response.status_code == 422 + detail = response.json()["detail"][0] + assert detail["type"] == "enum" + assert detail["input"] == "plugin-source" + chain.async_search.assert_not_awaited() + + @pytest.mark.parametrize( ("method_name", "module_method_name"), [ @@ -115,7 +209,7 @@ def test_chain_forwards_source_to_modules( getattr(ChainBase, method_name)( chain, name="测试", - media_source="themoviedb", + media_source=MediaSource.TMDB, ) ) @@ -123,7 +217,7 @@ def test_chain_forwards_source_to_modules( chain.async_run_module.assert_awaited_once_with( module_method_name, name="测试", - media_source="themoviedb", + media_source=MediaSource.TMDB, ) @@ -135,10 +229,10 @@ def test_tmdb_person_search_respects_explicit_source(monkeypatch) -> None: module.tmdb.async_search_persons = AsyncMock(return_value=[]) skipped = asyncio.run( - module.async_search_persons(name="测试", media_source="douban") + module.async_search_persons(name="测试", media_source=MediaSource.Douban) ) result = asyncio.run( - module.async_search_persons(name="测试", media_source="themoviedb") + module.async_search_persons(name="测试", media_source=MediaSource.TMDB) ) assert skipped is None @@ -156,10 +250,10 @@ def test_douban_person_search_respects_explicit_source(monkeypatch) -> None: module.doubanapi.async_person_search = AsyncMock(return_value={}) skipped = asyncio.run( - module.async_search_persons(name="测试", media_source="themoviedb") + module.async_search_persons(name="测试", media_source=MediaSource.TMDB) ) result = asyncio.run( - module.async_search_persons(name="测试", media_source="douban") + module.async_search_persons(name="测试", media_source=MediaSource.Douban) ) assert skipped is None @@ -174,7 +268,7 @@ def test_tmdb_collection_search_rejects_unsupported_source() -> None: module.tmdb.async_search_collections = AsyncMock(return_value=[]) result = asyncio.run( - module.async_search_collections(name="测试", media_source="douban") + module.async_search_collections(name="测试", media_source=MediaSource.Douban) ) assert result is None @@ -182,14 +276,15 @@ def test_tmdb_collection_search_rejects_unsupported_source() -> None: def test_tmdb_collection_search_supports_multi_source_request() -> None: - """TMDB合集搜索应支持请求级逗号分隔多数据源。""" + """TMDB合集搜索应支持请求级多数据源枚举元组。""" module = TheMovieDbModule() module.tmdb = Mock() module.tmdb.async_search_collections = AsyncMock(return_value=[]) result = asyncio.run( module.async_search_collections( - name="测试", media_source="themoviedb,douban" + name="测试", + media_source=(MediaSource.TMDB, MediaSource.Douban), ) ) @@ -198,7 +293,7 @@ def test_tmdb_collection_search_supports_multi_source_request() -> None: def test_tmdb_media_search_supports_multi_source_request(monkeypatch) -> None: - """TMDB媒体搜索应支持请求级逗号分隔多数据源,不被其他来源请求阻断。""" + """TMDB媒体搜索应支持请求级多数据源枚举元组。""" monkeypatch.setattr("app.modules.themoviedb.settings.SEARCH_SOURCE", "douban") module = TheMovieDbModule() module.tmdb = Mock() @@ -208,8 +303,11 @@ def test_tmdb_media_search_supports_multi_source_request(monkeypatch) -> None: meta.type = MediaType.UNKNOWN meta.year = None - skipped = module.search_medias(meta=meta, media_source="douban") - result = module.search_medias(meta=meta, media_source="themoviedb,douban") + skipped = module.search_medias(meta=meta, media_source=MediaSource.Douban) + result = module.search_medias( + meta=meta, + media_source=(MediaSource.TMDB, MediaSource.Douban), + ) assert skipped is None assert result == [] @@ -217,7 +315,7 @@ def test_tmdb_media_search_supports_multi_source_request(monkeypatch) -> None: def test_douban_media_search_supports_multi_source_request(monkeypatch) -> None: - """豆瓣媒体搜索应支持请求级逗号分隔多数据源,并跟随全局配置参与搜索。""" + """豆瓣媒体搜索应支持请求级多数据源枚举元组。""" monkeypatch.setattr("app.modules.douban.settings.SEARCH_SOURCE", "themoviedb") module = DoubanModule() module.doubanapi = Mock() @@ -226,7 +324,10 @@ def test_douban_media_search_supports_multi_source_request(monkeypatch) -> None: meta.name = "测试" result = asyncio.run( - module.async_search_medias(meta=meta, media_source="themoviedb,douban") + module.async_search_medias( + meta=meta, + media_source=(MediaSource.TMDB, MediaSource.Douban), + ) ) assert result == [] @@ -243,7 +344,7 @@ def test_multi_source_request_keeps_missing_module_skipped(monkeypatch) -> None: meta.name = "测试" skipped = asyncio.run( - module.async_search_medias(meta=meta, media_source="themoviedb") + module.async_search_medias(meta=meta, media_source=MediaSource.TMDB) ) assert skipped is None diff --git a/tests/test_media_source_routing.py b/tests/test_media_source_routing.py index bc5c96d13..a6f7c1d68 100644 --- a/tests/test_media_source_routing.py +++ b/tests/test_media_source_routing.py @@ -3,45 +3,49 @@ from unittest.mock import Mock, patch from app.chain import ChainBase from app.core.context import MediaInfo from app.core.meta import MetaBase -from app.schemas.types import MediaType +from app.schemas.types import MediaSource, MediaType +from app.utils.media import resolve_media_identity def _chain_without_init() -> ChainBase: """构造不加载真实模块和外部服务的识别链实例。""" - return object.__new__(ChainBase) + chain = object.__new__(ChainBase) + chain.eventmanager = Mock(check=Mock(return_value=False)) + return chain -def test_generic_source_id_wins_over_legacy_ids() -> None: - """显式source与media_id应优先于同一请求残留的兼容ID字段。""" - resolved = ChainBase._resolve_media_source_params( - source="anilist", - mediaid="154587", - tmdbid=999, - doubanid="888", - ) - - assert resolved == ("anilist", None, None, None, 154587) +def test_generic_source_id_resolves_as_fixed_enum() -> None: + """规范来源和原生 ID 应组成唯一媒体身份。""" + assert resolve_media_identity( + media_source="anilist", + media_id="154587", + ) == (MediaSource.AniList, "154587") -def test_custom_plugin_source_is_not_discarded() -> None: - """插件自定义来源应保留来源名,并通过通用原生 ID 交给插件。""" - resolved = ChainBase._resolve_media_source_params( - source="plugin_source", - mediaid="custom-1", - ) - - assert resolved == ("plugin_source", None, None, None, None) +def test_unknown_plugin_source_is_rejected() -> None: + """固定枚举以外的来源不能进入通用识别链。""" + assert resolve_media_identity( + media_source="plugin_source", + media_id="custom-1", + ) == (None, None) -def test_explicit_source_recognition_reaches_plugins_with_all_ids() -> None: - """显式选择数据源时应保留通用参数并进入完整模块调度。""" +def test_zero_media_id_is_rejected() -> None: + """零值 ID 是旧表占位符,不得重新进入统一媒体身份链路。""" + assert resolve_media_identity( + media_source=MediaSource.TMDB, + media_id="0", + ) == (None, None) + + +def test_explicit_source_recognition_reaches_modules_with_unified_identity() -> None: + """显式身份应只以 media_source 和 media_id 进入模块调度。""" chain = _chain_without_init() media = MediaInfo( - anilist_info={ - "id": 154587, - "title": {"english": "Frieren"}, - "format": "TV", - } + media_source=MediaSource.AniList, + media_id="154587", + type=MediaType.TV, + title="Frieren", ) chain.run_module = Mock(return_value=media) @@ -50,24 +54,30 @@ def test_explicit_source_recognition_reaches_plugins_with_all_ids() -> None: return_value=False, ): result = chain.recognize_media( - source="anilist", - mediaid="154587", - tmdbid=999, + media_source=MediaSource.AniList, + media_id="154587", mtype=MediaType.TV, ) assert result is media call = chain.run_module.call_args - assert call.kwargs["source"] == "anilist" - assert call.kwargs["mediaid"] == "154587" - assert call.kwargs["anilistid"] == 154587 - assert call.kwargs["tmdbid"] is None + assert call.kwargs["media_source"] == MediaSource.AniList + assert call.kwargs["media_id"] == "154587" + assert not { + "source", "mediaid", "tmdbid", "doubanid", "bangumiid", "anilistid" + }.intersection(call.kwargs) def test_default_recognition_passes_empty_generic_identity() -> None: - """默认识别也应向插件传递完整但为空的通用媒体身份参数。""" + """默认识别也只向模块传递为空的统一媒体身份。""" chain = _chain_without_init() - media = MediaInfo(title="测试电影", type=MediaType.MOVIE, tmdb_id=1) + media = MediaInfo( + media_source=MediaSource.TMDB, + media_id="1", + title="测试电影", + type=MediaType.MOVIE, + tmdb_id=1, + ) chain.run_module = Mock(return_value=media) meta = MetaBase("测试电影") meta.cn_name = "测试电影" @@ -81,9 +91,8 @@ def test_default_recognition_passes_empty_generic_identity() -> None: assert result is media call = chain.run_module.call_args - assert call.kwargs["source"] is None - assert call.kwargs["mediaid"] is None - assert call.kwargs["anilistid"] is None + assert call.kwargs["media_source"] is None + assert call.kwargs["media_id"] is None def test_module_dispatch_always_reaches_plugins() -> None: @@ -100,16 +109,16 @@ def test_module_dispatch_always_reaches_plugins() -> None: def test_explicit_search_source_reaches_plugins() -> None: - """请求级搜索来源应进入包含插件的完整模块调度。""" + """请求级搜索来源应以统一字段进入完整模块调度。""" chain = _chain_without_init() chain.run_module = Mock(return_value=[]) meta = MetaBase("Frieren") - result = chain.search_medias(meta, source="anilist") + result = chain.search_medias(meta, media_source=MediaSource.AniList) assert result == [] chain.run_module.assert_called_once_with( "search_medias", meta=meta, - source="anilist", + media_source=MediaSource.AniList, ) diff --git a/tests/test_mediascrape.py b/tests/test_mediascrape.py index cb0840c92..88cab8ffb 100644 --- a/tests/test_mediascrape.py +++ b/tests/test_mediascrape.py @@ -13,26 +13,27 @@ with stub_modules({ 'app.db.systemconfig_oper': _systemconfig_stub, }): from app import schemas - from app.chain.media import MediaChain, ScrapingConfig, ScrapingOption + from app.chain.media import MediaChain + from app.chain.scraping import ScrapingChain, ScrapingConfig, ScrapingOption from app.core.context import MediaInfo from app.core.event import Event from app.core.metainfo import MetaInfo from app.schemas.types import EventType, MediaType, ScrapingTarget, ScrapingMetadata, ScrapingPolicy -def reset_media_chain_singleton(): - """清理 MediaChain 单例,避免测试间复用被 mock 的实例。""" - MediaChain._instances.pop((MediaChain, (), frozenset()), None) +def reset_scraping_chain_singleton(): + """清理 ScrapingChain 单例,避免测试间复用被 mock 的实例。""" + ScrapingChain._instances.pop((ScrapingChain, (), frozenset()), None) class TestMediaScrapingPaths(unittest.TestCase): def setUp(self): - reset_media_chain_singleton() - self.media_chain = MediaChain() + reset_scraping_chain_singleton() + self.media_chain = ScrapingChain() self.media_chain.storagechain = MagicMock() def tearDown(self): - reset_media_chain_singleton() + reset_scraping_chain_singleton() def test_movie_file_nfo_path(self): fileitem = schemas.FileItem(path="/movies/avatar.mkv", name="avatar.mkv", type="file", storage="local") @@ -162,8 +163,8 @@ class TestMediaScrapingPaths(unittest.TestCase): class TestMediaScrapingNFO(unittest.TestCase): def setUp(self): - reset_media_chain_singleton() - self.media_chain = MediaChain() + reset_scraping_chain_singleton() + self.media_chain = ScrapingChain() self.media_chain.storagechain = MagicMock() self.media_chain.metadata_nfo = MagicMock(return_value="<nfo></nfo>") self.media_chain._save_file = MagicMock() @@ -174,7 +175,7 @@ class TestMediaScrapingNFO(unittest.TestCase): self.mediainfo = MediaInfo() def tearDown(self): - reset_media_chain_singleton() + reset_scraping_chain_singleton() def test_scrape_nfo_off(self): self.media_chain.scraping_policies.option.return_value = ScrapingOption("movie", "nfo", ScrapingPolicy.SKIP) @@ -212,8 +213,8 @@ class TestMediaScrapingNFO(unittest.TestCase): class TestMediaScrapingImages(unittest.TestCase): def setUp(self): - reset_media_chain_singleton() - self.media_chain = MediaChain() + reset_scraping_chain_singleton() + self.media_chain = ScrapingChain() self.original_download = self.media_chain._download_and_save_image self.media_chain.storagechain = MagicMock() self.media_chain.metadata_img = MagicMock() @@ -222,7 +223,7 @@ class TestMediaScrapingImages(unittest.TestCase): def tearDown(self): self.media_chain._download_and_save_image = self.original_download - reset_media_chain_singleton() + reset_scraping_chain_singleton() def test_scrape_images_mapping(self): fileitem = schemas.FileItem(path="/movies/Avatar", name="Avatar", type="dir", storage="local") @@ -410,13 +411,13 @@ class TestMediaScrapingImages(unittest.TestCase): self.assertEqual(target_item, fileitem) self.assertEqual(target_path, Path("/tv/Show/Season 1/backdrop.jpg")) - @patch("app.chain.media.RequestUtils") - @patch("app.chain.media.NamedTemporaryFile") - @patch("app.chain.media.Path.chmod") - @patch("app.chain.media.settings") + @patch("app.chain.scraping.RequestUtils") + @patch("app.chain.scraping.NamedTemporaryFile") + @patch("app.chain.scraping.Path.chmod") + @patch("app.chain.scraping.settings") def test_download_and_save_image(self, mock_settings, mock_chmod, mock_temp_file, mock_request_utils): # We need to test _download_and_save_image directly so we remove mock - self.media_chain = MediaChain() + self.media_chain = ScrapingChain() self.media_chain._download_and_save_image = self.original_download self.media_chain.storagechain = MagicMock() @@ -452,11 +453,11 @@ class TestMediaScrapingImages(unittest.TestCase): self.assertEqual(call_args["fileitem"], fileitem) self.assertEqual(call_args["new_name"], "poster.jpg") - @patch("app.chain.media.NamedTemporaryFile") + @patch("app.chain.scraping.NamedTemporaryFile") @patch("app.chain.media.Path.chmod") def test_save_file_uses_python310_compatible_tempfile(self, mock_chmod, mock_temp_file): """保存刮削文件时不应使用 Python 3.12 才支持的 delete_on_close 参数。""" - self.media_chain = MediaChain() + self.media_chain = ScrapingChain() self.media_chain.storagechain = MagicMock() self.media_chain._cleanup_temp_file = MagicMock() @@ -479,14 +480,14 @@ class TestMediaScrapingImages(unittest.TestCase): class TestMediaScrapingTVDirectory(unittest.TestCase): def setUp(self): - reset_media_chain_singleton() - self.media_chain = MediaChain() + reset_scraping_chain_singleton() + self.media_chain = ScrapingChain() self.media_chain.storagechain = MagicMock() self.media_chain._scrape_nfo_generic = MagicMock() self.media_chain._scrape_images_generic = MagicMock() def tearDown(self): - reset_media_chain_singleton() + reset_scraping_chain_singleton() @patch("app.chain.media.settings") def test_initialize_tv_directory_specials(self, mock_settings): @@ -554,14 +555,14 @@ class TestMediaScrapingTVDirectory(unittest.TestCase): class TestMediaScrapeEvents(unittest.TestCase): def setUp(self): - reset_media_chain_singleton() - self.media_chain = MediaChain() + reset_scraping_chain_singleton() + self.media_chain = ScrapingChain() self.media_chain.storagechain = MagicMock() def tearDown(self): - reset_media_chain_singleton() + reset_scraping_chain_singleton() - @patch("app.chain.media.MediaChain.scrape_metadata") + @patch("app.chain.scraping.ScrapingChain.scrape_metadata") def test_scrape_metadata_event_file( self, mock_scrape_metadata ): @@ -591,7 +592,7 @@ class TestMediaScrapeEvents(unittest.TestCase): overwrite=True ) - @patch("app.chain.media.MediaChain.scrape_metadata") + @patch("app.chain.scraping.ScrapingChain.scrape_metadata") def test_scrape_metadata_event_dir_bluray( self, mock_scrape_metadata ): @@ -621,7 +622,7 @@ class TestMediaScrapeEvents(unittest.TestCase): overwrite=False ) - @patch("app.chain.media.MediaChain.scrape_metadata") + @patch("app.chain.scraping.ScrapingChain.scrape_metadata") def test_scrape_metadata_event_dir_with_filelist( self, mock_scrape_metadata ): @@ -657,7 +658,7 @@ class TestMediaScrapeEvents(unittest.TestCase): self.assertIn("/tv/show/Season 1", paths) self.assertIn("/tv/show/Season 1/S01E01.mp4", paths) - @patch("app.chain.media.MediaChain.scrape_metadata") + @patch("app.chain.scraping.ScrapingChain.scrape_metadata") def test_scrape_metadata_event_dir_full( self, mock_scrape_metadata ): @@ -687,7 +688,7 @@ class TestMediaScrapeEvents(unittest.TestCase): overwrite=True ) - @patch("app.chain.media.MediaChain._handle_movie_scraping") + @patch("app.chain.scraping.ScrapingChain._handle_movie_scraping") @patch("app.chain.media.MediaChain.recognize_by_meta") def test_scrape_metadata_movie( self, mock_recognize, mock_handle_movie @@ -716,7 +717,7 @@ class TestMediaScrapeEvents(unittest.TestCase): recursive=True ) - @patch("app.chain.media.MediaChain._handle_tv_scraping") + @patch("app.chain.scraping.ScrapingChain._handle_tv_scraping") @patch("app.chain.media.MediaChain.recognize_by_meta") def test_scrape_metadata_tv( self, mock_recognize, mock_handle_tv @@ -744,7 +745,7 @@ class TestMediaScrapeEvents(unittest.TestCase): recursive=True ) - @patch("app.chain.media.MediaChain._handle_movie_scraping") + @patch("app.chain.scraping.ScrapingChain._handle_movie_scraping") @patch("app.chain.media.MediaChain.recognize_by_meta") def test_scrape_metadata_recognize_fallback( self, mock_recognize, mock_handle_movie @@ -766,8 +767,8 @@ class TestMediaScrapeEvents(unittest.TestCase): self.assertEqual(kwargs['mediainfo'], mediainfo) self.assertEqual(kwargs['meta'].name, "Movie") - @patch("app.chain.media.MediaChain._handle_movie_scraping") - @patch("app.chain.media.MediaChain._handle_tv_scraping") + @patch("app.chain.scraping.ScrapingChain._handle_movie_scraping") + @patch("app.chain.scraping.ScrapingChain._handle_tv_scraping") def test_scrape_metadata_invalid_extension( self, mock_handle_tv, mock_handle_movie ): @@ -780,7 +781,7 @@ class TestMediaScrapeEvents(unittest.TestCase): mock_handle_movie.assert_not_called() mock_handle_tv.assert_not_called() - @patch("app.chain.media.MediaChain.scrape_metadata") + @patch("app.chain.scraping.ScrapingChain.scrape_metadata") def test_scrape_metadata_event_dir_with_multiple_files( self, mock_scrape_metadata ): @@ -822,7 +823,7 @@ class TestMediaScrapeEvents(unittest.TestCase): self.assertIn("/movies/collection/movie2.mkv", paths) self.assertIn("/movies/collection/movie3.avi", paths) - @patch("app.chain.media.MediaChain.scrape_metadata") + @patch("app.chain.scraping.ScrapingChain.scrape_metadata") def test_scrape_metadata_event_dir_with_tv_multi_seasons_episodes( self, mock_scrape_metadata ): diff --git a/tests/test_mediaserver_tv_stale_itemid.py b/tests/test_mediaserver_tv_stale_itemid.py index b91f298e6..4537789ab 100644 --- a/tests/test_mediaserver_tv_stale_itemid.py +++ b/tests/test_mediaserver_tv_stale_itemid.py @@ -10,6 +10,7 @@ from app.modules.plex.plex import Plex from app.modules.trimemedia.trimemedia import TrimeMedia from app.modules.ugreen.ugreen import Ugreen from app.modules.zspace.zspace import ZSpace +from app.schemas.types import MediaSource class _FakeResponse: @@ -56,7 +57,8 @@ class MediaServerTvStaleItemIdTest(unittest.TestCase): title="测试剧集", original_title="Test Show", year="2026", - tmdb_id=12345, + media_source=MediaSource.TMDB, + media_id="12345", season=1, ) @@ -95,7 +97,8 @@ class MediaServerTvStaleItemIdTest(unittest.TestCase): item_id, episodes = plex.get_tv_episodes( item_id="107797", title="测试剧集", - tmdb_id=12345, + media_source=MediaSource.TMDB, + media_id="12345", ) self.assertEqual(item_id, "/library/metadata/107797") @@ -109,7 +112,10 @@ class MediaServerTvStaleItemIdTest(unittest.TestCase): client._host = "http://emby.local/" client._apikey = "api-key" client.user = "user-id" - client.get_iteminfo = Mock(side_effect=[None, SimpleNamespace(tmdbid=12345)]) + client.get_iteminfo = Mock(side_effect=[ + None, + SimpleNamespace(media_source=MediaSource.TMDB, media_id="12345"), + ]) client._Emby__get_emby_series_id_by_name = Mock(return_value="new-series-id") with patch("app.modules.emby.emby.RequestUtils") as request_utils_cls: @@ -121,7 +127,8 @@ class MediaServerTvStaleItemIdTest(unittest.TestCase): item_id="old-series-id", title="测试剧集", year="2026", - tmdb_id=12345, + media_source=MediaSource.TMDB, + media_id="12345", ) self.assertEqual(item_id, "new-series-id") @@ -134,7 +141,10 @@ class MediaServerTvStaleItemIdTest(unittest.TestCase): client._host = "http://jellyfin.local/" client._apikey = "api-key" client.user = "user-id" - client.get_iteminfo = Mock(side_effect=[None, SimpleNamespace(tmdbid=12345)]) + client.get_iteminfo = Mock(side_effect=[ + None, + SimpleNamespace(media_source=MediaSource.TMDB, media_id="12345"), + ]) client._Jellyfin__get_jellyfin_series_id_by_name = Mock(return_value="new-series-id") with patch("app.modules.jellyfin.jellyfin.RequestUtils") as request_utils_cls: @@ -146,7 +156,8 @@ class MediaServerTvStaleItemIdTest(unittest.TestCase): item_id="old-series-id", title="测试剧集", year="2026", - tmdb_id=12345, + media_source=MediaSource.TMDB, + media_id="12345", ) self.assertEqual(item_id, "new-series-id") @@ -159,7 +170,10 @@ class MediaServerTvStaleItemIdTest(unittest.TestCase): client._host = "http://zspace.local/" client._apikey = "api-key" client.user = "user-id" - client.get_iteminfo = Mock(side_effect=[None, SimpleNamespace(tmdbid=12345)]) + client.get_iteminfo = Mock(side_effect=[ + None, + SimpleNamespace(media_source=MediaSource.TMDB, media_id="12345"), + ]) client._ZSpace__get_series_id_by_name = Mock(return_value="new-series-id") with patch("app.modules.zspace.zspace.RequestUtils") as request_utils_cls: @@ -171,7 +185,8 @@ class MediaServerTvStaleItemIdTest(unittest.TestCase): item_id="old-series-id", title="测试剧集", year="2026", - tmdb_id=12345, + media_source=MediaSource.TMDB, + media_id="12345", ) self.assertEqual(item_id, "new-series-id") @@ -183,7 +198,10 @@ class MediaServerTvStaleItemIdTest(unittest.TestCase): client = Ugreen.__new__(Ugreen) client._api = Mock() client.is_authenticated = Mock(return_value=True) - client.get_iteminfo = Mock(side_effect=[None, SimpleNamespace(tmdbid=12345)]) + client.get_iteminfo = Mock(side_effect=[ + None, + SimpleNamespace(media_source=MediaSource.TMDB, media_id="12345"), + ]) client._Ugreen__search_tv_item = Mock(return_value={"ug_video_info_id": "new-series-id"}) client._api.get_tv.return_value = { "season_info": [{"category_id": "season-1", "season_num": 1}], @@ -194,19 +212,25 @@ class MediaServerTvStaleItemIdTest(unittest.TestCase): item_id="old-series-id", title="测试剧集", year="2026", - tmdb_id=12345, + media_source=MediaSource.TMDB, + media_id="12345", ) self.assertEqual(item_id, "new-series-id") self.assertEqual(episodes, {1: [1]}) - client._Ugreen__search_tv_item.assert_called_once_with("测试剧集", "2026", 12345) + client._Ugreen__search_tv_item.assert_called_once_with( + "测试剧集", "2026", MediaSource.TMDB, "12345" + ) def test_trime_media_tv_episodes_fallback_when_cached_item_id_missing(self): """飞牛影视缓存ID失效时,应重新搜索剧集ID后再查询集信息。""" client = TrimeMedia.__new__(TrimeMedia) client._api = Mock() client.is_authenticated = Mock(return_value=True) - client.get_iteminfo = Mock(side_effect=[None, SimpleNamespace(tmdbid=12345)]) + client.get_iteminfo = Mock(side_effect=[ + None, + SimpleNamespace(media_source=MediaSource.TMDB, media_id="12345"), + ]) client._TrimeMedia__get_series_id_by_name = Mock(return_value="new-series-id") client._api.season_list.return_value = [SimpleNamespace(season_number=1, guid="season-1")] client._api.episode_list.return_value = [ @@ -217,7 +241,8 @@ class MediaServerTvStaleItemIdTest(unittest.TestCase): item_id="old-series-id", title="测试剧集", year="2026", - tmdb_id=12345, + media_source=MediaSource.TMDB, + media_id="12345", ) self.assertEqual(item_id, "new-series-id") diff --git a/tests/test_message_media_identity.py b/tests/test_message_media_identity.py index bcc7e0e9d..44ee2bc71 100644 --- a/tests/test_message_media_identity.py +++ b/tests/test_message_media_identity.py @@ -1,13 +1,14 @@ from app.core.context import MediaInfo from app.helper.message import TemplateContextBuilder -from app.schemas.types import MediaType +from app.schemas.types import MediaSource, MediaType -def test_message_context_contains_all_media_identity_fields() -> None: - """消息模板上下文应向插件暴露全部媒体源 ID 和当前主身份。""" +def test_message_context_contains_primary_and_auxiliary_media_fields() -> None: + """消息上下文应区分规范主身份与仅供展示的辅助来源 ID。""" context = {} media = MediaInfo( - source="anilist", + media_source=MediaSource.AniList, + media_id="170942", type=MediaType.TV, title="测试动画", tmdb_id=24680, @@ -24,18 +25,18 @@ def test_message_context_contains_all_media_identity_fields() -> None: assert context["anilistid"] == 170942 assert context["media_source"] == "anilist" assert context["media_id"] == "170942" - assert media.to_dict()["mediaid_prefix"] == "anilist" + assert "mediaid_prefix" not in media.to_dict() assert media.to_dict()["media_id"] == "170942" -def test_media_info_preserves_plugin_source_identity() -> None: - """核心媒体对象应原样保留插件自定义数据源的原生 ID。""" +def test_media_info_rejects_unknown_plugin_source_identity() -> None: + """核心媒体对象应丢弃枚举以外的插件自定义来源。""" media = MediaInfo( - source="plugin_source", + media_source="plugin_source", media_id="custom-100", type=MediaType.MOVIE, title="插件电影", ) - assert media.media_id == "custom-100" - assert media.to_dict()["media_id"] == "custom-100" + assert media.media_source is None + assert media.media_id is None diff --git a/tests/test_metamusic.py b/tests/test_metamusic.py index e94994964..17fd31a84 100644 --- a/tests/test_metamusic.py +++ b/tests/test_metamusic.py @@ -1,3 +1,4 @@ +from pathlib import Path from typing import Optional import pytest @@ -10,6 +11,7 @@ from app.core.meta import ( MusicNamePattern, MusicNameRegistry, ) +from app.core.metainfo import MetaInfo, MetaInfoPath def parse_title(title: str) -> MetaMusic: @@ -17,7 +19,7 @@ def parse_title(title: str) -> MetaMusic: return MetaMusic(org_string=title, title=title, parse_title=True) -def test_music_name_registry_supports_dynamic_pattern_and_parser(): +def test_music_name_registry_supports_dynamic_pattern_and_parser(monkeypatch): """外部程序可独立注册命名模式和解析器,并在使用后完整注销。""" pattern_name = "test_program" parser_name = "test_program_parser" @@ -45,6 +47,12 @@ def test_music_name_registry_supports_dynamic_pattern_and_parser(): MusicNameParser(parser_name, (pattern_name,), parse_program, priority=1000) ) try: + monkeypatch.setattr( + "app.core.meta.metamusic.rust_accel.parse_metamusic", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("自定义注册表不应调用 Rust") + ), + ) context = MusicNameContext( raw="PROGRAM::周杰伦::晴天", normalized="PROGRAM::周杰伦::晴天", @@ -70,6 +78,209 @@ def test_music_name_registry_supports_dynamic_pattern_and_parser(): assert pattern_name not in {pattern.name for pattern in MusicNameRegistry.get_patterns()} +def test_music_name_registry_same_name_replacement_falls_back_to_python(monkeypatch): + """同名替换内置解析器时应保留 Python 动态扩展语义。""" + original = next( + parser for parser in MusicNameRegistry.get_parsers() + if parser.name == "fallback" + ) + + def parse_replacement(context, _matched): + """为同名替换用例返回固定解析结果。""" + return MusicNameParseResult( + title=f"Python:{context.text}", + artists=["扩展解析器"], + ) + + replacement = MusicNameParser( + "fallback", + ("fallback",), + parse_replacement, + ) + MusicNameRegistry.register_parser(replacement, replace=True) + try: + monkeypatch.setattr( + "app.core.meta.metamusic.rust_accel.parse_metamusic", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("同名替换解析器时不应调用 Rust") + ), + ) + + meta = parse_title("自定义曲名") + + assert meta.title == "Python:自定义曲名" + assert meta.artists == ["扩展解析器"] + finally: + MusicNameRegistry.register_parser(original, replace=True) + + assert MusicNameRegistry._uses_default_components() + + +def test_parse_query_uses_rust_and_maps_music_fields(monkeypatch): + """MetaMusic 公共查询入口应使用 Rust 并回填完整核心字段。""" + calls = [] + parsed = { + "title": "晴天", + "artists": ["周杰伦"], + "album": "叶惠美", + "year": 2003, + "disc_number": 1, + "track_number": 3, + "audio_format": "FLAC", + "audio_lossless": True, + "bit_depth": 24, + "sample_rate": 96000, + "bitrate": 2304000, + } + + def parse_metamusic(title, artists=None, year=None): + """记录 Rust wrapper 调用并返回完整音乐字段。""" + calls.append((title, artists, year)) + return parsed + + monkeypatch.setattr( + "app.core.meta.metamusic.rust_accel.parse_metamusic", + parse_metamusic, + ) + + meta = MetaMusic.parse_query("周杰伦 - 晴天 FLAC 24bit 96kHz") + + assert calls == [("周杰伦 - 晴天 FLAC 24bit 96kHz", None, None)] + assert { + key: getattr(meta, key) + for key in parsed + } == parsed + + +def test_apply_title_preserves_existing_fields_from_rust(monkeypatch): + """Rust 解析只应补充空字段,不覆盖标签或文件后缀证据。""" + monkeypatch.setattr( + "app.core.meta.metamusic.rust_accel.parse_metamusic", + lambda *_args, **_kwargs: { + "title": "Rust 曲名", + "artists": ["Rust 歌手"], + "album": "Rust 专辑", + "year": 2026, + "disc_number": 2, + "track_number": 8, + "audio_format": "AAC", + "audio_lossless": False, + "bit_depth": 16, + "sample_rate": 48000, + "bitrate": 320000, + }, + ) + + meta = MetaMusic( + org_string="source.flac", + title="source", + artists=["标签歌手"], + album="标签专辑", + year=2003, + disc_number=1, + track_number=3, + audio_format="FLAC", + bit_depth=24, + sample_rate=96000, + bitrate=2304000, + parse_title=True, + ) + + assert meta.title == "Rust 曲名" + assert meta.artists == ["标签歌手"] + assert meta.album == "标签专辑" + assert meta.year == 2003 + assert meta.disc_number == 1 + assert meta.track_number == 3 + assert meta.audio_format == "FLAC" + assert meta.audio_lossless is True + assert meta.bit_depth == 24 + assert meta.sample_rate == 96000 + assert meta.bitrate == 2304000 + + +def test_apply_title_falls_back_when_rust_returns_none(monkeypatch): + """Rust wrapper 不可用时应完整执行现有 Python 命名解析。""" + monkeypatch.setattr( + "app.core.meta.metamusic.rust_accel.parse_metamusic", + lambda *_args, **_kwargs: None, + ) + + meta = MetaMusic.parse_query("周杰伦 - 晴天 FLAC 24bit 96kHz") + + assert meta.title == "晴天" + assert meta.artists == ["周杰伦"] + assert meta.audio_format == "FLAC" + assert meta.bit_depth == 24 + assert meta.sample_rate == 96000 + + +def test_metainfo_audio_suffix_remains_authoritative_with_rust(monkeypatch): + """音频文件后缀应优先于标题中的演唱会音频编码标记。""" + calls = [] + + def parse_metamusic(title, artists=None, year=None): + """模拟 Rust 从标题规格中识别出 AAC。""" + calls.append((title, artists, year)) + return { + "title": "S.H.E十七音乐会", + "artists": ["S.H.E"], + "year": 2018, + "audio_format": "AAC", + "audio_lossless": False, + } + + monkeypatch.setattr( + "app.core.meta.metamusic.rust_accel.parse_metamusic", + parse_metamusic, + ) + filename = "S H E - S H E十七音乐会 2018 WEB-DL 1080P AVC AAC-FHDMv.flac" + + meta = MetaInfo(filename) + + assert calls == [(filename.removesuffix(".flac"), None, None)] + assert meta.title == "S.H.E十七音乐会" + assert meta.artists == ["S.H.E"] + assert meta.audio_format == "FLAC" + assert meta.audio_lossless is True + + +def test_metainfo_path_uses_rust_once_and_keeps_python_directory_context( + monkeypatch, +): + """音频路径只应用 Rust 解析一次文件名,目录线索仍由 Python 补齐。""" + calls = [] + + def parse_metamusic(title, artists=None, year=None): + """记录路径中的 Rust 文件名解析并返回曲目字段。""" + calls.append((title, artists, year)) + return { + "title": "我的地盘", + "artists": [], + "track_number": 1, + "audio_format": "AAC", + "audio_lossless": False, + } + + monkeypatch.setattr( + "app.core.meta.metamusic.rust_accel.parse_metamusic", + parse_metamusic, + ) + path = MetaInfoPath( + Path("/music/周杰伦 - 七里香 (2004) [FLAC]/01.我的地盘.flac") + ) + + assert calls == [("我的地盘", None, None)] + assert path.title == "我的地盘" + assert path.track_number == 1 + assert path.album == "七里香" + assert path.artists == ["周杰伦"] + assert path.album_artist == "周杰伦" + assert path.year == 2004 + assert path.audio_format == "FLAC" + assert path.audio_lossless is True + + @pytest.mark.parametrize( ("raw", "artists", "title", "year", "audio_format"), [ diff --git a/tests/test_music_album_match.py b/tests/test_music_album_match.py index 923e4e784..b8e895510 100644 --- a/tests/test_music_album_match.py +++ b/tests/test_music_album_match.py @@ -4,7 +4,6 @@ from unittest.mock import AsyncMock, Mock import pytest from app.chain.media import MediaChain -from app.chain.music import MusicChain from app.core.context import MusicAlbumInfo, MusicInfo from app.core.meta import MetaMusic from app.modules.musicbrainz import MusicBrainzModule @@ -111,15 +110,15 @@ def test_release_queries_fallback_to_track_titles(): @pytest.fixture() -def music_chain(): - """构造绕过重量级初始化的 MusicChain,并清理目录匹配缓存。""" - chain = MusicChain.__new__(MusicChain) - MusicChain._album_dir_cache.clear() +def media_chain(): + """构造媒体链并清理目录匹配缓存。""" + chain = MediaChain() + MediaChain._album_dir_cache.clear() yield chain - MusicChain._album_dir_cache.clear() + MediaChain._album_dir_cache.clear() -def test_recognize_album_directory_maps_files(tmp_path, music_chain, monkeypatch): +def test_recognize_album_directory_maps_files(tmp_path, media_chain, monkeypatch): """目录级匹配应把每个音频文件对位到专辑曲目并缓存结果。""" album_dir = tmp_path / "周杰伦 - 七里香 (2004)" album_dir.mkdir() @@ -147,15 +146,14 @@ def test_recognize_album_directory_maps_files(tmp_path, music_chain, monkeypatch for index, (name, length) in enumerate(ALBUM_TRACKS) ], ) - calls = {"count": 0} + source_chain = Mock() + source_chain.match_music_album.side_effect = lambda *_args, **_kwargs: album + monkeypatch.setattr( + "app.chain.media.MusicBrainzChain", + Mock(return_value=source_chain), + ) - def fake_run_module(method, **kwargs): - calls["count"] += 1 - return [album] if method == "match_music_album" else [] - - monkeypatch.setattr(music_chain, "run_module", fake_run_module) - - matched = music_chain.recognize_album_directory(album_dir) + matched = media_chain.recognize_music_album_directory(album_dir) assert len(matched) == len(files) for index, file in enumerate(files): @@ -163,13 +161,13 @@ def test_recognize_album_directory_maps_files(tmp_path, music_chain, monkeypatch assert info.media_id == f"rec-{index + 1}" assert info.title == ALBUM_TRACKS[index][0] # 同一目录再次识别直接命中缓存,不重复请求模块 - assert music_chain.recognize_album_directory(album_dir) == matched - assert calls["count"] == 1 + assert media_chain.recognize_music_album_directory(album_dir) == matched + source_chain.match_music_album.assert_called_once() def test_async_recognize_album_directory_calls_async_module( tmp_path, - music_chain, + media_chain, monkeypatch, ): """异步目录识别应直接调用模块异步接口,并兼容单个专辑返回值。""" @@ -201,15 +199,16 @@ def test_async_recognize_album_directory_calls_async_module( ) async_run_module = AsyncMock(return_value=album) run_module = Mock(side_effect=AssertionError("异步目录识别不应调用同步模块接口")) - monkeypatch.setattr(music_chain, "async_run_module", async_run_module) - monkeypatch.setattr(music_chain, "run_module", run_module) + source_chain = Mock() + source_chain.async_match_music_album = async_run_module + source_chain.match_music_album = run_module + monkeypatch.setattr("app.chain.media.MusicBrainzChain", Mock(return_value=source_chain)) monkeypatch.setattr( - music_chain, - "_read_album_path_metas", + "app.chain.media.AudioMetadataHelper.read_many", lambda _files: _local_tracks(), ) - matched = asyncio.run(music_chain.async_recognize_album_directory(album_dir)) + matched = asyncio.run(media_chain.async_recognize_music_album_directory(album_dir)) assert [matched[str(file.resolve())].media_id for file in files] == [ "rec-1", @@ -217,11 +216,11 @@ def test_async_recognize_album_directory_calls_async_module( "rec-3", ] async_run_module.assert_awaited_once() - assert async_run_module.await_args.args == ("async_match_music_album",) + assert async_run_module.await_args.args[0].album == "七里香" run_module.assert_not_called() -def test_recognize_album_directory_skips_single_file(tmp_path, music_chain, monkeypatch): +def test_recognize_album_directory_skips_single_file(tmp_path, media_chain, monkeypatch): """单文件目录不走专辑匹配,交给单曲识别链路。""" album_dir = tmp_path / "单曲" album_dir.mkdir() @@ -230,14 +229,14 @@ def test_recognize_album_directory_skips_single_file(tmp_path, music_chain, monk def fake_run_module(method, **kwargs): raise AssertionError("单文件目录不应触发专辑匹配") - monkeypatch.setattr(music_chain, "run_module", fake_run_module) + monkeypatch.setattr(media_chain, "_match_music_album_directory", fake_run_module) - assert music_chain.recognize_album_directory(album_dir) == {} + assert media_chain.recognize_music_album_directory(album_dir) == {} def test_recognize_album_directory_invalidates_cache_after_same_count_rename( tmp_path, - music_chain, + media_chain, monkeypatch, ): """目录内文件数量不变但文件名变化时,专辑曲目映射缓存必须失效。""" @@ -254,11 +253,11 @@ def test_recognize_album_directory_invalidates_cache_after_same_count_rename( calls.append([file.name for file in files]) return {} - monkeypatch.setattr(music_chain, "_match_album_directory", fake_match) + monkeypatch.setattr(media_chain, "_match_music_album_directory", fake_match) - music_chain.recognize_album_directory(album_dir) + media_chain.recognize_music_album_directory(album_dir) first.rename(album_dir / "01 - Renamed.wav") - music_chain.recognize_album_directory(album_dir) + media_chain.recognize_music_album_directory(album_dir) assert calls == [ ["01 - First.wav", "02 - Second.wav"], @@ -315,13 +314,17 @@ def test_async_music_album_fallback_calls_async_directory_match(tmp_path, monkey ) sync_recognize = Mock(side_effect=AssertionError("异步回退不应调用同步目录识别")) monkeypatch.setattr( - MusicChain, - "async_recognize_album_directory", + MediaChain, + "async_recognize_music_album_directory", async_recognize, ) - monkeypatch.setattr(MusicChain, "recognize_album_directory", sync_recognize) + monkeypatch.setattr( + MediaChain, + "recognize_music_album_directory", + sync_recognize, + ) - result = asyncio.run(MediaChain._async_music_album_dir_fallback(file)) + result = asyncio.run(MediaChain()._async_music_album_dir_fallback(file)) assert result is matched_info async_recognize.assert_awaited_once_with(album_dir) diff --git a/tests/test_music_context.py b/tests/test_music_context.py index 65b1858ba..1736ed40d 100644 --- a/tests/test_music_context.py +++ b/tests/test_music_context.py @@ -5,7 +5,7 @@ from app.schemas.context import Context as ContextSchema from app.schemas.context import MediaInfo as MediaInfoSchema from app.schemas.music import MusicInfo as MusicInfoSchema from app.schemas.music import MusicMeta as MusicMetaSchema -from app.schemas.types import MediaType, media_type_to_agent +from app.schemas.types import MediaSource, MediaType, media_type_to_agent def test_media_type_supports_music_agent_conversion(): @@ -59,7 +59,7 @@ def test_music_info_serializes_shared_media_display_fields(): assert payload["artist"] == "周杰伦" assert payload["title_year"] == "七里香 (2004)" assert payload["poster_path"] == "https://example.invalid/cover.jpg" - assert payload["mediaid_prefix"] == "musicbrainz" + assert payload["media_source"] == "musicbrainz" assert payload["media_id"] == "release-1" @@ -94,7 +94,7 @@ def test_schema_context_uses_music_models_for_music_payload(): }, "media_info": { "type": "音乐", - "source": "musicbrainz", + "media_source": "musicbrainz", "media_id": "release-1", "title": "七里香", "artists": ["周杰伦"], @@ -115,7 +115,8 @@ def test_schema_context_keeps_video_payload_on_existing_model(): { "media_info": { "type": "电影", - "source": "themoviedb", + "media_source": "themoviedb", + "media_id": "157336", "title": "Interstellar", "tmdb_id": 157336, } @@ -130,7 +131,8 @@ def test_core_video_context_remains_compatible(): """新增音乐模型后现有电影 Context 序列化应保持兼容。""" context = Context( media_info=MediaInfo( - source="themoviedb", + media_source=MediaSource.TMDB, + media_id="157336", type=MediaType.MOVIE, title="Interstellar", year="2014", diff --git a/tests/test_music_download.py b/tests/test_music_download.py index 264eaaff1..0a2bbc315 100644 --- a/tests/test_music_download.py +++ b/tests/test_music_download.py @@ -2,7 +2,6 @@ from unittest.mock import Mock, patch from app.api.endpoints.download import add, download from app.chain.download import DownloadChain -from app.chain.music import MusicChain from app.core.context import MUSIC_ENTITY_ALBUM, Context, MusicInfo from app.core.meta import MetaMusic from app.schemas import ExistMediaInfo @@ -38,13 +37,14 @@ def _album_info(total_tracks: int | None = 3) -> MusicInfo: ) -def test_music_info_exposes_download_chain_compatibility_fields(): - """音乐信息应安全兼容下载链现有的视频身份字段访问。""" +def test_music_info_exposes_download_chain_unified_identity(): + """音乐下载上下文仅使用统一身份,并保留无季集的领域约束。""" info = _music_info() - meta = MusicChain.to_meta(info) + meta = MetaMusic.from_music_info(info) assert info.type == MediaType.MUSIC - assert info.tmdb_id is None + assert info.media_source.value == "musicbrainz" + assert info.media_id == "recording-1" assert info.episode_group is None assert meta.episode_list == [] assert meta.season_episode == "" @@ -53,7 +53,7 @@ def test_music_info_exposes_download_chain_compatibility_fields(): def test_download_note_keeps_versioned_music_context(): """音乐下载历史备注应保存可恢复且不含上游原始大对象的上下文。""" info = _music_info() - meta = MusicChain.to_meta(info) + meta = MetaMusic.from_music_info(info) note = DownloadChain._build_download_note("Manual", info, meta) @@ -114,7 +114,7 @@ def test_download_single_stops_before_client_when_album_pack_is_incomplete(): """下载入口应在添加任务前拒绝不完整专辑,并记录可供后续候选继续尝试的失败原因。""" context = Context( media_info=_album_info(total_tracks=3), - meta_info=MusicChain.to_meta(_album_info(total_tracks=3)), + meta_info=MetaMusic.from_music_info(_album_info(total_tracks=3)), torrent_info=TorrentInfo( title="周杰伦 - 叶惠美 FLAC", category=MediaType.MUSIC.value, @@ -219,7 +219,7 @@ def test_music_library_exists_uses_atomic_album_lookup(): with patch("app.chain.download.MediaServerOper", return_value=mediaserver): exists, no_exists = chain.get_no_exists_info( - meta=MusicChain.to_meta(album), + meta=MetaMusic.from_music_info(album), mediainfo=album, ) diff --git a/tests/test_music_endpoint.py b/tests/test_music_endpoint.py index 740558784..e5a984f5d 100644 --- a/tests/test_music_endpoint.py +++ b/tests/test_music_endpoint.py @@ -17,7 +17,7 @@ from app.api.endpoints.music import ( ) from app.core.context import MusicAlbumInfo, MusicArtistInfo, MusicInfo, MusicRelease from app.schemas.music import MusicRecognizeRequest -from app.schemas.types import MediaType +from app.schemas.types import MediaSource, MediaType def test_music_routes_are_registered(): @@ -55,11 +55,10 @@ def test_music_routes_are_registered(): def test_media_search_routes_music_queries_with_query_kwarg(): - """统一媒体搜索的音乐分支应以 AsyncSearch 的关键字参数调用 MusicChain。""" - from app.chain.music import MusicChain + """统一媒体搜索的音乐分支应以关键字参数调用 MediaChain。""" chain = Mock() - chain.async_search = AsyncMock( + chain.async_search_music = AsyncMock( return_value=[ MusicInfo( media_source="musicbrainz", @@ -74,8 +73,7 @@ def test_media_search_routes_music_queries_with_query_kwarg(): ) with ( - patch("app.api.endpoints.media.MusicChain", return_value=chain) as music_chain, - patch.object(media_endpoints, "MediaChain") as media_chain, + patch.object(media_endpoints, "MediaChain", return_value=chain) as media_chain, ): result = asyncio.run( media_endpoints.search( @@ -90,19 +88,16 @@ def test_media_search_routes_music_queries_with_query_kwarg(): assert result[0]["media_id"] == "recording-1" assert result[0]["music_type"] == "recording" assert result[0]["title"] == "晴天" - chain.async_search.assert_awaited_once_with(query="晴天", limit=30) - media_chain.return_value.async_search.assert_not_called() + chain.async_search_music.assert_awaited_once_with(query="晴天", limit=30) + media_chain.assert_called_once() def test_media_search_forwards_explicit_music_source(): - """统一音乐搜索应把显式选择的可扩展音乐源转发给 MusicChain。""" + """统一音乐搜索应把显式选择的可扩展音乐源转发给 MediaChain。""" chain = Mock() - chain.async_search = AsyncMock(return_value=[]) + chain.async_search_music = AsyncMock(return_value=[]) - with ( - patch("app.api.endpoints.media.MusicChain", return_value=chain), - patch.object(media_endpoints, "MediaChain"), - ): + with patch.object(media_endpoints, "MediaChain", return_value=chain): result = asyncio.run( media_endpoints.search( title="Coldplay", @@ -114,10 +109,10 @@ def test_media_search_forwards_explicit_music_source(): ) assert result == [] - chain.async_search.assert_awaited_once_with( + chain.async_search_music.assert_awaited_once_with( query="Coldplay", limit=20, - media_source="theaudiodb", + media_source=(MediaSource.TheAudioDB,), ) @@ -179,7 +174,7 @@ def test_recognize_music_returns_404_for_unknown_item(): def test_explore_music_forwards_filters_and_serializes_chart(): """音乐探索接口应传递实体、周期、排序、热度和封面筛选条件。""" chain = Mock() - chain.async_chart = AsyncMock( + chain.async_music_chart = AsyncMock( return_value=[ MusicInfo( media_source="musicbrainz", @@ -191,7 +186,7 @@ def test_explore_music_forwards_filters_and_serializes_chart(): ] ) - with patch("app.api.endpoints.music.MusicChain", return_value=chain): + with patch("app.api.endpoints.music.RecommendChain", return_value=chain): result = asyncio.run( explore_music( page=2, @@ -205,7 +200,7 @@ def test_explore_music_forwards_filters_and_serializes_chart(): ) assert result[0].listen_count == 123 - chain.async_chart.assert_awaited_once_with( + chain.async_music_chart.assert_awaited_once_with( range_name="this_week", page=2, count=20, @@ -219,7 +214,7 @@ def test_explore_music_forwards_filters_and_serializes_chart(): def test_explore_music_supports_official_fresh_release_mode(): """新发行模式应按 ListenBrainz 官方排序和时间窗口请求探索数据。""" chain = Mock() - chain.async_fresh_releases = AsyncMock( + chain.async_music_fresh_releases = AsyncMock( return_value=[ MusicInfo( media_source="musicbrainz", @@ -231,7 +226,7 @@ def test_explore_music_supports_official_fresh_release_mode(): ] ) - with patch("app.api.endpoints.music.MusicChain", return_value=chain): + with patch("app.api.endpoints.music.RecommendChain", return_value=chain): result = asyncio.run( explore_music( page=1, @@ -247,7 +242,7 @@ def test_explore_music_supports_official_fresh_release_mode(): ) assert result[0].music_type == "album" - chain.async_fresh_releases.assert_awaited_once_with( + chain.async_music_fresh_releases.assert_awaited_once_with( days=30, sort="artist_credit_name", past=True, @@ -261,7 +256,7 @@ def test_explore_music_supports_official_fresh_release_mode(): def test_explore_music_forces_douban_music_to_tag_browsing(): """豆瓣音乐探索即使收到榜单模式也应固定分类浏览,不与推荐页重复。""" chain = Mock() - chain.async_discover = AsyncMock( + chain.async_music_discover = AsyncMock( return_value=[ MusicInfo( media_source="doubanmusic", @@ -272,7 +267,7 @@ def test_explore_music_forces_douban_music_to_tag_browsing(): ] ) - with patch("app.api.endpoints.music.MusicChain", return_value=chain): + with patch("app.api.endpoints.music.RecommendChain", return_value=chain): result = asyncio.run( explore_music( media_source="doubanmusic", @@ -287,8 +282,8 @@ def test_explore_music_forces_douban_music_to_tag_browsing(): ) assert result[0].media_source == "doubanmusic" - chain.async_discover.assert_awaited_once_with( - media_source="doubanmusic", + chain.async_music_discover.assert_awaited_once_with( + media_source=MediaSource.DoubanMusic, page=2, count=20, entity="album", @@ -301,7 +296,7 @@ def test_explore_music_forces_douban_music_to_tag_browsing(): def test_explore_music_filters_missing_covers_for_external_sources(): """外部音乐源选择仅有封面时应在统一响应层过滤无图条目。""" chain = Mock() - chain.async_discover = AsyncMock( + chain.async_music_discover = AsyncMock( return_value=[ MusicInfo(media_source="doubanmusic", media_id="album-1", title="No Cover"), MusicInfo( @@ -313,7 +308,7 @@ def test_explore_music_filters_missing_covers_for_external_sources(): ] ) - with patch("app.api.endpoints.music.MusicChain", return_value=chain): + with patch("app.api.endpoints.music.RecommendChain", return_value=chain): result = asyncio.run( explore_music( media_source="doubanmusic", @@ -328,7 +323,7 @@ def test_explore_music_filters_missing_covers_for_external_sources(): def test_music_album_returns_tracks_and_releases(): """专辑接口应返回专辑详情、曲目和发行版本。""" chain = Mock() - chain.async_album = AsyncMock( + chain.async_get_music_album = AsyncMock( return_value=MusicAlbumInfo( media_source="musicbrainz", media_id="release-group-1", @@ -342,7 +337,7 @@ def test_music_album_returns_tracks_and_releases(): ) ) - with patch("app.api.endpoints.music.MusicChain", return_value=chain): + with patch("app.api.endpoints.music.MediaChain", return_value=chain): result = asyncio.run(music_album(album_id="release-group-1", _=Mock())) assert result.music_type == "album" @@ -350,16 +345,18 @@ def test_music_album_returns_tracks_and_releases(): assert result.total_tracks == 1 assert result.tracks[0].media_id == "recording-1" assert result.releases[0].media_id == "release-1" - chain.async_album.assert_awaited_once_with(media_source="musicbrainz", media_id="release-group-1") + chain.async_get_music_album.assert_awaited_once_with( + media_source=MediaSource.MusicBrainz, media_id="release-group-1" + ) def test_music_album_returns_404_for_unknown_album(): """专辑不存在时接口应返回 404。""" chain = Mock() - chain.async_album = AsyncMock(return_value=None) + chain.async_get_music_album = AsyncMock(return_value=None) with ( - patch("app.api.endpoints.music.MusicChain", return_value=chain), + patch("app.api.endpoints.music.MediaChain", return_value=chain), pytest.raises(HTTPException) as error, ): asyncio.run(music_album(album_id="missing", _=Mock())) @@ -370,7 +367,7 @@ def test_music_album_returns_404_for_unknown_album(): def test_music_album_related_returns_source_results(): """专辑关联浏览接口应传递来源和数量并序列化结果。""" chain = Mock() - chain.async_album_related = AsyncMock( + chain.async_get_music_album_related = AsyncMock( return_value=[ MusicInfo( media_source="doubanmusic", @@ -381,7 +378,7 @@ def test_music_album_related_returns_source_results(): ] ) - with patch("app.api.endpoints.music.MusicChain", return_value=chain): + with patch("app.api.endpoints.music.MediaChain", return_value=chain): result = asyncio.run( music_album_related( album_id="album-1", @@ -392,8 +389,8 @@ def test_music_album_related_returns_source_results(): ) assert result[0].media_id == "album-2" - chain.async_album_related.assert_awaited_once_with( - media_source="doubanmusic", + chain.async_get_music_album_related.assert_awaited_once_with( + media_source=MediaSource.DoubanMusic, media_id="album-1", count=12, ) @@ -402,7 +399,7 @@ def test_music_album_related_returns_source_results(): def test_music_artist_returns_detail(): """艺术家接口应返回名称、类型和活跃时间。""" chain = Mock() - chain.async_artist = AsyncMock( + chain.async_get_music_artist = AsyncMock( return_value=MusicArtistInfo( media_source="musicbrainz", media_id="artist-1", @@ -412,19 +409,21 @@ def test_music_artist_returns_detail(): ) ) - with patch("app.api.endpoints.music.MusicChain", return_value=chain): + with patch("app.api.endpoints.music.MediaChain", return_value=chain): result = asyncio.run(music_artist(artist_id="artist-1", _=Mock())) assert result.name == "Queen" assert result.title == "Queen" assert result.music_type == "artist" - chain.async_artist.assert_awaited_once_with(media_source="musicbrainz", media_id="artist-1") + chain.async_get_music_artist.assert_awaited_once_with( + media_source=MediaSource.MusicBrainz, media_id="artist-1" + ) def test_music_artist_albums_forwards_pagination_and_type(): """艺术家专辑接口应传递分页和专辑类型筛选。""" chain = Mock() - chain.async_artist_albums = AsyncMock( + chain.async_get_music_artist_albums = AsyncMock( return_value=[ MusicInfo( media_source="musicbrainz", @@ -435,14 +434,14 @@ def test_music_artist_albums_forwards_pagination_and_type(): ] ) - with patch("app.api.endpoints.music.MusicChain", return_value=chain): + with patch("app.api.endpoints.music.MediaChain", return_value=chain): result = asyncio.run( music_artist_albums(artist_id="artist-1", page=2, count=10, album_type="ep", _=Mock()) ) assert result[0].media_id == "release-group-1" - chain.async_artist_albums.assert_awaited_once_with( - media_source="musicbrainz", + chain.async_get_music_artist_albums.assert_awaited_once_with( + media_source=MediaSource.MusicBrainz, media_id="artist-1", page=2, count=10, @@ -453,7 +452,7 @@ def test_music_artist_albums_forwards_pagination_and_type(): def test_music_artist_related_returns_relationship_text(): """关联艺术家接口应返回关系说明,供详情页展示。""" chain = Mock() - chain.async_artist_related = AsyncMock( + chain.async_get_music_artist_related = AsyncMock( return_value=[ MusicArtistInfo( media_source="musicbrainz", @@ -464,12 +463,12 @@ def test_music_artist_related_returns_relationship_text(): ] ) - with patch("app.api.endpoints.music.MusicChain", return_value=chain): + with patch("app.api.endpoints.music.MediaChain", return_value=chain): result = asyncio.run(music_artist_related(artist_id="artist-1", count=5, _=Mock())) assert result[0].relation == "member of band" - chain.async_artist_related.assert_awaited_once_with( - media_source="musicbrainz", + chain.async_get_music_artist_related.assert_awaited_once_with( + media_source=MediaSource.MusicBrainz, media_id="artist-1", count=5, ) diff --git a/tests/test_music_filemanager.py b/tests/test_music_filemanager.py index 0f942dd77..4a56b5f0a 100644 --- a/tests/test_music_filemanager.py +++ b/tests/test_music_filemanager.py @@ -29,7 +29,7 @@ def _audio_file(name: str, extension: str = "flac") -> FileItem: def _recording(**overrides) -> MusicInfo: """构造本地媒体库查重使用的单曲身份。""" values = { - "source": "musicbrainz", + "media_source": "musicbrainz", "media_id": "recording-1", "title": "Get Lucky", "artists": ["Daft Punk"], diff --git a/tests/test_music_metadata_sources.py b/tests/test_music_metadata_sources.py index af6de2b4b..34039c9e2 100644 --- a/tests/test_music_metadata_sources.py +++ b/tests/test_music_metadata_sources.py @@ -5,12 +5,12 @@ from unittest.mock import AsyncMock, Mock, call import pytest from app.chain.media import MediaChain -from app.chain.music import MusicChain +from app.chain.scraping import ScrapingChain from app.core.context import MUSIC_ENTITY_ALBUM, MusicInfo from app.core.meta import MetaMusic from app.modules.douban import DoubanModule from app.modules.theaudiodb import TheAudioDbModule -from app.schemas.types import MediaRecognizeType, MediaType +from app.schemas.types import MediaRecognizeType, MediaSource, MediaType def test_theaudiodb_module_maps_track_and_album(monkeypatch): @@ -37,7 +37,7 @@ def test_theaudiodb_module_maps_track_and_album(monkeypatch): results = module.search_music( MetaMusic(title="Yellow", artists=["Coldplay"]), - media_source="theaudiodb", + media_source=MediaSource.TheAudioDB, ) assert results and len(results) == 1 @@ -54,7 +54,9 @@ def test_theaudiodb_module_ignores_other_sources(monkeypatch): request = Mock() monkeypatch.setattr(module, "_request_json", request) - searched = module.search_music(MetaMusic(title="Yellow"), media_source="musicbrainz") + searched = module.search_music( + MetaMusic(title="Yellow"), media_source=MediaSource.MusicBrainz + ) recognized = module.recognize_media( meta=MetaMusic(title="Yellow"), media_source="doubanmusic", @@ -65,6 +67,83 @@ def test_theaudiodb_module_ignores_other_sources(monkeypatch): request.assert_not_called() +def test_theaudiodb_title_only_search_skips_incomplete_track_and_album_requests(monkeypatch): + """缺少艺术家时只搜索艺术家,避免请求会返回空正文的曲目和专辑接口。""" + module = TheAudioDbModule() + request = Mock(return_value={"artists": []}) + monkeypatch.setattr(module, "_request_json", request) + + results = module.search_music( + MetaMusic(title="Yellow"), + media_source=MediaSource.TheAudioDB, + ) + + assert results == [] + request.assert_called_once_with("search.php", {"s": "Yellow"}) + + +def test_theaudiodb_search_uses_album_artist_for_required_artist_parameter(monkeypatch): + """音轨没有独立艺术家时应使用专辑艺术家补齐 TheAudioDB 必填参数。""" + module = TheAudioDbModule() + request = Mock(side_effect=[{"track": []}, {"album": []}]) + monkeypatch.setattr(module, "_request_json", request) + meta = MetaMusic( + title="Yellow", + album="Parachutes", + album_artist="Coldplay", + ) + + assert module._search_tracks(meta) == [] + assert module._search_albums(meta) == [] + assert request.call_args_list == [ + call("searchtrack.php", {"t": "Yellow", "s": "Coldplay"}), + call("searchalbum.php", {"a": "Parachutes", "s": "Coldplay"}), + ] + + +def test_theaudiodb_empty_response_is_soft_failure_and_closes_response(monkeypatch): + """TheAudioDB 返回 HTTP 200 空正文时应软失败并及时关闭同步连接。""" + response = Mock( + status_code=200, + content=b"", + text="", + headers={"Content-Type": "text/html; charset=UTF-8"}, + ) + get_res = Mock(return_value=response) + monkeypatch.setattr("app.modules.theaudiodb.RequestUtils.get_res", get_res) + TheAudioDbModule._request_json.cache_clear() + + result = TheAudioDbModule._request_json("searchtrack.php", {"t": "Yellow"}) + + assert result is None + response.json.assert_not_called() + response.close.assert_called_once_with() + + +@pytest.mark.asyncio +async def test_theaudiodb_async_empty_response_is_soft_failure_and_closes_response(monkeypatch): + """TheAudioDB 返回 HTTP 200 空正文时应软失败并及时关闭异步连接。""" + response = Mock( + status_code=200, + content=b"", + text="", + headers={"Content-Type": "text/html; charset=UTF-8"}, + ) + response.aclose = AsyncMock() + get_res = AsyncMock(return_value=response) + monkeypatch.setattr("app.modules.theaudiodb.AsyncRequestUtils.get_res", get_res) + await TheAudioDbModule._async_request_json.cache_clear() + + result = await TheAudioDbModule._async_request_json( + "searchalbum.php", + {"a": "Parachutes"}, + ) + + assert result is None + response.json.assert_not_called() + response.aclose.assert_awaited_once_with() + + def test_theaudiodb_detail_respects_requested_entity(monkeypatch): """TheAudioDB 数值 ID 必须按显式实体调用对应接口,不能跨表探测。""" module = TheAudioDbModule() @@ -126,8 +205,8 @@ def test_douban_music_search_and_album_mapping(monkeypatch): "target": { "id": "1401853", "title": "范特西", - "artists": [{"name": "周杰伦"}], - "year": "2001", + # 豆瓣真实搜索响应只在卡片副标题提供艺术家和年份。 + "card_subtitle": "周杰伦 / 2001", }, }] } @@ -157,12 +236,15 @@ def test_douban_music_search_and_album_mapping(monkeypatch): results = module.search_music( MetaMusic(title="范特西", artists=["周杰伦"]), - media_source="doubanmusic", + media_source=MediaSource.DoubanMusic, ) album = module.music_album("doubanmusic", "1401853") assert results and results[0].media_source == "doubanmusic" assert results[0].music_type == MUSIC_ENTITY_ALBUM + assert results[0].artists == ["周杰伦"] + assert results[0].artist == "周杰伦" + assert results[0].album_artist == "周杰伦" assert album and album.media_source == "doubanmusic" assert album.year == 2001 assert album.artists == ["周杰伦"] @@ -173,6 +255,28 @@ def test_douban_music_search_and_album_mapping(monkeypatch): assert album.tracks[1].cover_url == "https://img.example/track.jpg" +@pytest.mark.parametrize( + ("subtitle", "expected"), + [ + ("Mr Hudson Vic Mensa / 2017", ["Mr Hudson Vic Mensa"]), + ("周杰伦 / 2001 / CD / 阿尔发音乐", ["周杰伦"]), + ("周杰伦", ["周杰伦"]), + ("2007", []), + ], +) +def test_douban_music_card_subtitle_artist_fallback(subtitle, expected): + """搜索副标题回退应保留完整署名,且不能把纯年份误判为艺术家。""" + assert DoubanModule._douban_music_search_artists({"card_subtitle": subtitle}) == expected + + +def test_douban_music_search_prefers_structured_artists_over_card_subtitle(): + """搜索响应已有结构化艺术家时不能被卡片副标题覆盖。""" + assert DoubanModule._douban_music_search_artists({ + "artists": [{"name": "结构化艺术家"}], + "card_subtitle": "回退艺术家 / 2024", + }) == ["结构化艺术家"] + + def test_douban_music_discover_and_related_accept_collection_wrappers(monkeypatch): """豆瓣新碟榜与相关推荐应兼容 subject 包装并保留专辑身份。""" module = DoubanModule() @@ -356,19 +460,82 @@ async def test_douban_recognize_media_routes_only_douban_music(monkeypatch): @pytest.mark.asyncio -async def test_music_chain_defaults_to_musicbrainz_and_forwards_explicit_source(monkeypatch): +async def test_media_chain_defaults_to_musicbrainz_and_forwards_explicit_source(monkeypatch): """音乐搜索默认 MusicBrainz,手动选择时原样转发其它音乐源。""" - chain = MusicChain() - run_module = Mock(return_value=[]) - async_run_module = AsyncMock(return_value=[]) - monkeypatch.setattr(chain, "run_module", run_module) - monkeypatch.setattr(chain, "async_run_module", async_run_module) + chain = MediaChain() + musicbrainz = Mock() + musicbrainz.search_music.return_value = [] + douban = Mock() + douban.async_search_music = AsyncMock(return_value=[]) + monkeypatch.setattr( + chain, + "_music_source_chain", + Mock(side_effect=[musicbrainz, douban]), + ) - chain.search("Yellow") - await chain.async_search("范特西", media_source="doubanmusic") + chain.search_music("Yellow") + await chain.async_search_music( + "范特西", media_source=MediaSource.DoubanMusic + ) - assert run_module.call_args.kwargs["media_source"] == "musicbrainz" - assert async_run_module.await_args.kwargs["media_source"] == "doubanmusic" + assert chain._music_source_chain.call_args_list[0].args[0] == "musicbrainz" + assert chain._music_source_chain.call_args_list[1].args[0] == "doubanmusic" + assert musicbrainz.search_music.call_args.args[0].title == "Yellow" + assert douban.async_search_music.await_args.args[0].title == "范特西" + + +@pytest.mark.asyncio +async def test_media_chain_aggregates_music_sources_and_isolates_source_failure(monkeypatch): + """多音乐来源应按选择顺序聚合,单一来源失败不能清空其它来源结果。""" + chain = MediaChain() + musicbrainz = Mock() + musicbrainz.async_search_music = AsyncMock(return_value=[ + MusicInfo( + media_source="musicbrainz", + media_id="recording-1", + title="Yellow", + ) + ]) + theaudiodb = Mock() + theaudiodb.async_search_music = AsyncMock(side_effect=ValueError("empty response")) + douban = Mock() + douban.async_search_music = AsyncMock(return_value=[ + MusicInfo( + media_source="doubanmusic", + media_id="album-1", + music_type="album", + title="Yellow", + ) + ]) + source_chains = { + "musicbrainz": musicbrainz, + "theaudiodb": theaudiodb, + "doubanmusic": douban, + } + select_chain = Mock(side_effect=lambda source: source_chains[str(source)]) + monkeypatch.setattr(chain, "_music_source_chain", select_chain) + + results = await chain.async_search_music( + "Yellow", + limit=30, + media_source=( + MediaSource.MusicBrainz, + MediaSource.TheAudioDB, + MediaSource.DoubanMusic, + MediaSource.MusicBrainz, + ), + ) + + assert [(str(item.media_source), item.media_id) for item in results] == [ + ("musicbrainz", "recording-1"), + ("doubanmusic", "album-1"), + ] + assert [str(item.args[0]) for item in select_chain.call_args_list] == [ + "musicbrainz", + "theaudiodb", + "doubanmusic", + ] + theaudiodb.async_search_music.assert_awaited_once() def test_music_scrape_resolves_with_selected_source(tmp_path, monkeypatch): @@ -377,13 +544,17 @@ def test_music_scrape_resolves_with_selected_source(tmp_path, monkeypatch): path.write_bytes(b"audio") expected = MusicInfo(media_source="theaudiodb", media_id="32793500", title="Yellow") recognize = Mock(return_value=(MetaMusic(title="Yellow"), expected)) - monkeypatch.setattr(MediaChain, "recognize_music_by_path", recognize) + media_chain = Mock() + media_chain.recognize_music_by_path = recognize + monkeypatch.setattr("app.chain.scraping.MediaChain", Mock(return_value=media_chain)) - result = MediaChain._resolve_music_scrape_info( + result = ScrapingChain._resolve_music_scrape_info( path, mediainfo=None, - media_source="theaudiodb", + media_source=MediaSource.TheAudioDB, ) assert result is expected - recognize.assert_called_once_with(path, media_source="theaudiodb") + recognize.assert_called_once_with( + path, media_source=MediaSource.TheAudioDB + ) diff --git a/tests/test_music_plugin_recognize.py b/tests/test_music_plugin_recognize.py index d2f2800a2..79d32d3cf 100644 --- a/tests/test_music_plugin_recognize.py +++ b/tests/test_music_plugin_recognize.py @@ -11,7 +11,7 @@ from app.chain.media import MediaChain from app.core.context import MediaInfo, MusicInfo from app.core.event import Event from app.core.meta import MetaBase, MetaMusic -from app.schemas.types import ChainEventType, MediaType +from app.schemas.types import ChainEventType, MediaSource, MediaType def _fallback_music(title: str = "晴天", **kwargs) -> MusicInfo: @@ -51,7 +51,7 @@ def test_music_recognize_help_sends_event_and_rematches(monkeypatch): remote = _remote_music() recognize_calls = [] - def fake_recognize_media(meta=None, source=None, **kwargs): + def fake_recognize_media(meta=None, media_source=None, **kwargs): recognize_calls.append(meta) # 首次返回无身份兜底,辅助识别修正要素后二次识别命中远端 return remote if len(recognize_calls) > 1 else _fallback_music(title=meta.title) @@ -157,7 +157,7 @@ def test_async_music_recognize_help(monkeypatch): remote = _remote_music() recognize_calls = [] - async def fake_async_recognize_media(meta=None, source=None, **kwargs): + async def fake_async_recognize_media(meta=None, media_source=None, **kwargs): recognize_calls.append(meta) return remote if len(recognize_calls) > 1 else _fallback_music(title=meta.title) @@ -208,7 +208,7 @@ def test_chain_supplement_music_recognize_uses_plugin_result(): meta = MetaMusic(title="晴天", artists=["周杰伦"], album="叶惠美") plugin_data = { "mediainfo": { - "source": "qqmusic", + "media_source": "theaudiodb", "media_id": "song-123", "title": "晴天", "artists": ["周杰伦"], @@ -222,14 +222,14 @@ def test_chain_supplement_music_recognize_uses_plugin_result(): result = chain._supplement_media_recognize( meta=meta, mtype=None, - source=None, - mediaid=None, + media_source=None, + media_id=None, mediainfo=None, music_type="recording", ) assert isinstance(result, MusicInfo) - assert result.source == "qqmusic" + assert result.media_source == MediaSource.TheAudioDB assert result.media_id == "song-123" # 音乐请求使用音乐媒体识别事件,载荷携带已知要素 assert sender.call_args.args[0] == ChainEventType.MusicMediaRecognize @@ -246,7 +246,7 @@ def test_chain_supplement_music_rejects_cross_entity_plugin_result(): fallback = _fallback_music(title="叶惠美") event = Event(ChainEventType.MusicMediaRecognize, { "mediainfo": { - "source": "qqmusic", + "media_source": "theaudiodb", "media_id": "song-123", "music_type": "recording", "title": "叶惠美", @@ -258,8 +258,8 @@ def test_chain_supplement_music_rejects_cross_entity_plugin_result(): result = chain._supplement_media_recognize( meta=MetaMusic(title="叶惠美"), mtype=MediaType.MUSIC, - source="qqmusic", - mediaid="album-123", + media_source=MediaSource.TheAudioDB, + media_id="album-123", mediainfo=fallback, music_type="album", ) @@ -274,7 +274,8 @@ def test_chain_supplement_video_recognize_uses_plugin_result(): meta.type = MediaType.MOVIE plugin_data = { "mediainfo": { - "source": "themoviedb", + "media_source": "themoviedb", + "media_id": "603", "tmdb_id": 603, "title": "黑客帝国", "year": "1999", @@ -284,7 +285,11 @@ def test_chain_supplement_video_recognize_uses_plugin_result(): with patch.object(chain.eventmanager, "check", return_value=True), \ patch.object(chain.eventmanager, "send_event", return_value=event) as sender: result = chain._supplement_media_recognize( - meta=meta, mtype=MediaType.MOVIE, source=None, mediaid=None, mediainfo=None + meta=meta, + mtype=MediaType.MOVIE, + media_source=None, + media_id=None, + mediainfo=None, ) assert isinstance(result, MediaInfo) @@ -301,12 +306,16 @@ def test_chain_supplement_media_recognize_requires_identity(): meta = MetaMusic(title="晴天") fallback = _fallback_music(title="晴天") event = Event(ChainEventType.MusicMediaRecognize, { - "mediainfo": {"source": "qqmusic", "title": "晴天"}, + "mediainfo": {"media_source": "theaudiodb", "title": "晴天"}, }) with patch.object(chain.eventmanager, "check", return_value=True), \ patch.object(chain.eventmanager, "send_event", return_value=event): result = chain._supplement_media_recognize( - meta=meta, mtype=None, source=None, mediaid=None, mediainfo=fallback + meta=meta, + mtype=None, + media_source=None, + media_id=None, + mediainfo=fallback, ) assert result is fallback @@ -314,13 +323,13 @@ def test_chain_supplement_media_recognize_requires_identity(): meta_video = MetaBase("Some.Movie") meta_video.type = MediaType.MOVIE event_video = Event(ChainEventType.MediaRecognize, { - "mediainfo": {"source": "themoviedb", "title": "某部电影"}, + "mediainfo": {"media_source": "themoviedb", "title": "某部电影"}, }) with patch.object(chain.eventmanager, "check", return_value=True), \ patch.object(chain.eventmanager, "send_event", return_value=event_video): result = chain._supplement_media_recognize( meta=meta_video, mtype=MediaType.MOVIE, - source=None, mediaid=None, mediainfo=None, + media_source=None, media_id=None, mediainfo=None, ) assert result is None @@ -332,7 +341,11 @@ def test_chain_supplement_media_recognize_skips_identified_result(): remote = _remote_music() with patch.object(chain.eventmanager, "check") as checker: result = chain._supplement_media_recognize( - meta=meta, mtype=None, source=None, mediaid=None, mediainfo=remote + meta=meta, + mtype=None, + media_source=None, + media_id=None, + mediainfo=remote, ) assert result is remote @@ -345,21 +358,21 @@ def test_chain_recognize_media_music_plugin_supplement(): meta = MetaMusic(title="晴天", artists=["周杰伦"]) fallback = _fallback_music(title="晴天") plugin_music = MusicInfo( - source="qqmusic", + media_source=MediaSource.TheAudioDB, media_id="song-123", title="晴天", artists=["周杰伦"], ) event = Event(ChainEventType.MusicMediaRecognize, {"mediainfo": plugin_music.to_dict()}) - with patch("app.chain.music.MusicChain.recognize_best", return_value=fallback), \ + with patch.object(chain, "recognize_music_from_source", return_value=fallback), \ patch.object(chain.eventmanager, "check", return_value=True), \ patch.object(chain.eventmanager, "send_event", return_value=event), \ patch("app.chain.MoviePilotServerHelper.report_recognize_share") as report_mock: result = chain.recognize_media(meta=meta, cache=False) assert result is not fallback - assert result.source == "qqmusic" + assert result.media_source == MediaSource.TheAudioDB report_mock.assert_called_once() assert report_mock.call_args.kwargs["mediainfo"] is result @@ -369,16 +382,51 @@ def test_chain_async_supplement_media_recognize(): chain = ChainBase() meta = MetaMusic(title="晴天") event = Event(ChainEventType.MusicMediaRecognize, { - "mediainfo": {"source": "qqmusic", "media_id": "song-1", "title": "晴天"}, + "mediainfo": { + "media_source": "theaudiodb", + "media_id": "song-1", + "title": "晴天", + }, }) with patch.object(chain.eventmanager, "check", return_value=True), \ patch.object(chain.eventmanager, "async_send_event", AsyncMock(return_value=event)): result = asyncio.run( chain._async_supplement_media_recognize( - meta=meta, mtype=None, source=None, mediaid=None, mediainfo=None + meta=meta, + mtype=None, + media_source=None, + media_id=None, + mediainfo=None, ) ) assert isinstance(result, MusicInfo) - assert result.source == "qqmusic" + assert result.media_source == MediaSource.TheAudioDB assert result.media_id == "song-1" + + +def test_chain_supplement_rejects_unknown_media_source(): + """插件返回非固定枚举来源时不得进入统一识别链。""" + chain = ChainBase() + event = Event( + ChainEventType.MusicMediaRecognize, + { + "mediainfo": { + "media_source": "qqmusic", + "media_id": "song-1", + "title": "晴天", + } + }, + ) + with patch.object(chain.eventmanager, "check", return_value=True), patch.object( + chain.eventmanager, "send_event", return_value=event + ): + result = chain._supplement_media_recognize( + meta=MetaMusic(title="晴天"), + mtype=MediaType.MUSIC, + media_source=None, + media_id=None, + mediainfo=None, + ) + + assert result is None diff --git a/tests/test_music_recognize_cache.py b/tests/test_music_recognize_cache.py index 691a54e91..f0cf3948b 100644 --- a/tests/test_music_recognize_cache.py +++ b/tests/test_music_recognize_cache.py @@ -117,7 +117,7 @@ def _build_initialized_music_cache(monkeypatch, file_cache: _FileCacheStub, def _music_info(**kwargs) -> MusicInfo: """构造标准音乐识别结果,默认携带远端身份。""" defaults = { - "source": "musicbrainz", + "media_source": "musicbrainz", "media_id": "rec-1", "title": "晴天", "artists": ["周杰伦"], @@ -144,7 +144,12 @@ def test_music_cache_endpoints_require_superuser(): def test_music_cache_key_prefers_media_id(): """携带数据源原生 ID 的元数据应以 ID 作为缓存键主身份。""" cache = _build_music_cache({}) - meta = MetaMusic(title="晴天", artists=["周杰伦"], media_id="rec-1") + meta = MetaMusic( + title="晴天", + artists=["周杰伦"], + media_source="musicbrainz", + media_id="rec-1", + ) cache.update(meta, _music_info()) @@ -431,6 +436,6 @@ def test_module_update_recognize_cache_only_for_musicbrainz_music(): assert module.update_recognize_cache(meta=meta, mediainfo=_music_info()) is True assert cache.get(meta) is not None - other_source = _music_info(source="other", media_id="x-1") + other_source = _music_info(media_source="theaudiodb", media_id="x-1") assert module.update_recognize_cache(meta=meta, mediainfo=other_source) is None assert module.update_recognize_cache(meta=None, mediainfo=_music_info()) is None diff --git a/tests/test_music_recognize_routing.py b/tests/test_music_recognize_routing.py index 970411540..cc129ba89 100644 --- a/tests/test_music_recognize_routing.py +++ b/tests/test_music_recognize_routing.py @@ -8,8 +8,8 @@ import asyncio from unittest.mock import AsyncMock, Mock, patch +from app.chain.acoustid import AcoustIdChain from app.chain.media import MediaChain -from app.chain.music import MusicChain from app.core.context import MUSIC_ENTITY_ALBUM, MusicInfo from app.core.meta import MetaMusic from app.modules.anilist import AniListModule @@ -17,7 +17,7 @@ from app.modules.bangumi import BangumiModule from app.modules.musicbrainz import MusicBrainzModule from app.modules.theaudiodb import TheAudioDbModule from app.modules.themoviedb import TheMovieDbModule -from app.schemas.types import MediaType +from app.schemas.types import MediaSource, MediaType def _music_info() -> MusicInfo: @@ -48,7 +48,7 @@ def test_media_chain_recognize_by_meta_routes_metamusic_to_module(monkeypatch): call_kwargs = chain.recognize_media.call_args.kwargs assert call_kwargs["meta"] is meta assert call_kwargs["mtype"] == MediaType.MUSIC - assert call_kwargs["source"] == "musicbrainz" + assert call_kwargs["media_source"] == "musicbrainz" assert result is expected @@ -69,7 +69,7 @@ def test_media_chain_async_recognize_by_meta_routes_metamusic_to_module(monkeypa call_kwargs = chain.async_recognize_media.await_args.kwargs assert call_kwargs["meta"] is meta assert call_kwargs["mtype"] == MediaType.MUSIC - assert call_kwargs["source"] == "musicbrainz" + assert call_kwargs["media_source"] == "musicbrainz" assert result is expected @@ -98,7 +98,7 @@ def test_media_chain_recognize_by_path_routes_musicbrainz_source_to_music_chain( context = MediaChain().recognize_by_path("/downloads/晴天", media_source="musicbrainz") recognize_music.assert_called_once() - assert recognize_music.call_args.kwargs["source"] == "musicbrainz" + assert recognize_music.call_args.kwargs["media_source"] == "musicbrainz" assert context.media_info is expected_info @@ -118,13 +118,12 @@ def test_async_recognize_music_by_path_reads_local_audio_tags(tmp_path, monkeypa recognize = AsyncMock(return_value=info) filename_meta = MetaMusic(title="02. 眼泪成诗") monkeypatch.setattr( - MusicChain, - "read_path_evidence", + "app.chain.media.AudioMetadataHelper.read_evidence", Mock(return_value=(meta, meta, filename_meta)), ) monkeypatch.setattr( - MusicChain, - "async_identify_by_fingerprint", + AcoustIdChain, + "async_identify_music_by_fingerprint", AsyncMock(return_value=None), ) monkeypatch.setattr(chain, "async_recognize_media", recognize) @@ -137,7 +136,7 @@ def test_async_recognize_music_by_path_reads_local_audio_tags(tmp_path, monkeypa assert recognized_info is info recognize.assert_awaited_once_with( meta=meta, - source=None, + media_source=None, music_type="recording", ) @@ -157,13 +156,12 @@ def test_recognize_music_by_path_fingerprint_mbid_skips_later_tiers(monkeypatch) direct = Mock(return_value=expected) later_tier = Mock() monkeypatch.setattr( - MusicChain, - "read_path_evidence", + "app.chain.media.AudioMetadataHelper.read_evidence", Mock(return_value=(merged, tag_meta, filename_meta)), ) monkeypatch.setattr( - MusicChain, - "identify_by_fingerprint", + AcoustIdChain, + "identify_music_by_fingerprint", Mock(return_value=recording_id), ) monkeypatch.setattr(chain, "_recognize_musicbrainz_recording", direct) @@ -196,11 +194,14 @@ def test_recognize_music_by_path_tag_mbid_skips_multi_source_matching(monkeypatc generic = Mock() tier = Mock(wraps=chain._recognize_music_meta_tier) monkeypatch.setattr( - MusicChain, - "read_path_evidence", + "app.chain.media.AudioMetadataHelper.read_evidence", Mock(return_value=(tag_meta, tag_meta, filename_meta)), ) - monkeypatch.setattr(MusicChain, "identify_by_fingerprint", Mock(return_value=None)) + monkeypatch.setattr( + AcoustIdChain, + "identify_music_by_fingerprint", + Mock(return_value=None), + ) monkeypatch.setattr(chain, "_recognize_musicbrainz_recording", direct) monkeypatch.setattr(chain, "recognize_media", generic) monkeypatch.setattr(chain, "_recognize_music_meta_tier", tier) @@ -226,11 +227,14 @@ def test_recognize_music_by_path_falls_back_from_tags_to_filename(monkeypatch): chain = MediaChain() recognize = Mock(side_effect=[MusicInfo(title="Offline Tag"), expected]) monkeypatch.setattr( - MusicChain, - "read_path_evidence", + "app.chain.media.AudioMetadataHelper.read_evidence", Mock(return_value=(tag_meta, tag_meta, filename_meta)), ) - monkeypatch.setattr(MusicChain, "identify_by_fingerprint", Mock(return_value=None)) + monkeypatch.setattr( + AcoustIdChain, + "identify_music_by_fingerprint", + Mock(return_value=None), + ) monkeypatch.setattr(chain, "recognize_media", recognize) _, recognized_info = chain.recognize_music_by_path("track.flac") @@ -259,13 +263,12 @@ def test_async_recognize_music_by_path_fingerprint_mbid_skips_later_tiers(monkey direct = AsyncMock(return_value=expected) later_tier = AsyncMock() monkeypatch.setattr( - MusicChain, - "read_path_evidence", + "app.chain.media.AudioMetadataHelper.read_evidence", Mock(return_value=(merged, MetaMusic(), MetaMusic())), ) monkeypatch.setattr( - MusicChain, - "async_identify_by_fingerprint", + AcoustIdChain, + "async_identify_music_by_fingerprint", AsyncMock(return_value=recording_id), ) monkeypatch.setattr(chain, "_async_recognize_musicbrainz_recording", direct) @@ -284,7 +287,10 @@ def test_async_recognize_music_by_path_fingerprint_mbid_skips_later_tiers(monkey def test_musicbrainz_module_recognize_media_ignores_non_music(): """非音乐请求应直接返回 None,不占用影视识别管线。""" result = MusicBrainzModule().recognize_media( - meta=None, mtype=MediaType.MOVIE, source="themoviedb", mediaid="123" + meta=None, + mtype=MediaType.MOVIE, + media_source=MediaSource.TMDB, + media_id="123", ) assert result is None @@ -295,7 +301,7 @@ def test_chain_explicit_music_source_bypasses_generic_module_dispatch(monkeypatc chain = MediaChain() recognize_source = Mock(return_value=expected) generic_dispatch = Mock() - monkeypatch.setattr(MusicChain, "recognize_from_source", recognize_source) + monkeypatch.setattr(chain, "recognize_music_from_source", recognize_source) monkeypatch.setattr(chain, "run_module", generic_dispatch) monkeypatch.setattr(chain.eventmanager, "check", Mock(return_value=False)) @@ -305,45 +311,48 @@ def test_chain_explicit_music_source_bypasses_generic_module_dispatch(monkeypatc result = chain.recognize_media( mtype=MediaType.MUSIC, media_source="musicbrainz", - mediaid="recording-1", + media_id="recording-1", ) assert result is expected recognize_source.assert_called_once_with( - media_source="musicbrainz", + media_source=MediaSource.MusicBrainz, meta=None, - mediaid="recording-1", + media_id="recording-1", cache=True, ) generic_dispatch.assert_not_called() -def test_music_chain_rejects_cross_entity_detail_result(monkeypatch): +def test_media_chain_rejects_cross_entity_detail_result(monkeypatch): """指定专辑实体时,即使来源返回同 ID 的单曲也不得采信。""" - chain = MusicChain() - recognize_source = Mock(return_value=_music_info()) - monkeypatch.setattr(chain, "_recognize_from_source", recognize_source) + chain = MediaChain() + source_chain = Mock() + source_chain.recognize_music.return_value = _music_info() + monkeypatch.setattr(chain, "_music_source_chain", Mock(return_value=source_chain)) - result = chain.recognize_from_source( + result = chain.recognize_music_from_source( media_source="musicbrainz", - mediaid="recording-1", + media_id="recording-1", music_type=MUSIC_ENTITY_ALBUM, ) assert result is None - assert recognize_source.call_args.kwargs["music_type"] == MUSIC_ENTITY_ALBUM + assert source_chain.recognize_music.call_args.kwargs["music_type"] == MUSIC_ENTITY_ALBUM -def test_music_chain_rejects_replaced_explicit_identity(monkeypatch): +def test_media_chain_rejects_replaced_explicit_identity(monkeypatch): """显式 ID 识别不得用标题搜索得到的另一 ID 替换请求目标。""" - chain = MusicChain() + chain = MediaChain() replaced = _music_info() replaced.media_id = "recording-other" - monkeypatch.setattr(chain, "_recognize_from_source", Mock(return_value=replaced)) + source_chain = Mock() + source_chain.recognize_music.return_value = replaced + monkeypatch.setattr(chain, "_music_source_chain", Mock(return_value=source_chain)) - result = chain.recognize_from_source( + result = chain.recognize_music_from_source( media_source="musicbrainz", - mediaid="recording-requested", + media_id="recording-requested", music_type="recording", ) @@ -361,13 +370,13 @@ def test_chain_music_type_rejects_video_source_before_module_dispatch(monkeypatc sync_result = chain.recognize_media( mtype=MediaType.MUSIC, - source="themoviedb", - mediaid="123", + media_source=MediaSource.TMDB, + media_id="123", ) async_result = asyncio.run(chain.async_recognize_media( mtype=MediaType.MUSIC, - source="themoviedb", - mediaid="123", + media_source=MediaSource.TMDB, + media_id="123", )) assert sync_result is None @@ -400,10 +409,18 @@ def test_bangumi_module_recognize_media_ignores_music(monkeypatch): api = Mock() monkeypatch.setattr(module, "bangumiapi", api) - by_meta = module.recognize_media(meta=MetaMusic(title="晴天"), source="bangumi") - by_type = module.recognize_media(mtype=MediaType.MUSIC, bangumiid=123) + by_meta = module.recognize_media( + meta=MetaMusic(title="晴天"), media_source=MediaSource.Bangumi + ) + by_type = module.recognize_media( + mtype=MediaType.MUSIC, + media_source=MediaSource.Bangumi, + media_id="123", + ) async_result = asyncio.run( - module.async_recognize_media(meta=MetaMusic(title="晴天"), source="bangumi") + module.async_recognize_media( + meta=MetaMusic(title="晴天"), media_source=MediaSource.Bangumi + ) ) assert by_meta is None @@ -418,10 +435,18 @@ def test_anilist_module_recognize_media_ignores_music(monkeypatch): api = Mock() monkeypatch.setattr(module, "anilist_api", api) - by_meta = module.recognize_media(meta=MetaMusic(title="晴天"), source="anilist") - by_type = module.recognize_media(mtype=MediaType.MUSIC, anilistid=123) + by_meta = module.recognize_media( + meta=MetaMusic(title="晴天"), media_source=MediaSource.AniList + ) + by_type = module.recognize_media( + mtype=MediaType.MUSIC, + media_source=MediaSource.AniList, + media_id="123", + ) async_result = asyncio.run( - module.async_recognize_media(meta=MetaMusic(title="晴天"), source="anilist") + module.async_recognize_media( + meta=MetaMusic(title="晴天"), media_source=MediaSource.AniList + ) ) assert by_meta is None @@ -497,7 +522,9 @@ def test_musicbrainz_module_recognize_media_by_music_type_and_media_id(monkeypat monkeypatch.setattr(module, "recognize_music", Mock(return_value=expected)) result = module.recognize_media( - mtype=MediaType.MUSIC, media_source="musicbrainz", mediaid="recording-1" + mtype=MediaType.MUSIC, + media_source=MediaSource.MusicBrainz, + media_id="recording-1", ) module.recognize_music.assert_called_once_with("musicbrainz", "recording-1") @@ -550,7 +577,9 @@ def test_chain_recognize_media_returns_musicinfo_and_reports_share(): """MusicBrainz 自动识别返回的 MusicInfo 应只上报一次共享识别。""" expected = _music_info() chain = MediaChain() - with patch.object(MusicChain, "recognize_best", return_value=expected), patch( + with patch.object( + chain, "recognize_music_from_source", return_value=expected + ), patch( "app.helper.server.MoviePilotServerHelper.report_recognize_share" ) as report_mock: result = chain.recognize_media(meta=MetaMusic(title="晴天")) @@ -565,8 +594,8 @@ def test_chain_async_recognize_media_returns_musicinfo_and_reports_share(): expected = _music_info() chain = MediaChain() with patch.object( - MusicChain, - "async_recognize_best", + chain, + "async_recognize_music_from_source", AsyncMock(return_value=expected), ), patch( "app.helper.server.MoviePilotServerHelper.async_report_recognize_share", diff --git a/tests/test_music_scrape.py b/tests/test_music_scrape.py index f39415643..23d24bcb3 100644 --- a/tests/test_music_scrape.py +++ b/tests/test_music_scrape.py @@ -1,7 +1,7 @@ from types import SimpleNamespace from unittest.mock import Mock, patch -from app.chain.media import MediaChain, ScrapingConfig, _MusicScrapeFileResult +from app.chain.scraping import ScrapingChain, ScrapingConfig, _MusicScrapeFileResult from app.core.context import MUSIC_ENTITY_ALBUM, MusicAlbumInfo, MusicInfo, MusicLyrics from app.core.event import Event from app.core.meta import MetaMusic @@ -9,9 +9,9 @@ from app.schemas import FileItem from app.schemas.types import EventType, ScrapingPolicy -def _media_chain() -> MediaChain: +def _media_chain() -> ScrapingChain: """构造不注册全局单例的音乐刮削链测试实例。""" - return object.__new__(MediaChain) + return object.__new__(ScrapingChain) def _album_info() -> MusicInfo: @@ -42,7 +42,7 @@ def test_album_scrape_merge_preserves_track_fields_and_applies_album_identity() total_tracks=99, ) - merged = MediaChain._merge_music_album_metadata(local, _album_info()) + merged = ScrapingChain._merge_music_album_metadata(local, _album_info()) assert merged.title == "晴天" assert merged.artists == ["周杰伦"] @@ -105,16 +105,16 @@ def test_music_cover_download_uses_bounded_external_response_cache() -> None: ) request = Mock() request.get_res.return_value = response - MediaChain._request_music_cover.cache_clear() + ScrapingChain._request_music_cover.cache_clear() - with patch("app.chain.media.RequestUtils", return_value=request): - first = MediaChain._download_music_cover("https://example.com/album.webp") - second = MediaChain._download_music_cover("https://example.com/album.webp") + with patch("app.chain.scraping.RequestUtils", return_value=request): + first = ScrapingChain._download_music_cover("https://example.com/album.webp") + second = ScrapingChain._download_music_cover("https://example.com/album.webp") assert first == second == (b"cover", "image/webp") request.get_res.assert_called_once_with("https://example.com/album.webp") response.close.assert_called_once() - MediaChain._request_music_cover.cache_clear() + ScrapingChain._request_music_cover.cache_clear() def test_recording_identity_rejects_multi_track_directory_scrape() -> None: @@ -180,16 +180,18 @@ def test_album_track_match_uses_disc_track_title_and_duration() -> None: media_id="album-1", title="叶惠美", tracks=[ - MusicInfo( - media_id="recording-1", + MusicInfo( + media_source="musicbrainz", + media_id="recording-1", title="以父之名", artists=["周杰伦"], disc_number=1, track_number=1, duration=342, ), - MusicInfo( - media_id="recording-3", + MusicInfo( + media_source="musicbrainz", + media_id="recording-3", title="晴天", artists=["周杰伦"], disc_number=1, @@ -199,7 +201,7 @@ def test_album_track_match_uses_disc_track_title_and_duration() -> None: ], ) - matched = MediaChain._match_music_album_track( + matched = ScrapingChain._match_music_album_track( MetaMusic( title="03 - 晴天", artists=["周杰伦"], @@ -237,7 +239,7 @@ def test_music_scrape_can_run_lyrics_without_tags_or_cover() -> None: ) music_chain = Mock() - with patch("app.chain.media.MusicChain", return_value=music_chain): + with patch("app.chain.scraping.LrclibChain", return_value=music_chain): success, message = chain.scrape_music_metadata( FileItem( storage="local", @@ -254,7 +256,7 @@ def test_music_scrape_can_run_lyrics_without_tags_or_cover() -> None: call = chain._scrape_music_file.call_args assert call.kwargs["write_tags"] is False assert call.kwargs["with_cover"] is False - assert call.kwargs["music_chain"] is music_chain + assert call.kwargs["lyrics_chain"] is music_chain def test_write_music_lyrics_sidecar_creates_same_name_lrc(tmp_path) -> None: @@ -300,7 +302,7 @@ def test_missing_only_lyrics_skips_existing_sidecar(tmp_path) -> None: scrape_info=MetaMusic(title="晴天", artists=["周杰伦"]), lyrics_option=lyrics_option, overwrite=False, - music_chain=music_chain, + lyrics_chain=music_chain, album_info=None, ) @@ -471,7 +473,7 @@ def test_music_download_failure_is_attributed_only_to_enabled_outputs() -> None: tag_overwrite=False, with_cover=False, lyrics_option=lyrics_option, - music_chain=Mock(), + lyrics_chain=Mock(), ) metadata_only = chain._scrape_music_file( fileitem=fileitem, @@ -480,7 +482,7 @@ def test_music_download_failure_is_attributed_only_to_enabled_outputs() -> None: tag_overwrite=False, with_cover=False, lyrics_option=SimpleNamespace(is_skip=True), - music_chain=None, + lyrics_chain=None, ) assert lyrics_only.metadata_success is True diff --git a/tests/test_music_search.py b/tests/test_music_search.py index 21c46ba72..9702f3b83 100644 --- a/tests/test_music_search.py +++ b/tests/test_music_search.py @@ -4,7 +4,7 @@ from app.chain.search import SearchChain from app.core.meta import MetaMusic from app.core.context import MusicInfo from app.schemas.context import TorrentInfo -from app.schemas.types import MediaType +from app.schemas.types import MediaSource, MediaType def test_music_context_builder_keeps_only_music_category(): @@ -118,7 +118,7 @@ def test_search_by_id_routes_music_identity_to_recognize_and_process(): ): result = chain.search_by_id( media_source="musicbrainz", - mediaid="recording-1", + media_id="recording-1", mtype=MediaType.MUSIC, music_type="recording", sites=[1], @@ -126,12 +126,8 @@ def test_search_by_id_routes_music_identity_to_recognize_and_process(): assert result == expected media_chain.recognize_media.assert_called_once_with( - media_source="musicbrainz", - mediaid="recording-1", - tmdbid=None, - doubanid=None, - bangumiid=None, - anilistid=None, + media_source=MediaSource.MusicBrainz, + media_id="recording-1", mtype=MediaType.MUSIC, music_type="recording", ) diff --git a/tests/test_music_source_chains.py b/tests/test_music_source_chains.py new file mode 100644 index 000000000..c7fdd6fc5 --- /dev/null +++ b/tests/test_music_source_chains.py @@ -0,0 +1,126 @@ +import asyncio +from pathlib import Path +from unittest.mock import AsyncMock, Mock + +from app.chain.acoustid import AcoustIdChain +from app.chain.douban import DoubanChain +from app.chain.listenbrainz import ListenBrainzChain +from app.chain.lrclib import LrclibChain +from app.chain.musicbrainz import MusicBrainzChain +from app.chain.theaudiodb import TheAudioDbChain +from app.core.context import MusicAlbumInfo, MusicInfo, MusicLyrics +from app.core.meta import MetaMusic +from app.schemas.types import MediaSource + + +def test_musicbrainz_chain_fixes_source_on_search(monkeypatch) -> None: + """MusicBrainz 来源链应固定请求来源并拒绝跨来源候选。""" + chain = MusicBrainzChain() + run_module = Mock(return_value=[ + MusicInfo(media_source="musicbrainz", media_id="recording-1", title="One"), + MusicInfo(media_source="theaudiodb", media_id="track-2", title="Two"), + ]) + monkeypatch.setattr(chain, "run_module", run_module) + + result = chain.search_music(MetaMusic(title="One"), limit=5) + + assert [item.media_id for item in result] == ["recording-1"] + assert run_module.call_args.kwargs["media_source"] == MediaSource.MusicBrainz + + +def test_theaudiodb_chain_exposes_only_theaudiodb_results(monkeypatch) -> None: + """TheAudioDB 来源链应仅返回自身来源的标准音乐信息。""" + chain = TheAudioDbChain() + run_module = Mock(return_value=MusicInfo( + media_source="theaudiodb", + media_id="track-1", + title="Track", + )) + monkeypatch.setattr(chain, "run_module", run_module) + + result = chain.recognize_music(media_id="track-1") + + assert result and result.media_source == MediaSource.TheAudioDB + assert run_module.call_args.kwargs["media_source"] == MediaSource.TheAudioDB + + +def test_musicbrainz_album_rejects_mismatched_identity(monkeypatch) -> None: + """显式专辑 ID 查询不应接受另一身份的详情结果。""" + chain = MusicBrainzChain() + monkeypatch.setattr(chain, "run_module", Mock(return_value=MusicAlbumInfo( + media_source="musicbrainz", + media_id="other-album", + title="Other", + ))) + + assert chain.get_music_album("requested-album") is None + + +def test_douban_music_discover_fixes_source(monkeypatch) -> None: + """豆瓣音乐发现端口应固定 doubanmusic 而非影视 douban 来源。""" + chain = DoubanChain() + run_module = Mock(return_value=[ + MusicInfo(media_source="doubanmusic", media_id="album-1", title="Album") + ]) + monkeypatch.setattr(chain, "run_module", run_module) + + result = chain.music_discover(page=2, count=10) + + assert result[0].media_source == MediaSource.DoubanMusic + assert run_module.call_args.kwargs["media_source"] == MediaSource.DoubanMusic + + +def test_listenbrainz_chain_translates_page_to_offset(monkeypatch) -> None: + """ListenBrainz 来源链应把页码转换为模块使用的偏移量。""" + chain = ListenBrainzChain() + run_module = Mock(return_value=[ + MusicInfo(media_source="musicbrainz", media_id="recording-1", title="Track") + ]) + monkeypatch.setattr(chain, "run_module", run_module) + + result = chain.music_chart("this_week", page=3, count=10) + + assert result[0].media_id == "recording-1" + assert run_module.call_args.kwargs["offset"] == 20 + + +def test_acoustid_chain_normalizes_fingerprint_result(monkeypatch) -> None: + """AcoustID 来源链应把指纹结果规范化为 Recording ID 文本。""" + chain = AcoustIdChain() + run_module = Mock(return_value=" recording-1 ") + monkeypatch.setattr(chain, "run_module", run_module) + + result = chain.identify_music_by_fingerprint(Path("track.flac")) + + assert result == "recording-1" + + +def test_lrclib_chain_converts_dictionary_result(monkeypatch) -> None: + """LRCLIB 来源链应把字典结果转换为标准歌词对象。""" + chain = LrclibChain() + monkeypatch.setattr(chain, "run_module", Mock(return_value={ + "provider": "lrclib", + "provider_id": "1", + "synced_lyrics": "[00:01] Track", + })) + + result = chain.get_music_lyrics(MetaMusic(title="Track", artists=["Artist"])) + + assert isinstance(result, MusicLyrics) + assert result.extension == ".lrc" + + +def test_async_source_ports_use_async_dispatch(monkeypatch) -> None: + """来源链异步端口应通过 async_run_module 分发模块能力。""" + chain = MusicBrainzChain() + async_run_module = AsyncMock(return_value=MusicInfo( + media_source="musicbrainz", + media_id="recording-1", + title="Track", + )) + monkeypatch.setattr(chain, "async_run_module", async_run_module) + + result = asyncio.run(chain.async_recognize_music(media_id="recording-1")) + + assert result and result.media_id == "recording-1" + async_run_module.assert_awaited_once() diff --git a/tests/test_music_subscribe.py b/tests/test_music_subscribe.py index d955b306e..016c5eb3d 100644 --- a/tests/test_music_subscribe.py +++ b/tests/test_music_subscribe.py @@ -3,7 +3,6 @@ from unittest.mock import Mock, patch import pytest -from app.chain.music import MusicChain from app.chain.subscribe import SubscribeChain, build_subscribe_meta from app.core.context import ( MUSIC_ENTITY_ALBUM, @@ -14,7 +13,7 @@ from app.core.context import ( TorrentInfo, ) from app.core.meta import MetaMusic -from app.schemas.types import MediaType +from app.schemas.types import MediaSource, MediaType def _music_info() -> MusicInfo: @@ -115,7 +114,7 @@ def test_music_subscribe_recovers_completion_from_persisted_download_note(): def test_music_subscribe_reuses_search_download_and_finish_flow(): """音乐订阅应复用站点搜索、批量下载和订阅完成主流程。""" - subscribe = _subscribe() + subscribe = _subscribe(keyword="周杰伦 晴天") target = _music_info() context = Context( torrent_info=TorrentInfo( @@ -124,7 +123,7 @@ def test_music_subscribe_reuses_search_download_and_finish_flow(): ) ) search_chain = Mock() - search_chain.search_by_title.return_value = [context] + search_chain.search_by_title.side_effect = [[context], []] download_chain = Mock() download_chain.batch_download.return_value = ([context], None) chain = SubscribeChain() @@ -139,11 +138,8 @@ def test_music_subscribe_reuses_search_download_and_finish_flow(): subscribe_oper.return_value.get.return_value = subscribe chain._search_music_subscribe(subscribe) - search_chain.search_by_title.assert_called_once_with( - title="周杰伦 晴天", - sites=[], - mtype=MediaType.MUSIC, - rule_groups=[], + search_chain.search_by_title.assert_any_call( + title="周杰伦 晴天", sites=[], mtype=MediaType.MUSIC, rule_groups=[] ) download_chain.batch_download.assert_called_once() matched_context = download_chain.batch_download.call_args.kwargs["contexts"][0] @@ -329,7 +325,7 @@ def test_album_best_version_requires_confirmed_full_coverage(): artists=["周杰伦"], total_tracks=11, ) - meta = MusicChain.to_meta(album) + meta = MetaMusic.from_music_info(album) downloaded = Context( torrent_info=TorrentInfo( title="周杰伦 - 叶惠美 FLAC", @@ -473,15 +469,13 @@ def test_album_subscription_uses_persisted_snapshot_when_remote_detail_is_unavai media_chain = Mock() media_chain.recognize_media.return_value = None - with patch("app.chain.subscribe.MediaChain", return_value=media_chain), \ - patch("app.chain.subscribe.MusicChain.search") as search: + with patch("app.chain.subscribe.MediaChain", return_value=media_chain): restored = SubscribeChain._recognize_music_subscribe(subscribe) assert restored.music_type == MUSIC_ENTITY_ALBUM assert restored.album == "叶惠美" assert restored.artists == ["周杰伦"] assert restored.total_tracks == 11 - search.assert_not_called() def test_legacy_music_identity_failure_does_not_guess_entity_from_title(): @@ -490,12 +484,10 @@ def test_legacy_music_identity_failure_does_not_guess_entity_from_title(): media_chain = Mock() media_chain.recognize_media.return_value = None - with patch("app.chain.subscribe.MediaChain", return_value=media_chain), \ - patch("app.chain.subscribe.MusicChain.search") as search: + with patch("app.chain.subscribe.MediaChain", return_value=media_chain): restored = SubscribeChain._recognize_music_subscribe(subscribe) assert restored is None - search.assert_not_called() def test_album_subscription_without_remote_id_uses_persisted_entity_snapshot(): @@ -527,12 +519,10 @@ def test_legacy_music_without_identity_uses_recording_recognition_boundary(): media_chain = Mock() media_chain.recognize_media.return_value = recording - with patch("app.chain.subscribe.MediaChain", return_value=media_chain), \ - patch("app.chain.subscribe.MusicChain.search") as mixed_search: + with patch("app.chain.subscribe.MediaChain", return_value=media_chain): restored = SubscribeChain._recognize_music_subscribe(subscribe) assert restored is recording - mixed_search.assert_not_called() media_chain.recognize_media.assert_called_once() call = media_chain.recognize_media.call_args assert isinstance(call.kwargs["meta"], MetaMusic) @@ -733,26 +723,26 @@ def test_subscribe_add_music_uses_explicit_entity_recognize(): routed_meta = media_chain.recognize_media.call_args.kwargs["meta"] assert isinstance(routed_meta, MetaMusic) assert routed_meta.media_id == "recording-1" - assert media_chain.recognize_media.call_args.kwargs["source"] == "musicbrainz" + assert media_chain.recognize_media.call_args.kwargs["media_source"] == MediaSource.MusicBrainz assert media_chain.recognize_media.call_args.kwargs["music_type"] == MUSIC_ENTITY_RECORDING media_chain.recognize_by_meta.assert_not_called() @pytest.mark.parametrize( - ("source", "media_id", "title"), + ("media_source", "media_id", "title"), [ ("theaudiodb", "2109619", "Parachutes"), ("doubanmusic", "1401853", "范特西"), ], ) def test_subscribe_add_music_routes_new_album_sources( - source: str, + media_source: str, media_id: str, title: str, ): """新增音乐源的专辑订阅应保留来源、原生 ID 与实体类型。""" target = MusicInfo( - source=source, + media_source=media_source, media_id=media_id, music_type=MUSIC_ENTITY_ALBUM, title=title, @@ -772,7 +762,7 @@ def test_subscribe_add_music_routes_new_album_sources( title=title, year="2000", mtype=MediaType.MUSIC, - media_source=source, + media_source=media_source, media_id=media_id, music_type=MUSIC_ENTITY_ALBUM, message=False, @@ -781,10 +771,10 @@ def test_subscribe_add_music_routes_new_album_sources( assert sid == 1 assert err_msg == "" media_chain.recognize_media.assert_called_once() - assert media_chain.recognize_media.call_args.kwargs["source"] == source - assert media_chain.recognize_media.call_args.kwargs["mediaid"] == media_id + assert media_chain.recognize_media.call_args.kwargs["media_source"] == MediaSource(media_source) + assert media_chain.recognize_media.call_args.kwargs["media_id"] == media_id assert media_chain.recognize_media.call_args.kwargs["music_type"] == MUSIC_ENTITY_ALBUM - assert subscribe_oper.add.call_args.kwargs["media_source"] == source + assert subscribe_oper.add.call_args.kwargs["media_source"] == MediaSource(media_source) assert subscribe_oper.add.call_args.kwargs["media_id"] == media_id media_chain.recognize_by_meta.assert_not_called() diff --git a/tests/test_music_transfer.py b/tests/test_music_transfer.py index fd44fa686..02738a2f3 100644 --- a/tests/test_music_transfer.py +++ b/tests/test_music_transfer.py @@ -5,7 +5,6 @@ from unittest.mock import Mock from jinja2 import Template from app.chain.media import MediaChain -from app.chain.music import MusicChain from app.chain.transfer import JobManager, TransferChain from app.core.config import settings from app.core.meta import MetaMusic @@ -31,7 +30,7 @@ def _music_context() -> tuple[MetaMusic, MusicInfo]: total_tracks=13, category="Album", ) - return MusicChain.to_meta(info), info + return MetaMusic.from_music_info(info), info def _music_task(path: str, info: MusicInfo) -> TransferTask: @@ -46,7 +45,7 @@ def _music_task(path: str, info: MusicInfo) -> TransferTask: type="file", extension=file_path.suffix.lstrip("."), ), - meta=MusicChain.to_meta(info), + meta=MetaMusic.from_music_info(info), mediainfo=info, mtype=MediaType.MUSIC, ) @@ -237,7 +236,7 @@ def test_restore_music_context_from_download_history(): assert restored_meta is not None assert restored_info is not None assert restored_meta.org_string == "08 - Get Lucky.flac" - assert restored_info.source == "musicbrainz" + assert restored_info.media_source.value == "musicbrainz" assert restored_info.media_id == "recording-1" assert restored_info.album == "Random Access Memories" @@ -270,7 +269,7 @@ def test_restore_album_context_keeps_album_identity_and_track_specific_tags(tmp_ year=2003, total_tracks=11, ) - meta = MusicChain.to_meta(album) + meta = MetaMusic.from_music_info(album) history = SimpleNamespace(note={ "music": { "version": 1, diff --git a/tests/test_music_chain.py b/tests/test_music_workflows.py similarity index 70% rename from tests/test_music_chain.py rename to tests/test_music_workflows.py index 554a5be75..7f90bdb47 100644 --- a/tests/test_music_chain.py +++ b/tests/test_music_workflows.py @@ -1,17 +1,22 @@ +import asyncio from pathlib import Path -from unittest.mock import AsyncMock, Mock +from unittest.mock import AsyncMock, Mock, patch +from app.chain.acoustid import AcoustIdChain +from app.chain.listenbrainz import ListenBrainzChain from app.chain.media import MediaChain -from app.chain.music import MusicChain +from app.chain.musicbrainz import MusicBrainzChain +from app.chain.recommend import RecommendChain +from app.chain.search import SearchChain from app.core.context import MusicAlbumInfo, MusicArtistInfo, MusicInfo from app.core.meta import MetaMusic from app.modules.musicbrainz import MusicBrainzModule -from app.schemas.types import MediaType +from app.schemas.types import MediaSource, MediaType def test_parse_query_supports_artist_title_format(): """艺术家与标题格式应拆分为结构化搜索条件。""" - meta = MusicChain.parse_query(" 周杰伦 - 晴天 ") + meta = MetaMusic.parse_query(" 周杰伦 - 晴天 ") assert meta.artists == ["周杰伦"] assert meta.title == "晴天" @@ -20,7 +25,7 @@ def test_parse_query_supports_artist_title_format(): def test_parse_query_keeps_plain_title(): """普通文本应保留为歌曲或专辑标题。""" - meta = MusicChain.parse_query(" Random Access Memories ") + meta = MetaMusic.parse_query(" Random Access Memories ") assert meta.artists == [] assert meta.title == "Random Access Memories" @@ -28,7 +33,7 @@ def test_parse_query_keeps_plain_title(): def test_parse_query_strips_quality_tokens_before_artist_split(): """音质规格不应参与艺术家/曲名拆分,年份括号与格式后缀需剔除。""" - meta = MusicChain.parse_query("毛阿敏 - 永遠是朋友(2000) - ALAC [16B-44.1kHz]") + meta = MetaMusic.parse_query("毛阿敏 - 永遠是朋友(2000) - ALAC [16B-44.1kHz]") assert meta.artists == ["毛阿敏"] assert meta.title == "永遠是朋友" @@ -37,7 +42,7 @@ def test_parse_query_strips_quality_tokens_before_artist_split(): def test_parse_query_does_not_misplit_quality_only_tail(): """无艺术家时格式规格段不能被误拆成曲名,曲名不能丢字。""" - meta = MusicChain.parse_query("永遠是朋友(2000) - ALAC [16B-44.1kHz]") + meta = MetaMusic.parse_query("永遠是朋友(2000) - ALAC [16B-44.1kHz]") assert meta.artists == [] assert meta.title == "永遠是朋友" @@ -45,13 +50,13 @@ def test_parse_query_does_not_misplit_quality_only_tail(): def test_parse_query_strips_trailing_artist_suffix(): """曲名尾部重复的艺术家署名应被剥离,不作为曲名参与检索。""" - meta = MusicChain.parse_query("毛阿敏 - 名人名曲-毛阿敏(2000)") + meta = MetaMusic.parse_query("毛阿敏 - 名人名曲-毛阿敏(2000)") assert meta.artists == ["毛阿敏"] assert meta.title == "名人名曲" assert meta.year == 2000 - meta = MusicChain.parse_query("许茹芸 - 争奇斗艳演唱会实况 2 - 许茹芸 (1996)") + meta = MetaMusic.parse_query("许茹芸 - 争奇斗艳演唱会实况 2 - 许茹芸 (1996)") assert meta.artists == ["许茹芸"] assert meta.title == "争奇斗艳演唱会实况 2" @@ -59,7 +64,7 @@ def test_parse_query_strips_trailing_artist_suffix(): def test_parse_query_keeps_non_artist_suffix(): """曲名尾段与艺术家不一致时不应被误剥离。""" - meta = MusicChain.parse_query("毛阿敏 - 思念 - 现场版") + meta = MetaMusic.parse_query("毛阿敏 - 思念 - 现场版") assert meta.artists == ["毛阿敏"] assert meta.title == "思念 - 现场版" @@ -74,7 +79,7 @@ def test_build_site_keywords_prefers_artist_album(): album="Random Access Memories", ) - assert MusicChain.build_site_keywords(info) == [ + assert SearchChain.music_site_keywords(info) == [ "Daft Punk Random Access Memories", "Random Access Memories", ] @@ -89,7 +94,7 @@ def test_build_site_keywords_keeps_recording_out_of_album_search(): album="Random Access Memories", ) - assert MusicChain.build_site_keywords(info) == [ + assert SearchChain.music_site_keywords(info) == [ "Daft Punk Get Lucky", "Get Lucky", ] @@ -104,11 +109,11 @@ def test_album_resource_match_requires_selected_album_title(): names=["Random-Access Memories"], ) - assert MusicChain.matches_site_resource( + assert SearchChain.matches_music_resource( album, "Daft.Punk-Random.Access.Memories-2013-FLAC", ) is True - assert MusicChain.matches_site_resource(album, "Daft Punk - Discovery - FLAC") is False + assert SearchChain.matches_music_resource(album, "Daft Punk - Discovery - FLAC") is False def test_recording_resource_match_does_not_treat_album_name_as_track_alias(): @@ -120,8 +125,8 @@ def test_recording_resource_match_does_not_treat_album_name_as_track_alias(): names=["Get Lucky", "Random Access Memories"], ) - assert MusicChain.matches_site_resource(recording, "Daft Punk - Get Lucky FLAC") is True - assert MusicChain.matches_site_resource( + assert SearchChain.matches_music_resource(recording, "Daft Punk - Get Lucky FLAC") is True + assert SearchChain.matches_music_resource( recording, "Daft Punk - Random Access Memories FLAC", ) is False @@ -135,19 +140,19 @@ def test_resource_match_requires_artist_when_target_artist_is_known(): artists=["周杰伦"], ) - assert MusicChain.matches_site_resource(recording, "周杰伦 - 晴天 FLAC") is True - assert MusicChain.matches_site_resource(recording, "其他艺人 - 晴天 FLAC") is False - assert MusicChain.matches_site_resource(recording, "晴天 FLAC") is False + assert SearchChain.matches_music_resource(recording, "周杰伦 - 晴天 FLAC") is True + assert SearchChain.matches_music_resource(recording, "其他艺人 - 晴天 FLAC") is False + assert SearchChain.matches_music_resource(recording, "晴天 FLAC") is False def test_normalize_candidates_deduplicates_source_identity(): """同一来源和媒体 ID 的音乐候选应只保留一次。""" - results = MusicChain.normalize_candidates( + results = MediaChain.normalize_music_candidates( [ MusicInfo(media_source="musicbrainz", media_id="recording-1", title="A"), { "type": "音乐", - "source": "musicbrainz", + "media_source": "musicbrainz", "media_id": "recording-1", "title": "A duplicate", }, @@ -160,7 +165,7 @@ def test_normalize_candidates_deduplicates_source_identity(): def test_normalize_candidates_keeps_different_entities_with_same_source_id(): """同一来源 ID 在不同音乐实体命名空间下不能互相去重。""" - results = MusicChain.normalize_candidates( + results = MediaChain.normalize_music_candidates( [ MusicInfo(media_source="musicbrainz", media_id="shared-id", music_type="recording", title="Song"), MusicInfo(media_source="musicbrainz", media_id="shared-id", music_type="album", title="Album"), @@ -172,7 +177,7 @@ def test_normalize_candidates_keeps_different_entities_with_same_source_id(): def test_normalize_candidates_deduplicates_metadata_without_id(): """缺少来源 ID 时应按标题、艺术家和专辑去重。""" - results = MusicChain.normalize_candidates( + results = MediaChain.normalize_music_candidates( [ MusicInfo(title="One More Time", artists=["Daft Punk"], album="Discovery"), MusicInfo(title=" one more time ", artists=["daft punk"], album="DISCOVERY"), @@ -194,7 +199,7 @@ def test_to_meta_preserves_selected_identity(): track_number=3, ) - meta = MusicChain.to_meta(info) + meta = MetaMusic.from_music_info(info) assert meta.media_source == "musicbrainz" assert meta.media_id == "recording-1" @@ -205,7 +210,7 @@ def test_to_meta_preserves_selected_identity(): def test_chart_converts_page_to_listenbrainz_offset(monkeypatch): """音乐榜单处理链应将页码转换为模块需要的偏移量。""" - chain = MusicChain() + chain = ListenBrainzChain() requested = {} def fake_run_module(method, **kwargs): @@ -218,24 +223,24 @@ def test_chart_converts_page_to_listenbrainz_offset(monkeypatch): monkeypatch.setattr(chain, "run_module", fake_run_module) - results = chain.chart(range_name="this_week", page=2, count=30) + results = chain.music_chart(range_name="this_week", page=2, count=30) assert requested == { "method": "music_chart", "range_name": "this_week", "offset": 30, "count": 30, + "entity": "recording", } - assert len(results) == 1 + assert len(results) == 2 def test_async_chart_applies_music_explore_filters(monkeypatch): """音乐探索应按收听次数、封面条件和升序设置筛选榜单。""" - chain = MusicChain() + chain = RecommendChain() - async def fake_async_run_module(method, **kwargs): + async def fake_music_chart(**kwargs): """返回包含不同热度和封面状态的榜单候选。""" - assert method == "music_chart" return [ MusicInfo(media_id="1", media_source="musicbrainz", title="A", listen_count=300), MusicInfo( @@ -254,12 +259,12 @@ def test_async_chart_applies_music_explore_filters(monkeypatch): ), ] - monkeypatch.setattr(chain, "async_run_module", fake_async_run_module) - - import asyncio + source_chain = Mock() + source_chain.async_music_chart = AsyncMock(side_effect=fake_music_chart) + monkeypatch.setattr("app.chain.recommend.ListenBrainzChain", Mock(return_value=source_chain)) results = asyncio.run( - chain.async_chart( + chain.async_music_chart( range_name="this_month", count=30, sort_by="listen_count.asc", @@ -313,19 +318,16 @@ def test_async_recognize_by_path_reads_local_audio_tags(tmp_path, monkeypatch): recognize = AsyncMock(return_value=info) filename_meta = MetaMusic(title="02. 眼泪成诗") monkeypatch.setattr( - MusicChain, - "read_path_evidence", + "app.chain.media.AudioMetadataHelper.read_evidence", Mock(return_value=(meta, meta, filename_meta)), ) monkeypatch.setattr( - MusicChain, - "async_identify_by_fingerprint", + AcoustIdChain, + "async_identify_music_by_fingerprint", AsyncMock(return_value=None), ) monkeypatch.setattr(chain, "async_recognize_media", recognize) - import asyncio - recognized_meta, recognized_info = asyncio.run( chain.async_recognize_music_by_path(audio_path) ) @@ -334,14 +336,14 @@ def test_async_recognize_by_path_reads_local_audio_tags(tmp_path, monkeypatch): assert recognized_info is info recognize.assert_awaited_once_with( meta=meta, - source=None, + media_source=None, music_type="recording", ) -def test_recognize_best_only_queries_musicbrainz(monkeypatch): +def test_media_chain_default_recognition_only_queries_musicbrainz(monkeypatch): """自动音乐识别只应调用 MusicBrainz 主数据源。""" - chain = MusicChain() + chain = MediaChain() meta = MetaMusic( title="晴天", artists=["周杰伦"], @@ -357,22 +359,22 @@ def test_recognize_best_only_queries_musicbrainz(monkeypatch): year=2003, ) recognize_source = Mock(return_value=expected) - monkeypatch.setattr(chain, "recognize_from_source", recognize_source) + monkeypatch.setattr(chain, "recognize_music_from_source", recognize_source) - result = chain.recognize_best(meta) + result = chain.recognize_media(meta=meta) assert result is expected recognize_source.assert_called_once_with( - media_source="musicbrainz", + media_source=MediaSource.MusicBrainz, meta=meta, cache=True, music_type="recording", ) -def test_recognize_from_source_selects_only_declared_music_module(monkeypatch): +def test_recognize_from_source_selects_only_declared_music_source_chain(monkeypatch): """单源识别只允许调用声明该音乐来源的模块,忽略同接口影视模块。""" - chain = MusicChain() + chain = MediaChain() meta = MetaMusic(title="晴天", artists=["周杰伦"]) expected = MusicInfo( media_source="musicbrainz", @@ -380,50 +382,40 @@ def test_recognize_from_source_selects_only_declared_music_module(monkeypatch): title="晴天", artists=["周杰伦"], ) - video_module = Mock(spec=["recognize_media"]) - music_module = Mock(spec=["get_music_source", "recognize_media"]) - music_module.get_music_source.return_value = "musicbrainz" - music_module.recognize_media.return_value = expected - monkeypatch.setattr( - chain.modulemanager, - "get_running_modules", - Mock(return_value=[video_module, music_module]), - ) + source_chain = Mock() + source_chain.recognize_music.return_value = expected + monkeypatch.setattr(chain, "_music_source_chain", Mock(return_value=source_chain)) - result = chain.recognize_from_source( + result = chain.recognize_music_from_source( media_source="musicbrainz", meta=meta, cache=True, ) assert result is expected - video_module.recognize_media.assert_not_called() - music_module.recognize_media.assert_called_once_with( + source_chain.recognize_music.assert_called_once_with( meta=meta, - mtype=MediaType.MUSIC, - media_source="musicbrainz", - mediaid=None, + media_id=None, cache=True, + music_type=None, ) -def test_recognize_best_does_not_fallback_after_musicbrainz_miss(monkeypatch): +def test_default_recognition_does_not_fallback_after_musicbrainz_miss(monkeypatch): """MusicBrainz 未命中时自动识别不得继续请求其它音乐来源。""" - chain = MusicChain() + chain = MediaChain() meta = MetaMusic(title="晴天", artists=["周杰伦"]) recognize_source = Mock(return_value=None) - monkeypatch.setattr(chain, "recognize_from_source", recognize_source) + monkeypatch.setattr(chain, "recognize_music_from_source", recognize_source) - assert chain.recognize_best(meta) is None + assert chain.recognize_media(meta=meta) is None recognize_source.assert_called_once() - assert recognize_source.call_args.kwargs["source"] == "musicbrainz" + assert recognize_source.call_args.kwargs["media_source"] == MediaSource.MusicBrainz -def test_async_recognize_best_only_queries_musicbrainz(monkeypatch): +def test_async_default_recognition_only_queries_musicbrainz(monkeypatch): """异步自动音乐识别也只应调用 MusicBrainz 主数据源。""" - import asyncio - - chain = MusicChain() + chain = MediaChain() meta = MetaMusic(title="晴天", artists=["周杰伦"]) expected = MusicInfo( media_source="musicbrainz", @@ -432,60 +424,52 @@ def test_async_recognize_best_only_queries_musicbrainz(monkeypatch): artists=["周杰伦"], ) recognize_source = AsyncMock(return_value=expected) - monkeypatch.setattr(chain, "async_recognize_from_source", recognize_source) + monkeypatch.setattr(chain, "async_recognize_music_from_source", recognize_source) - result = asyncio.run(chain.async_recognize_best(meta)) + with patch( + "app.chain.MoviePilotServerHelper.async_report_recognize_share", + new=AsyncMock(), + ): + result = asyncio.run(chain.async_recognize_media(meta=meta)) assert result is expected recognize_source.assert_awaited_once_with( - media_source="musicbrainz", + media_source=MediaSource.MusicBrainz, meta=meta, cache=True, music_type="recording", ) -def test_async_recognize_from_source_calls_module_async_method(monkeypatch): +def test_musicbrainz_source_chain_calls_module_async_method(monkeypatch): """单源异步识别必须直接等待模块异步入口。""" - chain = MusicChain() + chain = MusicBrainzChain() expected = MusicInfo( media_source="musicbrainz", media_id="recording-1", title="晴天", ) - module = Mock() - module.async_recognize_media = AsyncMock(return_value=expected) - module.recognize_media = Mock( - side_effect=AssertionError("异步识别不应调用同步模块方法") - ) - monkeypatch.setattr( - chain, - "_music_recognize_module", - Mock(return_value=module), - ) + async_run_module = AsyncMock(return_value=expected) + run_module = Mock(side_effect=AssertionError("异步识别不应调用同步模块方法")) + monkeypatch.setattr(chain, "async_run_module", async_run_module) + monkeypatch.setattr(chain, "run_module", run_module) - import asyncio - - result = asyncio.run(chain._async_recognize_from_source( - MetaMusic(title="晴天"), - "musicbrainz", - True, + result = asyncio.run(chain.async_recognize_music( + meta=MetaMusic(title="晴天"), cache=True )) assert result is expected - module.async_recognize_media.assert_awaited_once() - module.recognize_media.assert_not_called() + async_run_module.assert_awaited_once() + run_module.assert_not_called() def test_async_identify_by_fingerprint_uses_async_module_contract(monkeypatch): """指纹异步链路应请求模块的异步方法名。""" - chain = MusicChain() + chain = AcoustIdChain() async_run_module = AsyncMock(return_value="recording-1") monkeypatch.setattr(chain, "async_run_module", async_run_module) - import asyncio - - result = asyncio.run(chain.async_identify_by_fingerprint("/music/track.flac")) + result = asyncio.run(chain.async_identify_music_by_fingerprint("/music/track.flac")) assert result == "recording-1" async_run_module.assert_awaited_once_with( @@ -496,7 +480,7 @@ def test_async_identify_by_fingerprint_uses_async_module_contract(monkeypatch): def test_async_chart_forwards_album_entity(monkeypatch): """热门专辑探索应把实体类型透传给 ListenBrainz 榜单模块。""" - chain = MusicChain() + chain = ListenBrainzChain() requested = {} async def fake_async_run_module(method, **kwargs): @@ -514,9 +498,7 @@ def test_async_chart_forwards_album_entity(monkeypatch): monkeypatch.setattr(chain, "async_run_module", fake_async_run_module) - import asyncio - - results = asyncio.run(chain.async_chart(range_name="week", page=3, count=20, entity="album")) + results = asyncio.run(chain.async_music_chart(range_name="week", page=3, count=20, entity="album")) assert requested["method"] == "music_chart" assert requested["entity"] == "album" @@ -526,12 +508,12 @@ def test_async_chart_forwards_album_entity(monkeypatch): def test_async_fresh_releases_keeps_official_order(monkeypatch): """新发行探索应保留官方排序,只按封面条件过滤。""" - chain = MusicChain() + chain = RecommendChain() requested = {} - async def fake_async_run_module(method, **kwargs): + async def fake_fresh_releases(**kwargs): """记录新发行请求参数并返回带封面与不带封面的候选。""" - requested.update(method=method, **kwargs) + requested.update(**kwargs) return [ MusicInfo(media_id="b", media_source="musicbrainz", music_type="album", title="B"), MusicInfo( @@ -543,23 +525,24 @@ def test_async_fresh_releases_keeps_official_order(monkeypatch): ), ] - monkeypatch.setattr(chain, "async_run_module", fake_async_run_module) - - import asyncio + source_chain = Mock() + source_chain.async_music_fresh_releases = AsyncMock(side_effect=fake_fresh_releases) + monkeypatch.setattr("app.chain.recommend.ListenBrainzChain", Mock(return_value=source_chain)) results = asyncio.run( - chain.async_fresh_releases(days=30, sort="release_name", page=2, count=10, with_cover=True) + chain.async_music_fresh_releases( + days=30, sort="release_name", page=2, count=10, with_cover=True + ) ) - assert requested["method"] == "music_fresh_releases" - assert requested["offset"] == 10 + assert requested["page"] == 2 assert requested["sort"] == "release_name" assert [item.title for item in results] == ["A"] -def test_async_artist_related_deduplicates_artists(monkeypatch): - """关联艺术家应按标准 ID 去重,避免同一成员重复出现。""" - chain = MusicChain() +def test_async_artist_related_preserves_source_results(monkeypatch): + """关联艺术家来源链应保留来源返回顺序和实体。""" + chain = MusicBrainzChain() async def fake_async_run_module(method, **kwargs): """返回重复的关联艺术家候选。""" @@ -572,33 +555,33 @@ def test_async_artist_related_deduplicates_artists(monkeypatch): monkeypatch.setattr(chain, "async_run_module", fake_async_run_module) - import asyncio + results = asyncio.run(chain.async_get_music_artist_related(media_id="artist-0")) - results = asyncio.run(chain.async_artist_related(media_source="musicbrainz", media_id="artist-0")) - - assert [item.media_id for item in results] == ["artist-1", "artist-2"] + assert [item.media_id for item in results] == ["artist-1", "artist-1", "artist-2"] -def test_async_album_restores_dataclass_from_plugin_dict(monkeypatch): - """插件返回字典时专辑链应恢复为标准专辑对象。""" - chain = MusicChain() +def test_async_album_returns_source_chain_result(monkeypatch): + """媒体链应按来源和 ID 返回来源链提供的标准专辑对象。""" + chain = MediaChain() - async def fake_async_run_module(method, **kwargs): + async def fake_get_album(media_id): """模拟插件模块以字典形式返回专辑详情。""" - assert method == "music_album" + assert media_id == "release-group-1" return MusicAlbumInfo( media_source="musicbrainz", media_id="release-group-1", title="A Night at the Opera", artists=["Queen"], release_date="1975-11-21", - ).to_dict() + ) - monkeypatch.setattr(chain, "async_run_module", fake_async_run_module) + source_chain = Mock() + source_chain.async_get_music_album = AsyncMock(side_effect=fake_get_album) + monkeypatch.setattr(chain, "_music_source_chain", Mock(return_value=source_chain)) - import asyncio - - album = asyncio.run(chain.async_album(media_source="musicbrainz", media_id="release-group-1")) + album = asyncio.run(chain.async_get_music_album( + media_source="musicbrainz", media_id="release-group-1" + )) assert album is not None assert album.year == 1975 diff --git a/tests/test_musicbrainz_module.py b/tests/test_musicbrainz_module.py index d1e703c72..4b7238de8 100644 --- a/tests/test_musicbrainz_module.py +++ b/tests/test_musicbrainz_module.py @@ -86,7 +86,7 @@ def test_recording_to_info_maps_musicbrainz_payload(): ) assert info is not None - assert info.source == "musicbrainz" + assert info.media_source.value == "musicbrainz" assert info.media_id == "recording-1" assert info.artists == ["Daft Punk", "Pharrell Williams"] assert info.album == "Random Access Memories" diff --git a/tests/test_plugin_helper.py b/tests/test_plugin_helper.py index 95bef343a..62f7dbc54 100644 --- a/tests/test_plugin_helper.py +++ b/tests/test_plugin_helper.py @@ -679,10 +679,23 @@ class TestPluginHelper: base_plugins = { "V2FlagPlugin": {"name": "V2Flag", "version": "1.0.0", "v2": True, "level": 1}, + "RejectedSharedPlugin": { + "name": "Rejected shared", + "version": "1.0.0", + "v2": True, + "v3": False, + "level": 1, + }, "LegacyPlugin": {"name": "Legacy", "version": "1.0.0", "level": 1}, } v2_native_plugins = { "V2NativePlugin": {"name": "V2Native", "version": "1.0.0", "level": 1}, + "RejectedV2Plugin": { + "name": "Rejected V2", + "version": "1.0.0", + "v3": False, + "level": 1, + }, } def fake_get_plugins(_self, _repo_url, package_version=None): @@ -710,6 +723,8 @@ class TestPluginHelper: assert "V2FlagPlugin" in plugin_ids assert "V2NativePlugin" in plugin_ids + assert "RejectedSharedPlugin" not in plugin_ids + assert "RejectedV2Plugin" not in plugin_ids assert "LegacyPlugin" not in plugin_ids def test_get_plugin_package_version_resolves_backward_compatible_v2_sources(self, monkeypatch) -> None: @@ -723,10 +738,21 @@ class TestPluginHelper: base_plugins = { "V2FlagPlugin": {"name": "V2Flag", "version": "1.0.0", "v2": True}, + "RejectedSharedPlugin": { + "name": "Rejected shared", + "version": "1.0.0", + "v2": True, + "v3": False, + }, "LegacyPlugin": {"name": "Legacy", "version": "1.0.0"}, } v2_native_plugins = { "V2NativePlugin": {"name": "V2Native", "version": "1.0.0"}, + "RejectedV2Plugin": { + "name": "Rejected V2", + "version": "1.0.0", + "v3": False, + }, } def fake_get_plugins(_self, _repo_url, package_version=None): @@ -742,8 +768,97 @@ class TestPluginHelper: assert helper.get_plugin_package_version("V2NativePlugin", REPO_URL) == "v2" assert helper.get_plugin_package_version("V2FlagPlugin", REPO_URL) == "" + assert helper.get_plugin_package_version("RejectedV2Plugin", REPO_URL) is None + assert helper.get_plugin_package_version("RejectedSharedPlugin", REPO_URL) is None assert helper.get_plugin_package_version("LegacyPlugin", REPO_URL) is None + def test_explicit_v2_resolution_still_respects_v3_false(self, monkeypatch) -> None: + """V3 显式解析 V2 索引时也不得绕过专用副本的排除标志。""" + try: + from app.helper.plugin import PluginHelper + except ModuleNotFoundError as exc: + pytest.skip(f"missing dependency: {exc}") + + monkeypatch.setattr( + "app.helper.plugin.settings", + SimpleNamespace(VERSION_FLAG="v3"), + ) + helper = PluginHelper.__new__(PluginHelper) + monkeypatch.setattr( + PluginHelper, + "get_plugins", + lambda _self, _repo, package_version=None: { + "DefaultV2": {"version": "1.0.0"}, + "V3Copied": {"version": "1.0.0", "v3": False}, + } if package_version == "v2" else {}, + ) + + assert helper.get_plugin_package_version( + "DefaultV2", REPO_URL, package_version="v2" + ) == "v2" + assert helper.get_plugin_package_version( + "V3Copied", REPO_URL, package_version="v2" + ) is None + + def test_async_resolution_matches_v2_default_compatibility(self, monkeypatch) -> None: + """异步安装解析应默认接纳 V2,并排除显式 v3:false 的旧实现。""" + try: + from app.helper.plugin import PluginHelper + except ModuleNotFoundError as exc: + pytest.skip(f"missing dependency: {exc}") + + monkeypatch.setattr( + "app.helper.plugin.settings", + SimpleNamespace(VERSION_FLAG="v3"), + ) + helper = PluginHelper.__new__(PluginHelper) + + async def fake_get_plugins(_repo, package_version=None): + """按索引版本返回异步解析测试数据。""" + if package_version == "v3": + return {} + if package_version == "v2": + return { + "DefaultV2": {"version": "1.0.0"}, + "V3Copied": {"version": "1.0.0", "v3": False}, + } + return {"SharedV2": {"version": "1.0.0", "v2": True}} + + monkeypatch.setattr(helper, "async_get_plugins", fake_get_plugins) + + assert asyncio.run(helper.async_get_plugin_package_version( + "DefaultV2", REPO_URL + )) == "v2" + assert asyncio.run(helper.async_get_plugin_package_version( + "SharedV2", REPO_URL + )) == "" + assert asyncio.run(helper.async_get_plugin_package_version( + "V3Copied", REPO_URL + )) is None + + def test_v3_package_compatibility_defaults_v2_to_allowed(self, monkeypatch) -> None: + """V3 临时兼容 V2,显式 false 优先拒绝且纯 V1 不被带入。""" + try: + from app.helper.plugin import PluginHelper + except ModuleNotFoundError as exc: + pytest.skip(f"missing dependency: {exc}") + + monkeypatch.setattr( + "app.helper.plugin.settings", + SimpleNamespace(VERSION_FLAG="v3"), + ) + + assert PluginHelper.is_package_plugin_compatible({}, "v3") + assert PluginHelper.is_package_plugin_compatible({}, "v2") + assert not PluginHelper.is_package_plugin_compatible( + {"v3": False}, "v2" + ) + assert PluginHelper.is_package_plugin_compatible({"v2": True}, "") + assert not PluginHelper.is_package_plugin_compatible( + {"v2": True, "v3": False}, "" + ) + assert not PluginHelper.is_package_plugin_compatible({}, "") + def test_get_online_plugins_force_keeps_release_cache_scoped(self, monkeypatch): """ 全市场刷新不清理 Release 缓存,Release 接口按请求仓库协调刷新两类数据。 diff --git a/tests/test_recommend_chain.py b/tests/test_recommend_chain.py index 2ebcbc9cc..8a348f4be 100644 --- a/tests/test_recommend_chain.py +++ b/tests/test_recommend_chain.py @@ -99,26 +99,27 @@ def test_async_recommend_methods_do_not_cache_empty_result( def test_music_weekly_uses_music_chart(): """同步推荐缓存应从本周音乐榜单生成通用媒体字典。""" chain = RecommendChain() - with patch("app.chain.recommend.MusicChain") as music_chain: - music_chain.return_value.chart.return_value = [ + with patch("app.chain.recommend.ListenBrainzChain") as source_chain: + source_chain.return_value.music_chart.return_value = [ MusicInfo(media_source="musicbrainz", media_id="recording-1", title="晴天") ] result = chain.music_weekly(page=2, count=10) assert result[0]["media_id"] == "recording-1" - music_chain.return_value.chart.assert_called_once_with( + source_chain.return_value.music_chart.assert_called_once_with( range_name="this_week", page=2, count=10, + entity="recording", ) def test_async_music_weekly_uses_music_chart(): """异步推荐接口应从本周音乐榜单返回统一媒体字典。""" chain = RecommendChain() - with patch("app.chain.recommend.MusicChain") as music_chain: - music_chain.return_value.async_chart = AsyncMock( + with patch("app.chain.recommend.ListenBrainzChain") as source_chain: + source_chain.return_value.async_music_chart = AsyncMock( return_value=[ MusicInfo(media_source="musicbrainz", media_id="recording-1", title="晴天") ] @@ -127,18 +128,19 @@ def test_async_music_weekly_uses_music_chart(): result = asyncio.run(chain.async_music_weekly(page=1, count=30)) assert result[0]["type"] == "音乐" - music_chain.return_value.async_chart.assert_awaited_once_with( + source_chain.return_value.async_music_chart.assert_awaited_once_with( range_name="this_week", page=1, count=30, + entity="recording", ) def test_music_douban_recommendations_use_discover(): """豆瓣音乐推荐入口应保留来源与实体,并输出统一媒体字典。""" chain = RecommendChain() - with patch("app.chain.recommend.MusicChain") as music_chain: - music_chain.return_value.discover.return_value = [ + with patch("app.chain.recommend.DoubanChain") as source_chain: + source_chain.return_value.music_discover.return_value = [ MusicInfo( media_source="doubanmusic", media_id="music-1", @@ -150,19 +152,21 @@ def test_music_douban_recommendations_use_discover(): result = chain.music_douban(page=2, count=10) assert result[0]["media_source"] == "doubanmusic" - music_chain.return_value.discover.assert_called_once_with( - media_source="doubanmusic", + source_chain.return_value.music_discover.assert_called_once_with( page=2, count=10, entity=MUSIC_ENTITY_ALBUM, + mode="chart", + tags="", + sort="U", ) def test_async_music_douban_recommendations_use_discover(): """异步豆瓣音乐推荐入口应调用统一发现链并保留来源。""" chain = RecommendChain() - with patch("app.chain.recommend.MusicChain") as music_chain: - music_chain.return_value.async_discover = AsyncMock( + with patch("app.chain.recommend.DoubanChain") as source_chain: + source_chain.return_value.async_music_discover = AsyncMock( return_value=[ MusicInfo(media_source="doubanmusic", media_id="music-1", title="Music") ] @@ -171,9 +175,79 @@ def test_async_music_douban_recommendations_use_discover(): result = asyncio.run(chain.async_music_douban(page=1, count=30)) assert result[0]["media_source"] == "doubanmusic" - music_chain.return_value.async_discover.assert_awaited_once_with( - media_source="doubanmusic", + source_chain.return_value.async_music_discover.assert_awaited_once_with( page=1, count=30, entity=MUSIC_ENTITY_ALBUM, + mode="chart", + tags="", + sort="U", + ) + + +def test_music_chart_applies_filter_and_sort() -> None: + """音乐榜单应在 RecommendChain 统一执行热度、封面和排序约束。""" + chain = RecommendChain() + candidates = [ + MusicInfo( + media_source="musicbrainz", + media_id="low", + title="Low", + listen_count=10, + cover_url="cover-low", + ), + MusicInfo( + media_source="musicbrainz", + media_id="high", + title="High", + listen_count=30, + cover_url="cover-high", + ), + MusicInfo( + media_source="musicbrainz", + media_id="no-cover", + title="No Cover", + listen_count=40, + ), + ] + with patch("app.chain.recommend.ListenBrainzChain") as source_chain: + source_chain.return_value.music_chart.return_value = candidates + + result = chain.music_chart( + range_name="this_month", + page=1, + count=10, + sort_by="listen_count.desc", + min_listen_count=20, + with_cover=True, + ) + + assert [item.media_id for item in result] == ["high"] + + +def test_async_music_fresh_releases_uses_listenbrainz_source() -> None: + """新发行推荐应委派 ListenBrainz 来源链并保留分页参数。""" + chain = RecommendChain() + with patch("app.chain.recommend.ListenBrainzChain") as source_chain: + source_chain.return_value.async_music_fresh_releases = AsyncMock( + return_value=[ + MusicInfo( + media_source="musicbrainz", + media_id="album-1", + music_type=MUSIC_ENTITY_ALBUM, + title="Album", + ) + ] + ) + + result = asyncio.run(chain.async_music_fresh_releases(page=2, count=12)) + + assert result[0].media_id == "album-1" + source_chain.return_value.async_music_fresh_releases.assert_awaited_once_with( + days=14, + sort="release_date", + past=True, + future=True, + page=2, + count=12, ) diff --git a/tests/test_rust_accel.py b/tests/test_rust_accel.py index edfc3c3d0..48483ea83 100644 --- a/tests/test_rust_accel.py +++ b/tests/test_rust_accel.py @@ -314,6 +314,68 @@ def test_metainfo_public_entry_uses_rust(monkeypatch): assert meta.apply_words == ["旧名 => 新名 && 第 <> 集 >> EP+1"] +def test_rust_metamusic_wrapper_calls_extension(monkeypatch): + """MetaMusic wrapper 应把标题和已有证据透传给 Rust 扩展。""" + calls = [] + expected = {"title": "晴天", "artists": ["周杰伦"], "year": 2003} + + def parse_metamusic_fast(title, artists, year): + """记录 Rust 扩展入口参数并返回测试结果。""" + calls.append((title, artists, year)) + return expected + + fake_extension = SimpleNamespace( + is_available=lambda: True, + parse_metamusic_fast=parse_metamusic_fast, + ) + monkeypatch.setattr(settings, "RUST_ACCEL", True) + monkeypatch.setattr(rust_accel, "_moviepilot_rust", fake_extension) + + result = rust_accel.parse_metamusic("周杰伦 - 晴天", ["周杰伦"], 2003) + + assert result == expected + assert calls == [("周杰伦 - 晴天", ["周杰伦"], 2003)] + + +def test_rust_metamusic_wrapper_respects_runtime_switch(monkeypatch): + """Rust 总开关关闭时 MetaMusic wrapper 不应调用扩展。""" + fake_extension = SimpleNamespace( + is_available=lambda: True, + parse_metamusic_fast=lambda *_args: (_ for _ in ()).throw( + AssertionError("总开关关闭时不应调用 Rust") + ), + ) + monkeypatch.setattr(settings, "RUST_ACCEL", False) + monkeypatch.setattr(rust_accel, "_moviepilot_rust", fake_extension) + + assert rust_accel.parse_metamusic("周杰伦 - 晴天") is None + + +def test_rust_metamusic_wrapper_supports_legacy_extension(monkeypatch): + """旧版 Rust 扩展缺少 MetaMusic 入口时应安静回退。""" + fake_extension = SimpleNamespace(is_available=lambda: True) + monkeypatch.setattr(settings, "RUST_ACCEL", True) + monkeypatch.setattr(rust_accel, "_moviepilot_rust", fake_extension) + + assert rust_accel.parse_metamusic("周杰伦 - 晴天") is None + + +def test_rust_metamusic_wrapper_falls_back_on_extension_error(monkeypatch): + """Rust MetaMusic 入口异常时 wrapper 应返回 None 触发 Python 兜底。""" + def parse_metamusic_fast(*_args): + """模拟 Rust 扩展在音乐解析期间报错。""" + raise RuntimeError("parse failed") + + fake_extension = SimpleNamespace( + is_available=lambda: True, + parse_metamusic_fast=parse_metamusic_fast, + ) + monkeypatch.setattr(settings, "RUST_ACCEL", True) + monkeypatch.setattr(rust_accel, "_moviepilot_rust", fake_extension) + + assert rust_accel.parse_metamusic("周杰伦 - 晴天") is None + + def test_rust_indexer_parser_handles_jinja_pyquery_filters_and_links(): """ Rust indexer 解析应覆盖普通站点配置的 Jinja、PyQuery selector 和过滤器。 diff --git a/tests/test_search_ai_recommend.py b/tests/test_search_ai_recommend.py index f28fd1dd9..96538b8d3 100644 --- a/tests/test_search_ai_recommend.py +++ b/tests/test_search_ai_recommend.py @@ -425,7 +425,8 @@ class SearchChainAIRecommendTest(unittest.IsolatedAsyncioTestCase): ), ): chain.search_by_id( - tmdbid=123, + media_source="themoviedb", + media_id="123", mtype=MediaType.MOVIE, area="title", season=2, @@ -437,7 +438,9 @@ class SearchChainAIRecommendTest(unittest.IsolatedAsyncioTestCase): ( "__search_params__", { - "keyword": "tmdb:123", + "keyword": "", + "media_source": "themoviedb", + "media_id": "123", "type": "电影", "area": "title", "title": "", diff --git a/tests/test_search_media_sources.py b/tests/test_search_media_sources.py index e6057cbf8..7617cc118 100644 --- a/tests/test_search_media_sources.py +++ b/tests/test_search_media_sources.py @@ -1,4 +1,5 @@ import asyncio +from typing import Optional from unittest.mock import AsyncMock, Mock, patch import pytest @@ -35,6 +36,38 @@ def test_resolve_anilist_search_params_preserves_identity() -> None: } +@pytest.mark.parametrize( + ("media_source", "media_id"), + [ + (None, "154587"), + (MediaSource.TMDB, ""), + (MediaSource.TMDB, " "), + (MediaSource.TMDB, "0"), + ], +) +def test_resource_search_rejects_invalid_identity_before_chain( + monkeypatch, + media_source: Optional[MediaSource], + media_id: str, +) -> None: + """半身份、空白或零值 ID 应在进入搜索链及其本地缓存前被拒绝。""" + search_chain = Mock(side_effect=AssertionError("无效身份不应创建搜索链")) + monkeypatch.setattr(search_endpoint, "SearchChain", search_chain) + + response = asyncio.run( + search_endpoint.search_by_id( + media_id=media_id, + media_source=media_source, + mtype=MediaType.MOVIE.value, + _=None, + ) + ) + + assert not response.success + assert response.message == "媒体ID格式无效" + search_chain.assert_not_called() + + @pytest.mark.parametrize( ("media_source", "media_id"), [ @@ -166,6 +199,70 @@ def test_media_detail_does_not_fallback_for_explicit_identity(monkeypatch) -> No media_chain.async_recognize_by_meta.assert_not_awaited() +def test_media_detail_rejects_zero_identity_before_chain(monkeypatch) -> None: + """媒体详情的零值原生 ID 不得进入识别链。""" + media_chain = Mock(side_effect=AssertionError("零值身份不应创建媒体链")) + monkeypatch.setattr(media_endpoint, "MediaChain", media_chain) + + result = asyncio.run( + media_endpoint.detail( + media_id="0", + media_source=MediaSource.TMDB, + type_name=MediaType.MOVIE.value, + _=None, + ) + ) + + assert isinstance(result, media_endpoint.schemas.MediaInfo) + media_chain.assert_not_called() + + +def test_media_seasons_rejects_zero_identity_before_external_chain(monkeypatch) -> None: + """季信息接口收到零值身份时不得调用 TMDB 或通用识别链。""" + tmdb_chain = Mock(side_effect=AssertionError("零值身份不应创建 TMDB 链")) + media_chain = Mock(side_effect=AssertionError("零值身份不应创建媒体链")) + monkeypatch.setattr(media_endpoint, "TmdbChain", tmdb_chain) + monkeypatch.setattr(media_endpoint, "MediaChain", media_chain) + + result = asyncio.run( + media_endpoint.seasons( + media_source=MediaSource.TMDB, + media_id="0", + title="不应回退标题", + _=None, + ) + ) + + assert result == [] + tmdb_chain.assert_not_called() + media_chain.assert_not_called() + + +@pytest.mark.parametrize( + ("endpoint", "kwargs"), + [ + (media_endpoint.groups, {"tmdbid": 0}), + (media_endpoint.group_seasons, {"episode_group": "0"}), + ], +) +def test_explicit_tmdb_endpoints_reject_zero_before_external_chain( + monkeypatch, + endpoint, + kwargs: dict, +) -> None: + """本文件内的明确 TMDB 辅助入口不得把零值 ID 传给外部链。""" + tmdb_chain = Mock(side_effect=AssertionError("零值身份不应创建 TMDB 链")) + media_chain = Mock(side_effect=AssertionError("零值身份不应创建媒体链")) + monkeypatch.setattr(media_endpoint, "TmdbChain", tmdb_chain) + monkeypatch.setattr(media_endpoint, "MediaChain", media_chain) + + result = asyncio.run(endpoint(**kwargs, _=None)) + + assert result == [] + tmdb_chain.assert_not_called() + media_chain.assert_not_called() + + @pytest.mark.parametrize( ("media_source", "media_id", "media_kwargs", "episode_count"), [ diff --git a/tests/test_server_helper.py b/tests/test_server_helper.py index 1aa09f6fb..02d0dce3d 100644 --- a/tests/test_server_helper.py +++ b/tests/test_server_helper.py @@ -4,6 +4,7 @@ import unittest from unittest.mock import Mock, patch from app.helper.server import MoviePilotServerHelper +from app.schemas.types import MediaSource class MoviePilotServerHelperTests(unittest.TestCase): @@ -150,3 +151,58 @@ class MoviePilotServerHelperTests(unittest.TestCase): with patch.object(MoviePilotServerHelper, "get_github_user", return_value="user"), \ patch.object(MoviePilotServerHelper, "user_permissions", return_value=response): self.assertFalse(MoviePilotServerHelper.is_admin_user()) + + def test_subscribe_statistic_payload_only_keeps_server_contract(self): + """订阅统计载荷应删除本地运行列和所有旧专用媒体 ID。""" + payload = MoviePilotServerHelper._build_subscribe_statistic_payload({ + "id": 1, + "name": "Test", + "type": "电影", + "media_source": MediaSource.Douban, + "media_id": 42, + "tmdbid": 99, + "state": "N", + "username": "tester", + }) + + self.assertEqual(payload, { + "name": "Test", + "type": "电影", + "media_source": MediaSource.Douban.value, + "media_id": "42", + }) + + def test_subscribe_share_payload_only_keeps_server_contract(self): + """订阅分享载荷应保留分享配置并剔除本地下载状态。""" + payload = MoviePilotServerHelper._build_subscribe_share_payload({ + "share_title": "Share", + "share_user": "tester", + "name": "Test", + "type": "电视剧", + "media_source": "bangumi", + "media_id": "7", + "include": "WEB-DL", + "audio_quality": "lossless", + "downloader": "default", + "bangumiid": 7, + }) + + self.assertEqual(payload, { + "share_title": "Share", + "share_user": "tester", + "name": "Test", + "type": "电视剧", + "media_source": MediaSource.Bangumi.value, + "media_id": "7", + "include": "WEB-DL", + }) + + def test_subscribe_payload_rejects_incomplete_unified_identity(self): + """中心服务载荷不得再从旧专用 ID 推导主身份。""" + self.assertIsNone( + MoviePilotServerHelper._build_subscribe_statistic_payload({ + "name": "Legacy", + "type": "电影", + "tmdbid": 99, + }) + ) diff --git a/tests/test_subscribe_chain.py b/tests/test_subscribe_chain.py index 13fd79aa2..c39f85f6d 100644 --- a/tests/test_subscribe_chain.py +++ b/tests/test_subscribe_chain.py @@ -375,7 +375,6 @@ def _load_subscribe_chain_class(): chain_dependencies = { "app.chain.download": "DownloadChain", "app.chain.mediaserver": "MediaServerChain", - "app.chain.music": "MusicChain", "app.chain.search": "SearchChain", "app.chain.tmdb": "TmdbChain", "app.chain.torrents": "TorrentsChain", diff --git a/tests/test_subscribe_endpoint.py b/tests/test_subscribe_endpoint.py index ab6079411..76273a6a8 100644 --- a/tests/test_subscribe_endpoint.py +++ b/tests/test_subscribe_endpoint.py @@ -3,6 +3,9 @@ from types import SimpleNamespace from unittest import TestCase from unittest.mock import AsyncMock, patch +import pytest +from pydantic import ValidationError + from app.api.endpoints.subscribe import create_subscribe from app.schemas.subscribe import Subscribe from app.schemas.types import EventType, MediaSource, MediaType @@ -220,6 +223,90 @@ class SubscribeEndpointTest(TestCase): self.assertNotIn("username", payload["fields"]) self.assertEqual(payload["subscribe_info"]["username"], "alice") + def test_update_subscribe_preserves_existing_media_identity_when_omitted(self): + """普通字段更新未提交身份时,不得把已有媒体身份清空。""" + from app.api.endpoints.subscribe import update_subscribe + + subscribe = _EndpointSubscribe( + id=24, + username="alice", + name="旧标题", + media_source=MediaSource.TMDB, + media_id="12345", + total_episode=8, + lack_episode=2, + sites=[], + search_imdbid=0, + filter_groups=[], + start_episode=0, + ) + + with patch( + "app.api.endpoints.subscribe.Subscribe.async_get", + new=AsyncMock(side_effect=[subscribe, subscribe]), + ), patch( + "app.api.endpoints.subscribe.eventmanager.async_send_event", + new=AsyncMock(), + ): + response = asyncio.run( + update_subscribe( + subscribe_in=Subscribe(id=24, name="新标题"), + db=object(), + current_user=_EndpointUser(name="alice", is_superuser=False), + ) + ) + + self.assertTrue(response.success) + self.assertEqual(subscribe.media_source, MediaSource.TMDB) + self.assertEqual(subscribe.media_id, "12345") + + def test_update_subscribe_clears_existing_media_identity_with_empty_pair(self): + """更新同时显式提交两个空身份字段时,应清空存量身份。""" + from app.api.endpoints.subscribe import update_subscribe + + subscribe = _EndpointSubscribe( + id=26, + username="alice", + name="旧标题", + media_source=MediaSource.TMDB, + media_id="12345", + total_episode=8, + lack_episode=2, + sites=[], + search_imdbid=0, + filter_groups=[], + start_episode=0, + ) + + with patch( + "app.api.endpoints.subscribe.Subscribe.async_get", + new=AsyncMock(side_effect=[subscribe, subscribe]), + ), patch( + "app.api.endpoints.subscribe.eventmanager.async_send_event", + new=AsyncMock(), + ): + response = asyncio.run( + update_subscribe( + subscribe_in=Subscribe( + id=26, + name="新标题", + media_source="", + media_id="", + ), + db=object(), + current_user=_EndpointUser(name="alice", is_superuser=False), + ) + ) + + self.assertTrue(response.success) + self.assertIsNone(subscribe.media_source) + self.assertIsNone(subscribe.media_id) + + def test_update_subscribe_rejects_partial_media_identity(self): + """更新媒体身份时只提交来源或 ID 之一应在 Schema 边界直接拒绝。""" + with self.assertRaises(ValidationError): + Subscribe(id=25, media_source=MediaSource.Douban) + def test_update_subscribe_preserves_recognized_music_entity(self): """普通编辑不得把专辑改为单曲或覆盖整专完成判定所需的曲目总数。""" from app.api.endpoints.subscribe import update_subscribe @@ -1264,6 +1351,41 @@ def test_subscribe_preserves_explicit_zero_and_numeric_string_values(): assert subscribe.vote == 0.0 +@pytest.mark.parametrize( + "identity", + [ + {"media_source": MediaSource.TMDB}, + {"media_id": "123"}, + {"media_source": ""}, + {"media_id": ""}, + {"media_source": "unknown", "media_id": "123"}, + {"media_source": MediaSource.TMDB, "media_id": "0"}, + {"media_source": MediaSource.TMDB, "media_id": " "}, + ], +) +def test_subscribe_schema_rejects_incomplete_or_invalid_media_identity(identity): + """订阅 Schema 自身必须拒绝半对、零值和空白 ID,不能只依赖端点兜底。""" + with pytest.raises(ValidationError): + Subscribe(name="测试订阅", **identity) + + +def test_subscribe_schema_distinguishes_omitted_and_explicit_empty_identity(): + """省略和显式空对都合法,但必须保留字段是否由请求提交的信息。""" + omitted = Subscribe(name="省略身份") + explicit_empty = Subscribe( + name="清空身份", + media_source="", + media_id="", + ) + + assert omitted.media_source is None + assert omitted.media_id is None + assert not {"media_source", "media_id"}.intersection(omitted.model_fields_set) + assert explicit_empty.media_source is None + assert explicit_empty.media_id is None + assert {"media_source", "media_id"}.issubset(explicit_empty.model_fields_set) + + def test_create_subscribe_accepts_music_payload_with_empty_strings(): """带空字符串的音乐订阅应能通过新增订阅接口,不返回 422。""" subscribe_in = Subscribe( diff --git a/tests/test_subscribe_media_id_conflict.py b/tests/test_subscribe_media_id_conflict.py index ecfba5d87..9a12338d1 100644 --- a/tests/test_subscribe_media_id_conflict.py +++ b/tests/test_subscribe_media_id_conflict.py @@ -2,12 +2,16 @@ from types import SimpleNamespace from app.chain.subscribe import SubscribeChain from app.core.context import Context, MediaInfo, TorrentInfo -from app.schemas.types import MediaType +from app.schemas.types import MediaSource, MediaType def _target_media(tmdb_id=106449, douban_id=None) -> MediaInfo: """构造订阅目标媒体。""" + media_source = MediaSource.TMDB if tmdb_id is not None else MediaSource.Douban + media_id = str(tmdb_id) if tmdb_id is not None else douban_id return MediaInfo( + media_source=media_source if media_id is not None else None, + media_id=media_id, title="凡人修仙传", original_title="凡人修仙传", names=["A Record Of A Mortals Journey To Immortality"], @@ -21,7 +25,11 @@ def _target_media(tmdb_id=106449, douban_id=None) -> MediaInfo: def _candidate_media(tmdb_id=285479, douban_id=None) -> MediaInfo: """构造由 RSS 标题推断出的候选媒体。""" + media_source = MediaSource.TMDB if tmdb_id is not None else MediaSource.Douban + media_id = str(tmdb_id) if tmdb_id is not None else douban_id return MediaInfo( + media_source=media_source if media_id is not None else None, + media_id=media_id, title="凡人修仙传", type=MediaType.TV, year="2020", @@ -33,11 +41,7 @@ def _candidate_media(tmdb_id=285479, douban_id=None) -> MediaInfo: def _torrent_meta(*, en_name="A Record Of A Mortals Journey To Immortality", tmdbid=None): """构造不触发外部识别的标题解析结果。""" return SimpleNamespace( - tmdbid=tmdbid, - doubanid=None, - bangumiid=None, - anilistid=None, - media_source="themoviedb" if tmdbid else None, + media_source=MediaSource.TMDB if tmdbid else None, media_id=str(tmdbid) if tmdbid else None, cn_name="", en_name=en_name, diff --git a/tests/test_subtitle_signed_download.py b/tests/test_subtitle_signed_download.py index 019cb5b86..87529f998 100644 --- a/tests/test_subtitle_signed_download.py +++ b/tests/test_subtitle_signed_download.py @@ -124,7 +124,11 @@ def test_subtitle_media_search_response_signs_enclosure(monkeypatch): fake_build_subtitle_search_source, ) - response = _run(search_endpoint.search_subtitle_by_id(mediaid="tmdb:1", _=None)) + response = _run(search_endpoint.search_subtitle_by_id( + media_source="themoviedb", + media_id="1", + _=None, + )) assert response.success signed_url = response.data[0]["enclosure"] @@ -189,6 +193,8 @@ def test_download_subtitle_rejects_unsigned_enclosure(monkeypatch): response = download_endpoint.download_subtitle( subtitle_in=_subtitle_payload(), + media_source="themoviedb", + media_id="1", current_user=SimpleNamespace(name="tester"), ) @@ -210,6 +216,8 @@ def test_download_subtitle_rejects_other_purpose_signature(monkeypatch): response = download_endpoint.download_subtitle( subtitle_in=_subtitle_payload(enclosure=signed_url), + media_source="themoviedb", + media_id="1", current_user=SimpleNamespace(name="tester"), ) @@ -239,6 +247,8 @@ def test_download_subtitle_cleans_url_and_uses_server_site_request_fields(monkey response = download_endpoint.download_subtitle( subtitle_in=_subtitle_payload(enclosure=signed_url), + media_source="themoviedb", + media_id="1", current_user=SimpleNamespace(name="tester"), ) diff --git a/tests/test_tmdb_auxiliary.py b/tests/test_tmdb_auxiliary.py index ba4c0a914..74823eaa3 100644 --- a/tests/test_tmdb_auxiliary.py +++ b/tests/test_tmdb_auxiliary.py @@ -3,7 +3,7 @@ from types import SimpleNamespace from app.chain.media import MediaChain from app.core.context import MediaInfo from app.core.metainfo import MetaInfo -from app.schemas.types import MediaType +from app.schemas.types import MediaSource, MediaType class _FakeTmdbModule: @@ -30,10 +30,10 @@ def _make_chain(tmdb_media: MediaInfo) -> MediaChain: return chain -def test_supplement_tmdb_keeps_custom_source_identity() -> None: - """自定义识别源补充 TMDB 后,主身份和展示字段必须保持不变。""" +def test_supplement_tmdb_keeps_primary_source_identity() -> None: + """非 TMDB 主来源补充 TMDB 后,主身份和展示字段必须保持不变。""" primary = MediaInfo( - source="plugin-anime", + media_source=MediaSource.AniList, media_id="subject-42", type=MediaType.TV, title="原识别标题", @@ -56,7 +56,7 @@ def test_supplement_tmdb_keeps_custom_source_identity() -> None: ) assert result is primary - assert result.source == "plugin-anime" + assert result.media_source == MediaSource.AniList assert result.media_id == "subject-42" assert result.title == "原识别标题" assert result.tmdb_id == 12345 @@ -67,7 +67,7 @@ def test_supplement_tmdb_keeps_custom_source_identity() -> None: def test_supplement_tmdb_does_not_override_custom_category() -> None: """下载历史或目录指定的自定义分类优先于 TMDB 自动分类。""" primary = MediaInfo( - source="douban", + media_source=MediaSource.Douban, media_id="35593344", douban_id="35593344", type=MediaType.MOVIE, @@ -86,7 +86,7 @@ def test_supplement_tmdb_does_not_override_custom_category() -> None: result = _make_chain(tmdb_media).supplement_tmdb_info(primary) - assert result.source == "douban" + assert result.media_source == MediaSource.Douban assert result.media_id == "35593344" assert result.category == "纪录片" assert result.tmdb_id == 9876 @@ -95,7 +95,7 @@ def test_supplement_tmdb_does_not_override_custom_category() -> None: def test_tmdb_supplement_uses_current_season_year_and_keeps_season_zero() -> None: """电视剧优先使用当前季年份,并且特别季季号不能退化为空。""" media = MediaInfo( - source="bangumi", + media_source=MediaSource.Bangumi, media_id="42", type=MediaType.TV, title="测试动画", diff --git a/tests/test_tmdb_recognize.py b/tests/test_tmdb_recognize.py index 6bf6a6686..ed22f33ff 100644 --- a/tests/test_tmdb_recognize.py +++ b/tests/test_tmdb_recognize.py @@ -12,7 +12,7 @@ from app.chain import ChainBase from app.helper.server import MoviePilotServerHelper from app.modules.themoviedb import TheMovieDbModule from app.modules.themoviedb.tmdbv3api.tmdb import TMDb -from app.schemas.types import MediaType +from app.schemas.types import MediaSource, MediaType # 离线 TMDB 响应回放:识别测试断言的是 tmdbid 优先/电影电视消歧/类型推断等逻辑, # 这些逻辑需要真实结构的 TMDB 响应才有意义,但直连 api.themoviedb.org 属于不可接受的 @@ -99,8 +99,7 @@ def tearDownModule(): class TmdbRecognizeModuleTest(TestCase): """ TMDB模块层识别测试 - 模块层的 async_recognize_media 不会自动从 meta.tmdbid 提取 tmdbid, - 该提取在 ChainBase 层完成,因此测试中需显式传入 tmdbid 参数。 + 模块层使用统一媒体身份;历史 tmdbid 文件名标签只在元数据解析边界转换。 """ @classmethod @@ -121,12 +120,16 @@ class TmdbRecognizeModuleTest(TestCase): 而非回退到标题搜索 """ meta = MetaInfo(title="空之境界 {tmdbid=938416}") - self.assertEqual(meta.tmdbid, 938416) + self.assertEqual(meta.media_source, MediaSource.TMDB) + self.assertEqual(meta.media_id, "938416") self.assertEqual(meta.cn_name, "空之境界") result = self._run( self.module.async_recognize_media( - meta=meta, tmdbid=meta.tmdbid, cache=False + meta=meta, + media_source=meta.media_source, + media_id=meta.media_id, + cache=False, ) ) self.assertIsNotNone(result, "应能识别到媒体信息") @@ -139,11 +142,15 @@ class TmdbRecognizeModuleTest(TestCase): 标题包含"空之境界"应消歧为电影 """ meta = MetaInfo(title="空之境界 第五章 矛盾螺旋 (2008) {tmdbid=23155}") - self.assertEqual(meta.tmdbid, 23155) + self.assertEqual(meta.media_source, MediaSource.TMDB) + self.assertEqual(meta.media_id, "23155") result = self._run( self.module.async_recognize_media( - meta=meta, tmdbid=meta.tmdbid, cache=False + meta=meta, + media_source=meta.media_source, + media_id=meta.media_id, + cache=False, ) ) self.assertIsNotNone(result, "同ID存在电影和电视剧时应能通过元数据消歧") @@ -158,7 +165,11 @@ class TmdbRecognizeModuleTest(TestCase): result = self._run( self.module.async_recognize_media( - meta=meta, tmdbid=meta.tmdbid, mtype=MediaType.TV, cache=False + meta=meta, + media_source=meta.media_source, + media_id=meta.media_id, + mtype=MediaType.TV, + cache=False, ) ) self.assertIsNotNone(result) @@ -171,11 +182,15 @@ class TmdbRecognizeModuleTest(TestCase): tmdbid=496891 仅存在电影"少女与战车 最终章 ~第2话~" """ meta = MetaInfo(title="少女与战车 最终章 ~第2话~ (2019) {tmdbid=496891}") - self.assertEqual(meta.tmdbid, 496891) + self.assertEqual(meta.media_source, MediaSource.TMDB) + self.assertEqual(meta.media_id, "496891") result = self._run( self.module.async_recognize_media( - meta=meta, tmdbid=meta.tmdbid, cache=False + meta=meta, + media_source=meta.media_source, + media_id=meta.media_id, + cache=False, ) ) self.assertIsNotNone(result, "仅存在电影时应正确识别") @@ -186,7 +201,7 @@ class TmdbRecognizeModuleTest(TestCase): class TmdbRecognizeChainTest(TestCase): """ ChainBase层识别测试(端到端) - 验证从 meta.tmdbid 提取到模块识别的完整流程 + 验证旧文件名标签转换为统一媒体身份后的完整识别流程 """ @classmethod @@ -215,7 +230,8 @@ class TmdbRecognizeChainTest(TestCase): """ meta = MetaInfo(title="少女与战车 最终章 ~第2话~ (2019) {tmdbid=496891}") self.assertEqual(meta.type, MediaType.TV, "meta.type应被推断为TV") - self.assertEqual(meta.tmdbid, 496891) + self.assertEqual(meta.media_source, MediaSource.TMDB) + self.assertEqual(meta.media_id, "496891") result = self._run( self.chain.async_recognize_media(meta=meta, cache=False) diff --git a/tests/test_torrent_cache_music.py b/tests/test_torrent_cache_music.py index 575a68813..5bef6c5e6 100644 --- a/tests/test_torrent_cache_music.py +++ b/tests/test_torrent_cache_music.py @@ -4,8 +4,8 @@ import asyncio from unittest.mock import AsyncMock, Mock, patch from app.api.endpoints.torrent import reidentify_cache, torrents_cache -from app.chain.music import MusicChain from app.core.context import MUSIC_ENTITY_ALBUM, Context, MusicInfo, TorrentInfo +from app.core.meta import MetaMusic from app.schemas.types import MediaType from app.utils.crypto import HashUtils @@ -20,7 +20,7 @@ def _album_context() -> Context: artists=["周杰伦"], ) return Context( - meta_info=MusicChain.to_meta(album), + meta_info=MetaMusic.from_music_info(album), media_info=album, torrent_info=TorrentInfo( title="周杰伦 - 叶惠美 FLAC", @@ -87,7 +87,7 @@ def test_torrent_cache_exact_reidentify_forwards_album_namespace(): recognize_kwargs = media_chain.async_recognize_media.await_args.kwargs assert recognize_kwargs["mtype"] == MediaType.MUSIC assert recognize_kwargs["music_type"] == MUSIC_ENTITY_ALBUM - assert recognize_kwargs["mediaid"] == "release-group-1" + assert recognize_kwargs["media_id"] == "release-group-1" def test_torrent_cache_auto_reidentify_keeps_music_meta_and_entity(): diff --git a/tests/test_transfer_history_retransfer.py b/tests/test_transfer_history_retransfer.py index f00803c44..24d56c697 100644 --- a/tests/test_transfer_history_retransfer.py +++ b/tests/test_transfer_history_retransfer.py @@ -148,8 +148,8 @@ def test_manual_transfer_without_history_recognition_ignores_old_hash(monkeypatc assert resp.success is True assert captured["downloader"] is None assert captured["download_hash"] is None - assert captured["tmdbid"] is None - assert captured["doubanid"] is None + assert captured["media_source"] is None + assert captured["media_id"] is None assert captured["episode_group"] is None diff --git a/tests/test_transfer_job_manager.py b/tests/test_transfer_job_manager.py index 162972907..80696b666 100644 --- a/tests/test_transfer_job_manager.py +++ b/tests/test_transfer_job_manager.py @@ -9,7 +9,7 @@ from app.core.meta import MetaVideo from app.chain.transfer import JobManager, TransferChain from app.modules.filemanager.transhandler import TransHandler from app.schemas import EpisodeFormat, FileItem, TransferInfo, TransferTask -from app.schemas.types import EventType, MediaType +from app.schemas.types import EventType, MediaSource, MediaType class FakeMeta: @@ -61,7 +61,8 @@ class FakeMedia: self.douban_id = None self.bangumi_id = None self.anilist_id = None - self.source = "themoviedb" + self.media_source = MediaSource.TMDB + self.media_id = str(tmdb_id) self.type = MediaType.TV self.title_year = "Test Show (2026)" @@ -76,6 +77,8 @@ class FakeMedia: "title": "Test Show", "year": "2026", "title_year": "Test Show (2026)", + "media_source": self.media_source, + "media_id": self.media_id, "tmdb_id": self.tmdb_id, "douban_id": self.douban_id, "bangumi_id": self.bangumi_id, diff --git a/tests/test_transfer_tmdb_category.py b/tests/test_transfer_tmdb_category.py index 0e074c777..acd1dc983 100644 --- a/tests/test_transfer_tmdb_category.py +++ b/tests/test_transfer_tmdb_category.py @@ -4,7 +4,70 @@ from app.chain.transfer import TransferChain from app.core.context import MediaInfo from app.core.metainfo import MetaInfo from app.schemas import FileItem, TransferDirectoryConf, TransferTask -from app.schemas.types import MediaType +from app.schemas.types import MediaSource, MediaType + + +def test_transfer_rejects_partial_or_unknown_explicit_identity() -> None: + """整理公共入口不得把半套身份或未知来源传入后台任务。""" + chain = object.__new__(TransferChain) + fileitem = FileItem( + storage="local", + path="/downloads/Test.Movie.2024.mkv", + type="file", + ) + + partial_state, partial_message = chain.do_transfer( + fileitem=fileitem, + media_source=MediaSource.TMDB, + ) + unknown_state, unknown_message = chain.do_transfer( + fileitem=fileitem, + media_source="plugin-source", + media_id="1234", + ) + + assert not partial_state + assert "media_source" in partial_message + assert not unknown_state + assert "media_source" in unknown_message + + +def test_transfer_resolves_complete_identity_before_building_tasks(monkeypatch) -> None: + """整理入口收到完整身份时应精确识别,失败后不得退化为标题识别。""" + chain = object.__new__(TransferChain) + fileitem = FileItem( + storage="local", + path="/downloads/Test.Movie.2024.mkv", + type="file", + ) + recognize = monkeypatch.setattr + calls = [] + + class FakeMediaChain: + """记录精确识别参数并返回空结果。""" + + def recognize_media(self, **kwargs): + """模拟显式身份识别失败。""" + calls.append(kwargs) + return None + + recognize("app.chain.transfer.MediaChain", FakeMediaChain) + + state, message = chain.do_transfer( + fileitem=fileitem, + media_source="tmdb", + media_id="1234", + mtype=MediaType.MOVIE, + ) + + assert not state + assert "未识别到媒体信息" in message + assert calls == [{ + "mtype": MediaType.MOVIE, + "media_source": MediaSource.TMDB, + "media_id": "1234", + "music_type": None, + }] def test_transfer_stops_when_automatic_category_has_no_tmdb_result(monkeypatch) -> None: @@ -32,7 +95,7 @@ def test_transfer_stops_when_automatic_category_has_no_tmdb_result(monkeypatch) ), meta=MetaInfo("Test Movie 2024"), mediainfo=MediaInfo( - source="anilist", + media_source=MediaSource.AniList, media_id="1234", anilist_id=1234, type=MediaType.MOVIE, @@ -52,5 +115,5 @@ def test_transfer_stops_when_automatic_category_has_no_tmdb_result(monkeypatch) assert not state assert message == "未识别到 TMDB 辅助信息,无法按媒体类别整理" - assert task.mediainfo.source == "anilist" + assert task.mediainfo.media_source == MediaSource.AniList assert task.mediainfo.media_id == "1234" diff --git a/tests/test_video_recognize_fallback.py b/tests/test_video_recognize_fallback.py index b7d6f5e05..4f1469b47 100644 --- a/tests/test_video_recognize_fallback.py +++ b/tests/test_video_recognize_fallback.py @@ -7,7 +7,7 @@ from app.chain import ChainBase from app.chain.media import MediaChain from app.core.context import MediaInfo from app.core.meta import MetaBase -from app.schemas.types import MediaType +from app.schemas.types import MediaSource, MediaType def _video_meta() -> MetaBase: @@ -19,17 +19,15 @@ def _video_meta() -> MetaBase: return meta -def _module_kwargs(meta: MetaBase, source: Optional[str] = None) -> dict: +def _module_kwargs( + meta: MetaBase, media_source: Optional[MediaSource] = None +) -> dict: """构造原生识别路由使用的模块参数。""" return { "meta": meta, "mtype": MediaType.MOVIE, - "source": source, - "mediaid": None, - "tmdbid": None, - "doubanid": None, - "bangumiid": None, - "anilistid": None, + "media_source": media_source, + "media_id": None, "episode_group": None, "cache": True, } @@ -58,14 +56,14 @@ def test_video_auto_recognize_only_uses_tmdb(monkeypatch) -> None: assert result is None assert len(calls) == 1 - assert calls[0][0]["source"] == "themoviedb" + assert calls[0][0]["media_source"] == MediaSource.TMDB def test_async_video_auto_recognize_only_uses_tmdb(monkeypatch) -> None: """异步未指定影视来源时也只委托 TMDB 原生识别入口。""" chain = object.__new__(MediaChain) expected = MediaInfo( - source="themoviedb", + media_source=MediaSource.TMDB, media_id="550", tmdb_id=550, title="流浪地球", @@ -92,14 +90,14 @@ def test_async_video_auto_recognize_only_uses_tmdb(monkeypatch) -> None: assert result is expected assert len(calls) == 1 - assert calls[0][0]["source"] == "themoviedb" + assert calls[0][0]["media_source"] == MediaSource.TMDB def test_video_explicit_source_is_preserved(monkeypatch) -> None: """手工指定影视来源时必须保持严格单源分发。""" chain = object.__new__(MediaChain) expected = MediaInfo( - source="douban", + media_source=MediaSource.Douban, media_id="26266893", douban_id="26266893", title="流浪地球", @@ -120,9 +118,9 @@ def test_video_explicit_source_is_preserved(monkeypatch) -> None: ) result = chain._run_native_media_recognize( - _module_kwargs(_video_meta(), source="douban"), + _module_kwargs(_video_meta(), media_source=MediaSource.Douban), cache=True, ) assert result is expected - assert calls[0][0]["source"] == "douban" + assert calls[0][0]["media_source"] == MediaSource.Douban diff --git a/tests/test_webhook_media_identity.py b/tests/test_webhook_media_identity.py new file mode 100644 index 000000000..5525a9b8b --- /dev/null +++ b/tests/test_webhook_media_identity.py @@ -0,0 +1,81 @@ +import json + +import pytest + +from app.modules.emby.emby import Emby +from app.modules.jellyfin.jellyfin import Jellyfin +from app.modules.zspace.zspace import ZSpace +from app.schemas import WebhookEventInfo +from app.schemas.types import MediaSource + + +def test_webhook_event_migrates_legacy_tmdb_input_without_serializing_legacy_field() -> None: + """旧 tmdb_id 输入应迁移为统一身份,序列化时不再输出旧字段。""" + event = WebhookEventInfo(tmdb_id="12345") + + assert event.media_source == MediaSource.TMDB + assert event.media_id == "12345" + assert event.tmdb_id == "12345" + assert "tmdb_id" not in event.model_dump() + + +def test_webhook_event_legacy_tmdb_property_keeps_old_plugins_working() -> None: + """旧插件通过 tmdb_id 属性读写时也应同步到统一身份。""" + event = WebhookEventInfo() + + event.tmdb_id = 67890 + + assert event.media_source == MediaSource.TMDB + assert event.media_id == "67890" + assert event.tmdb_id == "67890" + + +@pytest.mark.parametrize( + "payload", + [ + {"media_source": MediaSource.TMDB}, + {"media_id": "12345"}, + {"media_source": MediaSource.TMDB, "media_id": "0"}, + ], +) +def test_webhook_event_rejects_invalid_unified_identity(payload: dict) -> None: + """Webhook 事件不得携带半对身份或零值 ID。""" + with pytest.raises(ValueError): + WebhookEventInfo(**payload) + + +def test_jellyfin_webhook_uses_provider_identity_pair() -> None: + """Jellyfin webhook 应把 Provider 字段转换为统一身份。""" + client = Jellyfin.__new__(Jellyfin) + event = client.get_webhook_message(json.dumps({ + "NotificationType": "ItemAdded", + "ItemType": "Movie", + "Name": "测试电影", + "Year": 2026, + "Provider_tmdb": "1001", + })) + + assert event is not None + assert event.media_source == MediaSource.TMDB + assert event.media_id == "1001" + + +@pytest.mark.parametrize("client_class", [Emby, ZSpace]) +def test_emby_family_webhook_uses_provider_identity_pair(client_class: type) -> None: + """Emby 系 webhook 应支持非 TMDB Provider 并输出统一身份。""" + client = client_class.__new__(client_class) + event = client.get_webhook_message({ + "data": json.dumps({ + "Event": "library.new", + "Item": { + "Type": "Movie", + "Name": "测试电影", + "ProductionYear": 2026, + "ProviderIds": {"Douban": "2002"}, + }, + }) + }, {}) + + assert event is not None + assert event.media_source == MediaSource.Douban + assert event.media_id == "2002" diff --git a/tests/test_workflow_actions.py b/tests/test_workflow_actions.py index 4f9e26440..8d04eb14f 100644 --- a/tests/test_workflow_actions.py +++ b/tests/test_workflow_actions.py @@ -89,16 +89,20 @@ def test_scrape_file_keeps_workflow_action_context(monkeypatch): return True class FakeMediaChain: - """模拟媒体识别和刮削链。""" + """模拟媒体识别链。""" def recognize_by_path(self, path, obtain_images=False): return SimpleNamespace(meta_info="meta", media_info="media") + class FakeScrapingChain: + """模拟独立刮削链。""" + def scrape_metadata(self, fileitem, meta=None, mediainfo=None): scraped.append((fileitem.path, meta, mediainfo)) monkeypatch.setattr(scrape_file_module, "StorageChain", FakeStorageChain) monkeypatch.setattr(scrape_file_module, "MediaChain", FakeMediaChain) + monkeypatch.setattr(scrape_file_module, "ScrapingChain", FakeScrapingChain) monkeypatch.setattr(scrape_file_module.global_vars, "is_workflow_stopped", lambda workflow_id: False) monkeypatch.setattr(ScrapeFileAction, "check_cache", lambda self, workflow_id, key: False) monkeypatch.setattr(ScrapeFileAction, "save_cache", lambda self, workflow_id, data: None) @@ -131,16 +135,20 @@ def test_scrape_file_does_not_cache_failed_music_scrape(monkeypatch): return True class FakeMediaChain: - """模拟识别成功但音乐产物写入失败。""" + """模拟媒体识别成功。""" def recognize_by_path(self, path, obtain_images=False): return SimpleNamespace(meta_info="music-meta", media_info="music") + class FakeScrapingChain: + """模拟音乐产物写入失败。""" + def scrape_metadata(self, fileitem, meta=None, mediainfo=None): return False, "歌词保存失败" monkeypatch.setattr(scrape_file_module, "StorageChain", FakeStorageChain) monkeypatch.setattr(scrape_file_module, "MediaChain", FakeMediaChain) + monkeypatch.setattr(scrape_file_module, "ScrapingChain", FakeScrapingChain) monkeypatch.setattr( scrape_file_module.global_vars, "is_workflow_stopped",