From 57b883d2bfb35c611e62976e11344e0efcf12a57 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Thu, 15 Oct 2026 07:20:44 +0800 Subject: [PATCH] feat(music): add global local cover control --- .../resources/system_settings_catalog.json | 8 ++- app/chain/scraping.py | 5 +- app/chain/transfer/filter.py | 4 +- app/runtime/config.py | 2 + docs/cli.md | 1 + docs/music-covers.md | 7 ++- tests/test_music_cover_fallback.py | 54 ++++++++++++++++--- 7 files changed, 69 insertions(+), 12 deletions(-) diff --git a/app/application/settings/resources/system_settings_catalog.json b/app/application/settings/resources/system_settings_catalog.json index 73f97b304..0b87d7586 100644 --- a/app/application/settings/resources/system_settings_catalog.json +++ b/app/application/settings/resources/system_settings_catalog.json @@ -1079,7 +1079,7 @@ "description": "同目录音乐封面匹配规则,按顺序选择,逗号分隔、大小写不敏感;留空禁用本地封面", "group": "music", "source_file": "app/runtime/config.py", - "source_line": 406 + "source_line": 408 }, "MUSIC_COVER_PROXY": { "description": "音乐封面代理地址(用于解决 coverartarchive.org 无法访问导致的封面不显示问题,留空则使用官方地址)", @@ -1099,6 +1099,12 @@ "source_file": "app/runtime/config.py", "source_line": 410 }, + "MUSIC_LOCAL_COVER_ENABLE": { + "description": "是否同步整理同目录音乐封面,并在在线封面不可用时用于内嵌回退", + "group": "music", + "source_file": "app/runtime/config.py", + "source_line": 406 + }, "MUSIC_METADATA_TO_SIMPLIFIED": { "description": "是否将识别到的音乐标题、艺术家、专辑等标准元数据转换为简体中文", "group": "music", diff --git a/app/chain/scraping.py b/app/chain/scraping.py index 157f9decd..71f28e89e 100644 --- a/app/chain/scraping.py +++ b/app/chain/scraping.py @@ -1606,7 +1606,10 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): ) def _load_local_music_cover(self, fileitem: _SchemaFileItem) -> tuple[Optional[bytes], str]: - """在线封面不可用时读取同目录图片;下载经存储端口,坏图不阻断后续候选。""" + """全局开关开启且在线封面不可用时经存储端口读取本地图片,跳过坏图。""" + if not get_runtime_setting("MUSIC_LOCAL_COVER_ENABLE"): + return None, "image/jpeg" + from PIL import Image parent = self.storagechain.get_parent_item(fileitem) diff --git a/app/chain/transfer/filter.py b/app/chain/transfer/filter.py index 9359f1fc6..8f8fc34c6 100644 --- a/app/chain/transfer/filter.py +++ b/app/chain/transfer/filter.py @@ -160,8 +160,8 @@ class _MusicFileFilterBase(_TransferOwnerBase): @staticmethod def _is_music_cover_file(fileitem: FileItem) -> bool: - """仅接纳配置命中的音乐封面候选,实际归属由音轨批次确认。""" - return fileitem.type == "file" and music_cover_priority( + """全局开关开启时接纳匹配的音乐封面,实际归属由音轨批次确认。""" + return get_runtime_setting("MUSIC_LOCAL_COVER_ENABLE") and fileitem.type == "file" and music_cover_priority( str(fileitem.path or fileitem.name or ""), get_runtime_setting("MUSIC_COVER_FILE_PATTERNS") ) is not None diff --git a/app/runtime/config.py b/app/runtime/config.py index 8d1e63681..915950069 100644 --- a/app/runtime/config.py +++ b/app/runtime/config.py @@ -402,6 +402,8 @@ class ConfigModel(BaseModel): # ==================== 音乐配置 ==================== # 音乐封面代理地址(用于解决 coverartarchive.org 无法访问导致的封面不显示问题,留空则使用官方地址) MUSIC_COVER_PROXY: str = "" + # 是否同步整理同目录音乐封面,并在在线封面不可用时用于内嵌回退 + MUSIC_LOCAL_COVER_ENABLE: bool = True # 同目录音乐封面匹配规则,按顺序选择,逗号分隔、大小写不敏感;留空禁用本地封面 MUSIC_COVER_FILE_PATTERNS: str = "cover.jpg,cover.jpeg,cover.png,folder.jpg,folder.jpeg,folder.png,front.jpg,front.jpeg,front.png,*.png" # AcoustID 应用 API Key,用于查询本地音频的 Chromaprint 指纹 diff --git a/docs/cli.md b/docs/cli.md index 6d44a0a22..2cf6e7520 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -507,6 +507,7 @@ moviepilot config set LYRICS_BATCH_TIMEOUT 120 moviepilot config set LYRICS_PROVIDER_RETRY_MAX_WAIT 5 moviepilot config set MUSIC_METADATA_TO_SIMPLIFIED true moviepilot config set MUSIC_LYRICS_TO_SIMPLIFIED true +moviepilot config set MUSIC_LOCAL_COVER_ENABLE true moviepilot config set MUSIC_COVER_FILE_PATTERNS 'cover.jpg,folder.jpg,front.jpg,*.png' moviepilot config set MUSIC_RELEASE_REGION_PRIORITY CN,TW,HK moviepilot config set MUSIC_RELEASE_SCRIPT_PRIORITY Hans,Hant,Latn diff --git a/docs/music-covers.md b/docs/music-covers.md index c7dd264d9..888011f01 100644 --- a/docs/music-covers.md +++ b/docs/music-covers.md @@ -1,6 +1,11 @@ # 音乐本地封面 -开启整理请求的“同步整理附加文件”后,音乐目录或单个音轨整理会携带同目录的封面图片。 +在“设置 → 系统 → 高级设置 → 媒体 → 音乐元数据”中可设置全局“音乐本地封面”开关,默认开启, +对应 `MUSIC_LOCAL_COVER_ENABLE`。关闭后不再同步整理本地封面,也不再用本地图片回退内嵌; +在线封面刮削仍按原有策略执行,已整理的图片和已有内嵌封面不会被删除。 + +开启时,音乐目录或单个音轨整理会携带同目录的封面图片。手动按专辑目录整理默认同步附加文件; +后台或插件直接调用整理链时,还需启用 `sync_extra_files` 参数,它没有单独的前端开关。 图片沿用当前复制、硬链接、移动和覆盖策略;重命名时保留图片原名,放入目标专辑根目录, 不套用曲名、曲序或 Disc 子目录。专辑、EP 和单曲共用这一行为。 diff --git a/tests/test_music_cover_fallback.py b/tests/test_music_cover_fallback.py index fe3053a64..3dba5fb18 100644 --- a/tests/test_music_cover_fallback.py +++ b/tests/test_music_cover_fallback.py @@ -10,6 +10,7 @@ import pytest from mutagen.flac import FLAC, Picture from PIL import Image +from app.application.settings.contract import ALL_SETTING_SPECS, build_value_schema from app.application.transfer.workflow import TransferPlanningInput from app.chain.media import MediaChain from app.chain.scraping import ScrapingChain @@ -19,7 +20,7 @@ from app.domain.meta.metamusic import MetaMusic from app.domain.music import music_cover_priority from app.modules.filemanager.storages.local import LocalStorage from app.modules.filemanager.transhandler import TransHandler -from app.runtime.config import settings +from app.runtime.config import ConfigModel, Settings, settings from app.schemas.file import FileItem from app.schemas.system import TransferDirectoryConf from app.schemas.transfer import TransferInfo @@ -87,8 +88,10 @@ def test_music_cover_does_not_borrow_identity_from_different_albums_or_video(tmp @pytest.mark.parametrize("single", [False, True]) @pytest.mark.parametrize("mtype", [None, MediaType.MUSIC]) -def test_music_cover_reaches_public_transfer_entrypoint(tmp_path, monkeypatch, single, mtype): - """从真实目录遍历到公开整理入口,封面只复用主音轨身份而不单独在线识别。""" +@pytest.mark.parametrize("enabled", [False, True]) +def test_music_cover_reaches_public_transfer_entrypoint(tmp_path, monkeypatch, single, mtype, enabled): + """公开整理入口遵守全局开关,开启时封面复用主音轨身份而不单独在线识别。""" + monkeypatch.setattr(settings, "MUSIC_LOCAL_COVER_ENABLE", enabled) audio = _tagged_audio(tmp_path / "source") cover = audio.parent / "cover.jpg" Image.new("RGB", (2, 2)).save(cover) @@ -125,9 +128,9 @@ def test_music_cover_reaches_public_transfer_entrypoint(tmp_path, monkeypatch, s mtype=mtype, sync_extra_files=True, force=True, preview=True, ) assert success, result - assert [task.fileitem.path for task in tasks] == [str(audio), str(cover)] + assert [task.fileitem.path for task in tasks] == ([str(audio), str(cover)] if enabled else [str(audio)]) assert all(task.mediainfo.album == "Album" for task in tasks) - assert result["summary"] == {"total": 2, "success": 2, "failed": 0} + assert result["summary"] == {"total": 2 if enabled else 1, "success": 2 if enabled else 1, "failed": 0} @pytest.mark.parametrize("mode", ["copy", "link", "move"]) @@ -167,8 +170,10 @@ def test_music_cover_real_storage_transfer_preserves_filename_and_album_root(tmp @pytest.mark.parametrize("online", ["missing", "failed", "available"]) @pytest.mark.parametrize("policy", ["missing", "skip", "overwrite"]) @pytest.mark.parametrize("existing", [False, True]) -def test_music_scrape_uses_online_first_then_valid_local_cover_without_changing_seed(tmp_path, online, policy, existing): - """真实 FLAC 验证下载失败回退、坏图跳过、刮削策略和硬链接源保护。""" +@pytest.mark.parametrize("enabled", [False, True]) +def test_music_scrape_uses_online_first_then_valid_local_cover_without_changing_seed(tmp_path, monkeypatch, online, policy, existing, enabled): + """真实 FLAC 验证开关仅控制本地回退,不改变在线刮削、既有封面策略和种子。""" + monkeypatch.setattr(settings, "MUSIC_LOCAL_COVER_ENABLE", enabled) source = _tagged_audio(tmp_path / "source") existing_path = tmp_path / "existing.png" Image.new("RGB", (2, 2), "green").save(existing_path) @@ -210,11 +215,46 @@ def test_music_scrape_uses_online_first_then_valid_local_cover_without_changing_ if policy == "skip": assert [picture.data for picture in pictures] == ([existing_bytes] if existing else []) chain._download_music_cover.assert_not_called() + elif not enabled and online != "available": + assert [picture.data for picture in pictures] == ([existing_bytes] if existing else []) else: expected = existing_bytes if existing and policy == "missing" else online_bytes if online == "available" else local_bytes assert pictures[0].data == expected +def test_disabled_local_cover_does_not_read_storage(monkeypatch): + """关闭本地封面时不列目录或下载图片,避免触发远端存储访问。""" + monkeypatch.setattr(settings, "MUSIC_LOCAL_COVER_ENABLE", False) + chain = object.__new__(ScrapingChain) + chain.storagechain = Mock() + assert chain._load_local_music_cover(make_fileitem("/music/01.flac")) == (None, "image/jpeg") + assert not chain.storagechain.mock_calls + + +def test_local_cover_setting_default_and_contract(): + """全局开关默认开启并通过设置目录公开可写的音乐布尔配置。""" + assert ConfigModel.model_fields["MUSIC_LOCAL_COVER_ENABLE"].default is True + spec = ALL_SETTING_SPECS["MUSIC_LOCAL_COVER_ENABLE"] + assert spec.group == "music" + assert build_value_schema(spec)["type"] == "boolean" + + +@pytest.mark.parametrize("enabled", [False, True]) +def test_local_cover_setting_persists_without_changing_patterns(tmp_path, monkeypatch, enabled): + """开关可保存并重新加载,切换后仍保留用户的封面文件名匹配规则。""" + env_path = tmp_path / "app.env" + env_path.touch() + monkeypatch.delenv("MUSIC_LOCAL_COVER_ENABLE", raising=False) + monkeypatch.delenv("MUSIC_COVER_FILE_PATTERNS", raising=False) + monkeypatch.setattr("app.runtime.config.get_env_path", lambda: env_path) + config = Settings(_env_file=env_path, MUSIC_LOCAL_COVER_ENABLE=not enabled) + assert config.update_setting("MUSIC_COVER_FILE_PATTERNS", "Artwork.webp")[0] + assert config.update_setting("MUSIC_LOCAL_COVER_ENABLE", enabled)[0] + restored = Settings(_env_file=env_path) + assert restored.MUSIC_LOCAL_COVER_ENABLE is enabled + assert restored.MUSIC_COVER_FILE_PATTERNS == "Artwork.webp" + + @pytest.mark.parametrize("remote", [False, True]) def test_local_cover_reader_supports_storage_paths_and_album_root(tmp_path, remote): """碟片音轨从专辑根读取归档封面,远端路径只通过存储下载读取。"""