diff --git a/app/chain/transfer.py b/app/chain/transfer.py index f998d050..341d684d 100755 --- a/app/chain/transfer.py +++ b/app/chain/transfer.py @@ -1543,7 +1543,13 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): if download_history: task.username = download_history.username # 识别媒体信息 - if download_history.tmdbid or download_history.doubanid: + history_year_conflict = self._is_movie_year_conflict( + task.meta, download_history + ) + if ( + (download_history.tmdbid or download_history.doubanid) + and not history_year_conflict + ): # 下载记录中已存在识别信息 mediainfo: Optional[MediaInfo] = self.recognize_media( mtype=MediaType(download_history.type), @@ -1556,6 +1562,18 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): # 更新自定义媒体类别 if download_history.media_category: mediainfo.category = download_history.media_category + else: + if history_year_conflict: + logger.info( + f"{task.fileitem.name} 文件年份 {task.meta.year} 与下载记录年份 " + f"{download_history.year} 不一致,按文件名重新识别" + ) + mediainfo = MediaChain().recognize_by_meta( + task.meta, + obtain_images=True, + ) + if mediainfo and download_history.media_category: + mediainfo.category = download_history.media_category else: # 识别媒体信息 mediainfo = MediaChain().recognize_by_meta( @@ -2304,6 +2322,31 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): return None + @staticmethod + def _is_movie_year_conflict( + file_meta: MetaBase, media: Union[DownloadHistory, MediaInfo] + ) -> bool: + """ + 判断文件名年份是否与已识别电影年份冲突。 + + 多电影合集只保存一条下载历史,不能把合集首部电影的媒体 ID 套用到其它年份的文件; + 电视剧季包仍应继续复用同一条下载历史。 + """ + file_year = getattr(file_meta, "year", None) + media_year = getattr(media, "year", None) + if not file_meta or not media or not file_year or not media_year: + return False + media_type = getattr(media, "type", None) + if not isinstance(media_type, MediaType): + try: + media_type = MediaType(media_type) + except (TypeError, ValueError): + return False + return ( + media_type == MediaType.MOVIE + and str(file_year) != str(media_year) + ) + @staticmethod def __optional_attr_equal( source: MetaBase, @@ -2964,11 +3007,19 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): _downloader = downloader _download_hash = download_hash + # 自动整理预载的媒体信息来自整条下载历史;电影合集内文件年份冲突时逐文件识别。 + task_mediainfo = mediainfo + if ( + not manual + and self._is_movie_year_conflict(file_meta, task_mediainfo) + ): + task_mediainfo = None + # 后台整理 transfer_task = TransferTask( fileitem=file_item, meta=file_meta, - mediainfo=mediainfo, + mediainfo=task_mediainfo, target_directory=target_directory, target_storage=target_storage, target_path=target_path, diff --git a/tests/test_metainfo.py b/tests/test_metainfo.py index c59eac3a..5211ee68 100644 --- a/tests/test_metainfo.py +++ b/tests/test_metainfo.py @@ -273,3 +273,39 @@ def test_metainfopath_cn_title_containing_keyword_not_cleared(): path = Path("/Some Movie 2024/粤语残片.mkv") meta = MetaInfoPath(path) assert "粤语残片" in meta.cn_name + + +def test_metainfopath_movie_collection_parent_does_not_override_file_title(): + """电影合集父目录不应覆盖文件名中更具体的片名与年份。""" + collection = ( + "/Unraid/Media/MoviePilot/电影/" + "The.Hunger.Games.Complete.4-Film.Collection.2160p.UHD.Blu-ray." + "DV.Atmos.TrueHD.7.1.x265-HDH" + ) + cases = [ + ( + "The.Hunger.Games.2012.2160p.UHD.Blu-ray.DV.Atmos.TrueHD.7.1.x265-HDH.mkv", + "The Hunger Games", + "2012", + ), + ( + "The.Hunger.Games.Catching.Fire.2013.2160p.UHD.Blu-ray.DV.Atmos.TrueHD.7.1.x265-HDH.mkv", + "The Hunger Games Catching Fire", + "2013", + ), + ( + "The.Hunger.Games.Mockingjay.Part.1.2014.2160p.UHD.Blu-ray.DV.Atmos.TrueHD.7.1.x265-HDH.mkv", + "The Hunger Games Mockingjay Part 1", + "2014", + ), + ( + "The.Hunger.Games.Mockingjay.Part.2.2015.2160p.UHD.Blu-ray.DV.Atmos.TrueHD.7.1.x265-HDH.mkv", + "The Hunger Games Mockingjay Part 2", + "2015", + ), + ] + + for file_name, expected_name, expected_year in cases: + meta = MetaInfoPath(Path(f"{collection}/{file_name}")) + assert meta.name == expected_name + assert meta.year == expected_year diff --git a/tests/test_transfer_movie_collection.py b/tests/test_transfer_movie_collection.py new file mode 100644 index 00000000..fdd804c6 --- /dev/null +++ b/tests/test_transfer_movie_collection.py @@ -0,0 +1,192 @@ +from types import SimpleNamespace + +import pytest + +from app.chain.transfer import TransferChain +from app.core.config import settings +from app.core.context import MediaInfo +from app.schemas import DownloadHistory, FileItem, TransferTask +from app.schemas.types import MediaType + + +def _make_chain() -> TransferChain: + """构造不启动后台线程的整理链测试实例。""" + chain = object.__new__(TransferChain) + chain._media_exts = settings.RMT_MEDIAEXT + chain._subtitle_exts = settings.RMT_SUBEXT + chain._audio_exts = settings.RMT_AUDIOEXT + chain._allowed_exts = ( + chain._media_exts + chain._subtitle_exts + chain._audio_exts + ) + chain.jobview = SimpleNamespace( + finish_task=lambda task: None, + try_remove_job=lambda task: None, + ) + chain._TransferChain__get_trans_fileitems = lambda fileitem, predicate: [ + (fileitem, False) + ] + chain._TransferChain__put_to_jobview = lambda task: True + chain._TransferChain__register_scrape_batch_task = lambda task: None + chain._TransferChain__close_scrape_batch = lambda batch_id: None + return chain + + +def _make_file_meta(year: str = "2013") -> SimpleNamespace: + """构造电影合集文件的元数据。""" + return SimpleNamespace( + name="The Hunger Games Catching Fire", + year=year, + type=MediaType.UNKNOWN, + begin_season=None, + begin_episode=None, + part=None, + ) + + +def _make_history() -> SimpleNamespace: + """构造被合集首部电影占用的下载历史。""" + return SimpleNamespace( + id=1, + download_hash="collection-hash", + downloader="qbittorrent", + type=MediaType.MOVIE.value, + title="饥饿游戏", + year="2012", + tmdbid=70160, + doubanid=None, + episode_group=None, + media_category=None, + username=None, + custom_words=None, + note=None, + ) + + +def test_movie_year_conflict_only_applies_to_movies(): + """仅电影年份冲突应触发逐文件识别,电视剧季包仍复用下载历史。""" + file_meta = _make_file_meta() + movie_history = _make_history() + tv_history = SimpleNamespace(type=MediaType.TV, year="2012") + + assert TransferChain._is_movie_year_conflict(file_meta, movie_history) + assert not TransferChain._is_movie_year_conflict(file_meta, tv_history) + movie_history.year = "2013" + assert not TransferChain._is_movie_year_conflict(file_meta, movie_history) + + +def test_conflicting_download_history_recognizes_movie_by_file_meta(monkeypatch): + """手动整理未指定媒体时,冲突的合集历史应回退到文件元数据识别。""" + chain = object.__new__(TransferChain) + fallback_media = MediaInfo( + type=MediaType.MOVIE, + title="饥饿游戏2:星火燎原", + year="2013", + tmdb_id=101299, + ) + recognized_meta = [] + chain.recognize_media = lambda **kwargs: pytest.fail("不应按合集历史 ID 识别") + chain.jobview = SimpleNamespace( + migrate_task=lambda task: False, + try_remove_job=lambda task: None, + ) + monkeypatch.setattr( + "app.chain.transfer.TransferHistoryOper", + lambda: SimpleNamespace(get_by_type_tmdbid=lambda **kwargs: None), + ) + monkeypatch.setattr( + "app.chain.transfer.MediaChain", + lambda: SimpleNamespace( + recognize_by_meta=lambda meta, obtain_images: ( + recognized_meta.append(meta) or fallback_media + ) + ), + ) + task = TransferTask( + fileitem=FileItem( + storage="local", + path="/downloads/collection/The.Hunger.Games.Catching.Fire.2013.mkv", + type="file", + name="The.Hunger.Games.Catching.Fire.2013.mkv", + extension="mkv", + size=1024, + ), + meta=_make_file_meta(), + download_history=DownloadHistory(**vars(_make_history())), + preview=True, + ) + + state, message = chain._TransferChain__handle_transfer(task) + + assert not state + assert "已在整理队列中" in message + assert recognized_meta == [task.meta] + assert task.mediainfo.tmdb_id == 101299 + + +@pytest.mark.parametrize( + ("manual", "expected_tmdb_id"), + [ + (False, None), + (True, 70160), + ], +) +def test_movie_collection_conflict_only_drops_automatic_media( + monkeypatch, manual: bool, expected_tmdb_id: int +): + """自动整理应丢弃冲突的合集媒体,手动明确指定的媒体仍应保留。""" + chain = _make_chain() + source_file = FileItem( + storage="local", + path=( + "/downloads/The.Hunger.Games.Complete.4-Film.Collection/" + "The.Hunger.Games.Catching.Fire.2013.mkv" + ), + type="file", + name="The.Hunger.Games.Catching.Fire.2013.mkv", + extension="mkv", + size=1024, + ) + file_meta = _make_file_meta() + history = _make_history() + history_oper = SimpleNamespace( + get_by_hash=lambda download_hash: history, + get_file_by_fullpath=lambda fullpath: None, + get_files_by_savepath=lambda savepath: [], + get_by_path=lambda path: None, + ) + captured_tasks = [] + + def fake_handle_transfer(task, callback=None): + """记录整理任务,避免执行真实文件操作。""" + captured_tasks.append(task) + return True, "" + + chain._TransferChain__handle_transfer = fake_handle_transfer + monkeypatch.setattr( + "app.chain.transfer.TransferHistoryOper", + lambda: SimpleNamespace(get_by_src=lambda src, storage=None: None), + ) + monkeypatch.setattr("app.chain.transfer.DownloadHistoryOper", lambda: history_oper) + monkeypatch.setattr( + "app.chain.transfer.SystemConfigOper", + lambda: SimpleNamespace(get=lambda key: None), + ) + monkeypatch.setattr("app.chain.transfer.StorageChain", lambda: SimpleNamespace()) + monkeypatch.setattr("app.chain.transfer.MetaInfoPath", lambda *args, **kwargs: file_meta) + + chain.do_transfer( + fileitem=source_file, + mediainfo=SimpleNamespace( + tmdb_id=70160, + type=MediaType.MOVIE, + year="2012", + ), + download_hash=history.download_hash, + background=False, + manual=manual, + preview=True, + ) + + assert len(captured_tasks) == 1 + task_media = captured_tasks[0].mediainfo + assert getattr(task_media, "tmdb_id", None) == expected_tmdb_id