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
433 lines
17 KiB
Python
433 lines
17 KiB
Python
import logging
|
|
from typing import Literal
|
|
|
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
|
|
from fastapi.responses import Response
|
|
|
|
from api.v1.schemas.jellyfin import (
|
|
JellyfinAlbumDetail,
|
|
JellyfinAlbumMatch,
|
|
JellyfinAlbumSummary,
|
|
JellyfinArtistIndexResponse,
|
|
JellyfinArtistPage,
|
|
JellyfinArtistSummary,
|
|
JellyfinFavoritesExpanded,
|
|
JellyfinFilterFacets,
|
|
JellyfinHubResponse,
|
|
JellyfinImportResult,
|
|
JellyfinLibraryStats,
|
|
JellyfinLyricsResponse,
|
|
JellyfinPaginatedResponse,
|
|
JellyfinPlaylistDetail,
|
|
JellyfinPlaylistSummary,
|
|
JellyfinSearchResponse,
|
|
JellyfinSessionsResponse,
|
|
JellyfinTrackInfo,
|
|
JellyfinTrackPage,
|
|
)
|
|
from core.dependencies import (
|
|
get_jellyfin_library_service,
|
|
get_jellyfin_repository,
|
|
get_local_files_service,
|
|
get_navidrome_library_service,
|
|
get_plex_library_service,
|
|
get_playlist_service,
|
|
)
|
|
from core.exceptions import ExternalServiceError, ResourceNotFoundError
|
|
from infrastructure.msgspec_fastapi import MsgSpecRoute
|
|
from repositories.jellyfin_repository import JellyfinRepository
|
|
from services.jellyfin_library_service import JellyfinLibraryService
|
|
from services.playlist_service import PlaylistService
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(route_class=MsgSpecRoute, prefix="/jellyfin", tags=["jellyfin-library"])
|
|
|
|
|
|
@router.get("/hub", response_model=JellyfinHubResponse)
|
|
async def get_jellyfin_hub(
|
|
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
|
playlist_service: PlaylistService = Depends(get_playlist_service),
|
|
) -> JellyfinHubResponse:
|
|
try:
|
|
hub = await service.get_hub_data()
|
|
imported_ids = await playlist_service.get_imported_source_ids("jellyfin:")
|
|
for p in hub.playlists:
|
|
if p.id in imported_ids:
|
|
p.is_imported = True
|
|
return hub
|
|
except ExternalServiceError as e:
|
|
logger.error("Jellyfin service error getting hub data: %s", e)
|
|
raise HTTPException(status_code=502, detail="Failed to communicate with Jellyfin")
|
|
|
|
|
|
@router.get("/image/{item_id}")
|
|
async def get_jellyfin_image(
|
|
item_id: str,
|
|
size: int = Query(default=500, ge=32, le=1200),
|
|
repo: JellyfinRepository = Depends(get_jellyfin_repository),
|
|
) -> Response:
|
|
try:
|
|
image_bytes, content_type = await repo.proxy_image(item_id, size)
|
|
return Response(
|
|
content=image_bytes,
|
|
media_type=content_type,
|
|
headers={"Cache-Control": "public, max-age=31536000, immutable"},
|
|
)
|
|
except ExternalServiceError as e:
|
|
logger.warning("Jellyfin image failed for %s: %s", item_id, e)
|
|
raise HTTPException(status_code=502, detail="Failed to fetch image")
|
|
|
|
|
|
@router.get("/recently-added", response_model=list[JellyfinAlbumSummary])
|
|
async def get_jellyfin_recently_added(
|
|
limit: int = Query(default=20, ge=1, le=50),
|
|
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
|
) -> list[JellyfinAlbumSummary]:
|
|
try:
|
|
return await service.get_recently_added(limit=limit)
|
|
except ExternalServiceError as e:
|
|
logger.error("Jellyfin service error getting recently added: %s", e)
|
|
raise HTTPException(status_code=502, detail="Failed to communicate with Jellyfin")
|
|
|
|
|
|
@router.get("/most-played/artists", response_model=list[JellyfinArtistSummary])
|
|
async def get_jellyfin_most_played_artists(
|
|
limit: int = Query(default=10, ge=1, le=50),
|
|
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
|
) -> list[JellyfinArtistSummary]:
|
|
try:
|
|
return await service.get_most_played_artists(limit=limit)
|
|
except ExternalServiceError as e:
|
|
logger.error("Jellyfin service error getting most played artists: %s", e)
|
|
raise HTTPException(status_code=502, detail="Failed to communicate with Jellyfin")
|
|
|
|
|
|
@router.get("/most-played/albums", response_model=list[JellyfinAlbumSummary])
|
|
async def get_jellyfin_most_played_albums(
|
|
limit: int = Query(default=10, ge=1, le=50),
|
|
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
|
) -> list[JellyfinAlbumSummary]:
|
|
try:
|
|
return await service.get_most_played_albums(limit=limit)
|
|
except ExternalServiceError as e:
|
|
logger.error("Jellyfin service error getting most played albums: %s", e)
|
|
raise HTTPException(status_code=502, detail="Failed to communicate with Jellyfin")
|
|
|
|
|
|
@router.get("/albums", response_model=JellyfinPaginatedResponse)
|
|
async def get_jellyfin_albums(
|
|
limit: int = Query(default=50, ge=1, le=200),
|
|
offset: int = Query(default=0, ge=0),
|
|
sort_by: Literal["SortName", "DateCreated", "PlayCount", "ProductionYear"] = Query(default="SortName"),
|
|
sort_order: Literal["Ascending", "Descending"] = Query(default="Ascending"),
|
|
genre: str | None = Query(default=None),
|
|
year: int | None = Query(default=None),
|
|
tags: str | None = Query(default=None),
|
|
studios: str | None = Query(default=None),
|
|
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
|
) -> JellyfinPaginatedResponse:
|
|
try:
|
|
items, total = await service.get_albums(
|
|
limit=limit, offset=offset, sort_by=sort_by, sort_order=sort_order,
|
|
genre=genre, year=year, tags=tags, studios=studios,
|
|
)
|
|
return JellyfinPaginatedResponse(
|
|
items=items, total=total, offset=offset, limit=limit
|
|
)
|
|
except ExternalServiceError as e:
|
|
logger.error("Jellyfin service error getting albums: %s", e)
|
|
raise HTTPException(status_code=502, detail="Failed to communicate with Jellyfin")
|
|
|
|
|
|
@router.get("/albums/{album_id}", response_model=JellyfinAlbumDetail)
|
|
async def get_jellyfin_album_detail(
|
|
album_id: str,
|
|
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
|
) -> JellyfinAlbumDetail:
|
|
result = await service.get_album_detail(album_id)
|
|
if not result:
|
|
raise HTTPException(status_code=404, detail="Album not found")
|
|
return result
|
|
|
|
|
|
@router.get(
|
|
"/albums/{album_id}/tracks", response_model=list[JellyfinTrackInfo]
|
|
)
|
|
async def get_jellyfin_album_tracks(
|
|
album_id: str,
|
|
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
|
) -> list[JellyfinTrackInfo]:
|
|
try:
|
|
return await service.get_album_tracks(album_id)
|
|
except ExternalServiceError as e:
|
|
logger.error("Jellyfin service error getting album tracks %s: %s", album_id, e)
|
|
raise HTTPException(status_code=502, detail="Failed to communicate with Jellyfin")
|
|
|
|
|
|
@router.get(
|
|
"/albums/match/{musicbrainz_id}", response_model=JellyfinAlbumMatch
|
|
)
|
|
async def match_jellyfin_album(
|
|
musicbrainz_id: str,
|
|
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
|
) -> JellyfinAlbumMatch:
|
|
try:
|
|
return await service.match_album_by_mbid(musicbrainz_id)
|
|
except ExternalServiceError as e:
|
|
logger.error("Failed to match Jellyfin album %s: %s", musicbrainz_id, e)
|
|
raise HTTPException(status_code=502, detail="Failed to match Jellyfin album")
|
|
|
|
|
|
@router.get("/artists/browse", response_model=JellyfinArtistPage)
|
|
async def browse_jellyfin_artists(
|
|
limit: int = Query(48, ge=1, le=100),
|
|
offset: int = Query(0, ge=0),
|
|
sort_by: str = Query("SortName"),
|
|
sort_order: str = Query("Ascending"),
|
|
search: str = Query(""),
|
|
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
|
) -> JellyfinArtistPage:
|
|
try:
|
|
items, total = await service.browse_artists(
|
|
limit=limit, offset=offset, sort_by=sort_by, sort_order=sort_order, search=search
|
|
)
|
|
return JellyfinArtistPage(items=items, total=total, offset=offset, limit=limit)
|
|
except ExternalServiceError as e:
|
|
logger.error("Jellyfin service error browsing artists: %s", e)
|
|
raise HTTPException(status_code=502, detail="Failed to communicate with Jellyfin")
|
|
|
|
|
|
@router.get("/artists", response_model=list[JellyfinArtistSummary])
|
|
async def get_jellyfin_artists(
|
|
limit: int = Query(default=50, ge=1, le=200),
|
|
offset: int = Query(default=0, ge=0),
|
|
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
|
) -> list[JellyfinArtistSummary]:
|
|
return await service.get_artists(limit=limit, offset=offset)
|
|
|
|
|
|
@router.get("/artists/index", response_model=JellyfinArtistIndexResponse)
|
|
async def get_jellyfin_artists_index(
|
|
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
|
) -> JellyfinArtistIndexResponse:
|
|
try:
|
|
return await service.get_artists_index()
|
|
except ExternalServiceError as e:
|
|
logger.error("Jellyfin service error getting artist index: %s", e)
|
|
raise HTTPException(status_code=502, detail="Failed to communicate with Jellyfin")
|
|
|
|
|
|
@router.get("/tracks", response_model=JellyfinTrackPage)
|
|
async def browse_jellyfin_tracks(
|
|
limit: int = Query(48, ge=1, le=100),
|
|
offset: int = Query(0, ge=0),
|
|
sort_by: str = Query("SortName"),
|
|
sort_order: str = Query("Ascending"),
|
|
search: str = Query(""),
|
|
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
|
) -> JellyfinTrackPage:
|
|
try:
|
|
items, total = await service.browse_tracks(
|
|
limit=limit, offset=offset, sort_by=sort_by, sort_order=sort_order, search=search
|
|
)
|
|
return JellyfinTrackPage(items=items, total=total, offset=offset, limit=limit)
|
|
except ExternalServiceError as e:
|
|
logger.error("Jellyfin service error browsing tracks: %s", e)
|
|
raise HTTPException(status_code=502, detail="Failed to communicate with Jellyfin")
|
|
|
|
|
|
@router.get("/search", response_model=JellyfinSearchResponse)
|
|
async def search_jellyfin(
|
|
q: str = Query(..., min_length=1),
|
|
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
|
) -> JellyfinSearchResponse:
|
|
return await service.search(q)
|
|
|
|
|
|
@router.get("/recent", response_model=list[JellyfinAlbumSummary])
|
|
async def get_jellyfin_recent(
|
|
limit: int = Query(default=20, ge=1, le=50),
|
|
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
|
) -> list[JellyfinAlbumSummary]:
|
|
return await service.get_recently_played(limit=limit)
|
|
|
|
|
|
@router.get("/favorites", response_model=list[JellyfinAlbumSummary])
|
|
async def get_jellyfin_favorites(
|
|
limit: int = Query(default=20, ge=1, le=50),
|
|
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
|
) -> list[JellyfinAlbumSummary]:
|
|
return await service.get_favorites(limit=limit)
|
|
|
|
|
|
@router.get("/favorites/expanded", response_model=JellyfinFavoritesExpanded)
|
|
async def get_jellyfin_favorites_expanded(
|
|
limit: int = Query(default=50, ge=1, le=100),
|
|
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
|
) -> JellyfinFavoritesExpanded:
|
|
try:
|
|
return await service.get_favorites_expanded(limit=limit)
|
|
except ExternalServiceError as e:
|
|
logger.error("Jellyfin service error getting expanded favorites: %s", e)
|
|
raise HTTPException(status_code=502, detail="Failed to communicate with Jellyfin")
|
|
except Exception: # noqa: BLE001
|
|
logger.exception("Unexpected error in expanded favorites")
|
|
raise HTTPException(status_code=500, detail="Internal error fetching expanded favorites")
|
|
|
|
|
|
@router.get("/filters", response_model=JellyfinFilterFacets)
|
|
async def get_jellyfin_filter_facets(
|
|
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
|
) -> JellyfinFilterFacets:
|
|
try:
|
|
return await service.get_filter_facets()
|
|
except ExternalServiceError as e:
|
|
logger.error("Jellyfin service error getting filter facets: %s", e)
|
|
raise HTTPException(status_code=502, detail="Failed to communicate with Jellyfin")
|
|
|
|
|
|
@router.get("/genres", response_model=list[str])
|
|
async def get_jellyfin_genres(
|
|
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
|
) -> list[str]:
|
|
try:
|
|
return await service.get_genres()
|
|
except ExternalServiceError as e:
|
|
logger.error("Jellyfin service error getting genres: %s", e)
|
|
raise HTTPException(status_code=502, detail="Failed to communicate with Jellyfin")
|
|
|
|
|
|
@router.get("/genres/songs", response_model=JellyfinTrackPage)
|
|
async def get_jellyfin_genre_songs(
|
|
genre: str = Query(..., min_length=1),
|
|
limit: int = Query(default=50, ge=1, le=100),
|
|
offset: int = Query(default=0, ge=0),
|
|
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
|
) -> JellyfinTrackPage:
|
|
tracks, total = await service.get_songs_by_genre(genre, limit=limit, offset=offset)
|
|
return JellyfinTrackPage(items=tracks, total=total, offset=offset, limit=limit)
|
|
|
|
|
|
@router.get("/instant-mix/artist/{artist_id}", response_model=list[JellyfinTrackInfo])
|
|
async def get_jellyfin_instant_mix_by_artist(
|
|
artist_id: str,
|
|
limit: int = Query(default=50, ge=1, le=100),
|
|
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
|
) -> list[JellyfinTrackInfo]:
|
|
return await service.get_instant_mix_by_artist(artist_id, limit=limit)
|
|
|
|
|
|
@router.get("/instant-mix/genre", response_model=list[JellyfinTrackInfo])
|
|
async def get_jellyfin_instant_mix_by_genre(
|
|
genre: str = Query(..., min_length=1),
|
|
limit: int = Query(default=50, ge=1, le=100),
|
|
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
|
) -> list[JellyfinTrackInfo]:
|
|
return await service.get_instant_mix_by_genre(genre, limit=limit)
|
|
|
|
|
|
@router.get("/instant-mix/{item_id}", response_model=list[JellyfinTrackInfo])
|
|
async def get_jellyfin_instant_mix(
|
|
item_id: str,
|
|
limit: int = Query(default=50, ge=1, le=100),
|
|
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
|
) -> list[JellyfinTrackInfo]:
|
|
return await service.get_instant_mix(item_id, limit=limit)
|
|
|
|
|
|
@router.get("/stats", response_model=JellyfinLibraryStats)
|
|
async def get_jellyfin_stats(
|
|
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
|
) -> JellyfinLibraryStats:
|
|
return await service.get_stats()
|
|
|
|
|
|
@router.get("/playlists", response_model=list[JellyfinPlaylistSummary])
|
|
async def get_jellyfin_playlists(
|
|
limit: int = Query(default=50, ge=1, le=200),
|
|
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
|
playlist_service: PlaylistService = Depends(get_playlist_service),
|
|
) -> list[JellyfinPlaylistSummary]:
|
|
try:
|
|
playlists = await service.list_playlists(limit=limit)
|
|
imported_ids = await playlist_service.get_imported_source_ids("jellyfin:")
|
|
for p in playlists:
|
|
if p.id in imported_ids:
|
|
p.is_imported = True
|
|
return playlists
|
|
except ExternalServiceError as e:
|
|
logger.error("Failed to get Jellyfin playlists: %s", e)
|
|
raise HTTPException(status_code=502, detail="Failed to get Jellyfin playlists")
|
|
|
|
|
|
@router.get("/playlists/{playlist_id}", response_model=JellyfinPlaylistDetail)
|
|
async def get_jellyfin_playlist_detail(
|
|
playlist_id: str,
|
|
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
|
) -> JellyfinPlaylistDetail:
|
|
try:
|
|
return await service.get_playlist_detail(playlist_id)
|
|
except ResourceNotFoundError:
|
|
raise HTTPException(status_code=404, detail="Jellyfin playlist not found")
|
|
except ExternalServiceError as e:
|
|
logger.error("Failed to get Jellyfin playlist %s: %s", playlist_id, e)
|
|
raise HTTPException(status_code=502, detail="Failed to get Jellyfin playlist")
|
|
|
|
|
|
@router.post("/playlists/{playlist_id}/import", response_model=JellyfinImportResult)
|
|
async def import_jellyfin_playlist(
|
|
playlist_id: str,
|
|
background_tasks: BackgroundTasks,
|
|
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
|
playlist_service: PlaylistService = Depends(get_playlist_service),
|
|
local_service=Depends(get_local_files_service),
|
|
nd_service=Depends(get_navidrome_library_service),
|
|
plex_service=Depends(get_plex_library_service),
|
|
) -> JellyfinImportResult:
|
|
try:
|
|
result = await service.import_playlist(playlist_id, playlist_service)
|
|
except ResourceNotFoundError:
|
|
raise HTTPException(status_code=404, detail="Jellyfin playlist not found")
|
|
except ExternalServiceError as e:
|
|
logger.error("Failed to import Jellyfin playlist %s: %s", playlist_id, e)
|
|
raise HTTPException(status_code=502, detail="Failed to import Jellyfin playlist")
|
|
|
|
if not result.already_imported:
|
|
background_tasks.add_task(
|
|
playlist_service.resolve_track_sources,
|
|
result.musicseerr_playlist_id,
|
|
jf_service=service,
|
|
local_service=local_service,
|
|
nd_service=nd_service,
|
|
plex_service=plex_service,
|
|
)
|
|
return result
|
|
|
|
|
|
@router.get("/sessions", response_model=JellyfinSessionsResponse)
|
|
async def get_jellyfin_sessions(
|
|
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
|
) -> JellyfinSessionsResponse:
|
|
return await service.get_sessions()
|
|
|
|
|
|
@router.get("/similar/{item_id}", response_model=list[JellyfinAlbumSummary])
|
|
async def get_jellyfin_similar_items(
|
|
item_id: str,
|
|
limit: int = Query(10, ge=1, le=50),
|
|
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
|
) -> list[JellyfinAlbumSummary]:
|
|
return await service.get_similar_items(item_id, limit=limit)
|
|
|
|
|
|
@router.get("/lyrics/{item_id}", response_model=JellyfinLyricsResponse)
|
|
async def get_jellyfin_lyrics(
|
|
item_id: str,
|
|
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
|
) -> JellyfinLyricsResponse:
|
|
lyrics = await service.get_lyrics(item_id)
|
|
if lyrics is None:
|
|
raise HTTPException(status_code=404, detail="Lyrics not available")
|
|
return lyrics
|