feat(scraping): make season image root copies optional

This commit is contained in:
jxxghp
2026-10-21 07:04:42 +08:00
parent 53c5344f4e
commit a1d0bf1fd2
7 changed files with 128 additions and 3 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -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,

View File

@@ -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配置

View File

@@ -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,

View File

@@ -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 及别名。

View File

@@ -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