From ff9ba79b60721b42a4d78b11ae060ce87d2fca02 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 25 Jul 2026 08:07:08 +0800 Subject: [PATCH] Codex/fix season subscription poster (#6185) --- app/agent/tools/impl/delete_subscribe.py | 1 + app/api/endpoints/subscribe.py | 1 + app/chain/download.py | 15 ++- app/chain/media.py | 107 ++++++++++++++++++++ app/chain/subscribe.py | 23 ++++- app/chain/transfer.py | 69 +++++++++---- app/schemas/transfer.py | 10 +- tests/test_download_chain.py | 65 ++++++++++++ tests/test_download_save_path_allowlist.py | 15 +++ tests/test_tmdb_auxiliary.py | 112 +++++++++++++++++++++ tests/test_transfer_movie_collection.py | 3 +- tests/test_transfer_tmdb_category.py | 56 +++++++++++ 12 files changed, 439 insertions(+), 38 deletions(-) create mode 100644 tests/test_tmdb_auxiliary.py create mode 100644 tests/test_transfer_tmdb_category.py diff --git a/app/agent/tools/impl/delete_subscribe.py b/app/agent/tools/impl/delete_subscribe.py index 334abc6f..df3e78f7 100644 --- a/app/agent/tools/impl/delete_subscribe.py +++ b/app/agent/tools/impl/delete_subscribe.py @@ -61,6 +61,7 @@ class DeleteSubscribeTool(MoviePilotTool): "anilistid": subscribe.anilistid, "media_source": subscribe.media_source, "media_id": subscribe.media_id, + "season": subscribe.season, } ) diff --git a/app/api/endpoints/subscribe.py b/app/api/endpoints/subscribe.py index dd2419be..4ce3dfa9 100644 --- a/app/api/endpoints/subscribe.py +++ b/app/api/endpoints/subscribe.py @@ -863,6 +863,7 @@ async def delete_subscribe( "anilistid": subscribe_info.get("anilistid"), "media_source": subscribe_info.get("media_source"), "media_id": subscribe_info.get("media_id"), + "season": subscribe_info.get("season"), } ) return schemas.Response(success=True) diff --git a/app/chain/download.py b/app/chain/download.py index bee6c060..36fc8d74 100644 --- a/app/chain/download.py +++ b/app/chain/download.py @@ -11,6 +11,7 @@ from urllib.parse import parse_qs, urljoin, urlparse from app import schemas from app.chain import ChainBase +from app.chain.media import MediaChain from app.chain.storage import StorageChain from app.core.cache import FileCache from app.core.config import settings, global_vars @@ -402,6 +403,7 @@ class DownloadChain(ChainBase): ) if not mediainfo: return False, "无法识别媒体信息", [] + mediainfo = MediaChain().supplement_tmdb_info(mediainfo, metainfo) storage, target_dir, error_msg = self._resolve_media_download_dir( media_info=mediainfo, @@ -804,6 +806,10 @@ class DownloadChain(ChainBase): _meta = context.meta_info _site_downloader = _torrent.site_downloader + # 下载目录和下载器分类依赖 TMDB 辅助分类,但媒体主身份保持不变。 + _media = MediaChain().supplement_tmdb_info(_media, _meta) + context.media_info = _media + # 发送资源下载事件,允许外部拦截下载 event_data = ResourceDownloadEventData( context=context, @@ -839,15 +845,6 @@ class DownloadChain(ChainBase): logger.warn(str(err)) return (None, str(err)) if return_detail else None - # 补充完整的media数据 - if not _media.genre_ids: - new_media = self.recognize_media(mtype=_media.type, tmdbid=_media.tmdb_id, - doubanid=_media.douban_id, bangumiid=_media.bangumi_id, - anilistid=_media.anilist_id, - episode_group=_media.episode_group) - if new_media: - _media = new_media - # 实际下载的集数 download_episodes = StringUtils.format_ep(list(episodes)) if episodes else None if episodes is not None: diff --git a/app/chain/media.py b/app/chain/media.py index b2acc243..2c17871a 100644 --- a/app/chain/media.py +++ b/app/chain/media.py @@ -614,6 +614,113 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): logger.warn(f"{metainfo.title} 未识别到媒体信息") return mediainfo + @staticmethod + def _build_tmdb_supplement_meta( + mediainfo: MediaInfo, + metainfo: Optional[MetaBase] = None, + ) -> MetaBase: + """ + 根据主识别结果构造 TMDB 辅助识别参数。 + + :param mediainfo: 主识别源返回的媒体信息 + :param metainfo: 原始标题解析信息 + :return: 不携带主识别源身份的 TMDB 查询参数 + """ + title = mediainfo.title or getattr(metainfo, "name", None) or "" + tmdb_meta = MetaInfo(title) + if not tmdb_meta.cn_name and getattr(metainfo, "cn_name", None): + tmdb_meta.cn_name = metainfo.cn_name + if not tmdb_meta.en_name: + tmdb_meta.en_name = mediainfo.en_title or ( + getattr(metainfo, "en_name", None) + ) + tmdb_meta.type = mediainfo.type or ( + getattr(metainfo, "type", None) or MediaType.UNKNOWN + ) + season = ( + mediainfo.season + if mediainfo.season is not None + else getattr(metainfo, "begin_season", None) + ) + tmdb_meta.begin_season = season + season_year = None + if season is not None and mediainfo.season_years: + season_year = ( + mediainfo.season_years.get(season) + or mediainfo.season_years.get(str(season)) + ) + tmdb_meta.year = ( + season_year + or mediainfo.year + or getattr(metainfo, "year", None) + ) + return tmdb_meta + + @staticmethod + def _merge_tmdb_auxiliary( + mediainfo: MediaInfo, + tmdb_media: MediaInfo, + ) -> MediaInfo: + """ + 将 TMDB 兼容字段合并到主识别结果,不改变主数据源身份和展示信息。 + + :param mediainfo: 主识别源返回的媒体信息 + :param tmdb_media: TMDB 辅助识别结果 + :return: 已补充 TMDB 兼容字段的主媒体信息 + """ + if not tmdb_media or tmdb_media.source != "themoviedb" or not tmdb_media.tmdb_id: + return mediainfo + + mediainfo.tmdb_id = tmdb_media.tmdb_id + mediainfo.tmdb_info = tmdb_media.tmdb_info or mediainfo.tmdb_info + if not mediainfo.category: + mediainfo.category = tmdb_media.category + if not mediainfo.genre_ids: + mediainfo.genre_ids = list(tmdb_media.genre_ids or []) + for field in ("imdb_id", "tvdb_id", "collection_id"): + if not getattr(mediainfo, field, None): + setattr(mediainfo, field, getattr(tmdb_media, field, None)) + return mediainfo + + def supplement_tmdb_info( + self, + mediainfo: Optional[MediaInfo], + metainfo: Optional[MetaBase] = None, + ) -> Optional[MediaInfo]: + """ + 为任意主识别源补充 TMDB 辅助信息,同时保留原始媒体身份。 + + :param mediainfo: 主识别源返回的媒体信息 + :param metainfo: 原始标题解析信息 + :return: 已补充 TMDB 辅助字段的原媒体对象 + """ + if not mediainfo: + return None + if mediainfo.tmdb_id and mediainfo.tmdb_info and mediainfo.genre_ids: + return mediainfo + tmdb_meta = self._build_tmdb_supplement_meta(mediainfo, metainfo) + tmdb_module = self.modulemanager.get_running_module("TheMovieDbModule") + if not tmdb_module: + logger.warn("TMDB 模块未启用,无法补充 TMDB 辅助信息") + return mediainfo + try: + tmdb_media = tmdb_module.recognize_media( + meta=tmdb_meta, + mtype=mediainfo.type, + source="themoviedb", + mediaid=str(mediainfo.tmdb_id) if mediainfo.tmdb_id else None, + tmdbid=mediainfo.tmdb_id, + episode_group=mediainfo.episode_group, + cache=True, + ) + except Exception as err: + logger.warn(f"{mediainfo.title_year} 补充 TMDB 辅助信息失败:{err}") + return mediainfo + if not tmdb_media: + logger.warn(f"{mediainfo.title_year} 未匹配到 TMDB 辅助信息") + return mediainfo + return self._merge_tmdb_auxiliary(mediainfo, tmdb_media) + def _recognize_with_fallback_by_meta( self, metainfo: MetaBase, diff --git a/app/chain/subscribe.py b/app/chain/subscribe.py index d6b799c1..fa707e4c 100644 --- a/app/chain/subscribe.py +++ b/app/chain/subscribe.py @@ -1055,7 +1055,7 @@ class SubscribeChain(ChainBase): "anilistid": mediainfo.anilist_id, "media_source": media_source, "media_id": media_id, - "season": metainfo.begin_season, + "season": season, "poster": mediainfo.get_poster_image(), "backdrop": mediainfo.get_backdrop_image(), "vote": mediainfo.vote_average, @@ -1250,7 +1250,7 @@ class SubscribeChain(ChainBase): "anilistid": mediainfo.anilist_id, "media_source": media_source, "media_id": media_id, - "season": metainfo.begin_season, + "season": season, "poster": mediainfo.get_poster_image(), "backdrop": mediainfo.get_backdrop_image(), "vote": mediainfo.vote_average, @@ -2835,7 +2835,12 @@ class SubscribeChain(ChainBase): # 统计订阅 MoviePilotServerHelper.sub_done_async({ "tmdbid": mediainfo.tmdb_id, - "doubanid": mediainfo.douban_id + "doubanid": mediainfo.douban_id, + "bangumiid": mediainfo.bangumi_id, + "anilistid": mediainfo.anilist_id, + "media_source": subscribe.media_source, + "media_id": subscribe.media_id, + "season": subscribe.season, }) def remote_list( @@ -3490,6 +3495,11 @@ class SubscribeChain(ChainBase): { "tmdbid": subscribe.tmdbid, "doubanid": subscribe.doubanid, + "bangumiid": subscribe.bangumiid, + "anilistid": subscribe.anilistid, + "media_source": subscribe.media_source, + "media_id": subscribe.media_id, + "season": subscribe.season, } ) @@ -3537,7 +3547,12 @@ class SubscribeChain(ChainBase): # 统计订阅 MoviePilotServerHelper.sub_done_async({ "tmdbid": subscribe.tmdbid, - "doubanid": subscribe.doubanid + "doubanid": subscribe.doubanid, + "bangumiid": subscribe.bangumiid, + "anilistid": subscribe.anilistid, + "media_source": subscribe.media_source, + "media_id": subscribe.media_id, + "season": subscribe.season, }) # 重新发送消息 self.remote_list(channel=channel, userid=userid, source=source) diff --git a/app/chain/transfer.py b/app/chain/transfer.py index ddb82c60..7c73babf 100755 --- a/app/chain/transfer.py +++ b/app/chain/transfer.py @@ -55,7 +55,7 @@ from app.schemas.types import ( ContentType, ) from app.utils.mixins import ConfigReloadMixin -from app.utils.media import parse_media_key +from app.utils.media import normalize_media_source, parse_media_key, resolve_media_identity from app.utils.singleton import Singleton from app.utils.string import StringUtils from app.utils.system import SystemUtils @@ -142,19 +142,8 @@ class JobManager: """ if not media: return None, season - media_ids = { - "themoviedb": media.tmdb_id, - "douban": media.douban_id, - "bangumi": media.bangumi_id, - "anilist": media.anilist_id, - } - source = media.source - if not source or media_ids.get(source) is None: - source = next( - (name for name, media_id in media_ids.items() if media_id is not None), - source, - ) - return (source, media_ids.get(source)), season + source, media_id = resolve_media_identity(media=media) + return (source, media_id), season @staticmethod def __get_file_key(fileitem: FileItem) -> Optional[Tuple[str, str]]: @@ -794,6 +783,23 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): "TRANSFER_THREADS", } + @staticmethod + def _requires_automatic_category(task: TransferTask) -> bool: + """ + 判断当前整理任务是否需要根据媒体识别结果自动创建类别目录。 + + :param task: 整理任务 + :return: 是否必须具备自动分类结果 + """ + target_directory = task.target_directory + if target_directory and target_directory.media_category: + return False + if task.library_category_folder is not None: + return bool(task.library_category_folder) + return bool( + target_directory and target_directory.library_category_folder + ) + def __init__(self): """初始化文件整理处理链。""" super().__init__() @@ -1709,8 +1715,15 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): mediainfo_changed = True - # 如果未开启新增已入库媒体是否跟随TMDB信息变化则根据tmdbid查询之前的title - if not settings.SCRAP_FOLLOW_TMDB: + # TMDB 仅作为辅助信息合并,不能改变原识别源的主身份和标题。 + mediainfo = MediaChain().supplement_tmdb_info(mediainfo, task.meta) + task.mediainfo = mediainfo + + # 只有 TMDB 主源沿用历史 TMDB 标题,避免辅助 ID 改写其它识别源标题。 + if ( + not settings.SCRAP_FOLLOW_TMDB + and normalize_media_source(mediainfo.source) == "themoviedb" + ): transfer_history = transferhis.get_by_type_tmdbid( tmdbid=mediainfo.tmdb_id, mtype=mediainfo.type.value ) @@ -1727,7 +1740,11 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): return False, f"{task.fileitem.name} 已在整理队列中" # 获取集数据 - if task.mediainfo.type == MediaType.TV and not task.episodes_info: + if ( + task.mediainfo.type == MediaType.TV + and task.mediainfo.tmdb_id + and not task.episodes_info + ): # 判断注意season为0的情况 season_num = task.mediainfo.season if season_num is None and task.meta.season_seq: @@ -1762,6 +1779,24 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): if not task.target_storage and task.target_directory: task.target_storage = task.target_directory.library_storage + if self._requires_automatic_category(task) and not task.mediainfo.category: + if task.mediainfo.tmdb_id: + error_message = "TMDB 信息未匹配到媒体分类,无法按媒体类别整理" + else: + error_message = "未识别到 TMDB 辅助信息,无法按媒体类别整理" + logger.error(f"{task.fileitem.name} {error_message}") + if callback: + return callback( + task, + TransferInfo( + success=False, + fileitem=task.fileitem, + transfer_type=task.transfer_type, + message=error_message, + ), + ) + return False, error_message + # 正在处理 self.jobview.running_task(task) diff --git a/app/schemas/transfer.py b/app/schemas/transfer.py index 1b25ac5b..1c12d0bb 100644 --- a/app/schemas/transfer.py +++ b/app/schemas/transfer.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Any, Callable, List, Literal, Optional +from typing import Any, Callable, List, Optional from pydantic import BaseModel, Field @@ -60,9 +60,7 @@ class TransferTask(BaseModel): fileitem: FileItem meta: Optional[Any] = None mediainfo: Optional[Any] = None - media_source: Optional[ - Literal["themoviedb", "douban", "bangumi", "anilist"] - ] = None + media_source: Optional[str] = None target_directory: Optional[TransferDirectoryConf] = None target_storage: Optional[str] = None target_path: Optional[Path] = None @@ -220,9 +218,7 @@ class ManualTransferItem(BaseModel): # AniList ID anilistid: Optional[int] = None # 媒体数据源 - media_source: Optional[ - Literal["themoviedb", "douban", "bangumi", "anilist"] - ] = None + media_source: Optional[str] = None # 数据源原生ID media_id: Optional[str] = None # 类型 diff --git a/tests/test_download_chain.py b/tests/test_download_chain.py index e77a64ab..2aa5568c 100644 --- a/tests/test_download_chain.py +++ b/tests/test_download_chain.py @@ -2,6 +2,8 @@ from pathlib import Path from types import SimpleNamespace from unittest.mock import MagicMock +import pytest + import app.chain.download as download_module from app.chain.download import DownloadChain from app.core.config import settings @@ -11,6 +13,21 @@ from app.schemas import FileItem, NotExistMediaInfo, TransferDirectoryConf from app.schemas.types import MediaType +@pytest.fixture(autouse=True) +def _mock_tmdb_supplement(monkeypatch): + """隔离下载用例中的 TMDB 辅助识别外部边界。""" + + class _NoopMediaChain: + """保持原媒体对象不变的 TMDB 辅助识别替身。""" + + @staticmethod + def supplement_tmdb_info(media, _meta): + """返回原媒体对象。""" + return media + + monkeypatch.setattr(download_module, "MediaChain", _NoopMediaChain) + + class _FakeDownloadHistoryOper: """ 避免单元测试写入真实下载历史,只验证下载链路的控制流。 @@ -176,6 +193,54 @@ def test_download_single_submits_download_added_to_background(monkeypatch): ) +def test_download_single_supplements_category_before_download_event(monkeypatch): + """下载事件和目录选择前应已有 TMDB 分类,同时保留原识别源身份。""" + captured = {} + + class _FakeMediaChain: + """模拟 TMDB 辅助识别并记录调用。""" + + @staticmethod + def supplement_tmdb_info(media, _meta): + """给原媒体对象补充下载分类。""" + media.tmdb_id = 12345 + media.category = "日本动画" + return media + + def cancel_download(_event_type, event_data): + """捕获下载事件后取消,避免进入真实下载流程。""" + captured["event_data"] = event_data + event_data.cancel = True + return SimpleNamespace(event_data=event_data) + + monkeypatch.setattr(download_module, "MediaChain", _FakeMediaChain) + monkeypatch.setattr(download_module.eventmanager, "send_event", cancel_download) + media = MediaInfo( + source="bangumi", + media_id="40000", + bangumi_id=40000, + type=MediaType.TV, + title="测试动画", + ) + context = Context( + meta_info=MetaInfo("测试动画 S01"), + media_info=media, + torrent_info=TorrentInfo(title="测试动画 S01"), + ) + + result = DownloadChain.__new__(DownloadChain).download_single( + context=context, + torrent_content="magnet:?xt=urn:btih:test", + return_detail=True, + ) + + assert result == (None, "下载被事件取消") + assert captured["event_data"].options["media_category"] == "日本动画" + assert context.media_info.source == "bangumi" + assert context.media_info.media_id == "40000" + assert context.media_info.tmdb_id == 12345 + + def test_download_single_persists_custom_words_snapshot(monkeypatch): """下载成功登记历史时,应把传入的订阅识别词原样存入快照,供整理时原样复现识别。""" captured = {} diff --git a/tests/test_download_save_path_allowlist.py b/tests/test_download_save_path_allowlist.py index 6335302c..1557ea6c 100644 --- a/tests/test_download_save_path_allowlist.py +++ b/tests/test_download_save_path_allowlist.py @@ -18,6 +18,21 @@ from app.schemas import DownloaderTorrent, TransferDirectoryConf from app.schemas.types import MediaType +@pytest.fixture(autouse=True) +def _mock_tmdb_supplement(monkeypatch): + """隔离下载路径用例中的 TMDB 辅助识别外部边界。""" + + class _NoopMediaChain: + """保持原媒体对象不变的 TMDB 辅助识别替身。""" + + @staticmethod + def supplement_tmdb_info(media, _meta): + """返回原媒体对象。""" + return media + + monkeypatch.setattr(download_module, "MediaChain", _NoopMediaChain) + + def _download_dirs(): return [ TransferDirectoryConf( diff --git a/tests/test_tmdb_auxiliary.py b/tests/test_tmdb_auxiliary.py new file mode 100644 index 00000000..ba4c0a91 --- /dev/null +++ b/tests/test_tmdb_auxiliary.py @@ -0,0 +1,112 @@ +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 + + +class _FakeTmdbModule: + """返回固定 TMDB 结果,避免测试访问外部元数据服务。""" + + def __init__(self, result: MediaInfo): + """保存测试需要返回的 TMDB 媒体信息。""" + self.result = result + + def recognize_media(self, **_kwargs): + """同步返回固定 TMDB 媒体信息。""" + return self.result + + +def _make_chain(tmdb_media: MediaInfo) -> MediaChain: + """构造不加载真实模块的媒体处理链。""" + chain = object.__new__(MediaChain) + module = _FakeTmdbModule(tmdb_media) + chain.modulemanager = SimpleNamespace( + get_running_module=lambda module_id: ( + module if module_id == "TheMovieDbModule" else None + ) + ) + return chain + + +def test_supplement_tmdb_keeps_custom_source_identity() -> None: + """自定义识别源补充 TMDB 后,主身份和展示字段必须保持不变。""" + primary = MediaInfo( + source="plugin-anime", + media_id="subject-42", + type=MediaType.TV, + title="原识别标题", + year="2024", + category="", + ) + tmdb_media = MediaInfo( + tmdb_info={ + "id": 12345, + "media_type": MediaType.TV, + "name": "TMDB 标题", + "genre_ids": [16, 18], + "external_ids": {"imdb_id": "tt12345", "tvdb_id": 6789}, + } + ) + tmdb_media.category = "日本动画" + + result = _make_chain(tmdb_media).supplement_tmdb_info( + primary, MetaInfo("原识别标题 2024") + ) + + assert result is primary + assert result.source == "plugin-anime" + assert result.media_id == "subject-42" + assert result.title == "原识别标题" + assert result.tmdb_id == 12345 + assert result.genre_ids == [16, 18] + assert result.category == "日本动画" + + +def test_supplement_tmdb_does_not_override_custom_category() -> None: + """下载历史或目录指定的自定义分类优先于 TMDB 自动分类。""" + primary = MediaInfo( + source="douban", + media_id="35593344", + douban_id="35593344", + type=MediaType.MOVIE, + title="测试电影", + category="纪录片", + ) + tmdb_media = MediaInfo( + tmdb_info={ + "id": 9876, + "media_type": MediaType.MOVIE, + "title": "Test Movie", + "genre_ids": [28], + } + ) + tmdb_media.category = "动作片" + + result = _make_chain(tmdb_media).supplement_tmdb_info(primary) + + assert result.source == "douban" + assert result.media_id == "35593344" + assert result.category == "纪录片" + assert result.tmdb_id == 9876 + + +def test_tmdb_supplement_uses_current_season_year_and_keeps_season_zero() -> None: + """电视剧优先使用当前季年份,并且特别季季号不能退化为空。""" + media = MediaInfo( + source="bangumi", + media_id="42", + type=MediaType.TV, + title="测试动画", + year="2020", + season=0, + season_years={0: "2024"}, + ) + + tmdb_meta = MediaChain._build_tmdb_supplement_meta( + media, MetaInfo("测试动画 S00 2020") + ) + + assert tmdb_meta.begin_season == 0 + assert tmdb_meta.year == "2024" diff --git a/tests/test_transfer_movie_collection.py b/tests/test_transfer_movie_collection.py index fdd804c6..42462272 100644 --- a/tests/test_transfer_movie_collection.py +++ b/tests/test_transfer_movie_collection.py @@ -98,7 +98,8 @@ def test_conflicting_download_history_recognizes_movie_by_file_meta(monkeypatch) lambda: SimpleNamespace( recognize_by_meta=lambda meta, obtain_images: ( recognized_meta.append(meta) or fallback_media - ) + ), + supplement_tmdb_info=lambda media, _meta: media, ), ) task = TransferTask( diff --git a/tests/test_transfer_tmdb_category.py b/tests/test_transfer_tmdb_category.py new file mode 100644 index 00000000..0e074c77 --- /dev/null +++ b/tests/test_transfer_tmdb_category.py @@ -0,0 +1,56 @@ +from types import SimpleNamespace + +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 + + +def test_transfer_stops_when_automatic_category_has_no_tmdb_result(monkeypatch) -> None: + """启用自动类别目录时,缺少 TMDB 分类必须在文件操作前明确失败。""" + chain = object.__new__(TransferChain) + chain.jobview = SimpleNamespace(try_remove_job=lambda _task: None) + monkeypatch.setattr( + "app.chain.transfer.TransferHistoryOper", + lambda: SimpleNamespace(), + ) + monkeypatch.setattr( + "app.chain.transfer.MediaChain", + lambda: SimpleNamespace( + supplement_tmdb_info=lambda media, _meta: media, + ), + ) + task = TransferTask( + fileitem=FileItem( + storage="local", + path="/downloads/Test.Movie.2024.mkv", + type="file", + name="Test.Movie.2024.mkv", + extension="mkv", + size=1024, + ), + meta=MetaInfo("Test Movie 2024"), + mediainfo=MediaInfo( + source="anilist", + media_id="1234", + anilist_id=1234, + type=MediaType.MOVIE, + title="Test Movie", + year="2024", + ), + target_directory=TransferDirectoryConf( + library_storage="local", + library_path="/library", + library_category_folder=True, + ), + library_category_folder=True, + preview=True, + ) + + state, message = chain._TransferChain__handle_transfer(task) + + assert not state + assert message == "未识别到 TMDB 辅助信息,无法按媒体类别整理" + assert task.mediainfo.source == "anilist" + assert task.mediainfo.media_id == "1234"