Files
musicseerr/backend/services/jellyfin_library_service.py
T
Harvey 0f25ebc26d Plex Integration + Music Source Integration Improvements (#37)
* 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
2026-04-13 23:39:01 +01:00

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