diff --git a/app/application/configuration.py b/app/application/configuration.py index 62382b6d8..b71faf102 100644 --- a/app/application/configuration.py +++ b/app/application/configuration.py @@ -374,6 +374,7 @@ class ChainRuntimeConfig: :param data_cleanup_outbox_completed_days: Outbox 已完成记录保留天数,0为不清理 :param data_cleanup_outbox_dead_days: Outbox 死信记录保留天数,0为不清理 :param download_subtitle: 下载站点字幕 + :param scrap_season_image_to_root: 季图片是否额外写入剧集根目录;默认开启以兼容已有媒体库 :param lyrics_batch_timeout: 单次音乐刮削批次用于在线歌词查询的总预算(秒) :param music_metadata_to_simplified: 是否将识别到的音乐标题、艺术家、专辑等标准元数据转换为简体中文 :param music_lyrics_to_simplified: 是否将写入 LRC/TXT 的歌词正文转为简体中文;保留时间轴、LRC 标签和来源 Lyricsfile @@ -462,6 +463,7 @@ class ChainRuntimeConfig: data_cleanup_outbox_completed_days: Any = 0 data_cleanup_outbox_dead_days: Any = 0 download_subtitle: bool = True + scrap_season_image_to_root: bool = True lyrics_batch_timeout: int = 120 music_metadata_to_simplified: bool = True music_lyrics_to_simplified: bool = False diff --git a/app/application/settings/resources/system_settings_catalog.json b/app/application/settings/resources/system_settings_catalog.json index 4d7a5fb4c..9099cbecd 100644 --- a/app/application/settings/resources/system_settings_catalog.json +++ b/app/application/settings/resources/system_settings_catalog.json @@ -1327,6 +1327,12 @@ "source_file": "app/runtime/config.py", "source_line": 586 }, + "SCRAP_SEASON_IMAGE_TO_ROOT": { + "description": "季图片是否同时写入剧集根目录;关闭后仅写季目录,不清理已有图片", + "group": "media_metadata", + "source_file": "app/runtime/config.py", + "source_line": 334 + }, "SCRAP_SOURCE": { "description": "刮削来源 themoviedb/douban/bangumi/anilist/imdb/musicbrainz/theaudiodb/doubanmusic", "group": "media_metadata", diff --git a/app/chain/scraping.py b/app/chain/scraping.py index 84e44c1ea..8e61b65de 100644 --- a/app/chain/scraping.py +++ b/app/chain/scraping.py @@ -653,7 +653,16 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): ) -> List[Tuple[_SchemaFileItem, Path]]: """ 根据刮削上下文生成一个或多个保存目标。 - 季图片需要同时兼容根目录 seasonxx-poster 和季目录 poster 两种命名。 + + 季目录始终使用通用图片名;开启根目录写入时额外保存 season 前缀图片。 + 是否补缺或覆盖仍由调用方对每个目标独立判断。 + + :param current_fileitem: 当前刮削的目录或文件 + :param item_type: 刮削目标类型 + :param metadata_type: 元数据类型 + :param filename_hint: 刮削源返回的文件名,季图片可带 season 前缀 + :param parent_fileitem: 已知父目录,未提供时按需查询存储 + :return: 按保存顺序排列的目录项和目标路径 """ target_item, target_path = self._get_target_fileitem_and_path( current_fileitem=current_fileitem, @@ -665,7 +674,8 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): targets = [(target_item, target_path)] if target_path else [] if ( - item_type != ScrapingTarget.SEASON + not self.runtime_config.scrap_season_image_to_root + or item_type != ScrapingTarget.SEASON or not filename_hint or not filename_hint.lower().startswith("season") or metadata_type not in { @@ -1041,7 +1051,7 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): ) continue - # 获取目标 FileItem 和 Path,季图片会同时写根目录和季目录。 + # 按配置生成季图片的根目录和季目录目标,再逐个应用刮削策略。 image_targets = self._get_target_fileitems_and_paths( current_fileitem=current_fileitem, item_type=item_type, diff --git a/app/runtime/config.py b/app/runtime/config.py index 9b51fc65d..f66255485 100644 --- a/app/runtime/config.py +++ b/app/runtime/config.py @@ -331,6 +331,10 @@ class ConfigModel(BaseModel): default="themoviedb", description='刮削来源 themoviedb/douban/bangumi/anilist/imdb/musicbrainz/theaudiodb/doubanmusic', ) + SCRAP_SEASON_IMAGE_TO_ROOT: bool = Field( + default=True, + description='季图片是否同时写入剧集根目录;关闭后仅写季目录,不清理已有图片', + ) ANIME_GENREIDS: List[int] = Field(default=[16], description='电视剧动漫的分类genre_ids') # TMDB配置 diff --git a/app/startup/composition/configuration.py b/app/startup/composition/configuration.py index 9b80cd59e..d7a20ebbc 100644 --- a/app/startup/composition/configuration.py +++ b/app/startup/composition/configuration.py @@ -276,6 +276,7 @@ def build_chain_runtime_config(settings: Settings) -> ChainRuntimeConfig: data_cleanup_outbox_completed_days=settings.DATA_CLEANUP_OUTBOX_COMPLETED_DAYS, data_cleanup_outbox_dead_days=settings.DATA_CLEANUP_OUTBOX_DEAD_DAYS, download_subtitle=settings.DOWNLOAD_SUBTITLE, + scrap_season_image_to_root=settings.SCRAP_SEASON_IMAGE_TO_ROOT, music_metadata_to_simplified=settings.MUSIC_METADATA_TO_SIMPLIFIED, music_lyrics_to_simplified=settings.MUSIC_LYRICS_TO_SIMPLIFIED, music_cue_enable=settings.MUSIC_CUE_ENABLE, diff --git a/tests/test_mediascrape.py b/tests/test_mediascrape.py index 505522d0a..8de072a70 100644 --- a/tests/test_mediascrape.py +++ b/tests/test_mediascrape.py @@ -249,6 +249,36 @@ def test_season_dir_specials_poster_path(path_context) -> None: assert target_path == Path("/tv/Show/Specials/poster.jpg") +@pytest.mark.parametrize("write_root", [True, False]) +@pytest.mark.parametrize("season_name", ["season01", "season-specials"]) +@pytest.mark.parametrize("image_type", ["poster", "banner", "thumb", "backdrop", "landscape"]) +def test_season_image_root_switch(path_context, write_root, season_name, image_type) -> None: + """ + 根目录开关覆盖普通季、特别篇及各类季图片,保留季目录文件名和扩展名。 + """ + chain = path_context.media_chain + chain.runtime_config = replace(chain.runtime_config, scrap_season_image_to_root=write_root) + parent = FileItem(path="/tv/Show", name="Show", type="dir", storage="local") + season = FileItem(path="/tv/Show/Season 1", name="Season 1", type="dir", storage="local") + chain.storagechain.get_parent_item.return_value = parent + filename = f"{season_name}-{image_type}.png" + + targets = chain._get_target_fileitems_and_paths( + current_fileitem=season, + item_type=ScrapingTarget.SEASON, + metadata_type=ScrapingMetadata(image_type), + filename_hint=filename, + ) + + expected = [(season, Path(season.path) / f"{image_type}.png")] + if write_root: + expected.insert(0, (parent, Path(parent.path) / filename)) + chain.storagechain.get_parent_item.assert_called_once_with(season) + else: + chain.storagechain.get_parent_item.assert_not_called() + assert targets == expected + + def test_movie_file_image_path_uses_parent_dir(path_context) -> None: """ 直接刮削电影文件时,图片应保存到父目录。 @@ -403,6 +433,56 @@ def test_scrape_images_season_filter(image_context) -> None: ] +@pytest.mark.parametrize("write_root", [True, False]) +@pytest.mark.parametrize("root_exists,season_exists", [(False, False), (True, False), (False, True), (True, True)]) +def test_season_image_missing_only_checks_each_target(image_context, write_root, root_exists, season_exists) -> None: + """ + 仅缺失策略逐个补全启用的保存位置,关闭根目录写入时不再查询或下载根目录图片。 + """ + chain = image_context.media_chain + chain.runtime_config = replace(chain.runtime_config, scrap_season_image_to_root=write_root) + season = FileItem(path="/tv/Show/Season 1", name="Season 1", type="dir", storage="local") + parent = FileItem(path="/tv/Show", name="Show", type="dir", storage="local") + root_path = Path("/tv/Show/season01-poster.jpg") + season_path = Path("/tv/Show/Season 1/poster.jpg") + existing = {root_path: root_exists, season_path: season_exists} + chain.metadata_img.return_value = {"season01-poster.jpg": "http://season01"} + chain.scraping_policies = ScrapingConfig({}) + chain.storagechain.get_file_item.side_effect = lambda *, storage, path: existing[path] + + chain._scrape_images_generic( + season, MediaInfo(), ScrapingTarget.SEASON, parent_fileitem=parent, season_number=1 + ) + + selected = [root_path, season_path] if write_root else [season_path] + assert [call.kwargs["path"] for call in chain.storagechain.get_file_item.call_args_list] == selected + assert [call.kwargs["path"] for call in chain._download_and_save_image.call_args_list] == [ + path for path in selected if not existing[path] + ] + + +@pytest.mark.parametrize("season_number,filename", [(1, "season01-thumb.jpg"), (0, "season-specials-thumb.jpg")]) +@pytest.mark.parametrize("policy", [ScrapingPolicy.SKIP, ScrapingPolicy.OVERWRITE]) +def test_season_image_root_disabled_preserves_policy_and_aliases(image_context, season_number, filename, policy) -> None: + """ + 关闭根目录后仍遵循跳过和覆盖策略,特别篇与普通季均只在季目录扩展图片别名。 + """ + chain = image_context.media_chain + chain.runtime_config = replace(chain.runtime_config, scrap_season_image_to_root=False) + season = FileItem(path="/tv/Show/Season 1", name="Season 1", type="dir", storage="local") + chain.metadata_img.return_value = {filename: "http://thumb"} + chain.scraping_policies = ScrapingConfig({"season_thumb": policy}) + chain.storagechain.get_file_item.return_value = True + + chain._scrape_images_generic(season, MediaInfo(), ScrapingTarget.SEASON, season_number=season_number) + + paths = [call.kwargs["path"] for call in chain._download_and_save_image.call_args_list] + assert paths == ([] if policy == ScrapingPolicy.SKIP else [ + Path("/tv/Show/Season 1/thumb.jpg"), + Path("/tv/Show/Season 1/landscape.jpg"), + ]) + + def test_scrape_movie_file_images_when_initialized_directly(image_context) -> None: """ 直接初始化刮削电影文件时,应生成同级 poster/backdrop 及别名。 diff --git a/tests/test_scraping_configuration.py b/tests/test_scraping_configuration.py new file mode 100644 index 000000000..43c7b9ff0 --- /dev/null +++ b/tests/test_scraping_configuration.py @@ -0,0 +1,22 @@ +""" +验证季图片落盘开关从部署配置传入刮削链快照。 +""" + +import pytest + +from app.runtime.config import Settings +from app.startup.composition.configuration import build_chain_runtime_config + + +@pytest.mark.parametrize("env_value,expected", [(None, True), ("true", True), ("false", False)]) +def test_season_image_root_setting_reaches_chain_snapshot(monkeypatch, tmp_path, env_value, expected) -> None: + """ + 未配置时保持双写,环境变量可以显式启用或关闭根目录写入。 + """ + monkeypatch.delenv("SCRAP_SEASON_IMAGE_TO_ROOT", raising=False) + if env_value is not None: + monkeypatch.setenv("SCRAP_SEASON_IMAGE_TO_ROOT", env_value) + settings = Settings(_env_file=None, CONFIG_DIR=str(tmp_path), API_TOKEN="0123456789abcdef") + + assert settings.SCRAP_SEASON_IMAGE_TO_ROOT is expected + assert build_chain_runtime_config(settings).scrap_season_image_to_root is expected