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
This commit is contained in:
Harvey
2026-04-13 23:39:01 +01:00
committed by GitHub
parent 90b7b67a10
commit 0f25ebc26d
177 changed files with 21156 additions and 769 deletions
+302 -4
View File
@@ -1,28 +1,120 @@
import logging
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query
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
from core.exceptions import ExternalServiceError
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),
@@ -30,11 +122,15 @@ async def get_jellyfin_albums(
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
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
@@ -83,6 +179,25 @@ async def match_jellyfin_album(
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),
@@ -92,6 +207,36 @@ async def get_jellyfin_artists(
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),
@@ -116,6 +261,32 @@ async def get_jellyfin_favorites(
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),
@@ -127,8 +298,135 @@ async def get_jellyfin_genres(
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
+279 -4
View File
@@ -1,23 +1,45 @@
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Path, Query
from fastapi.responses import Response
from api.v1.schemas.navidrome import (
NavidromeAlbumDetail,
NavidromeAlbumInfoSchema,
NavidromeAlbumMatch,
NavidromeAlbumPage,
NavidromeAlbumSummary,
NavidromeArtistIndexResponse,
NavidromeArtistInfoSchema,
NavidromeArtistPage,
NavidromeArtistSummary,
NavidromeGenreSongsResponse,
NavidromeHubResponse,
NavidromeImportResult,
NavidromeLibraryStats,
NavidromeLyricsResponse,
NavidromeMusicFolder,
NavidromeNowPlayingResponse,
NavidromePlaylistDetail,
NavidromePlaylistSummary,
NavidromeSearchResponse,
NavidromeTrackInfo,
NavidromeTrackPage,
)
from core.dependencies import get_navidrome_library_service, get_navidrome_repository
from core.exceptions import ExternalServiceError
from core.dependencies import (
get_jellyfin_library_service,
get_local_files_service,
get_navidrome_library_service,
get_navidrome_repository,
get_plex_library_service,
get_playlist_service,
)
from core.exceptions import ExternalServiceError, ResourceNotFoundError
from infrastructure.msgspec_fastapi import MsgSpecRoute
from infrastructure.resilience.retry import CircuitOpenError
from repositories.navidrome_repository import NavidromeRepository
from services.navidrome_library_service import NavidromeLibraryService
from services.playlist_service import PlaylistService
logger = logging.getLogger(__name__)
@@ -27,27 +49,61 @@ router = APIRouter(route_class=MsgSpecRoute, prefix="/navidrome", tags=["navidro
_SORT_MAP: dict[str, str] = {
"name": "alphabeticalByName",
"date_added": "newest",
"year": "alphabeticalByName",
"year": "byYear",
}
_NEEDS_REVERSE: dict[tuple[str, str], bool] = {
("name", "desc"): True,
("date_added", ""): True,
("date_added", "asc"): True,
}
@router.get("/hub", response_model=NavidromeHubResponse)
async def get_navidrome_hub(
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
playlist_service: PlaylistService = Depends(get_playlist_service),
) -> NavidromeHubResponse:
try:
hub = await service.get_hub_data()
imported_ids = await playlist_service.get_imported_source_ids("navidrome:")
for p in hub.playlists:
if p.id in imported_ids:
p.is_imported = True
return hub
except ExternalServiceError as e:
logger.error("Navidrome service error getting hub data: %s", e)
raise HTTPException(status_code=502, detail="Failed to communicate with Navidrome")
@router.get("/albums", response_model=NavidromeAlbumPage)
async def get_navidrome_albums(
limit: int = Query(default=48, ge=1, le=500, alias="limit"),
offset: int = Query(default=0, ge=0),
sort_by: str = Query(default="name"),
sort_order: str = Query(default=""),
genre: str = Query(default=""),
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
) -> NavidromeAlbumPage:
subsonic_type = "byGenre" if genre else _SORT_MAP.get(sort_by, "alphabeticalByName")
year_kwargs: dict[str, int] = {}
if subsonic_type == "byYear":
if sort_order == "desc":
year_kwargs = {"from_year": 9999, "to_year": 0}
else:
year_kwargs = {"from_year": 0, "to_year": 9999}
try:
items = await service.get_albums(
type=subsonic_type, size=limit, offset=offset, genre=genre if genre else None,
**year_kwargs,
)
except ExternalServiceError as e:
logger.error("Navidrome service error getting albums: %s", e)
raise HTTPException(status_code=502, detail="Failed to communicate with Navidrome")
if not genre and _NEEDS_REVERSE.get((sort_by, sort_order), False):
items = list(reversed(items))
try:
stats = await service.get_stats()
total = stats.total_albums if len(items) >= limit else offset + len(items)
@@ -69,6 +125,21 @@ async def get_navidrome_album_detail(
return result
@router.get("/artists/browse", response_model=NavidromeArtistPage)
async def browse_navidrome_artists(
limit: int = Query(48, ge=1, le=100),
offset: int = Query(0, ge=0),
search: str = Query(""),
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
) -> NavidromeArtistPage:
try:
items, total = await service.browse_artists(size=limit, offset=offset, search=search)
return NavidromeArtistPage(items=items, total=total, offset=offset, limit=limit)
except ExternalServiceError as e:
logger.error("Navidrome service error browsing artists: %s", e)
raise HTTPException(status_code=502, detail="Failed to communicate with Navidrome")
@router.get("/artists", response_model=list[NavidromeArtistSummary])
async def get_navidrome_artists(
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
@@ -76,6 +147,17 @@ async def get_navidrome_artists(
return await service.get_artists()
@router.get("/artists/index", response_model=NavidromeArtistIndexResponse)
async def get_navidrome_artists_index(
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
) -> NavidromeArtistIndexResponse:
try:
return await service.get_artists_index()
except ExternalServiceError as e:
logger.error("Navidrome service error getting artist index: %s", e)
raise HTTPException(status_code=502, detail="Failed to communicate with Navidrome")
@router.get("/artists/{artist_id}")
async def get_navidrome_artist_detail(
artist_id: str,
@@ -87,6 +169,21 @@ async def get_navidrome_artist_detail(
return result
@router.get("/tracks", response_model=NavidromeTrackPage)
async def browse_navidrome_tracks(
limit: int = Query(48, ge=1, le=100),
offset: int = Query(0, ge=0),
search: str = Query(""),
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
) -> NavidromeTrackPage:
try:
items, total = await service.browse_tracks(size=limit, offset=offset, search=search)
return NavidromeTrackPage(items=items, total=total, offset=offset, limit=limit)
except ExternalServiceError as e:
logger.error("Navidrome service error browsing tracks: %s", e)
raise HTTPException(status_code=502, detail="Failed to communicate with Navidrome")
@router.get("/search", response_model=NavidromeSearchResponse)
async def search_navidrome(
q: str = Query(..., min_length=1),
@@ -111,6 +208,13 @@ async def get_navidrome_favorites(
return result.albums
@router.get("/favorites/expanded", response_model=NavidromeSearchResponse)
async def get_navidrome_favorites_expanded(
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
) -> NavidromeSearchResponse:
return await service.get_favorites()
@router.get("/genres", response_model=list[str])
async def get_navidrome_genres(
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
@@ -122,6 +226,55 @@ async def get_navidrome_genres(
raise HTTPException(status_code=502, detail="Failed to communicate with Navidrome")
@router.get("/genres/songs", response_model=NavidromeGenreSongsResponse)
async def get_navidrome_multi_genre_songs(
genres: str = Query(..., min_length=1),
count: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
) -> NavidromeGenreSongsResponse:
genre_list = [g.strip() for g in genres.split(",") if g.strip()]
if not genre_list:
return NavidromeGenreSongsResponse(songs=[], genre="")
if len(genre_list) == 1:
return await service.get_songs_by_genre(genre=genre_list[0], count=count, offset=offset)
return await service.get_songs_by_genres(genres=genre_list, count=count, offset=offset)
@router.get("/genres/{genre}/songs", response_model=NavidromeGenreSongsResponse)
async def get_navidrome_genre_songs(
genre: str = Path(...),
count: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
) -> NavidromeGenreSongsResponse:
try:
return await service.get_songs_by_genre(genre=genre, count=count, offset=offset)
except ExternalServiceError as e:
logger.error("Navidrome service error getting songs by genre: %s", e)
raise HTTPException(status_code=502, detail="Failed to communicate with Navidrome")
@router.get("/music-folders", response_model=list[NavidromeMusicFolder])
async def get_navidrome_music_folders(
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
) -> list[NavidromeMusicFolder]:
try:
return await service.get_music_folders()
except ExternalServiceError as e:
logger.error("Navidrome service error getting music folders: %s", e)
raise HTTPException(status_code=502, detail="Failed to communicate with Navidrome")
@router.get("/random", response_model=list[NavidromeTrackInfo])
async def get_navidrome_random(
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
size: int = Query(default=20, ge=1, le=50),
genre: str | None = Query(default=None),
) -> list[NavidromeTrackInfo]:
return await service.get_random_songs(size=size, genre=genre)
@router.get("/stats", response_model=NavidromeLibraryStats)
async def get_navidrome_stats(
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
@@ -161,3 +314,125 @@ async def match_navidrome_album(
except ExternalServiceError as e:
logger.error("Failed to match Navidrome album %s: %s", album_id, e)
raise HTTPException(status_code=502, detail="Failed to match Navidrome album")
@router.get("/playlists", response_model=list[NavidromePlaylistSummary])
async def get_navidrome_playlists(
limit: int = Query(default=50, ge=1, le=200),
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
playlist_service: PlaylistService = Depends(get_playlist_service),
) -> list[NavidromePlaylistSummary]:
try:
playlists = await service.list_playlists(limit=limit)
imported_ids = await playlist_service.get_imported_source_ids("navidrome:")
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 Navidrome playlists: %s", e)
raise HTTPException(status_code=502, detail="Failed to get Navidrome playlists")
@router.get("/playlists/{playlist_id}", response_model=NavidromePlaylistDetail)
async def get_navidrome_playlist_detail(
playlist_id: str,
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
) -> NavidromePlaylistDetail:
try:
return await service.get_playlist_detail(playlist_id)
except ResourceNotFoundError:
raise HTTPException(status_code=404, detail="Navidrome playlist not found")
except ExternalServiceError as e:
logger.error("Failed to get Navidrome playlist %s: %s", playlist_id, e)
raise HTTPException(status_code=502, detail="Failed to get Navidrome playlist")
@router.post("/playlists/{playlist_id}/import", response_model=NavidromeImportResult)
async def import_navidrome_playlist(
playlist_id: str,
background_tasks: BackgroundTasks,
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
playlist_service: PlaylistService = Depends(get_playlist_service),
jf_service=Depends(get_jellyfin_library_service),
local_service=Depends(get_local_files_service),
plex_service=Depends(get_plex_library_service),
) -> NavidromeImportResult:
try:
result = await service.import_playlist(playlist_id, playlist_service)
except ResourceNotFoundError:
raise HTTPException(status_code=404, detail="Navidrome playlist not found")
except ExternalServiceError as e:
logger.error("Failed to import Navidrome playlist %s: %s", playlist_id, e)
raise HTTPException(status_code=502, detail="Failed to import Navidrome playlist")
if not result.already_imported:
background_tasks.add_task(
playlist_service.resolve_track_sources,
result.musicseerr_playlist_id,
jf_service=jf_service,
local_service=local_service,
nd_service=service,
plex_service=plex_service,
)
return result
@router.get("/now-playing", response_model=NavidromeNowPlayingResponse)
async def get_navidrome_now_playing(
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
) -> NavidromeNowPlayingResponse:
return await service.get_now_playing()
@router.get("/top-songs/{artist_name}", response_model=list[NavidromeTrackInfo])
async def get_navidrome_top_songs(
artist_name: str = Path(..., min_length=1, max_length=256),
count: int = Query(20, ge=1, le=50),
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
) -> list[NavidromeTrackInfo]:
return await service.get_top_songs(artist_name, count=count)
@router.get("/similar-songs/{song_id}", response_model=list[NavidromeTrackInfo])
async def get_navidrome_similar_songs(
song_id: str,
count: int = Query(20, ge=1, le=50),
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
) -> list[NavidromeTrackInfo]:
return await service.get_similar_songs(song_id, count=count)
@router.get("/artist-info/{artist_id}", response_model=NavidromeArtistInfoSchema)
async def get_navidrome_artist_info(
artist_id: str,
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
) -> NavidromeArtistInfoSchema:
info = await service.get_artist_info(artist_id)
if info is None:
return NavidromeArtistInfoSchema(navidrome_id=artist_id)
return info
@router.get("/album-info/{album_id}", response_model=NavidromeAlbumInfoSchema)
async def get_navidrome_album_info(
album_id: str,
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
) -> NavidromeAlbumInfoSchema:
info = await service.get_album_info(album_id)
if info is None:
return NavidromeAlbumInfoSchema(album_id=album_id)
return info
@router.get("/lyrics/{song_id}", response_model=NavidromeLyricsResponse)
async def get_navidrome_lyrics(
song_id: str,
artist: str = Query("", description="Artist name for fallback lookup"),
title: str = Query("", description="Track title for fallback lookup"),
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
) -> NavidromeLyricsResponse:
lyrics = await service.get_lyrics(song_id, artist=artist, title=title)
if lyrics is None:
raise HTTPException(status_code=404, detail="Lyrics not available")
return lyrics
+13 -3
View File
@@ -21,7 +21,7 @@ from api.v1.schemas.playlists import (
UpdatePlaylistRequest,
UpdateTrackRequest,
)
from core.dependencies import JellyfinLibraryServiceDep, LocalFilesServiceDep, NavidromeLibraryServiceDep, PlaylistServiceDep
from core.dependencies import JellyfinLibraryServiceDep, LocalFilesServiceDep, NavidromeLibraryServiceDep, PlexLibraryServiceDep, PlaylistServiceDep
from core.exceptions import PlaylistNotFoundError
from infrastructure.msgspec_fastapi import MsgSpecBody, MsgSpecRoute
@@ -74,6 +74,7 @@ def _track_to_response(t) -> PlaylistTrackResponse:
disc_number=t.disc_number,
duration=t.duration,
created_at=t.created_at,
plex_rating_key=getattr(t, "plex_rating_key", None),
)
@@ -91,6 +92,7 @@ async def list_playlists(
total_duration=s.total_duration,
cover_urls=[_normalize_cover_url(u) for u in s.cover_urls] if s.cover_urls else [],
custom_cover_url=_custom_cover_url(s.id, s.cover_image_path),
source_ref=s.source_ref,
created_at=s.created_at,
updated_at=s.updated_at,
)
@@ -119,6 +121,7 @@ async def create_playlist(
id=playlist.id,
name=playlist.name,
custom_cover_url=_custom_cover_url(playlist.id, playlist.cover_image_path),
source_ref=playlist.source_ref,
tracks=[],
track_count=0,
total_duration=None,
@@ -141,6 +144,7 @@ async def get_playlist(
name=playlist.name,
cover_urls=cover_urls,
custom_cover_url=_custom_cover_url(playlist.id, playlist.cover_image_path),
source_ref=playlist.source_ref,
tracks=track_responses,
track_count=len(tracks),
total_duration=total_duration or None,
@@ -164,6 +168,7 @@ async def update_playlist(
name=playlist.name,
cover_urls=cover_urls,
custom_cover_url=_custom_cover_url(playlist.id, playlist.cover_image_path),
source_ref=playlist.source_ref,
tracks=track_responses,
track_count=len(tracks),
total_duration=total_duration or None,
@@ -206,6 +211,7 @@ async def add_tracks(
"track_number": t.track_number,
"disc_number": t.disc_number,
"duration": int(t.duration) if t.duration is not None else None,
"plex_rating_key": t.plex_rating_key,
}
for t in body.tracks
]
@@ -269,6 +275,7 @@ async def update_track(
jf_service: JellyfinLibraryServiceDep,
local_service: LocalFilesServiceDep,
nd_service: NavidromeLibraryServiceDep,
plex_service: PlexLibraryServiceDep,
body: UpdateTrackRequest = MsgSpecBody(UpdateTrackRequest),
) -> PlaylistTrackResponse:
result = await service.update_track_source(
@@ -278,6 +285,7 @@ async def update_track(
jf_service=jf_service,
local_service=local_service,
nd_service=nd_service,
plex_service=plex_service,
)
return _track_to_response(result)
@@ -292,9 +300,11 @@ async def resolve_sources(
jf_service: JellyfinLibraryServiceDep,
local_service: LocalFilesServiceDep,
nd_service: NavidromeLibraryServiceDep,
plex_service: PlexLibraryServiceDep,
) -> ResolveSourcesResponse:
sources = await service.resolve_track_sources(
playlist_id, jf_service=jf_service, local_service=local_service, nd_service=nd_service,
playlist_id, jf_service=jf_service, local_service=local_service,
nd_service=nd_service, plex_service=plex_service,
)
return ResolveSourcesResponse(sources=sources)
@@ -305,7 +315,7 @@ async def upload_cover(
service: PlaylistServiceDep,
cover_image: UploadFile = File(...),
) -> CoverUploadResponse:
max_size = 2 * 1024 * 1024 # 2 MB
max_size = 2 * 1024 * 1024
chunk_size = 8192
chunks: list[bytes] = []
total = 0
+62
View File
@@ -0,0 +1,62 @@
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException
from api.v1.schemas.settings import PlexOAuthPinResponse, PlexOAuthPollResponse
from core.dependencies import get_plex_repository, get_preferences_service
from core.exceptions import PlexApiError
from infrastructure.msgspec_fastapi import MsgSpecRoute
from repositories.plex_repository import PlexRepository
from services.preferences_service import PreferencesService
logger = logging.getLogger(__name__)
router = APIRouter(route_class=MsgSpecRoute, prefix="/plex", tags=["plex-auth"])
def _get_or_create_client_id(preferences: PreferencesService) -> str:
return preferences.get_or_create_setting("plex_client_id", lambda: str(uuid.uuid4()))
@router.post("/auth/pin", response_model=PlexOAuthPinResponse)
async def create_plex_pin(
preferences: PreferencesService = Depends(get_preferences_service),
repo: PlexRepository = Depends(get_plex_repository),
):
try:
client_id = _get_or_create_client_id(preferences)
pin = await repo.create_oauth_pin(client_id)
auth_url = (
f"https://app.plex.tv/auth#?clientID={client_id}"
f"&code={pin.code}"
f"&context%5Bdevice%5D%5Bproduct%5D=MusicSeerr"
)
return PlexOAuthPinResponse(
pin_id=pin.id,
pin_code=pin.code,
auth_url=auth_url,
)
except PlexApiError as e:
logger.error("Failed to create Plex OAuth pin: %s", e)
raise HTTPException(status_code=502, detail="Could not start Plex authentication")
except Exception as e:
logger.exception("Unexpected error creating Plex pin: %s", e)
raise HTTPException(status_code=500, detail="Internal error during Plex authentication")
@router.get("/auth/poll", response_model=PlexOAuthPollResponse)
async def poll_plex_pin(
pin_id: int,
preferences: PreferencesService = Depends(get_preferences_service),
repo: PlexRepository = Depends(get_plex_repository),
):
try:
client_id = _get_or_create_client_id(preferences)
token = await repo.poll_oauth_pin(pin_id, client_id)
if token:
return PlexOAuthPollResponse(completed=True, auth_token=token)
return PlexOAuthPollResponse(completed=False)
except Exception as e:
logger.exception("Error polling Plex pin %d: %s", pin_id, e)
raise HTTPException(status_code=502, detail="Error polling Plex authentication status")
+386
View File
@@ -0,0 +1,386 @@
import logging
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
from fastapi.responses import Response
from api.v1.schemas.plex import (
PlexAlbumDetail,
PlexAlbumMatch,
PlexAlbumPage,
PlexAlbumSummary,
PlexAnalyticsResponse,
PlexArtistIndexResponse,
PlexArtistPage,
PlexArtistSummary,
PlexDiscoveryResponse,
PlexHistoryResponse,
PlexHubResponse,
PlexImportResult,
PlexLibraryStats,
PlexPlaylistDetail,
PlexPlaylistSummary,
PlexSearchResponse,
PlexSessionsResponse,
PlexTrackPage,
)
from core.dependencies import (
get_jellyfin_library_service,
get_local_files_service,
get_navidrome_library_service,
get_plex_library_service,
get_plex_repository,
get_playlist_service,
)
from core.exceptions import ExternalServiceError, ResourceNotFoundError
from infrastructure.msgspec_fastapi import MsgSpecRoute
from repositories.plex_repository import PlexRepository
from services.plex_library_service import PlexLibraryService
from services.playlist_service import PlaylistService
logger = logging.getLogger(__name__)
router = APIRouter(route_class=MsgSpecRoute, prefix="/plex", tags=["plex-library"])
_PLEX_SORT_FIELD: dict[str, str] = {
"name": "titleSort",
"date_added": "addedAt",
"year": "year",
"play_count": "viewCount",
"rating": "userRating",
"last_played": "lastViewedAt",
}
@router.get("/hub", response_model=PlexHubResponse)
async def get_plex_hub(
service: PlexLibraryService = Depends(get_plex_library_service),
playlist_service: PlaylistService = Depends(get_playlist_service),
) -> PlexHubResponse:
try:
hub = await service.get_hub_data()
imported_ids = await playlist_service.get_imported_source_ids("plex:")
for p in hub.playlists:
if p.id in imported_ids:
p.is_imported = True
return hub
except ExternalServiceError as e:
logger.error("Plex service error getting hub data: %s", e)
raise HTTPException(status_code=502, detail="Failed to communicate with Plex")
@router.get("/albums", response_model=PlexAlbumPage)
async def get_plex_albums(
limit: int = Query(default=48, ge=1, le=500, alias="limit"),
offset: int = Query(default=0, ge=0),
sort_by: str = Query(default="name"),
sort_order: str = Query(default=""),
genre: str = Query(default=""),
mood: str = Query(default=""),
decade: str = Query(default=""),
service: PlexLibraryService = Depends(get_plex_library_service),
) -> PlexAlbumPage:
field = _PLEX_SORT_FIELD.get(sort_by, "titleSort")
direction = "desc" if sort_order == "desc" else "asc"
plex_sort = f"{field}:{direction}"
try:
items, total_from_plex = await service.get_albums(
size=limit, offset=offset, sort=plex_sort,
genre=genre if genre else None,
mood=mood if mood else None,
decade=decade if decade else None,
)
except ExternalServiceError as e:
logger.error("Plex service error getting albums: %s", e)
raise HTTPException(status_code=502, detail="Failed to communicate with Plex")
return PlexAlbumPage(items=items, total=total_from_plex)
@router.get("/albums/{rating_key}", response_model=PlexAlbumDetail)
async def get_plex_album_detail(
rating_key: str,
service: PlexLibraryService = Depends(get_plex_library_service),
) -> PlexAlbumDetail:
result = await service.get_album_detail(rating_key)
if not result:
raise HTTPException(status_code=404, detail="Album not found")
return result
@router.get("/artists/browse", response_model=PlexArtistPage)
async def browse_plex_artists(
limit: int = Query(48, ge=1, le=100),
offset: int = Query(0, ge=0),
sort: str = Query("titleSort:asc"),
search: str = Query(""),
service: PlexLibraryService = Depends(get_plex_library_service),
) -> PlexArtistPage:
try:
items, total = await service.browse_artists(size=limit, offset=offset, sort=sort, search=search)
return PlexArtistPage(items=items, total=total, offset=offset, limit=limit)
except ExternalServiceError as e:
logger.error("Plex service error browsing artists: %s", e)
raise HTTPException(status_code=502, detail="Failed to communicate with Plex")
@router.get("/artists", response_model=list[PlexArtistSummary])
async def get_plex_artists(
service: PlexLibraryService = Depends(get_plex_library_service),
) -> list[PlexArtistSummary]:
try:
return await service.get_artists()
except ExternalServiceError as e:
logger.error("Plex service error getting artists: %s", e)
raise HTTPException(status_code=502, detail="Failed to communicate with Plex")
@router.get("/artists/index", response_model=PlexArtistIndexResponse)
async def get_plex_artists_index(
service: PlexLibraryService = Depends(get_plex_library_service),
) -> PlexArtistIndexResponse:
try:
return await service.get_artists_index()
except ExternalServiceError as e:
logger.error("Plex service error getting artist index: %s", e)
raise HTTPException(status_code=502, detail="Failed to communicate with Plex")
@router.get("/tracks", response_model=PlexTrackPage)
async def browse_plex_tracks(
limit: int = Query(48, ge=1, le=100),
offset: int = Query(0, ge=0),
sort: str = Query("titleSort:asc"),
search: str = Query(""),
service: PlexLibraryService = Depends(get_plex_library_service),
) -> PlexTrackPage:
try:
items, total = await service.browse_tracks(size=limit, offset=offset, sort=sort, search=search)
return PlexTrackPage(items=items, total=total, offset=offset, limit=limit)
except ExternalServiceError as e:
logger.error("Plex service error browsing tracks: %s", e)
raise HTTPException(status_code=502, detail="Failed to communicate with Plex")
@router.get("/search", response_model=PlexSearchResponse)
async def search_plex(
q: str = Query(..., min_length=1),
service: PlexLibraryService = Depends(get_plex_library_service),
) -> PlexSearchResponse:
try:
return await service.search(q)
except ExternalServiceError as e:
logger.error("Plex service error searching: %s", e)
raise HTTPException(status_code=502, detail="Failed to communicate with Plex")
@router.get("/recent", response_model=list[PlexAlbumSummary])
async def get_plex_recent(
limit: int = Query(default=20, ge=1, le=50),
service: PlexLibraryService = Depends(get_plex_library_service),
) -> list[PlexAlbumSummary]:
try:
return await service.get_recent(limit=limit)
except ExternalServiceError as e:
logger.error("Plex service error getting recent: %s", e)
raise HTTPException(status_code=502, detail="Failed to communicate with Plex")
@router.get("/recently-added", response_model=list[PlexAlbumSummary])
async def get_plex_recently_added(
limit: int = Query(default=20, ge=1, le=50),
service: PlexLibraryService = Depends(get_plex_library_service),
) -> list[PlexAlbumSummary]:
try:
return await service.get_recently_added_albums(limit=limit)
except ExternalServiceError as e:
logger.error("Plex service error getting recently added: %s", e)
raise HTTPException(status_code=502, detail="Failed to communicate with Plex")
@router.get("/genres", response_model=list[str])
async def get_plex_genres(
service: PlexLibraryService = Depends(get_plex_library_service),
) -> list[str]:
try:
return await service.get_genres()
except ExternalServiceError as e:
logger.error("Plex service error getting genres: %s", e)
raise HTTPException(status_code=502, detail="Failed to communicate with Plex")
@router.get("/genres/songs", response_model=PlexTrackPage)
async def get_plex_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: PlexLibraryService = Depends(get_plex_library_service),
) -> PlexTrackPage:
tracks, total = await service.get_songs_by_genre(genre, limit=limit, offset=offset)
return PlexTrackPage(items=tracks, total=total, offset=offset, limit=limit)
@router.get("/moods", response_model=list[str])
async def get_plex_moods(
service: PlexLibraryService = Depends(get_plex_library_service),
) -> list[str]:
try:
return await service.get_moods()
except ExternalServiceError as e:
logger.error("Plex service error getting moods: %s", e)
raise HTTPException(status_code=502, detail="Failed to communicate with Plex")
@router.get("/stats", response_model=PlexLibraryStats)
async def get_plex_stats(
service: PlexLibraryService = Depends(get_plex_library_service),
) -> PlexLibraryStats:
try:
return await service.get_stats()
except ExternalServiceError as e:
logger.error("Plex service error getting stats: %s", e)
raise HTTPException(status_code=502, detail="Failed to communicate with Plex")
@router.get("/discovery", response_model=PlexDiscoveryResponse)
async def get_plex_discovery(
count: int = Query(default=10, ge=1, le=20),
service: PlexLibraryService = Depends(get_plex_library_service),
) -> PlexDiscoveryResponse:
return await service.get_discovery_hubs(count=count)
@router.get("/thumb/{rating_key}")
async def get_plex_thumb(
rating_key: str,
size: int = Query(default=500, ge=32, le=1200),
repo: PlexRepository = Depends(get_plex_repository),
) -> Response:
try:
image_bytes, content_type = await repo.proxy_thumb(rating_key, size)
return Response(
content=image_bytes,
media_type=content_type,
headers={"Cache-Control": "public, max-age=31536000, immutable"},
)
except ExternalServiceError as e:
logger.warning("Plex thumb failed for %s: %s", rating_key, e)
raise HTTPException(status_code=502, detail="Failed to fetch thumbnail")
@router.get("/playlist-thumb/{rating_key}")
async def get_plex_playlist_thumb(
rating_key: str,
size: int = Query(default=500, ge=32, le=1200),
repo: PlexRepository = Depends(get_plex_repository),
) -> Response:
try:
image_bytes, content_type = await repo.proxy_playlist_composite(rating_key, size)
return Response(
content=image_bytes,
media_type=content_type,
headers={"Cache-Control": "public, max-age=86400"},
)
except ExternalServiceError as e:
logger.warning("Plex playlist composite failed for %s: %s", rating_key, e)
raise HTTPException(status_code=502, detail="Failed to fetch playlist thumbnail")
@router.get("/album-match/{album_id}", response_model=PlexAlbumMatch)
async def match_plex_album(
album_id: str,
name: str = Query(default=""),
artist: str = Query(default=""),
service: PlexLibraryService = Depends(get_plex_library_service),
) -> PlexAlbumMatch:
try:
return await service.get_album_match(
album_id=album_id, album_name=name, artist_name=artist,
)
except ExternalServiceError as e:
logger.error("Failed to match Plex album %s: %s", album_id, e)
raise HTTPException(status_code=502, detail="Failed to match Plex album")
@router.get("/playlists", response_model=list[PlexPlaylistSummary])
async def get_plex_playlists(
limit: int = Query(default=50, ge=1, le=200),
service: PlexLibraryService = Depends(get_plex_library_service),
playlist_service: PlaylistService = Depends(get_playlist_service),
) -> list[PlexPlaylistSummary]:
try:
playlists = await service.list_playlists(limit=limit)
imported_ids = await playlist_service.get_imported_source_ids("plex:")
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 Plex playlists: %s", e)
raise HTTPException(status_code=502, detail="Failed to get Plex playlists")
@router.get("/playlists/{playlist_id}", response_model=PlexPlaylistDetail)
async def get_plex_playlist_detail(
playlist_id: str,
service: PlexLibraryService = Depends(get_plex_library_service),
) -> PlexPlaylistDetail:
try:
return await service.get_playlist_detail(playlist_id)
except ResourceNotFoundError:
raise HTTPException(status_code=404, detail="Plex playlist not found")
except ExternalServiceError as e:
logger.error("Failed to get Plex playlist %s: %s", playlist_id, e)
raise HTTPException(status_code=502, detail="Failed to get Plex playlist")
@router.post("/playlists/{playlist_id}/import", response_model=PlexImportResult)
async def import_plex_playlist(
playlist_id: str,
background_tasks: BackgroundTasks,
service: PlexLibraryService = Depends(get_plex_library_service),
playlist_service: PlaylistService = Depends(get_playlist_service),
jf_service=Depends(get_jellyfin_library_service),
local_service=Depends(get_local_files_service),
nd_service=Depends(get_navidrome_library_service),
) -> PlexImportResult:
try:
result = await service.import_playlist(playlist_id, playlist_service)
except ResourceNotFoundError:
raise HTTPException(status_code=404, detail="Plex playlist not found")
except ExternalServiceError as e:
logger.error("Failed to import Plex playlist %s: %s", playlist_id, e)
raise HTTPException(status_code=502, detail="Failed to import Plex playlist")
if not result.already_imported:
background_tasks.add_task(
playlist_service.resolve_track_sources,
result.musicseerr_playlist_id,
jf_service=jf_service,
local_service=local_service,
nd_service=nd_service,
plex_service=service,
)
return result
@router.get("/sessions", response_model=PlexSessionsResponse)
async def get_plex_sessions(
service: PlexLibraryService = Depends(get_plex_library_service),
) -> PlexSessionsResponse:
return await service.get_sessions()
@router.get("/history", response_model=PlexHistoryResponse)
async def get_plex_history(
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
service: PlexLibraryService = Depends(get_plex_library_service),
) -> PlexHistoryResponse:
return await service.get_history(limit=limit, offset=offset)
@router.get("/analytics", response_model=PlexAnalyticsResponse)
async def get_plex_analytics(
service: PlexLibraryService = Depends(get_plex_library_service),
) -> PlexAnalyticsResponse:
return await service.get_analytics()
+51
View File
@@ -22,7 +22,10 @@ from api.v1.schemas.settings import (
LastFmVerifyResponse,
ScrobbleSettings,
PrimaryMusicSourceSettings,
PlexConnectionSettings,
PlexVerifyResponse,
)
from api.v1.schemas.plex import PlexLibrarySectionInfo
from api.v1.schemas.common import VerifyConnectionResponse
from api.v1.schemas.advanced_settings import AdvancedSettingsFrontend, FrontendCacheTTLs, _is_masked_api_key
from core.dependencies import (
@@ -99,6 +102,7 @@ async def get_frontend_cache_ttls(
search=backend_settings.frontend_ttl_search,
local_files_sidebar=backend_settings.frontend_ttl_local_files_sidebar,
jellyfin_sidebar=backend_settings.frontend_ttl_jellyfin_sidebar,
plex_sidebar=backend_settings.frontend_ttl_plex_sidebar,
playlist_sources=backend_settings.frontend_ttl_playlist_sources,
discover_queue_polling_interval=backend_settings.discover_queue_polling_interval,
discover_queue_auto_generate=backend_settings.discover_queue_auto_generate,
@@ -287,6 +291,53 @@ async def verify_navidrome_connection(
return VerifyConnectionResponse(valid=result.valid, message=result.message)
@router.get("/plex", response_model=PlexConnectionSettings)
async def get_plex_settings(
preferences_service: PreferencesService = Depends(get_preferences_service),
):
return preferences_service.get_plex_connection()
@router.put("/plex", response_model=PlexConnectionSettings)
async def update_plex_settings(
settings: PlexConnectionSettings = MsgSpecBody(PlexConnectionSettings),
preferences_service: PreferencesService = Depends(get_preferences_service),
settings_service: SettingsService = Depends(get_settings_service),
):
try:
preferences_service.save_plex_connection(settings)
await settings_service.on_plex_settings_changed(enabled=settings.enabled)
logger.info("Updated Plex connection settings")
return preferences_service.get_plex_connection()
except ConfigurationError as e:
logger.warning("Configuration error updating Plex settings: %s", e)
raise HTTPException(status_code=400, detail="Plex settings are incomplete or invalid")
@router.post("/plex/verify", response_model=PlexVerifyResponse)
async def verify_plex_connection(
settings: PlexConnectionSettings = MsgSpecBody(PlexConnectionSettings),
settings_service: SettingsService = Depends(get_settings_service),
):
result = await settings_service.verify_plex(settings)
libs = [PlexLibrarySectionInfo(key=k, title=t) for k, t in result.libraries]
return PlexVerifyResponse(valid=result.valid, message=result.message, libraries=libs)
@router.get("/plex/libraries", response_model=list[PlexLibrarySectionInfo])
async def get_plex_libraries(
settings_service: SettingsService = Depends(get_settings_service),
):
try:
libs = await settings_service.get_plex_libraries()
return [PlexLibrarySectionInfo(key=k, title=t) for k, t in libs]
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.exception("Failed to fetch Plex libraries: %s", e)
raise HTTPException(status_code=502, detail="Could not fetch libraries from Plex")
@router.get("/listenbrainz", response_model=ListenBrainzConnectionSettings)
async def get_listenbrainz_settings(
preferences_service: PreferencesService = Depends(get_preferences_service),
+70 -2
View File
@@ -13,12 +13,14 @@ from core.dependencies import (
get_jellyfin_playback_service,
get_local_files_service,
get_navidrome_playback_service,
get_plex_playback_service,
)
from core.exceptions import ExternalServiceError, PlaybackNotAllowedError, ResourceNotFoundError
from infrastructure.msgspec_fastapi import MsgSpecBody, MsgSpecRoute
from services.jellyfin_playback_service import JellyfinPlaybackService
from services.local_files_service import LocalFilesService
from services.navidrome_playback_service import NavidromePlaybackService
from services.plex_playback_service import PlexPlaybackService
logger = logging.getLogger(__name__)
@@ -138,7 +140,7 @@ async def head_local_file(
except FileNotFoundError:
raise HTTPException(status_code=404, detail="Track file not found on disk")
except PermissionError:
raise HTTPException(status_code=403, detail="Access denied path outside music directory")
raise HTTPException(status_code=403, detail="Access denied: path is outside the music directory")
except ExternalServiceError as e:
logger.error("Local head error for track %s: %s", track_id, e)
raise HTTPException(status_code=502, detail="Failed to check local file")
@@ -170,7 +172,7 @@ async def stream_local_file(
except FileNotFoundError:
raise HTTPException(status_code=404, detail="Track file not found on disk")
except PermissionError:
raise HTTPException(status_code=403, detail="Access denied path outside music directory")
raise HTTPException(status_code=403, detail="Access denied: path is outside the music directory")
except ExternalServiceError as e:
detail = str(e)
if "Range not satisfiable" in detail:
@@ -228,3 +230,69 @@ async def navidrome_now_playing(
) -> dict[str, str]:
ok = await playback_service.report_now_playing(item_id)
return {"status": "ok" if ok else "error"}
@router.post("/navidrome/{item_id}/stopped")
async def navidrome_stopped(
item_id: str,
playback_service: NavidromePlaybackService = Depends(get_navidrome_playback_service),
) -> dict[str, str]:
await playback_service.clear_now_playing(item_id)
return {"status": "ok"}
@router.head("/plex/{part_key:path}")
async def head_plex_audio(
part_key: str,
playback_service: PlexPlaybackService = Depends(get_plex_playback_service),
) -> Response:
try:
return await playback_service.proxy_head(part_key)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid stream request")
except ExternalServiceError:
raise HTTPException(status_code=502, detail="Failed to stream from Plex")
@router.get("/plex/{part_key:path}")
async def stream_plex_audio(
part_key: str,
request: Request,
playback_service: PlexPlaybackService = Depends(get_plex_playback_service),
) -> StreamingResponse:
try:
return await playback_service.proxy_stream(part_key, request.headers.get("Range"))
except ValueError:
raise HTTPException(status_code=400, detail="Invalid stream request")
except ExternalServiceError as e:
detail = str(e)
if "416" in detail or "Range not satisfiable" in detail:
raise HTTPException(status_code=416, detail="Range not satisfiable")
raise HTTPException(status_code=502, detail="Failed to stream from Plex")
@router.post("/plex/{rating_key}/scrobble")
async def scrobble_plex(
rating_key: str,
playback_service: PlexPlaybackService = Depends(get_plex_playback_service),
) -> dict[str, str]:
ok = await playback_service.scrobble(rating_key)
return {"status": "ok" if ok else "error"}
@router.post("/plex/{rating_key}/now-playing")
async def plex_now_playing(
rating_key: str,
playback_service: PlexPlaybackService = Depends(get_plex_playback_service),
) -> dict[str, str]:
ok = await playback_service.report_now_playing(rating_key)
return {"status": "ok" if ok else "error"}
@router.post("/plex/{rating_key}/stopped")
async def plex_stopped(
rating_key: str,
playback_service: PlexPlaybackService = Depends(get_plex_playback_service),
) -> dict[str, str]:
ok = await playback_service.report_stopped(rating_key)
return {"status": "ok" if ok else "error"}
+36 -1
View File
@@ -22,7 +22,7 @@ def _coerce_positive_int(value: object, field_name: str) -> int:
def _mask_api_key(key: str) -> str:
if len(key) > 3:
return f"***{key[-3:]}"
return f"***...{key[-3:]}"
return "***"
@@ -53,6 +53,10 @@ class AdvancedSettings(AppStruct):
cache_ttl_navidrome_search: int = 120
cache_ttl_navidrome_genres: int = 3600
cache_ttl_navidrome_stats: int = 600
cache_ttl_plex_albums: int = 300
cache_ttl_plex_search: int = 120
cache_ttl_plex_genres: int = 3600
cache_ttl_plex_stats: int = 600
http_timeout: int = 10
http_connect_timeout: int = 5
http_max_connections: int = 200
@@ -92,6 +96,7 @@ class AdvancedSettings(AppStruct):
frontend_ttl_search: int = 300000
frontend_ttl_local_files_sidebar: int = 120000
frontend_ttl_jellyfin_sidebar: int = 120000
frontend_ttl_plex_sidebar: int = 120000
frontend_ttl_playlist_sources: int = 900000
audiodb_enabled: bool = True
audiodb_name_search_fallback: bool = False
@@ -136,6 +141,10 @@ class AdvancedSettings(AppStruct):
"cache_ttl_navidrome_search": (60, 3600),
"cache_ttl_navidrome_genres": (60, 86400),
"cache_ttl_navidrome_stats": (60, 3600),
"cache_ttl_plex_albums": (60, 3600),
"cache_ttl_plex_search": (60, 3600),
"cache_ttl_plex_genres": (60, 86400),
"cache_ttl_plex_stats": (60, 3600),
"http_timeout": (5, 60),
"http_connect_timeout": (1, 30),
"http_max_connections": (50, 500),
@@ -178,6 +187,7 @@ class AdvancedSettings(AppStruct):
"frontend_ttl_search": (60000, 3600000),
"frontend_ttl_local_files_sidebar": (60000, 3600000),
"frontend_ttl_jellyfin_sidebar": (60000, 3600000),
"frontend_ttl_plex_sidebar": (60000, 3600000),
"frontend_ttl_playlist_sources": (60000, 3600000),
"cache_ttl_audiodb_found": (3600, 2592000),
"cache_ttl_audiodb_not_found": (3600, 604800),
@@ -202,6 +212,7 @@ class FrontendCacheTTLs(AppStruct):
search: int = 300000
local_files_sidebar: int = 120000
jellyfin_sidebar: int = 120000
plex_sidebar: int = 120000
playlist_sources: int = 900000
discover_queue_polling_interval: int = 4000
discover_queue_auto_generate: bool = True
@@ -228,6 +239,10 @@ class AdvancedSettingsFrontend(AppStruct):
cache_ttl_navidrome_search: int = 2
cache_ttl_navidrome_genres: int = 60
cache_ttl_navidrome_stats: int = 10
cache_ttl_plex_albums: int = 5
cache_ttl_plex_search: int = 2
cache_ttl_plex_genres: int = 60
cache_ttl_plex_stats: int = 10
http_timeout: int = 10
http_connect_timeout: int = 5
http_max_connections: int = 200
@@ -266,6 +281,7 @@ class AdvancedSettingsFrontend(AppStruct):
frontend_ttl_search: int = 5
frontend_ttl_local_files_sidebar: int = 2
frontend_ttl_jellyfin_sidebar: int = 2
frontend_ttl_plex_sidebar: int = 2
frontend_ttl_playlist_sources: int = 15
audiodb_enabled: bool = True
audiodb_name_search_fallback: bool = False
@@ -309,6 +325,10 @@ class AdvancedSettingsFrontend(AppStruct):
"cache_ttl_navidrome_search",
"cache_ttl_navidrome_genres",
"cache_ttl_navidrome_stats",
"cache_ttl_plex_albums",
"cache_ttl_plex_search",
"cache_ttl_plex_genres",
"cache_ttl_plex_stats",
"cache_ttl_audiodb_found",
"cache_ttl_audiodb_not_found",
"cache_ttl_audiodb_library",
@@ -343,6 +363,10 @@ class AdvancedSettingsFrontend(AppStruct):
"cache_ttl_navidrome_search": (1, 60),
"cache_ttl_navidrome_genres": (1, 1440),
"cache_ttl_navidrome_stats": (1, 60),
"cache_ttl_plex_albums": (1, 60),
"cache_ttl_plex_search": (1, 60),
"cache_ttl_plex_genres": (1, 1440),
"cache_ttl_plex_stats": (1, 60),
"http_timeout": (5, 60),
"http_connect_timeout": (1, 30),
"http_max_connections": (50, 500),
@@ -379,6 +403,7 @@ class AdvancedSettingsFrontend(AppStruct):
"frontend_ttl_search": (1, 60),
"frontend_ttl_local_files_sidebar": (1, 60),
"frontend_ttl_jellyfin_sidebar": (1, 60),
"frontend_ttl_plex_sidebar": (1, 60),
"frontend_ttl_playlist_sources": (1, 60),
"cache_ttl_audiodb_found": (1, 720),
"cache_ttl_audiodb_not_found": (1, 168),
@@ -422,6 +447,10 @@ class AdvancedSettingsFrontend(AppStruct):
cache_ttl_navidrome_search=settings.cache_ttl_navidrome_search // 60,
cache_ttl_navidrome_genres=settings.cache_ttl_navidrome_genres // 60,
cache_ttl_navidrome_stats=settings.cache_ttl_navidrome_stats // 60,
cache_ttl_plex_albums=settings.cache_ttl_plex_albums // 60,
cache_ttl_plex_search=settings.cache_ttl_plex_search // 60,
cache_ttl_plex_genres=settings.cache_ttl_plex_genres // 60,
cache_ttl_plex_stats=settings.cache_ttl_plex_stats // 60,
http_timeout=settings.http_timeout,
http_connect_timeout=settings.http_connect_timeout,
http_max_connections=settings.http_max_connections,
@@ -460,6 +489,7 @@ class AdvancedSettingsFrontend(AppStruct):
frontend_ttl_search=settings.frontend_ttl_search // 60000,
frontend_ttl_local_files_sidebar=settings.frontend_ttl_local_files_sidebar // 60000,
frontend_ttl_jellyfin_sidebar=settings.frontend_ttl_jellyfin_sidebar // 60000,
frontend_ttl_plex_sidebar=settings.frontend_ttl_plex_sidebar // 60000,
frontend_ttl_playlist_sources=settings.frontend_ttl_playlist_sources // 60000,
audiodb_enabled=settings.audiodb_enabled,
audiodb_name_search_fallback=settings.audiodb_name_search_fallback,
@@ -504,6 +534,10 @@ class AdvancedSettingsFrontend(AppStruct):
cache_ttl_navidrome_search=self.cache_ttl_navidrome_search * 60,
cache_ttl_navidrome_genres=self.cache_ttl_navidrome_genres * 60,
cache_ttl_navidrome_stats=self.cache_ttl_navidrome_stats * 60,
cache_ttl_plex_albums=self.cache_ttl_plex_albums * 60,
cache_ttl_plex_search=self.cache_ttl_plex_search * 60,
cache_ttl_plex_genres=self.cache_ttl_plex_genres * 60,
cache_ttl_plex_stats=self.cache_ttl_plex_stats * 60,
http_timeout=self.http_timeout,
http_connect_timeout=self.http_connect_timeout,
http_max_connections=self.http_max_connections,
@@ -542,6 +576,7 @@ class AdvancedSettingsFrontend(AppStruct):
frontend_ttl_search=self.frontend_ttl_search * 60000,
frontend_ttl_local_files_sidebar=self.frontend_ttl_local_files_sidebar * 60000,
frontend_ttl_jellyfin_sidebar=self.frontend_ttl_jellyfin_sidebar * 60000,
frontend_ttl_plex_sidebar=self.frontend_ttl_plex_sidebar * 60000,
frontend_ttl_playlist_sources=self.frontend_ttl_playlist_sources * 60000,
audiodb_enabled=self.audiodb_enabled,
audiodb_name_search_fallback=self.audiodb_name_search_fallback,
+1
View File
@@ -15,6 +15,7 @@ class IntegrationStatus(AppStruct):
lastfm: bool
navidrome: bool = False
youtube_api: bool = False
plex: bool = False
class StatusReport(AppStruct):
+123
View File
@@ -9,8 +9,10 @@ class JellyfinTrackInfo(AppStruct):
disc_number: int = 1
album_name: str = ""
artist_name: str = ""
album_id: str = ""
codec: str | None = None
bitrate: int | None = None
image_url: str | None = None
class JellyfinAlbumSummary(AppStruct):
@@ -22,6 +24,7 @@ class JellyfinAlbumSummary(AppStruct):
image_url: str | None = None
musicbrainz_id: str | None = None
artist_musicbrainz_id: str | None = None
play_count: int = 0
class JellyfinAlbumDetail(AppStruct):
@@ -48,6 +51,7 @@ class JellyfinArtistSummary(AppStruct):
image_url: str | None = None
album_count: int = 0
musicbrainz_id: str | None = None
play_count: int = 0
class JellyfinLibraryStats(AppStruct):
@@ -62,8 +66,127 @@ class JellyfinSearchResponse(AppStruct):
tracks: list[JellyfinTrackInfo] = []
class JellyfinPlaylistSummary(AppStruct):
id: str
name: str
track_count: int = 0
duration_seconds: int = 0
cover_url: str = ""
created_at: str = ""
is_imported: bool = False
class JellyfinHubResponse(AppStruct):
stats: JellyfinLibraryStats | None = None
recently_played: list[JellyfinAlbumSummary] = []
recently_added: list[JellyfinAlbumSummary] = []
favorites: list[JellyfinAlbumSummary] = []
most_played_artists: list[JellyfinArtistSummary] = []
most_played_albums: list[JellyfinAlbumSummary] = []
all_albums_preview: list[JellyfinAlbumSummary] = []
genres: list[str] = []
playlists: list[JellyfinPlaylistSummary] = []
class JellyfinPaginatedResponse(AppStruct):
items: list[JellyfinAlbumSummary] = []
total: int = 0
offset: int = 0
limit: int = 50
class JellyfinArtistPage(AppStruct):
items: list[JellyfinArtistSummary] = []
total: int = 0
offset: int = 0
limit: int = 50
class JellyfinArtistIndexEntry(AppStruct):
name: str = ""
artists: list[JellyfinArtistSummary] = []
class JellyfinArtistIndexResponse(AppStruct):
index: list[JellyfinArtistIndexEntry] = []
class JellyfinTrackPage(AppStruct):
items: list[JellyfinTrackInfo] = []
total: int = 0
offset: int = 0
limit: int = 50
class JellyfinPlaylistTrack(AppStruct):
id: str
track_name: str
artist_name: str = ""
album_name: str = ""
album_id: str = ""
artist_id: str = ""
duration_seconds: int = 0
track_number: int = 0
disc_number: int = 1
cover_url: str = ""
class JellyfinPlaylistDetail(AppStruct):
id: str
name: str
track_count: int = 0
duration_seconds: int = 0
cover_url: str = ""
created_at: str = ""
tracks: list[JellyfinPlaylistTrack] = []
class JellyfinImportResult(AppStruct):
musicseerr_playlist_id: str = ""
tracks_imported: int = 0
tracks_failed: int = 0
already_imported: bool = False
class JellyfinSessionInfo(AppStruct):
session_id: str = ""
user_name: str = ""
device_name: str = ""
client_name: str = ""
track_name: str = ""
artist_name: str = ""
album_name: str = ""
album_id: str = ""
cover_url: str = ""
position_seconds: float = 0.0
duration_seconds: float = 0.0
is_paused: bool = False
play_method: str = ""
audio_codec: str = ""
bitrate: int = 0
class JellyfinSessionsResponse(AppStruct):
sessions: list[JellyfinSessionInfo] = []
class JellyfinLyricsLineSchema(AppStruct):
text: str = ""
start_seconds: float | None = None
class JellyfinLyricsResponse(AppStruct):
lines: list[JellyfinLyricsLineSchema] = []
is_synced: bool = False
lyrics_text: str = ""
class JellyfinFavoritesExpanded(AppStruct):
albums: list[JellyfinAlbumSummary] = []
artists: list[JellyfinArtistSummary] = []
class JellyfinFilterFacets(AppStruct):
years: list[int] = []
tags: list[str] = []
studios: list[str] = []
+130
View File
@@ -13,6 +13,7 @@ class NavidromeTrackInfo(AppStruct):
artist_name: str = ""
codec: str | None = None
bitrate: int | None = None
image_url: str | None = None
class NavidromeAlbumSummary(AppStruct):
@@ -64,6 +65,135 @@ class NavidromeSearchResponse(AppStruct):
tracks: list[NavidromeTrackInfo] = []
class NavidromePlaylistSummary(AppStruct):
id: str
name: str
track_count: int = 0
duration_seconds: int = 0
cover_url: str = ""
owner: str = ""
is_public: bool = False
updated_at: str = ""
is_imported: bool = False
class NavidromeHubResponse(AppStruct):
stats: NavidromeLibraryStats | None = None
recently_played: list[NavidromeAlbumSummary] = []
favorites: list[NavidromeAlbumSummary] = []
favorite_artists: list[NavidromeArtistSummary] = []
favorite_tracks: list[NavidromeTrackInfo] = []
all_albums_preview: list[NavidromeAlbumSummary] = []
genres: list[str] = []
playlists: list[NavidromePlaylistSummary] = []
class NavidromeAlbumPage(AppStruct):
items: list[NavidromeAlbumSummary] = []
total: int = 0
class NavidromeArtistPage(AppStruct):
items: list[NavidromeArtistSummary] = []
total: int = 0
offset: int = 0
limit: int = 50
class NavidromeTrackPage(AppStruct):
items: list[NavidromeTrackInfo] = []
total: int = 0
offset: int = 0
limit: int = 50
class NavidromePlaylistTrack(AppStruct):
id: str
track_name: str
artist_name: str = ""
album_name: str = ""
album_id: str = ""
artist_id: str = ""
duration_seconds: int = 0
track_number: int = 0
disc_number: int = 1
cover_url: str = ""
class NavidromePlaylistDetail(AppStruct):
id: str
name: str
track_count: int = 0
duration_seconds: int = 0
cover_url: str = ""
tracks: list[NavidromePlaylistTrack] = []
class NavidromeImportResult(AppStruct):
musicseerr_playlist_id: str = ""
tracks_imported: int = 0
tracks_failed: int = 0
already_imported: bool = False
class NavidromeNowPlayingEntrySchema(AppStruct):
user_name: str = ""
minutes_ago: int = 0
player_name: str = ""
track_name: str = ""
artist_name: str = ""
album_name: str = ""
album_id: str = ""
cover_art_id: str = ""
duration_seconds: int = 0
estimated_position_seconds: float = 0.0
class NavidromeNowPlayingResponse(AppStruct):
entries: list[NavidromeNowPlayingEntrySchema] = []
class NavidromeArtistInfoSchema(AppStruct):
navidrome_id: str = ""
name: str = ""
biography: str = ""
image_url: str = ""
similar_artists: list[NavidromeArtistSummary] = []
class NavidromeAlbumInfoSchema(AppStruct):
album_id: str = ""
notes: str = ""
musicbrainz_id: str = ""
lastfm_url: str = ""
image_url: str = ""
class NavidromeLyricLine(AppStruct):
text: str = ""
start_seconds: float | None = None
class NavidromeLyricsResponse(AppStruct):
text: str = ""
is_synced: bool = False
lines: list[NavidromeLyricLine] = []
class NavidromeArtistIndexEntry(AppStruct):
name: str = ""
artists: list[NavidromeArtistSummary] = []
class NavidromeArtistIndexResponse(AppStruct):
index: list[NavidromeArtistIndexEntry] = []
class NavidromeGenreSongsResponse(AppStruct):
songs: list[NavidromeTrackInfo] = []
genre: str = ""
class NavidromeMusicFolder(AppStruct):
id: str = ""
name: str = ""
+5 -1
View File
@@ -19,6 +19,7 @@ class PlaylistTrackResponse(AppStruct):
disc_number: int | None = None
duration: int | None = None
created_at: str = ""
plex_rating_key: str | None = None
class PlaylistSummaryResponse(AppStruct):
@@ -28,16 +29,18 @@ class PlaylistSummaryResponse(AppStruct):
total_duration: int | None = None
cover_urls: list[str] = msgspec.field(default_factory=list)
custom_cover_url: str | None = None
source_ref: str | None = None
created_at: str = ""
updated_at: str = ""
class PlaylistDetailResponse(AppStruct):
# Frontend PlaylistDetail extends PlaylistSummary — keep fields in sync with PlaylistSummaryResponse
# Keep these fields in sync with PlaylistSummaryResponse because the frontend extends PlaylistSummary.
id: str
name: str
cover_urls: list[str] = msgspec.field(default_factory=list)
custom_cover_url: str | None = None
source_ref: str | None = None
tracks: list[PlaylistTrackResponse] = msgspec.field(default_factory=list)
track_count: int = 0
total_duration: int | None = None
@@ -71,6 +74,7 @@ class TrackDataRequest(AppStruct):
track_number: int | None = None
disc_number: int | None = None
duration: float | int | None = None
plex_rating_key: str | None = None
class AddTracksRequest(AppStruct):
+231
View File
@@ -0,0 +1,231 @@
from __future__ import annotations
from infrastructure.msgspec_fastapi import AppStruct
class PlexTrackInfo(AppStruct):
plex_id: str
title: str
track_number: int
duration_seconds: float
disc_number: int = 1
album_name: str = ""
artist_name: str = ""
codec: str | None = None
bitrate: int | None = None
audio_channels: int | None = None
container: str | None = None
part_key: str | None = None
image_url: str | None = None
class PlexAlbumSummary(AppStruct):
plex_id: str
name: str
artist_name: str = ""
year: int | None = None
track_count: int = 0
image_url: str | None = None
musicbrainz_id: str | None = None
artist_musicbrainz_id: str | None = None
last_viewed_at: int = 0
class PlexAlbumDetail(AppStruct):
plex_id: str
name: str
artist_name: str = ""
year: int | None = None
track_count: int = 0
image_url: str | None = None
musicbrainz_id: str | None = None
artist_musicbrainz_id: str | None = None
tracks: list[PlexTrackInfo] = []
genres: list[str] = []
class PlexAlbumMatch(AppStruct):
found: bool
plex_album_id: str | None = None
tracks: list[PlexTrackInfo] = []
class PlexArtistSummary(AppStruct):
plex_id: str
name: str
image_url: str | None = None
musicbrainz_id: str | None = None
class PlexLibraryStats(AppStruct):
total_tracks: int = 0
total_albums: int = 0
total_artists: int = 0
class PlexSearchResponse(AppStruct):
albums: list[PlexAlbumSummary] = []
artists: list[PlexArtistSummary] = []
tracks: list[PlexTrackInfo] = []
class PlexAlbumPage(AppStruct):
items: list[PlexAlbumSummary] = []
total: int = 0
class PlexArtistPage(AppStruct):
items: list[PlexArtistSummary] = []
total: int = 0
offset: int = 0
limit: int = 50
class PlexArtistIndexEntry(AppStruct):
name: str = ""
artists: list[PlexArtistSummary] = []
class PlexArtistIndexResponse(AppStruct):
index: list[PlexArtistIndexEntry] = []
class PlexTrackPage(AppStruct):
items: list[PlexTrackInfo] = []
total: int = 0
offset: int = 0
limit: int = 50
class PlexPlaylistSummary(AppStruct):
id: str
name: str
track_count: int = 0
duration_seconds: int = 0
is_smart: bool = False
cover_url: str = ""
updated_at: str = ""
is_imported: bool = False
class PlexHubResponse(AppStruct):
stats: PlexLibraryStats | None = None
recently_played: list[PlexAlbumSummary] = []
recently_added: list[PlexAlbumSummary] = []
all_albums_preview: list[PlexAlbumSummary] = []
genres: list[str] = []
playlists: list[PlexPlaylistSummary] = []
class PlexDiscoveryAlbum(AppStruct):
plex_id: str
name: str
artist_name: str = ""
year: int | None = None
image_url: str | None = None
class PlexDiscoveryHub(AppStruct):
title: str
hub_type: str = ""
albums: list[PlexDiscoveryAlbum] = []
class PlexDiscoveryResponse(AppStruct):
hubs: list[PlexDiscoveryHub] = []
class PlexLibrarySectionInfo(AppStruct):
key: str
title: str
class PlexPlaylistTrack(AppStruct):
id: str
track_name: str
artist_name: str = ""
album_name: str = ""
album_id: str = ""
plex_rating_key: str = ""
duration_seconds: int = 0
track_number: int = 0
disc_number: int = 1
cover_url: str = ""
class PlexPlaylistDetail(AppStruct):
id: str
name: str
track_count: int = 0
duration_seconds: int = 0
is_smart: bool = False
cover_url: str = ""
updated_at: str = ""
tracks: list[PlexPlaylistTrack] = []
class PlexImportResult(AppStruct):
musicseerr_playlist_id: str = ""
tracks_imported: int = 0
tracks_failed: int = 0
already_imported: bool = False
class PlexSessionInfo(AppStruct):
session_id: str = ""
user_name: str = ""
track_title: str = ""
artist_name: str = ""
album_name: str = ""
cover_url: str = ""
player_device: str = ""
player_platform: str = ""
player_state: str = ""
is_direct_play: bool = True
progress_ms: int = 0
duration_ms: int = 0
audio_codec: str = ""
audio_channels: int = 0
bitrate: int = 0
class PlexSessionsResponse(AppStruct):
sessions: list[PlexSessionInfo] = []
available: bool = True
class PlexHistoryEntrySchema(AppStruct):
rating_key: str = ""
track_title: str = ""
artist_name: str = ""
album_name: str = ""
cover_url: str = ""
viewed_at: str = ""
device_name: str = ""
class PlexHistoryResponse(AppStruct):
entries: list[PlexHistoryEntrySchema] = []
total: int = 0
limit: int = 0
offset: int = 0
available: bool = True
class PlexAnalyticsItem(AppStruct):
name: str = ""
subtitle: str = ""
play_count: int = 0
cover_url: str | None = None
class PlexAnalyticsResponse(AppStruct):
top_artists: list[PlexAnalyticsItem] = []
top_albums: list[PlexAnalyticsItem] = []
top_tracks: list[PlexAnalyticsItem] = []
total_listens: int = 0
listens_last_7_days: int = 0
listens_last_30_days: int = 0
total_hours: float = 0.0
is_complete: bool = True
entries_analyzed: int = 0
+30
View File
@@ -2,6 +2,7 @@ from typing import Literal
import msgspec
from api.v1.schemas.plex import PlexLibrarySectionInfo
from infrastructure.msgspec_fastapi import AppStruct
LASTFM_SECRET_MASK = "••••••••"
@@ -93,6 +94,7 @@ class JellyfinConnectionSettings(AppStruct):
NAVIDROME_PASSWORD_MASK = "********"
PLEX_TOKEN_MASK = "plex****"
class NavidromeConnectionSettings(AppStruct):
@@ -105,6 +107,34 @@ class NavidromeConnectionSettings(AppStruct):
self.navidrome_url = self.navidrome_url.rstrip("/") if self.navidrome_url else ""
class PlexConnectionSettings(AppStruct):
plex_url: str = ""
plex_token: str = ""
enabled: bool = False
music_library_ids: list[str] = []
scrobble_to_plex: bool = True
def __post_init__(self) -> None:
self.plex_url = self.plex_url.rstrip("/") if self.plex_url else ""
class PlexVerifyResponse(AppStruct):
valid: bool
message: str
libraries: list[PlexLibrarySectionInfo] = []
class PlexOAuthPinResponse(AppStruct):
pin_id: int
pin_code: str
auth_url: str
class PlexOAuthPollResponse(AppStruct):
completed: bool
auth_token: str = ""
class JellyfinUserInfo(AppStruct):
id: str
name: str