0f25ebc26d
* plex integration * The big one - Full Music Source page rework + Playlist importing + Full Plex Integration + Discovery Options + More Like This/Surprise Me/Instant Mix + More... * Music source track page - Play all / shuffle fixes * lint * format * fix type checks * format
673 lines
27 KiB
Python
673 lines
27 KiB
Python
import asyncio
|
|
import logging
|
|
|
|
from api.v1.schemas.jellyfin import (
|
|
JellyfinAlbumDetail,
|
|
JellyfinAlbumMatch,
|
|
JellyfinAlbumSummary,
|
|
JellyfinArtistIndexEntry,
|
|
JellyfinArtistIndexResponse,
|
|
JellyfinArtistSummary,
|
|
JellyfinFavoritesExpanded,
|
|
JellyfinFilterFacets,
|
|
JellyfinHubResponse,
|
|
JellyfinImportResult,
|
|
JellyfinLibraryStats,
|
|
JellyfinLyricsLineSchema,
|
|
JellyfinLyricsResponse,
|
|
JellyfinPlaylistDetail,
|
|
JellyfinPlaylistSummary,
|
|
JellyfinPlaylistTrack,
|
|
JellyfinSearchResponse,
|
|
JellyfinSessionInfo,
|
|
JellyfinSessionsResponse,
|
|
JellyfinTrackInfo,
|
|
)
|
|
from infrastructure.cover_urls import prefer_artist_cover_url, prefer_release_group_cover_url
|
|
from core.exceptions import ExternalServiceError
|
|
from repositories.protocols import JellyfinRepositoryProtocol
|
|
from repositories.jellyfin_models import JellyfinItem
|
|
from services.preferences_service import PreferencesService
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class JellyfinLibraryService:
|
|
_DEFAULT_RECENTLY_PLAYED_TTL = 300
|
|
_DEFAULT_FAVORITES_TTL = 300
|
|
_DEFAULT_GENRES_TTL = 3600
|
|
_DEFAULT_STATS_TTL = 600
|
|
|
|
def __init__(
|
|
self,
|
|
jellyfin_repo: JellyfinRepositoryProtocol,
|
|
preferences_service: PreferencesService,
|
|
):
|
|
self._jellyfin = jellyfin_repo
|
|
self._preferences = preferences_service
|
|
|
|
def _get_recently_played_ttl(self) -> int:
|
|
try:
|
|
return self._preferences.get_advanced_settings().cache_ttl_jellyfin_recently_played
|
|
except Exception: # noqa: BLE001
|
|
return self._DEFAULT_RECENTLY_PLAYED_TTL
|
|
|
|
def _get_favorites_ttl(self) -> int:
|
|
try:
|
|
return self._preferences.get_advanced_settings().cache_ttl_jellyfin_favorites
|
|
except Exception: # noqa: BLE001
|
|
return self._DEFAULT_FAVORITES_TTL
|
|
|
|
def _get_genres_ttl(self) -> int:
|
|
try:
|
|
return self._preferences.get_advanced_settings().cache_ttl_jellyfin_genres
|
|
except Exception: # noqa: BLE001
|
|
return self._DEFAULT_GENRES_TTL
|
|
|
|
def _get_stats_ttl(self) -> int:
|
|
try:
|
|
return self._preferences.get_advanced_settings().cache_ttl_jellyfin_library_stats
|
|
except Exception: # noqa: BLE001
|
|
return self._DEFAULT_STATS_TTL
|
|
|
|
def _item_to_album_summary(self, item: JellyfinItem) -> JellyfinAlbumSummary:
|
|
pids = item.provider_ids or {}
|
|
mbid = pids.get("MusicBrainzReleaseGroup") or pids.get("MusicBrainzAlbum")
|
|
artist_mbid = pids.get("MusicBrainzAlbumArtist") or pids.get("MusicBrainzArtist")
|
|
proxy_url = f"/api/v1/jellyfin/image/{item.id}" if item.id else None
|
|
image_url = prefer_release_group_cover_url(
|
|
mbid,
|
|
proxy_url,
|
|
size=500,
|
|
)
|
|
return JellyfinAlbumSummary(
|
|
jellyfin_id=item.id,
|
|
name=item.name,
|
|
artist_name=item.artist_name or "",
|
|
year=item.year,
|
|
track_count=item.child_count or 0,
|
|
image_url=image_url,
|
|
musicbrainz_id=mbid,
|
|
artist_musicbrainz_id=artist_mbid,
|
|
play_count=item.play_count,
|
|
)
|
|
|
|
def _item_to_artist_summary(self, item: JellyfinItem) -> JellyfinArtistSummary:
|
|
mbid = item.provider_ids.get("MusicBrainzArtist") if item.provider_ids else None
|
|
proxy_url = f"/api/v1/jellyfin/image/{item.id}" if item.id else None
|
|
image_url = prefer_artist_cover_url(
|
|
mbid,
|
|
proxy_url,
|
|
size=500,
|
|
)
|
|
return JellyfinArtistSummary(
|
|
jellyfin_id=item.id,
|
|
name=item.name,
|
|
image_url=image_url,
|
|
album_count=item.album_count or 0,
|
|
musicbrainz_id=mbid,
|
|
play_count=item.play_count,
|
|
)
|
|
|
|
def _item_to_track_info(self, item: JellyfinItem) -> JellyfinTrackInfo:
|
|
duration_seconds = (item.duration_ticks / 10_000_000.0) if item.duration_ticks else 0.0
|
|
album_id = item.album_id or ""
|
|
return JellyfinTrackInfo(
|
|
jellyfin_id=item.id,
|
|
title=item.name,
|
|
track_number=item.index_number or 0,
|
|
disc_number=item.parent_index_number or 1,
|
|
duration_seconds=duration_seconds,
|
|
album_name=item.album_name or "",
|
|
artist_name=item.artist_name or "",
|
|
album_id=album_id,
|
|
codec=item.codec,
|
|
bitrate=item.bitrate,
|
|
image_url=f"/api/v1/jellyfin/image/{album_id}" if album_id else None,
|
|
)
|
|
|
|
@staticmethod
|
|
def _fix_missing_track_numbers(tracks: list[JellyfinTrackInfo]) -> list[JellyfinTrackInfo]:
|
|
"""When all tracks share the same track_number (e.g. Jellyfin returns 0
|
|
for every track), assign 1-based indices so downstream Map lookups work."""
|
|
if len(tracks) <= 1:
|
|
return tracks
|
|
tracks_by_disc: dict[int, list[JellyfinTrackInfo]] = {}
|
|
for track in tracks:
|
|
tracks_by_disc.setdefault(track.disc_number, []).append(track)
|
|
|
|
renumbered_ids: dict[str, int] = {}
|
|
for disc_tracks in tracks_by_disc.values():
|
|
numbers = {t.track_number for t in disc_tracks}
|
|
if len(numbers) > 1:
|
|
continue
|
|
for i, track in enumerate(disc_tracks, start=1):
|
|
renumbered_ids[track.jellyfin_id] = i
|
|
|
|
fixed: list[JellyfinTrackInfo] = []
|
|
for track in tracks:
|
|
track_number = renumbered_ids.get(track.jellyfin_id, track.track_number)
|
|
fixed.append(JellyfinTrackInfo(
|
|
jellyfin_id=track.jellyfin_id,
|
|
title=track.title,
|
|
track_number=track_number,
|
|
disc_number=track.disc_number,
|
|
duration_seconds=track.duration_seconds,
|
|
album_name=track.album_name,
|
|
artist_name=track.artist_name,
|
|
codec=track.codec,
|
|
bitrate=track.bitrate,
|
|
))
|
|
return fixed
|
|
|
|
async def get_albums(
|
|
self,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
sort_by: str = "SortName",
|
|
sort_order: str = "Ascending",
|
|
genre: str | None = None,
|
|
year: int | None = None,
|
|
tags: str | None = None,
|
|
studios: str | None = None,
|
|
) -> tuple[list[JellyfinAlbumSummary], int]:
|
|
items, total = await self._jellyfin.get_albums(
|
|
limit=limit, offset=offset, sort_by=sort_by, sort_order=sort_order,
|
|
genre=genre, year=year, tags=tags, studios=studios,
|
|
)
|
|
return [self._item_to_album_summary(i) for i in items], total
|
|
|
|
async def get_album_detail(self, album_id: str) -> JellyfinAlbumDetail | None:
|
|
item = await self._jellyfin.get_album_detail(album_id)
|
|
if not item:
|
|
return None
|
|
|
|
tracks_items = await self._jellyfin.get_album_tracks(album_id)
|
|
tracks = self._fix_missing_track_numbers(
|
|
[self._item_to_track_info(t) for t in tracks_items]
|
|
)
|
|
pids = item.provider_ids or {}
|
|
mbid = pids.get("MusicBrainzReleaseGroup") or pids.get("MusicBrainzAlbum")
|
|
artist_mbid = pids.get("MusicBrainzAlbumArtist") or pids.get("MusicBrainzArtist")
|
|
proxy_url = f"/api/v1/jellyfin/image/{item.id}" if item.id else None
|
|
image_url = prefer_release_group_cover_url(
|
|
mbid,
|
|
proxy_url,
|
|
size=500,
|
|
)
|
|
|
|
return JellyfinAlbumDetail(
|
|
jellyfin_id=item.id,
|
|
name=item.name,
|
|
artist_name=item.artist_name or "",
|
|
year=item.year,
|
|
track_count=len(tracks),
|
|
image_url=image_url,
|
|
musicbrainz_id=mbid,
|
|
artist_musicbrainz_id=artist_mbid,
|
|
tracks=tracks,
|
|
)
|
|
|
|
async def get_album_tracks(self, album_id: str) -> list[JellyfinTrackInfo]:
|
|
items = await self._jellyfin.get_album_tracks(album_id)
|
|
return self._fix_missing_track_numbers(
|
|
[self._item_to_track_info(i) for i in items]
|
|
)
|
|
|
|
async def match_album_by_mbid(self, musicbrainz_id: str) -> JellyfinAlbumMatch:
|
|
item = await self._jellyfin.get_album_by_mbid(musicbrainz_id)
|
|
if not item:
|
|
return JellyfinAlbumMatch(found=False)
|
|
|
|
tracks_items = await self._jellyfin.get_album_tracks(item.id)
|
|
tracks = self._fix_missing_track_numbers(
|
|
[self._item_to_track_info(t) for t in tracks_items]
|
|
)
|
|
|
|
return JellyfinAlbumMatch(
|
|
found=True,
|
|
jellyfin_album_id=item.id,
|
|
tracks=tracks,
|
|
)
|
|
|
|
async def get_artists(
|
|
self, limit: int = 50, offset: int = 0
|
|
) -> list[JellyfinArtistSummary]:
|
|
items, _total = await self._jellyfin.get_artists(limit=limit, offset=offset)
|
|
return [self._item_to_artist_summary(i) for i in items]
|
|
|
|
async def browse_artists(
|
|
self,
|
|
limit: int = 48,
|
|
offset: int = 0,
|
|
sort_by: str = "SortName",
|
|
sort_order: str = "Ascending",
|
|
search: str = "",
|
|
) -> tuple[list[JellyfinArtistSummary], int]:
|
|
items, total = await self._jellyfin.get_artists(
|
|
limit=limit, offset=offset, sort_by=sort_by, sort_order=sort_order, search=search
|
|
)
|
|
return [self._item_to_artist_summary(i) for i in items], total
|
|
|
|
async def get_artists_index(self) -> JellyfinArtistIndexResponse:
|
|
all_artists: list[JellyfinArtistSummary] = []
|
|
offset = 0
|
|
batch_size = 500
|
|
while True:
|
|
items, _total = await self._jellyfin.get_artists(
|
|
limit=batch_size, offset=offset, sort_by="SortName", sort_order="Ascending"
|
|
)
|
|
all_artists.extend(self._item_to_artist_summary(i) for i in items)
|
|
if len(items) < batch_size:
|
|
break
|
|
offset += batch_size
|
|
|
|
groups: dict[str, list[JellyfinArtistSummary]] = {}
|
|
for artist in all_artists:
|
|
letter = artist.name[0].upper() if artist.name else "#"
|
|
if not letter.isalpha():
|
|
letter = "#"
|
|
groups.setdefault(letter, []).append(artist)
|
|
|
|
entries = [
|
|
JellyfinArtistIndexEntry(name=letter, artists=artists)
|
|
for letter, artists in sorted(groups.items())
|
|
]
|
|
return JellyfinArtistIndexResponse(index=entries)
|
|
|
|
async def browse_tracks(
|
|
self,
|
|
limit: int = 48,
|
|
offset: int = 0,
|
|
sort_by: str = "SortName",
|
|
sort_order: str = "Ascending",
|
|
search: str = "",
|
|
) -> tuple[list[JellyfinTrackInfo], int]:
|
|
items, total = await self._jellyfin.get_tracks(
|
|
limit=limit, offset=offset, sort_by=sort_by, sort_order=sort_order, search=search
|
|
)
|
|
return [self._item_to_track_info(i) for i in items], total
|
|
|
|
async def search(
|
|
self, query: str
|
|
) -> JellyfinSearchResponse:
|
|
items = await self._jellyfin.search_items(query)
|
|
albums = []
|
|
artists = []
|
|
tracks = []
|
|
for item in items:
|
|
if item.type == "MusicAlbum":
|
|
albums.append(self._item_to_album_summary(item))
|
|
elif item.type in ("MusicArtist", "Artist"):
|
|
artists.append(self._item_to_artist_summary(item))
|
|
elif item.type == "Audio":
|
|
tracks.append(self._item_to_track_info(item))
|
|
return JellyfinSearchResponse(albums=albums, artists=artists, tracks=tracks)
|
|
|
|
async def get_recently_played(self, limit: int = 20) -> list[JellyfinAlbumSummary]:
|
|
ttl_seconds = self._get_recently_played_ttl()
|
|
items = await self._jellyfin.get_recently_played(
|
|
limit=limit,
|
|
ttl_seconds=ttl_seconds,
|
|
)
|
|
seen_album_ids: set[str] = set()
|
|
unique_album_ids: list[str] = []
|
|
for item in items:
|
|
aid = item.album_id or item.parent_id
|
|
if not aid or aid in seen_album_ids:
|
|
continue
|
|
seen_album_ids.add(aid)
|
|
unique_album_ids.append(aid)
|
|
if len(unique_album_ids) >= limit:
|
|
break
|
|
|
|
_CONCURRENCY_LIMIT = 5
|
|
sem = asyncio.Semaphore(_CONCURRENCY_LIMIT)
|
|
|
|
async def _fetch(aid: str) -> JellyfinItem | None:
|
|
async with sem:
|
|
return await self._jellyfin.get_album_detail(aid)
|
|
|
|
details = await asyncio.gather(
|
|
*(_fetch(aid) for aid in unique_album_ids)
|
|
)
|
|
return [
|
|
self._item_to_album_summary(detail)
|
|
for detail in details
|
|
if detail is not None
|
|
]
|
|
|
|
async def get_favorites(self, limit: int = 20) -> list[JellyfinAlbumSummary]:
|
|
ttl_seconds = self._get_favorites_ttl()
|
|
items = await self._jellyfin.get_favorite_albums(
|
|
limit=limit,
|
|
ttl_seconds=ttl_seconds,
|
|
)
|
|
return [self._item_to_album_summary(i) for i in items]
|
|
|
|
async def get_favorites_expanded(self, limit: int = 50) -> JellyfinFavoritesExpanded:
|
|
ttl_seconds = self._get_favorites_ttl()
|
|
albums_items, artists_items = await asyncio.gather(
|
|
self._jellyfin.get_favorite_albums(limit=limit, ttl_seconds=ttl_seconds),
|
|
self._jellyfin.get_favorite_artists(limit=limit),
|
|
)
|
|
return JellyfinFavoritesExpanded(
|
|
albums=[self._item_to_album_summary(i) for i in albums_items],
|
|
artists=[self._item_to_artist_summary(i) for i in artists_items],
|
|
)
|
|
|
|
async def get_filter_facets(self) -> JellyfinFilterFacets:
|
|
facets = await self._jellyfin.get_filter_facets()
|
|
return JellyfinFilterFacets(
|
|
years=facets.get("years", []),
|
|
tags=facets.get("tags", []),
|
|
studios=facets.get("studios", []),
|
|
)
|
|
|
|
async def get_genres(self) -> list[str]:
|
|
ttl_seconds = self._get_genres_ttl()
|
|
return await self._jellyfin.get_genres(ttl_seconds=ttl_seconds)
|
|
|
|
async def get_songs_by_genre(
|
|
self, genre: str, limit: int = 50, offset: int = 0
|
|
) -> tuple[list[JellyfinTrackInfo], int]:
|
|
items, total = await self._jellyfin.get_tracks(
|
|
limit=limit, offset=offset, sort_by="Random", genre=genre
|
|
)
|
|
return [self._item_to_track_info(i) for i in items], total
|
|
|
|
async def get_stats(self) -> JellyfinLibraryStats:
|
|
ttl_seconds = self._get_stats_ttl()
|
|
raw = await self._jellyfin.get_library_stats(ttl_seconds=ttl_seconds)
|
|
return JellyfinLibraryStats(
|
|
total_tracks=raw.get("total_tracks", 0),
|
|
total_albums=raw.get("total_albums", 0),
|
|
total_artists=raw.get("total_artists", 0),
|
|
)
|
|
|
|
async def get_recently_added(self, limit: int = 20) -> list[JellyfinAlbumSummary]:
|
|
items = await self._jellyfin.get_recently_added(limit=limit)
|
|
return [self._item_to_album_summary(i) for i in items]
|
|
|
|
async def get_most_played_artists(self, limit: int = 20) -> list[JellyfinArtistSummary]:
|
|
items = await self._jellyfin.get_most_played_artists(limit=limit)
|
|
return [self._item_to_artist_summary(i) for i in items]
|
|
|
|
async def get_most_played_albums(self, limit: int = 20) -> list[JellyfinAlbumSummary]:
|
|
items = await self._jellyfin.get_most_played_albums(limit=limit)
|
|
return [self._item_to_album_summary(i) for i in items]
|
|
|
|
async def list_playlists(self, limit: int = 50) -> list[JellyfinPlaylistSummary]:
|
|
items = await self._jellyfin.get_playlists(limit=limit)
|
|
summaries = []
|
|
for i in items:
|
|
cover = f"/api/v1/jellyfin/image/{i.id}"
|
|
summaries.append(JellyfinPlaylistSummary(
|
|
id=i.id,
|
|
name=i.name,
|
|
track_count=i.child_count or 0,
|
|
duration_seconds=int(i.duration_ticks / 10_000_000) if i.duration_ticks else 0,
|
|
cover_url=cover,
|
|
created_at=i.date_created or "",
|
|
))
|
|
return summaries
|
|
|
|
async def get_playlist_detail(self, playlist_id: str) -> JellyfinPlaylistDetail:
|
|
playlist = await self._jellyfin.get_playlist(playlist_id)
|
|
if playlist is None:
|
|
from core.exceptions import ResourceNotFoundError
|
|
raise ResourceNotFoundError(f"Jellyfin playlist {playlist_id} not found")
|
|
|
|
items = await self._jellyfin.get_playlist_items(playlist_id)
|
|
tracks = []
|
|
for t in items:
|
|
tracks.append(JellyfinPlaylistTrack(
|
|
id=t.id,
|
|
track_name=t.name,
|
|
artist_name=t.artist_name or "",
|
|
album_name=t.album_name or "",
|
|
album_id=t.album_id or "",
|
|
artist_id=t.artist_id or "",
|
|
duration_seconds=int(t.duration_ticks / 10_000_000) if t.duration_ticks else 0,
|
|
track_number=t.index_number or 0,
|
|
disc_number=t.parent_index_number or 1,
|
|
cover_url=f"/api/v1/jellyfin/image/{t.album_id}" if t.album_id else "",
|
|
))
|
|
|
|
cover = f"/api/v1/jellyfin/image/{playlist.id}"
|
|
if not playlist.image_tag and tracks:
|
|
first_with_album = next((t for t in tracks if t.album_id), None)
|
|
if first_with_album:
|
|
cover = f"/api/v1/jellyfin/image/{first_with_album.album_id}"
|
|
return JellyfinPlaylistDetail(
|
|
id=playlist.id,
|
|
name=playlist.name,
|
|
track_count=playlist.child_count or 0,
|
|
duration_seconds=int(playlist.duration_ticks / 10_000_000) if playlist.duration_ticks else 0,
|
|
cover_url=cover,
|
|
created_at=playlist.date_created or "",
|
|
tracks=tracks,
|
|
)
|
|
|
|
async def import_playlist(
|
|
self,
|
|
playlist_id: str,
|
|
playlist_service: 'PlaylistService',
|
|
) -> JellyfinImportResult:
|
|
source_ref = f"jellyfin:{playlist_id}"
|
|
existing = await playlist_service.get_by_source_ref(source_ref)
|
|
if existing:
|
|
return JellyfinImportResult(
|
|
musicseerr_playlist_id=existing.id,
|
|
already_imported=True,
|
|
)
|
|
|
|
detail = await self.get_playlist_detail(playlist_id)
|
|
try:
|
|
created = await playlist_service.create_playlist(detail.name, source_ref=source_ref)
|
|
except Exception: # noqa: BLE001
|
|
re_check = await playlist_service.get_by_source_ref(source_ref)
|
|
if re_check:
|
|
return JellyfinImportResult(musicseerr_playlist_id=re_check.id, already_imported=True)
|
|
raise
|
|
|
|
track_dicts = []
|
|
failed = 0
|
|
for t in detail.tracks:
|
|
try:
|
|
track_dicts.append({
|
|
"track_name": t.track_name,
|
|
"artist_name": t.artist_name,
|
|
"album_name": t.album_name,
|
|
"duration": t.duration_seconds,
|
|
"track_source_id": t.id,
|
|
"source_type": "jellyfin",
|
|
"album_id": t.album_id,
|
|
"artist_id": t.artist_id,
|
|
"track_number": t.track_number,
|
|
"disc_number": t.disc_number,
|
|
"cover_url": t.cover_url,
|
|
})
|
|
except Exception: # noqa: BLE001
|
|
failed += 1
|
|
|
|
if track_dicts:
|
|
try:
|
|
await playlist_service.add_tracks(created.id, track_dicts)
|
|
except Exception: # noqa: BLE001
|
|
logger.error("Failed to add tracks during Jellyfin playlist import %s", playlist_id, exc_info=True)
|
|
await playlist_service.delete_playlist(created.id)
|
|
raise ExternalServiceError(f"Failed to import Jellyfin playlist {playlist_id}")
|
|
|
|
return JellyfinImportResult(
|
|
musicseerr_playlist_id=created.id,
|
|
tracks_imported=len(track_dicts),
|
|
tracks_failed=failed,
|
|
)
|
|
|
|
async def get_instant_mix(
|
|
self, item_id: str, limit: int = 50
|
|
) -> list[JellyfinTrackInfo]:
|
|
try:
|
|
items = await self._jellyfin.get_instant_mix(item_id, limit=limit)
|
|
return [self._item_to_track_info(i) for i in items]
|
|
except Exception: # noqa: BLE001
|
|
logger.warning("get_instant_mix failed for %s", item_id, exc_info=True)
|
|
return []
|
|
|
|
async def get_instant_mix_by_artist(
|
|
self, artist_id: str, limit: int = 50
|
|
) -> list[JellyfinTrackInfo]:
|
|
try:
|
|
items = await self._jellyfin.get_instant_mix_by_artist(artist_id, limit=limit)
|
|
return [self._item_to_track_info(i) for i in items]
|
|
except Exception: # noqa: BLE001
|
|
logger.warning("get_instant_mix_by_artist failed for %s", artist_id, exc_info=True)
|
|
return []
|
|
|
|
async def get_instant_mix_by_genre(
|
|
self, genre_name: str, limit: int = 50
|
|
) -> list[JellyfinTrackInfo]:
|
|
try:
|
|
items = await self._jellyfin.get_instant_mix_by_genre(genre_name, limit=limit)
|
|
return [self._item_to_track_info(i) for i in items]
|
|
except Exception: # noqa: BLE001
|
|
logger.warning("get_instant_mix_by_genre failed for %s", genre_name, exc_info=True)
|
|
return []
|
|
|
|
async def get_sessions(self) -> JellyfinSessionsResponse:
|
|
_TICKS_TO_SECONDS = 10_000_000
|
|
try:
|
|
raw_sessions = await self._jellyfin.get_sessions()
|
|
sessions = [
|
|
JellyfinSessionInfo(
|
|
session_id=s.session_id,
|
|
user_name=s.user_name,
|
|
device_name=s.device_name,
|
|
client_name=s.client_name,
|
|
track_name=s.now_playing_name,
|
|
artist_name=s.now_playing_artist,
|
|
album_name=s.now_playing_album,
|
|
album_id=s.now_playing_album_id,
|
|
cover_url=f"/api/v1/jellyfin/image/{s.now_playing_item_id}" if s.now_playing_item_id else "",
|
|
position_seconds=s.position_ticks / _TICKS_TO_SECONDS if s.position_ticks else 0.0,
|
|
duration_seconds=s.runtime_ticks / _TICKS_TO_SECONDS if s.runtime_ticks else 0.0,
|
|
is_paused=s.is_paused,
|
|
play_method=s.play_method,
|
|
audio_codec=s.audio_codec,
|
|
bitrate=s.bitrate,
|
|
)
|
|
for s in raw_sessions
|
|
]
|
|
return JellyfinSessionsResponse(sessions=sessions)
|
|
except Exception: # noqa: BLE001
|
|
logger.warning("get_sessions failed", exc_info=True)
|
|
return JellyfinSessionsResponse(sessions=[])
|
|
|
|
async def get_hub_data(self) -> JellyfinHubResponse:
|
|
_HUB_TIMEOUT = 10
|
|
|
|
results = await asyncio.gather(
|
|
asyncio.wait_for(self.get_recently_played(limit=20), timeout=_HUB_TIMEOUT),
|
|
asyncio.wait_for(self.get_favorites(limit=20), timeout=_HUB_TIMEOUT),
|
|
asyncio.wait_for(self.get_albums(limit=12), timeout=_HUB_TIMEOUT),
|
|
asyncio.wait_for(self.get_stats(), timeout=_HUB_TIMEOUT),
|
|
asyncio.wait_for(self.get_recently_added(limit=20), timeout=_HUB_TIMEOUT),
|
|
asyncio.wait_for(self.get_most_played_artists(limit=10), timeout=_HUB_TIMEOUT),
|
|
asyncio.wait_for(self.get_most_played_albums(limit=10), timeout=_HUB_TIMEOUT),
|
|
asyncio.wait_for(self.list_playlists(limit=20), timeout=_HUB_TIMEOUT),
|
|
asyncio.wait_for(self.get_genres(), timeout=_HUB_TIMEOUT),
|
|
return_exceptions=True,
|
|
)
|
|
|
|
all_failed = all(isinstance(r, BaseException) for r in results)
|
|
if all_failed:
|
|
raise ExternalServiceError("All Jellyfin hub data requests failed")
|
|
|
|
recently_played = results[0] if not isinstance(results[0], BaseException) else []
|
|
if isinstance(results[0], BaseException):
|
|
logger.warning("Hub: get_recently_played failed: %s", results[0])
|
|
|
|
favorites = results[1] if not isinstance(results[1], BaseException) else []
|
|
if isinstance(results[1], BaseException):
|
|
logger.warning("Hub: get_favorites failed: %s", results[1])
|
|
|
|
albums_result = results[2]
|
|
if isinstance(albums_result, BaseException):
|
|
logger.warning("Hub: get_albums failed: %s", albums_result)
|
|
all_albums_preview: list[JellyfinAlbumSummary] = []
|
|
else:
|
|
all_albums_preview = albums_result[0]
|
|
|
|
stats = results[3] if not isinstance(results[3], BaseException) else None
|
|
if isinstance(results[3], BaseException):
|
|
logger.warning("Hub: get_stats failed: %s", results[3])
|
|
|
|
recently_added = results[4] if not isinstance(results[4], BaseException) else []
|
|
if isinstance(results[4], BaseException):
|
|
logger.warning("Hub: get_recently_added failed: %s", results[4])
|
|
|
|
most_played_artists = results[5] if not isinstance(results[5], BaseException) else []
|
|
if isinstance(results[5], BaseException):
|
|
logger.warning("Hub: get_most_played_artists failed: %s", results[5])
|
|
|
|
most_played_albums = results[6] if not isinstance(results[6], BaseException) else []
|
|
if isinstance(results[6], BaseException):
|
|
logger.warning("Hub: get_most_played_albums failed: %s", results[6])
|
|
|
|
playlists = results[7] if not isinstance(results[7], BaseException) else []
|
|
if isinstance(results[7], BaseException):
|
|
logger.warning("Hub: list_playlists failed: %s", results[7])
|
|
|
|
genres = results[8] if not isinstance(results[8], BaseException) else []
|
|
if isinstance(results[8], BaseException):
|
|
logger.warning("Hub: get_genres failed: %s", results[8])
|
|
|
|
return JellyfinHubResponse(
|
|
stats=stats,
|
|
recently_played=recently_played,
|
|
recently_added=recently_added,
|
|
favorites=favorites,
|
|
most_played_artists=most_played_artists,
|
|
most_played_albums=most_played_albums,
|
|
all_albums_preview=all_albums_preview,
|
|
playlists=playlists,
|
|
genres=genres,
|
|
)
|
|
|
|
async def get_similar_items(
|
|
self, item_id: str, limit: int = 10
|
|
) -> list[JellyfinAlbumSummary]:
|
|
try:
|
|
items = await self._jellyfin.get_similar_items(item_id, limit=limit)
|
|
return [self._item_to_album_summary(i) for i in items if i.type in ("MusicAlbum", "Audio")]
|
|
except Exception: # noqa: BLE001
|
|
logger.warning("Similar items unavailable for %s", item_id)
|
|
return []
|
|
|
|
async def get_lyrics(self, item_id: str) -> JellyfinLyricsResponse | None:
|
|
try:
|
|
lyrics = await self._jellyfin.get_lyrics(item_id)
|
|
if lyrics is None:
|
|
return None
|
|
is_synced = any(line.start is not None for line in lyrics.lines)
|
|
lines = [
|
|
JellyfinLyricsLineSchema(
|
|
text=line.text,
|
|
start_seconds=line.start / 10_000_000 if line.start is not None else None,
|
|
)
|
|
for line in lyrics.lines
|
|
]
|
|
lyrics_text = "\n".join(line.text for line in lyrics.lines)
|
|
return JellyfinLyricsResponse(
|
|
lines=lines,
|
|
is_synced=is_synced,
|
|
lyrics_text=lyrics_text,
|
|
)
|
|
except ExternalServiceError:
|
|
logger.warning("Jellyfin API error fetching lyrics for item %s", item_id)
|
|
return None
|
|
except Exception: # noqa: BLE001
|
|
logger.warning("Unexpected error fetching lyrics for item %s", item_id, exc_info=True)
|
|
return None
|