mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-07 00:16:57 +08:00
fix(music): complete subscription lifecycle
This commit is contained in:
+16
-7
@@ -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
@@ -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
@@ -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
@@ -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]):
|
||||||
"""
|
"""
|
||||||
媒体服务器帮助类
|
媒体服务器帮助类
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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]}"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 = (
|
||||||
|
|||||||
@@ -159,6 +159,10 @@ class _FakeListenBrainzResponse:
|
|||||||
"""返回预设的 JSON 负载。"""
|
"""返回预设的 JSON 负载。"""
|
||||||
return self._payload
|
return self._payload
|
||||||
|
|
||||||
|
def __bool__(self):
|
||||||
|
"""模拟 requests.Response:HTTP 错误状态在布尔判断中为 False。"""
|
||||||
|
return self.status_code < 400
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
"""无需释放的资源。"""
|
"""无需释放的资源。"""
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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="叶惠美",
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -458,6 +458,10 @@ class _FakeMusicBrainzResponse:
|
|||||||
"""返回预设的 JSON 负载。"""
|
"""返回预设的 JSON 负载。"""
|
||||||
return self._payload
|
return self._payload
|
||||||
|
|
||||||
|
def __bool__(self):
|
||||||
|
"""模拟 requests.Response:HTTP 错误状态在布尔判断中为 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
|
||||||
|
|||||||
Reference in New Issue
Block a user