mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-31 12:24:39 +08:00
feat(music): add global local cover control
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 指纹
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
# 音乐本地封面
|
||||
|
||||
开启整理请求的“同步整理附加文件”后,音乐目录或单个音轨整理会携带同目录的封面图片。
|
||||
在“设置 → 系统 → 高级设置 → 媒体 → 音乐元数据”中可设置全局“音乐本地封面”开关,默认开启,
|
||||
对应 `MUSIC_LOCAL_COVER_ENABLE`。关闭后不再同步整理本地封面,也不再用本地图片回退内嵌;
|
||||
在线封面刮削仍按原有策略执行,已整理的图片和已有内嵌封面不会被删除。
|
||||
|
||||
开启时,音乐目录或单个音轨整理会携带同目录的封面图片。手动按专辑目录整理默认同步附加文件;
|
||||
后台或插件直接调用整理链时,还需启用 `sync_extra_files` 参数,它没有单独的前端开关。
|
||||
图片沿用当前复制、硬链接、移动和覆盖策略;重命名时保留图片原名,放入目标专辑根目录,
|
||||
不套用曲名、曲序或 Disc 子目录。专辑、EP 和单曲共用这一行为。
|
||||
|
||||
|
||||
@@ -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):
|
||||
"""碟片音轨从专辑根读取归档封面,远端路径只通过存储下载读取。"""
|
||||
|
||||
Reference in New Issue
Block a user