feat(music): add global local cover control

This commit is contained in:
jxxghp
2026-10-15 07:20:44 +08:00
parent 19f9098d68
commit 57b883d2bf
7 changed files with 69 additions and 12 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,11 @@
# 音乐本地封面
开启整理请求的“同步整理附加文件”后,音乐目录或单个音轨整理会携带同目录的封面图片。
在“设置 → 系统 → 高级设置 → 媒体 → 音乐元数据”中可设置全局“音乐本地封面”开关,默认开启,
对应 `MUSIC_LOCAL_COVER_ENABLE`。关闭后不再同步整理本地封面,也不再用本地图片回退内嵌;
在线封面刮削仍按原有策略执行,已整理的图片和已有内嵌封面不会被删除。
开启时,音乐目录或单个音轨整理会携带同目录的封面图片。手动按专辑目录整理默认同步附加文件;
后台或插件直接调用整理链时,还需启用 `sync_extra_files` 参数,它没有单独的前端开关。
图片沿用当前复制、硬链接、移动和覆盖策略;重命名时保留图片原名,放入目标专辑根目录,
不套用曲名、曲序或 Disc 子目录。专辑、EP 和单曲共用这一行为。

View File

@@ -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):
"""碟片音轨从专辑根读取归档封面,远端路径只通过存储下载读取。"""