mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-07 16:36:53 +08:00
feat(music): complete album subscription workflow
This commit is contained in:
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
"""从字典恢复标准化艺术家信息。"""
|
||||
|
||||
@@ -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)
|
||||
# 海报
|
||||
|
||||
@@ -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)
|
||||
# 海报
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
# 海报
|
||||
|
||||
@@ -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")
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 仅支持刮削单个音频文件,整目录请选择专辑"
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
"""异步新增与同步路径使用相同的剧集组身份契约。"""
|
||||
|
||||
Reference in New Issue
Block a user