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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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),
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -15,6 +15,7 @@ class IntegrationStatus(AppStruct):
|
||||
lastfm: bool
|
||||
navidrome: bool = False
|
||||
youtube_api: bool = False
|
||||
plex: bool = False
|
||||
|
||||
|
||||
class StatusReport(AppStruct):
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user