feat(music): preserve local covers and fall back after online scraping fails

This commit is contained in:
jxxghp
2026-10-14 23:15:41 +08:00
parent bb86a6ebea
commit 72c8bff312
17 changed files with 442 additions and 20 deletions

View File

@@ -1075,6 +1075,12 @@
"source_file": "app/runtime/config.py",
"source_line": 679
},
"MUSIC_COVER_FILE_PATTERNS": {
"description": "同目录音乐封面匹配规则,按顺序选择,逗号分隔、大小写不敏感;留空禁用本地封面",
"group": "music",
"source_file": "app/runtime/config.py",
"source_line": 406
},
"MUSIC_COVER_PROXY": {
"description": "音乐封面代理地址(用于解决 coverartarchive.org 无法访问导致的封面不显示问题,留空则使用官方地址)",
"group": "music",

View File

@@ -28,12 +28,14 @@ from app.domain.context import (
from app.domain.meta.metabase import MetaBase
from app.domain.meta.metamusic import MetaMusic
from app.domain.metainfo import MetaInfo, MetaInfoPath
from app.domain.music import music_cover_priority
from app.foundation.singleton import Singleton
from app.foundation.text import convert
from app.runtime.cache import cached
from app.runtime.events import Event, eventmanager
from app.runtime.log import logger
from app.runtime.reload import ConfigReloadMixin
from app.runtime.settings import get_runtime_setting
from app.schemas.file import FileItem as _SchemaFileItem
from app.schemas.media import resolve_media_identity
from app.schemas.types import (
@@ -1421,6 +1423,12 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
) -> _MusicScrapeFileResult:
"""在本地音轨副本上执行刮削,并将变更后的音频和歌词写回目标存储。"""
scrape_info = self._resolve_music_scrape_info(local_path, mediainfo, media_source=media_source)
if with_cover:
if cover is None:
cover_url = getattr(mediainfo, "cover_url", None) or getattr(scrape_info, "cover_url", None)
cover = self._download_music_cover(cover_url)
if not cover[0]:
cover = self._load_local_music_cover(fileitem)
metadata_requested = write_tags or with_cover
metadata_success = True
if metadata_requested:
@@ -1597,6 +1605,41 @@ class ScrapingChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
cover_overwrite=cover_overwrite,
)
def _load_local_music_cover(self, fileitem: _SchemaFileItem) -> tuple[Optional[bytes], str]:
"""在线封面不可用时读取同目录图片;下载经存储端口,坏图不阻断后续候选。"""
from PIL import Image
parent = self.storagechain.get_parent_item(fileitem)
if not parent:
return None, "image/jpeg"
if MetaMusic.parse_disc_dir(Path(str(parent.path)).name) is not None:
# 整理把碟片目录内的封面放到专辑根,刮削仍从该发行范围内取图。
parent = self.storagechain.get_parent_item(parent) or parent
patterns = get_runtime_setting("MUSIC_COVER_FILE_PATTERNS")
candidates = []
for item in self.storagechain.list_files(parent, recursion=False) or []:
priority = music_cover_priority(str(item.path or item.name or ""), patterns)
if item.type == "file" and priority is not None:
candidates.append((priority, str(item.path).casefold(), item))
for _priority, _name, item in sorted(candidates, key=lambda value: value[:2]):
try:
with TemporaryDirectory(prefix="moviepilot-music-cover-") as directory:
path = self.storagechain.download_file(item, path=Path(directory))
if not path:
continue
with Image.open(path) as picture:
mime = Image.MIME.get(picture.format)
picture.verify()
if mime not in {"image/jpeg", "image/png", "image/webp"}:
continue
data = Path(path).read_bytes()
logger.info(f"音乐在线封面不可用,使用同目录封面:{item.path}")
return data, mime
except (OSError, ValueError) as error:
logger.warning(f"读取音乐本地封面失败:{item.path} - {error}")
logger.info(f"音乐在线及同目录封面均不可用:{fileitem.path}")
return None, "image/jpeg"
def _scrape_music_lyrics(
self,
fileitem: _SchemaFileItem,

View File

@@ -86,6 +86,7 @@ if TYPE_CHECKING:
_is_media_file: Callable[..., Any]
_is_movie_year_conflict: Callable[..., Any]
_is_music_lyrics_file: Callable[..., Any]
_is_music_cover_file: Callable[..., Any]
_is_music_retry_source: Callable[..., Any]
_is_overwrite_declined: Callable[..., Any]
_is_primary_media_file: Callable[..., Any]

View File

@@ -33,12 +33,14 @@ from app.domain.music import (
MusicDirectoryMatch,
music_album_matches,
music_artist_matches,
music_cover_priority,
music_release_year_matches,
music_title_matches,
music_track_title_is_weak,
music_version_matches,
)
from app.runtime.log import logger
from app.runtime.settings import get_runtime_setting
from app.schemas.transfer import TransferInfo
from app.schemas.types import (
MUSIC_ENTITY_ALBUM,
@@ -156,6 +158,13 @@ class _MusicFileFilterBase(_TransferOwnerBase):
path = str(fileitem.path or fileitem.name or "").casefold()
return path.endswith((".lrc", ".txt", ".lyricsfile.yaml"))
@staticmethod
def _is_music_cover_file(fileitem: FileItem) -> bool:
"""仅接纳配置命中的音乐封面候选,实际归属由音轨批次确认。"""
return fileitem.type == "file" and music_cover_priority(
str(fileitem.path or fileitem.name or ""), get_runtime_setting("MUSIC_COVER_FILE_PATTERNS")
) is not None
def _is_media_file(
self,
fileitem: FileItem,
@@ -367,6 +376,7 @@ class FileFilterMixin(_TransferOwnerBase):
_is_music_lyrics_file = cast(
Any, staticmethod(_MusicFileFilterBase._is_music_lyrics_file)
)
_is_music_cover_file = cast(Any, staticmethod(_MusicFileFilterBase._is_music_cover_file))
_is_media_file = cast(Any, _MusicFileFilterBase._is_media_file)
_is_primary_media_file = cast(Any, _MusicFileFilterBase._is_primary_media_file)
_music_info_from_meta = cast(

View File

@@ -600,7 +600,7 @@ def prepare_music_batch_context(
file_items: list[tuple[FileItem, bool]],
batch_mtype: Optional[MediaType],
) -> MusicBatchContext:
"""建立同目录音轨、歌词和单音轨目录的批次索引。"""
"""建立同目录音轨、歌词、封面和单音轨目录的批次索引;歧义发行不共享封面。"""
context = MusicBatchContext()
if batch_mtype not in (None, MediaType.MUSIC):
return context
@@ -635,6 +635,7 @@ def prepare_music_batch_context(
if evidence and len(group.files) > 1 and evidence.album and (evidence.album_artist or evidence.artists):
context.album_main_keys.add(item_key)
for current_item, _current_bluray_dir in file_items:
_bind_music_cover_context(owner, context, current_item, main_items_by_dir)
if str(current_item.extension or "").casefold() == "cue":
for main_key, cue_meta in context.cue_by_main_key.items():
if (cue_meta.music_layout == "image_cue" and current_item.storage == main_key[0]
@@ -651,6 +652,19 @@ def prepare_music_batch_context(
return context
def _bind_music_cover_context(
owner: _TransferOwnerBase, context: MusicBatchContext, item: FileItem,
main_items_by_dir: dict[Tuple[str, str], list[FileItem]],
) -> None:
"""只把封面绑定到同目录的唯一物理发行,避免多专辑目录共享错图。"""
if not owner._is_music_cover_file(item):
return
main_items = main_items_by_dir.get(owner._get_file_parent_key(item), [])
groups = {context.release_by_main_key[owner._get_file_key(main)].preview_id for main in main_items}
if len(groups) == 1:
context.related_main_keys[owner._get_file_key(item)] = owner._get_file_key(main_items[0])
def _finalize_music_group_context(
owner: _TransferOwnerBase,
context: MusicBatchContext,

View File

@@ -299,6 +299,7 @@ class _TransferCandidatePlanner:
self._chain._is_subtitle_file(item)
or self._chain._is_audio_file(item)
or self._chain._is_music_lyrics_file(item)
or self._is_music_cover_candidate(item)
):
extra_items_by_dir.setdefault(dir_key, []).append((item, is_bluray_dir))
return main_items_by_dir, extra_items_by_dir
@@ -330,6 +331,7 @@ class _TransferCandidatePlanner:
self._chain._is_subtitle_file(item)
or self._chain._is_audio_file(item)
or self._chain._is_music_lyrics_file(item)
or self._is_music_cover_candidate(item)
):
continue
if not self._is_allowed_transfer_item(item, False):
@@ -337,6 +339,31 @@ class _TransferCandidatePlanner:
extra_items.append((item, False))
return main_fileitems, extra_items
def _is_music_cover_candidate(self, item: FileItem) -> bool:
"""封面只随音乐主文件同步,影视批次及关闭附加文件同步时不接纳图片。"""
return (self._sync_extra_files and not self._has_reliable_video_source()
and self._chain._is_music_cover_file(item))
def _infer_music_batch_type(self, items: List[Tuple[FileItem, bool]]) -> None:
"""未指定类型的纯音频请求仍建立音乐主文件索引,混合影视目录保持原类型。"""
if (self._batch_mtype is None and not self._has_reliable_video_source()
and any(self._chain._is_audio_file(item) for item, _ in items)
and not any(bluray or self._chain._is_media_file(item, None) for item, bluray in items)):
self._batch_mtype = MediaType.MUSIC
def _append_remaining_items(
self, items: List[Tuple[FileItem, bool]], planned: List[Tuple[FileItem, bool]],
seen: set[Tuple[str, str]], inherited: Dict[Tuple[str, str], MetaBase],
) -> None:
"""保留未匹配的普通附件,音乐歌词和封面必须已获得主文件归属。"""
for item, is_bluray_dir in items:
if self._chain._is_music_cover_file(item) and self._chain._get_file_key(item) not in inherited:
continue
if (self._batch_mtype == MediaType.MUSIC and self._chain._is_music_lyrics_file(item)
and self._chain._get_file_key(item) not in inherited):
continue
self._append_item(planned, seen, item, is_bluray_dir)
def _plan_file_items(
self,
items: List[Tuple[FileItem, bool]]
@@ -347,6 +374,8 @@ class _TransferCandidatePlanner:
if not items:
return [], {}
self._infer_music_batch_type(items)
download_history_repository = self._chain.download_history_repository
inherited_map: Dict[Tuple[str, str], MetaBase] = {}
main_items_by_dir, extra_items_by_dir = self._build_directory_index(items)
@@ -408,7 +437,7 @@ class _TransferCandidatePlanner:
remaining = [
item
for item in items
if not (
if not self._chain._is_music_cover_file(item[0]) and not (
self._batch_mtype == MediaType.MUSIC
and self._chain._is_music_lyrics_file(item[0])
)
@@ -468,6 +497,12 @@ class _TransferCandidatePlanner:
for extra_item, extra_bluray_dir in extra_items_by_dir.get(dir_key, []):
if self._chain._get_file_key(extra_item) in seen_file_keys:
continue
if self._is_music_cover_candidate(extra_item):
# 图片没有曲名,先继承主音轨标签;批次分组再确认唯一发行归属。
if self._chain._is_audio_file(main_item) and self._append_item(
planned_items, seen_file_keys, extra_item, extra_bluray_dir):
inherited_map[self._chain._get_file_key(extra_item)] = deepcopy(main_meta)
continue
related_main_file_key = self._chain._get_related_main_file_key(
extra_fileitem=extra_item,
main_fileitems=main_fileitems,
@@ -500,14 +535,7 @@ class _TransferCandidatePlanner:
):
inherited_map[self._chain._get_file_key(extra_item)] = deepcopy(extra_meta)
for item, is_bluray_dir in items:
if (
self._batch_mtype == MediaType.MUSIC
and self._chain._is_music_lyrics_file(item)
and self._chain._get_file_key(item) not in inherited_map
):
continue
self._append_item(planned_items, seen_file_keys, item, is_bluray_dir)
self._append_remaining_items(items, planned_items, seen_file_keys, inherited_map)
return append_cue_companions(self._chain, planned_items, inherited_map, self._has_reliable_video_source(), self._transfer_exclude_words)

View File

@@ -3,7 +3,7 @@
import re
import uuid
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
from typing import Any, Callable, Dict, Generator, List, Optional, Tuple, Union
from app.application.audio import capture_audio_metadata
from app.application.classification.reference import (
@@ -355,7 +355,7 @@ class TransferWorkflowOwner(_TransferOwnerBase):
return False
matched_template = True
if batch_mtype == MediaType.MUSIC:
if self._is_music_lyrics_file(item):
if self._is_music_lyrics_file(item) or self._is_music_cover_file(item):
return not self._is_blocked_by_exclude_words(item.path, exclude_words)
if not self._is_media_file(item, batch_mtype) and not (
item.type == "file" and music_package_error(item.path or "")
@@ -363,7 +363,8 @@ class TransferWorkflowOwner(_TransferOwnerBase):
return False
if not self._is_allow_filesize(item, min_filesize):
return False
elif not is_bluray_dir and not self._is_subtitle_file(item) and not self._is_audio_file(item):
elif (not is_bluray_dir and not self._is_subtitle_file(item) and not self._is_audio_file(item)
and not (batch_mtype is None and self._is_music_cover_file(item))):
if not self._is_media_file(item, batch_mtype):
return False
if not self._is_allow_filesize(item, min_filesize):
@@ -745,7 +746,7 @@ class TransferWorkflowOwner(_TransferOwnerBase):
)
music_batch_context = self._prepare_music_batch_context(file_items, batch_mtype)
try:
for file_item, bluray_dir in file_items:
for file_item, bluray_dir in self._music_transfer_items(file_items, music_batch_context):
if runtime_stop_state.is_system_stopped:
raise OperationInterrupted()
if continue_callback and not continue_callback():
@@ -972,6 +973,18 @@ class TransferWorkflowOwner(_TransferOwnerBase):
skipped_torrents,
)
def _music_transfer_items(
self, items: List[Tuple[FileItem, bool]], context: Any,
) -> Generator[Tuple[FileItem, bool], None, None]:
"""随主音轨逐项解析再放行封面,无法证明唯一音乐归属时不生成图片任务。"""
for item, bluray in items:
if self._is_music_cover_file(item):
related = context.related_main_keys.get(self._get_file_key(item))
if related not in context.resolved_contexts:
logger.info(f"{item.name} 没有唯一且已识别的同目录音乐归属,跳过封面整理")
continue
yield item, bluray
def _cancel_unexecuted_tasks(self, tasks: list[TransferTask]) -> None:
"""撤销本次同步请求尚未执行的内存占位,让用户之后可以重新提交这些文件。"""
for task in tasks:

View File

@@ -4,6 +4,8 @@ import re
from copy import deepcopy
from dataclasses import dataclass
from datetime import date
from fnmatch import fnmatchcase
from pathlib import Path
from typing import Any, Generator, Iterable, Literal, Optional
from unicodedata import combining, normalize
@@ -12,6 +14,15 @@ from app.domain.meta.metamusic import MetaMusic, music_credit_values
from app.foundation.text import convert as zhconv_convert
from app.schemas.types import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MediaSource, MediaType
def music_cover_priority(path: str, patterns: str) -> Optional[int]:
"""按配置顺序匹配同目录封面,忽略大小写且只允许可内嵌的常见图片格式。"""
name = Path(path).name.casefold()
if name.startswith(".") or Path(name).suffix not in {".jpg", ".jpeg", ".png", ".webp"}:
return None
return next((index for index, pattern in enumerate(patterns.split(","))
if pattern.strip() and fnmatchcase(name, pattern.strip().casefold())), None)
_EDITION = re.compile(
r"\s*[\[((【](?:[^\]))】]*\b(?:deluxe|expanded|special|limited|anniversary|remaster(?:ed)?)\b"
r"[^\]))】]*|[^\]))】]*(?:豪华版|典藏版|纪念版|重制版|周年版)[^\]))】]*)[\]))】]",

View File

@@ -160,6 +160,8 @@ class TransHandler:
"""比较新旧音乐的实际音质,无法形成可靠结论时交回原覆盖策略。"""
if getattr(mediainfo, "type", None) != MediaType.MUSIC or not target_item:
return None
if Path(str(target_item.path or "")).suffix.casefold() in {".jpg", ".jpeg", ".png", ".webp"}:
return None
source_score = getattr(meta, "audio_quality_score", 0) or getattr(
mediainfo, "audio_quality_score", 0
@@ -422,6 +424,10 @@ class TransHandler:
)
if not target_directory_path:
raise ValueError("重命名格式无效")
if (mediainfo.type == MediaType.MUSIC
and Path(str(fileitem.path)).suffix.casefold() in {".jpg", ".jpeg", ".png", ".webp"}):
# 封面保持原名并放到专辑根,不套用曲序/曲名模板或进入 Disc 子目录。
final_target = target_directory_path / Path(str(fileitem.path)).name
if getattr(planning_meta, "music_layout", None) == "image_cue":
# 整轨与索引只调整专辑目录,保留原名才能让 CUE FILE 引用继续有效。
final_target = final_target.parent / Path(str(fileitem.path)).name
@@ -537,7 +543,9 @@ class TransHandler:
need_notify=need_notify,
)
if not target_item:
return False, overwrite_mode == "latest", None
return False, overwrite_mode == "latest" and target_file.suffix.casefold() not in {
".jpg", ".jpeg", ".png", ".webp"
}, None
checked_target = target_file
over_flag = False

View File

@@ -402,6 +402,8 @@ class ConfigModel(BaseModel):
# ==================== 音乐配置 ====================
# 音乐封面代理地址(用于解决 coverartarchive.org 无法访问导致的封面不显示问题,留空则使用官方地址)
MUSIC_COVER_PROXY: str = ""
# 同目录音乐封面匹配规则,按顺序选择,逗号分隔、大小写不敏感;留空禁用本地封面
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 指纹
ACOUSTID_API_KEY: str = "b1auxfOzAg"
# 是否将识别到的音乐标题、艺术家、专辑等标准元数据转换为简体中文

View File

@@ -763,7 +763,7 @@ flowchart LR
| 指标 | 当前值 |
|---|---:|
| Python 模块 | 1119 |
| 内部导入边 | 9,542 |
| 内部导入边 | 9,545 |
| 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) |
| Application / Chain 具体 Adapter 直连 | 0 / 0 |
| Direct egress | 49(债务已清零,49 条精确 containment) |
@@ -773,7 +773,7 @@ flowchart LR
| Model/Oper 自动事务与自建 Session | 0 |
| 组合根外 `SystemConfigOper()` | 0 |
成功通知聚合复用既有 owner 和领域/模板模型,新增 14 条合法内部依赖边(含父包展开),未新增模块或 SCC。整理失败反馈由 `app.application.transfer.feedback` 集中投影;已整理下载的站点字幕复用 `app.chain.transfer` 自动整理队列;网络连通性检测按 `app.application.nettest` 归档,并覆盖 HTTP 与 WebSocket 探测。未启用智能助手的消息音乐交互由 `app.chain.music_interaction` 包中的解析展示与订阅协调模块处理;音乐标签准入与批次共识由 `app.chain.transfer.music` 负责,预览证据由 `app.application.transfer.projection` 只读投影;宿主依赖边现为 9,542;订阅独立日志复用运行时上下文、系统日志端口与资源鉴权,新增 19 条合法依赖边(含父包展开),未新增模块或 SCC;插件实例校验复用 `app.domain.plugin` 的物理身份规则,兼容下划线 ID,新增 2 条 Schema 到 Domain 的合法依赖边(含父包展开);种子下载限流窗口由 `app.domain.torrent` 定义,失败冷却 owner 复用同一语义,新增 1 条合法 Chain 到 Domain 的导入边;工作流添加下载动作由 `app.workflow.actions.add_download` 转换工作流 Schema 为下载链领域上下文,新增 5 条模块导入边;资源过滤动作 `app.workflow.actions.filter_torrents` 与动作包共用的领域还原函数同样把 Schema 资源还原为领域对象后再交给过滤链,新增 6 条模块导入边。音乐来源链与识别缓存复用领域身份核验,音乐交互包未新增 SCC,插件一次性任务 owner `app.scheduler.oncejob` 带来 1 个模块和 9 条依赖边。Agent 持久回执新增 Application 端口及 DB Model/Oper/Adapter 四个冷导入模块。缓存组合根只在启动时选用 Redis 缓存才导入 Redis 适配器,依赖 langchain_core 的对话记忆模型归属 `app.agent.memory`,文件缓存且未启用智能体的实例不再加载 redis 与 langchain_core。Telegram 渠道改用模块内 `app.modules.telegram.botapi` 精简 Bot API 客户端,经 `RequestUtils` 出站,宿主不再导入 pyTelegramBotAPI,启用 Telegram 的实例也不会因该 SDK 加载 redis;该依赖暂时保留给插件使用。Web Push 协议(aes128gcm 加密、VAPID 签名与经 `RequestUtils` 投递)由网络适配器 `app.adapters.network.webpush` 直接基于 http-ece 与 py-vapid 实现,消息端点与 `WebPushModule` 共用,宿主不再导入 pywebpush 及其连带的 aiohttp;该依赖同样暂时保留给插件使用。飞书渠道改用模块内 `app.modules.feishu.openapi` OpenAPI 客户端(经 `RequestUtils` 出站)与网络适配器 `app.adapters.network.feishu` 事件长连接(websocket-client 同步收发 pbbp2 帧),宿主不再导入 lark-oapi 及其上万个生成模块,长连接也不再依赖 SDK 的模块级事件循环;插件经 `app.sdk.feishu` 复用同一长连接,lark-oapi 暂时保留给尚未迁移的插件使用。API 文档路由由 `app.adapters.web.docs` 按 `API_DOCS_ENABLE` 逐请求开放,默认关闭,未开放时不生成文档。当前 `app.startup.lifecycle` 为 587、`app.factory` 为 600、`app.main` 为 602。专辑订阅入库对账新增 `app.chain.subscribe.music` 一个职责 owner;按注入的下载/整理历史 Port 读取事实,复用音频标签/CUE 读取和领域覆盖规则,依赖图增加 18 条合法边,不引入跨层数据库或 Adapter 直连。订阅业务活动新增 `app.application.subscription.activity` 及对应 DB Model、Oper、Adapter 四个模块,三入口与功能前提交对照均只增加这四个模块;性能基线仅同步结构计数,保留原有历史耗时采样和预算。音乐调用观察 `app.application.music.observation` 新增一个轻量模块,记录来源结果及请求预算;AcoustID及路径识别复用此上下文传递有界指纹候选,保留旧单ID模块合同。站点图片域名快照新增 `app.application.security.image`,各启动入口增加一个模块;性能基线只同步模块数量,原有耗时预算、历史采样和生命周期资源约束保持有效。
成功通知聚合复用既有 owner 和领域/模板模型,新增 14 条合法内部依赖边(含父包展开),未新增模块或 SCC。整理失败反馈由 `app.application.transfer.feedback` 集中投影;已整理下载的站点字幕复用 `app.chain.transfer` 自动整理队列;网络连通性检测按 `app.application.nettest` 归档,并覆盖 HTTP 与 WebSocket 探测。未启用智能助手的消息音乐交互由 `app.chain.music_interaction` 包中的解析展示与订阅协调模块处理;音乐标签准入与批次共识由 `app.chain.transfer.music` 负责,预览证据由 `app.application.transfer.projection` 只读投影;宿主依赖边现为 9,545;订阅独立日志复用运行时上下文、系统日志端口与资源鉴权,新增 19 条合法依赖边(含父包展开),未新增模块或 SCC;插件实例校验复用 `app.domain.plugin` 的物理身份规则,兼容下划线 ID,新增 2 条 Schema 到 Domain 的合法依赖边(含父包展开);种子下载限流窗口由 `app.domain.torrent` 定义,失败冷却 owner 复用同一语义,新增 1 条合法 Chain 到 Domain 的导入边;工作流添加下载动作由 `app.workflow.actions.add_download` 转换工作流 Schema 为下载链领域上下文,新增 5 条模块导入边;资源过滤动作 `app.workflow.actions.filter_torrents` 与动作包共用的领域还原函数同样把 Schema 资源还原为领域对象后再交给过滤链,新增 6 条模块导入边。音乐来源链与识别缓存复用领域身份核验,音乐交互包未新增 SCC,插件一次性任务 owner `app.scheduler.oncejob` 带来 1 个模块和 9 条依赖边。Agent 持久回执新增 Application 端口及 DB Model/Oper/Adapter 四个冷导入模块。缓存组合根只在启动时选用 Redis 缓存才导入 Redis 适配器,依赖 langchain_core 的对话记忆模型归属 `app.agent.memory`,文件缓存且未启用智能体的实例不再加载 redis 与 langchain_core。Telegram 渠道改用模块内 `app.modules.telegram.botapi` 精简 Bot API 客户端,经 `RequestUtils` 出站,宿主不再导入 pyTelegramBotAPI,启用 Telegram 的实例也不会因该 SDK 加载 redis;该依赖暂时保留给插件使用。Web Push 协议(aes128gcm 加密、VAPID 签名与经 `RequestUtils` 投递)由网络适配器 `app.adapters.network.webpush` 直接基于 http-ece 与 py-vapid 实现,消息端点与 `WebPushModule` 共用,宿主不再导入 pywebpush 及其连带的 aiohttp;该依赖同样暂时保留给插件使用。飞书渠道改用模块内 `app.modules.feishu.openapi` OpenAPI 客户端(经 `RequestUtils` 出站)与网络适配器 `app.adapters.network.feishu` 事件长连接(websocket-client 同步收发 pbbp2 帧),宿主不再导入 lark-oapi 及其上万个生成模块,长连接也不再依赖 SDK 的模块级事件循环;插件经 `app.sdk.feishu` 复用同一长连接,lark-oapi 暂时保留给尚未迁移的插件使用。API 文档路由由 `app.adapters.web.docs` 按 `API_DOCS_ENABLE` 逐请求开放,默认关闭,未开放时不生成文档。当前 `app.startup.lifecycle` 为 587、`app.factory` 为 600、`app.main` 为 602。专辑订阅入库对账新增 `app.chain.subscribe.music` 一个职责 owner;按注入的下载/整理历史 Port 读取事实,复用音频标签/CUE 读取和领域覆盖规则,依赖图增加 18 条合法边,不引入跨层数据库或 Adapter 直连。订阅业务活动新增 `app.application.subscription.activity` 及对应 DB Model、Oper、Adapter 四个模块,三入口与功能前提交对照均只增加这四个模块;性能基线仅同步结构计数,保留原有历史耗时采样和预算。音乐调用观察 `app.application.music.observation` 新增一个轻量模块,记录来源结果及请求预算;AcoustID及路径识别复用此上下文传递有界指纹候选,保留旧单ID模块合同。站点图片域名快照新增 `app.application.security.image`,各启动入口增加一个模块;性能基线只同步模块数量,原有耗时预算、历史采样和生命周期资源约束保持有效。
架构专项验证分为两个 CI 投影:`Check event semantic policy` 先运行依赖、Adapter、出口和 Event
语义门禁,`Check host architecture snapshot` 再执行快照测试及一次

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_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
```
@@ -531,6 +532,7 @@ moviepilot config describe API_TOKEN --show-secrets
- `AMLL_BASE_URL` 默认使用无需 API Key 的 AMLL TTML API,也可指向兼容原生接口的自建实例。来源优先按 ISRC 查询,再严格核对歌曲、艺术家及已有专辑信息;下载的 TTML 主唱时间轴转换为 Lyricsfile 和 LRC,限流时短暂冷却并让其他歌词来源继续处理
- `THEAUDIODB_API_KEY` 用于音乐元数据及纯文本歌词兜底,默认 `123` 为官方公开 V1 Key;额外歌词来源可通过插件提供,接入方式见[歌词插件开发说明](https://github.com/jxxghp/MoviePilot-Plugins/blob/main/docs/faq/21-register-lyrics-provider.md)
- `MUSIC_METADATA_TO_SIMPLIFIED` 默认开启;开启后会将识别结果中的曲名、艺术家、专辑和分类等标准音乐元数据转换为简体中文,不转换歌词与来源原始响应
- `MUSIC_COVER_FILE_PATTERNS` 控制同目录音乐封面的文件名匹配与选择顺序,逗号分隔、大小写不敏感;附加文件整理和内嵌封面回退共用此配置,详见[音乐本地封面](music-covers.md)
- `MUSIC_LYRICS_TO_SIMPLIFIED` 默认关闭,独立控制生成的 `.lrc` / `.txt` 歌词正文转简体;时间轴、LRC 标签及来源 Lyricsfile 保持原样。已有歌词仍遵循原刮削策略,需要转换时选择“总是刮削”重新刮削
- `MUSIC_RELEASE_REGION_PRIORITY` 与 `MUSIC_RELEASE_SCRIPT_PRIORITY` 分别使用逗号分隔的 ISO 3166-1 地区代码和 ISO 15924 文字代码;仅在 MusicBrainz 候选结构同样可信时作为发行版本排序依据,最多使用前三项
- `config describe` 显示单个配置项的类型、默认值和当前值

28
docs/music-covers.md Normal file
View File

@@ -0,0 +1,28 @@
# 音乐本地封面
开启整理请求的“同步整理附加文件”后,音乐目录或单个音轨整理会携带同目录的封面图片。
图片沿用当前复制、硬链接、移动和覆盖策略;重命名时保留图片原名,放入目标专辑根目录,
不套用曲名、曲序或 Disc 子目录。专辑、EP 和单曲共用这一行为。
本地图片作为附件保留,不依赖在线封面是否可下载,也不要求开启刮削。
同目录包含多个不同发行时不自动分配封面;影视整理和单独选择图片不会触发音乐封面整理。
后续生成 STRM 时仍需让对应插件同步图片后缀,MoviePilot 不负责该插件的复制配置。
音乐刮削内嵌封面使用“在线优先,下载不到再读取同目录图片”的顺序;本地候选按配置顺序选择,
损坏或不支持的图片跳过并尝试下一张。目录读取和图片下载均走存储能力,支持本地和远端目标。
刮削为“跳过”时不写内嵌封面,“缺失时刮削”保留已有内嵌封面,“总是刮削”可替换已有封面。
写入继续遵守音频源保护,不通过媒体库硬链接或软链接改写下载源文件。
`MUSIC_COVER_FILE_PATTERNS` 为全局封面匹配配置,逗号分隔、忽略大小写,支持 `*` 等文件名通配符。
默认值为:
```text
cover.jpg,cover.jpeg,cover.png,folder.jpg,folder.jpeg,folder.png,front.jpg,front.jpeg,front.png,*.png
```
匹配始终限制为 JPG、JPEG、PNG、WebP 图片,不读取隐藏文件。可以加入 `*.jpg` 或 `artwork.webp`;
留空关闭本地封面匹配。配置示例:
```shell
moviepilot config set MUSIC_COVER_FILE_PATTERNS 'cover.jpg,folder.jpg,front.jpg,*.png,artwork.webp'
```

View File

@@ -100,7 +100,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
| 指标 | 当前值 | 解释 |
|---|---:|---|
| 宿主 Python 模块 / 内部依赖边 | 1119 / 9,542 | `dependency-baseline.json` 当前快照;成功通知聚合复用既有 owner 和领域/模板模型,新增 14 条合法内部依赖边(含父包展开),未新增模块或 SCC。专辑订阅入库对账新增 1 个 Chain owner 和 18 条合法依赖边(含父包展开),仍经注入 Port 读取历史并通过既有 mutation 写入;订阅业务活动新增 4 个模块和 50 条合法内部依赖边(含父包展开),Application 仅定义事实与持久化 Port,DB Adapter/Oper 实现存取,组合根注入,未增加 Application/Chain 到具体 Adapter 或数据库的直连;插件实例校验复用 `app.domain.plugin` 的物理身份规则,兼容下划线 ID,新增 2 条合法依赖边(含父包展开);种子失败冷却复用 `app.domain.torrent` 的限流语义,新增 1 条合法导入边;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档;消息音乐交互由 `app.chain.music_interaction` 包中的音乐解析/展示与订阅协调模块处理;已整理下载的站点字幕经 `app.chain.transfer` 自动整理队列完成命名与归档;网络连通性检测按 `app.application.nettest` 归档;工作流添加下载动作由 `app.workflow.actions.add_download` 转换工作流 Schema 为下载链领域上下文,新增 5 条合法模块导入边,资源过滤动作复用同一还原再新增 6 条;LLM 独占连接关闭复用宿主线程池;歌词字形转换复用 `app.foundation.text`;订阅创建使用 `app.domain.context.MediaInfo` 和运行时配置端口,将非 TMDB 影视身份投影为 TMDB 身份 |
| 宿主 Python 模块 / 内部依赖边 | 1119 / 9,545 | `dependency-baseline.json` 当前快照;成功通知聚合复用既有 owner 和领域/模板模型,新增 14 条合法内部依赖边(含父包展开),未新增模块或 SCC。专辑订阅入库对账新增 1 个 Chain owner 和 18 条合法依赖边(含父包展开),仍经注入 Port 读取历史并通过既有 mutation 写入;订阅业务活动新增 4 个模块和 50 条合法内部依赖边(含父包展开),Application 仅定义事实与持久化 Port,DB Adapter/Oper 实现存取,组合根注入,未增加 Application/Chain 到具体 Adapter 或数据库的直连;插件实例校验复用 `app.domain.plugin` 的物理身份规则,兼容下划线 ID,新增 2 条合法依赖边(含父包展开);种子失败冷却复用 `app.domain.torrent` 的限流语义,新增 1 条合法导入边;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档;消息音乐交互由 `app.chain.music_interaction` 包中的音乐解析/展示与订阅协调模块处理;已整理下载的站点字幕经 `app.chain.transfer` 自动整理队列完成命名与归档;网络连通性检测按 `app.application.nettest` 归档;工作流添加下载动作由 `app.workflow.actions.add_download` 转换工作流 Schema 为下载链领域上下文,新增 5 条合法模块导入边,资源过滤动作复用同一还原再新增 6 条;LLM 独占连接关闭复用宿主线程池;歌词字形转换复用 `app.foundation.text`;订阅创建使用 `app.domain.context.MediaInfo` 和运行时配置端口,将非 TMDB 影视身份投影为 TMDB 身份 |
| 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 |
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |

View File

@@ -600,6 +600,12 @@ architecture snapshot, not through incidental module globals.
## Existing Chain, Module and DB Layers
音乐封面的文件名匹配与优先级是 `app/domain/music.py` 的纯规则;
`transfer/filter.py` 和 `ScrapingChain` 经 `runtime/settings.py` 读取同一个全局配置。
候选规划及 `transfer/music.py` 确认同目录唯一发行归属,图片走既有 durable 整理任务;
`TransHandler` 保留图片名并定位到专辑根。在线失败后的图片读取由 `ScrapingChain` 经
`StorageChain` 完成,不在领域层引入配置或 I/O,也不恢复未经证明的远端发行标识。
### Chain layer
运行时停止信号统一由 `app/runtime/stop.py` 的 `StopState` 持有。业务代码应注入或读取

View File

@@ -941,8 +941,8 @@
"runtime_only": true
}
},
"edge_count": 9542,
"edge_sha256": "74e5a7f8dd6e0529fa7ea16c3b35dcdf363574aaea5ad44551016c89357a3b90",
"edge_count": 9545,
"edge_sha256": "8784d89756b95ce35702eb7aa43513050e4b181641dad727a39d6a0354ec0eb6",
"edges": [
"app -> app.foundation",
"app -> app.foundation.environment",
@@ -5049,6 +5049,7 @@
"app.chain.scraping -> app.domain.meta.metabase",
"app.chain.scraping -> app.domain.meta.metamusic",
"app.chain.scraping -> app.domain.metainfo",
"app.chain.scraping -> app.domain.music",
"app.chain.scraping -> app.foundation",
"app.chain.scraping -> app.foundation.singleton",
"app.chain.scraping -> app.foundation.text",
@@ -5057,6 +5058,7 @@
"app.chain.scraping -> app.runtime.events",
"app.chain.scraping -> app.runtime.log",
"app.chain.scraping -> app.runtime.reload",
"app.chain.scraping -> app.runtime.settings",
"app.chain.scraping -> app.schemas",
"app.chain.scraping -> app.schemas.file",
"app.chain.scraping -> app.schemas.media",
@@ -5792,6 +5794,7 @@
"app.chain.transfer.filter -> app.domain.music",
"app.chain.transfer.filter -> app.runtime",
"app.chain.transfer.filter -> app.runtime.log",
"app.chain.transfer.filter -> app.runtime.settings",
"app.chain.transfer.filter -> app.schemas",
"app.chain.transfer.filter -> app.schemas.transfer",
"app.chain.transfer.filter -> app.schemas.types",

View File

@@ -0,0 +1,247 @@
"""音乐封面随附加文件整理及在线失败后的本地图片回退回归。"""
import os
import shutil
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from mutagen.flac import FLAC, Picture
from PIL import Image
from app.application.transfer.workflow import TransferPlanningInput
from app.chain.media import MediaChain
from app.chain.scraping import ScrapingChain
from app.chain.transfer.request import _TransferCandidatePlanner
from app.domain.context import MusicInfo
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.schemas.file import FileItem
from app.schemas.system import TransferDirectoryConf
from app.schemas.transfer import TransferInfo
from app.schemas.types import MediaType
from tests.test_transfer_sync_extra_files import bind_empty_history_repositories, make_fileitem, make_transfer_chain
def _tagged_audio(directory, name="01.flac", album="Album"):
"""用真实标签建立可离线整理的音轨和唯一发行证据。"""
directory.mkdir(parents=True, exist_ok=True)
path = directory / name
shutil.copyfile(Path(__file__).parent / "fixtures/audio/silence.flac", path)
audio = FLAC(path)
audio.update(title=[f"Song {path.stem}"], artist=["Artist"], album=[album], albumartist=["Artist"])
audio.save()
return path
def _planner(chain, item, sync=True, mtype=MediaType.MUSIC):
"""构造真实候选规划器,保留同目录匹配与附加文件开关。"""
return _TransferCandidatePlanner(
chain, meta=None, season=None, formater=None, batch_mtype=mtype, mediainfo=None,
continue_callback=None, has_episode_format_template=False, transfer_exclude_words=[],
download_hash=None, sync_extra_files=sync, fileitem=item,
)
@pytest.mark.parametrize("single", [False, True])
@pytest.mark.parametrize("sync", [False, True])
@pytest.mark.parametrize("mtype", [None, MediaType.MUSIC])
def test_music_cover_enters_directory_and_single_transfer_only_when_sync_enabled(tmp_path, monkeypatch, single, sync, mtype):
"""目录与单音轨使用相同附加文件开关,图片继承音轨身份并且只规划一次。"""
path = _tagged_audio(tmp_path / "source")
cover = make_fileitem(str(path.parent / "COVER.JPG"))
audio = make_fileitem(str(path))
root = audio if single else FileItem(storage="local", path=str(path.parent), type="dir")
chain = make_transfer_chain()
bind_empty_history_repositories(chain)
storage = Mock()
storage.get_parent_item.return_value = root
storage.list_files.return_value = [audio, cover]
monkeypatch.setattr("app.chain.transfer.request.StorageChain", lambda: storage)
planner = _planner(chain, root, sync, mtype)
items, inherited = planner._plan_file_items([(audio, False)] if single else [(cover, False), (audio, False)])
assert [item.path for item, _ in items] == ([audio.path, cover.path] if sync else [audio.path])
assert (chain._get_file_key(cover) in inherited) is sync
if sync:
context = chain._prepare_music_batch_context(items, mtype)
assert context.related_main_keys[chain._get_file_key(cover)] == chain._get_file_key(audio)
def test_music_cover_does_not_borrow_identity_from_different_albums_or_video(tmp_path):
"""同目录多发行存在歧义时不关联封面,影视批次也不会吸收图片。"""
first = make_fileitem(str(_tagged_audio(tmp_path / "source", "01.flac", "First")))
second = make_fileitem(str(_tagged_audio(tmp_path / "source", "02.flac", "Second")))
cover = make_fileitem(str(tmp_path / "source/cover.jpg"))
chain = make_transfer_chain()
bind_empty_history_repositories(chain)
context = chain._prepare_music_batch_context([(first, False), (second, False), (cover, False)], MediaType.MUSIC)
assert chain._get_file_key(cover) not in context.related_main_keys
assert not _planner(chain, first, mtype=MediaType.TV)._is_music_cover_candidate(cover)
assert _planner(chain, cover)._plan_file_items([(cover, False)])[0] == []
@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):
"""从真实目录遍历到公开整理入口,封面只复用主音轨身份而不单独在线识别。"""
audio = _tagged_audio(tmp_path / "source")
cover = audio.parent / "cover.jpg"
Image.new("RGB", (2, 2)).save(cover)
storage = LocalStorage()
port = SimpleNamespace(
get_item=lambda item: storage.get_item(Path(item.path)),
get_parent_item=lambda item: storage.get_item(Path(item.path).parent),
list_files=lambda item, **_kwargs: [storage.get_item(path) for path in Path(item.path).iterdir()],
contains_bluray_subdirectories=lambda _items: False,
)
monkeypatch.setattr("app.chain.transfer.workflow.StorageChain", lambda: port)
monkeypatch.setattr("app.chain.transfer.request.StorageChain", lambda: port)
monkeypatch.setattr("app.chain.transfer.workflow.get_configured_system_config", lambda: SimpleNamespace(get=lambda _key: None))
chain = make_transfer_chain()
bind_empty_history_repositories(chain)
chain.eventmanager = SimpleNamespace(send_event=lambda *_args, **_kwargs: None)
monkeypatch.setattr(chain, "_finalize_recognition_result", lambda info, **_kwargs: info)
monkeypatch.setattr(chain, "_TransferChain__put_to_jobview", lambda _task: True)
monkeypatch.setattr(chain, "_register_scrape_batch_task", lambda _task: None)
monkeypatch.setattr(chain, "_close_scrape_batch", lambda _batch: None)
for name in ("recognize_music_album_directory", "recognize_music_by_path", "recognize_by_meta", "recognize_media"):
monkeypatch.setattr(MediaChain, name, Mock(side_effect=AssertionError("完整标签不得在线识别")))
tasks = []
def preview(task, **_kwargs):
"""检查完整请求给规划执行器传入的主文件和封面上下文。"""
tasks.append(task)
return TransferInfo(success=True, fileitem=task.fileitem, target_item=task.fileitem)
monkeypatch.setattr(chain, "_plan_checkpoint_and_execute", preview)
success, result = chain.do_transfer(
fileitem=storage.get_item(audio if single else audio.parent),
target_directory=TransferDirectoryConf(library_storage="local", library_path=str(tmp_path / "library")),
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 all(task.mediainfo.album == "Album" for task in tasks)
assert result["summary"] == {"total": 2, "success": 2, "failed": 0}
@pytest.mark.parametrize("mode", ["copy", "link", "move"])
@pytest.mark.parametrize("rename", [False, True])
def test_music_cover_real_storage_transfer_preserves_filename_and_album_root(tmp_path, monkeypatch, mode, rename):
"""封面沿用真实复制/硬链接/移动执行及覆盖策略,重命名时不进入碟片子目录。"""
source = tmp_path / "source"
source.mkdir()
path = source / "COVER.JPG"
Image.new("RGB", (2, 2), "red").save(path)
content = path.read_bytes()
storage = LocalStorage()
item = storage.get_item(path)
info = MusicInfo(title="Song", album="Album", artists=["Artist"], album_artist="Artist", disc_number=2, total_discs=2)
meta = MetaMusic(title="Song", album="Album", artists=["Artist"], album_artist="Artist", disc_number=2, total_discs=2)
planning = TransferPlanningInput(source_fileitem=item.model_dump(mode="json"), target_storage="local",
target_path=str(tmp_path / "library"), requested_transfer_type=mode, need_rename=rename)
monkeypatch.setattr("app.modules.filemanager.transhandler.eventmanager.send_event", Mock(return_value=None))
handler = TransHandler()
checkpoint = handler.plan_transfer(planning, meta=meta, mediainfo=info, source_oper=storage, target_storage="local",
target_path=tmp_path / "library", transfer_type=mode, need_scrape=False,
need_rename=rename, need_notify=False, overwrite_mode="never", episodes_info=None, preview=False)
target = Path(checkpoint.final_target_path)
assert target.name == path.name and "Disc 2" not in target.parts
result = handler.execute_transfer_plan(checkpoint, meta=meta, mediainfo=info, source_oper=storage, target_oper=storage)
assert result.success and target.read_bytes() == content
assert path.exists() is (mode != "move")
if mode == "link":
assert os.path.samefile(path, target)
refused = handler._TransHandler__resolve_overwrite(
fileitem=item, meta=meta, mediainfo=info, target_oper=storage, target_storage="local", target_file=target,
transfer_type=mode, overwrite_mode="never", need_notify=False,
)
assert refused[2].overwrite_skipped
@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 验证下载失败回退、坏图跳过、刮削策略和硬链接源保护。"""
source = _tagged_audio(tmp_path / "source")
existing_path = tmp_path / "existing.png"
Image.new("RGB", (2, 2), "green").save(existing_path)
existing_bytes = existing_path.read_bytes()
if existing:
audio = FLAC(source)
picture = Picture()
picture.type, picture.mime, picture.data = 3, "image/png", existing_bytes
audio.add_picture(picture)
audio.save()
target_dir = tmp_path / "library"
target_dir.mkdir()
target = target_dir / "01.flac"
os.link(source, target)
before = source.read_bytes()
(target_dir / "cover.jpg").write_bytes(b"invalid image")
Image.new("RGB", (2, 2), "blue").save(target_dir / "front.png")
local_bytes = (target_dir / "front.png").read_bytes()
online_path = tmp_path / "online.png"
Image.new("RGB", (2, 2), "red").save(online_path)
online_bytes = online_path.read_bytes()
info = MusicInfo(title="Song", artists=["Artist"], album="Album", cover_url=None if online == "missing" else "https://example.invalid/cover")
storage = LocalStorage()
chain = object.__new__(ScrapingChain)
chain.storagechain = SimpleNamespace(
get_parent_item=lambda item: storage.get_item(Path(item.path).parent),
list_files=lambda item, **_kwargs: [storage.get_item(path) for path in Path(item.path).iterdir()],
download_file=lambda item, **_kwargs: Path(item.path),
)
chain._download_music_cover = Mock(return_value=(online_bytes, "image/png") if online == "available" else (None, "image/jpeg"))
result = chain._apply_music_file_scrape(
fileitem=storage.get_item(target), local_path=target, mediainfo=info, write_tags=False, tag_overwrite=False,
with_cover=policy != "skip", cover_overwrite=policy == "overwrite", cover=None, lyrics_option=None,
lyrics_overwrite=False, lyrics_chain=None, album_info=None, media_source=None,
)
assert result.metadata_success
assert source.read_bytes() == before
pictures = FLAC(target).pictures
if policy == "skip":
assert [picture.data for picture in pictures] == ([existing_bytes] if existing else [])
chain._download_music_cover.assert_not_called()
else:
expected = existing_bytes if existing and policy == "missing" else online_bytes if online == "available" else local_bytes
assert pictures[0].data == expected
@pytest.mark.parametrize("remote", [False, True])
def test_local_cover_reader_supports_storage_paths_and_album_root(tmp_path, remote):
"""碟片音轨从专辑根读取归档封面,远端路径只通过存储下载读取。"""
picture_path = tmp_path / "cover.png"
Image.new("RGB", (2, 2), "blue").save(picture_path)
storage = "cloud" if remote else "local"
audio = FileItem(storage=storage, path="/library/Album/Disc 2/01.flac", type="file")
disc = FileItem(storage=storage, path="/library/Album/Disc 2", type="dir")
album = FileItem(storage=storage, path="/library/Album", type="dir")
cover = FileItem(storage=storage, path="/library/Album/cover.png", type="file")
port = Mock()
port.get_parent_item.side_effect = [disc, album]
port.list_files.return_value = [cover]
port.download_file.return_value = picture_path
chain = object.__new__(ScrapingChain)
chain.storagechain = port
assert chain._load_local_music_cover(audio) == (picture_path.read_bytes(), "image/png")
port.list_files.assert_called_once_with(album, recursion=False)
assert port.download_file.call_args.args[0] == cover
def test_music_cover_patterns_are_case_insensitive_and_reject_non_images(monkeypatch):
"""通配配置可扩展文件名,但不能把文本或隐藏文件当作音乐封面。"""
monkeypatch.setattr(settings, "MUSIC_COVER_FILE_PATTERNS", "Artwork.*,*.PNG")
chain = make_transfer_chain()
assert chain._is_music_cover_file(make_fileitem("/music/ARTWORK.JPEG"))
assert chain._is_music_cover_file(make_fileitem("/music/custom.png"))
assert not chain._is_music_cover_file(make_fileitem("/music/Artwork.txt"))
assert music_cover_priority("/music/.cover.png", "*.png") is None
assert music_cover_priority("/music/cover.jpg", "") is None