feat(music): complete album subscription workflow

This commit is contained in:
jxxghp
2026-08-09 20:43:33 +08:00
parent a94202a28d
commit 393a4ca4e2
30 changed files with 1402 additions and 92 deletions
+2 -2
View File
@@ -145,8 +145,8 @@ def not_exists(
)
media_source, media_id = resolve_media_identity(media=mediainfo)
mediakey = build_media_key(media_source, media_id)
if mediainfo.type == MediaType.MOVIE:
# 电影已存在时返回空列表,不存在时返回空对像列表
if mediainfo.type in {MediaType.MOVIE, MediaType.MUSIC}:
# 电影和音乐都是原子存在性结果;专辑内部曲目完整性由下载入口校验。
return [] if exist_flag else [NotExistMediaInfo()]
elif no_exists and no_exists.get(mediakey):
# 电视剧返回缺失的剧集
+78 -4
View File
@@ -15,7 +15,14 @@ from app.chain.media import MediaChain
from app.chain.storage import StorageChain
from app.core.cache import FileCache
from app.core.config import settings, global_vars
from app.core.context import Context, MediaInfo, MusicInfo, SubtitleInfo, TorrentInfo
from app.core.context import (
MUSIC_ENTITY_ALBUM,
Context,
MediaInfo,
MusicInfo,
SubtitleInfo,
TorrentInfo,
)
from app.core.event import eventmanager, Event
from app.core.meta import MetaBase
from app.core.metainfo import MetaInfo
@@ -48,6 +55,7 @@ DOWNLOAD_FAILURE_RESOURCE_ERROR_KEYWORDS = (
"404",
"deleted",
"invalid torrent",
"专辑资源",
)
@@ -80,6 +88,45 @@ class DownloadChain(ChainBase):
}
return note
@staticmethod
def _validate_music_album_resource(
context: Context,
file_list: Optional[List[str]],
) -> Optional[str]:
"""校验专辑种子是否包含预期数量的独立音轨,并标记已确认的整专覆盖。"""
media = context.media_info
if (
not media
or media.type != MediaType.MUSIC
or getattr(media, "music_type", None) != MUSIC_ENTITY_ALBUM
):
return None
context.confirmed_full_coverage = False
try:
expected_tracks = int(getattr(media, "total_tracks", None) or 0)
except (TypeError, ValueError):
expected_tracks = 0
if expected_tracks <= 0:
return "专辑资源无法校验:专辑总曲目数未知"
if not file_list:
return "专辑资源无法校验:种子未提供文件清单,不能确认整专曲目"
audio_files = {
str(Path(str(file)))
for file in file_list
if Path(str(file)).suffix.lower() in settings.RMT_AUDIOEXT
}
actual_tracks = len(audio_files)
if actual_tracks < expected_tracks:
return (
f"专辑资源不完整:专辑共 {expected_tracks} 首,"
f"种子仅包含 {actual_tracks} 个独立音频文件"
)
context.confirmed_full_coverage = True
return None
@staticmethod
def _normalize_indirect_download_url(url: str, base_url: Optional[str] = None) -> str:
"""
@@ -912,6 +959,18 @@ class DownloadChain(ChainBase):
# 获取种子文件的文件夹名和文件清单
_folder_name, _file_list = TorrentHelper().get_fileinfo_from_torrent_content(torrent_content)
album_validation_error = self._validate_music_album_resource(context, _file_list)
if album_validation_error:
logger.info(f"{_torrent.title} {album_validation_error},跳过该资源")
self._record_download_failure(
context=context,
error_msg=album_validation_error,
downloader=downloader or _site_downloader,
source=source,
episodes=episodes,
)
return (None, album_validation_error) if return_detail else None
storage, download_dir, error_msg = self._resolve_media_download_dir(
media_info=_media,
save_path=save_path,
@@ -1271,7 +1330,7 @@ class DownloadChain(ChainBase):
else:
__remember_context_failure(context)
# 音乐与电影一样按单个订阅目标择一下载,不进入电视剧季集组合逻辑
# 音乐按单个订阅目标择一下载;专辑在 download_single 内先按文件清单确认整专覆盖
downloaded_music = set()
for context in contexts:
if global_vars.is_system_stopped:
@@ -1637,12 +1696,12 @@ class DownloadChain(ChainBase):
return downloaded_list, no_exists
def get_no_exists_info(self, meta: MetaBase,
mediainfo: MediaInfo,
mediainfo: MediaInfo | MusicInfo,
no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None,
totals: Dict[int, int] = None
) -> Tuple[bool, Dict[Union[int, str], Dict[int, NotExistMediaInfo]]]:
"""
检查媒体库,查询是否存在对于剧集同时返回不存在的季集信息
检查媒体库,查询电影或音乐是否存在对于剧集同时返回不存在的季集信息
:param meta: 元数据
:param mediainfo: 已识别的媒体信息
:param no_exists: 在调用该方法前已经存储的不存在的季集信息,有传入时该函数搜索的内容将会叠加后输出
@@ -1696,6 +1755,21 @@ class DownloadChain(ChainBase):
logger.info(f"媒体库中已存在电影:{mediainfo.title_year}")
return True, {}
return False, {}
if mediainfo.type == MediaType.MUSIC:
# 专辑在媒体库中按一个集合条目判断;单曲则由具体音乐服务器继续按曲名检索。
itemid = mediaserver.get_item_id(
mtype=mediainfo.type.value,
title=mediainfo.title,
year=mediainfo.year,
)
exists_music: Optional[ExistMediaInfo] = self.media_exists(
mediainfo=mediainfo,
itemid=itemid,
)
if exists_music:
logger.info(f"媒体库中已存在音乐:{mediainfo.title_year}")
return True, {}
return False, {}
else:
if not mediainfo.seasons:
# 补充媒体信息
+93 -23
View File
@@ -9,7 +9,13 @@ from app import schemas
from app.chain import ChainBase
from app.chain.storage import StorageChain
from app.core.config import settings
from app.core.context import Context, MediaInfo, MusicInfo
from app.core.context import (
MUSIC_ENTITY_ALBUM,
MUSIC_ENTITY_RECORDING,
Context,
MediaInfo,
MusicInfo,
)
from app.core.event import eventmanager, Event
from app.core.meta import MetaBase, MetaMusic
from app.core.metainfo import MetaInfo, MetaInfoPath
@@ -1407,8 +1413,12 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
files = self._music_audio_fileitems(fileitem)
if not files:
return False, "刮削路径中没有支持的音频文件"
if mediainfo and len(files) > 1:
return False, "指定 MusicBrainz ID 时仅支持刮削单个音频文件"
if (
mediainfo
and len(files) > 1
and mediainfo.music_type != MUSIC_ENTITY_ALBUM
):
return False, "单曲 MusicBrainz ID 仅支持刮削单个音频文件,整目录请选择专辑"
# 读取音乐刮削策略:music_nfo 控制标签写入,music_poster 控制封面嵌入
nfo_option = self.scraping_policies.option("music", "nfo")
@@ -1416,21 +1426,20 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
if nfo_option.is_skip:
return False, "音乐标签刮削策略为跳过,请先在高级设置中开启"
with_cover = not poster_option.is_skip
shared_cover = (
self._download_music_cover(mediainfo.cover_url)
if mediainfo and with_cover
else None
)
failures: list[str] = []
for audio_item in files:
info = mediainfo
if not info:
# 未指定识别信息时按文件名识别音乐,识别阶段不读取文件标签
meta = MetaInfoPath(Path(audio_item.path))
info = self.recognize_media(meta=meta, mtype=MediaType.MUSIC, source="musicbrainz")
if not info or not info.title:
failures.append(f"{audio_item.name or audio_item.path} 无法识别音乐信息")
continue
if not self._scrape_music_file(
audio_item,
info,
mediainfo,
overwrite=overwrite or nfo_option.is_overwrite,
with_cover=not poster_option.is_skip,
with_cover=with_cover,
cover=shared_cover,
):
failures.append(f"{audio_item.name or audio_item.path} 标签写入失败")
if failures:
@@ -1476,36 +1485,34 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
def _scrape_music_file(
self,
fileitem: schemas.FileItem,
mediainfo: MusicInfo,
mediainfo: Optional[MusicInfo],
overwrite: bool,
with_cover: bool = True,
cover: Optional[tuple[Optional[bytes], str]] = None,
) -> bool:
"""下载单个音频文件、写入标签,并在远端存储场景上传覆盖原文件。"""
cover_data, cover_mime = (
self._download_music_cover(mediainfo.cover_url) if with_cover else (None, "image/jpeg")
)
storage = StorageChain()
if fileitem.storage == "local":
local_path = storage.download_file(fileitem)
return bool(
local_path
and AudioMetadataHelper.write(
and self._write_music_metadata(
local_path,
mediainfo,
cover_data=cover_data,
cover_mime=cover_mime,
overwrite=overwrite,
with_cover=with_cover,
cover=cover,
)
)
with TemporaryDirectory(prefix="moviepilot-music-scrape-") as temp_dir:
local_path = storage.download_file(fileitem, path=Path(temp_dir))
if not local_path or not AudioMetadataHelper.write(
if not local_path or not self._write_music_metadata(
local_path,
mediainfo,
cover_data=cover_data,
cover_mime=cover_mime,
overwrite=overwrite,
with_cover=with_cover,
cover=cover,
):
return False
parent = storage.get_parent_item(fileitem)
@@ -1520,6 +1527,69 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
)
)
@staticmethod
def _merge_music_album_metadata(local_meta: MetaMusic, album: MusicInfo) -> MetaMusic:
"""把专辑级字段合并到单个音轨标签,同时保留该文件自己的标题、艺术家和曲序。"""
merged = deepcopy(local_meta)
merged.artists = list(local_meta.artists or album.artists)
merged.album = album.album or album.title or local_meta.album
merged.album_artist = album.album_artist or album.artist or local_meta.album_artist
merged.year = album.year or local_meta.year
merged.total_tracks = album.total_tracks or local_meta.total_tracks
merged.media_source = album.source or local_meta.media_source
merged.media_id = album.media_id or local_meta.media_id
return merged
@classmethod
def _resolve_music_scrape_info(
cls,
local_path: Path,
mediainfo: Optional[MusicInfo],
) -> Optional[MetaMusic | MusicInfo]:
"""在文件已下载到本地后解析刮削信息,专辑场景只覆盖专辑级标签。"""
if mediainfo and mediainfo.music_type == MUSIC_ENTITY_ALBUM:
return cls._merge_music_album_metadata(
AudioMetadataHelper.read(local_path),
mediainfo,
)
if mediainfo and mediainfo.music_type in (MUSIC_ENTITY_RECORDING, None, ""):
return mediainfo
if mediainfo:
return None
# 延迟导入避免 MediaChain 与 MusicChain 在模块加载阶段形成双向依赖。
from app.chain.music import MusicChain
_, recognized = MusicChain().recognize_by_path(local_path, source="musicbrainz")
return recognized
def _write_music_metadata(
self,
local_path: Path,
mediainfo: Optional[MusicInfo],
overwrite: bool,
with_cover: bool,
cover: Optional[tuple[Optional[bytes], str]] = None,
) -> bool:
"""解析单个本地音轨并写入标签,显式专辑刮削可复用一次下载的封面。"""
scrape_info = self._resolve_music_scrape_info(local_path, mediainfo)
if not scrape_info or not scrape_info.title:
logger.warning(f"无法识别音乐信息:{local_path}")
return False
if not with_cover:
cover_data, cover_mime = None, "image/jpeg"
elif cover is not None:
cover_data, cover_mime = cover
else:
cover_url = getattr(mediainfo, "cover_url", None) or getattr(scrape_info, "cover_url", None)
cover_data, cover_mime = self._download_music_cover(cover_url)
return AudioMetadataHelper.write(
local_path,
scrape_info,
cover_data=cover_data,
cover_mime=cover_mime,
overwrite=overwrite,
)
def _handle_movie_scraping(
self,
fileitem: schemas.FileItem,
+26 -3
View File
@@ -8,6 +8,7 @@ from app.db.mediaserver_oper import MediaServerOper
from app.helper.service import ServiceConfigHelper
from app.log import logger
from app.schemas import MediaServerLibrary, MediaServerItem, MediaServerSeasonInfo, MediaServerPlayItem
from app.schemas.types import MediaType
from app.utils.security import SecurityUtils
lock = threading.Lock()
@@ -25,6 +26,28 @@ class MediaServerChain(ChainBase):
"""
return SecurityUtils.sign_url(url) if url else url
@staticmethod
def _normalize_item_type(item_type: Optional[str]) -> str:
"""把不同媒体服务器的原始条目类型归一为电影、电视剧或音乐。"""
normalized = str(item_type or "").strip().casefold()
if normalized in {
MediaType.MUSIC.value.casefold(),
"music",
"musicalbum",
"album",
"audio",
"song",
}:
return MediaType.MUSIC.value
if normalized in {
MediaType.TV.value.casefold(),
"tv",
"series",
"show",
}:
return MediaType.TV.value
return MediaType.MOVIE.value
def _sign_library_images(
self, libraries: Optional[List[MediaServerLibrary]]
) -> Optional[List[MediaServerLibrary]]:
@@ -132,7 +155,7 @@ class MediaServerChain(ChainBase):
获取指定媒体服务器可同步的电影、电视剧和音乐总数
:param server: 媒体服务器名称
:return: 电影电视剧总数,无法获取时返回None
:return: 电影电视剧和音乐总数,无法获取时返回None
"""
statistics = self.run_module("media_statistic", server=server)
if not statistics:
@@ -429,8 +452,8 @@ class MediaServerChain(ChainBase):
global_media_finished += 1
seasoninfo = {}
# 类型
item_type = "电视剧" if item.item_type in ["Series", "show"] else "电影"
if item_type == "电视剧":
item_type = self._normalize_item_type(item.item_type)
if item_type == MediaType.TV.value:
# 查询剧集信息
espisodes_info = self.episodes(server_name, item.item_id) or []
for episode in espisodes_info:
+30 -1
View File
@@ -8,6 +8,7 @@ from app import schemas
from app.chain import ChainBase
from app.core.config import settings
from app.core.context import (
MUSIC_ENTITY_ALBUM,
MUSIC_ENTITY_RECORDING,
MusicAlbumInfo,
MusicArtistInfo,
@@ -51,6 +52,28 @@ class MusicChain(ChainBase):
keywords.append(music.title)
return cls._unique_texts(keywords)
@classmethod
def matches_site_resource(cls, music: MusicInfo, resource_title: str) -> bool:
"""判断站点资源标题是否包含订阅目标名称,避免宽泛搜索结果串专辑或串单曲。"""
normalized_resource = cls._normalize_match_text(resource_title)
if not normalized_resource:
return False
if music.music_type == MUSIC_ENTITY_ALBUM:
candidates = cls._unique_texts([
music.album or music.title,
*(music.names or []),
])
else:
# Recording 的 names 兼容字段会包含所属专辑名;单曲匹配只能使用曲名,
# 否则整专资源会被当成单曲下载并在首个任务后误销订阅。
candidates = cls._unique_texts([music.title])
return any(
normalized_target and normalized_target in normalized_resource
for normalized_target in (
cls._normalize_match_text(candidate) for candidate in candidates
)
)
@classmethod
def normalize_candidates(
cls,
@@ -239,6 +262,11 @@ class MusicChain(ChainBase):
results = [info for info in results if info.cover_url]
return list(results)
@staticmethod
def _normalize_match_text(value: Optional[str]) -> str:
"""移除大小写、空白和标点差异,生成站点标题匹配使用的紧凑文本。"""
return re.sub(r"[^\w]+", "", str(value or "").casefold(), flags=re.UNICODE)
@classmethod
def is_audio_path(cls, path: str | Path) -> bool:
"""判断路径是否指向系统支持的音频文件。"""
@@ -318,9 +346,10 @@ class MusicChain(ChainBase):
def _candidate_identity(cls, info: MusicInfo) -> tuple[str, ...]:
"""构造跨来源稳定的候选去重键。"""
if info.source and info.media_id:
return "id", info.source.casefold(), info.media_id.casefold()
return "id", info.source.casefold(), info.music_type.casefold(), info.media_id.casefold()
return (
"metadata",
info.music_type.casefold(),
cls._normalize_text(info.title).casefold(),
cls._normalize_text(info.artist).casefold(),
cls._normalize_text(info.album).casefold(),
+24 -11
View File
@@ -1362,11 +1362,7 @@ class SearchChain(ChainBase):
filter_params: Optional[Dict[str, str]] = None,
) -> List[Context]:
"""过滤音乐分类资源并组装携带目标音乐身份的下载上下文。"""
torrents = [
torrent
for torrent in torrents
if torrent.category in (MediaType.MUSIC, MediaType.MUSIC.value)
]
torrents = self._matching_music_torrents(torrents, mediainfo)
if filter_params:
torrenthelper = TorrentHelper()
torrents = [
@@ -1400,6 +1396,19 @@ class SearchChain(ChainBase):
)
return self.__remove_duplicate(TorrentHelper.sort_torrents(contexts))
@staticmethod
def _matching_music_torrents(
torrents: Optional[List[TorrentInfo]],
mediainfo: MusicInfo,
) -> List[TorrentInfo]:
"""筛出音乐分类且标题包含目标单曲或专辑名称的站点资源。"""
return [
torrent
for torrent in torrents or []
if torrent.category in (MediaType.MUSIC, MediaType.MUSIC.value)
and MusicChain.matches_site_resource(mediainfo, torrent.title)
]
def _process_music(
self,
mediainfo: MusicInfo,
@@ -1414,15 +1423,17 @@ class SearchChain(ChainBase):
for index, search_word in enumerate(keywords or [mediainfo.title]):
if index:
time.sleep(random.randint(1, 10))
torrents.extend(
matched_torrents = self._matching_music_torrents(
self.__search_all_sites(
keyword=search_word,
mediainfo=mediainfo,
sites=sites,
mtype=MediaType.MUSIC,
) or []
),
mediainfo,
)
if torrents and not settings.SEARCH_MULTIPLE_NAME:
torrents.extend(matched_torrents)
if matched_torrents and not settings.SEARCH_MULTIPLE_NAME:
break
return self._build_music_contexts(
torrents=torrents,
@@ -1445,15 +1456,17 @@ class SearchChain(ChainBase):
for index, search_word in enumerate(keywords or [mediainfo.title]):
if index:
await asyncio.sleep(random.randint(1, 10))
torrents.extend(
matched_torrents = self._matching_music_torrents(
await self.__async_search_all_sites(
keyword=search_word,
mediainfo=mediainfo,
sites=sites,
mtype=MediaType.MUSIC,
) or []
),
mediainfo,
)
if torrents and not settings.SEARCH_MULTIPLE_NAME:
torrents.extend(matched_torrents)
if matched_torrents and not settings.SEARCH_MULTIPLE_NAME:
break
return await run_in_threadpool(
self._build_music_contexts,
+96 -9
View File
@@ -17,7 +17,13 @@ from app.chain.search import SearchChain
from app.chain.tmdb import TmdbChain
from app.chain.torrents import TorrentsChain
from app.core.config import settings, global_vars
from app.core.context import Context, MediaInfo, MusicInfo, TorrentInfo
from app.core.context import (
MUSIC_ENTITY_ALBUM,
Context,
MediaInfo,
MusicInfo,
TorrentInfo,
)
from app.core.event import eventmanager, Event
from app.core.meta import MetaBase
from app.core.meta import MetaMusic
@@ -60,9 +66,12 @@ def build_subscribe_meta(subscribe: Subscribe) -> MetaBase:
按订阅对象构造主程序链路共用的媒体元数据
"""
if subscribe.type == MediaType.MUSIC.value:
is_album = getattr(subscribe, "music_type", None) == MUSIC_ENTITY_ALBUM
return MetaMusic(
title=subscribe.name,
album=subscribe.name if is_album else None,
year=subscribe.year,
total_tracks=getattr(subscribe, "total_tracks", None),
media_source=subscribe.media_source,
media_id=str(subscribe.media_id) if subscribe.media_id is not None else None,
)
@@ -1093,6 +1102,8 @@ class SubscribeChain(ChainBase):
"anilistid": mediainfo.anilist_id,
"media_source": media_source,
"media_id": media_id,
"music_type": getattr(mediainfo, "music_type", None),
"total_tracks": getattr(mediainfo, "total_tracks", None),
"season": season,
"poster": mediainfo.get_poster_image(),
"backdrop": mediainfo.get_backdrop_image(),
@@ -1309,6 +1320,8 @@ class SubscribeChain(ChainBase):
"anilistid": mediainfo.anilist_id,
"media_source": media_source,
"media_id": media_id,
"music_type": getattr(mediainfo, "music_type", None),
"total_tracks": getattr(mediainfo, "total_tracks", None),
"season": season,
"poster": mediainfo.get_poster_image(),
"backdrop": mediainfo.get_backdrop_image(),
@@ -1339,7 +1352,7 @@ class SubscribeChain(ChainBase):
@staticmethod
def _recognize_music_subscribe(subscribe: Subscribe) -> Optional[MusicInfo]:
"""按订阅身份恢复音乐目标,缺少身份时按标题查询首个候选"""
"""按订阅身份恢复音乐目标,远端暂不可用时使用已持久化的稳定快照"""
if subscribe.media_source and subscribe.media_id:
# 与影视共用统一识别入口,按媒体源和原生 ID 恢复音乐详情
mediainfo = MediaChain().recognize_media(
@@ -1349,12 +1362,23 @@ class SubscribeChain(ChainBase):
)
if mediainfo:
return mediainfo
candidates = MusicChain().search(subscribe.name, limit=1)
return candidates[0] if candidates else None
if getattr(subscribe, "music_type", None):
return SubscribeChain._music_info_from_subscribe(subscribe)
# 旧订阅没有保存实体类型时不能猜测为单曲,否则可能误把专辑按单曲完成。
return None
if getattr(subscribe, "music_type", None) == MUSIC_ENTITY_ALBUM:
# 缺少远端 ID 的专辑不能退化为单曲识别,使用已保存专辑快照更可靠。
return SubscribeChain._music_info_from_subscribe(subscribe)
# 旧订阅没有实体类型时只允许走 Recording 识别,不能从全局混合搜索中猜成专辑或艺术家。
return MediaChain().recognize_media(
meta=build_subscribe_meta(subscribe),
mtype=MediaType.MUSIC,
source=subscribe.media_source,
)
@staticmethod
async def _async_recognize_music_subscribe(subscribe: Subscribe) -> Optional[MusicInfo]:
"""异步按订阅身份恢复音乐目标,缺少身份时按标题查询首个候选"""
"""异步按订阅身份恢复音乐目标,远端暂不可用时使用已持久化的稳定快照"""
if subscribe.media_source and subscribe.media_id:
# 与影视共用统一识别入口,按媒体源和原生 ID 恢复音乐详情
mediainfo = await MediaChain().async_recognize_media(
@@ -1364,8 +1388,60 @@ class SubscribeChain(ChainBase):
)
if mediainfo:
return mediainfo
candidates = await MusicChain().async_search(subscribe.name, limit=1)
return candidates[0] if candidates else None
if getattr(subscribe, "music_type", None):
return SubscribeChain._music_info_from_subscribe(subscribe)
return None
if getattr(subscribe, "music_type", None) == MUSIC_ENTITY_ALBUM:
return SubscribeChain._music_info_from_subscribe(subscribe)
return await MediaChain().async_recognize_media(
meta=build_subscribe_meta(subscribe),
mtype=MediaType.MUSIC,
source=subscribe.media_source,
)
@staticmethod
def _music_info_from_subscribe(subscribe: Subscribe) -> MusicInfo:
"""从订阅行恢复不依赖远端请求的最小音乐目标,保留专辑完成判断所需字段。"""
year_text = str(subscribe.year or "")[:4]
music_type = getattr(subscribe, "music_type", None)
return MusicInfo(
source=subscribe.media_source,
media_id=str(subscribe.media_id) if subscribe.media_id is not None else None,
music_type=music_type,
title=subscribe.name,
album=subscribe.name if music_type == MUSIC_ENTITY_ALBUM else None,
year=int(year_text) if year_text.isdigit() else None,
total_tracks=getattr(subscribe, "total_tracks", None),
cover_url=getattr(subscribe, "poster", None) or getattr(subscribe, "backdrop", None),
)
@staticmethod
def _sync_music_subscribe_target(subscribe: Subscribe, mediainfo: MusicInfo) -> None:
"""把远端识别得到的专辑类型和总曲目数同步到订阅,供搜索失败与完成历史复用。"""
update_data = {}
if mediainfo.music_type and getattr(subscribe, "music_type", None) != mediainfo.music_type:
update_data["music_type"] = mediainfo.music_type
if mediainfo.total_tracks and getattr(subscribe, "total_tracks", None) != mediainfo.total_tracks:
update_data["total_tracks"] = mediainfo.total_tracks
if not update_data:
return
SubscribeOper().update(subscribe.id, update_data)
for key, value in update_data.items():
setattr(subscribe, key, value)
@staticmethod
def _is_music_download_complete(
subscribe: Subscribe,
mediainfo: MusicInfo,
downloads: Optional[List[Context]],
) -> bool:
"""判断音乐下载是否满足订阅完成条件;专辑必须由下载层确认整专曲目覆盖。"""
if not downloads:
return False
music_type = getattr(subscribe, "music_type", None) or mediainfo.music_type
if music_type != MUSIC_ENTITY_ALBUM:
return True
return any(context.confirmed_full_coverage for context in downloads)
def _search_music_subscribe(self, subscribe: Subscribe) -> None:
"""复用站点标题搜索、订阅过滤和批量下载完成单个音乐订阅。"""
@@ -1376,6 +1452,7 @@ class SubscribeChain(ChainBase):
f"媒体源:{subscribe.media_source},媒体ID{subscribe.media_id}"
)
return
self._sync_music_subscribe_target(subscribe, mediainfo)
sites = self.get_sub_sites(subscribe)
rule_groups = subscribe.filter_groups \
@@ -1398,6 +1475,7 @@ class SubscribeChain(ChainBase):
for context in contexts
if context.torrent_info
and context.torrent_info.category in (MediaType.MUSIC, MediaType.MUSIC.value)
and MusicChain.matches_site_resource(mediainfo, context.torrent_info.title)
and TorrentHelper().filter_torrent(
context.torrent_info,
self.get_params(subscribe),
@@ -1744,7 +1822,11 @@ class SubscribeChain(ChainBase):
scene="download",
)
if ((no_lefts and meta.type == MediaType.TV)
or (downloads and meta.type in (MediaType.MOVIE, MediaType.MUSIC))
or (downloads and meta.type == MediaType.MOVIE)
or (
meta.type == MediaType.MUSIC
and self._is_music_download_complete(subscribe, mediainfo, downloads)
)
or force):
self.__finish_subscribe(subscribe=subscribe, meta=meta, mediainfo=mediainfo)
else:
@@ -2350,7 +2432,7 @@ class SubscribeChain(ChainBase):
# 更新TMDB信息
update_data = {
"name": mediainfo.title,
"year": mediainfo.year,
"year": str(mediainfo.year) if mediainfo.year is not None else None,
"vote": mediainfo.vote_average,
"poster": mediainfo.get_poster_image(),
"backdrop": mediainfo.get_backdrop_image(),
@@ -2365,6 +2447,11 @@ class SubscribeChain(ChainBase):
"media_id": resolve_media_identity(media=mediainfo)[1],
"total_episode": total_episode,
}
if meta.type == MediaType.MUSIC:
update_data.update({
"music_type": getattr(mediainfo, "music_type", None),
"total_tracks": getattr(mediainfo, "total_tracks", None),
})
update_data.update(progress_update)
for key, value in progress_update.items():
setattr(subscribe, key, value)
+14 -5
View File
@@ -17,7 +17,7 @@ from app.chain.storage import StorageChain
from app.chain.subscribe import SubscribeChain
from app.chain.tmdb import TmdbChain
from app.core.config import settings, global_vars
from app.core.context import MediaInfo, MusicInfo
from app.core.context import MUSIC_ENTITY_ALBUM, MediaInfo, MusicInfo
from app.core.event import eventmanager
from app.core.meta import MetaBase, MetaMusic
from app.core.metainfo import MetaInfoPath
@@ -1095,20 +1095,22 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
# 防止整包目录继续沿用订阅/下载标题(单曲名、专辑名等)导致所有文件重名。
if file_tags.title:
file_meta.title = file_tags.title
is_album_context = saved_info.music_type == MUSIC_ENTITY_ALBUM
for field_name in (
"artists",
"album",
"album_artist",
"year",
"disc_number",
"track_number",
"total_discs",
"total_tracks",
"version",
"isrc",
):
if getattr(file_tags, field_name, None):
setattr(file_meta, field_name, deepcopy(getattr(file_tags, field_name)))
for field_name in ("album", "album_artist", "year", "total_tracks"):
file_value = getattr(file_tags, field_name, None)
# 整专下载以订阅选中的专辑字段为准,避免单个错误标签把曲目拆到其它专辑目录。
if file_value and (not is_album_context or not getattr(file_meta, field_name, None)):
setattr(file_meta, field_name, deepcopy(file_value))
for field_name in (
"audio_format",
"bit_depth",
@@ -1124,10 +1126,17 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
file_info = cls._music_info_from_meta(file_meta)
file_info.source = saved_info.source
file_info.media_id = saved_info.media_id
file_info.music_type = saved_info.music_type
file_info.artist_ids = list(saved_info.artist_ids)
file_info.album_id = saved_info.album_id
file_info.album_type = saved_info.album_type
file_info.release_date = saved_info.release_date
file_info.cover_url = saved_info.cover_url
file_info.lyrics = saved_info.lyrics
file_info.category = saved_info.category
file_info.genres = list(saved_info.genres)
file_info.detail_link = saved_info.detail_link
file_info.listen_count = saved_info.listen_count
return file_meta, file_info
def __is_allowed_file(self, fileitem: FileItem) -> bool:
+16
View File
@@ -506,6 +506,22 @@ class MusicArtistInfo:
)
return payload
def to_music_info(self) -> MusicInfo:
"""转换为统一搜索列表使用的音乐信息,但不赋予下载或订阅语义。"""
return MusicInfo(
source=self.source,
media_id=self.media_id,
music_type=MUSIC_ENTITY_ARTIST,
title=self.name,
cover_url=self.image_url,
version=self.disambiguation,
category=self.artist_type or "",
genres=list(self.genres),
names=[name for name in [self.name, *self.aliases] if name],
detail_link=self.detail_link,
raw_data=dict(self.raw_data),
)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Self:
"""从字典恢复标准化艺术家信息。"""
+4
View File
@@ -30,6 +30,10 @@ class Subscribe(Base):
mediaid = Column(String, index=True)
media_source = Column(String, index=True)
media_id = Column(String, index=True)
# 音乐实体类型:recording 单曲、album 专辑
music_type = Column(String)
# 专辑预期总曲目数,供整专资源完整性判断
total_tracks = Column(Integer)
# 季号
season = Column(Integer)
# 海报
+4
View File
@@ -29,6 +29,10 @@ class SubscribeHistory(Base):
mediaid = Column(String, index=True)
media_source = Column(String, index=True)
media_id = Column(String, index=True)
# 音乐实体类型:recording 单曲、album 专辑
music_type = Column(String)
# 专辑预期总曲目数
total_tracks = Column(Integer)
# 季号
season = Column(Integer)
# 海报
+16 -3
View File
@@ -1,10 +1,11 @@
import time
from typing import Tuple, List, Optional
from app.core.context import MediaInfo
from app.core.context import MediaInfo, MusicInfo
from app.db import DbOper
from app.db.models.subscribe import Subscribe
from app.db.models.subscribehistory import SubscribeHistory
from app.schemas.types import MediaType
from app.utils.media import resolve_media_identity
INTEGER_FLAG_FIELDS = ("best_version", "best_version_full", "search_imdbid", "manual_total_episode")
@@ -31,12 +32,22 @@ def _normalize_year(year: Optional[int | str]) -> Optional[str]:
return str(year)
def _music_subscription_fields(mediainfo: MediaInfo | MusicInfo) -> dict:
"""从标准媒体信息提取音乐订阅需要持久化的专辑级字段。"""
if mediainfo.type != MediaType.MUSIC:
return {"music_type": None, "total_tracks": None}
return {
"music_type": getattr(mediainfo, "music_type", None),
"total_tracks": getattr(mediainfo, "total_tracks", None),
}
class SubscribeOper(DbOper):
"""
订阅管理
"""
def add(self, mediainfo: MediaInfo, **kwargs) -> Tuple[int, str]:
def add(self, mediainfo: MediaInfo | MusicInfo, **kwargs) -> Tuple[int, str]:
"""
新增订阅
"""
@@ -83,6 +94,7 @@ class SubscribeOper(DbOper):
"search_imdbid": 1 if kwargs.get('search_imdbid') else 0,
"date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
})
kwargs.update(_music_subscription_fields(mediainfo))
kwargs = _normalize_integer_flags(kwargs)
if not subscribe:
subscribe = Subscribe(**kwargs)
@@ -98,7 +110,7 @@ class SubscribeOper(DbOper):
else:
return subscribe.id, "订阅已存在"
async def async_add(self, mediainfo: MediaInfo, **kwargs) -> Tuple[int, str]:
async def async_add(self, mediainfo: MediaInfo | MusicInfo, **kwargs) -> Tuple[int, str]:
"""
异步新增订阅
"""
@@ -145,6 +157,7 @@ class SubscribeOper(DbOper):
"search_imdbid": 1 if kwargs.get('search_imdbid') else 0,
"date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
})
kwargs.update(_music_subscription_fields(mediainfo))
kwargs = _normalize_integer_flags(kwargs)
if not subscribe:
subscribe = Subscribe(**kwargs)
+72 -3
View File
@@ -101,7 +101,20 @@ class MusicBrainzModule(_ModuleBase):
return 0
def search_music(self, meta: MetaMusic, limit: int = 20) -> list[MusicInfo]:
"""根据标准音乐搜索条件返回 MusicBrainz 录音候选。"""
"""搜索单曲、专辑和艺术家,并交错返回可浏览的 MusicBrainz 候选。"""
normalized_limit = max(1, min(limit, 100))
recordings = self._search_recordings(meta, limit=normalized_limit)
albums = self._search_albums(meta, limit=normalized_limit)
artists = self._search_artists(meta, limit=normalized_limit)
return self._interleave_results(
recordings,
albums,
artists,
limit=normalized_limit,
)
def _search_recordings(self, meta: MetaMusic, limit: int) -> list[MusicInfo]:
"""按音频标签条件搜索 Recording,供全局搜索和文件识别复用。"""
query = self._build_query(meta)
if not query:
return []
@@ -115,6 +128,61 @@ class MusicBrainzModule(_ModuleBase):
if (info := self._recording_to_info(item))
]
def _search_albums(self, meta: MetaMusic, limit: int) -> list[MusicInfo]:
"""按标题和可选艺术家搜索 Release Group 专辑候选。"""
title = meta.album or meta.title
if not title:
return []
clauses = [f'releasegroup:"{self._escape_query(title)}"']
if meta.artists:
clauses.append(f'artist:"{self._escape_query(meta.artists[0])}"')
payload = self._request_json(
"/release-group",
params={
"query": " AND ".join(clauses),
"limit": max(1, min(limit, 100)),
"fmt": "json",
},
)
return [
album.to_music_info()
for item in (payload or {}).get("release-groups") or []
if (album := self._release_group_to_album(item))
]
def _search_artists(self, meta: MetaMusic, limit: int) -> list[MusicInfo]:
"""按用户输入中的艺术家部分搜索 Artist 浏览候选。"""
artist_name = meta.artists[0] if meta.artists else meta.title
if not artist_name:
return []
payload = self._request_json(
"/artist",
params={
"query": f'artist:"{self._escape_query(artist_name)}"',
"limit": max(1, min(limit, 100)),
"fmt": "json",
},
)
return [
artist.to_music_info()
for item in (payload or {}).get("artists") or []
if (artist := self._artist_to_info(item, include_raw=True))
]
@staticmethod
def _interleave_results(*groups: list[MusicInfo], limit: int) -> list[MusicInfo]:
"""按实体轮询合并结果,避免单曲数量占满全局搜索页。"""
results: list[MusicInfo] = []
index = 0
while len(results) < limit and any(index < len(group) for group in groups):
for group in groups:
if index < len(group):
results.append(group[index])
if len(results) >= limit:
break
index += 1
return results
def recognize_media(
self,
meta: MetaBase = None,
@@ -139,7 +207,8 @@ class MusicBrainzModule(_ModuleBase):
if info:
return info
# 无身份时按标题搜索并挑选可信候选,检索不到时返回元数据兜底
candidates = self.search_music(meta, limit=10)
# 文件识别只能从 Recording 中挑选,专辑或艺术家同名结果不能成为音轨身份。
candidates = self._search_recordings(meta, limit=10)
matched = self._select_candidate(meta, candidates, source=resolved_source or self._source)
return matched or self._info_from_meta(meta)
@@ -230,7 +299,7 @@ class MusicBrainzModule(_ModuleBase):
)
if payload:
return self._recording_to_info(payload)
# 订阅只持久化来源和 ID,无法区分单曲与专辑,因此按专辑再查一次
# MusicBrainz 各实体共用 UUID 形式,统一详情入口在 Recording 未命中后继续探测专辑。
album = self.music_album(source, media_id)
return album.to_music_info() if album else None
+24 -7
View File
@@ -3,7 +3,7 @@
from typing import Any, Dict, Generator, List, Optional, Tuple, Union
from app import schemas
from app.core.context import MediaInfo
from app.core.context import MUSIC_ENTITY_ALBUM, MediaInfo
from app.core.event import eventmanager
from app.log import logger
from app.modules import _MediaServerBase, _ModuleBase
@@ -100,6 +100,18 @@ class NavidromeModule(_ModuleBase, _MediaServerBase[Navidrome]):
return credentials
return None
@staticmethod
def _has_complete_album(mediainfo: MediaInfo, item: schemas.MediaServerItem) -> bool:
"""校验 Navidrome 专辑条目的曲目数是否覆盖订阅目标。"""
if getattr(mediainfo, "music_type", None) != MUSIC_ENTITY_ALBUM:
return True
try:
expected_tracks = int(getattr(mediainfo, "total_tracks", None) or 0)
actual_tracks = int((item.note or {}).get("song_count") or 0)
except (AttributeError, TypeError, ValueError):
return False
return expected_tracks > 0 and actual_tracks >= expected_tracks
def media_exists(
self, mediainfo: MediaInfo, itemid: Optional[str] = None, server: Optional[str] = None
) -> Optional[schemas.ExistMediaInfo]:
@@ -114,24 +126,30 @@ class NavidromeModule(_ModuleBase, _MediaServerBase[Navidrome]):
for name, service in servers:
if not service:
continue
if itemid and service.get_iteminfo(str(itemid)):
item = service.get_iteminfo(str(itemid)) if itemid else None
if item and self._has_complete_album(mediainfo, item):
return schemas.ExistMediaInfo(
type=MediaType.MUSIC,
server_type="navidrome",
server=name,
itemid=itemid,
)
is_album = getattr(mediainfo, "music_type", None) == MUSIC_ENTITY_ALBUM
matches = service.search_music(
title=getattr(mediainfo, "title", None),
title=None if is_album else getattr(mediainfo, "title", None),
artist=getattr(mediainfo, "artist", None),
album=getattr(mediainfo, "album", None),
album=getattr(mediainfo, "title", None) if is_album else None,
)
if matches:
match = next(
(candidate for candidate in matches if self._has_complete_album(mediainfo, candidate)),
None,
)
if match:
return schemas.ExistMediaInfo(
type=MediaType.MUSIC,
server_type="navidrome",
server=name,
itemid=matches[0].item_id,
itemid=match.item_id,
)
return None
@@ -202,4 +220,3 @@ class NavidromeModule(_ModuleBase, _MediaServerBase[Navidrome]):
) -> List[str]:
"""获取最近新增专辑封面。"""
return [item.image for item in (self.mediaserver_latest(server, count, username) or []) if item.image]
+68 -5
View File
@@ -1,6 +1,7 @@
"""Navidrome Subsonic/OpenSubsonic API 客户端。"""
import hashlib
import re
import secrets
from typing import Any, Dict, Generator, List, Optional
from urllib.parse import urlencode
@@ -122,6 +123,39 @@ class Navidrome:
note={"artist": album.get("artist"), "song_count": album.get("songCount")},
)
def _song_to_item(self, song: dict) -> schemas.MediaServerItem:
"""将 Subsonic 单曲转换为统一音乐条目。"""
song_id = str(song.get("id") or "")
title = song.get("title") or song.get("name") or ""
return schemas.MediaServerItem(
id=song_id,
item_id=song_id,
title=title,
original_title=title,
year=song.get("year") or song.get("created"),
item_type=MediaType.MUSIC.value,
server_type="navidrome",
path=song.get("path"),
note={"artist": song.get("artist"), "album": song.get("album")},
)
@staticmethod
def _same_name(left: Optional[str], right: Optional[str]) -> bool:
"""忽略大小写、空白和标点比较 Navidrome 音乐名称。"""
normalized_left = re.sub(r"[^\w]+", "", str(left or "").casefold())
normalized_right = re.sub(r"[^\w]+", "", str(right or "").casefold())
return bool(normalized_left) and normalized_left == normalized_right
@classmethod
def _same_artist(cls, item: dict, artist: Optional[str]) -> bool:
"""检查专辑或单曲的艺术家是否与目标一致;目标为空时不限制。"""
if not artist:
return True
return any(
cls._same_name(candidate, artist)
for candidate in (item.get("artist"), item.get("albumArtist"))
)
def _album_cover(self, album: dict) -> Optional[str]:
"""返回专辑封面地址。"""
cover_id = album.get("coverArt")
@@ -188,16 +222,28 @@ class Navidrome:
self, title: Optional[str] = None, artist: Optional[str] = None,
album: Optional[str] = None,
) -> List[schemas.MediaServerItem]:
"""按歌曲、艺术家或专辑名称查询音乐条目"""
query = " ".join(filter(None, [title, artist, album])).strip()
"""按歌曲或专辑名称精确筛选音乐条目,避免模糊搜索误报已入库"""
target = album or title
query = " ".join(dict.fromkeys(filter(None, [target, artist]))).strip()
if not query:
return []
payload = self._call("search3", query=query, artistCount=0, albumCount=20, songCount=20)
result = ((payload or {}).get("searchResult3") or {})
albums = result.get("album") or []
songs = result.get("song") or []
candidates = albums or songs
return [self._album_to_item(item) for item in candidates]
if album:
return [
self._album_to_item(item)
for item in albums
if self._same_name(self._album_name(item), album)
and self._same_artist(item, artist)
]
return [
self._song_to_item(item)
for item in songs
if self._same_name(item.get("title") or item.get("name"), title)
and self._same_artist(item, artist)
]
def _to_play_item(self, album: dict) -> schemas.MediaServerPlayItem:
"""将专辑转换为仪表盘播放/最新条目。"""
@@ -213,6 +259,20 @@ class Navidrome:
server_type="navidrome",
)
def _song_to_play_item(self, song: dict) -> schemas.MediaServerPlayItem:
"""将正在播放的单曲转换为仪表盘条目,避免把所属专辑名误作曲名。"""
song_id = str(song.get("id") or "")
return schemas.MediaServerPlayItem(
id=song_id,
item_id=song_id,
title=song.get("title") or song.get("name") or "",
subtitle=song.get("artist") or song.get("albumArtist"),
type=MediaType.MUSIC.value,
image=self._album_cover(song),
link=self._play_host,
server_type="navidrome",
)
def get_latest(self, count: int = 20) -> List[schemas.MediaServerPlayItem]:
"""返回最近新增专辑。"""
return [self._to_play_item(album) for album in self._albums("newest")[:count]]
@@ -221,7 +281,7 @@ class Navidrome:
"""返回当前用户正在播放的音乐。"""
payload = self._call("getNowPlaying")
items = ((payload or {}).get("nowPlaying") or {}).get("entry") or []
return [self._to_play_item(item) for item in items[:count]]
return [self._song_to_play_item(item) for item in items[:count]]
def get_play_url(self, item_id: str) -> Optional[str]:
"""返回 Navidrome 流播放地址。"""
@@ -230,3 +290,6 @@ class Navidrome:
return None
return f"{url}/stream?{urlencode(self._params(id=item_id))}"
def refresh_root_library(self) -> bool:
"""请求 Navidrome 执行增量音乐库扫描。"""
return self._call("startScan", fullScan=False) is not None
+8
View File
@@ -66,6 +66,10 @@ class Subscribe(BaseModel):
mediaid: Optional[str] = None
media_source: Optional[str] = None
media_id: Optional[str] = None
# 音乐实体类型:recording 单曲、album 专辑
music_type: Optional[str] = None
# 专辑预期总曲目数
total_tracks: Optional[int] = None
# 季号
season: Optional[int] = None
# 海报
@@ -194,6 +198,10 @@ class SubscribeShare(BaseModel):
anilistid: Optional[int] = None
media_source: Optional[str] = None
media_id: Optional[str] = None
# 音乐实体类型:recording 单曲、album 专辑
music_type: Optional[str] = None
# 专辑预期总曲目数
total_tracks: Optional[int] = None
# 季号
season: Optional[int] = None
# 海报
+56
View File
@@ -0,0 +1,56 @@
"""2.2.16
为音乐订阅保存实体类型和专辑总曲目数
Revision ID: c9d4e7f1a2b3
Revises: a8c4e2f6b1d9
Create Date: 2026-08-09
"""
from alembic import op
import sqlalchemy as sa
revision = "c9d4e7f1a2b3"
down_revision = "a8c4e2f6b1d9"
branch_labels = None
depends_on = None
def _has_column(table_name: str, column_name: str) -> bool:
"""检查数据表是否已存在指定字段。"""
inspector = sa.inspect(op.get_bind())
if table_name not in inspector.get_table_names():
return False
return any(
column["name"] == column_name
for column in inspector.get_columns(table_name)
)
def _add_music_columns(table_name: str) -> None:
"""为订阅或订阅历史表幂等补充音乐专辑字段。"""
if not _has_column(table_name, "music_type"):
op.add_column(
table_name,
sa.Column("music_type", sa.String(), nullable=True),
)
if not _has_column(table_name, "total_tracks"):
op.add_column(
table_name,
sa.Column("total_tracks", sa.Integer(), nullable=True),
)
def upgrade() -> None:
"""为当前订阅及完成历史补充音乐专辑字段。"""
_add_music_columns("subscribe")
_add_music_columns("subscribehistory")
def downgrade() -> None:
"""移除音乐订阅专辑字段。"""
for table_name in ("subscribehistory", "subscribe"):
if _has_column(table_name, "total_tracks"):
op.drop_column(table_name, "total_tracks")
if _has_column(table_name, "music_type"):
op.drop_column(table_name, "music_type")
+53 -2
View File
@@ -57,10 +57,61 @@ def test_media_count_reuses_existing_server_statistics():
"""整服同步应复用现有媒体统计并排除剧集集数。"""
chain = object.__new__(MediaServerChain)
chain.run_module = lambda *_args, **_kwargs: [
schemas.Statistic(movie_count=12, tv_count=8, episode_count=200)
schemas.Statistic(movie_count=12, tv_count=8, music_count=3, episode_count=200)
]
assert chain.media_count("plex") == 20
assert chain.media_count("plex") == 23
@pytest.mark.parametrize(
("raw_type", "expected"),
[
("Series", "电视剧"),
("show", "电视剧"),
("音乐", "音乐"),
("MusicAlbum", "音乐"),
("Audio", "音乐"),
("Movie", "电影"),
],
)
def test_sync_normalizes_movie_tv_and_music_item_types(raw_type, expected):
"""同步缓存应保留音乐类型,并兼容不同媒体服务器的原始类型名称。"""
assert MediaServerChain._normalize_item_type(raw_type) == expected
def test_sync_persists_music_without_querying_tv_episodes(database):
"""Navidrome 专辑同步应写成音乐条目,且不能触发电视剧分集查询。"""
chain = object.__new__(MediaServerChain)
chain.librarys = lambda _server: [SimpleNamespace(id="music", name="音乐")]
chain.media_count = lambda _server: 1
chain.items_count = lambda **_kwargs: pytest.fail("整服统计存在时不应逐库计数")
chain.items = lambda **_kwargs: iter(
[
schemas.MediaServerItem(
server="navidrome",
library="music",
item_id="album-1",
item_type="音乐",
title="叶惠美",
year="2003",
)
]
)
chain.episodes = lambda *_args, **_kwargs: pytest.fail("音乐条目不应查询电视剧分集")
with patch("app.db.ScopedSession", database), patch.object(
MEDIA_SERVER_CHAIN_MODULE.ServiceConfigHelper,
"get_mediaserver_configs",
return_value=[SimpleNamespace(name="navidrome", enabled=True, sync_libraries=["all"])],
):
chain.sync()
with database() as db:
item = db.query(MediaServerItem).one()
assert item.item_type == "音乐"
assert item.title == "叶惠美"
assert item.seasoninfo == {}
def test_sync_updates_rows_and_removes_stale_entries(database):
+44
View File
@@ -40,6 +40,38 @@ def test_build_site_keywords_prefers_artist_album():
]
def test_album_resource_match_requires_selected_album_title():
"""专辑订阅只接受包含目标专辑名的站点资源,忽略大小写、空格和标点差异。"""
album = MusicInfo(
music_type="album",
title="Random Access Memories",
album="Random Access Memories",
names=["Random-Access Memories"],
)
assert MusicChain.matches_site_resource(
album,
"Daft.Punk-Random.Access.Memories-2013-FLAC",
) is True
assert MusicChain.matches_site_resource(album, "Daft Punk - Discovery - FLAC") is False
def test_recording_resource_match_does_not_treat_album_name_as_track_alias():
"""单曲候选的兼容 names 即使包含专辑名,也不能让整专标题冒充目标单曲。"""
recording = MusicInfo(
music_type="recording",
title="Get Lucky",
album="Random Access Memories",
names=["Get Lucky", "Random Access Memories"],
)
assert MusicChain.matches_site_resource(recording, "Daft Punk - Get Lucky FLAC") is True
assert MusicChain.matches_site_resource(
recording,
"Daft Punk - Random Access Memories FLAC",
) is False
def test_normalize_candidates_deduplicates_source_identity():
"""同一来源和媒体 ID 的音乐候选应只保留一次。"""
results = MusicChain.normalize_candidates(
@@ -58,6 +90,18 @@ def test_normalize_candidates_deduplicates_source_identity():
assert results[0].title == "A"
def test_normalize_candidates_keeps_different_entities_with_same_source_id():
"""同一来源 ID 在不同音乐实体命名空间下不能互相去重。"""
results = MusicChain.normalize_candidates(
[
MusicInfo(source="musicbrainz", media_id="shared-id", music_type="recording", title="Song"),
MusicInfo(source="musicbrainz", media_id="shared-id", music_type="album", title="Album"),
]
)
assert [item.music_type for item in results] == ["recording", "album"]
def test_normalize_candidates_deduplicates_metadata_without_id():
"""缺少来源 ID 时应按标题、艺术家和专辑去重。"""
results = MusicChain.normalize_candidates(
+116 -1
View File
@@ -3,7 +3,8 @@ from unittest.mock import Mock, patch
from app.api.endpoints.download import download
from app.chain.download import DownloadChain
from app.chain.music import MusicChain
from app.core.context import MusicInfo
from app.core.context import MUSIC_ENTITY_ALBUM, Context, MusicInfo
from app.schemas import ExistMediaInfo
from app.schemas.context import TorrentInfo
from app.schemas.music import MusicInfo as MusicInfoSchema
from app.schemas.types import MediaType
@@ -23,6 +24,19 @@ def _music_info() -> MusicInfo:
)
def _album_info(total_tracks: int | None = 3) -> MusicInfo:
"""构造整张专辑下载校验使用的目标信息。"""
return MusicInfo(
source="musicbrainz",
media_id="release-group-1",
music_type=MUSIC_ENTITY_ALBUM,
title="叶惠美",
album="叶惠美",
artists=["周杰伦"],
total_tracks=total_tracks,
)
def test_music_info_exposes_download_chain_compatibility_fields():
"""音乐信息应安全兼容下载链现有的视频身份字段访问。"""
info = _music_info()
@@ -49,6 +63,73 @@ def test_download_note_keeps_versioned_music_context():
assert "raw_data" not in note["music"]["media"]
def test_album_resource_requires_all_independent_audio_tracks():
"""整专资源只有在独立音频文件数覆盖专辑曲目数时才可标记完整。"""
context = Context(media_info=_album_info(total_tracks=3))
error = DownloadChain._validate_music_album_resource(
context,
["叶惠美/01.flac", "叶惠美/02.flac", "叶惠美/03.m4a", "叶惠美/cover.jpg"],
)
assert error is None
assert context.confirmed_full_coverage is True
def test_album_resource_rejects_incomplete_or_unverifiable_pack():
"""曲目不足、未知曲目总数或无文件清单时不得把专辑订阅判定为完成。"""
incomplete = Context(media_info=_album_info(total_tracks=3))
unknown = Context(media_info=_album_info(total_tracks=None))
assert "仅包含 1 个独立音频文件" in (
DownloadChain._validate_music_album_resource(incomplete, ["叶惠美/disc.flac"]) or ""
)
assert incomplete.confirmed_full_coverage is False
assert "总曲目数未知" in (
DownloadChain._validate_music_album_resource(unknown, ["叶惠美/01.flac"]) or ""
)
assert "未提供文件清单" in (
DownloadChain._validate_music_album_resource(
Context(media_info=_album_info(total_tracks=3)),
[],
) or ""
)
def test_download_single_stops_before_client_when_album_pack_is_incomplete():
"""下载入口应在添加任务前拒绝不完整专辑,并记录可供后续候选继续尝试的失败原因。"""
context = Context(
media_info=_album_info(total_tracks=3),
meta_info=MusicChain.to_meta(_album_info(total_tracks=3)),
torrent_info=TorrentInfo(
title="周杰伦 - 叶惠美 FLAC",
category=MediaType.MUSIC.value,
),
)
chain = DownloadChain()
chain._record_download_failure = Mock()
media_chain = Mock()
media_chain.supplement_tmdb_info.return_value = context.media_info
torrent_helper = Mock()
torrent_helper.get_fileinfo_from_torrent_content.return_value = (
"叶惠美",
["叶惠美/整轨.flac", "叶惠美/整轨.cue"],
)
with patch("app.chain.download.MediaChain", return_value=media_chain), \
patch("app.chain.download.TorrentHelper", return_value=torrent_helper), \
patch("app.chain.download.eventmanager.send_event", return_value=None):
task_id, error = chain.download_single(
context,
torrent_content=b"torrent",
return_detail=True,
)
assert task_id is None
assert "专辑资源不完整" in error
chain._record_download_failure.assert_called_once()
def test_download_endpoint_builds_music_context():
"""现有添加下载接口应使用 MusicInfo 和 MetaMusic 构造音乐上下文。"""
chain = Mock()
@@ -74,3 +155,37 @@ def test_download_endpoint_builds_music_context():
assert context.media_info.media_id == "recording-1"
assert context.meta_info.type == MediaType.MUSIC
assert context.meta_info.org_string == "周杰伦 - 叶惠美 FLAC"
def test_music_library_exists_uses_atomic_album_lookup():
"""整专存在性检查应按音乐条目判断,不能落入电视剧季集补全分支。"""
album = _album_info(total_tracks=11)
chain = DownloadChain()
chain.media_exists = Mock(
return_value=ExistMediaInfo(
type=MediaType.MUSIC,
server_type="navidrome",
server="music",
itemid="album-item-1",
)
)
mediaserver = Mock()
mediaserver.get_item_id.return_value = "album-item-1"
with patch("app.chain.download.MediaServerOper", return_value=mediaserver):
exists, no_exists = chain.get_no_exists_info(
meta=MusicChain.to_meta(album),
mediainfo=album,
)
assert exists is True
assert no_exists == {}
mediaserver.get_item_id.assert_called_once_with(
mtype=MediaType.MUSIC.value,
title="叶惠美",
year=None,
)
chain.media_exists.assert_called_once_with(
mediainfo=album,
itemid="album-item-1",
)
+6 -6
View File
@@ -84,22 +84,22 @@ def test_musicbrainz_module_recognize_media_uses_detail_when_meta_has_identity(m
meta = MetaMusic(title="晴天", media_source="musicbrainz", media_id="recording-1")
expected = _music_info()
monkeypatch.setattr(module, "recognize_music", Mock(return_value=expected))
search_mock = Mock(return_value=[])
monkeypatch.setattr(module, "search_music", search_mock)
recording_search = Mock(return_value=[])
monkeypatch.setattr(module, "_search_recordings", recording_search)
result = module.recognize_media(meta=meta, source="musicbrainz")
module.recognize_music.assert_called_once_with("musicbrainz", "recording-1")
search_mock.assert_not_called()
recording_search.assert_not_called()
assert result is expected
def test_musicbrainz_module_recognize_media_matches_search_candidate(monkeypatch):
"""无身份时应按标题搜索选择匹配候选。"""
"""无身份时应从 Recording 搜索选择匹配候选。"""
module = MusicBrainzModule()
meta = MetaMusic(title="晴天", artists=["周杰伦"], album="叶惠美")
candidate = _music_info()
monkeypatch.setattr(module, "search_music", Mock(return_value=[candidate]))
monkeypatch.setattr(module, "_search_recordings", Mock(return_value=[candidate]))
result = module.recognize_media(meta=meta)
@@ -110,7 +110,7 @@ def test_musicbrainz_module_recognize_media_falls_back_to_offline_when_no_match(
"""搜索无候选时应返回元数据兜底,且兜底结果不带远端身份。"""
module = MusicBrainzModule()
meta = MetaMusic(title="未知曲目", artists=["未知艺术家"])
monkeypatch.setattr(module, "search_music", Mock(return_value=[]))
monkeypatch.setattr(module, "_search_recordings", Mock(return_value=[]))
result = module.recognize_media(meta=meta)
+104
View File
@@ -0,0 +1,104 @@
from types import SimpleNamespace
from unittest.mock import Mock
from app.chain.media import MediaChain
from app.core.context import MUSIC_ENTITY_ALBUM, MusicInfo
from app.core.meta import MetaMusic
from app.schemas import FileItem
def _media_chain() -> MediaChain:
"""构造不注册全局单例的音乐刮削链测试实例。"""
return object.__new__(MediaChain)
def _album_info() -> MusicInfo:
"""构造专辑批量刮削使用的标准目标。"""
return MusicInfo(
source="musicbrainz",
media_id="release-group-1",
music_type=MUSIC_ENTITY_ALBUM,
title="叶惠美",
artists=["周杰伦"],
album="叶惠美",
album_artist="周杰伦",
year=2003,
total_tracks=11,
cover_url="https://example.com/album.jpg",
)
def test_album_scrape_merge_preserves_track_fields_and_applies_album_identity() -> None:
"""专辑批量刮削应保留每首歌自己的曲名和曲序,只统一专辑级字段。"""
local = MetaMusic(
title="晴天",
artists=["周杰伦"],
album="错误专辑",
album_artist="错误艺术家",
year=1999,
track_number=3,
total_tracks=99,
)
merged = MediaChain._merge_music_album_metadata(local, _album_info())
assert merged.title == "晴天"
assert merged.artists == ["周杰伦"]
assert merged.track_number == 3
assert merged.album == "叶惠美"
assert merged.album_artist == "周杰伦"
assert merged.year == 2003
assert merged.total_tracks == 11
assert merged.media_source == "musicbrainz"
assert merged.media_id == "release-group-1"
def test_album_directory_scrape_processes_each_track_and_reuses_cover() -> None:
"""显式选择专辑刮削目录时应逐曲写标签,并让整批文件共用一次封面下载。"""
chain = _media_chain()
chain.storagechain = Mock()
chain.scraping_policies = Mock()
chain.scraping_policies.option.return_value = SimpleNamespace(
is_skip=False,
is_overwrite=False,
)
audio_files = [
FileItem(storage="local", path="/music/叶惠美/01.flac", type="file", name="01.flac"),
FileItem(storage="local", path="/music/叶惠美/02.m4a", type="file", name="02.m4a"),
]
chain.storagechain.list_files.return_value = audio_files
chain._download_music_cover = Mock(return_value=(b"cover", "image/jpeg"))
chain._scrape_music_file = Mock(return_value=True)
album = _album_info()
success, message = chain.scrape_music_metadata(
FileItem(storage="local", path="/music/叶惠美", type="dir", name="叶惠美"),
mediainfo=album,
)
assert success is True
assert message == "已刮削 2 个音频文件"
chain._download_music_cover.assert_called_once_with(album.cover_url)
assert chain._scrape_music_file.call_count == 2
assert all(
call.args[1] is album and call.kwargs["cover"] == (b"cover", "image/jpeg")
for call in chain._scrape_music_file.call_args_list
)
def test_recording_identity_rejects_multi_track_directory_scrape() -> None:
"""单曲身份不得覆盖整目录,否则会把同一首歌的标签写到专辑内所有文件。"""
chain = _media_chain()
chain.storagechain = Mock()
chain.storagechain.list_files.return_value = [
FileItem(storage="local", path="/music/01.flac", type="file"),
FileItem(storage="local", path="/music/02.flac", type="file"),
]
success, message = chain.scrape_music_metadata(
FileItem(storage="local", path="/music", type="dir"),
mediainfo=MusicInfo(title="晴天", music_type="recording"),
)
assert success is False
assert message == "单曲 MusicBrainz ID 仅支持刮削单个音频文件,整目录请选择专辑"
+39 -1
View File
@@ -19,7 +19,12 @@ def test_music_context_builder_keeps_only_music_category():
)
torrents = [
TorrentInfo(
title="Daft Punk - Random Access Memories FLAC",
title="Daft Punk - Get Lucky - Random Access Memories FLAC",
category=MediaType.MUSIC.value,
site_name="MusicSite",
),
TorrentInfo(
title="Daft Punk - Discovery FLAC",
category=MediaType.MUSIC.value,
site_name="MusicSite",
),
@@ -44,6 +49,39 @@ def test_music_context_builder_keeps_only_music_category():
assert contexts[0].torrent_info.category == MediaType.MUSIC.value
def test_music_search_continues_after_unrelated_first_keyword_results():
"""首组关键词只命中其它专辑时应继续尝试后续关键词,不能提前返回空结果。"""
chain = SearchChain()
music = MusicInfo(
source="musicbrainz",
media_id="recording-1",
title="Get Lucky",
artists=["Daft Punk"],
album="Random Access Memories",
)
unrelated = TorrentInfo(
title="Daft Punk - Discovery FLAC",
category=MediaType.MUSIC.value,
site_name="MusicSite",
)
matched = TorrentInfo(
title="Daft Punk - Get Lucky FLAC",
category=MediaType.MUSIC.value,
site_name="MusicSite",
)
with patch.object(
chain,
"_SearchChain__search_all_sites",
side_effect=[[unrelated], [matched]],
) as search_sites, patch("app.chain.search.time.sleep"):
contexts = chain._process_music(music, rule_groups=[])
assert search_sites.call_count == 2
assert len(contexts) == 1
assert contexts[0].torrent_info.title == matched.title
def test_search_by_id_routes_music_identity_to_recognize_and_process():
"""MusicBrainz 精确身份搜索应经统一识别入口识别后进入现有搜索处理链。"""
chain = SearchChain()
+134 -5
View File
@@ -2,7 +2,7 @@ from types import SimpleNamespace
from unittest.mock import Mock, patch
from app.chain.subscribe import SubscribeChain, build_subscribe_meta
from app.core.context import Context, TorrentInfo
from app.core.context import MUSIC_ENTITY_ALBUM, Context, TorrentInfo
from app.core.meta import MetaMusic
from app.core.context import MusicInfo
from app.schemas.types import MediaType
@@ -20,9 +20,9 @@ def _music_info() -> MusicInfo:
)
def _subscribe() -> SimpleNamespace:
def _subscribe(**overrides) -> SimpleNamespace:
"""构造不依赖数据库的音乐订阅对象。"""
return SimpleNamespace(
values = dict(
id=7,
name="晴天",
year="2003",
@@ -30,6 +30,8 @@ def _subscribe() -> SimpleNamespace:
keyword=None,
media_source="musicbrainz",
media_id="recording-1",
music_type="recording",
total_tracks=None,
season=None,
episode_group=None,
tmdbid=None,
@@ -53,7 +55,11 @@ def _subscribe() -> SimpleNamespace:
best_version=0,
state="R",
note=None,
poster=None,
backdrop=None,
)
values.update(overrides)
return SimpleNamespace(**values)
def test_build_subscribe_meta_returns_music_meta():
@@ -72,7 +78,7 @@ def test_music_subscribe_reuses_search_download_and_finish_flow():
target = _music_info()
context = Context(
torrent_info=TorrentInfo(
title="周杰伦 - 叶惠美 FLAC",
title="周杰伦 - 晴天 FLAC",
category=MediaType.MUSIC.value,
)
)
@@ -98,7 +104,7 @@ def test_music_subscribe_reuses_search_download_and_finish_flow():
)
assert context.media_info is target
assert isinstance(context.meta_info, MetaMusic)
assert context.meta_info.org_string == "周杰伦 - 叶惠美 FLAC"
assert context.meta_info.org_string == "周杰伦 - 晴天 FLAC"
download_chain.batch_download.assert_called_once()
chain.finish_subscribe_or_not.assert_called_once()
@@ -124,6 +130,129 @@ def test_music_subscribe_ignores_non_music_category():
download_chain.assert_not_called()
def test_music_subscribe_ignores_unrelated_music_title():
"""即使站点分类为音乐,资源标题不含目标单曲或专辑名时也不得自动下载。"""
subscribe = _subscribe()
context = Context(
torrent_info=TorrentInfo(
title="周杰伦 - 七里香 FLAC",
category=MediaType.MUSIC.value,
)
)
search_chain = Mock()
search_chain.search_by_title.return_value = [context]
with patch.object(SubscribeChain, "_recognize_music_subscribe", return_value=_music_info()), \
patch("app.chain.subscribe.SearchChain", return_value=search_chain), \
patch("app.chain.subscribe.DownloadChain") as download_chain:
SubscribeChain()._search_music_subscribe(subscribe)
download_chain.assert_not_called()
def test_album_subscription_uses_persisted_snapshot_when_remote_detail_is_unavailable():
"""远端详情短暂失败时应从订阅快照恢复专辑语义,不能按标题猜成第一首单曲。"""
subscribe = _subscribe(
name="叶惠美",
media_id="release-group-1",
music_type=MUSIC_ENTITY_ALBUM,
total_tracks=11,
)
media_chain = Mock()
media_chain.recognize_media.return_value = None
with patch("app.chain.subscribe.MediaChain", return_value=media_chain), \
patch("app.chain.subscribe.MusicChain.search") as search:
restored = SubscribeChain._recognize_music_subscribe(subscribe)
assert restored.music_type == MUSIC_ENTITY_ALBUM
assert restored.album == "叶惠美"
assert restored.total_tracks == 11
search.assert_not_called()
def test_legacy_music_identity_failure_does_not_guess_entity_from_title():
"""旧订阅有标准 ID 却无实体类型时,识别失败后应保留订阅而不是误选标题搜索首项。"""
subscribe = _subscribe(music_type=None)
media_chain = Mock()
media_chain.recognize_media.return_value = None
with patch("app.chain.subscribe.MediaChain", return_value=media_chain), \
patch("app.chain.subscribe.MusicChain.search") as search:
restored = SubscribeChain._recognize_music_subscribe(subscribe)
assert restored is None
search.assert_not_called()
def test_album_subscription_without_remote_id_uses_persisted_entity_snapshot():
"""专辑快照缺少远端 ID 时也不得退化为单曲识别。"""
subscribe = _subscribe(
name="叶惠美",
media_source=None,
media_id=None,
music_type=MUSIC_ENTITY_ALBUM,
total_tracks=11,
)
with patch("app.chain.subscribe.MediaChain") as media_chain:
restored = SubscribeChain._recognize_music_subscribe(subscribe)
assert restored.music_type == MUSIC_ENTITY_ALBUM
assert restored.total_tracks == 11
media_chain.assert_not_called()
def test_legacy_music_without_identity_uses_recording_recognition_boundary():
"""旧订阅缺少标准身份时只能恢复为单曲,不能消费全局搜索中的专辑或艺术家候选。"""
subscribe = _subscribe(
media_source=None,
media_id=None,
music_type=None,
)
recording = _music_info()
media_chain = Mock()
media_chain.recognize_media.return_value = recording
with patch("app.chain.subscribe.MediaChain", return_value=media_chain), \
patch("app.chain.subscribe.MusicChain.search") as mixed_search:
restored = SubscribeChain._recognize_music_subscribe(subscribe)
assert restored is recording
mixed_search.assert_not_called()
media_chain.recognize_media.assert_called_once()
call = media_chain.recognize_media.call_args
assert isinstance(call.kwargs["meta"], MetaMusic)
assert call.kwargs["mtype"] == MediaType.MUSIC
def test_album_subscription_finishes_only_after_confirmed_full_pack():
"""专辑与电视剧全集相同,必须确认整专覆盖;单曲仍在任一成功下载后完成。"""
album_subscribe = _subscribe(music_type=MUSIC_ENTITY_ALBUM, total_tracks=11)
album = MusicInfo(
music_type=MUSIC_ENTITY_ALBUM,
title="叶惠美",
album="叶惠美",
total_tracks=11,
)
assert SubscribeChain._is_music_download_complete(
album_subscribe,
album,
[Context(confirmed_full_coverage=False)],
) is False
assert SubscribeChain._is_music_download_complete(
album_subscribe,
album,
[Context(confirmed_full_coverage=True)],
) is True
assert SubscribeChain._is_music_download_complete(
_subscribe(),
_music_info(),
[Context()],
) is True
def test_subscribe_add_music_uses_unified_recognize_by_meta():
"""音乐订阅新增应走统一 recognize_by_meta,并把媒体身份落到 MetaMusic 上。"""
target = _music_info()
+53
View File
@@ -133,6 +133,59 @@ def test_restore_music_context_from_download_history():
assert restored_info.album == "Random Access Memories"
def test_restore_album_context_keeps_album_identity_and_track_specific_tags(tmp_path, monkeypatch):
"""整专整理应保留选中的专辑身份,同时使用每个文件自己的曲名、艺术家和曲序。"""
album = MusicInfo(
source="musicbrainz",
media_id="release-group-1",
music_type="album",
title="叶惠美",
artists=["周杰伦"],
album="叶惠美",
album_artist="周杰伦",
year=2003,
total_tracks=11,
)
meta = MusicChain.to_meta(album)
history = SimpleNamespace(note={
"music": {
"version": 1,
"meta": meta.to_dict(),
"media": album.to_dict(),
}
})
audio_file = tmp_path / "03. 晴天.flac"
audio_file.write_bytes(b"fake-flac")
from app.helper.audio import AudioMetadataHelper
monkeypatch.setattr(
AudioMetadataHelper,
"read",
lambda path: MetaMusic(
org_string=path.name,
title="晴天",
artists=["周杰伦"],
album="错误专辑",
album_artist="错误艺术家",
year=1999,
track_number=3,
total_tracks=99,
),
)
restored_meta, restored_info = TransferChain._restore_music_download_context(history, audio_file)
assert restored_meta.title == "晴天"
assert restored_meta.track_number == 3
assert restored_meta.album == "叶惠美"
assert restored_meta.album_artist == "周杰伦"
assert restored_meta.year == 2003
assert restored_meta.total_tracks == 11
assert restored_info.music_type == "album"
assert restored_info.media_id == "release-group-1"
def test_restore_music_context_uses_file_title_over_subscription_title(tmp_path, monkeypatch):
"""曲目标题应优先取当前文件自身的标签/文件名,而非沿用订阅时的单曲标题。"""
meta, info = _music_context()
+76
View File
@@ -82,6 +82,82 @@ def test_search_music_normalizes_candidates(monkeypatch):
assert results[0].title == "晴天"
def test_search_music_interleaves_recordings_albums_and_artists(monkeypatch):
"""全局音乐搜索应交错返回三类实体,避免单曲结果挤掉整专和艺术家入口。"""
module = MusicBrainzModule()
requested = []
def fake_request(path, params=None):
"""按 MusicBrainz 实体路径返回可区分的搜索结果。"""
requested.append((path, params))
if path == "/recording":
return {
"recordings": [
{"id": "recording-1", "title": "晴天"},
{"id": "recording-2", "title": "轨迹"},
]
}
if path == "/release-group":
return {
"release-groups": [
{
"id": "album-1",
"title": "叶惠美",
"primary-type": "Album",
"artist-credit": [{"artist": {"id": "artist-1", "name": "周杰伦"}}],
},
{"id": "album-2", "title": "七里香", "primary-type": "Album"},
]
}
if path == "/artist":
return {
"artists": [
{"id": "artist-1", "name": "周杰伦", "type": "Person"},
{"id": "artist-2", "name": "Jay Chou", "type": "Person"},
]
}
return None
monkeypatch.setattr(module, "_request_json", fake_request)
results = module.search_music(
MetaMusic(title="晴天", artists=["周杰伦"]),
limit=5,
)
assert [item.music_type for item in results] == [
"recording",
"album",
"artist",
"recording",
"album",
]
assert results[1].album == "叶惠美"
assert results[2].title == "周杰伦"
assert results[2].artists == []
assert requested[1][1]["query"] == 'releasegroup:"晴天" AND artist:"周杰伦"'
assert requested[2][1]["query"] == 'artist:"周杰伦"'
def test_file_recognition_searches_recordings_only(monkeypatch):
"""本地音轨识别不得把同名专辑或艺术家候选当成 Recording。"""
module = MusicBrainzModule()
requested_paths = []
def fake_request(path, params=None):
"""记录文件识别实际访问的 MusicBrainz 实体。"""
requested_paths.append(path)
return {"recordings": [{"id": "recording-1", "title": "晴天"}]}
monkeypatch.setattr(module, "_request_json", fake_request)
result = module.recognize_media(meta=MetaMusic(title="晴天"))
assert result is not None
assert result.music_type == "recording"
assert requested_paths == ["/recording"]
def test_recognize_music_ignores_other_sources(monkeypatch):
"""MusicBrainz 模块不应处理其他元数据源的详情请求。"""
module = MusicBrainzModule()
+112
View File
@@ -1,6 +1,11 @@
"""Navidrome 媒体服务器模块接入测试。"""
from unittest.mock import Mock
from app import schemas
from app.core.module import ModuleManager
from app.core.context import MusicInfo
from app.modules.navidrome import NavidromeModule
from app.modules.navidrome.navidrome import Navidrome
from app.schemas.types import MediaServerType, ModuleType
@@ -30,3 +35,110 @@ def test_navidrome_module_ignores_non_music_media():
mediainfo.type = MediaType.MOVIE
assert NavidromeModule().media_exists(mediainfo) is None
def test_navidrome_refresh_requests_incremental_scan(monkeypatch):
"""音乐入库完成后应通过 Subsonic startScan 触发 Navidrome 增量扫描。"""
client = object.__new__(Navidrome)
requested = []
def fake_call(method, **kwargs):
"""记录媒体库刷新使用的 Subsonic 方法和参数。"""
requested.append((method, kwargs))
return {"status": "ok"}
monkeypatch.setattr(client, "_call", fake_call)
assert client.refresh_root_library() is True
assert requested == [("startScan", {"fullScan": False})]
def test_navidrome_search_filters_exact_album_and_song(monkeypatch):
"""Navidrome 模糊搜索结果必须按实体名称和艺术家精确过滤。"""
client = object.__new__(Navidrome)
monkeypatch.setattr(
client,
"_call",
lambda *_args, **_kwargs: {
"searchResult3": {
"album": [
{"id": "wrong", "name": "叶惠美 演唱会", "artist": "周杰伦", "songCount": 12},
{"id": "album-1", "name": "叶惠美", "artist": "周杰伦", "songCount": 11},
],
"song": [
{"id": "song-wrong", "title": "晴天 Live", "artist": "周杰伦"},
{"id": "song-1", "title": "晴天", "artist": "周杰伦"},
],
}
},
)
albums = client.search_music(album="叶惠美", artist="周杰伦")
songs = client.search_music(title="晴天", artist="周杰伦")
assert [item.item_id for item in albums] == ["album-1"]
assert albums[0].note["song_count"] == 11
assert [item.item_id for item in songs] == ["song-1"]
assert songs[0].title == "晴天"
def test_navidrome_now_playing_uses_song_title_instead_of_album(monkeypatch):
"""正在播放接口返回单曲时,仪表盘标题必须显示曲名而不是所属专辑名。"""
client = object.__new__(Navidrome)
client._play_host = "https://music.example.com"
monkeypatch.setattr(
client,
"_call",
lambda *_args, **_kwargs: {
"nowPlaying": {
"entry": [
{
"id": "song-1",
"title": "晴天",
"album": "叶惠美",
"artist": "周杰伦",
"coverArt": "cover-1",
}
]
}
},
)
monkeypatch.setattr(client, "_album_cover", lambda _item: "cover-url")
items = client.get_resume()
assert len(items) == 1
assert items[0].item_id == "song-1"
assert items[0].title == "晴天"
assert items[0].subtitle == "周杰伦"
assert items[0].image == "cover-url"
def test_navidrome_album_exists_requires_complete_track_count(monkeypatch):
"""同名专辑曲目不足时不得把整专订阅判定为已完整入库。"""
module = NavidromeModule()
service = Mock()
service.get_iteminfo.return_value = schemas.MediaServerItem(
item_id="album-1",
title="叶惠美",
item_type="音乐",
note={"artist": "周杰伦", "song_count": 10},
)
service.search_music.return_value = []
monkeypatch.setattr(module, "get_instances", lambda: {"music": service})
album = MusicInfo(
source="musicbrainz",
media_id="release-group-1",
music_type="album",
title="叶惠美",
artists=["周杰伦"],
total_tracks=11,
)
assert module.media_exists(album, itemid="album-1") is None
service.get_iteminfo.return_value.note["song_count"] = 11
exists = module.media_exists(album, itemid="album-1")
assert exists is not None
assert exists.itemid == "album-1"
+5
View File
@@ -78,6 +78,7 @@ def _load_subscribe_chain_class():
context_module.Context = SimpleNamespace
context_module.MediaInfo = SimpleNamespace
context_module.MusicInfo = SimpleNamespace
context_module.MUSIC_ENTITY_ALBUM = "album"
event_module = ensure_module("app.core.event", types.ModuleType("app.core.event"))
@@ -165,6 +166,8 @@ def _load_subscribe_chain_class():
"custom_words",
"media_category",
"filter_groups",
"music_type",
"total_tracks",
}
def __init__(self, **kwargs):
@@ -299,6 +302,8 @@ def _load_subscribe_chain_class():
self.media_source = None
self.media_id = None
self.mediaid = None
self.music_type = None
self.total_tracks = None
self.episode_group = None
for key, value in kwargs.items():
setattr(self, key, value)
+4 -1
View File
@@ -1129,6 +1129,8 @@ def test_create_subscribe_accepts_music_payload_with_empty_strings():
subscribe_in = Subscribe(
name="Random Access Memories",
type=MediaType.MUSIC.value,
music_type="album",
total_tracks=13,
tmdbid="",
season="",
total_episode="",
@@ -1154,4 +1156,5 @@ def test_create_subscribe_accepts_music_payload_with_empty_strings():
assert payload["total_episode"] == 0
assert payload["sites"] == []
assert payload["type"] == MediaType.MUSIC.value
assert payload["music_type"] == "album"
assert payload["total_tracks"] == 13
+25
View File
@@ -143,6 +143,31 @@ def test_music_subscribe_persists_numeric_year_as_string():
assert payload["year"] == "2025"
def test_music_album_subscription_persists_entity_and_track_count():
"""专辑订阅必须保存实体类型和总曲目数,供搜索校验与完成判定复用。"""
persisted = SimpleNamespace(id=94)
created = SimpleNamespace(create=MagicMock())
media = MusicInfo(
source="musicbrainz",
media_id="release-group-1",
music_type="album",
title="叶惠美",
album="叶惠美",
total_tracks=11,
)
with patch("app.db.subscribe_oper.Subscribe") as subscribe_model:
subscribe_model.exists.side_effect = [None, persisted]
subscribe_model.return_value = created
sid, _ = SubscribeOper(db=object()).add(mediainfo=media, season=None)
assert sid == 94
payload = subscribe_model.call_args.kwargs
assert payload["music_type"] == "album"
assert payload["total_tracks"] == 11
@pytest.mark.parametrize("episode_group", [None, "eg-1"])
def test_async_add_scopes_duplicate_lookup_by_episode_group(episode_group):
"""异步新增与同步路径使用相同的剧集组身份契约。"""