fix(music): complete subscription lifecycle

This commit is contained in:
jxxghp
2026-08-10 07:21:06 +08:00
parent 12b054c316
commit 1fc255b8a7
24 changed files with 789 additions and 131 deletions
+16 -7
View File
@@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, List, Optional, Tuple, Union
from app import schemas from app import schemas
from app.chain import ChainBase from app.chain import ChainBase
from app.chain.storage import StorageChain from app.chain.storage import StorageChain
from app.core.cache import cached
from app.core.config import settings from app.core.config import settings
from app.core.context import ( from app.core.context import (
MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_ALBUM,
@@ -1521,26 +1522,34 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
return True, message return True, message
@staticmethod @staticmethod
def _download_music_cover(url: Optional[str]) -> tuple[Optional[bytes], str]: @cached(maxsize=64, ttl=settings.CONF.meta, skip_none=True)
"""通过统一请求封装下载音乐封面,并返回图片内容与 MIME 类型。""" def _request_music_cover(url: str) -> Optional[tuple[Optional[bytes], str]]:
if not url: """下载并缓存音乐封面;仅稳定 404 与成功响应进入有界缓存。"""
return None, "image/jpeg"
response = RequestUtils( response = RequestUtils(
proxies=settings.PROXY, proxies=settings.PROXY,
ua=settings.NORMAL_USER_AGENT, ua=settings.NORMAL_USER_AGENT,
timeout=20, timeout=20,
).get_res(url) ).get_res(url)
if not response: if response is None:
return None, "image/jpeg" return None
try: try:
if response.status_code == 404:
return None, "image/jpeg"
if response.status_code != 200: if response.status_code != 200:
logger.warning(f"音乐封面下载失败:{response.status_code} {url}") logger.warning(f"音乐封面下载失败:{response.status_code} {url}")
return None, "image/jpeg" return None
mime = (response.headers.get("Content-Type") or "image/jpeg").split(";", 1)[0] mime = (response.headers.get("Content-Type") or "image/jpeg").split(";", 1)[0]
return response.content, mime return response.content, mime
finally: finally:
response.close() response.close()
@staticmethod
def _download_music_cover(url: Optional[str]) -> tuple[Optional[bytes], str]:
"""通过有界缓存下载音乐封面,并统一返回图片内容与 MIME 类型。"""
if not url:
return None, "image/jpeg"
return MediaChain._request_music_cover(url) or (None, "image/jpeg")
@staticmethod @staticmethod
def _is_music_audio_file(path: str) -> bool: def _is_music_audio_file(path: str) -> bool:
"""判断路径是否指向系统支持的音频文件。""" """判断路径是否指向系统支持的音频文件。"""
+18 -2
View File
@@ -71,12 +71,28 @@ class MusicChain(ChainBase):
# Recording 的 names 兼容字段会包含所属专辑名;单曲匹配只能使用曲名, # Recording 的 names 兼容字段会包含所属专辑名;单曲匹配只能使用曲名,
# 否则整专资源会被当成单曲下载并在首个任务后误销订阅。 # 否则整专资源会被当成单曲下载并在首个任务后误销订阅。
candidates = cls._unique_texts([music.title]) candidates = cls._unique_texts([music.title])
return any( title_matches = any(
normalized_target and normalized_target in normalized_resource normalized_target and normalized_target in normalized_resource
for normalized_target in ( for normalized_target in (
cls._normalize_match_text(candidate) for candidate in candidates cls._normalize_match_text(candidate) for candidate in candidates
) )
) )
if not title_matches:
return False
artists = cls._unique_texts([
music.artist,
music.album_artist,
*(music.artists or []),
])
if not artists:
return True
# 同名歌曲和专辑十分常见,已知艺术家时必须同时出现在资源标题中。
return any(
normalized_artist and normalized_artist in normalized_resource
for normalized_artist in (
cls._normalize_match_text(artist) for artist in artists
)
)
@classmethod @classmethod
def normalize_candidates( def normalize_candidates(
@@ -291,7 +307,7 @@ class MusicChain(ChainBase):
@staticmethod @staticmethod
def _normalize_match_text(value: Optional[str]) -> str: def _normalize_match_text(value: Optional[str]) -> str:
"""移除大小写、空白和标点差异,生成站点标题匹配使用的紧凑文本。""" """移除大小写、空白和标点差异,生成站点标题匹配使用的紧凑文本。"""
return re.sub(r"[^\w]+", "", str(value or "").casefold(), flags=re.UNICODE) return re.sub(r"[\W_]+", "", str(value or "").casefold(), flags=re.UNICODE)
@classmethod @classmethod
def is_audio_path(cls, path: str | Path) -> bool: def is_audio_path(cls, path: str | Path) -> bool:
+134 -39
View File
@@ -1472,11 +1472,18 @@ class SubscribeChain(ChainBase):
"""从订阅行恢复不依赖远端请求的最小音乐目标,保留专辑完成判断所需字段。""" """从订阅行恢复不依赖远端请求的最小音乐目标,保留专辑完成判断所需字段。"""
year_text = str(subscribe.year or "")[:4] year_text = str(subscribe.year or "")[:4]
music_type = getattr(subscribe, "music_type", None) music_type = getattr(subscribe, "music_type", None)
# 音乐订阅的 description 由标准 MusicInfo.overview 生成,首段固定为艺术家。
artist_text = str(getattr(subscribe, "description", None) or "") \
.split(" · ", maxsplit=1)[0].strip()
artists = [
artist.strip() for artist in artist_text.split(" / ") if artist.strip()
]
return MusicInfo( return MusicInfo(
source=subscribe.media_source, source=subscribe.media_source,
media_id=str(subscribe.media_id) if subscribe.media_id is not None else None, media_id=str(subscribe.media_id) if subscribe.media_id is not None else None,
music_type=music_type, music_type=music_type,
title=subscribe.name, title=subscribe.name,
artists=artists,
album=subscribe.name if music_type == MUSIC_ENTITY_ALBUM else None, album=subscribe.name if music_type == MUSIC_ENTITY_ALBUM else None,
year=int(year_text) if year_text.isdigit() else None, year=int(year_text) if year_text.isdigit() else None,
total_tracks=getattr(subscribe, "total_tracks", None) total_tracks=getattr(subscribe, "total_tracks", None)
@@ -1513,16 +1520,105 @@ class SubscribeChain(ChainBase):
return True return True
return any(context.confirmed_full_coverage for context in downloads) return any(context.confirmed_full_coverage for context in downloads)
def _search_music_subscribe(self, subscribe: Subscribe) -> None: def _prepare_music_subscribe(
"""复用站点标题搜索、订阅过滤和批量下载完成单个音乐订阅。""" self,
subscribe: Subscribe,
) -> Optional[Tuple[MusicInfo, MetaMusic]]:
"""识别音乐订阅目标、同步实体快照,并在搜索前处理已完整入库的目标。"""
mediainfo = self._recognize_music_subscribe(subscribe) mediainfo = self._recognize_music_subscribe(subscribe)
if not mediainfo: if not mediainfo:
logger.warning( logger.warning(
f"未识别到音乐订阅目标:{subscribe.name}" f"未识别到音乐订阅目标:{subscribe.name}"
f"媒体源:{subscribe.media_source},媒体ID{subscribe.media_id}" f"媒体源:{subscribe.media_source},媒体ID{subscribe.media_id}"
) )
return return None
self._sync_music_subscribe_target(subscribe, mediainfo) self._sync_music_subscribe_target(subscribe, mediainfo)
meta = MusicChain.to_meta(mediainfo)
exists, _ = self.check_and_handle_existing_media(
subscribe=subscribe,
meta=meta,
mediainfo=mediainfo,
mediakey=_subscribe_media_key(subscribe),
)
if exists:
return None
return mediainfo, meta
def _filter_music_subscribe_contexts(
self,
subscribe: Subscribe,
mediainfo: MusicInfo,
contexts: List[Context],
) -> List[Context]:
"""按站点、音乐实体、订阅参数和优先级规则筛选并绑定下载上下文。"""
sites = self.get_sub_sites(subscribe)
rule_groups = subscribe.filter_groups \
or SystemConfigOper().get(SystemConfigKey.SubscribeFilterRuleGroups) or []
torrent_helper = TorrentHelper()
matched: List[Context] = []
for source_context in contexts or []:
torrent = source_context.torrent_info
if not torrent or torrent.category not in (MediaType.MUSIC, MediaType.MUSIC.value):
continue
if sites and torrent.site not in sites:
continue
if not MusicChain.matches_site_resource(mediainfo, torrent.title):
continue
if not torrent_helper.filter_torrent(torrent, self.get_params(subscribe)):
continue
filtered = self.filter_torrents(
rule_groups=rule_groups,
torrent_list=[torrent],
mediainfo=mediainfo,
)
if filtered is not None and not filtered:
continue
context = copy.copy(source_context)
meta = MusicChain.to_meta(mediainfo)
meta.org_string = torrent.title
context.meta_info = meta
context.media_info = mediainfo
context.match_source = mediainfo.source or "title"
context.candidate_recognized = False
context.media_info_is_target = True
if subscribe.media_category:
context.media_info.category = subscribe.media_category
matched.append(context)
return matched
def _download_music_subscribe(
self,
subscribe: Subscribe,
mediainfo: MusicInfo,
contexts: List[Context],
) -> None:
"""批量择优下载音乐候选,并按单曲或整专完成语义推进订阅。"""
if not contexts:
return
downloads, _ = DownloadChain().batch_download(
contexts=contexts,
username=subscribe.username,
save_path=subscribe.save_path,
downloader=subscribe.downloader,
source=self.get_subscribe_source_keyword(subscribe),
custom_words=subscribe.custom_words,
)
current_subscribe = SubscribeOper().get(subscribe.id)
if current_subscribe:
self.finish_subscribe_or_not(
subscribe=current_subscribe,
meta=MusicChain.to_meta(mediainfo),
mediainfo=mediainfo,
downloads=downloads,
)
def _search_music_subscribe(self, subscribe: Subscribe) -> None:
"""复用站点标题搜索、订阅过滤和批量下载完成单个音乐订阅。"""
target = self._prepare_music_subscribe(subscribe)
if not target:
return
mediainfo, _ = target
sites = self.get_sub_sites(subscribe) sites = self.get_sub_sites(subscribe)
rule_groups = subscribe.filter_groups \ rule_groups = subscribe.filter_groups \
@@ -1540,17 +1636,11 @@ class SubscribeChain(ChainBase):
mtype=MediaType.MUSIC, mtype=MediaType.MUSIC,
rule_groups=rule_groups, rule_groups=rule_groups,
) )
contexts = [ contexts = self._filter_music_subscribe_contexts(
context subscribe=subscribe,
for context in contexts mediainfo=mediainfo,
if context.torrent_info contexts=contexts,
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),
) )
]
if contexts: if contexts:
break break
@@ -1558,33 +1648,27 @@ class SubscribeChain(ChainBase):
logger.warning(f"音乐订阅 {subscribe.keyword or subscribe.name} 未搜索到符合条件的资源") logger.warning(f"音乐订阅 {subscribe.keyword or subscribe.name} 未搜索到符合条件的资源")
return return
for context in contexts: self._download_music_subscribe(subscribe, mediainfo, contexts)
meta = MusicChain.to_meta(mediainfo)
meta.org_string = context.torrent_info.title
context.meta_info = meta
context.media_info = mediainfo
context.match_source = mediainfo.source or "title"
context.candidate_recognized = False
context.media_info_is_target = True
if subscribe.media_category:
context.media_info.category = subscribe.media_category
downloads, _ = DownloadChain().batch_download( def _match_music_subscribe(
contexts=contexts, self,
username=subscribe.username, subscribe: Subscribe,
save_path=subscribe.save_path, contexts: List[Context],
downloader=subscribe.downloader, ) -> None:
source=self.get_subscribe_source_keyword(subscribe), """直接匹配本轮 RSS 缓存中的音乐资源,避免再次调用站点搜索接口。"""
custom_words=subscribe.custom_words, target = self._prepare_music_subscribe(subscribe)
) if not target:
current_subscribe = SubscribeOper().get(subscribe.id) return
if current_subscribe: mediainfo, _ = target
self.finish_subscribe_or_not( matched = self._filter_music_subscribe_contexts(
subscribe=current_subscribe, subscribe=subscribe,
meta=MusicChain.to_meta(mediainfo),
mediainfo=mediainfo, mediainfo=mediainfo,
downloads=downloads, contexts=contexts,
) )
if not matched:
logger.info(f"音乐订阅 {subscribe.name} 未匹配到符合条件的 RSS 资源")
return
self._download_music_subscribe(subscribe, mediainfo, matched)
def search( def search(
self, self,
@@ -2046,6 +2130,13 @@ class SubscribeChain(ChainBase):
for context in contexts: for context in contexts:
if global_vars.is_system_stopped: if global_vars.is_system_stopped:
break break
if context.torrent_info and getattr(context.torrent_info, "category", None) in (
MediaType.MUSIC,
MediaType.MUSIC.value,
):
# 音乐 RSS 使用订阅目标做实体匹配,不应进入影视识别并累计失败次数。
processed_torrents[domain].append(context)
continue
# 如果种子未识别且失败次数未超过3次,尝试识别 # 如果种子未识别且失败次数未超过3次,尝试识别
if ( if (
not context.media_info not context.media_info
@@ -2110,8 +2201,12 @@ class SubscribeChain(ChainBase):
) )
logger.info(f'开始匹配订阅,标题:{subscribe.name} ...') logger.info(f'开始匹配订阅,标题:{subscribe.name} ...')
if subscribe.type == MediaType.MUSIC.value: if subscribe.type == MediaType.MUSIC.value:
# 音乐不参与影视预识别缓存,直接复用音乐订阅搜索链处理。 music_contexts = [
self._search_music_subscribe(subscribe) context
for contexts in processed_torrents.values()
for context in contexts
]
self._match_music_subscribe(subscribe, music_contexts)
continue continue
mediakey = _subscribe_media_key(subscribe) mediakey = _subscribe_media_key(subscribe)
try: try:
+170 -1
View File
@@ -1,10 +1,179 @@
from typing import Optional import re
from collections.abc import Iterable, Mapping
from typing import Any, Optional
from app import schemas
from app.core.context import MUSIC_ENTITY_ALBUM, MusicInfo
from app.helper.service import ServiceBaseHelper from app.helper.service import ServiceBaseHelper
from app.schemas import MediaServerConf, ServiceInfo from app.schemas import MediaServerConf, ServiceInfo
from app.schemas.types import SystemConfigKey, ModuleType from app.schemas.types import SystemConfigKey, ModuleType
class MusicMediaServerHelper:
"""统一音乐媒体库条目的字段转换、精确匹配和整专完整性判断。"""
_name_pattern = re.compile(r"[\W_]+", re.UNICODE)
@classmethod
def normalize_name(cls, value: Optional[str]) -> str:
"""忽略大小写、空白和标点,生成用于音乐名称精确比较的稳定文本。"""
return cls._name_pattern.sub("", str(value or "").casefold())
@classmethod
def same_name(cls, left: Optional[str], right: Optional[str]) -> bool:
"""判断两个非空音乐名称在规范化后是否完全一致。"""
normalized_left = cls.normalize_name(left)
normalized_right = cls.normalize_name(right)
return bool(normalized_left) and normalized_left == normalized_right
@staticmethod
def _first_value(data: Mapping[str, Any], *keys: str) -> Any:
"""按候选键顺序返回第一个非空字段,兼容不同媒体服务器命名。"""
for key in keys:
value = data.get(key)
if value not in (None, "", []):
return value
return None
@classmethod
def _extract_names(cls, value: Any) -> list[str]:
"""从字符串、对象列表或名称列表中提取非空名称。"""
if isinstance(value, str):
return [value] if value.strip() else []
if isinstance(value, Mapping):
name = cls._first_value(value, "Name", "name", "Title", "title")
return [str(name)] if name and str(name).strip() else []
if not isinstance(value, Iterable) or isinstance(value, bytes):
return []
names: list[str] = []
for item in value:
if isinstance(item, Mapping):
name = cls._first_value(item, "Name", "name", "Title", "title")
else:
name = item
if name and str(name).strip():
names.append(str(name))
return names
@classmethod
def build_note(cls, item: Mapping[str, Any]) -> dict[str, Any]:
"""把 Emby 系和 NAS 搜索结果中的音乐字段转换为统一备注结构。"""
artists = cls._extract_names(
cls._first_value(item, "Artists", "artists", "ArtistItems", "artist_items")
)
album_artists = cls._extract_names(
cls._first_value(item, "AlbumArtists", "album_artists")
)
artist = cls._first_value(
item,
"AlbumArtist",
"album_artist",
"Artist",
"artist",
"artist_name",
"singer",
)
explicit_artists = cls._extract_names(artist)
if explicit_artists:
artist = explicit_artists[0]
if not artist:
artist = next(iter(album_artists or artists), None)
item_type = cls.normalize_name(
cls._first_value(item, "Type", "type", "item_type")
)
album = cls._first_value(item, "Album", "album", "album_name")
if not album and item_type in {"musicalbum", "album"}:
album = cls._first_value(item, "Name", "name", "Title", "title")
song_count = cls._first_value(
item,
"ChildCount",
"child_count",
"SongCount",
"songCount",
"song_count",
"TrackCount",
"trackCount",
"track_count",
"LeafCount",
"leafCount",
)
return {
"artist": str(artist) if artist is not None else None,
"artists": artists or album_artists,
"album": str(album) if album is not None else None,
"song_count": song_count,
}
@staticmethod
def search_params(mediainfo: MusicInfo) -> dict[str, Optional[str]]:
"""按单曲或专辑实体构造媒体服务器音乐搜索参数。"""
is_album = getattr(mediainfo, "music_type", None) == MUSIC_ENTITY_ALBUM
artists = getattr(mediainfo, "artists", None) or []
artist = (
getattr(mediainfo, "album_artist", None)
or next(iter(artists), None)
or getattr(mediainfo, "artist", None)
)
title = getattr(mediainfo, "title", None)
album = getattr(mediainfo, "album", None) or title
return {
"title": None if is_album else title,
"artist": artist,
"album": album if is_album else None,
}
@classmethod
def item_matches(cls, mediainfo: MusicInfo, item: schemas.MediaServerItem) -> bool:
"""校验媒体库条目是否精确对应单曲,或完整覆盖目标专辑。"""
note = item.note if isinstance(item.note, Mapping) else {}
is_album = getattr(mediainfo, "music_type", None) == MUSIC_ENTITY_ALBUM
target_title = getattr(mediainfo, "title", None)
actual_title = item.title
if is_album:
target_title = getattr(mediainfo, "album", None) or target_title
actual_title = note.get("album") or actual_title
if not cls.same_name(actual_title, target_title):
return False
target_artists = [
getattr(mediainfo, "artist", None),
getattr(mediainfo, "album_artist", None),
*(getattr(mediainfo, "artists", None) or []),
]
target_artists = [artist for artist in target_artists if artist]
actual_artists = [note.get("artist"), *cls._extract_names(note.get("artists"))]
actual_artists = [artist for artist in actual_artists if artist]
if target_artists and not any(
cls.same_name(actual, target)
for actual in actual_artists
for target in target_artists
):
return False
if not is_album:
return True
try:
expected_tracks = int(getattr(mediainfo, "total_tracks", None) or 0)
actual_tracks = int(note.get("song_count") or 0)
except (TypeError, ValueError):
return False
return expected_tracks > 0 and actual_tracks >= expected_tracks
@classmethod
def find_match(
cls,
mediainfo: MusicInfo,
items: Optional[Iterable[schemas.MediaServerItem]],
) -> Optional[schemas.MediaServerItem]:
"""返回首个满足单曲精确匹配或整专完整性要求的媒体库条目。"""
return next(
(item for item in items or [] if item and cls.item_matches(mediainfo, item)),
None,
)
class MediaServerHelper(ServiceBaseHelper[MediaServerConf]): class MediaServerHelper(ServiceBaseHelper[MediaServerConf]):
""" """
媒体服务器帮助类 媒体服务器帮助类
+5 -7
View File
@@ -3,6 +3,7 @@ from typing import Any, Dict, Generator, List, Optional, Tuple, Union
from app import schemas from app import schemas
from app.core.context import MediaInfo from app.core.context import MediaInfo
from app.core.event import eventmanager from app.core.event import eventmanager
from app.helper.mediaserver import MusicMediaServerHelper
from app.log import logger from app.log import logger
from app.modules import _MediaServerBase, _ModuleBase from app.modules import _MediaServerBase, _ModuleBase
from app.modules.emby.emby import Emby from app.modules.emby.emby import Emby
@@ -152,17 +153,14 @@ class EmbyModule(_ModuleBase, _MediaServerBase[Emby]):
if not s: if not s:
continue continue
if mediainfo.type == MediaType.MUSIC: if mediainfo.type == MediaType.MUSIC:
matches = s.get_music( matches = s.get_music(**MusicMediaServerHelper.search_params(mediainfo))
title=getattr(mediainfo, "title", None), match = MusicMediaServerHelper.find_match(mediainfo, matches)
artist=getattr(mediainfo, "artist", None), if match:
album=getattr(mediainfo, "album", None),
)
if matches:
return schemas.ExistMediaInfo( return schemas.ExistMediaInfo(
type=MediaType.MUSIC, type=MediaType.MUSIC,
server_type="emby", server_type="emby",
server=name, server=name,
itemid=matches[0].item_id, itemid=match.item_id,
) )
continue continue
if mediainfo.type == MediaType.MOVIE: if mediainfo.type == MediaType.MOVIE:
+8 -1
View File
@@ -9,6 +9,7 @@ from requests import Response
from app import schemas from app import schemas
from app.core.config import settings from app.core.config import settings
from app.helper.mediaserver import MusicMediaServerHelper
from app.log import logger from app.log import logger
from app.schemas import MediaServerItem from app.schemas import MediaServerItem
from app.schemas.types import MediaType from app.schemas.types import MediaType
@@ -434,6 +435,8 @@ class Emby:
url = f"{self._host}emby/Users/{self.user}/Items" url = f"{self._host}emby/Users/{self.user}/Items"
params = { params = {
"IncludeItemTypes": "MusicAlbum,Audio", "IncludeItemTypes": "MusicAlbum,Audio",
"Fields": "Album,AlbumArtist,AlbumArtists,Artists,ArtistItems,ChildCount,"
"ProviderIds,OriginalTitle,ProductionYear,Path,ParentId",
"SearchTerm": query, "SearchTerm": query,
"Recursive": "true", "Recursive": "true",
"Limit": 20, "Limit": 20,
@@ -747,6 +750,8 @@ class Emby:
imdbid=item.get("ProviderIds", {}).get("Imdb"), imdbid=item.get("ProviderIds", {}).get("Imdb"),
tvdbid=item.get("ProviderIds", {}).get("Tvdb"), tvdbid=item.get("ProviderIds", {}).get("Tvdb"),
path=item.get("Path"), path=item.get("Path"),
note=MusicMediaServerHelper.build_note(item)
if item.get("Type") in {"MusicAlbum", "Audio"} else None,
user_state=user_state user_state=user_state
) )
@@ -819,7 +824,9 @@ class Emby:
params = { params = {
"ParentId": parent, "ParentId": parent,
"api_key": self._apikey, "api_key": self._apikey,
"Fields": "ProviderIds,OriginalTitle,ProductionYear,Path,UserDataPlayCount,UserDataLastPlayedDate,ParentId" "Fields": "Album,AlbumArtist,AlbumArtists,Artists,ArtistItems,ChildCount,"
"ProviderIds,OriginalTitle,ProductionYear,Path,UserDataPlayCount,"
"UserDataLastPlayedDate,ParentId"
} }
if limit is not None and limit != -1: if limit is not None and limit != -1:
params.update({ params.update({
+5 -7
View File
@@ -3,6 +3,7 @@ from typing import Any, Dict, Generator, List, Optional, Tuple, Union
from app import schemas from app import schemas
from app.core.context import MediaInfo from app.core.context import MediaInfo
from app.core.event import eventmanager from app.core.event import eventmanager
from app.helper.mediaserver import MusicMediaServerHelper
from app.log import logger from app.log import logger
from app.modules import _MediaServerBase, _ModuleBase from app.modules import _MediaServerBase, _ModuleBase
from app.modules.jellyfin.jellyfin import Jellyfin from app.modules.jellyfin.jellyfin import Jellyfin
@@ -153,17 +154,14 @@ class JellyfinModule(_ModuleBase, _MediaServerBase[Jellyfin]):
if not s: if not s:
continue continue
if mediainfo.type == MediaType.MUSIC: if mediainfo.type == MediaType.MUSIC:
matches = s.get_music( matches = s.get_music(**MusicMediaServerHelper.search_params(mediainfo))
title=getattr(mediainfo, "title", None), match = MusicMediaServerHelper.find_match(mediainfo, matches)
artist=getattr(mediainfo, "artist", None), if match:
album=getattr(mediainfo, "album", None),
)
if matches:
return schemas.ExistMediaInfo( return schemas.ExistMediaInfo(
type=MediaType.MUSIC, type=MediaType.MUSIC,
server_type="jellyfin", server_type="jellyfin",
server=name, server=name,
itemid=matches[0].item_id, itemid=match.item_id,
) )
continue continue
if mediainfo.type == MediaType.MOVIE: if mediainfo.type == MediaType.MOVIE:
+8 -1
View File
@@ -7,6 +7,7 @@ from requests import Response
from app import schemas from app import schemas
from app.core.config import settings from app.core.config import settings
from app.helper.mediaserver import MusicMediaServerHelper
from app.log import logger from app.log import logger
from app.schemas import MediaType from app.schemas import MediaType
from app.utils.http import RequestUtils from app.utils.http import RequestUtils
@@ -490,6 +491,8 @@ class Jellyfin:
url = f"{self._host}Users/{self.user}/Items" url = f"{self._host}Users/{self.user}/Items"
params = { params = {
"IncludeItemTypes": "MusicAlbum,Audio", "IncludeItemTypes": "MusicAlbum,Audio",
"Fields": "Album,AlbumArtist,AlbumArtists,Artists,ArtistItems,ChildCount,"
"ProviderIds,OriginalTitle,ProductionYear,Path,ParentId",
"searchTerm": query, "searchTerm": query,
"Recursive": "true", "Recursive": "true",
"Limit": 20, "Limit": 20,
@@ -904,6 +907,8 @@ class Jellyfin:
imdbid=item.get("ProviderIds", {}).get("Imdb"), imdbid=item.get("ProviderIds", {}).get("Imdb"),
tvdbid=item.get("ProviderIds", {}).get("Tvdb"), tvdbid=item.get("ProviderIds", {}).get("Tvdb"),
path=item.get("Path"), path=item.get("Path"),
note=MusicMediaServerHelper.build_note(item)
if item.get("Type") in {"MusicAlbum", "Audio"} else None,
user_state=user_state user_state=user_state
) )
@@ -977,7 +982,9 @@ class Jellyfin:
params = { params = {
"ParentId": parent, "ParentId": parent,
"api_key": self._apikey, "api_key": self._apikey,
"Fields": "ProviderIds,OriginalTitle,ProductionYear,Path,UserDataPlayCount,UserDataLastPlayedDate,ParentId", "Fields": "Album,AlbumArtist,AlbumArtists,Artists,ArtistItems,ChildCount,"
"ProviderIds,OriginalTitle,ProductionYear,Path,UserDataPlayCount,"
"UserDataLastPlayedDate,ParentId",
} }
if limit is not None and limit != -1: if limit is not None and limit != -1:
params.update({ params.update({
+1 -1
View File
@@ -180,7 +180,7 @@ class ListenBrainzModule(_ModuleBase):
proxies=settings.PROXY, proxies=settings.PROXY,
timeout=20, timeout=20,
).get_res(f"{cls._base_url}{path}", params=params) ).get_res(f"{cls._base_url}{path}", params=params)
if not response: if response is None:
return None return None
try: try:
if response.status_code == 204: if response.status_code == 204:
+3 -2
View File
@@ -775,13 +775,14 @@ class MusicBrainzModule(_ModuleBase):
proxies=settings.PROXY, proxies=settings.PROXY,
timeout=20, timeout=20,
).get_res(f"{cls._base_url}{path}", params=params) ).get_res(f"{cls._base_url}{path}", params=params)
if not response: if response is None:
return None return None
try: try:
if response.status_code == 404: if response.status_code == 404:
# 单曲与专辑共用同一套 ID 入口,404 属于正常的探测结果 # 单曲与专辑共用同一套 ID 入口,404 属于正常的探测结果
logger.debug(f"MusicBrainz 资源不存在:{path}") logger.debug(f"MusicBrainz 资源不存在:{path}")
return None # 使用空对象区分稳定的不存在与瞬时请求失败,使有界缓存能够复用探测结果。
return {}
if response.status_code != 200: if response.status_code != 200:
logger.warning( logger.warning(
f"MusicBrainz 请求失败:{response.status_code} {response.text[:200]}" f"MusicBrainz 请求失败:{response.status_code} {response.text[:200]}"
+5 -24
View File
@@ -3,8 +3,9 @@
from typing import Any, Dict, Generator, List, Optional, Tuple, Union from typing import Any, Dict, Generator, List, Optional, Tuple, Union
from app import schemas from app import schemas
from app.core.context import MUSIC_ENTITY_ALBUM, MediaInfo from app.core.context import MediaInfo
from app.core.event import eventmanager from app.core.event import eventmanager
from app.helper.mediaserver import MusicMediaServerHelper
from app.log import logger from app.log import logger
from app.modules import _MediaServerBase, _ModuleBase from app.modules import _MediaServerBase, _ModuleBase
from app.modules.navidrome.navidrome import Navidrome from app.modules.navidrome.navidrome import Navidrome
@@ -100,18 +101,6 @@ class NavidromeModule(_ModuleBase, _MediaServerBase[Navidrome]):
return credentials return credentials
return None 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( def media_exists(
self, mediainfo: MediaInfo, itemid: Optional[str] = None, server: Optional[str] = None self, mediainfo: MediaInfo, itemid: Optional[str] = None, server: Optional[str] = None
) -> Optional[schemas.ExistMediaInfo]: ) -> Optional[schemas.ExistMediaInfo]:
@@ -127,23 +116,15 @@ class NavidromeModule(_ModuleBase, _MediaServerBase[Navidrome]):
if not service: if not service:
continue continue
item = service.get_iteminfo(str(itemid)) if itemid else None item = service.get_iteminfo(str(itemid)) if itemid else None
if item and self._has_complete_album(mediainfo, item): if item and MusicMediaServerHelper.item_matches(mediainfo, item):
return schemas.ExistMediaInfo( return schemas.ExistMediaInfo(
type=MediaType.MUSIC, type=MediaType.MUSIC,
server_type="navidrome", server_type="navidrome",
server=name, server=name,
itemid=itemid, itemid=itemid,
) )
is_album = getattr(mediainfo, "music_type", None) == MUSIC_ENTITY_ALBUM matches = service.search_music(**MusicMediaServerHelper.search_params(mediainfo))
matches = service.search_music( match = MusicMediaServerHelper.find_match(mediainfo, matches)
title=None if is_album else getattr(mediainfo, "title", None),
artist=getattr(mediainfo, "artist", None),
album=getattr(mediainfo, "title", None) if is_album else None,
)
match = next(
(candidate for candidate in matches if self._has_complete_album(mediainfo, candidate)),
None,
)
if match: if match:
return schemas.ExistMediaInfo( return schemas.ExistMediaInfo(
type=MediaType.MUSIC, type=MediaType.MUSIC,
+5 -7
View File
@@ -3,6 +3,7 @@ from typing import Optional, Tuple, Union, Any, List, Generator, Dict
from app import schemas from app import schemas
from app.core.context import MediaInfo from app.core.context import MediaInfo
from app.core.event import eventmanager from app.core.event import eventmanager
from app.helper.mediaserver import MusicMediaServerHelper
from app.log import logger from app.log import logger
from app.modules import _ModuleBase, _MediaServerBase from app.modules import _ModuleBase, _MediaServerBase
from app.modules.plex.plex import Plex from app.modules.plex.plex import Plex
@@ -162,17 +163,14 @@ class PlexModule(_ModuleBase, _MediaServerBase[Plex]):
if not s: if not s:
continue continue
if mediainfo.type == MediaType.MUSIC: if mediainfo.type == MediaType.MUSIC:
matches = s.get_music( matches = s.get_music(**MusicMediaServerHelper.search_params(mediainfo))
title=getattr(mediainfo, "title", None), match = MusicMediaServerHelper.find_match(mediainfo, matches)
artist=getattr(mediainfo, "artist", None), if match:
album=getattr(mediainfo, "album", None),
)
if matches:
return schemas.ExistMediaInfo( return schemas.ExistMediaInfo(
type=MediaType.MUSIC, type=MediaType.MUSIC,
server_type="plex", server_type="plex",
server=name, server=name,
itemid=matches[0].item_id, itemid=match.item_id,
) )
continue continue
if mediainfo.type == MediaType.MOVIE: if mediainfo.type == MediaType.MOVIE:
+14 -2
View File
@@ -255,7 +255,7 @@ class Plex:
"""按歌曲、艺术家或专辑名称查询 Plex 音乐条目。""" """按歌曲、艺术家或专辑名称查询 Plex 音乐条目。"""
if not self._plex: if not self._plex:
return [] return []
query = " ".join(filter(None, [title, artist, album])).strip() query = album or title or artist
if not query: if not query:
return [] return []
results: List[schemas.MediaServerItem] = [] results: List[schemas.MediaServerItem] = []
@@ -264,15 +264,27 @@ class Plex:
if library.type not in ("artist", "music"): if library.type not in ("artist", "music"):
continue continue
for item in library.search(title=query): for item in library.search(title=query):
item_type = getattr(item, "type", None)
if item_type == "track":
item_artist = getattr(item, "grandparentTitle", None)
item_album = getattr(item, "parentTitle", None)
else:
item_artist = getattr(item, "parentTitle", None)
item_album = getattr(item, "title", None) if item_type == "album" else None
results.append(schemas.MediaServerItem( results.append(schemas.MediaServerItem(
server="plex", server="plex",
library=library.key, library=library.key,
item_id=getattr(item, "ratingKey", None) or getattr(item, "key", None), item_id=getattr(item, "ratingKey", None) or getattr(item, "key", None),
item_type=MediaType.MUSIC.value, item_type=item_type or MediaType.MUSIC.value,
title=getattr(item, "title", None), title=getattr(item, "title", None),
original_title=getattr(item, "title", None), original_title=getattr(item, "title", None),
year=getattr(item, "year", None), year=getattr(item, "year", None),
path=getattr(item, "file", None), path=getattr(item, "file", None),
note={
"artist": item_artist,
"album": item_album,
"song_count": getattr(item, "leafCount", None),
},
)) ))
except Exception as e: except Exception as e:
logger.debug(f"查询Plex音乐出错:{e}") logger.debug(f"查询Plex音乐出错:{e}")
+5 -5
View File
@@ -3,6 +3,7 @@ from typing import Any, Generator, List, Optional, Tuple, Union
from app import schemas from app import schemas
from app.core.context import MediaInfo from app.core.context import MediaInfo
from app.core.event import eventmanager from app.core.event import eventmanager
from app.helper.mediaserver import MusicMediaServerHelper
from app.log import logger from app.log import logger
from app.modules import _MediaServerBase, _ModuleBase from app.modules import _MediaServerBase, _ModuleBase
from app.modules.trimemedia.trimemedia import TrimeMedia from app.modules.trimemedia.trimemedia import TrimeMedia
@@ -182,16 +183,15 @@ class TrimeMediaModule(_ModuleBase, _MediaServerBase[TrimeMedia]):
continue continue
if mediainfo.type == MediaType.MUSIC: if mediainfo.type == MediaType.MUSIC:
matches = getattr(s, "get_music", lambda **_: [])( matches = getattr(s, "get_music", lambda **_: [])(
title=getattr(mediainfo, "title", None), **MusicMediaServerHelper.search_params(mediainfo)
artist=getattr(mediainfo, "artist", None),
album=getattr(mediainfo, "album", None),
) )
if matches: match = MusicMediaServerHelper.find_match(mediainfo, matches)
if match:
return schemas.ExistMediaInfo( return schemas.ExistMediaInfo(
type=MediaType.MUSIC, type=MediaType.MUSIC,
server_type="trimemedia", server_type="trimemedia",
server=name, server=name,
itemid=matches[0].item_id, itemid=match.item_id,
) )
continue continue
if mediainfo.type == MediaType.MOVIE: if mediainfo.type == MediaType.MOVIE:
+5 -5
View File
@@ -3,6 +3,7 @@ from typing import Any, Generator, List, Optional, Tuple, Union
from app import schemas from app import schemas
from app.core.context import MediaInfo from app.core.context import MediaInfo
from app.core.event import eventmanager from app.core.event import eventmanager
from app.helper.mediaserver import MusicMediaServerHelper
from app.log import logger from app.log import logger
from app.modules import _MediaServerBase, _ModuleBase from app.modules import _MediaServerBase, _ModuleBase
from app.modules.ugreen.ugreen import Ugreen from app.modules.ugreen.ugreen import Ugreen
@@ -164,16 +165,15 @@ class UgreenModule(_ModuleBase, _MediaServerBase[Ugreen]):
continue continue
if mediainfo.type == MediaType.MUSIC: if mediainfo.type == MediaType.MUSIC:
matches = getattr(s, "get_music", lambda **_: [])( matches = getattr(s, "get_music", lambda **_: [])(
title=getattr(mediainfo, "title", None), **MusicMediaServerHelper.search_params(mediainfo)
artist=getattr(mediainfo, "artist", None),
album=getattr(mediainfo, "album", None),
) )
if matches: match = MusicMediaServerHelper.find_match(mediainfo, matches)
if match:
return schemas.ExistMediaInfo( return schemas.ExistMediaInfo(
type=MediaType.MUSIC, type=MediaType.MUSIC,
server_type="ugreen", server_type="ugreen",
server=name, server=name,
itemid=matches[0].item_id, itemid=match.item_id,
) )
continue continue
if mediainfo.type == MediaType.MOVIE: if mediainfo.type == MediaType.MOVIE:
+3 -1
View File
@@ -7,6 +7,7 @@ from urllib.parse import parse_qs, urlparse
from app import schemas from app import schemas
from app.db.systemconfig_oper import SystemConfigOper from app.db.systemconfig_oper import SystemConfigOper
from app.helper.mediaserver import MusicMediaServerHelper
from app.log import logger from app.log import logger
from app.modules.ugreen.api import Api from app.modules.ugreen.api import Api
from app.schemas import MediaType from app.schemas import MediaType
@@ -667,7 +668,7 @@ class Ugreen:
"""按歌曲、艺术家或专辑名称查询绿联影视音乐条目。""" """按歌曲、艺术家或专辑名称查询绿联影视音乐条目。"""
if not self.is_authenticated() or not self._api: if not self.is_authenticated() or not self._api:
return [] return []
query = " ".join(filter(None, [album, title, artist])).strip() query = album or title or artist
if not query: if not query:
return [] return []
@@ -682,6 +683,7 @@ class Ugreen:
for info in self.__extract_video_info_list(data.get(bucket)): for info in self.__extract_video_info_list(data.get(bucket)):
media_item = self.__build_media_server_item(info) media_item = self.__build_media_server_item(info)
if media_item: if media_item:
media_item.note = MusicMediaServerHelper.build_note(info)
results.append(media_item) results.append(media_item)
return results return results
+5 -5
View File
@@ -3,6 +3,7 @@ from typing import Any, Generator, List, Optional, Tuple, Union
from app import schemas from app import schemas
from app.core.context import MediaInfo from app.core.context import MediaInfo
from app.core.event import eventmanager from app.core.event import eventmanager
from app.helper.mediaserver import MusicMediaServerHelper
from app.log import logger from app.log import logger
from app.modules import _MediaServerBase, _ModuleBase from app.modules import _MediaServerBase, _ModuleBase
from app.modules.zspace.zspace import ZSpace from app.modules.zspace.zspace import ZSpace
@@ -147,16 +148,15 @@ class ZSpaceModule(_ModuleBase, _MediaServerBase[ZSpace]):
continue continue
if mediainfo.type == MediaType.MUSIC: if mediainfo.type == MediaType.MUSIC:
matches = getattr(s, "get_music", lambda **_: [])( matches = getattr(s, "get_music", lambda **_: [])(
title=getattr(mediainfo, "title", None), **MusicMediaServerHelper.search_params(mediainfo)
artist=getattr(mediainfo, "artist", None),
album=getattr(mediainfo, "album", None),
) )
if matches: match = MusicMediaServerHelper.find_match(mediainfo, matches)
if match:
return schemas.ExistMediaInfo( return schemas.ExistMediaInfo(
type=MediaType.MUSIC, type=MediaType.MUSIC,
server_type="zspace", server_type="zspace",
server=name, server=name,
itemid=matches[0].item_id, itemid=match.item_id,
) )
continue continue
if mediainfo.type == MediaType.MOVIE: if mediainfo.type == MediaType.MOVIE:
+11 -3
View File
@@ -8,6 +8,7 @@ from typing import List, Optional, Union, Dict, Generator, Tuple, Any
from requests import Response from requests import Response
from app import schemas from app import schemas
from app.helper.mediaserver import MusicMediaServerHelper
from app.log import logger from app.log import logger
from app.schemas import MediaServerItem from app.schemas import MediaServerItem
from app.schemas.types import MediaType from app.schemas.types import MediaType
@@ -569,7 +570,8 @@ class ZSpace:
url = f"{self._host}emby/Items" url = f"{self._host}emby/Items"
params = { params = {
"IncludeItemTypes": "MusicAlbum,Audio", "IncludeItemTypes": "MusicAlbum,Audio",
"Fields": "ProviderIds,OriginalTitle,ProductionYear,Path,ParentId", "Fields": "Album,AlbumArtist,AlbumArtists,Artists,ArtistItems,ChildCount,"
"ProviderIds,OriginalTitle,ProductionYear,Path,ParentId",
"SearchTerm": query, "SearchTerm": query,
"Recursive": "true", "Recursive": "true",
"Limit": 20, "Limit": 20,
@@ -830,6 +832,8 @@ class ZSpace:
imdbid=item.get("ProviderIds", {}).get("Imdb"), imdbid=item.get("ProviderIds", {}).get("Imdb"),
tvdbid=item.get("ProviderIds", {}).get("Tvdb"), tvdbid=item.get("ProviderIds", {}).get("Tvdb"),
path=item.get("Path"), path=item.get("Path"),
note=MusicMediaServerHelper.build_note(item)
if item.get("Type") in {"MusicAlbum", "Audio"} else None,
user_state=user_state user_state=user_state
) )
@@ -904,7 +908,8 @@ class ZSpace:
"Recursive": "true", "Recursive": "true",
"StartIndex": current_start_index, "StartIndex": current_start_index,
"Limit": page_size, "Limit": page_size,
"Fields": "ProviderIds,OriginalTitle,ProductionYear,Path," "Fields": "Album,AlbumArtist,AlbumArtists,Artists,ArtistItems,ChildCount,"
"ProviderIds,OriginalTitle,ProductionYear,Path,"
"UserDataPlayCount,UserDataLastPlayedDate,ParentId" "UserDataPlayCount,UserDataLastPlayedDate,ParentId"
} }
try: try:
@@ -921,7 +926,10 @@ class ZSpace:
if sub_item: if sub_item:
yield sub_item yield sub_item
continue continue
if item.get("Type") not in ["Movie", "Series"]: if item.get("Type") not in ["Movie", "Series", "MusicAlbum"]:
continue
if item.get("Type") == "MusicAlbum":
yield self.__format_item_info(item)
continue continue
provider_ids = item.get("ProviderIds") or {} provider_ids = item.get("ProviderIds") or {}
needs_detail = ( needs_detail = (
+4
View File
@@ -159,6 +159,10 @@ class _FakeListenBrainzResponse:
"""返回预设的 JSON 负载。""" """返回预设的 JSON 负载。"""
return self._payload return self._payload
def __bool__(self):
"""模拟 requests.ResponseHTTP 错误状态在布尔判断中为 False。"""
return self.status_code < 400
def close(self): def close(self):
"""无需释放的资源。""" """无需释放的资源。"""
+13
View File
@@ -86,6 +86,19 @@ def test_recording_resource_match_does_not_treat_album_name_as_track_alias():
) is False ) is False
def test_resource_match_requires_artist_when_target_artist_is_known():
"""同名作品很多,目标已知艺术家时资源标题也必须包含该艺术家。"""
recording = MusicInfo(
music_type="recording",
title="晴天",
artists=["周杰伦"],
)
assert MusicChain.matches_site_resource(recording, "周杰伦 - 晴天 FLAC") is True
assert MusicChain.matches_site_resource(recording, "其他艺人 - 晴天 FLAC") is False
assert MusicChain.matches_site_resource(recording, "晴天 FLAC") is False
def test_normalize_candidates_deduplicates_source_identity(): def test_normalize_candidates_deduplicates_source_identity():
"""同一来源和媒体 ID 的音乐候选应只保留一次。""" """同一来源和媒体 ID 的音乐候选应只保留一次。"""
results = MusicChain.normalize_candidates( results = MusicChain.normalize_candidates(
+242
View File
@@ -0,0 +1,242 @@
"""音乐媒体服务器统一匹配契约测试。"""
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from app import schemas
from app.core.context import MusicInfo
from app.helper.mediaserver import MusicMediaServerHelper
from app.modules.emby import EmbyModule
from app.modules.emby.emby import Emby
from app.modules.jellyfin import JellyfinModule
from app.modules.jellyfin.jellyfin import Jellyfin
from app.modules.plex import PlexModule
from app.modules.plex.plex import Plex
from app.modules.trimemedia import TrimeMediaModule
from app.modules.ugreen import UgreenModule
from app.modules.zspace import ZSpaceModule
from app.modules.zspace.zspace import ZSpace
def _recording() -> MusicInfo:
"""构造媒体库匹配使用的单曲目标。"""
return MusicInfo(
source="musicbrainz",
media_id="recording-1",
music_type="recording",
title="晴天",
artists=["周杰伦"],
album="叶惠美",
)
def _album() -> MusicInfo:
"""构造媒体库完整性匹配使用的专辑目标。"""
return MusicInfo(
source="musicbrainz",
media_id="release-group-1",
music_type="album",
title="叶惠美",
album="叶惠美",
artists=["周杰伦"],
total_tracks=11,
)
def test_music_media_server_helper_requires_exact_recording_and_artist():
"""同名异艺人的单曲不得误判为已入库,目标艺术家和曲名均匹配才算存在。"""
wrong_artist = schemas.MediaServerItem(
item_id="wrong",
title="晴天",
note={"artist": "其他艺人", "album": "同名专辑"},
)
exact = schemas.MediaServerItem(
item_id="recording-1",
title="晴天",
note={"artist": "周杰伦", "album": "叶惠美"},
)
assert MusicMediaServerHelper.item_matches(_recording(), wrong_artist) is False
assert MusicMediaServerHelper.item_matches(_recording(), exact) is True
def test_music_media_server_helper_requires_complete_album_track_count():
"""专辑名称和艺术家相同但曲目不足时,仍必须保持订阅等待整专。"""
incomplete = schemas.MediaServerItem(
item_id="album-10",
title="叶惠美",
note={"artist": "周杰伦", "album": "叶惠美", "song_count": 10},
)
complete = schemas.MediaServerItem(
item_id="album-11",
title="叶惠美",
note={"artist": "周杰伦", "album": "叶惠美", "song_count": 11},
)
assert MusicMediaServerHelper.item_matches(_album(), incomplete) is False
assert MusicMediaServerHelper.item_matches(_album(), complete) is True
def test_music_media_server_helper_normalizes_emby_music_fields():
"""Emby 系音乐字段应统一提取艺术家、专辑和整专曲目数。"""
note = MusicMediaServerHelper.build_note({
"Type": "MusicAlbum",
"Name": "叶惠美",
"AlbumArtists": [{"Name": "周杰伦"}],
"ChildCount": 11,
})
assert note == {
"artist": "周杰伦",
"artists": ["周杰伦"],
"album": "叶惠美",
"song_count": 11,
}
@pytest.mark.parametrize(
"formatter",
[
Emby._Emby__format_item_info,
Jellyfin._Jellyfin__format_item_info,
ZSpace._ZSpace__format_item_info,
],
)
def test_emby_family_clients_preserve_music_match_fields(formatter):
"""Emby 系客户端格式化音乐结果时必须保留艺术家、专辑和曲目数。"""
item = formatter({
"Id": "album-1",
"Type": "MusicAlbum",
"Name": "叶惠美",
"AlbumArtists": [{"Name": "周杰伦"}],
"ChildCount": 11,
"ProviderIds": {},
})
assert item is not None
assert item.note == {
"artist": "周杰伦",
"artists": ["周杰伦"],
"album": "叶惠美",
"song_count": 11,
}
def test_plex_music_client_preserves_album_artist_and_track_count():
"""Plex 专辑搜索结果应保留父级艺术家和 leafCount,供整专完整性判断。"""
album_item = SimpleNamespace(
type="album",
title="叶惠美",
parentTitle="周杰伦",
leafCount=11,
ratingKey="album-1",
key="/library/metadata/album-1",
year=2003,
file=None,
)
library = SimpleNamespace(
type="music",
key="music-library",
search=Mock(return_value=[album_item]),
)
client = object.__new__(Plex)
client._plex = SimpleNamespace(
library=SimpleNamespace(sections=lambda: [library])
)
results = client.get_music(album="叶惠美", artist="周杰伦")
assert MusicMediaServerHelper.find_match(_album(), results).item_id == "album-1"
library.search.assert_called_once_with(title="叶惠美")
@pytest.mark.parametrize(
("module_class", "server_type"),
[
(EmbyModule, "emby"),
(JellyfinModule, "jellyfin"),
(PlexModule, "plex"),
(TrimeMediaModule, "trimemedia"),
(UgreenModule, "ugreen"),
(ZSpaceModule, "zspace"),
],
)
def test_music_media_server_modules_ignore_fuzzy_result_and_select_exact_match(
monkeypatch,
module_class,
server_type,
):
"""所有通用媒体服务器模块都必须在模糊搜索后应用统一音乐精确匹配。"""
service = Mock()
service.get_music.return_value = [
schemas.MediaServerItem(
item_id="wrong",
title="晴天 Live",
note={"artist": "周杰伦"},
),
schemas.MediaServerItem(
item_id="recording-1",
title="晴天",
note={"artist": "周杰伦", "album": "叶惠美"},
),
]
module = module_class()
monkeypatch.setattr(module, "get_instances", lambda: {"music": service})
exists = module.media_exists(_recording())
assert exists is not None
assert exists.server_type == server_type
assert exists.itemid == "recording-1"
service.get_music.assert_called_once_with(
title="晴天",
artist="周杰伦",
album=None,
)
@pytest.mark.parametrize(
("module_class", "server_type"),
[
(EmbyModule, "emby"),
(JellyfinModule, "jellyfin"),
(PlexModule, "plex"),
(TrimeMediaModule, "trimemedia"),
(UgreenModule, "ugreen"),
(ZSpaceModule, "zspace"),
],
)
def test_music_media_server_modules_require_complete_album(
monkeypatch,
module_class,
server_type,
):
"""所有通用媒体服务器都只能用完整专辑条目结束整专订阅。"""
service = Mock()
service.get_music.return_value = [
schemas.MediaServerItem(
item_id="album-10",
title="叶惠美",
note={"artist": "周杰伦", "album": "叶惠美", "song_count": 10},
),
schemas.MediaServerItem(
item_id="album-11",
title="叶惠美",
note={"artist": "周杰伦", "album": "叶惠美", "song_count": 11},
),
]
module = module_class()
monkeypatch.setattr(module, "get_instances", lambda: {"music": service})
exists = module.media_exists(_album())
assert exists is not None
assert exists.server_type == server_type
assert exists.itemid == "album-11"
service.get_music.assert_called_once_with(
title=None,
artist="周杰伦",
album="叶惠美",
)
+22
View File
@@ -95,6 +95,28 @@ def test_album_directory_scrape_processes_each_track_and_reuses_cover() -> None:
) )
def test_music_cover_download_uses_bounded_external_response_cache() -> None:
"""重复刮削同一封面时应复用缓存内容,避免再次访问外部图片接口。"""
response = SimpleNamespace(
status_code=200,
headers={"Content-Type": "image/webp"},
content=b"cover",
close=Mock(),
)
request = Mock()
request.get_res.return_value = response
MediaChain._request_music_cover.cache_clear()
with patch("app.chain.media.RequestUtils", return_value=request):
first = MediaChain._download_music_cover("https://example.com/album.webp")
second = MediaChain._download_music_cover("https://example.com/album.webp")
assert first == second == (b"cover", "image/webp")
request.get_res.assert_called_once_with("https://example.com/album.webp")
response.close.assert_called_once()
MediaChain._request_music_cover.cache_clear()
def test_recording_identity_rejects_multi_track_directory_scrape() -> None: def test_recording_identity_rejects_multi_track_directory_scrape() -> None:
"""单曲身份不得覆盖整目录,否则会把同一首歌的标签写到专辑内所有文件。""" """单曲身份不得覆盖整目录,否则会把同一首歌的标签写到专辑内所有文件。"""
chain = _media_chain() chain = _media_chain()
+75 -4
View File
@@ -55,6 +55,7 @@ def _subscribe(**overrides) -> SimpleNamespace:
best_version=0, best_version=0,
state="R", state="R",
note=None, note=None,
description=None,
poster=None, poster=None,
backdrop=None, backdrop=None,
) )
@@ -88,6 +89,8 @@ def test_music_subscribe_reuses_search_download_and_finish_flow():
download_chain.batch_download.return_value = ([context], None) download_chain.batch_download.return_value = ([context], None)
chain = SubscribeChain() chain = SubscribeChain()
chain.finish_subscribe_or_not = Mock() chain.finish_subscribe_or_not = Mock()
chain.check_and_handle_existing_media = Mock(return_value=(False, {}))
chain.filter_torrents = Mock(side_effect=lambda **kwargs: kwargs["torrent_list"])
with patch.object(SubscribeChain, "_recognize_music_subscribe", return_value=target), \ with patch.object(SubscribeChain, "_recognize_music_subscribe", return_value=target), \
patch("app.chain.subscribe.SearchChain", return_value=search_chain), \ patch("app.chain.subscribe.SearchChain", return_value=search_chain), \
@@ -102,10 +105,12 @@ def test_music_subscribe_reuses_search_download_and_finish_flow():
mtype=MediaType.MUSIC, mtype=MediaType.MUSIC,
rule_groups=[], rule_groups=[],
) )
assert context.media_info is target
assert isinstance(context.meta_info, MetaMusic)
assert context.meta_info.org_string == "周杰伦 - 晴天 FLAC"
download_chain.batch_download.assert_called_once() download_chain.batch_download.assert_called_once()
matched_context = download_chain.batch_download.call_args.kwargs["contexts"][0]
assert matched_context is not context
assert matched_context.media_info is target
assert isinstance(matched_context.meta_info, MetaMusic)
assert matched_context.meta_info.org_string == "周杰伦 - 晴天 FLAC"
chain.finish_subscribe_or_not.assert_called_once() chain.finish_subscribe_or_not.assert_called_once()
@@ -121,6 +126,7 @@ def test_music_subscribe_ignores_non_music_category():
search_chain = Mock() search_chain = Mock()
search_chain.search_by_title.return_value = [context] search_chain.search_by_title.return_value = [context]
chain = SubscribeChain() chain = SubscribeChain()
chain.check_and_handle_existing_media = Mock(return_value=(False, {}))
with patch.object(SubscribeChain, "_recognize_music_subscribe", return_value=_music_info()), \ with patch.object(SubscribeChain, "_recognize_music_subscribe", return_value=_music_info()), \
patch("app.chain.subscribe.SearchChain", return_value=search_chain), \ patch("app.chain.subscribe.SearchChain", return_value=search_chain), \
@@ -142,14 +148,77 @@ def test_music_subscribe_ignores_unrelated_music_title():
search_chain = Mock() search_chain = Mock()
search_chain.search_by_title.return_value = [context] search_chain.search_by_title.return_value = [context]
chain = SubscribeChain()
chain.check_and_handle_existing_media = Mock(return_value=(False, {}))
with patch.object(SubscribeChain, "_recognize_music_subscribe", return_value=_music_info()), \ with patch.object(SubscribeChain, "_recognize_music_subscribe", return_value=_music_info()), \
patch("app.chain.subscribe.SearchChain", return_value=search_chain), \ patch("app.chain.subscribe.SearchChain", return_value=search_chain), \
patch("app.chain.subscribe.DownloadChain") as download_chain: patch("app.chain.subscribe.DownloadChain") as download_chain:
SubscribeChain()._search_music_subscribe(subscribe) chain._search_music_subscribe(subscribe)
download_chain.assert_not_called() download_chain.assert_not_called()
def test_music_subscribe_skips_search_when_target_is_already_in_library():
"""单曲或完整专辑已在媒体库时应直接完成查重处理,不得重复搜索和下载。"""
subscribe = _subscribe()
chain = SubscribeChain()
chain.check_and_handle_existing_media = Mock(return_value=(True, {}))
with patch.object(SubscribeChain, "_recognize_music_subscribe", return_value=_music_info()), \
patch("app.chain.subscribe.SearchChain") as search_chain, \
patch("app.chain.subscribe.DownloadChain") as download_chain:
chain._search_music_subscribe(subscribe)
chain.check_and_handle_existing_media.assert_called_once()
search_chain.assert_not_called()
download_chain.assert_not_called()
def test_music_rss_match_reuses_cached_context_without_second_site_search():
"""订阅刷新应直接消费 RSS 音乐上下文,不得为每条音乐订阅再次调用站点搜索。"""
subscribe = _subscribe()
target = _music_info()
source_context = Context(
torrent_info=TorrentInfo(
title="周杰伦 - 晴天 FLAC",
category=MediaType.MUSIC.value,
site=1,
site_name="MusicSite",
)
)
subscribe_oper = Mock()
subscribe_oper.list.return_value = [subscribe]
subscribe_oper.get.return_value = subscribe
download_chain = Mock()
download_chain.batch_download.side_effect = lambda **kwargs: (kwargs["contexts"], None)
chain = SubscribeChain()
chain.check_and_handle_existing_media = Mock(return_value=(False, {}))
chain.get_sub_sites = Mock(return_value=[])
chain.get_params = Mock(return_value={})
chain.filter_torrents = Mock(side_effect=lambda **kwargs: kwargs["torrent_list"])
chain.finish_subscribe_or_not = Mock()
torrent_helper = Mock()
torrent_helper.filter_torrent.return_value = True
with patch.object(SubscribeChain, "_recognize_music_subscribe", return_value=target), \
patch("app.chain.subscribe.SubscribeOper", return_value=subscribe_oper), \
patch("app.chain.subscribe.TorrentHelper", return_value=torrent_helper), \
patch("app.chain.subscribe.DownloadChain", return_value=download_chain), \
patch("app.chain.subscribe.SearchChain") as search_chain, \
patch("app.chain.subscribe.MediaChain") as media_chain:
chain.match({"music.example": [source_context]})
search_chain.assert_not_called()
media_chain.assert_not_called()
download_chain.batch_download.assert_called_once()
matched_context = download_chain.batch_download.call_args.kwargs["contexts"][0]
assert matched_context is not source_context
assert matched_context.media_info is target
assert matched_context.meta_info.org_string == "周杰伦 - 晴天 FLAC"
chain.finish_subscribe_or_not.assert_called_once()
def test_album_subscription_uses_persisted_snapshot_when_remote_detail_is_unavailable(): def test_album_subscription_uses_persisted_snapshot_when_remote_detail_is_unavailable():
"""远端详情短暂失败时应从订阅快照恢复专辑语义,不能按标题猜成第一首单曲。""" """远端详情短暂失败时应从订阅快照恢复专辑语义,不能按标题猜成第一首单曲。"""
subscribe = _subscribe( subscribe = _subscribe(
@@ -157,6 +226,7 @@ def test_album_subscription_uses_persisted_snapshot_when_remote_detail_is_unavai
media_id="release-group-1", media_id="release-group-1",
music_type=MUSIC_ENTITY_ALBUM, music_type=MUSIC_ENTITY_ALBUM,
total_tracks=11, total_tracks=11,
description="周杰伦 · Album · 2003-07-31",
) )
media_chain = Mock() media_chain = Mock()
media_chain.recognize_media.return_value = None media_chain.recognize_media.return_value = None
@@ -167,6 +237,7 @@ def test_album_subscription_uses_persisted_snapshot_when_remote_detail_is_unavai
assert restored.music_type == MUSIC_ENTITY_ALBUM assert restored.music_type == MUSIC_ENTITY_ALBUM
assert restored.album == "叶惠美" assert restored.album == "叶惠美"
assert restored.artists == ["周杰伦"]
assert restored.total_tracks == 11 assert restored.total_tracks == 11
search.assert_not_called() search.assert_not_called()
+11 -6
View File
@@ -458,6 +458,10 @@ class _FakeMusicBrainzResponse:
"""返回预设的 JSON 负载。""" """返回预设的 JSON 负载。"""
return self._payload return self._payload
def __bool__(self):
"""模拟 requests.ResponseHTTP 错误状态在布尔判断中为 False。"""
return self.status_code < 400
def close(self): def close(self):
"""无需释放的资源。""" """无需释放的资源。"""
@@ -487,8 +491,8 @@ def test_request_json_caches_repeated_calls(monkeypatch):
assert network_calls["count"] == 1 assert network_calls["count"] == 1
def test_request_json_does_not_cache_not_found(monkeypatch): def test_request_json_caches_not_found(monkeypatch):
"""404 等空结果不应缓存,以便后续重新探测单曲与专辑入口。""" """MusicBrainz 稳定 404 应进入有界缓存,避免重复探测单曲与专辑入口。"""
import app.modules.musicbrainz as musicbrainz_module import app.modules.musicbrainz as musicbrainz_module
monkeypatch.setattr( monkeypatch.setattr(
@@ -497,14 +501,15 @@ def test_request_json_does_not_cache_not_found(monkeypatch):
network_calls = {"count": 0} network_calls = {"count": 0}
def fake_get_res(_self, url, params=None): def fake_get_res(_self, url, params=None):
"""始终返回 404,用于验证结果会被缓存。""" """始终返回 404,用于验证稳定不存在结果会被缓存。"""
network_calls["count"] += 1 network_calls["count"] += 1
return _FakeMusicBrainzResponse(None, status_code=404) return _FakeMusicBrainzResponse(None, status_code=404)
monkeypatch.setattr(musicbrainz_module.RequestUtils, "get_res", fake_get_res) monkeypatch.setattr(musicbrainz_module.RequestUtils, "get_res", fake_get_res)
MusicBrainzModule._request_json.cache_clear() MusicBrainzModule._request_json.cache_clear()
MusicBrainzModule._request_json("/recording/missing", params={"fmt": "json"}) first = MusicBrainzModule._request_json("/recording/missing", params={"fmt": "json"})
MusicBrainzModule._request_json("/recording/missing", params={"fmt": "json"}) second = MusicBrainzModule._request_json("/recording/missing", params={"fmt": "json"})
assert network_calls["count"] == 2 assert first == second == {}
assert network_calls["count"] == 1