mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 03:18:14 +08:00
feat(scraping): make season image root copies optional
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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配置
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 及别名。
|
||||
|
||||
22
tests/test_scraping_configuration.py
Normal file
22
tests/test_scraping_configuration.py
Normal 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
|
||||
Reference in New Issue
Block a user