0f25ebc26d
* plex integration * The big one - Full Music Source page rework + Playlist importing + Full Plex Integration + Discovery Options + More Like This/Surprise Me/Instant Mix + More... * Music source track page - Play all / shuffle fixes * lint * format * fix type checks * format
387 lines
14 KiB
Python
387 lines
14 KiB
Python
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()
|