fix(music): complete subscription lifecycle

This commit is contained in:
jxxghp
2026-08-10 07:21:00 +08:00
parent 12b054c316
commit 1fc255b8a7
24 changed files with 789 additions and 131 deletions

View File

@@ -3,6 +3,7 @@ from typing import Any, Dict, Generator, List, Optional, Tuple, Union
from app import schemas
from app.core.context import MediaInfo
from app.core.event import eventmanager
from app.helper.mediaserver import MusicMediaServerHelper
from app.log import logger
from app.modules import _MediaServerBase, _ModuleBase
from app.modules.emby.emby import Emby
@@ -152,17 +153,14 @@ class EmbyModule(_ModuleBase, _MediaServerBase[Emby]):
if not s:
continue
if mediainfo.type == MediaType.MUSIC:
matches = s.get_music(
title=getattr(mediainfo, "title", None),
artist=getattr(mediainfo, "artist", None),
album=getattr(mediainfo, "album", None),
)
if matches:
matches = s.get_music(**MusicMediaServerHelper.search_params(mediainfo))
match = MusicMediaServerHelper.find_match(mediainfo, matches)
if match:
return schemas.ExistMediaInfo(
type=MediaType.MUSIC,
server_type="emby",
server=name,
itemid=matches[0].item_id,
itemid=match.item_id,
)
continue
if mediainfo.type == MediaType.MOVIE:

View File

@@ -9,6 +9,7 @@ from requests import Response
from app import schemas
from app.core.config import settings
from app.helper.mediaserver import MusicMediaServerHelper
from app.log import logger
from app.schemas import MediaServerItem
from app.schemas.types import MediaType
@@ -434,6 +435,8 @@ class Emby:
url = f"{self._host}emby/Users/{self.user}/Items"
params = {
"IncludeItemTypes": "MusicAlbum,Audio",
"Fields": "Album,AlbumArtist,AlbumArtists,Artists,ArtistItems,ChildCount,"
"ProviderIds,OriginalTitle,ProductionYear,Path,ParentId",
"SearchTerm": query,
"Recursive": "true",
"Limit": 20,
@@ -747,6 +750,8 @@ class Emby:
imdbid=item.get("ProviderIds", {}).get("Imdb"),
tvdbid=item.get("ProviderIds", {}).get("Tvdb"),
path=item.get("Path"),
note=MusicMediaServerHelper.build_note(item)
if item.get("Type") in {"MusicAlbum", "Audio"} else None,
user_state=user_state
)
@@ -819,7 +824,9 @@ class Emby:
params = {
"ParentId": parent,
"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:
params.update({

View File

@@ -3,6 +3,7 @@ from typing import Any, Dict, Generator, List, Optional, Tuple, Union
from app import schemas
from app.core.context import MediaInfo
from app.core.event import eventmanager
from app.helper.mediaserver import MusicMediaServerHelper
from app.log import logger
from app.modules import _MediaServerBase, _ModuleBase
from app.modules.jellyfin.jellyfin import Jellyfin
@@ -153,17 +154,14 @@ class JellyfinModule(_ModuleBase, _MediaServerBase[Jellyfin]):
if not s:
continue
if mediainfo.type == MediaType.MUSIC:
matches = s.get_music(
title=getattr(mediainfo, "title", None),
artist=getattr(mediainfo, "artist", None),
album=getattr(mediainfo, "album", None),
)
if matches:
matches = s.get_music(**MusicMediaServerHelper.search_params(mediainfo))
match = MusicMediaServerHelper.find_match(mediainfo, matches)
if match:
return schemas.ExistMediaInfo(
type=MediaType.MUSIC,
server_type="jellyfin",
server=name,
itemid=matches[0].item_id,
itemid=match.item_id,
)
continue
if mediainfo.type == MediaType.MOVIE:

View File

@@ -7,6 +7,7 @@ from requests import Response
from app import schemas
from app.core.config import settings
from app.helper.mediaserver import MusicMediaServerHelper
from app.log import logger
from app.schemas import MediaType
from app.utils.http import RequestUtils
@@ -490,6 +491,8 @@ class Jellyfin:
url = f"{self._host}Users/{self.user}/Items"
params = {
"IncludeItemTypes": "MusicAlbum,Audio",
"Fields": "Album,AlbumArtist,AlbumArtists,Artists,ArtistItems,ChildCount,"
"ProviderIds,OriginalTitle,ProductionYear,Path,ParentId",
"searchTerm": query,
"Recursive": "true",
"Limit": 20,
@@ -904,6 +907,8 @@ class Jellyfin:
imdbid=item.get("ProviderIds", {}).get("Imdb"),
tvdbid=item.get("ProviderIds", {}).get("Tvdb"),
path=item.get("Path"),
note=MusicMediaServerHelper.build_note(item)
if item.get("Type") in {"MusicAlbum", "Audio"} else None,
user_state=user_state
)
@@ -977,7 +982,9 @@ class Jellyfin:
params = {
"ParentId": parent,
"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:
params.update({

View File

@@ -180,7 +180,7 @@ class ListenBrainzModule(_ModuleBase):
proxies=settings.PROXY,
timeout=20,
).get_res(f"{cls._base_url}{path}", params=params)
if not response:
if response is None:
return None
try:
if response.status_code == 204:

View File

@@ -775,13 +775,14 @@ class MusicBrainzModule(_ModuleBase):
proxies=settings.PROXY,
timeout=20,
).get_res(f"{cls._base_url}{path}", params=params)
if not response:
if response is None:
return None
try:
if response.status_code == 404:
# 单曲与专辑共用同一套 ID 入口404 属于正常的探测结果
logger.debug(f"MusicBrainz 资源不存在:{path}")
return None
# 使用空对象区分稳定的不存在与瞬时请求失败,使有界缓存能够复用探测结果。
return {}
if response.status_code != 200:
logger.warning(
f"MusicBrainz 请求失败:{response.status_code} {response.text[:200]}"

View File

@@ -3,8 +3,9 @@
from typing import Any, Dict, Generator, List, Optional, Tuple, Union
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.helper.mediaserver import MusicMediaServerHelper
from app.log import logger
from app.modules import _MediaServerBase, _ModuleBase
from app.modules.navidrome.navidrome import Navidrome
@@ -100,18 +101,6 @@ class NavidromeModule(_ModuleBase, _MediaServerBase[Navidrome]):
return credentials
return None
@staticmethod
def _has_complete_album(mediainfo: MediaInfo, item: schemas.MediaServerItem) -> bool:
"""校验 Navidrome 专辑条目的曲目数是否覆盖订阅目标。"""
if getattr(mediainfo, "music_type", None) != MUSIC_ENTITY_ALBUM:
return True
try:
expected_tracks = int(getattr(mediainfo, "total_tracks", None) or 0)
actual_tracks = int((item.note or {}).get("song_count") or 0)
except (AttributeError, TypeError, ValueError):
return False
return expected_tracks > 0 and actual_tracks >= expected_tracks
def media_exists(
self, mediainfo: MediaInfo, itemid: Optional[str] = None, server: Optional[str] = None
) -> Optional[schemas.ExistMediaInfo]:
@@ -127,23 +116,15 @@ class NavidromeModule(_ModuleBase, _MediaServerBase[Navidrome]):
if not service:
continue
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(
type=MediaType.MUSIC,
server_type="navidrome",
server=name,
itemid=itemid,
)
is_album = getattr(mediainfo, "music_type", None) == MUSIC_ENTITY_ALBUM
matches = service.search_music(
title=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,
)
matches = service.search_music(**MusicMediaServerHelper.search_params(mediainfo))
match = MusicMediaServerHelper.find_match(mediainfo, matches)
if match:
return schemas.ExistMediaInfo(
type=MediaType.MUSIC,

View File

@@ -3,6 +3,7 @@ from typing import Optional, Tuple, Union, Any, List, Generator, Dict
from app import schemas
from app.core.context import MediaInfo
from app.core.event import eventmanager
from app.helper.mediaserver import MusicMediaServerHelper
from app.log import logger
from app.modules import _ModuleBase, _MediaServerBase
from app.modules.plex.plex import Plex
@@ -162,17 +163,14 @@ class PlexModule(_ModuleBase, _MediaServerBase[Plex]):
if not s:
continue
if mediainfo.type == MediaType.MUSIC:
matches = s.get_music(
title=getattr(mediainfo, "title", None),
artist=getattr(mediainfo, "artist", None),
album=getattr(mediainfo, "album", None),
)
if matches:
matches = s.get_music(**MusicMediaServerHelper.search_params(mediainfo))
match = MusicMediaServerHelper.find_match(mediainfo, matches)
if match:
return schemas.ExistMediaInfo(
type=MediaType.MUSIC,
server_type="plex",
server=name,
itemid=matches[0].item_id,
itemid=match.item_id,
)
continue
if mediainfo.type == MediaType.MOVIE:

View File

@@ -255,7 +255,7 @@ class Plex:
"""按歌曲、艺术家或专辑名称查询 Plex 音乐条目。"""
if not self._plex:
return []
query = " ".join(filter(None, [title, artist, album])).strip()
query = album or title or artist
if not query:
return []
results: List[schemas.MediaServerItem] = []
@@ -264,15 +264,27 @@ class Plex:
if library.type not in ("artist", "music"):
continue
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(
server="plex",
library=library.key,
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),
original_title=getattr(item, "title", None),
year=getattr(item, "year", None),
path=getattr(item, "file", None),
note={
"artist": item_artist,
"album": item_album,
"song_count": getattr(item, "leafCount", None),
},
))
except Exception as e:
logger.debug(f"查询Plex音乐出错{e}")

View File

@@ -3,6 +3,7 @@ from typing import Any, Generator, List, Optional, Tuple, Union
from app import schemas
from app.core.context import MediaInfo
from app.core.event import eventmanager
from app.helper.mediaserver import MusicMediaServerHelper
from app.log import logger
from app.modules import _MediaServerBase, _ModuleBase
from app.modules.trimemedia.trimemedia import TrimeMedia
@@ -182,16 +183,15 @@ class TrimeMediaModule(_ModuleBase, _MediaServerBase[TrimeMedia]):
continue
if mediainfo.type == MediaType.MUSIC:
matches = getattr(s, "get_music", lambda **_: [])(
title=getattr(mediainfo, "title", None),
artist=getattr(mediainfo, "artist", None),
album=getattr(mediainfo, "album", None),
**MusicMediaServerHelper.search_params(mediainfo)
)
if matches:
match = MusicMediaServerHelper.find_match(mediainfo, matches)
if match:
return schemas.ExistMediaInfo(
type=MediaType.MUSIC,
server_type="trimemedia",
server=name,
itemid=matches[0].item_id,
itemid=match.item_id,
)
continue
if mediainfo.type == MediaType.MOVIE:

View File

@@ -3,6 +3,7 @@ from typing import Any, Generator, List, Optional, Tuple, Union
from app import schemas
from app.core.context import MediaInfo
from app.core.event import eventmanager
from app.helper.mediaserver import MusicMediaServerHelper
from app.log import logger
from app.modules import _MediaServerBase, _ModuleBase
from app.modules.ugreen.ugreen import Ugreen
@@ -164,16 +165,15 @@ class UgreenModule(_ModuleBase, _MediaServerBase[Ugreen]):
continue
if mediainfo.type == MediaType.MUSIC:
matches = getattr(s, "get_music", lambda **_: [])(
title=getattr(mediainfo, "title", None),
artist=getattr(mediainfo, "artist", None),
album=getattr(mediainfo, "album", None),
**MusicMediaServerHelper.search_params(mediainfo)
)
if matches:
match = MusicMediaServerHelper.find_match(mediainfo, matches)
if match:
return schemas.ExistMediaInfo(
type=MediaType.MUSIC,
server_type="ugreen",
server=name,
itemid=matches[0].item_id,
itemid=match.item_id,
)
continue
if mediainfo.type == MediaType.MOVIE:

View File

@@ -7,6 +7,7 @@ from urllib.parse import parse_qs, urlparse
from app import schemas
from app.db.systemconfig_oper import SystemConfigOper
from app.helper.mediaserver import MusicMediaServerHelper
from app.log import logger
from app.modules.ugreen.api import Api
from app.schemas import MediaType
@@ -667,7 +668,7 @@ class Ugreen:
"""按歌曲、艺术家或专辑名称查询绿联影视音乐条目。"""
if not self.is_authenticated() or not self._api:
return []
query = " ".join(filter(None, [album, title, artist])).strip()
query = album or title or artist
if not query:
return []
@@ -682,6 +683,7 @@ class Ugreen:
for info in self.__extract_video_info_list(data.get(bucket)):
media_item = self.__build_media_server_item(info)
if media_item:
media_item.note = MusicMediaServerHelper.build_note(info)
results.append(media_item)
return results

View File

@@ -3,6 +3,7 @@ from typing import Any, Generator, List, Optional, Tuple, Union
from app import schemas
from app.core.context import MediaInfo
from app.core.event import eventmanager
from app.helper.mediaserver import MusicMediaServerHelper
from app.log import logger
from app.modules import _MediaServerBase, _ModuleBase
from app.modules.zspace.zspace import ZSpace
@@ -147,16 +148,15 @@ class ZSpaceModule(_ModuleBase, _MediaServerBase[ZSpace]):
continue
if mediainfo.type == MediaType.MUSIC:
matches = getattr(s, "get_music", lambda **_: [])(
title=getattr(mediainfo, "title", None),
artist=getattr(mediainfo, "artist", None),
album=getattr(mediainfo, "album", None),
**MusicMediaServerHelper.search_params(mediainfo)
)
if matches:
match = MusicMediaServerHelper.find_match(mediainfo, matches)
if match:
return schemas.ExistMediaInfo(
type=MediaType.MUSIC,
server_type="zspace",
server=name,
itemid=matches[0].item_id,
itemid=match.item_id,
)
continue
if mediainfo.type == MediaType.MOVIE:

View File

@@ -8,6 +8,7 @@ from typing import List, Optional, Union, Dict, Generator, Tuple, Any
from requests import Response
from app import schemas
from app.helper.mediaserver import MusicMediaServerHelper
from app.log import logger
from app.schemas import MediaServerItem
from app.schemas.types import MediaType
@@ -569,7 +570,8 @@ class ZSpace:
url = f"{self._host}emby/Items"
params = {
"IncludeItemTypes": "MusicAlbum,Audio",
"Fields": "ProviderIds,OriginalTitle,ProductionYear,Path,ParentId",
"Fields": "Album,AlbumArtist,AlbumArtists,Artists,ArtistItems,ChildCount,"
"ProviderIds,OriginalTitle,ProductionYear,Path,ParentId",
"SearchTerm": query,
"Recursive": "true",
"Limit": 20,
@@ -830,6 +832,8 @@ class ZSpace:
imdbid=item.get("ProviderIds", {}).get("Imdb"),
tvdbid=item.get("ProviderIds", {}).get("Tvdb"),
path=item.get("Path"),
note=MusicMediaServerHelper.build_note(item)
if item.get("Type") in {"MusicAlbum", "Audio"} else None,
user_state=user_state
)
@@ -904,7 +908,8 @@ class ZSpace:
"Recursive": "true",
"StartIndex": current_start_index,
"Limit": page_size,
"Fields": "ProviderIds,OriginalTitle,ProductionYear,Path,"
"Fields": "Album,AlbumArtist,AlbumArtists,Artists,ArtistItems,ChildCount,"
"ProviderIds,OriginalTitle,ProductionYear,Path,"
"UserDataPlayCount,UserDataLastPlayedDate,ParentId"
}
try:
@@ -921,7 +926,10 @@ class ZSpace:
if sub_item:
yield sub_item
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
provider_ids = item.get("ProviderIds") or {}
needs_detail = (