Plex Integration + Music Source Integration Improvements (#37)

* plex integration

* The big one - Full Music Source page rework + Playlist importing + Full Plex Integration + Discovery Options + More Like This/Surprise Me/Instant Mix + More...

* Music source track page - Play all / shuffle fixes

* lint

* format

* fix type checks

* format
This commit is contained in:
Harvey 2026-04-13 23:39:01 +01:00 committed by GitHub
parent 90b7b67a10
commit 0f25ebc26d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
177 changed files with 21156 additions and 769 deletions

View file

@ -30,19 +30,27 @@ NPM ?= pnpm
backend-test-config-validation \
backend-test-coverart-audiodb \
backend-test-dedup-cancellation \
backend-test-discovery \
backend-test-deep-discovery \
backend-test-discovery-precache \
backend-test-exception-handling \
backend-test-home \
backend-test-now-playing \
backend-test-home-genre \
backend-test-infra-hardening \
backend-test-jellyfin \
backend-test-jellyfin-proxy \
backend-test-library-pagination \
backend-test-lidarr-url \
backend-test-local-files-fallback \
backend-test-monitoring-cache \
backend-test-navidrome \
backend-test-multidisc \
backend-test-mus15-status-race \
backend-test-performance \
backend-test-plex \
backend-test-plex-repository \
backend-test-plex-routes \
backend-test-playlist \
backend-test-request-queue \
backend-test-request-service \
@ -52,24 +60,25 @@ NPM ?= pnpm
backend-test-sync-generation \
backend-test-sync-resume \
backend-test-sync-watchdog \
backend-test-content-enrichment \
backend-test-peer-review-fixes \
test-audiodb-all test-mus14-all test-sync-all \
frontend-install frontend-build frontend-browser-install \
frontend-format-check frontend-check frontend-lint frontend-test frontend-test-server \
frontend-test-album-page \
frontend-test-audiodb-images \
frontend-test-jellyfin \
frontend-test-monitored-artists \
frontend-test-navidrome \
frontend-test-plex \
frontend-test-playlist-detail \
frontend-test-queuehelpers \
rebuild \
test tests check lint format ci
# Help
help: ## Show available targets
@grep -E '^[a-zA-Z0-9_.-]+:.*## ' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*## "}; {printf "%-34s %s\n", $$1, $$2}'
# Backend: virtualenv
$(BACKEND_VENV_DIR):
cd "$(BACKEND_DIR)" && test -f .virtualenv.pyz || curl -fsSLo .virtualenv.pyz https://bootstrap.pypa.io/virtualenv.pyz
cd "$(BACKEND_DIR)" && $(PYTHON) .virtualenv.pyz .venv
@ -81,18 +90,12 @@ $(BACKEND_VENV_STAMP): $(BACKEND_DIR)/requirements.txt $(BACKEND_DIR)/requiremen
backend-venv: $(BACKEND_VENV_STAMP) ## Create or refresh the backend virtualenv
# Backend: lint
backend-lint: $(BACKEND_VENV_STAMP) ## Run backend Ruff checks
cd "$(ROOT_DIR)" && $(BACKEND_VENV_DIR)/bin/ruff check backend
# Backend: full test suite
backend-test: $(BACKEND_VENV_STAMP) ## Run all backend tests
$(PYTEST)
# Backend: focused test targets
backend-test-album-refresh: $(BACKEND_VENV_STAMP) ## Run album refresh endpoint tests
$(PYTEST) tests/routes/test_album_refresh.py tests/services/test_navidrome_cache_invalidation.py -v
@ -135,12 +138,21 @@ backend-test-coverart-audiodb: $(BACKEND_VENV_STAMP) ## Run AudioDB coverart pro
backend-test-dedup-cancellation: $(BACKEND_VENV_STAMP) ## Run deduplicator cancellation tests
$(PYTEST) tests/infrastructure/test_dedup_cancellation.py tests/infrastructure/test_disconnect.py -v
backend-test-discovery: $(BACKEND_VENV_STAMP) ## Run discovery service and route tests
$(PYTEST) tests/services/test_discovery.py tests/routes/test_discovery_routes.py -v
backend-test-discovery-precache: $(BACKEND_VENV_STAMP) ## Run artist discovery precache tests
$(PYTEST) tests/services/test_discovery_precache_progress.py tests/services/test_discovery_precache_lock.py tests/infrastructure/test_retry_non_breaking.py -v
backend-test-exception-handling: $(BACKEND_VENV_STAMP) ## Run exception-handling regressions
$(PYTEST) tests/routes/test_scrobble_routes.py tests/routes/test_scrobble_settings_routes.py tests/test_error_leakage.py tests/test_background_task_logging.py
backend-test-now-playing: $(BACKEND_VENV_STAMP) ## Run now-playing service and route tests
$(PYTEST) tests/services/test_now_playing.py tests/routes/test_now_playing_routes.py -v
backend-test-deep-discovery: $(BACKEND_VENV_STAMP) ## Run deep discovery and analytics tests
$(PYTEST) tests/services/test_deep_discovery.py -v
backend-test-home: $(BACKEND_VENV_STAMP) ## Run home page backend tests
$(PYTEST) tests/services/test_home_service.py tests/routes/test_home_routes.py
@ -150,6 +162,9 @@ backend-test-home-genre: $(BACKEND_VENV_STAMP) ## Run home genre decoupling test
backend-test-infra-hardening: $(BACKEND_VENV_STAMP) ## Run infrastructure hardening tests
$(PYTEST) tests/infrastructure/test_circuit_breaker_sync.py tests/infrastructure/test_disk_cache_periodic.py tests/infrastructure/test_retry_non_breaking.py
backend-test-jellyfin: $(BACKEND_VENV_STAMP) ## Run all Jellyfin integration backend tests
$(PYTEST) tests/repositories/test_jellyfin_playback_url.py tests/services/test_jellyfin_playback_service.py tests/services/test_jellyfin_library_service.py tests/routes/test_stream_routes.py -v
backend-test-jellyfin-proxy: $(BACKEND_VENV_STAMP) ## Run Jellyfin stream proxy tests
$(PYTEST) tests/routes/test_stream_routes.py -v
@ -168,6 +183,9 @@ backend-test-monitoring-cache: $(BACKEND_VENV_STAMP) ## Run artist monitoring ca
backend-test-multidisc: $(BACKEND_VENV_STAMP) ## Run multi-disc album tests
$(PYTEST) tests/services/test_album_utils.py tests/services/test_album_service.py tests/infrastructure/test_cache_layer_followups.py
backend-test-navidrome: $(BACKEND_VENV_STAMP) ## Run all Navidrome integration backend tests
$(PYTEST) tests/repositories/test_navidrome_repository.py tests/services/test_navidrome_library_service.py tests/services/test_navidrome_playback_service.py tests/services/test_navidrome_cache_invalidation.py tests/services/test_navidrome_stream_proxy.py tests/routes/test_navidrome_routes.py -v
backend-test-mus15-status-race: $(BACKEND_VENV_STAMP) ## Run MUS-15 status race condition tests
$(PYTEST) tests/test_mus15_status_race.py -v
@ -189,6 +207,24 @@ backend-test-search-top-result: $(BACKEND_VENV_STAMP) ## Run search top result d
backend-test-security: $(BACKEND_VENV_STAMP) ## Run security regression tests
$(PYTEST) tests/test_rate_limiter_middleware.py tests/test_url_validation.py tests/test_error_leakage.py
backend-test-source-playlists: $(BACKEND_VENV_STAMP) ## Run source playlist import tests (Plex, Navidrome, Jellyfin)
$(PYTEST) tests/services/test_source_playlist_import.py -v
backend-test-content-enrichment: $(BACKEND_VENV_STAMP) ## Run content enrichment tests (lyrics, album info, audio quality)
$(PYTEST) tests/services/test_content_enrichment.py -v
backend-test-peer-review-fixes: $(BACKEND_VENV_STAMP) ## Run peer review fix regression tests
$(PYTEST) tests/test_peer_review_fixes.py -v
backend-test-plex: $(BACKEND_VENV_STAMP) ## Run all Plex integration backend tests
$(PYTEST) tests/repositories/test_plex_repository.py tests/services/test_plex_playback_service.py tests/services/test_plex_library_service.py tests/routes/test_plex_routes.py tests/routes/test_plex_settings.py tests/routes/test_plex_auth.py tests/services/test_plex_integration_status.py tests/services/test_plex_settings_lifecycle.py -v
backend-test-plex-repository: $(BACKEND_VENV_STAMP) ## Run Plex repository unit tests
$(PYTEST) tests/repositories/test_plex_repository.py -v
backend-test-plex-routes: $(BACKEND_VENV_STAMP) ## Run Plex route and settings tests
$(PYTEST) tests/routes/test_plex_routes.py tests/routes/test_plex_settings.py tests/routes/test_plex_auth.py -v
backend-test-sync-coordinator: $(BACKEND_VENV_STAMP) ## Run sync coordinator tests (cooldown, dedup)
$(PYTEST) tests/test_sync_coordinator.py -v
@ -201,16 +237,12 @@ backend-test-sync-resume: $(BACKEND_VENV_STAMP) ## Run sync resume-on-failure te
backend-test-sync-watchdog: $(BACKEND_VENV_STAMP) ## Run adaptive watchdog timeout tests
$(PYTEST) tests/test_sync_watchdog.py -v
# Backend: aggregate test targets
test-audiodb-all: backend-test-audiodb backend-test-audiodb-prewarm backend-test-audiodb-settings backend-test-coverart-audiodb backend-test-audiodb-phase8 backend-test-audiodb-phase9 frontend-test-audiodb-images ## Run every AudioDB test target
test-mus14-all: backend-test-request-queue backend-test-artist-lock backend-test-request-service ## Run all MUS-14 request system tests
$(PYTEST) tests/repositories/test_lidarr_library_cache.py -v
test-sync-all: backend-test-sync-watchdog backend-test-sync-resume backend-test-audiodb-parallel backend-test-sync-generation ## Run all sync robustness tests
# Frontend: setup
test-sync-all: backend-test-sync-watchdog backend-test-sync-resume backend-test-audiodb-parallel backend-test-sync-generation ## Run all sync reliability tests
frontend-install: ## Install frontend npm dependencies
cd "$(FRONTEND_DIR)" && $(NPM) install
@ -221,8 +253,6 @@ frontend-build: ## Run frontend production build
frontend-browser-install: ## Install Playwright Chromium for browser tests
cd "$(FRONTEND_DIR)" && $(NPM) exec playwright install chromium
# Frontend: lint & checks
frontend-format-check: ## Run frontend formatting checks
cd "$(FRONTEND_DIR)" && $(NPM) run format:check
@ -232,16 +262,12 @@ frontend-check: ## Run frontend type checks
frontend-lint: ## Run frontend linting
cd "$(FRONTEND_DIR)" && $(NPM) run lint
# Frontend: full test suite
frontend-test: ## Run the frontend vitest suite (all projects, needs Playwright)
cd "$(FRONTEND_DIR)" && $(NPM) run test
frontend-test-server: ## Run frontend server-project tests only (no Playwright)
cd "$(FRONTEND_DIR)" && $(NPM) exec vitest run --project server
# Frontend: focused test targets
frontend-test-album-page: ## Run the album page browser test
cd "$(FRONTEND_DIR)" && $(NPM) exec vitest run --project client src/routes/album/[id]/page.svelte.spec.ts
@ -258,13 +284,18 @@ frontend-test-playlist-detail: ## Run playlist page browser tests
frontend-test-queuehelpers: ## Run queue helper regressions
cd "$(FRONTEND_DIR)" && $(NPM) exec vitest run --project server src/lib/player/queueHelpers.spec.ts
# Utilities
frontend-test-plex: ## Run Plex frontend tests
cd "$(FRONTEND_DIR)" && $(NPM) exec vitest run --project server src/lib/player/plexPlaybackApi.spec.ts src/lib/player/launchPlexPlayback.spec.ts
frontend-test-navidrome: ## Run Navidrome frontend tests
cd "$(FRONTEND_DIR)" && $(NPM) exec vitest run --project server src/lib/player/queueHelpers.spec.ts
frontend-test-jellyfin: ## Run Jellyfin frontend tests
cd "$(FRONTEND_DIR)" && $(NPM) exec vitest run --project server src/lib/player/jellyfinPlaybackApi.spec.ts
rebuild: ## Rebuild the application
cd "$(ROOT_DIR)" && ./manage.sh --rebuild
# Aggregate targets
test: backend-test frontend-test ## Run backend and frontend tests
tests: test ## Alias for 'test'

View file

@ -13,7 +13,7 @@
---
MusicSeerr is a self-hosted music request and discovery app built around [Lidarr](https://lidarr.audio/). Search the full MusicBrainz catalogue, request albums, stream music from Jellyfin, Navidrome, or your local library, discover new albums based on your listening history, and scrobble everything to ListenBrainz and Last.fm. The whole thing runs as a single Docker container with a web UI for all configuration.
MusicSeerr is a self-hosted music request and discovery app built around [Lidarr](https://lidarr.audio/). Search the full MusicBrainz catalogue, request albums, stream music from Jellyfin, Navidrome, Plex, or your local library, discover new albums based on your listening history, and scrobble everything to ListenBrainz and Last.fm. The whole thing runs as a single Docker container with a web UI for all configuration.
---
@ -96,7 +96,7 @@ MusicSeerr is designed to work with Lidarr. If you're putting together a music s
| [slskd](https://github.com/slskd/slskd) | Soulseek download client |
| [Tubifarry](https://github.com/Tubifarry/Tubifarry) | YouTube-based download client for Lidarr |
Lidarr is the only requirement. slskd and Tubifarry are optional but between them they cover most music sourcing needs. For playback, connect Jellyfin, Navidrome, or mount your music folder directly into the container.
Lidarr is the only requirement. slskd and Tubifarry are optional but between them they cover most music sourcing needs. For playback, connect Jellyfin, Navidrome, Plex, or mount your music folder directly into the container.
---
@ -112,6 +112,7 @@ MusicSeerr has a full audio player that supports multiple playback sources per t
- Jellyfin, with configurable codec (AAC, MP3, FLAC, Opus, and others) and bitrate. Playback events are reported back to Jellyfin automatically.
- Navidrome, streaming via the Subsonic API.
- Plex Media Server, with direct-play audio streaming and native Plex scrobbling. Supports multi-library setups.
- Local files, served directly from a mounted music directory.
- YouTube, for previewing albums you haven't downloaded yet. Links can be auto-generated or set manually.
@ -129,7 +130,7 @@ You can also browse by genre, view trending and popular charts over different ti
Browse your Lidarr-managed library by artist or album with search, filtering, sorting, and pagination. View recently added albums and library statistics. Remove albums directly from the UI.
Jellyfin, Navidrome, and local file sources each get their own library view with play, shuffle, and queue actions.
Jellyfin, Navidrome, Plex, and local file sources each get their own library view with play, shuffle, and queue actions.
### Scrobbling
@ -137,7 +138,7 @@ Every track you play can be scrobbled to ListenBrainz and Last.fm simultaneously
### Playlists
Create playlists from any mix of Jellyfin, Navidrome, local, and YouTube tracks. Reorder by dragging, set custom cover art, and play everything through the same player.
Create playlists from any mix of Jellyfin, Navidrome, Plex, local, and YouTube tracks. Reorder by dragging, set custom cover art, and play everything through the same player.
### Profile
@ -156,6 +157,7 @@ Set a display name and avatar, view connected services, and check your library s
| [Wikidata](https://www.wikidata.org/) | Artist descriptions and external links |
| [Jellyfin](https://jellyfin.org/) | Audio streaming and library browsing |
| [Navidrome](https://www.navidrome.org/) | Audio streaming via Subsonic API |
| [Plex](https://www.plex.tv/) | Audio streaming and library browsing via Plex Media Server |
| [ListenBrainz](https://listenbrainz.org/) | Listening history, discovery, scrobbling, weekly playlists |
| [Last.fm](https://www.last.fm/) | Scrobbling and listen tracking |
| YouTube | Album playback when no local copy exists |
@ -189,6 +191,7 @@ Run `id` on your host to find your PUID and PGID values.
| Lidarr URL, API key, profiles, root folder, sync frequency | Settings > Lidarr |
| Jellyfin URL and API key | Settings > Jellyfin |
| Navidrome URL and credentials | Settings > Navidrome |
| Plex URL, token (OAuth or manual), music libraries, scrobble toggle | Settings > Plex |
| Local files directory path | Settings > Local Files |
| ListenBrainz username and token | Settings > ListenBrainz |
| Last.fm API key, secret, and OAuth session | Settings > Last.fm |
@ -235,6 +238,14 @@ volumes:
Connect your Navidrome instance under Settings > Navidrome.
### Plex
Connect Plex under Settings > Plex. You can sign in with Plex OAuth or paste in a token yourself. Once you're connected, choose the music libraries you want to include. If you pick more than one, MusicSeerr merges them into a single library view.
Tracks play directly from Plex with no server-side transcoding. The MusicSeerr backend proxies the stream so your Plex token never reaches the browser.
Plex scrobbling is on by default. Turn it off in Settings > Plex or from the library page if you'd rather rely on Last.fm and ListenBrainz instead.
### YouTube
Albums can be linked to a YouTube URL and played inline. This is useful for listening to albums before you've downloaded them. Links can be auto-generated with a YouTube API key or added manually.

View file

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

View file

@ -1,23 +1,45 @@
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Path, Query
from fastapi.responses import Response
from api.v1.schemas.navidrome import (
NavidromeAlbumDetail,
NavidromeAlbumInfoSchema,
NavidromeAlbumMatch,
NavidromeAlbumPage,
NavidromeAlbumSummary,
NavidromeArtistIndexResponse,
NavidromeArtistInfoSchema,
NavidromeArtistPage,
NavidromeArtistSummary,
NavidromeGenreSongsResponse,
NavidromeHubResponse,
NavidromeImportResult,
NavidromeLibraryStats,
NavidromeLyricsResponse,
NavidromeMusicFolder,
NavidromeNowPlayingResponse,
NavidromePlaylistDetail,
NavidromePlaylistSummary,
NavidromeSearchResponse,
NavidromeTrackInfo,
NavidromeTrackPage,
)
from core.dependencies import get_navidrome_library_service, get_navidrome_repository
from core.exceptions import ExternalServiceError
from core.dependencies import (
get_jellyfin_library_service,
get_local_files_service,
get_navidrome_library_service,
get_navidrome_repository,
get_plex_library_service,
get_playlist_service,
)
from core.exceptions import ExternalServiceError, ResourceNotFoundError
from infrastructure.msgspec_fastapi import MsgSpecRoute
from infrastructure.resilience.retry import CircuitOpenError
from repositories.navidrome_repository import NavidromeRepository
from services.navidrome_library_service import NavidromeLibraryService
from services.playlist_service import PlaylistService
logger = logging.getLogger(__name__)
@ -27,27 +49,61 @@ router = APIRouter(route_class=MsgSpecRoute, prefix="/navidrome", tags=["navidro
_SORT_MAP: dict[str, str] = {
"name": "alphabeticalByName",
"date_added": "newest",
"year": "alphabeticalByName",
"year": "byYear",
}
_NEEDS_REVERSE: dict[tuple[str, str], bool] = {
("name", "desc"): True,
("date_added", ""): True,
("date_added", "asc"): True,
}
@router.get("/hub", response_model=NavidromeHubResponse)
async def get_navidrome_hub(
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
playlist_service: PlaylistService = Depends(get_playlist_service),
) -> NavidromeHubResponse:
try:
hub = await service.get_hub_data()
imported_ids = await playlist_service.get_imported_source_ids("navidrome:")
for p in hub.playlists:
if p.id in imported_ids:
p.is_imported = True
return hub
except ExternalServiceError as e:
logger.error("Navidrome service error getting hub data: %s", e)
raise HTTPException(status_code=502, detail="Failed to communicate with Navidrome")
@router.get("/albums", response_model=NavidromeAlbumPage)
async def get_navidrome_albums(
limit: int = Query(default=48, ge=1, le=500, alias="limit"),
offset: int = Query(default=0, ge=0),
sort_by: str = Query(default="name"),
sort_order: str = Query(default=""),
genre: str = Query(default=""),
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
) -> NavidromeAlbumPage:
subsonic_type = "byGenre" if genre else _SORT_MAP.get(sort_by, "alphabeticalByName")
year_kwargs: dict[str, int] = {}
if subsonic_type == "byYear":
if sort_order == "desc":
year_kwargs = {"from_year": 9999, "to_year": 0}
else:
year_kwargs = {"from_year": 0, "to_year": 9999}
try:
items = await service.get_albums(
type=subsonic_type, size=limit, offset=offset, genre=genre if genre else None,
**year_kwargs,
)
except ExternalServiceError as e:
logger.error("Navidrome service error getting albums: %s", e)
raise HTTPException(status_code=502, detail="Failed to communicate with Navidrome")
if not genre and _NEEDS_REVERSE.get((sort_by, sort_order), False):
items = list(reversed(items))
try:
stats = await service.get_stats()
total = stats.total_albums if len(items) >= limit else offset + len(items)
@ -69,6 +125,21 @@ async def get_navidrome_album_detail(
return result
@router.get("/artists/browse", response_model=NavidromeArtistPage)
async def browse_navidrome_artists(
limit: int = Query(48, ge=1, le=100),
offset: int = Query(0, ge=0),
search: str = Query(""),
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
) -> NavidromeArtistPage:
try:
items, total = await service.browse_artists(size=limit, offset=offset, search=search)
return NavidromeArtistPage(items=items, total=total, offset=offset, limit=limit)
except ExternalServiceError as e:
logger.error("Navidrome service error browsing artists: %s", e)
raise HTTPException(status_code=502, detail="Failed to communicate with Navidrome")
@router.get("/artists", response_model=list[NavidromeArtistSummary])
async def get_navidrome_artists(
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
@ -76,6 +147,17 @@ async def get_navidrome_artists(
return await service.get_artists()
@router.get("/artists/index", response_model=NavidromeArtistIndexResponse)
async def get_navidrome_artists_index(
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
) -> NavidromeArtistIndexResponse:
try:
return await service.get_artists_index()
except ExternalServiceError as e:
logger.error("Navidrome service error getting artist index: %s", e)
raise HTTPException(status_code=502, detail="Failed to communicate with Navidrome")
@router.get("/artists/{artist_id}")
async def get_navidrome_artist_detail(
artist_id: str,
@ -87,6 +169,21 @@ async def get_navidrome_artist_detail(
return result
@router.get("/tracks", response_model=NavidromeTrackPage)
async def browse_navidrome_tracks(
limit: int = Query(48, ge=1, le=100),
offset: int = Query(0, ge=0),
search: str = Query(""),
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
) -> NavidromeTrackPage:
try:
items, total = await service.browse_tracks(size=limit, offset=offset, search=search)
return NavidromeTrackPage(items=items, total=total, offset=offset, limit=limit)
except ExternalServiceError as e:
logger.error("Navidrome service error browsing tracks: %s", e)
raise HTTPException(status_code=502, detail="Failed to communicate with Navidrome")
@router.get("/search", response_model=NavidromeSearchResponse)
async def search_navidrome(
q: str = Query(..., min_length=1),
@ -111,6 +208,13 @@ async def get_navidrome_favorites(
return result.albums
@router.get("/favorites/expanded", response_model=NavidromeSearchResponse)
async def get_navidrome_favorites_expanded(
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
) -> NavidromeSearchResponse:
return await service.get_favorites()
@router.get("/genres", response_model=list[str])
async def get_navidrome_genres(
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
@ -122,6 +226,55 @@ async def get_navidrome_genres(
raise HTTPException(status_code=502, detail="Failed to communicate with Navidrome")
@router.get("/genres/songs", response_model=NavidromeGenreSongsResponse)
async def get_navidrome_multi_genre_songs(
genres: str = Query(..., min_length=1),
count: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
) -> NavidromeGenreSongsResponse:
genre_list = [g.strip() for g in genres.split(",") if g.strip()]
if not genre_list:
return NavidromeGenreSongsResponse(songs=[], genre="")
if len(genre_list) == 1:
return await service.get_songs_by_genre(genre=genre_list[0], count=count, offset=offset)
return await service.get_songs_by_genres(genres=genre_list, count=count, offset=offset)
@router.get("/genres/{genre}/songs", response_model=NavidromeGenreSongsResponse)
async def get_navidrome_genre_songs(
genre: str = Path(...),
count: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
) -> NavidromeGenreSongsResponse:
try:
return await service.get_songs_by_genre(genre=genre, count=count, offset=offset)
except ExternalServiceError as e:
logger.error("Navidrome service error getting songs by genre: %s", e)
raise HTTPException(status_code=502, detail="Failed to communicate with Navidrome")
@router.get("/music-folders", response_model=list[NavidromeMusicFolder])
async def get_navidrome_music_folders(
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
) -> list[NavidromeMusicFolder]:
try:
return await service.get_music_folders()
except ExternalServiceError as e:
logger.error("Navidrome service error getting music folders: %s", e)
raise HTTPException(status_code=502, detail="Failed to communicate with Navidrome")
@router.get("/random", response_model=list[NavidromeTrackInfo])
async def get_navidrome_random(
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
size: int = Query(default=20, ge=1, le=50),
genre: str | None = Query(default=None),
) -> list[NavidromeTrackInfo]:
return await service.get_random_songs(size=size, genre=genre)
@router.get("/stats", response_model=NavidromeLibraryStats)
async def get_navidrome_stats(
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
@ -161,3 +314,125 @@ async def match_navidrome_album(
except ExternalServiceError as e:
logger.error("Failed to match Navidrome album %s: %s", album_id, e)
raise HTTPException(status_code=502, detail="Failed to match Navidrome album")
@router.get("/playlists", response_model=list[NavidromePlaylistSummary])
async def get_navidrome_playlists(
limit: int = Query(default=50, ge=1, le=200),
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
playlist_service: PlaylistService = Depends(get_playlist_service),
) -> list[NavidromePlaylistSummary]:
try:
playlists = await service.list_playlists(limit=limit)
imported_ids = await playlist_service.get_imported_source_ids("navidrome:")
for p in playlists:
if p.id in imported_ids:
p.is_imported = True
return playlists
except ExternalServiceError as e:
logger.error("Failed to get Navidrome playlists: %s", e)
raise HTTPException(status_code=502, detail="Failed to get Navidrome playlists")
@router.get("/playlists/{playlist_id}", response_model=NavidromePlaylistDetail)
async def get_navidrome_playlist_detail(
playlist_id: str,
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
) -> NavidromePlaylistDetail:
try:
return await service.get_playlist_detail(playlist_id)
except ResourceNotFoundError:
raise HTTPException(status_code=404, detail="Navidrome playlist not found")
except ExternalServiceError as e:
logger.error("Failed to get Navidrome playlist %s: %s", playlist_id, e)
raise HTTPException(status_code=502, detail="Failed to get Navidrome playlist")
@router.post("/playlists/{playlist_id}/import", response_model=NavidromeImportResult)
async def import_navidrome_playlist(
playlist_id: str,
background_tasks: BackgroundTasks,
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
playlist_service: PlaylistService = Depends(get_playlist_service),
jf_service=Depends(get_jellyfin_library_service),
local_service=Depends(get_local_files_service),
plex_service=Depends(get_plex_library_service),
) -> NavidromeImportResult:
try:
result = await service.import_playlist(playlist_id, playlist_service)
except ResourceNotFoundError:
raise HTTPException(status_code=404, detail="Navidrome playlist not found")
except ExternalServiceError as e:
logger.error("Failed to import Navidrome playlist %s: %s", playlist_id, e)
raise HTTPException(status_code=502, detail="Failed to import Navidrome playlist")
if not result.already_imported:
background_tasks.add_task(
playlist_service.resolve_track_sources,
result.musicseerr_playlist_id,
jf_service=jf_service,
local_service=local_service,
nd_service=service,
plex_service=plex_service,
)
return result
@router.get("/now-playing", response_model=NavidromeNowPlayingResponse)
async def get_navidrome_now_playing(
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
) -> NavidromeNowPlayingResponse:
return await service.get_now_playing()
@router.get("/top-songs/{artist_name}", response_model=list[NavidromeTrackInfo])
async def get_navidrome_top_songs(
artist_name: str = Path(..., min_length=1, max_length=256),
count: int = Query(20, ge=1, le=50),
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
) -> list[NavidromeTrackInfo]:
return await service.get_top_songs(artist_name, count=count)
@router.get("/similar-songs/{song_id}", response_model=list[NavidromeTrackInfo])
async def get_navidrome_similar_songs(
song_id: str,
count: int = Query(20, ge=1, le=50),
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
) -> list[NavidromeTrackInfo]:
return await service.get_similar_songs(song_id, count=count)
@router.get("/artist-info/{artist_id}", response_model=NavidromeArtistInfoSchema)
async def get_navidrome_artist_info(
artist_id: str,
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
) -> NavidromeArtistInfoSchema:
info = await service.get_artist_info(artist_id)
if info is None:
return NavidromeArtistInfoSchema(navidrome_id=artist_id)
return info
@router.get("/album-info/{album_id}", response_model=NavidromeAlbumInfoSchema)
async def get_navidrome_album_info(
album_id: str,
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
) -> NavidromeAlbumInfoSchema:
info = await service.get_album_info(album_id)
if info is None:
return NavidromeAlbumInfoSchema(album_id=album_id)
return info
@router.get("/lyrics/{song_id}", response_model=NavidromeLyricsResponse)
async def get_navidrome_lyrics(
song_id: str,
artist: str = Query("", description="Artist name for fallback lookup"),
title: str = Query("", description="Track title for fallback lookup"),
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
) -> NavidromeLyricsResponse:
lyrics = await service.get_lyrics(song_id, artist=artist, title=title)
if lyrics is None:
raise HTTPException(status_code=404, detail="Lyrics not available")
return lyrics

View file

@ -21,7 +21,7 @@ from api.v1.schemas.playlists import (
UpdatePlaylistRequest,
UpdateTrackRequest,
)
from core.dependencies import JellyfinLibraryServiceDep, LocalFilesServiceDep, NavidromeLibraryServiceDep, PlaylistServiceDep
from core.dependencies import JellyfinLibraryServiceDep, LocalFilesServiceDep, NavidromeLibraryServiceDep, PlexLibraryServiceDep, PlaylistServiceDep
from core.exceptions import PlaylistNotFoundError
from infrastructure.msgspec_fastapi import MsgSpecBody, MsgSpecRoute
@ -74,6 +74,7 @@ def _track_to_response(t) -> PlaylistTrackResponse:
disc_number=t.disc_number,
duration=t.duration,
created_at=t.created_at,
plex_rating_key=getattr(t, "plex_rating_key", None),
)
@ -91,6 +92,7 @@ async def list_playlists(
total_duration=s.total_duration,
cover_urls=[_normalize_cover_url(u) for u in s.cover_urls] if s.cover_urls else [],
custom_cover_url=_custom_cover_url(s.id, s.cover_image_path),
source_ref=s.source_ref,
created_at=s.created_at,
updated_at=s.updated_at,
)
@ -119,6 +121,7 @@ async def create_playlist(
id=playlist.id,
name=playlist.name,
custom_cover_url=_custom_cover_url(playlist.id, playlist.cover_image_path),
source_ref=playlist.source_ref,
tracks=[],
track_count=0,
total_duration=None,
@ -141,6 +144,7 @@ async def get_playlist(
name=playlist.name,
cover_urls=cover_urls,
custom_cover_url=_custom_cover_url(playlist.id, playlist.cover_image_path),
source_ref=playlist.source_ref,
tracks=track_responses,
track_count=len(tracks),
total_duration=total_duration or None,
@ -164,6 +168,7 @@ async def update_playlist(
name=playlist.name,
cover_urls=cover_urls,
custom_cover_url=_custom_cover_url(playlist.id, playlist.cover_image_path),
source_ref=playlist.source_ref,
tracks=track_responses,
track_count=len(tracks),
total_duration=total_duration or None,
@ -206,6 +211,7 @@ async def add_tracks(
"track_number": t.track_number,
"disc_number": t.disc_number,
"duration": int(t.duration) if t.duration is not None else None,
"plex_rating_key": t.plex_rating_key,
}
for t in body.tracks
]
@ -269,6 +275,7 @@ async def update_track(
jf_service: JellyfinLibraryServiceDep,
local_service: LocalFilesServiceDep,
nd_service: NavidromeLibraryServiceDep,
plex_service: PlexLibraryServiceDep,
body: UpdateTrackRequest = MsgSpecBody(UpdateTrackRequest),
) -> PlaylistTrackResponse:
result = await service.update_track_source(
@ -278,6 +285,7 @@ async def update_track(
jf_service=jf_service,
local_service=local_service,
nd_service=nd_service,
plex_service=plex_service,
)
return _track_to_response(result)
@ -292,9 +300,11 @@ async def resolve_sources(
jf_service: JellyfinLibraryServiceDep,
local_service: LocalFilesServiceDep,
nd_service: NavidromeLibraryServiceDep,
plex_service: PlexLibraryServiceDep,
) -> ResolveSourcesResponse:
sources = await service.resolve_track_sources(
playlist_id, jf_service=jf_service, local_service=local_service, nd_service=nd_service,
playlist_id, jf_service=jf_service, local_service=local_service,
nd_service=nd_service, plex_service=plex_service,
)
return ResolveSourcesResponse(sources=sources)
@ -305,7 +315,7 @@ async def upload_cover(
service: PlaylistServiceDep,
cover_image: UploadFile = File(...),
) -> CoverUploadResponse:
max_size = 2 * 1024 * 1024 # 2 MB
max_size = 2 * 1024 * 1024
chunk_size = 8192
chunks: list[bytes] = []
total = 0

View file

@ -0,0 +1,62 @@
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException
from api.v1.schemas.settings import PlexOAuthPinResponse, PlexOAuthPollResponse
from core.dependencies import get_plex_repository, get_preferences_service
from core.exceptions import PlexApiError
from infrastructure.msgspec_fastapi import MsgSpecRoute
from repositories.plex_repository import PlexRepository
from services.preferences_service import PreferencesService
logger = logging.getLogger(__name__)
router = APIRouter(route_class=MsgSpecRoute, prefix="/plex", tags=["plex-auth"])
def _get_or_create_client_id(preferences: PreferencesService) -> str:
return preferences.get_or_create_setting("plex_client_id", lambda: str(uuid.uuid4()))
@router.post("/auth/pin", response_model=PlexOAuthPinResponse)
async def create_plex_pin(
preferences: PreferencesService = Depends(get_preferences_service),
repo: PlexRepository = Depends(get_plex_repository),
):
try:
client_id = _get_or_create_client_id(preferences)
pin = await repo.create_oauth_pin(client_id)
auth_url = (
f"https://app.plex.tv/auth#?clientID={client_id}"
f"&code={pin.code}"
f"&context%5Bdevice%5D%5Bproduct%5D=MusicSeerr"
)
return PlexOAuthPinResponse(
pin_id=pin.id,
pin_code=pin.code,
auth_url=auth_url,
)
except PlexApiError as e:
logger.error("Failed to create Plex OAuth pin: %s", e)
raise HTTPException(status_code=502, detail="Could not start Plex authentication")
except Exception as e:
logger.exception("Unexpected error creating Plex pin: %s", e)
raise HTTPException(status_code=500, detail="Internal error during Plex authentication")
@router.get("/auth/poll", response_model=PlexOAuthPollResponse)
async def poll_plex_pin(
pin_id: int,
preferences: PreferencesService = Depends(get_preferences_service),
repo: PlexRepository = Depends(get_plex_repository),
):
try:
client_id = _get_or_create_client_id(preferences)
token = await repo.poll_oauth_pin(pin_id, client_id)
if token:
return PlexOAuthPollResponse(completed=True, auth_token=token)
return PlexOAuthPollResponse(completed=False)
except Exception as e:
logger.exception("Error polling Plex pin %d: %s", pin_id, e)
raise HTTPException(status_code=502, detail="Error polling Plex authentication status")

View file

@ -0,0 +1,386 @@
import logging
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
from fastapi.responses import Response
from api.v1.schemas.plex import (
PlexAlbumDetail,
PlexAlbumMatch,
PlexAlbumPage,
PlexAlbumSummary,
PlexAnalyticsResponse,
PlexArtistIndexResponse,
PlexArtistPage,
PlexArtistSummary,
PlexDiscoveryResponse,
PlexHistoryResponse,
PlexHubResponse,
PlexImportResult,
PlexLibraryStats,
PlexPlaylistDetail,
PlexPlaylistSummary,
PlexSearchResponse,
PlexSessionsResponse,
PlexTrackPage,
)
from core.dependencies import (
get_jellyfin_library_service,
get_local_files_service,
get_navidrome_library_service,
get_plex_library_service,
get_plex_repository,
get_playlist_service,
)
from core.exceptions import ExternalServiceError, ResourceNotFoundError
from infrastructure.msgspec_fastapi import MsgSpecRoute
from repositories.plex_repository import PlexRepository
from services.plex_library_service import PlexLibraryService
from services.playlist_service import PlaylistService
logger = logging.getLogger(__name__)
router = APIRouter(route_class=MsgSpecRoute, prefix="/plex", tags=["plex-library"])
_PLEX_SORT_FIELD: dict[str, str] = {
"name": "titleSort",
"date_added": "addedAt",
"year": "year",
"play_count": "viewCount",
"rating": "userRating",
"last_played": "lastViewedAt",
}
@router.get("/hub", response_model=PlexHubResponse)
async def get_plex_hub(
service: PlexLibraryService = Depends(get_plex_library_service),
playlist_service: PlaylistService = Depends(get_playlist_service),
) -> PlexHubResponse:
try:
hub = await service.get_hub_data()
imported_ids = await playlist_service.get_imported_source_ids("plex:")
for p in hub.playlists:
if p.id in imported_ids:
p.is_imported = True
return hub
except ExternalServiceError as e:
logger.error("Plex service error getting hub data: %s", e)
raise HTTPException(status_code=502, detail="Failed to communicate with Plex")
@router.get("/albums", response_model=PlexAlbumPage)
async def get_plex_albums(
limit: int = Query(default=48, ge=1, le=500, alias="limit"),
offset: int = Query(default=0, ge=0),
sort_by: str = Query(default="name"),
sort_order: str = Query(default=""),
genre: str = Query(default=""),
mood: str = Query(default=""),
decade: str = Query(default=""),
service: PlexLibraryService = Depends(get_plex_library_service),
) -> PlexAlbumPage:
field = _PLEX_SORT_FIELD.get(sort_by, "titleSort")
direction = "desc" if sort_order == "desc" else "asc"
plex_sort = f"{field}:{direction}"
try:
items, total_from_plex = await service.get_albums(
size=limit, offset=offset, sort=plex_sort,
genre=genre if genre else None,
mood=mood if mood else None,
decade=decade if decade else None,
)
except ExternalServiceError as e:
logger.error("Plex service error getting albums: %s", e)
raise HTTPException(status_code=502, detail="Failed to communicate with Plex")
return PlexAlbumPage(items=items, total=total_from_plex)
@router.get("/albums/{rating_key}", response_model=PlexAlbumDetail)
async def get_plex_album_detail(
rating_key: str,
service: PlexLibraryService = Depends(get_plex_library_service),
) -> PlexAlbumDetail:
result = await service.get_album_detail(rating_key)
if not result:
raise HTTPException(status_code=404, detail="Album not found")
return result
@router.get("/artists/browse", response_model=PlexArtistPage)
async def browse_plex_artists(
limit: int = Query(48, ge=1, le=100),
offset: int = Query(0, ge=0),
sort: str = Query("titleSort:asc"),
search: str = Query(""),
service: PlexLibraryService = Depends(get_plex_library_service),
) -> PlexArtistPage:
try:
items, total = await service.browse_artists(size=limit, offset=offset, sort=sort, search=search)
return PlexArtistPage(items=items, total=total, offset=offset, limit=limit)
except ExternalServiceError as e:
logger.error("Plex service error browsing artists: %s", e)
raise HTTPException(status_code=502, detail="Failed to communicate with Plex")
@router.get("/artists", response_model=list[PlexArtistSummary])
async def get_plex_artists(
service: PlexLibraryService = Depends(get_plex_library_service),
) -> list[PlexArtistSummary]:
try:
return await service.get_artists()
except ExternalServiceError as e:
logger.error("Plex service error getting artists: %s", e)
raise HTTPException(status_code=502, detail="Failed to communicate with Plex")
@router.get("/artists/index", response_model=PlexArtistIndexResponse)
async def get_plex_artists_index(
service: PlexLibraryService = Depends(get_plex_library_service),
) -> PlexArtistIndexResponse:
try:
return await service.get_artists_index()
except ExternalServiceError as e:
logger.error("Plex service error getting artist index: %s", e)
raise HTTPException(status_code=502, detail="Failed to communicate with Plex")
@router.get("/tracks", response_model=PlexTrackPage)
async def browse_plex_tracks(
limit: int = Query(48, ge=1, le=100),
offset: int = Query(0, ge=0),
sort: str = Query("titleSort:asc"),
search: str = Query(""),
service: PlexLibraryService = Depends(get_plex_library_service),
) -> PlexTrackPage:
try:
items, total = await service.browse_tracks(size=limit, offset=offset, sort=sort, search=search)
return PlexTrackPage(items=items, total=total, offset=offset, limit=limit)
except ExternalServiceError as e:
logger.error("Plex service error browsing tracks: %s", e)
raise HTTPException(status_code=502, detail="Failed to communicate with Plex")
@router.get("/search", response_model=PlexSearchResponse)
async def search_plex(
q: str = Query(..., min_length=1),
service: PlexLibraryService = Depends(get_plex_library_service),
) -> PlexSearchResponse:
try:
return await service.search(q)
except ExternalServiceError as e:
logger.error("Plex service error searching: %s", e)
raise HTTPException(status_code=502, detail="Failed to communicate with Plex")
@router.get("/recent", response_model=list[PlexAlbumSummary])
async def get_plex_recent(
limit: int = Query(default=20, ge=1, le=50),
service: PlexLibraryService = Depends(get_plex_library_service),
) -> list[PlexAlbumSummary]:
try:
return await service.get_recent(limit=limit)
except ExternalServiceError as e:
logger.error("Plex service error getting recent: %s", e)
raise HTTPException(status_code=502, detail="Failed to communicate with Plex")
@router.get("/recently-added", response_model=list[PlexAlbumSummary])
async def get_plex_recently_added(
limit: int = Query(default=20, ge=1, le=50),
service: PlexLibraryService = Depends(get_plex_library_service),
) -> list[PlexAlbumSummary]:
try:
return await service.get_recently_added_albums(limit=limit)
except ExternalServiceError as e:
logger.error("Plex service error getting recently added: %s", e)
raise HTTPException(status_code=502, detail="Failed to communicate with Plex")
@router.get("/genres", response_model=list[str])
async def get_plex_genres(
service: PlexLibraryService = Depends(get_plex_library_service),
) -> list[str]:
try:
return await service.get_genres()
except ExternalServiceError as e:
logger.error("Plex service error getting genres: %s", e)
raise HTTPException(status_code=502, detail="Failed to communicate with Plex")
@router.get("/genres/songs", response_model=PlexTrackPage)
async def get_plex_genre_songs(
genre: str = Query(..., min_length=1),
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
service: PlexLibraryService = Depends(get_plex_library_service),
) -> PlexTrackPage:
tracks, total = await service.get_songs_by_genre(genre, limit=limit, offset=offset)
return PlexTrackPage(items=tracks, total=total, offset=offset, limit=limit)
@router.get("/moods", response_model=list[str])
async def get_plex_moods(
service: PlexLibraryService = Depends(get_plex_library_service),
) -> list[str]:
try:
return await service.get_moods()
except ExternalServiceError as e:
logger.error("Plex service error getting moods: %s", e)
raise HTTPException(status_code=502, detail="Failed to communicate with Plex")
@router.get("/stats", response_model=PlexLibraryStats)
async def get_plex_stats(
service: PlexLibraryService = Depends(get_plex_library_service),
) -> PlexLibraryStats:
try:
return await service.get_stats()
except ExternalServiceError as e:
logger.error("Plex service error getting stats: %s", e)
raise HTTPException(status_code=502, detail="Failed to communicate with Plex")
@router.get("/discovery", response_model=PlexDiscoveryResponse)
async def get_plex_discovery(
count: int = Query(default=10, ge=1, le=20),
service: PlexLibraryService = Depends(get_plex_library_service),
) -> PlexDiscoveryResponse:
return await service.get_discovery_hubs(count=count)
@router.get("/thumb/{rating_key}")
async def get_plex_thumb(
rating_key: str,
size: int = Query(default=500, ge=32, le=1200),
repo: PlexRepository = Depends(get_plex_repository),
) -> Response:
try:
image_bytes, content_type = await repo.proxy_thumb(rating_key, size)
return Response(
content=image_bytes,
media_type=content_type,
headers={"Cache-Control": "public, max-age=31536000, immutable"},
)
except ExternalServiceError as e:
logger.warning("Plex thumb failed for %s: %s", rating_key, e)
raise HTTPException(status_code=502, detail="Failed to fetch thumbnail")
@router.get("/playlist-thumb/{rating_key}")
async def get_plex_playlist_thumb(
rating_key: str,
size: int = Query(default=500, ge=32, le=1200),
repo: PlexRepository = Depends(get_plex_repository),
) -> Response:
try:
image_bytes, content_type = await repo.proxy_playlist_composite(rating_key, size)
return Response(
content=image_bytes,
media_type=content_type,
headers={"Cache-Control": "public, max-age=86400"},
)
except ExternalServiceError as e:
logger.warning("Plex playlist composite failed for %s: %s", rating_key, e)
raise HTTPException(status_code=502, detail="Failed to fetch playlist thumbnail")
@router.get("/album-match/{album_id}", response_model=PlexAlbumMatch)
async def match_plex_album(
album_id: str,
name: str = Query(default=""),
artist: str = Query(default=""),
service: PlexLibraryService = Depends(get_plex_library_service),
) -> PlexAlbumMatch:
try:
return await service.get_album_match(
album_id=album_id, album_name=name, artist_name=artist,
)
except ExternalServiceError as e:
logger.error("Failed to match Plex album %s: %s", album_id, e)
raise HTTPException(status_code=502, detail="Failed to match Plex album")
@router.get("/playlists", response_model=list[PlexPlaylistSummary])
async def get_plex_playlists(
limit: int = Query(default=50, ge=1, le=200),
service: PlexLibraryService = Depends(get_plex_library_service),
playlist_service: PlaylistService = Depends(get_playlist_service),
) -> list[PlexPlaylistSummary]:
try:
playlists = await service.list_playlists(limit=limit)
imported_ids = await playlist_service.get_imported_source_ids("plex:")
for p in playlists:
if p.id in imported_ids:
p.is_imported = True
return playlists
except ExternalServiceError as e:
logger.error("Failed to get Plex playlists: %s", e)
raise HTTPException(status_code=502, detail="Failed to get Plex playlists")
@router.get("/playlists/{playlist_id}", response_model=PlexPlaylistDetail)
async def get_plex_playlist_detail(
playlist_id: str,
service: PlexLibraryService = Depends(get_plex_library_service),
) -> PlexPlaylistDetail:
try:
return await service.get_playlist_detail(playlist_id)
except ResourceNotFoundError:
raise HTTPException(status_code=404, detail="Plex playlist not found")
except ExternalServiceError as e:
logger.error("Failed to get Plex playlist %s: %s", playlist_id, e)
raise HTTPException(status_code=502, detail="Failed to get Plex playlist")
@router.post("/playlists/{playlist_id}/import", response_model=PlexImportResult)
async def import_plex_playlist(
playlist_id: str,
background_tasks: BackgroundTasks,
service: PlexLibraryService = Depends(get_plex_library_service),
playlist_service: PlaylistService = Depends(get_playlist_service),
jf_service=Depends(get_jellyfin_library_service),
local_service=Depends(get_local_files_service),
nd_service=Depends(get_navidrome_library_service),
) -> PlexImportResult:
try:
result = await service.import_playlist(playlist_id, playlist_service)
except ResourceNotFoundError:
raise HTTPException(status_code=404, detail="Plex playlist not found")
except ExternalServiceError as e:
logger.error("Failed to import Plex playlist %s: %s", playlist_id, e)
raise HTTPException(status_code=502, detail="Failed to import Plex playlist")
if not result.already_imported:
background_tasks.add_task(
playlist_service.resolve_track_sources,
result.musicseerr_playlist_id,
jf_service=jf_service,
local_service=local_service,
nd_service=nd_service,
plex_service=service,
)
return result
@router.get("/sessions", response_model=PlexSessionsResponse)
async def get_plex_sessions(
service: PlexLibraryService = Depends(get_plex_library_service),
) -> PlexSessionsResponse:
return await service.get_sessions()
@router.get("/history", response_model=PlexHistoryResponse)
async def get_plex_history(
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
service: PlexLibraryService = Depends(get_plex_library_service),
) -> PlexHistoryResponse:
return await service.get_history(limit=limit, offset=offset)
@router.get("/analytics", response_model=PlexAnalyticsResponse)
async def get_plex_analytics(
service: PlexLibraryService = Depends(get_plex_library_service),
) -> PlexAnalyticsResponse:
return await service.get_analytics()

View file

@ -22,7 +22,10 @@ from api.v1.schemas.settings import (
LastFmVerifyResponse,
ScrobbleSettings,
PrimaryMusicSourceSettings,
PlexConnectionSettings,
PlexVerifyResponse,
)
from api.v1.schemas.plex import PlexLibrarySectionInfo
from api.v1.schemas.common import VerifyConnectionResponse
from api.v1.schemas.advanced_settings import AdvancedSettingsFrontend, FrontendCacheTTLs, _is_masked_api_key
from core.dependencies import (
@ -99,6 +102,7 @@ async def get_frontend_cache_ttls(
search=backend_settings.frontend_ttl_search,
local_files_sidebar=backend_settings.frontend_ttl_local_files_sidebar,
jellyfin_sidebar=backend_settings.frontend_ttl_jellyfin_sidebar,
plex_sidebar=backend_settings.frontend_ttl_plex_sidebar,
playlist_sources=backend_settings.frontend_ttl_playlist_sources,
discover_queue_polling_interval=backend_settings.discover_queue_polling_interval,
discover_queue_auto_generate=backend_settings.discover_queue_auto_generate,
@ -287,6 +291,53 @@ async def verify_navidrome_connection(
return VerifyConnectionResponse(valid=result.valid, message=result.message)
@router.get("/plex", response_model=PlexConnectionSettings)
async def get_plex_settings(
preferences_service: PreferencesService = Depends(get_preferences_service),
):
return preferences_service.get_plex_connection()
@router.put("/plex", response_model=PlexConnectionSettings)
async def update_plex_settings(
settings: PlexConnectionSettings = MsgSpecBody(PlexConnectionSettings),
preferences_service: PreferencesService = Depends(get_preferences_service),
settings_service: SettingsService = Depends(get_settings_service),
):
try:
preferences_service.save_plex_connection(settings)
await settings_service.on_plex_settings_changed(enabled=settings.enabled)
logger.info("Updated Plex connection settings")
return preferences_service.get_plex_connection()
except ConfigurationError as e:
logger.warning("Configuration error updating Plex settings: %s", e)
raise HTTPException(status_code=400, detail="Plex settings are incomplete or invalid")
@router.post("/plex/verify", response_model=PlexVerifyResponse)
async def verify_plex_connection(
settings: PlexConnectionSettings = MsgSpecBody(PlexConnectionSettings),
settings_service: SettingsService = Depends(get_settings_service),
):
result = await settings_service.verify_plex(settings)
libs = [PlexLibrarySectionInfo(key=k, title=t) for k, t in result.libraries]
return PlexVerifyResponse(valid=result.valid, message=result.message, libraries=libs)
@router.get("/plex/libraries", response_model=list[PlexLibrarySectionInfo])
async def get_plex_libraries(
settings_service: SettingsService = Depends(get_settings_service),
):
try:
libs = await settings_service.get_plex_libraries()
return [PlexLibrarySectionInfo(key=k, title=t) for k, t in libs]
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.exception("Failed to fetch Plex libraries: %s", e)
raise HTTPException(status_code=502, detail="Could not fetch libraries from Plex")
@router.get("/listenbrainz", response_model=ListenBrainzConnectionSettings)
async def get_listenbrainz_settings(
preferences_service: PreferencesService = Depends(get_preferences_service),

View file

@ -13,12 +13,14 @@ from core.dependencies import (
get_jellyfin_playback_service,
get_local_files_service,
get_navidrome_playback_service,
get_plex_playback_service,
)
from core.exceptions import ExternalServiceError, PlaybackNotAllowedError, ResourceNotFoundError
from infrastructure.msgspec_fastapi import MsgSpecBody, MsgSpecRoute
from services.jellyfin_playback_service import JellyfinPlaybackService
from services.local_files_service import LocalFilesService
from services.navidrome_playback_service import NavidromePlaybackService
from services.plex_playback_service import PlexPlaybackService
logger = logging.getLogger(__name__)
@ -138,7 +140,7 @@ async def head_local_file(
except FileNotFoundError:
raise HTTPException(status_code=404, detail="Track file not found on disk")
except PermissionError:
raise HTTPException(status_code=403, detail="Access denied — path outside music directory")
raise HTTPException(status_code=403, detail="Access denied: path is outside the music directory")
except ExternalServiceError as e:
logger.error("Local head error for track %s: %s", track_id, e)
raise HTTPException(status_code=502, detail="Failed to check local file")
@ -170,7 +172,7 @@ async def stream_local_file(
except FileNotFoundError:
raise HTTPException(status_code=404, detail="Track file not found on disk")
except PermissionError:
raise HTTPException(status_code=403, detail="Access denied — path outside music directory")
raise HTTPException(status_code=403, detail="Access denied: path is outside the music directory")
except ExternalServiceError as e:
detail = str(e)
if "Range not satisfiable" in detail:
@ -228,3 +230,69 @@ async def navidrome_now_playing(
) -> dict[str, str]:
ok = await playback_service.report_now_playing(item_id)
return {"status": "ok" if ok else "error"}
@router.post("/navidrome/{item_id}/stopped")
async def navidrome_stopped(
item_id: str,
playback_service: NavidromePlaybackService = Depends(get_navidrome_playback_service),
) -> dict[str, str]:
await playback_service.clear_now_playing(item_id)
return {"status": "ok"}
@router.head("/plex/{part_key:path}")
async def head_plex_audio(
part_key: str,
playback_service: PlexPlaybackService = Depends(get_plex_playback_service),
) -> Response:
try:
return await playback_service.proxy_head(part_key)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid stream request")
except ExternalServiceError:
raise HTTPException(status_code=502, detail="Failed to stream from Plex")
@router.get("/plex/{part_key:path}")
async def stream_plex_audio(
part_key: str,
request: Request,
playback_service: PlexPlaybackService = Depends(get_plex_playback_service),
) -> StreamingResponse:
try:
return await playback_service.proxy_stream(part_key, request.headers.get("Range"))
except ValueError:
raise HTTPException(status_code=400, detail="Invalid stream request")
except ExternalServiceError as e:
detail = str(e)
if "416" in detail or "Range not satisfiable" in detail:
raise HTTPException(status_code=416, detail="Range not satisfiable")
raise HTTPException(status_code=502, detail="Failed to stream from Plex")
@router.post("/plex/{rating_key}/scrobble")
async def scrobble_plex(
rating_key: str,
playback_service: PlexPlaybackService = Depends(get_plex_playback_service),
) -> dict[str, str]:
ok = await playback_service.scrobble(rating_key)
return {"status": "ok" if ok else "error"}
@router.post("/plex/{rating_key}/now-playing")
async def plex_now_playing(
rating_key: str,
playback_service: PlexPlaybackService = Depends(get_plex_playback_service),
) -> dict[str, str]:
ok = await playback_service.report_now_playing(rating_key)
return {"status": "ok" if ok else "error"}
@router.post("/plex/{rating_key}/stopped")
async def plex_stopped(
rating_key: str,
playback_service: PlexPlaybackService = Depends(get_plex_playback_service),
) -> dict[str, str]:
ok = await playback_service.report_stopped(rating_key)
return {"status": "ok" if ok else "error"}

View file

@ -22,7 +22,7 @@ def _coerce_positive_int(value: object, field_name: str) -> int:
def _mask_api_key(key: str) -> str:
if len(key) > 3:
return f"***{key[-3:]}"
return f"***...{key[-3:]}"
return "***"
@ -53,6 +53,10 @@ class AdvancedSettings(AppStruct):
cache_ttl_navidrome_search: int = 120
cache_ttl_navidrome_genres: int = 3600
cache_ttl_navidrome_stats: int = 600
cache_ttl_plex_albums: int = 300
cache_ttl_plex_search: int = 120
cache_ttl_plex_genres: int = 3600
cache_ttl_plex_stats: int = 600
http_timeout: int = 10
http_connect_timeout: int = 5
http_max_connections: int = 200
@ -92,6 +96,7 @@ class AdvancedSettings(AppStruct):
frontend_ttl_search: int = 300000
frontend_ttl_local_files_sidebar: int = 120000
frontend_ttl_jellyfin_sidebar: int = 120000
frontend_ttl_plex_sidebar: int = 120000
frontend_ttl_playlist_sources: int = 900000
audiodb_enabled: bool = True
audiodb_name_search_fallback: bool = False
@ -136,6 +141,10 @@ class AdvancedSettings(AppStruct):
"cache_ttl_navidrome_search": (60, 3600),
"cache_ttl_navidrome_genres": (60, 86400),
"cache_ttl_navidrome_stats": (60, 3600),
"cache_ttl_plex_albums": (60, 3600),
"cache_ttl_plex_search": (60, 3600),
"cache_ttl_plex_genres": (60, 86400),
"cache_ttl_plex_stats": (60, 3600),
"http_timeout": (5, 60),
"http_connect_timeout": (1, 30),
"http_max_connections": (50, 500),
@ -178,6 +187,7 @@ class AdvancedSettings(AppStruct):
"frontend_ttl_search": (60000, 3600000),
"frontend_ttl_local_files_sidebar": (60000, 3600000),
"frontend_ttl_jellyfin_sidebar": (60000, 3600000),
"frontend_ttl_plex_sidebar": (60000, 3600000),
"frontend_ttl_playlist_sources": (60000, 3600000),
"cache_ttl_audiodb_found": (3600, 2592000),
"cache_ttl_audiodb_not_found": (3600, 604800),
@ -202,6 +212,7 @@ class FrontendCacheTTLs(AppStruct):
search: int = 300000
local_files_sidebar: int = 120000
jellyfin_sidebar: int = 120000
plex_sidebar: int = 120000
playlist_sources: int = 900000
discover_queue_polling_interval: int = 4000
discover_queue_auto_generate: bool = True
@ -228,6 +239,10 @@ class AdvancedSettingsFrontend(AppStruct):
cache_ttl_navidrome_search: int = 2
cache_ttl_navidrome_genres: int = 60
cache_ttl_navidrome_stats: int = 10
cache_ttl_plex_albums: int = 5
cache_ttl_plex_search: int = 2
cache_ttl_plex_genres: int = 60
cache_ttl_plex_stats: int = 10
http_timeout: int = 10
http_connect_timeout: int = 5
http_max_connections: int = 200
@ -266,6 +281,7 @@ class AdvancedSettingsFrontend(AppStruct):
frontend_ttl_search: int = 5
frontend_ttl_local_files_sidebar: int = 2
frontend_ttl_jellyfin_sidebar: int = 2
frontend_ttl_plex_sidebar: int = 2
frontend_ttl_playlist_sources: int = 15
audiodb_enabled: bool = True
audiodb_name_search_fallback: bool = False
@ -309,6 +325,10 @@ class AdvancedSettingsFrontend(AppStruct):
"cache_ttl_navidrome_search",
"cache_ttl_navidrome_genres",
"cache_ttl_navidrome_stats",
"cache_ttl_plex_albums",
"cache_ttl_plex_search",
"cache_ttl_plex_genres",
"cache_ttl_plex_stats",
"cache_ttl_audiodb_found",
"cache_ttl_audiodb_not_found",
"cache_ttl_audiodb_library",
@ -343,6 +363,10 @@ class AdvancedSettingsFrontend(AppStruct):
"cache_ttl_navidrome_search": (1, 60),
"cache_ttl_navidrome_genres": (1, 1440),
"cache_ttl_navidrome_stats": (1, 60),
"cache_ttl_plex_albums": (1, 60),
"cache_ttl_plex_search": (1, 60),
"cache_ttl_plex_genres": (1, 1440),
"cache_ttl_plex_stats": (1, 60),
"http_timeout": (5, 60),
"http_connect_timeout": (1, 30),
"http_max_connections": (50, 500),
@ -379,6 +403,7 @@ class AdvancedSettingsFrontend(AppStruct):
"frontend_ttl_search": (1, 60),
"frontend_ttl_local_files_sidebar": (1, 60),
"frontend_ttl_jellyfin_sidebar": (1, 60),
"frontend_ttl_plex_sidebar": (1, 60),
"frontend_ttl_playlist_sources": (1, 60),
"cache_ttl_audiodb_found": (1, 720),
"cache_ttl_audiodb_not_found": (1, 168),
@ -422,6 +447,10 @@ class AdvancedSettingsFrontend(AppStruct):
cache_ttl_navidrome_search=settings.cache_ttl_navidrome_search // 60,
cache_ttl_navidrome_genres=settings.cache_ttl_navidrome_genres // 60,
cache_ttl_navidrome_stats=settings.cache_ttl_navidrome_stats // 60,
cache_ttl_plex_albums=settings.cache_ttl_plex_albums // 60,
cache_ttl_plex_search=settings.cache_ttl_plex_search // 60,
cache_ttl_plex_genres=settings.cache_ttl_plex_genres // 60,
cache_ttl_plex_stats=settings.cache_ttl_plex_stats // 60,
http_timeout=settings.http_timeout,
http_connect_timeout=settings.http_connect_timeout,
http_max_connections=settings.http_max_connections,
@ -460,6 +489,7 @@ class AdvancedSettingsFrontend(AppStruct):
frontend_ttl_search=settings.frontend_ttl_search // 60000,
frontend_ttl_local_files_sidebar=settings.frontend_ttl_local_files_sidebar // 60000,
frontend_ttl_jellyfin_sidebar=settings.frontend_ttl_jellyfin_sidebar // 60000,
frontend_ttl_plex_sidebar=settings.frontend_ttl_plex_sidebar // 60000,
frontend_ttl_playlist_sources=settings.frontend_ttl_playlist_sources // 60000,
audiodb_enabled=settings.audiodb_enabled,
audiodb_name_search_fallback=settings.audiodb_name_search_fallback,
@ -504,6 +534,10 @@ class AdvancedSettingsFrontend(AppStruct):
cache_ttl_navidrome_search=self.cache_ttl_navidrome_search * 60,
cache_ttl_navidrome_genres=self.cache_ttl_navidrome_genres * 60,
cache_ttl_navidrome_stats=self.cache_ttl_navidrome_stats * 60,
cache_ttl_plex_albums=self.cache_ttl_plex_albums * 60,
cache_ttl_plex_search=self.cache_ttl_plex_search * 60,
cache_ttl_plex_genres=self.cache_ttl_plex_genres * 60,
cache_ttl_plex_stats=self.cache_ttl_plex_stats * 60,
http_timeout=self.http_timeout,
http_connect_timeout=self.http_connect_timeout,
http_max_connections=self.http_max_connections,
@ -542,6 +576,7 @@ class AdvancedSettingsFrontend(AppStruct):
frontend_ttl_search=self.frontend_ttl_search * 60000,
frontend_ttl_local_files_sidebar=self.frontend_ttl_local_files_sidebar * 60000,
frontend_ttl_jellyfin_sidebar=self.frontend_ttl_jellyfin_sidebar * 60000,
frontend_ttl_plex_sidebar=self.frontend_ttl_plex_sidebar * 60000,
frontend_ttl_playlist_sources=self.frontend_ttl_playlist_sources * 60000,
audiodb_enabled=self.audiodb_enabled,
audiodb_name_search_fallback=self.audiodb_name_search_fallback,

View file

@ -15,6 +15,7 @@ class IntegrationStatus(AppStruct):
lastfm: bool
navidrome: bool = False
youtube_api: bool = False
plex: bool = False
class StatusReport(AppStruct):

View file

@ -9,8 +9,10 @@ class JellyfinTrackInfo(AppStruct):
disc_number: int = 1
album_name: str = ""
artist_name: str = ""
album_id: str = ""
codec: str | None = None
bitrate: int | None = None
image_url: str | None = None
class JellyfinAlbumSummary(AppStruct):
@ -22,6 +24,7 @@ class JellyfinAlbumSummary(AppStruct):
image_url: str | None = None
musicbrainz_id: str | None = None
artist_musicbrainz_id: str | None = None
play_count: int = 0
class JellyfinAlbumDetail(AppStruct):
@ -48,6 +51,7 @@ class JellyfinArtistSummary(AppStruct):
image_url: str | None = None
album_count: int = 0
musicbrainz_id: str | None = None
play_count: int = 0
class JellyfinLibraryStats(AppStruct):
@ -62,8 +66,127 @@ class JellyfinSearchResponse(AppStruct):
tracks: list[JellyfinTrackInfo] = []
class JellyfinPlaylistSummary(AppStruct):
id: str
name: str
track_count: int = 0
duration_seconds: int = 0
cover_url: str = ""
created_at: str = ""
is_imported: bool = False
class JellyfinHubResponse(AppStruct):
stats: JellyfinLibraryStats | None = None
recently_played: list[JellyfinAlbumSummary] = []
recently_added: list[JellyfinAlbumSummary] = []
favorites: list[JellyfinAlbumSummary] = []
most_played_artists: list[JellyfinArtistSummary] = []
most_played_albums: list[JellyfinAlbumSummary] = []
all_albums_preview: list[JellyfinAlbumSummary] = []
genres: list[str] = []
playlists: list[JellyfinPlaylistSummary] = []
class JellyfinPaginatedResponse(AppStruct):
items: list[JellyfinAlbumSummary] = []
total: int = 0
offset: int = 0
limit: int = 50
class JellyfinArtistPage(AppStruct):
items: list[JellyfinArtistSummary] = []
total: int = 0
offset: int = 0
limit: int = 50
class JellyfinArtistIndexEntry(AppStruct):
name: str = ""
artists: list[JellyfinArtistSummary] = []
class JellyfinArtistIndexResponse(AppStruct):
index: list[JellyfinArtistIndexEntry] = []
class JellyfinTrackPage(AppStruct):
items: list[JellyfinTrackInfo] = []
total: int = 0
offset: int = 0
limit: int = 50
class JellyfinPlaylistTrack(AppStruct):
id: str
track_name: str
artist_name: str = ""
album_name: str = ""
album_id: str = ""
artist_id: str = ""
duration_seconds: int = 0
track_number: int = 0
disc_number: int = 1
cover_url: str = ""
class JellyfinPlaylistDetail(AppStruct):
id: str
name: str
track_count: int = 0
duration_seconds: int = 0
cover_url: str = ""
created_at: str = ""
tracks: list[JellyfinPlaylistTrack] = []
class JellyfinImportResult(AppStruct):
musicseerr_playlist_id: str = ""
tracks_imported: int = 0
tracks_failed: int = 0
already_imported: bool = False
class JellyfinSessionInfo(AppStruct):
session_id: str = ""
user_name: str = ""
device_name: str = ""
client_name: str = ""
track_name: str = ""
artist_name: str = ""
album_name: str = ""
album_id: str = ""
cover_url: str = ""
position_seconds: float = 0.0
duration_seconds: float = 0.0
is_paused: bool = False
play_method: str = ""
audio_codec: str = ""
bitrate: int = 0
class JellyfinSessionsResponse(AppStruct):
sessions: list[JellyfinSessionInfo] = []
class JellyfinLyricsLineSchema(AppStruct):
text: str = ""
start_seconds: float | None = None
class JellyfinLyricsResponse(AppStruct):
lines: list[JellyfinLyricsLineSchema] = []
is_synced: bool = False
lyrics_text: str = ""
class JellyfinFavoritesExpanded(AppStruct):
albums: list[JellyfinAlbumSummary] = []
artists: list[JellyfinArtistSummary] = []
class JellyfinFilterFacets(AppStruct):
years: list[int] = []
tags: list[str] = []
studios: list[str] = []

View file

@ -13,6 +13,7 @@ class NavidromeTrackInfo(AppStruct):
artist_name: str = ""
codec: str | None = None
bitrate: int | None = None
image_url: str | None = None
class NavidromeAlbumSummary(AppStruct):
@ -64,6 +65,135 @@ class NavidromeSearchResponse(AppStruct):
tracks: list[NavidromeTrackInfo] = []
class NavidromePlaylistSummary(AppStruct):
id: str
name: str
track_count: int = 0
duration_seconds: int = 0
cover_url: str = ""
owner: str = ""
is_public: bool = False
updated_at: str = ""
is_imported: bool = False
class NavidromeHubResponse(AppStruct):
stats: NavidromeLibraryStats | None = None
recently_played: list[NavidromeAlbumSummary] = []
favorites: list[NavidromeAlbumSummary] = []
favorite_artists: list[NavidromeArtistSummary] = []
favorite_tracks: list[NavidromeTrackInfo] = []
all_albums_preview: list[NavidromeAlbumSummary] = []
genres: list[str] = []
playlists: list[NavidromePlaylistSummary] = []
class NavidromeAlbumPage(AppStruct):
items: list[NavidromeAlbumSummary] = []
total: int = 0
class NavidromeArtistPage(AppStruct):
items: list[NavidromeArtistSummary] = []
total: int = 0
offset: int = 0
limit: int = 50
class NavidromeTrackPage(AppStruct):
items: list[NavidromeTrackInfo] = []
total: int = 0
offset: int = 0
limit: int = 50
class NavidromePlaylistTrack(AppStruct):
id: str
track_name: str
artist_name: str = ""
album_name: str = ""
album_id: str = ""
artist_id: str = ""
duration_seconds: int = 0
track_number: int = 0
disc_number: int = 1
cover_url: str = ""
class NavidromePlaylistDetail(AppStruct):
id: str
name: str
track_count: int = 0
duration_seconds: int = 0
cover_url: str = ""
tracks: list[NavidromePlaylistTrack] = []
class NavidromeImportResult(AppStruct):
musicseerr_playlist_id: str = ""
tracks_imported: int = 0
tracks_failed: int = 0
already_imported: bool = False
class NavidromeNowPlayingEntrySchema(AppStruct):
user_name: str = ""
minutes_ago: int = 0
player_name: str = ""
track_name: str = ""
artist_name: str = ""
album_name: str = ""
album_id: str = ""
cover_art_id: str = ""
duration_seconds: int = 0
estimated_position_seconds: float = 0.0
class NavidromeNowPlayingResponse(AppStruct):
entries: list[NavidromeNowPlayingEntrySchema] = []
class NavidromeArtistInfoSchema(AppStruct):
navidrome_id: str = ""
name: str = ""
biography: str = ""
image_url: str = ""
similar_artists: list[NavidromeArtistSummary] = []
class NavidromeAlbumInfoSchema(AppStruct):
album_id: str = ""
notes: str = ""
musicbrainz_id: str = ""
lastfm_url: str = ""
image_url: str = ""
class NavidromeLyricLine(AppStruct):
text: str = ""
start_seconds: float | None = None
class NavidromeLyricsResponse(AppStruct):
text: str = ""
is_synced: bool = False
lines: list[NavidromeLyricLine] = []
class NavidromeArtistIndexEntry(AppStruct):
name: str = ""
artists: list[NavidromeArtistSummary] = []
class NavidromeArtistIndexResponse(AppStruct):
index: list[NavidromeArtistIndexEntry] = []
class NavidromeGenreSongsResponse(AppStruct):
songs: list[NavidromeTrackInfo] = []
genre: str = ""
class NavidromeMusicFolder(AppStruct):
id: str = ""
name: str = ""

View file

@ -19,6 +19,7 @@ class PlaylistTrackResponse(AppStruct):
disc_number: int | None = None
duration: int | None = None
created_at: str = ""
plex_rating_key: str | None = None
class PlaylistSummaryResponse(AppStruct):
@ -28,16 +29,18 @@ class PlaylistSummaryResponse(AppStruct):
total_duration: int | None = None
cover_urls: list[str] = msgspec.field(default_factory=list)
custom_cover_url: str | None = None
source_ref: str | None = None
created_at: str = ""
updated_at: str = ""
class PlaylistDetailResponse(AppStruct):
# Frontend PlaylistDetail extends PlaylistSummary — keep fields in sync with PlaylistSummaryResponse
# Keep these fields in sync with PlaylistSummaryResponse because the frontend extends PlaylistSummary.
id: str
name: str
cover_urls: list[str] = msgspec.field(default_factory=list)
custom_cover_url: str | None = None
source_ref: str | None = None
tracks: list[PlaylistTrackResponse] = msgspec.field(default_factory=list)
track_count: int = 0
total_duration: int | None = None
@ -71,6 +74,7 @@ class TrackDataRequest(AppStruct):
track_number: int | None = None
disc_number: int | None = None
duration: float | int | None = None
plex_rating_key: str | None = None
class AddTracksRequest(AppStruct):

View file

@ -0,0 +1,231 @@
from __future__ import annotations
from infrastructure.msgspec_fastapi import AppStruct
class PlexTrackInfo(AppStruct):
plex_id: str
title: str
track_number: int
duration_seconds: float
disc_number: int = 1
album_name: str = ""
artist_name: str = ""
codec: str | None = None
bitrate: int | None = None
audio_channels: int | None = None
container: str | None = None
part_key: str | None = None
image_url: str | None = None
class PlexAlbumSummary(AppStruct):
plex_id: str
name: str
artist_name: str = ""
year: int | None = None
track_count: int = 0
image_url: str | None = None
musicbrainz_id: str | None = None
artist_musicbrainz_id: str | None = None
last_viewed_at: int = 0
class PlexAlbumDetail(AppStruct):
plex_id: str
name: str
artist_name: str = ""
year: int | None = None
track_count: int = 0
image_url: str | None = None
musicbrainz_id: str | None = None
artist_musicbrainz_id: str | None = None
tracks: list[PlexTrackInfo] = []
genres: list[str] = []
class PlexAlbumMatch(AppStruct):
found: bool
plex_album_id: str | None = None
tracks: list[PlexTrackInfo] = []
class PlexArtistSummary(AppStruct):
plex_id: str
name: str
image_url: str | None = None
musicbrainz_id: str | None = None
class PlexLibraryStats(AppStruct):
total_tracks: int = 0
total_albums: int = 0
total_artists: int = 0
class PlexSearchResponse(AppStruct):
albums: list[PlexAlbumSummary] = []
artists: list[PlexArtistSummary] = []
tracks: list[PlexTrackInfo] = []
class PlexAlbumPage(AppStruct):
items: list[PlexAlbumSummary] = []
total: int = 0
class PlexArtistPage(AppStruct):
items: list[PlexArtistSummary] = []
total: int = 0
offset: int = 0
limit: int = 50
class PlexArtistIndexEntry(AppStruct):
name: str = ""
artists: list[PlexArtistSummary] = []
class PlexArtistIndexResponse(AppStruct):
index: list[PlexArtistIndexEntry] = []
class PlexTrackPage(AppStruct):
items: list[PlexTrackInfo] = []
total: int = 0
offset: int = 0
limit: int = 50
class PlexPlaylistSummary(AppStruct):
id: str
name: str
track_count: int = 0
duration_seconds: int = 0
is_smart: bool = False
cover_url: str = ""
updated_at: str = ""
is_imported: bool = False
class PlexHubResponse(AppStruct):
stats: PlexLibraryStats | None = None
recently_played: list[PlexAlbumSummary] = []
recently_added: list[PlexAlbumSummary] = []
all_albums_preview: list[PlexAlbumSummary] = []
genres: list[str] = []
playlists: list[PlexPlaylistSummary] = []
class PlexDiscoveryAlbum(AppStruct):
plex_id: str
name: str
artist_name: str = ""
year: int | None = None
image_url: str | None = None
class PlexDiscoveryHub(AppStruct):
title: str
hub_type: str = ""
albums: list[PlexDiscoveryAlbum] = []
class PlexDiscoveryResponse(AppStruct):
hubs: list[PlexDiscoveryHub] = []
class PlexLibrarySectionInfo(AppStruct):
key: str
title: str
class PlexPlaylistTrack(AppStruct):
id: str
track_name: str
artist_name: str = ""
album_name: str = ""
album_id: str = ""
plex_rating_key: str = ""
duration_seconds: int = 0
track_number: int = 0
disc_number: int = 1
cover_url: str = ""
class PlexPlaylistDetail(AppStruct):
id: str
name: str
track_count: int = 0
duration_seconds: int = 0
is_smart: bool = False
cover_url: str = ""
updated_at: str = ""
tracks: list[PlexPlaylistTrack] = []
class PlexImportResult(AppStruct):
musicseerr_playlist_id: str = ""
tracks_imported: int = 0
tracks_failed: int = 0
already_imported: bool = False
class PlexSessionInfo(AppStruct):
session_id: str = ""
user_name: str = ""
track_title: str = ""
artist_name: str = ""
album_name: str = ""
cover_url: str = ""
player_device: str = ""
player_platform: str = ""
player_state: str = ""
is_direct_play: bool = True
progress_ms: int = 0
duration_ms: int = 0
audio_codec: str = ""
audio_channels: int = 0
bitrate: int = 0
class PlexSessionsResponse(AppStruct):
sessions: list[PlexSessionInfo] = []
available: bool = True
class PlexHistoryEntrySchema(AppStruct):
rating_key: str = ""
track_title: str = ""
artist_name: str = ""
album_name: str = ""
cover_url: str = ""
viewed_at: str = ""
device_name: str = ""
class PlexHistoryResponse(AppStruct):
entries: list[PlexHistoryEntrySchema] = []
total: int = 0
limit: int = 0
offset: int = 0
available: bool = True
class PlexAnalyticsItem(AppStruct):
name: str = ""
subtitle: str = ""
play_count: int = 0
cover_url: str | None = None
class PlexAnalyticsResponse(AppStruct):
top_artists: list[PlexAnalyticsItem] = []
top_albums: list[PlexAnalyticsItem] = []
top_tracks: list[PlexAnalyticsItem] = []
total_listens: int = 0
listens_last_7_days: int = 0
listens_last_30_days: int = 0
total_hours: float = 0.0
is_complete: bool = True
entries_analyzed: int = 0

View file

@ -2,6 +2,7 @@ from typing import Literal
import msgspec
from api.v1.schemas.plex import PlexLibrarySectionInfo
from infrastructure.msgspec_fastapi import AppStruct
LASTFM_SECRET_MASK = "••••••••"
@ -93,6 +94,7 @@ class JellyfinConnectionSettings(AppStruct):
NAVIDROME_PASSWORD_MASK = "********"
PLEX_TOKEN_MASK = "plex****"
class NavidromeConnectionSettings(AppStruct):
@ -105,6 +107,34 @@ class NavidromeConnectionSettings(AppStruct):
self.navidrome_url = self.navidrome_url.rstrip("/") if self.navidrome_url else ""
class PlexConnectionSettings(AppStruct):
plex_url: str = ""
plex_token: str = ""
enabled: bool = False
music_library_ids: list[str] = []
scrobble_to_plex: bool = True
def __post_init__(self) -> None:
self.plex_url = self.plex_url.rstrip("/") if self.plex_url else ""
class PlexVerifyResponse(AppStruct):
valid: bool
message: str
libraries: list[PlexLibrarySectionInfo] = []
class PlexOAuthPinResponse(AppStruct):
pin_id: int
pin_code: str
auth_url: str
class PlexOAuthPollResponse(AppStruct):
completed: bool
auth_token: str = ""
class JellyfinUserInfo(AppStruct):
id: str
name: str

View file

@ -26,6 +26,7 @@ from .repo_providers import ( # noqa: F401
get_listenbrainz_repository,
get_jellyfin_repository,
get_navidrome_repository,
get_plex_repository,
get_coverart_repository,
get_youtube_repo,
get_audiodb_repository,
@ -65,6 +66,8 @@ from .service_providers import ( # noqa: F401
get_jellyfin_library_service,
get_navidrome_library_service,
get_navidrome_playback_service,
get_plex_library_service,
get_plex_playback_service,
)
from .type_aliases import ( # noqa: F401
@ -109,6 +112,9 @@ from .type_aliases import ( # noqa: F401
NavidromeRepositoryDep,
NavidromeLibraryServiceDep,
NavidromePlaybackServiceDep,
PlexRepositoryDep,
PlexLibraryServiceDep,
PlexPlaybackServiceDep,
CacheStatusServiceDep,
)

View file

@ -1,4 +1,4 @@
"""Tier 3 — Repository providers and infrastructure services."""
"""Tier 3: repository providers and infrastructure services."""
from __future__ import annotations
@ -125,6 +125,33 @@ def get_navidrome_repository() -> "NavidromeRepository":
return repo
@singleton
def get_plex_repository() -> "PlexRepository":
from repositories.plex_repository import PlexRepository
cache = get_cache()
http_client = _get_configured_http_client()
preferences = get_preferences_service()
plex_settings = preferences.get_plex_connection_raw()
repo = PlexRepository(http_client=http_client, cache=cache)
if plex_settings.enabled:
client_id = preferences.get_setting("plex_client_id") or ""
repo.configure(
url=plex_settings.plex_url,
token=plex_settings.plex_token,
client_id=client_id,
)
adv = preferences.get_advanced_settings()
repo.configure_cache_ttls(
list_ttl=adv.cache_ttl_plex_albums,
search_ttl=adv.cache_ttl_plex_search,
genres_ttl=adv.cache_ttl_plex_genres,
detail_ttl=adv.cache_ttl_plex_albums,
stats_ttl=adv.cache_ttl_plex_stats,
)
return repo
@singleton
def get_youtube_repo() -> "YouTubeRepository":
from repositories.youtube import YouTubeRepository

View file

@ -35,6 +35,7 @@ from .repo_providers import (
get_listenbrainz_repository,
get_jellyfin_repository,
get_navidrome_repository,
get_plex_repository,
get_coverart_repository,
get_youtube_repo,
get_audiodb_image_service,
@ -138,7 +139,6 @@ def make_processor(lidarr_repo, memory_cache, disk_cache, cover_repo, request_hi
if payload and isinstance(payload, dict):
is_monitored = payload.get("monitored", False)
# Prefer the explicit monitored flag before falling back to the top-level result.
if not is_monitored:
is_monitored = bool(result.get("monitored"))
@ -568,7 +568,8 @@ def get_jellyfin_playback_service() -> "JellyfinPlaybackService":
from services.jellyfin_playback_service import JellyfinPlaybackService
jellyfin_repo = get_jellyfin_repository()
return JellyfinPlaybackService(jellyfin_repo)
cache = get_cache()
return JellyfinPlaybackService(jellyfin_repo, cache)
@singleton
@ -606,4 +607,25 @@ def get_navidrome_playback_service() -> "NavidromePlaybackService":
from services.navidrome_playback_service import NavidromePlaybackService
navidrome_repo = get_navidrome_repository()
return NavidromePlaybackService(navidrome_repo)
cache = get_cache()
return NavidromePlaybackService(navidrome_repo, cache)
@singleton
def get_plex_library_service() -> "PlexLibraryService":
from services.plex_library_service import PlexLibraryService
plex_repo = get_plex_repository()
preferences_service = get_preferences_service()
library_db = get_library_db()
mbid_store = get_mbid_store()
return PlexLibraryService(plex_repo, preferences_service, library_db, mbid_store)
@singleton
def get_plex_playback_service() -> "PlexPlaybackService":
from services.plex_playback_service import PlexPlaybackService
plex_repo = get_plex_repository()
cache = get_cache()
return PlexPlaybackService(plex_repo, cache)

View file

@ -21,6 +21,7 @@ from repositories.youtube import YouTubeRepository
from repositories.lastfm_repository import LastFmRepository
from repositories.playlist_repository import PlaylistRepository
from repositories.navidrome_repository import NavidromeRepository
from repositories.plex_repository import PlexRepository
from services.preferences_service import PreferencesService
from services.search_service import SearchService
from services.search_enrichment_service import SearchEnrichmentService
@ -44,6 +45,8 @@ from services.local_files_service import LocalFilesService
from services.jellyfin_library_service import JellyfinLibraryService
from services.navidrome_library_service import NavidromeLibraryService
from services.navidrome_playback_service import NavidromePlaybackService
from services.plex_library_service import PlexLibraryService
from services.plex_playback_service import PlexPlaybackService
from services.playlist_service import PlaylistService
from services.lastfm_auth_service import LastFmAuthService
from services.scrobble_service import ScrobbleService
@ -68,6 +71,7 @@ from .repo_providers import (
get_playlist_repository,
get_request_history_store,
get_navidrome_repository,
get_plex_repository,
)
from .service_providers import (
get_search_service,
@ -95,6 +99,8 @@ from .service_providers import (
get_jellyfin_library_service,
get_navidrome_library_service,
get_navidrome_playback_service,
get_plex_library_service,
get_plex_playback_service,
)
@ -139,4 +145,7 @@ PlaylistServiceDep = Annotated[PlaylistService, Depends(get_playlist_service)]
NavidromeRepositoryDep = Annotated[NavidromeRepository, Depends(get_navidrome_repository)]
NavidromeLibraryServiceDep = Annotated[NavidromeLibraryService, Depends(get_navidrome_library_service)]
NavidromePlaybackServiceDep = Annotated[NavidromePlaybackService, Depends(get_navidrome_playback_service)]
PlexRepositoryDep = Annotated[PlexRepository, Depends(get_plex_repository)]
PlexLibraryServiceDep = Annotated[PlexLibraryService, Depends(get_plex_library_service)]
PlexPlaybackServiceDep = Annotated[PlexPlaybackService, Depends(get_plex_playback_service)]
CacheStatusServiceDep = Annotated[CacheStatusService, Depends(get_cache_status_service)]

View file

@ -64,6 +64,21 @@ class TokenNotAuthorizedError(ExternalServiceError):
pass
class PlexApiError(ExternalServiceError):
def __init__(
self,
message: str,
details: Any = None,
code: int | None = None,
):
super().__init__(message, details)
self.code = code
class PlexAuthError(PlexApiError):
pass
class NavidromeApiError(ExternalServiceError):
def __init__(
self,

View file

@ -373,13 +373,27 @@ async def warm_navidrome_mbid_cache() -> None:
await asyncio.sleep(14400) # Re-warm every 4 hours
async def warm_plex_mbid_cache() -> None:
from core.dependencies import get_plex_library_service
await asyncio.sleep(15)
while True:
try:
service = get_plex_library_service()
await service.warm_mbid_cache()
await service.persist_if_dirty()
except Exception as e:
logger.error("Plex MBID cache warming failed: %s", e, exc_info=True)
await asyncio.sleep(14400)
async def warm_artist_discovery_cache_periodically(
artist_discovery_service: 'ArtistDiscoveryService',
library_db: 'LibraryDB',
interval: int = 14400,
delay: float = 0.5,
) -> None:
await asyncio.sleep(300) # Wait for Phase 1.5 sync to finish first
await asyncio.sleep(300) # Allow initial library sync to complete before warming caches
while True:
try:
@ -636,8 +650,6 @@ def start_request_status_sync_task(
return task
# --- Orphan cover demotion ---
async def demote_orphaned_covers_periodically(
cover_disk_cache: 'CoverDiskCache',
library_db: 'LibraryDB',
@ -684,8 +696,6 @@ def start_orphan_cover_demotion_task(
return task
# --- Store pruning (request history + ignored releases + youtube orphans) ---
async def prune_stores_periodically(
request_history: 'RequestHistoryStore',
mbid_store: 'MBIDStore',

View file

@ -2,7 +2,6 @@
from typing import Optional
MB_ARTIST_SEARCH_PREFIX = "mb:artist:search:"
MB_ARTIST_DETAIL_PREFIX = "mb:artist:detail:"
MB_ALBUM_SEARCH_PREFIX = "mb:album:search:"
@ -23,6 +22,8 @@ JELLYFIN_PREFIX = "jellyfin_"
NAVIDROME_PREFIX = "navidrome:"
PLEX_PREFIX = "plex:"
LIDARR_PREFIX = "lidarr:"
LIDARR_REQUESTED_PREFIX = "lidarr_requested"
LIDARR_ARTIST_IMAGE_PREFIX = "lidarr_artist_image:"
@ -59,9 +60,8 @@ PREFERENCES_PREFIX = "preferences:"
AUDIODB_PREFIX = "audiodb_"
def musicbrainz_prefixes() -> list[str]:
"""All MusicBrainz cache key prefixes for bulk invalidation."""
"""All MusicBrainz cache key prefixes for bulk invalidation."""
return [
MB_ARTIST_SEARCH_PREFIX,
MB_ARTIST_DETAIL_PREFIX,
@ -78,12 +78,10 @@ def musicbrainz_prefixes() -> list[str]:
def listenbrainz_prefixes() -> list[str]:
"""All ListenBrainz cache key prefixes."""
return [LB_PREFIX]
def lastfm_prefixes() -> list[str]:
"""All Last.fm cache key prefixes."""
return [LFM_PREFIX]
@ -92,14 +90,12 @@ def home_prefixes() -> list[str]:
return [HOME_RESPONSE_PREFIX, DISCOVER_RESPONSE_PREFIX, GENRE_ARTIST_PREFIX, GENRE_SECTION_PREFIX]
def _sort_params(**kwargs) -> str:
"""Sort parameters for consistent key generation."""
return ":".join(f"{k}={v}" for k, v in sorted(kwargs.items()) if v is not None)
def mb_artist_search_key(query: str, limit: int, offset: int) -> str:
"""Generate cache key for MusicBrainz artist search."""
return f"{MB_ARTIST_SEARCH_PREFIX}{query}:{limit}:{offset}"
@ -109,86 +105,70 @@ def mb_album_search_key(
offset: int,
included_secondary_types: Optional[set[str]] = None
) -> str:
"""Generate cache key for MusicBrainz album search."""
types_str = ",".join(sorted(included_secondary_types)) if included_secondary_types else "none"
return f"{MB_ALBUM_SEARCH_PREFIX}{query}:{limit}:{offset}:{types_str}"
def mb_artist_detail_key(mbid: str) -> str:
"""Generate cache key for MusicBrainz artist details."""
return f"{MB_ARTIST_DETAIL_PREFIX}{mbid}"
def mb_release_group_key(mbid: str, includes: Optional[list[str]] = None) -> str:
"""Generate cache key for MusicBrainz release group."""
includes_str = ",".join(sorted(includes)) if includes else "default"
return f"{MB_RG_DETAIL_PREFIX}{mbid}:{includes_str}"
def mb_release_key(release_id: str, includes: Optional[list[str]] = None) -> str:
"""Generate cache key for MusicBrainz release."""
includes_str = ",".join(sorted(includes)) if includes else "default"
return f"{MB_RELEASE_DETAIL_PREFIX}{release_id}:{includes_str}"
def lidarr_library_albums_key(include_unmonitored: bool = False) -> str:
"""Generate cache key for full Lidarr library album list."""
suffix = "all" if include_unmonitored else "monitored"
return f"{LIDARR_PREFIX}library:albums:{suffix}"
def lidarr_library_artists_key(include_unmonitored: bool = False) -> str:
"""Generate cache key for Lidarr library artist list."""
suffix = "all" if include_unmonitored else "monitored"
return f"{LIDARR_PREFIX}library:artists:{suffix}"
def lidarr_library_mbids_key(include_release_ids: bool = False) -> str:
"""Generate cache key for Lidarr library MBIDs."""
suffix = "with_releases" if include_release_ids else "albums_only"
return f"{LIDARR_PREFIX}library:mbids:{suffix}"
def lidarr_artist_mbids_key() -> str:
"""Generate cache key for Lidarr artist MBIDs."""
return f"{LIDARR_PREFIX}artists:mbids"
def lidarr_raw_albums_key() -> str:
"""Generate cache key for raw Lidarr album payload."""
return f"{LIDARR_PREFIX}raw:albums"
def lidarr_library_grouped_key() -> str:
"""Generate cache key for grouped Lidarr library albums."""
return f"{LIDARR_PREFIX}library:grouped"
def lidarr_requested_mbids_key() -> str:
"""Generate cache key for Lidarr requested (pending download) MBIDs."""
return f"{LIDARR_REQUESTED_PREFIX}_mbids"
def lidarr_status_key() -> str:
"""Generate cache key for Lidarr status."""
return f"{LIDARR_PREFIX}status"
def wikidata_artist_image_key(wikidata_id: str) -> str:
"""Generate cache key for Wikidata artist image."""
return f"{WIKIDATA_IMAGE_PREFIX}{wikidata_id}"
def wikidata_url_key(artist_id: str) -> str:
"""Generate cache key for artist Wikidata URL."""
return f"{WIKIDATA_URL_PREFIX}{artist_id}"
def wikipedia_extract_key(url: str) -> str:
"""Generate cache key for Wikipedia extract."""
return f"{WIKIPEDIA_PREFIX}{url}"
def preferences_key() -> str:
"""Generate cache key for preferences."""
return f"{PREFERENCES_PREFIX}current"

View file

@ -1,4 +1,4 @@
"""Domain 4 MBID resolution and external-service index persistence."""
"""Domain 4: MBID resolution and external-service index persistence."""
import logging
import sqlite3
@ -68,6 +68,24 @@ class MBIDStore(PersistenceBase):
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS plex_album_mbid_index (
cache_key TEXT PRIMARY KEY,
mbid TEXT,
saved_at REAL NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS plex_artist_mbid_index (
cache_key TEXT PRIMARY KEY,
mbid TEXT,
saved_at REAL NOT NULL
)
"""
)
conn.commit()
finally:
conn.close()
@ -260,6 +278,63 @@ class MBIDStore(PersistenceBase):
await self._write(operation)
async def save_plex_album_mbid_index(self, index: dict[str, str | None]) -> None:
saved_at = time.time()
def operation(conn: sqlite3.Connection) -> None:
conn.execute("DELETE FROM plex_album_mbid_index")
for cache_key, mbid in index.items():
if cache_key:
conn.execute(
"INSERT OR REPLACE INTO plex_album_mbid_index (cache_key, mbid, saved_at) VALUES (?, ?, ?)",
(cache_key, mbid, saved_at),
)
await self._write(operation)
async def load_plex_album_mbid_index(self, max_age_seconds: int = 86400) -> dict[str, str | None]:
def operation(conn: sqlite3.Connection) -> dict[str, str | None]:
row = conn.execute("SELECT MAX(saved_at) AS saved_at FROM plex_album_mbid_index").fetchone()
if row is None or row["saved_at"] is None:
return {}
if time.time() - float(row["saved_at"]) > max(max_age_seconds, 1):
return {}
rows = conn.execute("SELECT cache_key, mbid FROM plex_album_mbid_index").fetchall()
return {str(r["cache_key"]): (str(r["mbid"]) if r["mbid"] else None) for r in rows if r["cache_key"]}
return await self._read(operation)
async def save_plex_artist_mbid_index(self, index: dict[str, str | None]) -> None:
saved_at = time.time()
def operation(conn: sqlite3.Connection) -> None:
conn.execute("DELETE FROM plex_artist_mbid_index")
for cache_key, mbid in index.items():
if cache_key:
conn.execute(
"INSERT OR REPLACE INTO plex_artist_mbid_index (cache_key, mbid, saved_at) VALUES (?, ?, ?)",
(cache_key, mbid, saved_at),
)
await self._write(operation)
async def load_plex_artist_mbid_index(self, max_age_seconds: int = 86400) -> dict[str, str | None]:
def operation(conn: sqlite3.Connection) -> dict[str, str | None]:
row = conn.execute("SELECT MAX(saved_at) AS saved_at FROM plex_artist_mbid_index").fetchone()
if row is None or row["saved_at"] is None:
return {}
if time.time() - float(row["saved_at"]) > max(max_age_seconds, 1):
return {}
rows = conn.execute("SELECT cache_key, mbid FROM plex_artist_mbid_index").fetchall()
return {str(r["cache_key"]): (str(r["mbid"]) if r["mbid"] else None) for r in rows if r["cache_key"]}
return await self._read(operation)
async def clear_plex_mbid_indexes(self) -> None:
def operation(conn: sqlite3.Connection) -> None:
conn.execute("DELETE FROM plex_album_mbid_index")
conn.execute("DELETE FROM plex_artist_mbid_index")
await self._write(operation)
async def prune_old_ignored_releases(self, days: int) -> int:
"""Delete ignored releases older than `days` days."""
import time as _time

View file

@ -48,6 +48,8 @@ from api.v1.routes import navidrome_library as navidrome_library_routes
from api.v1.routes import local_library as local_library_routes
from api.v1.routes import lastfm as lastfm_routes
from api.v1.routes import scrobble as scrobble_routes
from api.v1.routes import plex_library as plex_library_routes
from api.v1.routes import plex_auth as plex_auth_routes
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
@ -196,6 +198,17 @@ async def lifespan(app: FastAPI):
)
TaskRegistry.get_instance().register("navidrome-mbid-warmup", nav_mbid_task)
plex_settings = preferences_service.get_plex_connection()
if plex_settings.enabled:
from core.tasks import warm_plex_mbid_cache
plex_mbid_task = asyncio.create_task(warm_plex_mbid_cache())
plex_mbid_task.add_done_callback(
lambda t: None if t.cancelled() else (
logger.error("Plex MBID cache warming failed: %s", t.exception()) if t.exception() else None
)
)
TaskRegistry.get_instance().register("plex-mbid-warmup", plex_mbid_task)
from core.dependencies import get_requests_page_service
requests_page_service = get_requests_page_service()
@ -322,6 +335,8 @@ v1_router.include_router(requests_page_routes.router)
v1_router.include_router(stream_routes.router)
v1_router.include_router(jellyfin_library_routes.router)
v1_router.include_router(navidrome_library_routes.router)
v1_router.include_router(plex_library_routes.router)
v1_router.include_router(plex_auth_routes.router)
v1_router.include_router(local_library_routes.router)
v1_router.include_router(lastfm_routes.router)
v1_router.include_router(scrobble_routes.router)

View file

@ -19,12 +19,18 @@ class AsyncPlaylistRepository:
def __init__(self, repo: PlaylistRepository):
self._repo = repo
async def create_playlist(self, name: str) -> PlaylistRecord:
return await asyncio.to_thread(self._repo.create_playlist, name)
async def create_playlist(self, name: str, source_ref: str | None = None) -> PlaylistRecord:
return await asyncio.to_thread(self._repo.create_playlist, name, source_ref)
async def get_playlist(self, playlist_id: str) -> Optional[PlaylistRecord]:
return await asyncio.to_thread(self._repo.get_playlist, playlist_id)
async def get_by_source_ref(self, source_ref: str) -> Optional[PlaylistRecord]:
return await asyncio.to_thread(self._repo.get_by_source_ref, source_ref)
async def get_imported_source_ids(self, prefix: str) -> set[str]:
return await asyncio.to_thread(self._repo.get_imported_source_ids, prefix)
async def get_all_playlists(self) -> list[PlaylistSummaryRecord]:
return await asyncio.to_thread(self._repo.get_all_playlists)
@ -69,10 +75,11 @@ class AsyncPlaylistRepository:
source_type: Optional[str] = None,
available_sources: Optional[list[str]] = None,
track_source_id: Optional[str] = None,
plex_rating_key: Optional[str] = _UNSET,
) -> Optional[PlaylistTrackRecord]:
return await asyncio.to_thread(
self._repo.update_track_source, playlist_id, track_id,
source_type, available_sources, track_source_id,
source_type, available_sources, track_source_id, plex_rating_key,
)
async def batch_update_available_sources(

View file

@ -28,6 +28,7 @@ class JellyfinItem(msgspec.Struct):
sort_name: str | None = None
album_count: int | None = None
child_count: int | None = None
date_created: str | None = None
class JellyfinUser(msgspec.Struct):
@ -77,6 +78,7 @@ def parse_item(item: dict[str, Any]) -> JellyfinItem:
sort_name=item.get("SortName"),
album_count=item.get("AlbumCount"),
child_count=item.get("ChildCount"),
date_created=item.get("DateCreated"),
)
@ -95,3 +97,82 @@ def parse_user(user: dict[str, Any]) -> JellyfinUser:
id=user.get("Id", ""),
name=user.get("Name", "Unknown")
)
class JellyfinSession(msgspec.Struct):
session_id: str = ""
user_name: str = ""
device_name: str = ""
client_name: str = ""
now_playing_name: str = ""
now_playing_artist: str = ""
now_playing_album: str = ""
now_playing_album_id: str = ""
now_playing_item_id: str = ""
now_playing_image_tag: str = ""
position_ticks: int = 0
runtime_ticks: int = 0
is_paused: bool = False
is_muted: bool = False
play_method: str = ""
audio_codec: str = ""
bitrate: int = 0
class JellyfinLyricLine(msgspec.Struct):
text: str = ""
start: int | None = None
class JellyfinLyrics(msgspec.Struct):
lines: list[JellyfinLyricLine] = msgspec.field(default_factory=list)
def parse_lyrics(data: dict[str, Any]) -> JellyfinLyrics | None:
"""Extract lyrics from a Jellyfin GET /Audio/{id}/Lyrics response (LyricDto)."""
raw_lines = data.get("Lyrics", [])
if not raw_lines:
return None
lines: list[JellyfinLyricLine] = []
for line in raw_lines:
lines.append(JellyfinLyricLine(
text=line.get("Text", ""),
start=line.get("Start"),
))
return JellyfinLyrics(lines=lines)
def parse_jellyfin_sessions(data: list[dict[str, Any]]) -> list[JellyfinSession]:
"""Filter to audio-only sessions with an active NowPlayingItem."""
sessions: list[JellyfinSession] = []
for s in data:
npi = s.get("NowPlayingItem")
if npi is None:
continue
if npi.get("Type", "") != "Audio":
continue
play_state = s.get("PlayState", {})
transcode = s.get("TranscodingInfo", {})
artists = npi.get("Artists", [])
artist_str = ", ".join(artists) if artists else npi.get("AlbumArtist", "")
image_tags = npi.get("ImageTags", {})
sessions.append(JellyfinSession(
session_id=s.get("Id", ""),
user_name=s.get("UserName", ""),
device_name=s.get("DeviceName", ""),
client_name=s.get("Client", ""),
now_playing_name=npi.get("Name", ""),
now_playing_artist=artist_str,
now_playing_album=npi.get("Album", ""),
now_playing_album_id=npi.get("AlbumId", ""),
now_playing_item_id=npi.get("Id", ""),
now_playing_image_tag=image_tags.get("Primary", ""),
position_ticks=play_state.get("PositionTicks", 0) or 0,
runtime_ticks=npi.get("RunTimeTicks", 0) or 0,
is_paused=play_state.get("IsPaused", False),
is_muted=play_state.get("IsMuted", False),
play_method=transcode.get("Type", play_state.get("PlayMethod", "")),
audio_codec=transcode.get("AudioCodec", _extract_codec(npi) or ""),
bitrate=npi.get("Bitrate", 0) or 0,
))
return sessions

View file

@ -14,9 +14,13 @@ from infrastructure.constants import BROWSER_AUDIO_DEVICE_PROFILE
from infrastructure.resilience.retry import with_retry, CircuitBreaker
from repositories.jellyfin_models import (
JellyfinItem,
JellyfinLyrics,
JellyfinSession,
JellyfinUser,
PlaybackUrlResult,
parse_item,
parse_jellyfin_sessions,
parse_lyrics as parse_jellyfin_lyrics,
parse_user,
)
from repositories.navidrome_models import StreamProxyResult
@ -249,7 +253,7 @@ class JellyfinRepository:
return items
except ExternalServiceError:
raise
except Exception as e:
except Exception as e: # noqa: BLE001
logger.error(f"{error_msg}: {e}")
if raise_on_error:
raise ExternalServiceError(f"{error_msg}: {e}") from e
@ -352,6 +356,30 @@ class JellyfinRepository:
_record_degradation(f"Failed to get genres: {e}")
return []
async def get_filter_facets(self, user_id: str | None = None, ttl_seconds: int = 3600) -> dict[str, Any]:
uid = user_id or self._user_id
cache_key = f"{JELLYFIN_PREFIX}filter_facets:{uid}"
cached = await self._cache.get(cache_key)
if cached:
return cached
params: dict[str, Any] = {"includeItemTypes": "MusicAlbum"}
if uid:
params["userId"] = uid
try:
result = await self._get("/Items/Filters", params=params)
if not result:
return {"years": [], "tags": [], "studios": []}
years = sorted(result.get("Years", []), reverse=True)
tags = sorted(result.get("Tags", []))
studios = sorted(s for s in result.get("Studios", []) if s)
facets = {"years": years, "tags": tags, "studios": studios}
await self._cache.set(cache_key, facets, ttl_seconds=ttl_seconds)
return facets
except Exception as e: # noqa: BLE001
logger.error("Failed to get filter facets: %s", e)
_record_degradation(f"Failed to get filter facets: {e}")
return {"years": [], "tags": [], "studios": []}
async def get_artists_by_genre(self, genre: str, user_id: str | None = None, limit: int = 50) -> list[JellyfinItem]:
uid = user_id or self._user_id
params: dict[str, Any] = {"genres": genre, "limit": limit, "enableUserData": "true"}
@ -378,6 +406,36 @@ class JellyfinRepository:
return url
async def proxy_image(self, item_id: str, size: int = 500) -> tuple[bytes, str]:
if not self._base_url or not self._api_key:
raise ExternalServiceError("Jellyfin not configured")
url = f"{self._base_url}/Items/{item_id}/Images/Primary"
params: dict[str, Any] = {
"maxWidth": size,
"maxHeight": size,
"quality": 90,
}
try:
response = await self._client.get(
url,
params=params,
headers={"X-Emby-Token": self._api_key},
timeout=15.0,
)
except httpx.TimeoutException:
raise ExternalServiceError("Jellyfin image request timed out")
except httpx.HTTPError:
raise ExternalServiceError("Jellyfin image request failed")
if response.status_code != 200:
raise ExternalServiceError(
f"Jellyfin image request failed ({response.status_code})"
)
content_type = response.headers.get("content-type", "image/jpeg")
return response.content, content_type
async def _post(
self,
endpoint: str,
@ -392,6 +450,9 @@ class JellyfinRepository:
sort_by: str = "SortName",
sort_order: str = "Ascending",
genre: str | None = None,
year: int | None = None,
tags: str | None = None,
studios: str | None = None,
) -> tuple[list[JellyfinItem], int]:
uid = self._user_id
params: dict[str, Any] = {
@ -408,7 +469,13 @@ class JellyfinRepository:
params["userId"] = uid
if genre:
params["genres"] = genre
cache_key = f"{JELLYFIN_PREFIX}albums:{uid}:{limit}:{offset}:{sort_by}:{sort_order}:{genre}"
if year:
params["years"] = str(year)
if tags:
params["tags"] = tags
if studios:
params["studios"] = studios
cache_key = f"{JELLYFIN_PREFIX}albums:{uid}:{limit}:{offset}:{sort_by}:{sort_order}:{genre}:{year}:{tags}:{studios}"
cached = await self._cache.get(cache_key)
if cached:
return cached
@ -502,20 +569,89 @@ class JellyfinRepository:
return None
async def get_artists(
self, limit: int = 50, offset: int = 0
) -> list[JellyfinItem]:
self, limit: int = 50, offset: int = 0,
sort_by: str = "SortName", sort_order: str = "Ascending",
search: str = "",
) -> tuple[list[JellyfinItem], int]:
params: dict[str, Any] = {
"limit": limit,
"startIndex": offset,
"sortBy": sort_by,
"sortOrder": sort_order,
"enableUserData": "true",
"Fields": "ProviderIds",
}
if self._user_id:
params["userId"] = self._user_id
cache_key = f"{JELLYFIN_PREFIX}artists:{self._user_id}:{limit}:{offset}"
return await self._fetch_items(
"/Artists", cache_key, params, "Failed to get artists", ttl=120
)
if search:
params["searchTerm"] = search
cache_key = f"{JELLYFIN_PREFIX}artists:{self._user_id}:{limit}:{offset}:{sort_by}:{sort_order}:{search}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
try:
result = await self._get("/Artists", params=params)
if not result:
return [], 0
raw_items = result.get("Items", [])
total = result.get("TotalRecordCount", len(raw_items))
items = [parse_item(i) for i in raw_items]
pair = (items, total)
if items:
await self._cache.set(cache_key, pair, ttl_seconds=120)
return pair
except Exception as e: # noqa: BLE001
logger.error("Failed to get artists: %s", e)
_record_degradation(f"Failed to get artists: {e}")
return [], 0
async def get_tracks(
self,
limit: int = 50,
offset: int = 0,
sort_by: str = "SortName",
sort_order: str = "Ascending",
search: str = "",
genre: str = "",
) -> tuple[list[JellyfinItem], int]:
uid = self._user_id
params: dict[str, Any] = {
"includeItemTypes": "Audio",
"recursive": "true",
"sortBy": sort_by,
"sortOrder": sort_order,
"limit": limit,
"startIndex": offset,
"enableUserData": "true",
"Fields": "ProviderIds",
}
if uid:
params["userId"] = uid
if search:
params["searchTerm"] = search
if genre:
params["genres"] = genre
cache_key = f"{JELLYFIN_PREFIX}tracks:{uid}:{limit}:{offset}:{sort_by}:{sort_order}:{search}:{genre}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
try:
result = await self._get("/Items", params=params)
if not result:
return [], 0
raw_items = result.get("Items", [])
total = result.get("TotalRecordCount", len(raw_items))
items = [parse_item(i) for i in raw_items]
pair = (items, total)
if items:
await self._cache.set(cache_key, pair, ttl_seconds=120)
return pair
except Exception as e: # noqa: BLE001
logger.error("Failed to get tracks: %s", e)
_record_degradation(f"Failed to get tracks: {e}")
return [], 0
async def build_mbid_index(self) -> dict[str, str]:
cache_key = f"{JELLYFIN_PREFIX}mbid_index:{self._user_id or 'default'}"
@ -640,6 +776,256 @@ class JellyfinRepository:
return stats
async def get_playlists(
self,
user_id: str | None = None,
limit: int = 50,
) -> list[JellyfinItem]:
uid = user_id or self._user_id
cache_key = f"{JELLYFIN_PREFIX}playlists:{uid}:{limit}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
params: dict[str, Any] = {
"IncludeItemTypes": "Playlist",
"MediaTypes": "Audio",
"Recursive": "true",
"Limit": limit,
"SortBy": "SortName",
"SortOrder": "Ascending",
"Fields": "ChildCount,DateCreated",
}
if uid:
params["UserId"] = uid
try:
result = await self._get("/Items", params=params)
if not result:
return []
raw_items = result.get("Items", [])
items = [parse_item(i) for i in raw_items]
await self._cache.set(cache_key, items, ttl_seconds=300)
return items
except Exception as e: # noqa: BLE001
logger.error(f"Failed to get Jellyfin playlists: {e}")
_record_degradation(f"Failed to get playlists: {e}")
return []
async def get_playlist(
self,
playlist_id: str,
user_id: str | None = None,
) -> JellyfinItem | None:
uid = user_id or self._user_id
params: dict[str, Any] = {
"Fields": "ChildCount,DateCreated,ProviderIds",
}
if uid:
params["UserId"] = uid
try:
result = await self._get(f"/Items/{playlist_id}", params=params)
if not result:
return None
return parse_item(result)
except Exception as e: # noqa: BLE001
logger.error("Failed to get Jellyfin playlist %s: %s", playlist_id, e)
_record_degradation(f"Failed to get playlist detail: {e}")
return None
async def get_playlist_items(
self,
playlist_id: str,
user_id: str | None = None,
limit: int = 1000,
) -> list[JellyfinItem]:
uid = user_id or self._user_id
cache_key = f"{JELLYFIN_PREFIX}playlist:{playlist_id}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
params: dict[str, Any] = {
"Limit": limit,
"Fields": "ProviderIds",
"EnableUserData": "true",
}
if uid:
params["UserId"] = uid
try:
result = await self._get(f"/Playlists/{playlist_id}/Items", params=params)
if not result:
return []
raw_items = result.get("Items", [])
items = [parse_item(i) for i in raw_items if i.get("Type") == "Audio"]
await self._cache.set(cache_key, items, ttl_seconds=120)
return items
except Exception as e: # noqa: BLE001
logger.error(f"Failed to get Jellyfin playlist items for {playlist_id}: {e}")
_record_degradation(f"Failed to get playlist items: {e}")
return []
async def get_instant_mix(
self,
item_id: str,
limit: int = 50,
) -> list[JellyfinItem]:
uid = self._user_id
cache_key = f"{JELLYFIN_PREFIX}instant_mix:{item_id}:{limit}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
params: dict[str, Any] = {
"Limit": limit,
"Fields": "ProviderIds",
"EnableUserData": "true",
}
if uid:
params["UserId"] = uid
try:
result = await self._get(f"/Items/{item_id}/InstantMix", params=params)
if not result:
return []
raw_items = result.get("Items", [])
items = [parse_item(i) for i in raw_items]
await self._cache.set(cache_key, items, ttl_seconds=600)
return items
except Exception as e: # noqa: BLE001
logger.warning(f"Failed to get instant mix for {item_id}: {e}")
_record_degradation(f"Failed to get instant mix: {e}")
return []
async def get_instant_mix_by_artist(
self,
artist_id: str,
limit: int = 50,
) -> list[JellyfinItem]:
uid = self._user_id
cache_key = f"{JELLYFIN_PREFIX}instant_mix_artist:{artist_id}:{limit}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
params: dict[str, Any] = {
"Limit": limit,
"Fields": "ProviderIds",
"EnableUserData": "true",
}
if uid:
params["UserId"] = uid
try:
result = await self._get(f"/Artists/{artist_id}/InstantMix", params=params)
if not result:
return []
raw_items = result.get("Items", [])
items = [parse_item(i) for i in raw_items]
await self._cache.set(cache_key, items, ttl_seconds=600)
return items
except Exception as e: # noqa: BLE001
logger.warning(f"Failed to get artist instant mix for {artist_id}: {e}")
_record_degradation(f"Failed to get artist instant mix: {e}")
return []
async def get_instant_mix_by_genre(
self,
genre_name: str,
limit: int = 50,
) -> list[JellyfinItem]:
uid = self._user_id
cache_key = f"{JELLYFIN_PREFIX}instant_mix_genre:{genre_name}:{limit}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
params: dict[str, Any] = {
"Limit": limit,
"Fields": "ProviderIds",
"EnableUserData": "true",
}
if uid:
params["UserId"] = uid
try:
encoded_genre = genre_name.replace("/", "%2F")
result = await self._get(f"/MusicGenres/{encoded_genre}/InstantMix", params=params)
if not result:
return []
raw_items = result.get("Items", [])
items = [parse_item(i) for i in raw_items]
await self._cache.set(cache_key, items, ttl_seconds=600)
return items
except Exception as e: # noqa: BLE001
logger.warning(f"Failed to get genre instant mix for {genre_name}: {e}")
_record_degradation(f"Failed to get genre instant mix: {e}")
return []
async def get_similar_items(
self,
item_id: str,
limit: int = 10,
) -> list[JellyfinItem]:
cache_key = f"{JELLYFIN_PREFIX}similar:{item_id}:{limit}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
params: dict[str, Any] = {
"Limit": limit,
"Fields": "ProviderIds",
"EnableUserData": "true",
}
uid = self._user_id
if uid:
params["UserId"] = uid
try:
result = await self._get(f"/Items/{item_id}/Similar", params=params)
if not result:
return []
raw_items = result.get("Items", [])
items = [parse_item(i) for i in raw_items]
await self._cache.set(cache_key, items, ttl_seconds=1800)
return items
except Exception as e: # noqa: BLE001
logger.warning(f"Failed to get similar items for {item_id}: {e}")
_record_degradation(f"Failed to get similar items: {e}")
return []
async def get_lyrics(self, item_id: str) -> JellyfinLyrics | None:
cache_key = f"{JELLYFIN_PREFIX}lyrics:{item_id}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
try:
result = await self._get(f"/Audio/{item_id}/Lyrics")
if not result:
return None
lyrics = parse_jellyfin_lyrics(result)
if lyrics:
await self._cache.set(cache_key, lyrics, 3600)
return lyrics
except httpx.HTTPStatusError as exc:
logger.warning("Jellyfin lyrics HTTP %s for item %s", exc.response.status_code, item_id)
return None
except (httpx.HTTPError, msgspec.DecodeError) as exc:
logger.warning("Jellyfin lyrics fetch/decode error for item %s: %s", item_id, exc)
return None
except Exception: # noqa: BLE001
logger.warning("Unexpected error fetching lyrics for item %s", item_id, exc_info=True)
return None
async def get_sessions(self) -> list[JellyfinSession]:
if not self.is_configured():
return []
uid = self._user_id or "default"
cache_key = f"{JELLYFIN_PREFIX}sessions:{uid}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
try:
result = await self._request("GET", "/Sessions")
if not result or not isinstance(result, list):
return []
sessions = parse_jellyfin_sessions(result)
await self._cache.set(cache_key, sessions, 2)
return sessions
except Exception: # noqa: BLE001
logger.warning("Failed to fetch Jellyfin sessions", exc_info=True)
_record_degradation("Jellyfin sessions fetch failed")
return []
async def get_playback_info(self, item_id: str) -> dict[str, Any]:
params: dict[str, Any] = {}
if self._user_id:
@ -846,7 +1232,7 @@ class JellyfinRepository:
media_type=resp_headers.get("Content-Type", "audio/mpeg"),
body_chunks=_stream_body(),
)
except Exception:
except Exception: # noqa: BLE001
if upstream_resp:
await upstream_resp.aclose()
await client.aclose()

View file

@ -55,6 +55,11 @@ class SubsonicPlaylist(msgspec.Struct):
name: str
songCount: int = 0
duration: int = 0
owner: str = ""
public: bool = False
created: str = ""
changed: str = ""
coverArt: str = ""
entry: list[SubsonicSong] | None = None
@ -64,6 +69,16 @@ class SubsonicGenre(msgspec.Struct):
albumCount: int = 0
class SubsonicArtistIndex(msgspec.Struct):
name: str = ""
artists: list[SubsonicArtist] = msgspec.field(default_factory=list)
class SubsonicMusicFolder(msgspec.Struct):
id: str = ""
name: str = ""
class SubsonicSearchResult(msgspec.Struct):
artist: list[SubsonicArtist] = msgspec.field(default_factory=list)
album: list[SubsonicAlbum] = msgspec.field(default_factory=list)
@ -148,3 +163,137 @@ def parse_genre(data: dict[str, Any]) -> SubsonicGenre:
songCount=data.get("songCount", 0),
albumCount=data.get("albumCount", 0),
)
class SubsonicArtistInfo(msgspec.Struct):
biography: str = ""
musicBrainzId: str = ""
smallImageUrl: str = ""
mediumImageUrl: str = ""
largeImageUrl: str = ""
similarArtist: list[SubsonicArtist] = msgspec.field(default_factory=list)
class SubsonicNowPlayingEntry(msgspec.Struct):
id: str = ""
title: str = ""
artist: str = ""
album: str = ""
albumId: str = ""
artistId: str = ""
coverArt: str = ""
duration: int = 0
bitRate: int = 0
suffix: str = ""
username: str = ""
minutesAgo: int = 0
playerId: int = 0
playerName: str = ""
def parse_now_playing_entries(data: dict[str, Any]) -> list[SubsonicNowPlayingEntry]:
"""Extract now-playing entries from a Subsonic getNowPlaying response."""
np = data.get("nowPlaying", {})
entries_raw = np.get("entry", [])
if not entries_raw:
return []
result: list[SubsonicNowPlayingEntry] = []
for e in entries_raw:
result.append(SubsonicNowPlayingEntry(
id=e.get("id", ""),
title=e.get("title", ""),
artist=e.get("artist", ""),
album=e.get("album", ""),
albumId=e.get("albumId", ""),
artistId=e.get("artistId", ""),
coverArt=e.get("coverArt", ""),
duration=e.get("duration", 0),
bitRate=e.get("bitRate", 0),
suffix=e.get("suffix", ""),
username=e.get("username", ""),
minutesAgo=e.get("minutesAgo", 0),
playerId=e.get("playerId", 0),
playerName=e.get("playerName", ""),
))
return result
def parse_artist_info(data: dict[str, Any]) -> SubsonicArtistInfo:
"""Extract artist info from a Subsonic getArtistInfo2 response."""
info = data.get("artistInfo2", {})
if not info:
return SubsonicArtistInfo()
similar_raw = info.get("similarArtist", [])
similar = [parse_artist(a) for a in similar_raw] if similar_raw else []
return SubsonicArtistInfo(
biography=info.get("biography", ""),
musicBrainzId=info.get("musicBrainzId", ""),
smallImageUrl=info.get("smallImageUrl", ""),
mediumImageUrl=info.get("mediumImageUrl", ""),
largeImageUrl=info.get("largeImageUrl", ""),
similarArtist=similar,
)
class SubsonicAlbumInfo(msgspec.Struct):
notes: str = ""
musicBrainzId: str = ""
lastFmUrl: str = ""
smallImageUrl: str = ""
mediumImageUrl: str = ""
largeImageUrl: str = ""
class SubsonicLyricLine(msgspec.Struct):
value: str = ""
start: int | None = None # milliseconds from OpenSubsonic structuredLyrics
class SubsonicLyrics(msgspec.Struct):
value: str = ""
artist: str = ""
title: str = ""
lines: list[SubsonicLyricLine] = []
is_synced: bool = False
def parse_album_info(data: dict[str, Any]) -> SubsonicAlbumInfo:
"""Extract album info from a Subsonic getAlbumInfo2 response."""
info = data.get("albumInfo", {})
if not info:
return SubsonicAlbumInfo()
return SubsonicAlbumInfo(
notes=info.get("notes", ""),
musicBrainzId=info.get("musicBrainzId", ""),
lastFmUrl=info.get("lastFmUrl", ""),
smallImageUrl=info.get("smallImageUrl", ""),
mediumImageUrl=info.get("mediumImageUrl", ""),
largeImageUrl=info.get("largeImageUrl", ""),
)
def parse_lyrics(data: dict[str, Any]) -> SubsonicLyrics | None:
"""Extract lyrics from a Subsonic getLyrics response."""
lyrics = data.get("lyrics", {})
if not lyrics:
return None
value = lyrics.get("value", "")
if not value:
return None
return SubsonicLyrics(
value=value,
artist=lyrics.get("artist", ""),
title=lyrics.get("title", ""),
)
def parse_top_songs(data: dict[str, Any]) -> list[SubsonicSong]:
"""Extract songs from a Subsonic getTopSongs response."""
raw = data.get("topSongs", {}).get("song", [])
return [parse_song(s) for s in raw] if raw else []
def parse_similar_songs(data: dict[str, Any]) -> list[SubsonicSong]:
"""Extract songs from a Subsonic getSimilarSongs2 response."""
raw = data.get("similarSongs2", {}).get("song", [])
return [parse_song(s) for s in raw] if raw else []

View file

@ -16,16 +16,28 @@ from infrastructure.resilience.retry import with_retry, CircuitBreaker
from repositories.navidrome_models import (
StreamProxyResult as StreamProxyResult,
SubsonicAlbum,
SubsonicAlbumInfo,
SubsonicArtist,
SubsonicArtistIndex,
SubsonicArtistInfo,
SubsonicGenre,
SubsonicLyrics,
SubsonicMusicFolder,
SubsonicNowPlayingEntry,
SubsonicPlaylist,
SubsonicSearchResult,
SubsonicSong,
parse_album,
parse_album_info,
parse_artist,
parse_artist_info,
parse_genre,
parse_lyrics,
parse_now_playing_entries,
parse_similar_songs,
parse_song,
parse_subsonic_response,
parse_top_songs,
)
from infrastructure.degradation import try_get_degradation_context
from infrastructure.integration_result import IntegrationResult
@ -262,8 +274,10 @@ class NavidromeRepository:
size: int = 20,
offset: int = 0,
genre: str | None = None,
from_year: int | None = None,
to_year: int | None = None,
) -> list[SubsonicAlbum]:
cache_key = f"{NAVIDROME_PREFIX}albums:{type}:{size}:{offset}:{genre or ''}"
cache_key = f"{NAVIDROME_PREFIX}albums:{type}:{size}:{offset}:{genre or ''}:{from_year}:{to_year}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
@ -271,6 +285,9 @@ class NavidromeRepository:
params: dict[str, Any] = {"type": type, "size": size, "offset": offset}
if genre and type == "byGenre":
params["genre"] = genre
if type == "byYear":
params["fromYear"] = from_year if from_year is not None else 0
params["toYear"] = to_year if to_year is not None else 9999
resp = await self._request(
"/rest/getAlbumList2",
params,
@ -385,6 +402,75 @@ class NavidromeRepository:
await self._cache.set(cache_key, genres, self._ttl_genres)
return genres
async def get_artists_index(self) -> list[SubsonicArtistIndex]:
cache_key = "navidrome:artists_index"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
resp = await self._request("/rest/getArtists")
index_data: list[SubsonicArtistIndex] = []
for idx in resp.get("artists", {}).get("index", []):
artists = [parse_artist(a) for a in idx.get("artist", [])]
index_data.append(SubsonicArtistIndex(name=idx.get("name", ""), artists=artists))
await self._cache.set(cache_key, index_data, self._ttl_list)
return index_data
async def get_songs_by_genre(
self, genre: str, count: int = 50, offset: int = 0
) -> list[SubsonicSong]:
cache_key = f"navidrome:songs_by_genre:{genre}:{count}:{offset}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
resp = await self._request(
"/rest/getSongsByGenre",
{"genre": genre, "count": count, "offset": offset},
)
raw = resp.get("songsByGenre", {}).get("song", [])
songs = [parse_song(s) for s in raw]
await self._cache.set(cache_key, songs, self._ttl_list)
return songs
async def search_songs(
self, query: str = "", count: int = 50, offset: int = 0
) -> list[SubsonicSong]:
cache_key = f"{NAVIDROME_PREFIX}songs_browse:{query}:{count}:{offset}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
resp = await self._request(
"/rest/search3",
{
"query": query or '""',
"artistCount": 0,
"albumCount": 0,
"songCount": count,
"songOffset": offset,
},
)
sr = resp.get("searchResult3", {})
songs = [parse_song(s) for s in sr.get("song", [])]
await self._cache.set(cache_key, songs, self._ttl_list)
return songs
async def get_music_folders(self) -> list[SubsonicMusicFolder]:
cache_key = "navidrome:music_folders"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
resp = await self._request("/rest/getMusicFolders")
raw = resp.get("musicFolders", {}).get("musicFolder", [])
folders = [
SubsonicMusicFolder(id=str(f.get("id", "")), name=f.get("name", ""))
for f in raw
]
await self._cache.set(cache_key, folders, self._ttl_list)
return folders
async def get_playlists(self) -> list[SubsonicPlaylist]:
cache_key = "navidrome:playlists"
cached = await self._cache.get(cache_key)
@ -465,6 +551,152 @@ class NavidromeRepository:
_record_degradation("Navidrome now-playing report failed")
return False
async def get_now_playing(self) -> list[SubsonicNowPlayingEntry]:
if not self._configured:
return []
cache_key = f"{NAVIDROME_PREFIX}now_playing"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
try:
data = await self._request("/rest/getNowPlaying")
entries = parse_now_playing_entries(data)
await self._cache.set(cache_key, entries, 2)
return entries
except Exception: # noqa: BLE001
logger.warning("Failed to fetch Navidrome now-playing", exc_info=True)
_record_degradation("Navidrome getNowPlaying failed")
return []
async def get_top_songs(
self,
artist_name: str,
count: int = 20,
) -> list[SubsonicSong]:
if not self._configured:
return []
cache_key = f"{NAVIDROME_PREFIX}top_songs:{artist_name}:{count}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
try:
data = await self._request("/rest/getTopSongs", {"artist": artist_name, "count": count})
songs = parse_top_songs(data)
await self._cache.set(cache_key, songs, 900)
return songs
except Exception: # noqa: BLE001
logger.debug("Navidrome getTopSongs returned empty for %s (Last.fm may not be configured)", artist_name)
_record_degradation("Navidrome getTopSongs failed")
return []
async def get_similar_songs(
self,
song_id: str,
count: int = 20,
) -> list[SubsonicSong]:
if not self._configured:
return []
cache_key = f"{NAVIDROME_PREFIX}similar_songs:{song_id}:{count}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
try:
data = await self._request("/rest/getSimilarSongs2", {"id": song_id, "count": count})
songs = parse_similar_songs(data)
await self._cache.set(cache_key, songs, 900)
return songs
except Exception: # noqa: BLE001
logger.debug("Navidrome getSimilarSongs2 returned empty for %s", song_id)
_record_degradation("Navidrome getSimilarSongs2 failed")
return []
async def get_artist_info(self, artist_id: str) -> SubsonicArtistInfo | None:
if not self._configured:
return None
cache_key = f"{NAVIDROME_PREFIX}artist_info:{artist_id}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
try:
data = await self._request("/rest/getArtistInfo2", {"id": artist_id})
info = parse_artist_info(data)
await self._cache.set(cache_key, info, 1800)
return info
except Exception: # noqa: BLE001
logger.debug("Navidrome getArtistInfo2 returned empty for %s", artist_id)
_record_degradation("Navidrome getArtistInfo2 failed")
return None
async def get_album_info(self, album_id: str) -> SubsonicAlbumInfo | None:
if not self._configured:
return None
cache_key = f"{NAVIDROME_PREFIX}album_info:{album_id}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
try:
data = await self._request("/rest/getAlbumInfo2", {"id": album_id})
info = parse_album_info(data)
await self._cache.set(cache_key, info, 1800)
return info
except Exception: # noqa: BLE001
logger.debug("Navidrome getAlbumInfo2 returned empty for %s", album_id)
_record_degradation("Navidrome getAlbumInfo2 failed")
return None
async def get_lyrics(self, artist: str, title: str) -> SubsonicLyrics | None:
if not self._configured:
return None
cache_key = f"{NAVIDROME_PREFIX}lyrics:{artist}:{title}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
try:
data = await self._request("/rest/getLyrics", {"artist": artist, "title": title})
lyrics = parse_lyrics(data)
if lyrics:
await self._cache.set(cache_key, lyrics, 3600)
return lyrics
except Exception: # noqa: BLE001
logger.debug("Navidrome getLyrics returned empty for %s - %s", artist, title)
return None
async def get_lyrics_by_song_id(self, song_id: str) -> SubsonicLyrics | None:
if not self._configured:
return None
cache_key = f"{NAVIDROME_PREFIX}lyrics_by_id:{song_id}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
try:
from repositories.navidrome_models import SubsonicLyricLine
data = await self._request("/rest/getLyricsBySongId", {"id": song_id})
lyric_list = data.get("lyricsList", {}).get("structuredLyrics", [])
if not lyric_list:
return None
best = lyric_list[0]
raw_lines = best.get("line", [])
synced = best.get("synced", False)
lines = [
SubsonicLyricLine(
value=l.get("value", ""),
start=l.get("start") if synced else None,
)
for l in raw_lines
]
has_text = any(l.value.strip() for l in lines)
has_timing = any(l.start is not None for l in lines)
if not has_text and not has_timing:
return None
text = "\n".join(l.value for l in lines)
lyrics = SubsonicLyrics(value=text, lines=lines, is_synced=synced)
await self._cache.set(cache_key, lyrics, 3600)
return lyrics
except Exception: # noqa: BLE001
logger.debug("Navidrome getLyricsBySongId not supported or empty for %s", song_id)
return None
async def validate_connection(self) -> tuple[bool, str]:
if not self._configured:
return False, "Navidrome URL, username, or password not configured"

View file

@ -14,7 +14,7 @@ _UNSET = object()
class PlaylistRecord:
__slots__ = ("id", "name", "cover_image_path", "created_at", "updated_at")
__slots__ = ("id", "name", "cover_image_path", "created_at", "updated_at", "source_ref")
def __init__(
self,
@ -23,18 +23,20 @@ class PlaylistRecord:
cover_image_path: Optional[str],
created_at: str,
updated_at: str,
source_ref: Optional[str] = None,
):
self.id = id
self.name = name
self.cover_image_path = cover_image_path
self.created_at = created_at
self.updated_at = updated_at
self.source_ref = source_ref
class PlaylistSummaryRecord:
__slots__ = (
"id", "name", "cover_image_path", "created_at", "updated_at",
"track_count", "total_duration", "cover_urls",
"track_count", "total_duration", "cover_urls", "source_ref",
)
def __init__(
@ -47,6 +49,7 @@ class PlaylistSummaryRecord:
track_count: int,
total_duration: Optional[int],
cover_urls: list[str],
source_ref: Optional[str] = None,
):
self.id = id
self.name = name
@ -56,6 +59,7 @@ class PlaylistSummaryRecord:
self.track_count = track_count
self.total_duration = total_duration
self.cover_urls = cover_urls
self.source_ref = source_ref
class PlaylistTrackRecord:
@ -63,7 +67,7 @@ class PlaylistTrackRecord:
"id", "playlist_id", "position", "track_name", "artist_name",
"album_name", "album_id", "artist_id", "track_source_id", "cover_url",
"source_type", "available_sources", "format", "track_number", "disc_number",
"duration", "created_at",
"duration", "created_at", "plex_rating_key",
)
def __init__(
@ -85,6 +89,7 @@ class PlaylistTrackRecord:
disc_number: Optional[int],
duration: Optional[int],
created_at: str,
plex_rating_key: Optional[str] = None,
):
self.id = id
self.playlist_id = playlist_id
@ -103,6 +108,7 @@ class PlaylistTrackRecord:
self.disc_number = disc_number
self.duration = duration
self.created_at = created_at
self.plex_rating_key = plex_rating_key
def get_cache_dir() -> Path:
from core.config import get_settings
@ -176,30 +182,44 @@ class PlaylistRepository:
conn.commit()
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE playlist_tracks ADD COLUMN plex_rating_key TEXT")
conn.commit()
except sqlite3.OperationalError:
pass
conn.execute("""
UPDATE playlist_tracks
SET cover_url = '/api/v1/covers/' || SUBSTR(cover_url, LENGTH('/api/covers/') + 1)
WHERE cover_url LIKE '/api/covers/%'
""")
try:
conn.execute("ALTER TABLE playlists ADD COLUMN source_ref TEXT")
conn.commit()
except sqlite3.OperationalError:
pass
conn.execute("""
CREATE UNIQUE INDEX IF NOT EXISTS idx_playlists_source_ref
ON playlists(source_ref) WHERE source_ref IS NOT NULL
""")
conn.commit()
finally:
conn.close()
def create_playlist(self, name: str) -> PlaylistRecord:
def create_playlist(self, name: str, source_ref: Optional[str] = None) -> PlaylistRecord:
playlist_id = str(uuid4())
now = datetime.now(timezone.utc).isoformat()
with self._write_lock:
conn = self._get_connection()
conn.execute(
"INSERT INTO playlists (id, name, cover_image_path, created_at, updated_at) "
"VALUES (?, ?, NULL, ?, ?)",
(playlist_id, name, now, now),
"INSERT INTO playlists (id, name, cover_image_path, created_at, updated_at, source_ref) "
"VALUES (?, ?, NULL, ?, ?, ?)",
(playlist_id, name, now, now, source_ref),
)
conn.commit()
return PlaylistRecord(
id=playlist_id, name=name, cover_image_path=None,
created_at=now, updated_at=now,
created_at=now, updated_at=now, source_ref=source_ref,
)
def get_playlist(self, playlist_id: str) -> Optional[PlaylistRecord]:
@ -209,6 +229,23 @@ class PlaylistRepository:
).fetchone()
return self._row_to_playlist(row) if row else None
def get_by_source_ref(self, source_ref: str) -> Optional[PlaylistRecord]:
conn = self._get_connection()
row = conn.execute(
"SELECT * FROM playlists WHERE source_ref = ?", (source_ref,)
).fetchone()
return self._row_to_playlist(row) if row else None
def get_imported_source_ids(self, prefix: str) -> set[str]:
"""Return the set of source IDs imported for a given prefix (e.g. 'plex:')."""
conn = self._get_connection()
rows = conn.execute(
"SELECT source_ref FROM playlists WHERE source_ref LIKE ?",
(f"{prefix}%",),
).fetchall()
plen = len(prefix)
return {row["source_ref"][plen:] for row in rows if row["source_ref"]}
def get_all_playlists(self) -> list[PlaylistSummaryRecord]:
conn = self._get_connection()
rows = conn.execute("""
@ -239,6 +276,7 @@ class PlaylistRepository:
track_count=row["track_count"],
total_duration=row["total_duration"],
cover_urls=cover_urls,
source_ref=row["source_ref"],
))
return results
@ -337,8 +375,9 @@ class PlaylistRepository:
"INSERT INTO playlist_tracks "
"(id, playlist_id, position, track_name, artist_name, album_name, "
"album_id, artist_id, track_source_id, cover_url, source_type, "
"available_sources, format, track_number, disc_number, duration, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"available_sources, format, track_number, disc_number, duration, "
"plex_rating_key, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
track_id, playlist_id, pos,
track["track_name"], track["artist_name"], track["album_name"],
@ -346,7 +385,7 @@ class PlaylistRepository:
track.get("track_source_id"), track.get("cover_url"),
track["source_type"], available_sources_json,
track.get("format"), track.get("track_number"), track.get("disc_number"),
track.get("duration"), now,
track.get("duration"), track.get("plex_rating_key"), now,
),
)
created_records.append(PlaylistTrackRecord(
@ -359,6 +398,7 @@ class PlaylistRepository:
format=track.get("format"), track_number=track.get("track_number"),
disc_number=track.get("disc_number"),
duration=track.get("duration"), created_at=now,
plex_rating_key=track.get("plex_rating_key"),
))
conn.execute(
@ -460,7 +500,6 @@ class PlaylistRepository:
if old_position == actual_position:
return actual_position
# Move track to temp position to avoid UNIQUE violation
conn.execute(
"UPDATE playlist_tracks SET position = -1 WHERE id = ?",
(track_id,),
@ -537,6 +576,7 @@ class PlaylistRepository:
source_type: Optional[str] = None,
available_sources: Optional[list[str]] = None,
track_source_id: Optional[str] = None,
plex_rating_key: Optional[str] = _UNSET,
) -> Optional[PlaylistTrackRecord]:
with self._write_lock:
conn = self._get_connection()
@ -554,11 +594,18 @@ class PlaylistRepository:
else row["available_sources"]
)
new_track_source_id = track_source_id if track_source_id is not None else row["track_source_id"]
new_plex_rating_key = (
plex_rating_key
if plex_rating_key is not _UNSET
else (row["plex_rating_key"] if "plex_rating_key" in row.keys() else None)
)
conn.execute(
"UPDATE playlist_tracks SET source_type = ?, available_sources = ?, track_source_id = ? "
"UPDATE playlist_tracks SET source_type = ?, available_sources = ?, "
"track_source_id = ?, plex_rating_key = ? "
"WHERE id = ? AND playlist_id = ?",
(new_source_type, new_available, new_track_source_id, track_id, playlist_id),
(new_source_type, new_available, new_track_source_id, new_plex_rating_key,
track_id, playlist_id),
)
now = datetime.now(timezone.utc).isoformat()
conn.execute(
@ -583,6 +630,7 @@ class PlaylistRepository:
disc_number=row["disc_number"] if "disc_number" in row.keys() else None,
duration=row["duration"],
created_at=row["created_at"],
plex_rating_key=new_plex_rating_key,
)
def get_tracks(self, playlist_id: str) -> list[PlaylistTrackRecord]:
@ -668,6 +716,7 @@ class PlaylistRepository:
cover_image_path=row["cover_image_path"],
created_at=row["created_at"],
updated_at=row["updated_at"],
source_ref=row["source_ref"] if "source_ref" in row.keys() else None,
)
@classmethod
@ -690,4 +739,5 @@ class PlaylistRepository:
disc_number=row["disc_number"] if "disc_number" in row.keys() else None,
duration=row["duration"],
created_at=row["created_at"],
plex_rating_key=row["plex_rating_key"] if "plex_rating_key" in row.keys() else None,
)

View file

@ -0,0 +1,313 @@
from __future__ import annotations
import logging
from collections.abc import AsyncIterator
from typing import Any
import msgspec
from core.exceptions import PlexApiError, PlexAuthError
logger = logging.getLogger(__name__)
class PlexGuid(msgspec.Struct):
id: str = ""
class PlexGenreTag(msgspec.Struct):
tag: str = ""
class PlexPart(msgspec.Struct):
id: int = 0
key: str = ""
duration: int = 0
file: str = ""
size: int = 0
container: str = ""
class PlexMedia(msgspec.Struct):
id: int = 0
duration: int = 0
bitrate: int = 0
audioCodec: str = ""
audioChannels: int = 0
container: str = ""
Part: list[PlexPart] = msgspec.field(default_factory=list)
class PlexArtist(msgspec.Struct):
ratingKey: str = ""
title: str = ""
thumb: str = ""
addedAt: int = 0
Guid: list[PlexGuid] = msgspec.field(default_factory=list)
class PlexAlbum(msgspec.Struct):
ratingKey: str = ""
title: str = ""
parentTitle: str = ""
parentRatingKey: str = ""
year: int = 0
thumb: str = ""
addedAt: int = 0
lastViewedAt: int = 0
viewCount: int = 0
userRating: float = 0.0
leafCount: int = 0
Genre: list[PlexGenreTag] = msgspec.field(default_factory=list)
Guid: list[PlexGuid] = msgspec.field(default_factory=list)
class PlexTrack(msgspec.Struct):
ratingKey: str = ""
title: str = ""
parentTitle: str = ""
grandparentTitle: str = ""
parentRatingKey: str = ""
index: int = 0
parentIndex: int = 1
duration: int = 0
addedAt: int = 0
Media: list[PlexMedia] = msgspec.field(default_factory=list)
Guid: list[PlexGuid] = msgspec.field(default_factory=list)
class PlexPlaylist(msgspec.Struct):
ratingKey: str = ""
title: str = ""
leafCount: int = 0
duration: int = 0
playlistType: str = ""
smart: bool = False
updatedAt: int = 0
composite: str = ""
class PlexLibrarySection(msgspec.Struct):
key: str = ""
title: str = ""
type: str = ""
uuid: str = ""
class StreamProxyResult(msgspec.Struct):
status_code: int
headers: dict[str, str]
media_type: str
body_chunks: AsyncIterator[bytes] | None = None
class PlexOAuthPin(msgspec.Struct):
id: int = 0
code: str = ""
authToken: str | None = None
def parse_plex_response(data: dict[str, Any]) -> dict[str, Any]:
container = data.get("MediaContainer")
if container is None:
raise PlexApiError("Missing MediaContainer envelope in Plex response")
return container
def parse_library_sections(container: dict[str, Any]) -> list[PlexLibrarySection]:
raw = container.get("Directory", [])
return [
PlexLibrarySection(
key=d.get("key", ""),
title=d.get("title", ""),
type=d.get("type", ""),
uuid=d.get("uuid", ""),
)
for d in raw
]
def parse_artist(data: dict[str, Any]) -> PlexArtist:
return PlexArtist(
ratingKey=str(data.get("ratingKey", "")),
title=data.get("title", "Unknown"),
thumb=data.get("thumb", ""),
addedAt=data.get("addedAt", 0),
Guid=_parse_guids(data.get("Guid", [])),
)
def parse_album(data: dict[str, Any]) -> PlexAlbum:
return PlexAlbum(
ratingKey=str(data.get("ratingKey", "")),
title=data.get("title", "Unknown"),
parentTitle=data.get("parentTitle", ""),
parentRatingKey=str(data.get("parentRatingKey", "")),
year=data.get("year", 0),
thumb=data.get("thumb", ""),
addedAt=data.get("addedAt", 0),
lastViewedAt=data.get("lastViewedAt", 0),
viewCount=data.get("viewCount", 0),
userRating=float(data.get("userRating", 0.0)),
leafCount=data.get("leafCount", 0),
Genre=_parse_genre_tags(data.get("Genre", [])),
Guid=_parse_guids(data.get("Guid", [])),
)
def parse_track(data: dict[str, Any]) -> PlexTrack:
media_list: list[PlexMedia] = []
for m in data.get("Media", []):
parts = [
PlexPart(
id=p.get("id", 0),
key=p.get("key", ""),
duration=p.get("duration", 0),
file=p.get("file", ""),
size=p.get("size", 0),
container=p.get("container", ""),
)
for p in m.get("Part", [])
]
media_list.append(
PlexMedia(
id=m.get("id", 0),
duration=m.get("duration", 0),
bitrate=m.get("bitrate", 0),
audioCodec=m.get("audioCodec", ""),
audioChannels=m.get("audioChannels", 0),
container=m.get("container", ""),
Part=parts,
)
)
return PlexTrack(
ratingKey=str(data.get("ratingKey", "")),
title=data.get("title", "Unknown"),
parentTitle=data.get("parentTitle", ""),
grandparentTitle=data.get("grandparentTitle", ""),
parentRatingKey=str(data.get("parentRatingKey", "")),
index=data.get("index", 0),
parentIndex=data.get("parentIndex", 1),
duration=data.get("duration", 0),
addedAt=data.get("addedAt", 0),
Media=media_list,
Guid=_parse_guids(data.get("Guid", [])),
)
def parse_playlist(data: dict[str, Any]) -> PlexPlaylist:
return PlexPlaylist(
ratingKey=str(data.get("ratingKey", "")),
title=data.get("title", ""),
leafCount=data.get("leafCount", 0),
duration=data.get("duration", 0),
playlistType=data.get("playlistType", ""),
smart=bool(data.get("smart", False)),
updatedAt=data.get("updatedAt", 0),
composite=data.get("composite", ""),
)
def _parse_guids(raw: list[dict[str, Any]] | None) -> list[PlexGuid]:
if not raw:
return []
return [PlexGuid(id=g.get("id", "")) for g in raw]
def _parse_genre_tags(raw: list[dict[str, Any]] | None) -> list[PlexGenreTag]:
if not raw:
return []
return [PlexGenreTag(tag=g.get("tag", "")) for g in raw]
def extract_mbid_from_guids(guids: list[PlexGuid], prefix: str = "mbid://") -> str:
for guid in guids:
if guid.id.startswith(prefix):
return guid.id[len(prefix):]
return ""
class PlexSession(msgspec.Struct):
session_id: str = ""
user_name: str = ""
track_title: str = ""
artist_name: str = ""
album_name: str = ""
album_thumb: str = ""
player_device: str = ""
player_platform: str = ""
player_state: str = ""
is_direct_play: bool = True
transcode_decision: str = ""
progress_ms: int = 0
duration_ms: int = 0
audio_codec: str = ""
audio_channels: int = 0
bitrate: int = 0
class PlexHistoryEntry(msgspec.Struct):
rating_key: str = ""
track_title: str = ""
artist_name: str = ""
album_name: str = ""
album_rating_key: str = ""
viewed_at: int = 0
duration_ms: int = 0
device_name: str = ""
player_platform: str = ""
def parse_plex_history(data: dict[str, Any]) -> tuple[list[PlexHistoryEntry], int]:
"""Extract audio-only history from Plex /status/sessions/history/all."""
container = data.get("MediaContainer", data)
total = container.get("totalSize", container.get("size", 0))
entries: list[PlexHistoryEntry] = []
for item in container.get("Metadata", []):
if item.get("type") != "track":
continue
entries.append(PlexHistoryEntry(
rating_key=str(item.get("ratingKey", "")),
track_title=item.get("title", ""),
artist_name=item.get("grandparentTitle", ""),
album_name=item.get("parentTitle", ""),
album_rating_key=str(item.get("parentRatingKey", "")),
viewed_at=item.get("viewedAt", 0),
duration_ms=item.get("duration", 0),
device_name=item.get("Player", {}).get("title", "") if isinstance(item.get("Player"), dict) else "",
player_platform=item.get("Player", {}).get("platform", "") if isinstance(item.get("Player"), dict) else "",
))
return entries, total
def parse_plex_sessions(data: dict[str, Any]) -> list[PlexSession]:
"""Extract audio-only sessions from a Plex /status/sessions response."""
container = data.get("MediaContainer", data)
sessions: list[PlexSession] = []
for track in container.get("Metadata", []):
if track.get("type") != "track":
continue
user = track.get("User", {})
player = track.get("Player", {})
transcode = track.get("TranscodeSession", {})
session = track.get("Session", {})
is_direct = player.get("local", False) or transcode.get("videoDecision") is None
sessions.append(PlexSession(
session_id=session.get("id", ""),
user_name=user.get("title", ""),
track_title=track.get("title", ""),
artist_name=track.get("grandparentTitle", ""),
album_name=track.get("parentTitle", ""),
album_thumb=track.get("parentRatingKey", ""),
player_device=player.get("title", ""),
player_platform=player.get("platform", ""),
player_state=player.get("state", "playing"),
is_direct_play="directplay" in transcode.get("audioDecision", "directplay").lower(),
transcode_decision=transcode.get("audioDecision", ""),
progress_ms=track.get("viewOffset", 0),
duration_ms=track.get("duration", 0),
audio_codec=transcode.get("audioCodec", track.get("Media", [{}])[0].get("audioCodec", "") if track.get("Media") else ""),
audio_channels=track.get("Media", [{}])[0].get("audioChannels", 0) if track.get("Media") else 0,
bitrate=track.get("Media", [{}])[0].get("bitrate", 0) if track.get("Media") else 0,
))
return sessions

View file

@ -0,0 +1,864 @@
from __future__ import annotations
import logging
from collections.abc import AsyncIterator
from typing import Any
from urllib.parse import quote
import httpx
from core.exceptions import ExternalServiceError, PlexApiError, PlexAuthError
from infrastructure.cache.cache_keys import PLEX_PREFIX
from infrastructure.cache.memory_cache import CacheInterface
from infrastructure.degradation import try_get_degradation_context
from infrastructure.integration_result import IntegrationResult
from infrastructure.resilience.retry import CircuitBreaker, with_retry
from repositories.plex_models import (
PlexAlbum,
PlexArtist,
PlexHistoryEntry,
PlexLibrarySection,
PlexOAuthPin,
PlexPlaylist,
PlexSession,
PlexTrack,
StreamProxyResult,
parse_album,
parse_artist,
parse_library_sections,
parse_plex_history,
parse_plex_response,
parse_plex_sessions,
parse_playlist,
parse_track,
)
logger = logging.getLogger(__name__)
_SOURCE = "plex"
_PLEX_TV_BASE = "https://plex.tv/api/v2"
_PROXY_FORWARD_HEADERS = {"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges"}
_STREAM_CHUNK_SIZE = 64 * 1024
_DEFAULT_TTL_LIST = 300
_DEFAULT_TTL_SEARCH = 120
_DEFAULT_TTL_GENRES = 3600
_DEFAULT_TTL_DETAIL = 300
_plex_circuit_breaker = CircuitBreaker(
failure_threshold=5,
success_threshold=2,
timeout=60.0,
name="plex",
)
def _record_degradation(msg: str) -> None:
ctx = try_get_degradation_context()
if ctx is not None:
ctx.record(IntegrationResult.error(source=_SOURCE, msg=msg))
class PlexRepository:
def __init__(
self,
http_client: httpx.AsyncClient,
cache: CacheInterface,
) -> None:
self._client = http_client
self._cache = cache
self._url: str = ""
self._token: str = ""
self._client_id: str = ""
self._configured: bool = False
self._ttl_list: int = _DEFAULT_TTL_LIST
self._ttl_search: int = _DEFAULT_TTL_SEARCH
self._ttl_genres: int = _DEFAULT_TTL_GENRES
self._ttl_detail: int = _DEFAULT_TTL_DETAIL
self._ttl_stats: int = 600
def configure(self, url: str, token: str, client_id: str = "") -> None:
self._url = url.rstrip("/") if url else ""
self._token = token
self._client_id = client_id
self._configured = bool(self._url and self._token)
def is_configured(self) -> bool:
return self._configured
@property
def stats_ttl(self) -> int:
return self._ttl_stats
def configure_cache_ttls(
self,
*,
list_ttl: int | None = None,
search_ttl: int | None = None,
genres_ttl: int | None = None,
detail_ttl: int | None = None,
stats_ttl: int | None = None,
) -> None:
if list_ttl is not None:
self._ttl_list = list_ttl
if search_ttl is not None:
self._ttl_search = search_ttl
if genres_ttl is not None:
self._ttl_genres = genres_ttl
if detail_ttl is not None:
self._ttl_detail = detail_ttl
if stats_ttl is not None:
self._ttl_stats = stats_ttl
@staticmethod
def reset_circuit_breaker() -> None:
_plex_circuit_breaker.reset()
def _build_headers(self) -> dict[str, str]:
headers: dict[str, str] = {
"X-Plex-Token": self._token,
"X-Plex-Product": "MusicSeerr",
"X-Plex-Version": "1.0",
"Accept": "application/json",
}
if self._client_id:
headers["X-Plex-Client-Identifier"] = self._client_id
return headers
@with_retry(
max_attempts=3,
base_delay=1.0,
max_delay=5.0,
circuit_breaker=_plex_circuit_breaker,
retriable_exceptions=(httpx.HTTPError, ExternalServiceError),
non_breaking_exceptions=(PlexApiError,),
)
async def _request(
self,
endpoint: str,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
if not self._configured:
raise ExternalServiceError("Plex not configured")
url = f"{self._url}{endpoint}"
try:
response = await self._client.get(
url,
params=params,
headers=self._build_headers(),
timeout=15.0,
)
except httpx.TimeoutException as exc:
raise ExternalServiceError(f"Plex request timed out: {exc}")
except httpx.HTTPError as exc:
raise ExternalServiceError(f"Plex request failed: {exc}")
if response.status_code in (401, 403):
raise PlexAuthError(
f"Plex authentication failed ({response.status_code})"
)
if response.status_code != 200:
raise PlexApiError(
f"Plex request failed ({response.status_code})",
)
try:
data: dict[str, Any] = response.json()
except Exception as exc:
raise PlexApiError(f"Plex returned invalid JSON for {endpoint}") from exc
return parse_plex_response(data)
async def ping(self) -> bool:
try:
if not self._configured:
return False
url = f"{self._url}/"
response = await self._client.get(
url,
headers=self._build_headers(),
timeout=10.0,
)
return response.status_code == 200
except Exception: # noqa: BLE001
logger.debug("Plex ping failed", exc_info=True)
_record_degradation("Plex ping failed")
return False
async def get_libraries(self) -> list[PlexLibrarySection]:
cache_key = f"{PLEX_PREFIX}libraries"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
container = await self._request("/library/sections")
sections = parse_library_sections(container)
await self._cache.set(cache_key, sections, self._ttl_list)
return sections
async def get_music_libraries(self) -> list[PlexLibrarySection]:
sections = await self.get_libraries()
return [s for s in sections if s.type == "artist"]
async def get_artists(
self,
section_id: str,
size: int = 100,
offset: int = 0,
search: str = "",
) -> list[PlexArtist]:
cache_key = f"{PLEX_PREFIX}artists:{section_id}:{size}:{offset}:{search}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
params: dict[str, Any] = {
"type": 8,
"X-Plex-Container-Start": offset,
"X-Plex-Container-Size": size,
}
if search:
params["title"] = search
container = await self._request(
f"/library/sections/{section_id}/all",
params=params,
)
raw = container.get("Metadata", [])
artists = [parse_artist(a) for a in raw]
await self._cache.set(cache_key, artists, self._ttl_list)
return artists
async def get_albums(
self,
section_id: str,
size: int = 50,
offset: int = 0,
sort: str = "titleSort:asc",
genre: str | None = None,
mood: str | None = None,
decade: str | None = None,
) -> tuple[list[PlexAlbum], int]:
cache_key = f"{PLEX_PREFIX}albums:{section_id}:{size}:{offset}:{sort}:{genre or ''}:{mood or ''}:{decade or ''}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
params: dict[str, Any] = {
"type": 9,
"X-Plex-Container-Start": offset,
"X-Plex-Container-Size": size,
"sort": sort,
}
if genre:
params["genre"] = genre
if mood:
params["mood"] = mood
if decade:
stripped = decade.rstrip("s")
try:
start = int(stripped)
params["year"] = ",".join(str(y) for y in range(start, start + 10))
except ValueError:
pass
container = await self._request(
f"/library/sections/{section_id}/all",
params=params,
)
raw = container.get("Metadata", [])
total = container.get("totalSize", len(raw))
albums = [parse_album(a) for a in raw]
result = (albums, total)
await self._cache.set(cache_key, result, self._ttl_list)
return result
async def get_track_count(self, section_id: str) -> int:
cache_key = f"{PLEX_PREFIX}track_count:{section_id}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
container = await self._request(
f"/library/sections/{section_id}/all",
params={
"type": 10,
"X-Plex-Container-Start": 0,
"X-Plex-Container-Size": 0,
},
)
total = container.get("totalSize", 0)
await self._cache.set(cache_key, total, self._ttl_list)
return total
async def get_artist_count(self, section_id: str) -> int:
cache_key = f"{PLEX_PREFIX}artist_count:{section_id}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
container = await self._request(
f"/library/sections/{section_id}/all",
params={
"type": 8,
"X-Plex-Container-Start": 0,
"X-Plex-Container-Size": 0,
},
)
total = container.get("totalSize", 0)
await self._cache.set(cache_key, total, self._ttl_list)
return total
async def get_tracks(
self,
section_id: str,
size: int = 100,
offset: int = 0,
sort: str = "titleSort:asc",
search: str = "",
genre: str = "",
) -> tuple[list[PlexTrack], int]:
cache_key = f"{PLEX_PREFIX}tracks:{section_id}:{size}:{offset}:{sort}:{search}:{genre}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
params: dict[str, Any] = {
"type": 10,
"sort": sort,
"X-Plex-Container-Start": offset,
"X-Plex-Container-Size": size,
}
if search:
params["title"] = search
if genre:
params["genre"] = genre
container = await self._request(
f"/library/sections/{section_id}/all",
params=params,
)
raw = container.get("Metadata", [])
tracks = [parse_track(t) for t in raw]
total = container.get("totalSize", len(tracks))
result = (tracks, total)
await self._cache.set(cache_key, result, self._ttl_list)
return result
async def get_album_tracks(self, rating_key: str) -> list[PlexTrack]:
cache_key = f"{PLEX_PREFIX}album_tracks:{rating_key}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
container = await self._request(f"/library/metadata/{rating_key}/children")
raw = container.get("Metadata", [])
tracks = [parse_track(t) for t in raw]
await self._cache.set(cache_key, tracks, self._ttl_detail)
return tracks
async def get_album_metadata(self, rating_key: str) -> PlexAlbum:
cache_key = f"{PLEX_PREFIX}album:{rating_key}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
container = await self._request(f"/library/metadata/{rating_key}")
raw = container.get("Metadata", [])
if not raw:
raise PlexApiError(f"Album {rating_key} not found")
album = parse_album(raw[0])
await self._cache.set(cache_key, album, self._ttl_detail)
return album
async def get_recently_added(
self,
section_id: str,
limit: int = 20,
) -> list[PlexAlbum]:
cache_key = f"{PLEX_PREFIX}recent:{section_id}:{limit}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
container = await self._request(
f"/library/sections/{section_id}/recentlyAdded",
params={
"type": 9,
"X-Plex-Container-Size": limit,
},
)
raw = container.get("Metadata", [])
albums = [parse_album(a) for a in raw]
await self._cache.set(cache_key, albums, self._ttl_list)
return albums
async def get_recently_viewed(
self,
section_id: str,
limit: int = 20,
) -> list[PlexAlbum]:
cache_key = f"{PLEX_PREFIX}recent_viewed:{section_id}:{limit}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
container = await self._request(
f"/library/sections/{section_id}/recentlyViewed",
params={
"type": 9,
"X-Plex-Container-Size": limit,
},
)
raw = container.get("Metadata", [])
albums = [parse_album(a) for a in raw]
await self._cache.set(cache_key, albums, self._ttl_list)
return albums
async def get_playlists(self) -> list[PlexPlaylist]:
cache_key = f"{PLEX_PREFIX}playlists"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
container = await self._request(
"/playlists",
params={"playlistType": "audio"},
)
raw = container.get("Metadata", [])
playlists = [parse_playlist(p) for p in raw]
await self._cache.set(cache_key, playlists, self._ttl_list)
return playlists
async def get_playlist_items(self, rating_key: str) -> list[PlexTrack]:
cache_key = f"{PLEX_PREFIX}playlist:{rating_key}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
container = await self._request(f"/playlists/{rating_key}/items")
raw = container.get("Metadata", [])
tracks = [parse_track(t) for t in raw]
await self._cache.set(cache_key, tracks, self._ttl_detail)
return tracks
async def search(
self,
query: str,
section_id: str | None = None,
limit: int = 20,
) -> dict[str, list[Any]]:
cache_key = f"{PLEX_PREFIX}search:{query}:{section_id or ''}:{limit}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
params: dict[str, Any] = {"query": query, "limit": limit}
if section_id:
params["sectionId"] = section_id
container = await self._request("/hubs/search", params=params)
albums: list[PlexAlbum] = []
tracks: list[PlexTrack] = []
artists: list[PlexArtist] = []
for hub in container.get("Hub", []):
hub_type = hub.get("type", "")
for item in hub.get("Metadata", []):
if hub_type == "album":
albums.append(parse_album(item))
elif hub_type == "track":
tracks.append(parse_track(item))
elif hub_type == "artist":
artists.append(parse_artist(item))
result: dict[str, list[Any]] = {
"albums": albums,
"tracks": tracks,
"artists": artists,
}
await self._cache.set(cache_key, result, self._ttl_search)
return result
async def get_genres(self, section_id: str) -> list[str]:
cache_key = f"{PLEX_PREFIX}genres:{section_id}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
container = await self._request(
f"/library/sections/{section_id}/genre",
)
raw = container.get("Directory", [])
genres = [g.get("title", "") for g in raw if g.get("title")]
await self._cache.set(cache_key, genres, self._ttl_genres)
return genres
async def get_moods(self, section_id: str) -> list[str]:
cache_key = f"{PLEX_PREFIX}moods:{section_id}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
container = await self._request(
f"/library/sections/{section_id}/mood",
)
raw = container.get("Directory", [])
moods = [m.get("title", "") for m in raw if m.get("title")]
await self._cache.set(cache_key, moods, self._ttl_genres)
return moods
async def get_hubs(
self, section_id: str, count: int = 10
) -> list[dict[str, Any]]:
cache_key = f"{PLEX_PREFIX}hubs:{section_id}:{count}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
try:
container = await self._request(
f"/hubs/sections/{section_id}",
params={"count": count},
)
hubs = container.get("Hub", [])
await self._cache.set(cache_key, hubs, ttl_seconds=1800)
return hubs
except Exception: # noqa: BLE001
logger.warning("get_hubs failed for section %s", section_id, exc_info=True)
_record_degradation("Plex get_hubs failed")
return []
async def scrobble(self, rating_key: str) -> bool:
try:
if not self._configured:
return False
url = f"{self._url}/:/scrobble"
response = await self._client.get(
url,
params={
"key": rating_key,
"identifier": "com.plexapp.plugins.library",
},
headers=self._build_headers(),
timeout=10.0,
)
return response.status_code == 200
except Exception: # noqa: BLE001
logger.warning("Plex scrobble failed for %s", rating_key, exc_info=True)
_record_degradation("Plex scrobble failed")
return False
async def now_playing(self, rating_key: str, state: str = "playing") -> bool:
try:
if not self._configured:
return False
url = f"{self._url}/:/timeline"
response = await self._client.get(
url,
params={
"ratingKey": rating_key,
"state": state,
"key": f"/library/metadata/{rating_key}",
},
headers=self._build_headers(),
timeout=10.0,
)
return response.status_code == 200
except Exception: # noqa: BLE001
logger.warning("Plex now-playing report failed for %s", rating_key, exc_info=True)
_record_degradation("Plex now-playing report failed")
return False
def build_stream_url(self, track: PlexTrack) -> str:
if not self._configured:
raise ValueError("Plex is not configured")
if not track.Media or not track.Media[0].Part:
raise ValueError(f"Track {track.ratingKey} has no streamable media")
part_key = track.Media[0].Part[0].key
return f"{self._url}{part_key}"
async def proxy_head_stream(self, part_key: str) -> StreamProxyResult:
if not self._configured:
raise ExternalServiceError("Plex not configured")
if not part_key.startswith("/"):
part_key = f"/{part_key}"
if ".." in part_key.split("/"):
raise ValueError(f"Invalid Plex part key: {part_key}")
if not part_key.startswith("/library/parts/"):
raise ValueError(f"Invalid Plex part key: {part_key}")
url = f"{self._url}{part_key}"
async with httpx.AsyncClient(
timeout=httpx.Timeout(connect=10, read=10, write=10, pool=10)
) as client:
try:
resp = await client.head(url, headers=self._build_headers())
except httpx.HTTPError:
raise ExternalServiceError("Failed to reach Plex for stream HEAD")
headers: dict[str, str] = {}
for h in _PROXY_FORWARD_HEADERS:
v = resp.headers.get(h)
if v:
headers[h] = v
return StreamProxyResult(
status_code=resp.status_code,
headers=headers,
media_type=headers.get("Content-Type", "audio/mpeg"),
)
async def proxy_get_stream(
self, part_key: str, range_header: str | None = None
) -> StreamProxyResult:
if not self._configured:
raise ExternalServiceError("Plex not configured")
if not part_key.startswith("/"):
part_key = f"/{part_key}"
if ".." in part_key.split("/"):
raise ValueError(f"Invalid Plex part key: {part_key}")
if not part_key.startswith("/library/parts/"):
raise ValueError(f"Invalid Plex part key: {part_key}")
url = f"{self._url}{part_key}"
upstream_headers = dict(self._build_headers())
if range_header:
upstream_headers["Range"] = range_header
client = httpx.AsyncClient(
timeout=httpx.Timeout(connect=10, read=120, write=30, pool=10)
)
upstream_resp = None
try:
upstream_resp = await client.send(
client.build_request("GET", url, headers=upstream_headers),
stream=True,
)
if upstream_resp.status_code == 416:
raise ExternalServiceError("416 Range not satisfiable")
if upstream_resp.status_code >= 400:
logger.error(
"Plex upstream returned %d for %s",
upstream_resp.status_code, part_key,
)
raise ExternalServiceError("Plex returned an error")
resp_headers: dict[str, str] = {}
for header_name in _PROXY_FORWARD_HEADERS:
value = upstream_resp.headers.get(header_name)
if value:
resp_headers[header_name] = value
status_code = 206 if upstream_resp.status_code == 206 else 200
async def _stream_body() -> AsyncIterator[bytes]:
try:
async for chunk in upstream_resp.aiter_bytes(
chunk_size=_STREAM_CHUNK_SIZE
):
yield chunk
finally:
await upstream_resp.aclose()
await client.aclose()
return StreamProxyResult(
status_code=status_code,
headers=resp_headers,
media_type=resp_headers.get("Content-Type", "audio/mpeg"),
body_chunks=_stream_body(),
)
except httpx.HTTPError as exc:
if upstream_resp:
await upstream_resp.aclose()
await client.aclose()
raise ExternalServiceError(f"Plex streaming failed: {exc}") from exc
except Exception:
if upstream_resp:
await upstream_resp.aclose()
await client.aclose()
raise
async def proxy_thumb(self, rating_key: str, size: int = 500) -> tuple[bytes, str]:
if not self._configured:
raise ExternalServiceError("Plex not configured")
url = f"{self._url}/library/metadata/{rating_key}/thumb"
try:
response = await self._client.get(
url,
params={"width": size, "height": size},
headers=self._build_headers(),
timeout=15.0,
)
except httpx.TimeoutException:
raise ExternalServiceError("Plex thumb request timed out")
except httpx.HTTPError:
raise ExternalServiceError("Plex thumb request failed")
if response.status_code != 200:
raise ExternalServiceError(
f"Plex thumb request failed ({response.status_code})"
)
content_type = response.headers.get("content-type", "image/jpeg")
return response.content, content_type
async def proxy_playlist_composite(self, rating_key: str, size: int = 500) -> tuple[bytes, str]:
if not self._configured:
raise ExternalServiceError("Plex not configured")
playlists = await self.get_playlists()
playlist = next((p for p in playlists if p.ratingKey == rating_key), None)
composite_path = playlist.composite if playlist and playlist.composite else f"/playlists/{rating_key}/composite"
url = f"{self._url}{composite_path}"
headers = self._build_headers()
headers["Accept"] = "image/*"
try:
response = await self._client.get(
url,
params={"width": size, "height": size},
headers=headers,
timeout=15.0,
)
except httpx.TimeoutException:
raise ExternalServiceError("Plex playlist composite request timed out")
except httpx.HTTPError:
raise ExternalServiceError("Plex playlist composite request failed")
if response.status_code != 200:
raise ExternalServiceError(
f"Plex playlist composite request failed ({response.status_code})"
)
content_type = response.headers.get("content-type", "image/jpeg")
return response.content, content_type
async def validate_connection(self) -> tuple[bool, str]:
if not self._configured:
return False, "Plex URL or token not configured"
try:
url = f"{self._url}/"
response = await self._client.get(
url,
headers=self._build_headers(),
timeout=10.0,
)
if response.status_code in (401, 403):
return False, "Authentication failed - check your Plex token"
if response.status_code != 200:
return False, f"Plex returned status {response.status_code}"
data = response.json()
container = data.get("MediaContainer", {})
friendly_name = container.get("friendlyName", "Unknown")
version = container.get("version", "unknown")
return True, f"Connected to {friendly_name} (v{version})"
except httpx.TimeoutException:
return False, "Connection timed out - check URL"
except httpx.HTTPError as exc:
msg = str(exc)
if "connect" in msg.lower() or "refused" in msg.lower():
return False, "Could not connect - check URL and ensure server is running"
return False, f"Connection failed: {msg}"
except Exception as exc: # noqa: BLE001
return False, f"Connection failed: {exc}"
async def create_oauth_pin(self, client_id: str) -> PlexOAuthPin:
async with httpx.AsyncClient(timeout=httpx.Timeout(15.0)) as client:
response = await client.post(
f"{_PLEX_TV_BASE}/pins",
headers={
"X-Plex-Product": "MusicSeerr",
"X-Plex-Client-Identifier": client_id,
"Accept": "application/json",
},
data={"strong": "true"},
)
if response.status_code != 201:
raise PlexApiError(f"Failed to create OAuth pin ({response.status_code})")
data = response.json()
return PlexOAuthPin(
id=data.get("id", 0),
code=data.get("code", ""),
)
async def poll_oauth_pin(self, pin_id: int, client_id: str) -> str | None:
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client:
response = await client.get(
f"{_PLEX_TV_BASE}/pins/{pin_id}",
headers={
"X-Plex-Client-Identifier": client_id,
"Accept": "application/json",
},
)
if response.status_code != 200:
return None
data = response.json()
token = data.get("authToken")
return token if token else None
async def get_sessions(self) -> list[PlexSession]:
if not self._configured:
return []
cache_key = f"{PLEX_PREFIX}sessions"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
try:
data = await self._request("/status/sessions")
sessions = parse_plex_sessions(data)
await self._cache.set(cache_key, sessions, 2)
return sessions
except (PlexAuthError, PlexApiError):
logger.warning("Plex sessions unavailable (may require admin token)")
_record_degradation("Plex sessions auth/api failure")
return []
except Exception: # noqa: BLE001
logger.warning("Failed to fetch Plex sessions", exc_info=True)
_record_degradation("Plex sessions fetch failed")
return []
async def get_listening_history(
self,
limit: int = 50,
offset: int = 0,
) -> tuple[list[PlexHistoryEntry], int]:
if not self._configured:
return [], 0
cache_key = f"{PLEX_PREFIX}history:{limit}:{offset}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
try:
data = await self._request(
"/status/sessions/history/all",
params={
"X-Plex-Container-Start": offset,
"X-Plex-Container-Size": limit,
"sort": "viewedAt:desc",
},
)
entries, total = parse_plex_history(data)
result = (entries, total)
await self._cache.set(cache_key, result, 300)
return result
except (PlexAuthError, PlexApiError):
logger.warning("Plex history unavailable (may require admin token)")
_record_degradation("Plex history auth/api failure")
return [], 0
except Exception: # noqa: BLE001
logger.warning("Failed to fetch Plex listening history", exc_info=True)
_record_degradation("Plex history fetch failed")
return [], 0
async def clear_cache(self) -> None:
await self._cache.clear_prefix(PLEX_PREFIX)

View file

@ -11,6 +11,7 @@ from repositories.protocols.lidarr import LidarrRepositoryProtocol as LidarrRepo
from repositories.protocols.listenbrainz import ListenBrainzRepositoryProtocol as ListenBrainzRepositoryProtocol
from repositories.protocols.musicbrainz import MusicBrainzRepositoryProtocol as MusicBrainzRepositoryProtocol
from repositories.protocols.navidrome import NavidromeRepositoryProtocol as NavidromeRepositoryProtocol
from repositories.protocols.plex import PlexRepositoryProtocol as PlexRepositoryProtocol
from repositories.protocols.wikidata import WikidataRepositoryProtocol as WikidataRepositoryProtocol
from repositories.protocols.youtube import YouTubeRepositoryProtocol as YouTubeRepositoryProtocol
@ -28,6 +29,7 @@ __all__ = [
"ListenBrainzRepositoryProtocol",
"MusicBrainzRepositoryProtocol",
"NavidromeRepositoryProtocol",
"PlexRepositoryProtocol",
"WikidataRepositoryProtocol",
"YouTubeRepositoryProtocol",
"ListenBrainzArtist",

View file

@ -1,6 +1,6 @@
from typing import Any, Protocol
from repositories.jellyfin_models import JellyfinItem, JellyfinUser, PlaybackUrlResult
from repositories.jellyfin_models import JellyfinItem, JellyfinLyrics, JellyfinSession, JellyfinUser, PlaybackUrlResult
from repositories.navidrome_models import StreamProxyResult
@ -57,6 +57,9 @@ class JellyfinRepositoryProtocol(Protocol):
async def get_genres(self, user_id: str | None = None, ttl_seconds: int = 3600) -> list[str]:
...
async def get_filter_facets(self, user_id: str | None = None, ttl_seconds: int = 3600) -> dict[str, Any]:
...
async def get_artists_by_genre(
self, genre: str, user_id: str | None = None, limit: int = 50
) -> list[JellyfinItem]:
@ -75,6 +78,9 @@ class JellyfinRepositoryProtocol(Protocol):
sort_by: str = "SortName",
sort_order: str = "Ascending",
genre: str | None = None,
year: int | None = None,
tags: str | None = None,
studios: str | None = None,
) -> tuple[list[JellyfinItem], int]:
...
@ -140,3 +146,44 @@ class JellyfinRepositoryProtocol(Protocol):
self, item_id: str, range_header: str | None = None
) -> StreamProxyResult:
...
async def get_instant_mix(
self, item_id: str, limit: int = 50
) -> list[JellyfinItem]:
...
async def get_instant_mix_by_artist(
self, artist_id: str, limit: int = 50
) -> list[JellyfinItem]:
...
async def get_instant_mix_by_genre(
self, genre_name: str, limit: int = 50
) -> list[JellyfinItem]:
...
async def get_sessions(self) -> list[JellyfinSession]:
...
async def get_similar_items(
self, item_id: str, limit: int = 10
) -> list[JellyfinItem]:
...
async def get_lyrics(self, item_id: str) -> JellyfinLyrics | None:
...
async def get_playlists(
self, user_id: str | None = None, limit: int = 50
) -> list[JellyfinItem]:
...
async def get_playlist(
self, playlist_id: str, user_id: str | None = None
) -> JellyfinItem | None:
...
async def get_playlist_items(
self, playlist_id: str, user_id: str | None = None, limit: int = 1000
) -> list[JellyfinItem]:
...

View file

@ -4,8 +4,14 @@ from typing import TYPE_CHECKING, Protocol
from repositories.navidrome_models import (
SubsonicAlbum,
SubsonicAlbumInfo,
SubsonicArtist,
SubsonicArtistIndex,
SubsonicArtistInfo,
SubsonicGenre,
SubsonicLyrics,
SubsonicMusicFolder,
SubsonicNowPlayingEntry,
SubsonicPlaylist,
SubsonicSearchResult,
SubsonicSong,
@ -58,6 +64,17 @@ class NavidromeRepositoryProtocol(Protocol):
async def get_genres(self) -> list[SubsonicGenre]:
...
async def get_artists_index(self) -> list[SubsonicArtistIndex]:
...
async def get_songs_by_genre(
self, genre: str, count: int = 50, offset: int = 0
) -> list[SubsonicSong]:
...
async def get_music_folders(self) -> list[SubsonicMusicFolder]:
...
async def get_playlists(self) -> list[SubsonicPlaylist]:
...
@ -91,3 +108,28 @@ class NavidromeRepositoryProtocol(Protocol):
async def now_playing(self, id: str) -> bool:
...
async def get_now_playing(self) -> list[SubsonicNowPlayingEntry]:
...
async def get_top_songs(
self, artist_name: str, count: int = 20
) -> list[SubsonicSong]:
...
async def get_similar_songs(
self, song_id: str, count: int = 20
) -> list[SubsonicSong]:
...
async def get_artist_info(self, artist_id: str) -> SubsonicArtistInfo | None:
...
async def get_album_info(self, album_id: str) -> SubsonicAlbumInfo | None:
...
async def get_lyrics(self, artist: str, title: str) -> SubsonicLyrics | None:
...
async def get_lyrics_by_song_id(self, song_id: str) -> SubsonicLyrics | None:
...

View file

@ -0,0 +1,157 @@
from __future__ import annotations
from typing import Any, Protocol, TYPE_CHECKING
from repositories.plex_models import (
PlexAlbum,
PlexArtist,
PlexHistoryEntry,
PlexLibrarySection,
PlexOAuthPin,
PlexPlaylist,
PlexSession,
PlexTrack,
)
if TYPE_CHECKING:
from repositories.plex_models import StreamProxyResult
class PlexRepositoryProtocol(Protocol):
def is_configured(self) -> bool:
...
def configure(self, url: str, token: str, client_id: str = "") -> None:
...
async def ping(self) -> bool:
...
async def get_libraries(self) -> list[PlexLibrarySection]:
...
async def get_music_libraries(self) -> list[PlexLibrarySection]:
...
async def get_artists(
self, section_id: str, size: int = 100, offset: int = 0
) -> list[PlexArtist]:
...
async def get_albums(
self,
section_id: str,
size: int = 50,
offset: int = 0,
sort: str = "titleSort:asc",
genre: str | None = None,
mood: str | None = None,
decade: str | None = None,
) -> tuple[list[PlexAlbum], int]:
...
async def get_track_count(self, section_id: str) -> int:
...
async def get_artist_count(self, section_id: str) -> int:
...
async def get_album_tracks(self, rating_key: str) -> list[PlexTrack]:
...
async def get_album_metadata(self, rating_key: str) -> PlexAlbum:
...
async def get_recently_added(
self, section_id: str, limit: int = 20
) -> list[PlexAlbum]:
...
async def get_recently_viewed(
self, section_id: str, limit: int = 20
) -> list[PlexAlbum]:
...
async def get_playlists(self) -> list[PlexPlaylist]:
...
async def get_playlist_items(self, rating_key: str) -> list[PlexTrack]:
...
async def search(
self,
query: str,
section_id: str | None = None,
limit: int = 20,
) -> dict[str, list[Any]]:
...
async def get_genres(self, section_id: str) -> list[str]:
...
async def get_moods(self, section_id: str) -> list[str]:
...
async def get_hubs(
self, section_id: str, count: int = 10
) -> list[dict[str, Any]]:
...
async def scrobble(self, rating_key: str) -> bool:
...
async def now_playing(self, rating_key: str, state: str = "playing") -> bool:
...
def build_stream_url(self, track: PlexTrack) -> str:
...
async def proxy_head_stream(self, part_key: str) -> StreamProxyResult:
...
async def proxy_get_stream(
self, part_key: str, range_header: str | None = None
) -> StreamProxyResult:
...
async def proxy_thumb(self, rating_key: str, size: int = 500) -> tuple[bytes, str]:
...
async def proxy_playlist_composite(self, rating_key: str, size: int = 500) -> tuple[bytes, str]:
...
async def validate_connection(self) -> tuple[bool, str]:
...
async def create_oauth_pin(self, client_id: str) -> PlexOAuthPin:
...
async def poll_oauth_pin(self, pin_id: int, client_id: str) -> str | None:
...
async def clear_cache(self) -> None:
...
@property
def stats_ttl(self) -> int:
...
def configure_cache_ttls(
self,
*,
list_ttl: int | None = None,
search_ttl: int | None = None,
genres_ttl: int | None = None,
detail_ttl: int | None = None,
stats_ttl: int | None = None,
) -> None:
...
async def get_sessions(self) -> list[PlexSession]:
...
async def get_listening_history(
self, limit: int = 50, offset: int = 0
) -> tuple[list[PlexHistoryEntry], int]:
...

View file

@ -1,4 +1,4 @@
"""Slim HomeService facade — preserves constructor signature, delegates to sub-services."""
"""Slim HomeService facade that preserves the constructor signature and delegates to sub-services."""
from __future__ import annotations
@ -87,6 +87,7 @@ class HomeService:
localfiles=self._helpers.is_local_files_enabled(),
lastfm=self._helpers.is_lastfm_enabled(),
navidrome=self._helpers.is_navidrome_enabled(),
plex=self._helpers.is_plex_enabled(),
)
async def get_genre_artist(

View file

@ -48,6 +48,15 @@ class HomeIntegrationHelpers:
and bool(nd_settings.password)
)
def is_plex_enabled(self) -> bool:
plex_settings = self._preferences.get_plex_connection()
return (
plex_settings.enabled
and bool(plex_settings.plex_url)
and bool(plex_settings.plex_token)
and bool(plex_settings.music_library_ids)
)
def is_lastfm_enabled(self) -> bool:
return self._preferences.is_lastfm_enabled()

View file

@ -5,12 +5,26 @@ from api.v1.schemas.jellyfin import (
JellyfinAlbumDetail,
JellyfinAlbumMatch,
JellyfinAlbumSummary,
JellyfinArtistIndexEntry,
JellyfinArtistIndexResponse,
JellyfinArtistSummary,
JellyfinFavoritesExpanded,
JellyfinFilterFacets,
JellyfinHubResponse,
JellyfinImportResult,
JellyfinLibraryStats,
JellyfinLyricsLineSchema,
JellyfinLyricsResponse,
JellyfinPlaylistDetail,
JellyfinPlaylistSummary,
JellyfinPlaylistTrack,
JellyfinSearchResponse,
JellyfinSessionInfo,
JellyfinSessionsResponse,
JellyfinTrackInfo,
)
from infrastructure.cover_urls import prefer_artist_cover_url, prefer_release_group_cover_url
from core.exceptions import ExternalServiceError
from repositories.protocols import JellyfinRepositoryProtocol
from repositories.jellyfin_models import JellyfinItem
from services.preferences_service import PreferencesService
@ -60,9 +74,10 @@ class JellyfinLibraryService:
pids = item.provider_ids or {}
mbid = pids.get("MusicBrainzReleaseGroup") or pids.get("MusicBrainzAlbum")
artist_mbid = pids.get("MusicBrainzAlbumArtist") or pids.get("MusicBrainzArtist")
proxy_url = f"/api/v1/jellyfin/image/{item.id}" if item.id else None
image_url = prefer_release_group_cover_url(
mbid,
self._jellyfin.get_image_url(item.id, item.image_tag),
proxy_url,
size=500,
)
return JellyfinAlbumSummary(
@ -74,10 +89,29 @@ class JellyfinLibraryService:
image_url=image_url,
musicbrainz_id=mbid,
artist_musicbrainz_id=artist_mbid,
play_count=item.play_count,
)
def _item_to_artist_summary(self, item: JellyfinItem) -> JellyfinArtistSummary:
mbid = item.provider_ids.get("MusicBrainzArtist") if item.provider_ids else None
proxy_url = f"/api/v1/jellyfin/image/{item.id}" if item.id else None
image_url = prefer_artist_cover_url(
mbid,
proxy_url,
size=500,
)
return JellyfinArtistSummary(
jellyfin_id=item.id,
name=item.name,
image_url=image_url,
album_count=item.album_count or 0,
musicbrainz_id=mbid,
play_count=item.play_count,
)
def _item_to_track_info(self, item: JellyfinItem) -> JellyfinTrackInfo:
duration_seconds = (item.duration_ticks / 10_000_000.0) if item.duration_ticks else 0.0
album_id = item.album_id or ""
return JellyfinTrackInfo(
jellyfin_id=item.id,
title=item.name,
@ -86,8 +120,10 @@ class JellyfinLibraryService:
duration_seconds=duration_seconds,
album_name=item.album_name or "",
artist_name=item.artist_name or "",
album_id=album_id,
codec=item.codec,
bitrate=item.bitrate,
image_url=f"/api/v1/jellyfin/image/{album_id}" if album_id else None,
)
@staticmethod
@ -131,9 +167,13 @@ class JellyfinLibraryService:
sort_by: str = "SortName",
sort_order: str = "Ascending",
genre: str | None = None,
year: int | None = None,
tags: str | None = None,
studios: str | None = None,
) -> tuple[list[JellyfinAlbumSummary], int]:
items, total = await self._jellyfin.get_albums(
limit=limit, offset=offset, sort_by=sort_by, sort_order=sort_order, genre=genre
limit=limit, offset=offset, sort_by=sort_by, sort_order=sort_order,
genre=genre, year=year, tags=tags, studios=studios,
)
return [self._item_to_album_summary(i) for i in items], total
@ -149,9 +189,10 @@ class JellyfinLibraryService:
pids = item.provider_ids or {}
mbid = pids.get("MusicBrainzReleaseGroup") or pids.get("MusicBrainzAlbum")
artist_mbid = pids.get("MusicBrainzAlbumArtist") or pids.get("MusicBrainzArtist")
proxy_url = f"/api/v1/jellyfin/image/{item.id}" if item.id else None
image_url = prefer_release_group_cover_url(
mbid,
self._jellyfin.get_image_url(item.id, item.image_tag),
proxy_url,
size=500,
)
@ -192,23 +233,60 @@ class JellyfinLibraryService:
async def get_artists(
self, limit: int = 50, offset: int = 0
) -> list[JellyfinArtistSummary]:
items = await self._jellyfin.get_artists(limit=limit, offset=offset)
artists = []
for item in items:
mbid = item.provider_ids.get("MusicBrainzArtist") if item.provider_ids else None
image_url = prefer_artist_cover_url(
mbid,
self._jellyfin.get_image_url(item.id, item.image_tag),
size=500,
items, _total = await self._jellyfin.get_artists(limit=limit, offset=offset)
return [self._item_to_artist_summary(i) for i in items]
async def browse_artists(
self,
limit: int = 48,
offset: int = 0,
sort_by: str = "SortName",
sort_order: str = "Ascending",
search: str = "",
) -> tuple[list[JellyfinArtistSummary], int]:
items, total = await self._jellyfin.get_artists(
limit=limit, offset=offset, sort_by=sort_by, sort_order=sort_order, search=search
)
return [self._item_to_artist_summary(i) for i in items], total
async def get_artists_index(self) -> JellyfinArtistIndexResponse:
all_artists: list[JellyfinArtistSummary] = []
offset = 0
batch_size = 500
while True:
items, _total = await self._jellyfin.get_artists(
limit=batch_size, offset=offset, sort_by="SortName", sort_order="Ascending"
)
artists.append(JellyfinArtistSummary(
jellyfin_id=item.id,
name=item.name,
image_url=image_url,
album_count=item.album_count or 0,
musicbrainz_id=mbid,
))
return artists
all_artists.extend(self._item_to_artist_summary(i) for i in items)
if len(items) < batch_size:
break
offset += batch_size
groups: dict[str, list[JellyfinArtistSummary]] = {}
for artist in all_artists:
letter = artist.name[0].upper() if artist.name else "#"
if not letter.isalpha():
letter = "#"
groups.setdefault(letter, []).append(artist)
entries = [
JellyfinArtistIndexEntry(name=letter, artists=artists)
for letter, artists in sorted(groups.items())
]
return JellyfinArtistIndexResponse(index=entries)
async def browse_tracks(
self,
limit: int = 48,
offset: int = 0,
sort_by: str = "SortName",
sort_order: str = "Ascending",
search: str = "",
) -> tuple[list[JellyfinTrackInfo], int]:
items, total = await self._jellyfin.get_tracks(
limit=limit, offset=offset, sort_by=sort_by, sort_order=sort_order, search=search
)
return [self._item_to_track_info(i) for i in items], total
async def search(
self, query: str
@ -221,18 +299,7 @@ class JellyfinLibraryService:
if item.type == "MusicAlbum":
albums.append(self._item_to_album_summary(item))
elif item.type in ("MusicArtist", "Artist"):
mbid = item.provider_ids.get("MusicBrainzArtist") if item.provider_ids else None
image_url = prefer_artist_cover_url(
mbid,
self._jellyfin.get_image_url(item.id, item.image_tag),
size=500,
)
artists.append(JellyfinArtistSummary(
jellyfin_id=item.id,
name=item.name,
image_url=image_url,
musicbrainz_id=mbid,
))
artists.append(self._item_to_artist_summary(item))
elif item.type == "Audio":
tracks.append(self._item_to_track_info(item))
return JellyfinSearchResponse(albums=albums, artists=artists, tracks=tracks)
@ -278,10 +345,37 @@ class JellyfinLibraryService:
)
return [self._item_to_album_summary(i) for i in items]
async def get_favorites_expanded(self, limit: int = 50) -> JellyfinFavoritesExpanded:
ttl_seconds = self._get_favorites_ttl()
albums_items, artists_items = await asyncio.gather(
self._jellyfin.get_favorite_albums(limit=limit, ttl_seconds=ttl_seconds),
self._jellyfin.get_favorite_artists(limit=limit),
)
return JellyfinFavoritesExpanded(
albums=[self._item_to_album_summary(i) for i in albums_items],
artists=[self._item_to_artist_summary(i) for i in artists_items],
)
async def get_filter_facets(self) -> JellyfinFilterFacets:
facets = await self._jellyfin.get_filter_facets()
return JellyfinFilterFacets(
years=facets.get("years", []),
tags=facets.get("tags", []),
studios=facets.get("studios", []),
)
async def get_genres(self) -> list[str]:
ttl_seconds = self._get_genres_ttl()
return await self._jellyfin.get_genres(ttl_seconds=ttl_seconds)
async def get_songs_by_genre(
self, genre: str, limit: int = 50, offset: int = 0
) -> tuple[list[JellyfinTrackInfo], int]:
items, total = await self._jellyfin.get_tracks(
limit=limit, offset=offset, sort_by="Random", genre=genre
)
return [self._item_to_track_info(i) for i in items], total
async def get_stats(self) -> JellyfinLibraryStats:
ttl_seconds = self._get_stats_ttl()
raw = await self._jellyfin.get_library_stats(ttl_seconds=ttl_seconds)
@ -290,3 +384,289 @@ class JellyfinLibraryService:
total_albums=raw.get("total_albums", 0),
total_artists=raw.get("total_artists", 0),
)
async def get_recently_added(self, limit: int = 20) -> list[JellyfinAlbumSummary]:
items = await self._jellyfin.get_recently_added(limit=limit)
return [self._item_to_album_summary(i) for i in items]
async def get_most_played_artists(self, limit: int = 20) -> list[JellyfinArtistSummary]:
items = await self._jellyfin.get_most_played_artists(limit=limit)
return [self._item_to_artist_summary(i) for i in items]
async def get_most_played_albums(self, limit: int = 20) -> list[JellyfinAlbumSummary]:
items = await self._jellyfin.get_most_played_albums(limit=limit)
return [self._item_to_album_summary(i) for i in items]
async def list_playlists(self, limit: int = 50) -> list[JellyfinPlaylistSummary]:
items = await self._jellyfin.get_playlists(limit=limit)
summaries = []
for i in items:
cover = f"/api/v1/jellyfin/image/{i.id}"
summaries.append(JellyfinPlaylistSummary(
id=i.id,
name=i.name,
track_count=i.child_count or 0,
duration_seconds=int(i.duration_ticks / 10_000_000) if i.duration_ticks else 0,
cover_url=cover,
created_at=i.date_created or "",
))
return summaries
async def get_playlist_detail(self, playlist_id: str) -> JellyfinPlaylistDetail:
playlist = await self._jellyfin.get_playlist(playlist_id)
if playlist is None:
from core.exceptions import ResourceNotFoundError
raise ResourceNotFoundError(f"Jellyfin playlist {playlist_id} not found")
items = await self._jellyfin.get_playlist_items(playlist_id)
tracks = []
for t in items:
tracks.append(JellyfinPlaylistTrack(
id=t.id,
track_name=t.name,
artist_name=t.artist_name or "",
album_name=t.album_name or "",
album_id=t.album_id or "",
artist_id=t.artist_id or "",
duration_seconds=int(t.duration_ticks / 10_000_000) if t.duration_ticks else 0,
track_number=t.index_number or 0,
disc_number=t.parent_index_number or 1,
cover_url=f"/api/v1/jellyfin/image/{t.album_id}" if t.album_id else "",
))
cover = f"/api/v1/jellyfin/image/{playlist.id}"
if not playlist.image_tag and tracks:
first_with_album = next((t for t in tracks if t.album_id), None)
if first_with_album:
cover = f"/api/v1/jellyfin/image/{first_with_album.album_id}"
return JellyfinPlaylistDetail(
id=playlist.id,
name=playlist.name,
track_count=playlist.child_count or 0,
duration_seconds=int(playlist.duration_ticks / 10_000_000) if playlist.duration_ticks else 0,
cover_url=cover,
created_at=playlist.date_created or "",
tracks=tracks,
)
async def import_playlist(
self,
playlist_id: str,
playlist_service: 'PlaylistService',
) -> JellyfinImportResult:
source_ref = f"jellyfin:{playlist_id}"
existing = await playlist_service.get_by_source_ref(source_ref)
if existing:
return JellyfinImportResult(
musicseerr_playlist_id=existing.id,
already_imported=True,
)
detail = await self.get_playlist_detail(playlist_id)
try:
created = await playlist_service.create_playlist(detail.name, source_ref=source_ref)
except Exception: # noqa: BLE001
re_check = await playlist_service.get_by_source_ref(source_ref)
if re_check:
return JellyfinImportResult(musicseerr_playlist_id=re_check.id, already_imported=True)
raise
track_dicts = []
failed = 0
for t in detail.tracks:
try:
track_dicts.append({
"track_name": t.track_name,
"artist_name": t.artist_name,
"album_name": t.album_name,
"duration": t.duration_seconds,
"track_source_id": t.id,
"source_type": "jellyfin",
"album_id": t.album_id,
"artist_id": t.artist_id,
"track_number": t.track_number,
"disc_number": t.disc_number,
"cover_url": t.cover_url,
})
except Exception: # noqa: BLE001
failed += 1
if track_dicts:
try:
await playlist_service.add_tracks(created.id, track_dicts)
except Exception: # noqa: BLE001
logger.error("Failed to add tracks during Jellyfin playlist import %s", playlist_id, exc_info=True)
await playlist_service.delete_playlist(created.id)
raise ExternalServiceError(f"Failed to import Jellyfin playlist {playlist_id}")
return JellyfinImportResult(
musicseerr_playlist_id=created.id,
tracks_imported=len(track_dicts),
tracks_failed=failed,
)
async def get_instant_mix(
self, item_id: str, limit: int = 50
) -> list[JellyfinTrackInfo]:
try:
items = await self._jellyfin.get_instant_mix(item_id, limit=limit)
return [self._item_to_track_info(i) for i in items]
except Exception: # noqa: BLE001
logger.warning("get_instant_mix failed for %s", item_id, exc_info=True)
return []
async def get_instant_mix_by_artist(
self, artist_id: str, limit: int = 50
) -> list[JellyfinTrackInfo]:
try:
items = await self._jellyfin.get_instant_mix_by_artist(artist_id, limit=limit)
return [self._item_to_track_info(i) for i in items]
except Exception: # noqa: BLE001
logger.warning("get_instant_mix_by_artist failed for %s", artist_id, exc_info=True)
return []
async def get_instant_mix_by_genre(
self, genre_name: str, limit: int = 50
) -> list[JellyfinTrackInfo]:
try:
items = await self._jellyfin.get_instant_mix_by_genre(genre_name, limit=limit)
return [self._item_to_track_info(i) for i in items]
except Exception: # noqa: BLE001
logger.warning("get_instant_mix_by_genre failed for %s", genre_name, exc_info=True)
return []
async def get_sessions(self) -> JellyfinSessionsResponse:
_TICKS_TO_SECONDS = 10_000_000
try:
raw_sessions = await self._jellyfin.get_sessions()
sessions = [
JellyfinSessionInfo(
session_id=s.session_id,
user_name=s.user_name,
device_name=s.device_name,
client_name=s.client_name,
track_name=s.now_playing_name,
artist_name=s.now_playing_artist,
album_name=s.now_playing_album,
album_id=s.now_playing_album_id,
cover_url=f"/api/v1/jellyfin/image/{s.now_playing_item_id}" if s.now_playing_item_id else "",
position_seconds=s.position_ticks / _TICKS_TO_SECONDS if s.position_ticks else 0.0,
duration_seconds=s.runtime_ticks / _TICKS_TO_SECONDS if s.runtime_ticks else 0.0,
is_paused=s.is_paused,
play_method=s.play_method,
audio_codec=s.audio_codec,
bitrate=s.bitrate,
)
for s in raw_sessions
]
return JellyfinSessionsResponse(sessions=sessions)
except Exception: # noqa: BLE001
logger.warning("get_sessions failed", exc_info=True)
return JellyfinSessionsResponse(sessions=[])
async def get_hub_data(self) -> JellyfinHubResponse:
_HUB_TIMEOUT = 10
results = await asyncio.gather(
asyncio.wait_for(self.get_recently_played(limit=20), timeout=_HUB_TIMEOUT),
asyncio.wait_for(self.get_favorites(limit=20), timeout=_HUB_TIMEOUT),
asyncio.wait_for(self.get_albums(limit=12), timeout=_HUB_TIMEOUT),
asyncio.wait_for(self.get_stats(), timeout=_HUB_TIMEOUT),
asyncio.wait_for(self.get_recently_added(limit=20), timeout=_HUB_TIMEOUT),
asyncio.wait_for(self.get_most_played_artists(limit=10), timeout=_HUB_TIMEOUT),
asyncio.wait_for(self.get_most_played_albums(limit=10), timeout=_HUB_TIMEOUT),
asyncio.wait_for(self.list_playlists(limit=20), timeout=_HUB_TIMEOUT),
asyncio.wait_for(self.get_genres(), timeout=_HUB_TIMEOUT),
return_exceptions=True,
)
all_failed = all(isinstance(r, BaseException) for r in results)
if all_failed:
raise ExternalServiceError("All Jellyfin hub data requests failed")
recently_played = results[0] if not isinstance(results[0], BaseException) else []
if isinstance(results[0], BaseException):
logger.warning("Hub: get_recently_played failed: %s", results[0])
favorites = results[1] if not isinstance(results[1], BaseException) else []
if isinstance(results[1], BaseException):
logger.warning("Hub: get_favorites failed: %s", results[1])
albums_result = results[2]
if isinstance(albums_result, BaseException):
logger.warning("Hub: get_albums failed: %s", albums_result)
all_albums_preview: list[JellyfinAlbumSummary] = []
else:
all_albums_preview = albums_result[0]
stats = results[3] if not isinstance(results[3], BaseException) else None
if isinstance(results[3], BaseException):
logger.warning("Hub: get_stats failed: %s", results[3])
recently_added = results[4] if not isinstance(results[4], BaseException) else []
if isinstance(results[4], BaseException):
logger.warning("Hub: get_recently_added failed: %s", results[4])
most_played_artists = results[5] if not isinstance(results[5], BaseException) else []
if isinstance(results[5], BaseException):
logger.warning("Hub: get_most_played_artists failed: %s", results[5])
most_played_albums = results[6] if not isinstance(results[6], BaseException) else []
if isinstance(results[6], BaseException):
logger.warning("Hub: get_most_played_albums failed: %s", results[6])
playlists = results[7] if not isinstance(results[7], BaseException) else []
if isinstance(results[7], BaseException):
logger.warning("Hub: list_playlists failed: %s", results[7])
genres = results[8] if not isinstance(results[8], BaseException) else []
if isinstance(results[8], BaseException):
logger.warning("Hub: get_genres failed: %s", results[8])
return JellyfinHubResponse(
stats=stats,
recently_played=recently_played,
recently_added=recently_added,
favorites=favorites,
most_played_artists=most_played_artists,
most_played_albums=most_played_albums,
all_albums_preview=all_albums_preview,
playlists=playlists,
genres=genres,
)
async def get_similar_items(
self, item_id: str, limit: int = 10
) -> list[JellyfinAlbumSummary]:
try:
items = await self._jellyfin.get_similar_items(item_id, limit=limit)
return [self._item_to_album_summary(i) for i in items if i.type in ("MusicAlbum", "Audio")]
except Exception: # noqa: BLE001
logger.warning("Similar items unavailable for %s", item_id)
return []
async def get_lyrics(self, item_id: str) -> JellyfinLyricsResponse | None:
try:
lyrics = await self._jellyfin.get_lyrics(item_id)
if lyrics is None:
return None
is_synced = any(line.start is not None for line in lyrics.lines)
lines = [
JellyfinLyricsLineSchema(
text=line.text,
start_seconds=line.start / 10_000_000 if line.start is not None else None,
)
for line in lyrics.lines
]
lyrics_text = "\n".join(line.text for line in lyrics.lines)
return JellyfinLyricsResponse(
lines=lines,
is_synced=is_synced,
lyrics_text=lyrics_text,
)
except ExternalServiceError:
logger.warning("Jellyfin API error fetching lyrics for item %s", item_id)
return None
except Exception: # noqa: BLE001
logger.warning("Unexpected error fetching lyrics for item %s", item_id, exc_info=True)
return None

View file

@ -5,6 +5,7 @@ from fastapi.responses import Response, StreamingResponse
from core.exceptions import ExternalServiceError, PlaybackNotAllowedError
from infrastructure.constants import JELLYFIN_TICKS_PER_SECOND
from infrastructure.cache.memory_cache import CacheInterface
from repositories.navidrome_models import StreamProxyResult
from repositories.protocols import JellyfinRepositoryProtocol
@ -12,8 +13,19 @@ logger = logging.getLogger(__name__)
class JellyfinPlaybackService:
def __init__(self, jellyfin_repo: JellyfinRepositoryProtocol):
def __init__(
self,
jellyfin_repo: JellyfinRepositoryProtocol,
cache: CacheInterface | None = None,
):
self._jellyfin = jellyfin_repo
self._cache = cache
async def _invalidate_sessions_cache(self) -> None:
if not self._cache:
return
uid = getattr(self._jellyfin, '_user_id', None) or 'default'
await self._cache.delete(f"jellyfin:sessions:{uid}")
async def start_playback(self, item_id: str, play_session_id: str | None = None) -> str:
"""Report playback start to Jellyfin. Returns play_session_id.
@ -36,7 +48,7 @@ class JellyfinPlaybackService:
resolved_play_session_id = info.get("PlaySessionId")
if not resolved_play_session_id:
logger.warning(
"Jellyfin returned null PlaySessionId for item %s "
"Jellyfin returned null PlaySessionId for item %s, "
"streaming without session reporting",
item_id,
)
@ -56,6 +68,7 @@ class JellyfinPlaybackService:
await self._jellyfin.report_playback_start(
item_id, resolved_play_session_id, play_method=play_method
)
await self._invalidate_sessions_cache()
except (httpx.HTTPError, ExternalServiceError) as e:
logger.error(
"Failed to report playback start for %s: %s", item_id, e
@ -77,6 +90,7 @@ class JellyfinPlaybackService:
await self._jellyfin.report_playback_progress(
item_id, play_session_id, position_ticks, is_paused
)
await self._invalidate_sessions_cache()
except (httpx.HTTPError, ExternalServiceError) as e:
logger.warning("Progress report failed for %s: %s", item_id, e)
@ -93,6 +107,7 @@ class JellyfinPlaybackService:
await self._jellyfin.report_playback_stopped(
item_id, play_session_id, position_ticks
)
await self._invalidate_sessions_cache()
except (httpx.HTTPError, ExternalServiceError) as e:
logger.warning("Stop report failed for %s: %s", item_id, e)

View file

@ -9,15 +9,32 @@ from typing import TYPE_CHECKING
from api.v1.schemas.navidrome import (
NavidromeAlbumDetail,
NavidromeAlbumInfoSchema,
NavidromeAlbumMatch,
NavidromeAlbumSummary,
NavidromeArtistIndexEntry,
NavidromeArtistIndexResponse,
NavidromeArtistInfoSchema,
NavidromeArtistSummary,
NavidromeGenreSongsResponse,
NavidromeHubResponse,
NavidromeImportResult,
NavidromeLibraryStats,
NavidromeLyricLine,
NavidromeLyricsResponse,
NavidromeMusicFolder,
NavidromeNowPlayingEntrySchema,
NavidromeNowPlayingResponse,
NavidromePlaylistDetail,
NavidromePlaylistSummary,
NavidromePlaylistTrack,
NavidromeSearchResponse,
NavidromeTrackInfo,
)
from infrastructure.cover_urls import prefer_artist_cover_url, prefer_release_group_cover_url
from repositories.navidrome_models import SubsonicAlbum, SubsonicSong
from infrastructure.validators import clean_lastfm_bio
from core.exceptions import ExternalServiceError
from repositories.navidrome_models import SubsonicAlbum, SubsonicSong, SubsonicArtistIndex
from repositories.protocols import NavidromeRepositoryProtocol
from services.preferences_service import PreferencesService
@ -27,7 +44,7 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
_CONCURRENCY_LIMIT = 5
_NEGATIVE_CACHE_TTL = 14400 # 4 hours - aligned with periodic warmup interval
_NEGATIVE_CACHE_TTL = 4 * 60 * 60
def _cache_get_mbid(cache: dict[str, str | tuple[None, float]], key: str) -> str | None:
@ -70,17 +87,15 @@ class NavidromeLibraryService:
self._preferences = preferences_service
self._library_db = library_db
self._mbid_store = mbid_store
# Cache values: str (resolved MBID) or tuple (None, timestamp) for negative entries
self._album_mbid_cache: dict[str, str | tuple[None, float]] = {}
self._artist_mbid_cache: dict[str, str | tuple[None, float]] = {}
self._mbid_to_navidrome_id: dict[str, str] = {}
# Lidarr in-memory indices (populated during warmup)
self._lidarr_album_index: dict[str, tuple[str, str]] = {}
self._lidarr_artist_index: dict[str, str] = {}
self._dirty = False
def lookup_navidrome_id(self, mbid: str) -> str | None:
"""Public accessor for MBID → Navidrome album ID reverse index."""
"""Public accessor for the MBID-to-Navidrome album ID reverse index."""
return self._mbid_to_navidrome_id.get(mbid)
def invalidate_album_cache(self, album_mbid: str) -> None:
@ -110,14 +125,12 @@ class NavidromeLibraryService:
elif cached is None:
del self._album_mbid_cache[cache_key]
# Try exact match in Lidarr index
match = self._lidarr_album_index.get(cache_key)
if match:
self._album_mbid_cache[cache_key] = match[0]
self._dirty = True
return match[0]
# Try cleaned name match (strip remaster/deluxe/EP/single suffixes)
clean_key = f"{_normalize(_clean_album_name(name))}:{_normalize(artist)}"
if clean_key != cache_key:
match = self._lidarr_album_index.get(clean_key)
@ -196,10 +209,10 @@ class NavidromeLibraryService:
artist_name=song.artist,
codec=song.suffix or None,
bitrate=song.bitRate or None,
image_url=f"/api/v1/navidrome/cover/{song.albumId}" if song.albumId else None,
)
async def _album_to_summary(self, album: SubsonicAlbum) -> NavidromeAlbumSummary:
# Only expose Lidarr-resolved MBIDs (Navidrome may have release IDs, not release-group IDs)
mbid = await self._resolve_album_mbid(album.name, album.artist) if album.name and album.artist else None
if mbid:
self._mbid_to_navidrome_id[mbid] = album.id
@ -255,8 +268,13 @@ class NavidromeLibraryService:
size: int = 50,
offset: int = 0,
genre: str | None = None,
from_year: int | None = None,
to_year: int | None = None,
) -> list[NavidromeAlbumSummary]:
albums = await self._navidrome.get_album_list(type=type, size=size, offset=offset, genre=genre)
albums = await self._navidrome.get_album_list(
type=type, size=size, offset=offset, genre=genre,
from_year=from_year, to_year=to_year,
)
filtered = [a for a in albums if a.name and a.name != "Unknown"]
summaries = await asyncio.gather(*(self._album_to_summary(a) for a in filtered))
return list(summaries)
@ -294,6 +312,38 @@ class NavidromeLibraryService:
summaries = await asyncio.gather(*(self._build_artist_summary(a) for a in artists))
return list(summaries)
async def browse_artists(
self,
size: int = 48,
offset: int = 0,
search: str = "",
) -> tuple[list[NavidromeArtistSummary], int]:
all_artists = await self._navidrome.get_artists()
if search:
query = search.lower()
all_artists = [a for a in all_artists if query in a.name.lower()]
total = len(all_artists)
page = all_artists[offset : offset + size]
summaries = await asyncio.gather(*(self._build_artist_summary(a) for a in page))
return list(summaries), total
async def browse_tracks(
self,
size: int = 48,
offset: int = 0,
search: str = "",
) -> tuple[list[NavidromeTrackInfo], int]:
songs = await self._navidrome.search_songs(
query=search, count=size, offset=offset
)
tracks = [self._song_to_track_info(s) for s in songs]
try:
stats = await self.get_stats()
total = stats.total_tracks if len(tracks) >= size else offset + len(tracks)
except Exception: # noqa: BLE001
total = offset + len(tracks) + (1 if len(tracks) >= size else 0)
return tracks, total
async def get_artist_detail(self, artist_id: str) -> dict[str, object] | None:
try:
artist = await self._navidrome.get_artist(artist_id)
@ -362,13 +412,63 @@ class NavidromeLibraryService:
genres = await self._navidrome.get_genres()
return [g.name for g in genres if g.name]
async def get_artists_index(self) -> NavidromeArtistIndexResponse:
index_data = await self._navidrome.get_artists_index()
entries: list[NavidromeArtistIndexEntry] = []
for idx in index_data:
artists = []
for a in idx.artists:
mbid = a.musicBrainzId or None
fallback = f"/api/v1/navidrome/cover/{a.coverArt}" if a.coverArt else None
image_url = prefer_artist_cover_url(mbid, fallback, size=300) if (mbid or fallback) else None
artists.append(NavidromeArtistSummary(
navidrome_id=a.id,
name=a.name,
album_count=a.albumCount,
image_url=image_url,
musicbrainz_id=mbid,
))
entries.append(NavidromeArtistIndexEntry(name=idx.name, artists=artists))
return NavidromeArtistIndexResponse(index=entries)
async def get_songs_by_genre(
self, genre: str, count: int = 50, offset: int = 0
) -> NavidromeGenreSongsResponse:
songs = await self._navidrome.get_songs_by_genre(genre=genre, count=count, offset=offset)
tracks = [self._song_to_track_info(s) for s in songs]
return NavidromeGenreSongsResponse(songs=tracks, genre=genre)
async def get_songs_by_genres(
self, genres: list[str], count: int = 50, offset: int = 0
) -> NavidromeGenreSongsResponse:
import asyncio
capped = genres[:10]
per_genre = max(count // len(capped), 10)
tasks = [
self._navidrome.get_songs_by_genre(genre=g, count=per_genre, offset=offset)
for g in capped
]
results = await asyncio.gather(*tasks)
seen: set[str] = set()
merged: list[NavidromeTrackInfo] = []
for songs in results:
for s in songs:
if s.id not in seen:
seen.add(s.id)
merged.append(self._song_to_track_info(s))
merged = merged[:count]
return NavidromeGenreSongsResponse(songs=merged, genre=",".join(capped))
async def get_music_folders(self) -> list[NavidromeMusicFolder]:
folders = await self._navidrome.get_music_folders()
return [NavidromeMusicFolder(id=f.id, name=f.name) for f in folders]
async def get_stats(self) -> NavidromeLibraryStats:
artists = await self._navidrome.get_artists()
# Fetch a single album just to trigger the endpoint, then count via pagination
first_page = await self._navidrome.get_album_list(type="alphabeticalByName", size=1, offset=0)
total_albums = 0
all_albums: list = []
if first_page:
# Count all albums by paginating with large pages
all_albums = await self._navidrome.get_album_list(type="alphabeticalByName", size=500, offset=0)
total_albums = len(all_albums)
if total_albums >= 500:
@ -377,12 +477,12 @@ class NavidromeLibraryService:
batch = await self._navidrome.get_album_list(type="alphabeticalByName", size=500, offset=offset)
if not batch:
break
all_albums.extend(batch)
total_albums += len(batch)
if len(batch) < 500:
break
offset += 500
genres = await self._navidrome.get_genres()
total_songs = sum(g.songCount for g in genres)
total_songs = sum(a.songCount for a in all_albums)
return NavidromeLibraryStats(
total_tracks=total_songs,
total_albums=total_albums,
@ -401,7 +501,6 @@ class NavidromeLibraryService:
async with sem:
return await self.get_album_detail(aid)
# Fast path: direct MBID→navidrome_id lookup from reverse index
if album_id and album_id in self._mbid_to_navidrome_id:
nav_id = self._mbid_to_navidrome_id[album_id]
detail = await _fetch_detail(nav_id)
@ -448,11 +547,208 @@ class NavidromeLibraryService:
return NavidromeAlbumMatch(found=False)
async def list_playlists(self, limit: int = 50) -> list[NavidromePlaylistSummary]:
raw = await self._navidrome.get_playlists()
summaries = []
for p in raw[:limit]:
summaries.append(NavidromePlaylistSummary(
id=p.id,
name=p.name,
track_count=p.songCount,
duration_seconds=p.duration,
cover_url=f"/api/v1/navidrome/cover/{p.id}" if p.id else "",
owner=p.owner,
is_public=p.public,
updated_at=p.changed,
))
return summaries
async def get_playlist_detail(self, playlist_id: str) -> NavidromePlaylistDetail:
raw = await self._navidrome.get_playlist(playlist_id)
if raw is None:
from core.exceptions import ResourceNotFoundError
raise ResourceNotFoundError(f"Navidrome playlist {playlist_id} not found")
tracks = []
for s in raw.entry or []:
tracks.append(NavidromePlaylistTrack(
id=s.id,
track_name=s.title,
artist_name=s.artist,
album_name=s.album,
album_id=s.albumId,
artist_id=s.artistId,
duration_seconds=s.duration,
track_number=s.track,
disc_number=s.discNumber,
cover_url=f"/api/v1/navidrome/cover/{s.albumId}" if s.albumId else "",
))
return NavidromePlaylistDetail(
id=raw.id,
name=raw.name,
track_count=raw.songCount,
duration_seconds=raw.duration,
cover_url=f"/api/v1/navidrome/cover/{raw.id}" if raw.id else "",
tracks=tracks,
)
async def import_playlist(
self,
playlist_id: str,
playlist_service: 'PlaylistService',
) -> NavidromeImportResult:
source_ref = f"navidrome:{playlist_id}"
existing = await playlist_service.get_by_source_ref(source_ref)
if existing:
return NavidromeImportResult(
musicseerr_playlist_id=existing.id,
already_imported=True,
)
detail = await self.get_playlist_detail(playlist_id)
try:
created = await playlist_service.create_playlist(detail.name, source_ref=source_ref)
except Exception: # noqa: BLE001
re_check = await playlist_service.get_by_source_ref(source_ref)
if re_check:
return NavidromeImportResult(musicseerr_playlist_id=re_check.id, already_imported=True)
raise
track_dicts = []
failed = 0
for t in detail.tracks:
try:
track_dicts.append({
"track_name": t.track_name,
"artist_name": t.artist_name,
"album_name": t.album_name,
"duration": t.duration_seconds,
"track_source_id": t.id,
"source_type": "navidrome",
"album_id": t.album_id,
"artist_id": t.artist_id,
"track_number": t.track_number,
"disc_number": t.disc_number,
"cover_url": t.cover_url,
})
except Exception: # noqa: BLE001
failed += 1
if track_dicts:
try:
await playlist_service.add_tracks(created.id, track_dicts)
except Exception: # noqa: BLE001
logger.error("Failed to add tracks during Navidrome playlist import %s", playlist_id, exc_info=True)
await playlist_service.delete_playlist(created.id)
raise ExternalServiceError(f"Failed to import Navidrome playlist {playlist_id}")
return NavidromeImportResult(
musicseerr_playlist_id=created.id,
tracks_imported=len(track_dicts),
tracks_failed=failed,
)
async def get_random_songs(
self,
size: int = 20,
genre: str | None = None,
) -> list[NavidromeTrackInfo]:
try:
songs = await self._navidrome.get_random_songs(size=size, genre=genre)
return [self._song_to_track_info(s) for s in songs]
except Exception: # noqa: BLE001
logger.warning("get_random_songs failed", exc_info=True)
return []
async def get_now_playing(self) -> NavidromeNowPlayingResponse:
from services.navidrome_playback_service import NavidromePlaybackService
try:
entries = await self._navidrome.get_now_playing()
mapped = [
NavidromeNowPlayingEntrySchema(
user_name=e.username,
minutes_ago=e.minutesAgo,
player_name=e.playerName,
track_name=e.title,
artist_name=e.artist,
album_name=e.album,
album_id=e.albumId,
cover_art_id=e.coverArt,
duration_seconds=e.duration,
estimated_position_seconds=NavidromePlaybackService.get_estimated_position(e.id) or 0.0,
)
for e in entries
]
return NavidromeNowPlayingResponse(entries=mapped)
except Exception: # noqa: BLE001
logger.warning("get_now_playing failed", exc_info=True)
return NavidromeNowPlayingResponse(entries=[])
async def get_hub_data(self) -> NavidromeHubResponse:
_HUB_TIMEOUT = 10
results = await asyncio.gather(
asyncio.wait_for(self.get_recent(limit=20), timeout=_HUB_TIMEOUT),
asyncio.wait_for(self.get_favorites(), timeout=_HUB_TIMEOUT),
asyncio.wait_for(self.get_albums(size=12), timeout=_HUB_TIMEOUT),
asyncio.wait_for(self.get_stats(), timeout=_HUB_TIMEOUT),
asyncio.wait_for(self.list_playlists(limit=20), timeout=_HUB_TIMEOUT),
asyncio.wait_for(self.get_genres(), timeout=_HUB_TIMEOUT),
return_exceptions=True,
)
all_failed = all(isinstance(r, BaseException) for r in results)
if all_failed:
raise ExternalServiceError("All Navidrome hub data requests failed")
recently_played = results[0] if not isinstance(results[0], BaseException) else []
if isinstance(results[0], BaseException):
logger.warning("Hub: get_recent failed: %s", results[0])
favorites_result = results[1]
if isinstance(favorites_result, BaseException):
logger.warning("Hub: get_favorites failed: %s", favorites_result)
favorites: list[NavidromeAlbumSummary] = []
favorite_artists: list[NavidromeArtistSummary] = []
favorite_tracks: list[NavidromeTrackInfo] = []
else:
favorites = favorites_result.albums
favorite_artists = favorites_result.artists
favorite_tracks = favorites_result.tracks
all_albums_preview = results[2] if not isinstance(results[2], BaseException) else []
if isinstance(results[2], BaseException):
logger.warning("Hub: get_albums failed: %s", results[2])
stats = results[3] if not isinstance(results[3], BaseException) else None
if isinstance(results[3], BaseException):
logger.warning("Hub: get_stats failed: %s", results[3])
playlists = results[4] if not isinstance(results[4], BaseException) else []
if isinstance(results[4], BaseException):
logger.warning("Hub: list_playlists failed: %s", results[4])
genres = results[5] if not isinstance(results[5], BaseException) else []
if isinstance(results[5], BaseException):
logger.warning("Hub: get_genres failed: %s", results[5])
return NavidromeHubResponse(
stats=stats,
recently_played=recently_played,
favorites=favorites,
favorite_artists=favorite_artists,
favorite_tracks=favorite_tracks,
all_albums_preview=all_albums_preview,
playlists=playlists,
genres=genres,
)
async def warm_mbid_cache(self) -> None:
"""Background task: enrich all Navidrome albums and artists with MBIDs from Lidarr library matching.
Loads from SQLite first for instant startup; enriches from Lidarr library matching."""
# Phase 0: Build Lidarr indices from library cache
if self._library_db:
try:
lidarr_albums = await self._library_db.get_all_albums_for_matching()
@ -474,7 +770,6 @@ class NavidromeLibraryService:
except Exception: # noqa: BLE001
logger.warning("Failed to build Lidarr matching indices", exc_info=True)
# Phase 1: Load from persistent SQLite cache (serves requests while Lidarr may be unavailable)
loaded_from_disk = False
if self._mbid_store:
try:
@ -494,7 +789,6 @@ class NavidromeLibraryService:
if not self._lidarr_album_index:
logger.warning("Lidarr library data unavailable - Lidarr enrichment will be skipped")
# Phase 2: Fetch current Navidrome library (paginated) for reconciliation + enrichment
try:
all_albums: list[SubsonicAlbum] = []
offset = 0
@ -512,7 +806,6 @@ class NavidromeLibraryService:
logger.warning("Failed to fetch Navidrome albums for MBID enrichment")
return
# Phase 3: Reconcile - remove stale entries no longer in Navidrome
current_album_keys: set[str] = set()
current_artist_names: set[str] = set()
for album in all_albums:
@ -534,7 +827,6 @@ class NavidromeLibraryService:
len(stale_album_keys), len(stale_artist_keys),
)
# Phase 4: Enrich all entries from Lidarr library matching (skipped when Lidarr unavailable)
resolved_albums = 0
resolved_artists = 0
@ -545,7 +837,6 @@ class NavidromeLibraryService:
cache_key = f"{_normalize(album.name)}:{_normalize(album.artist)}"
existing = self._album_mbid_cache.get(cache_key)
if isinstance(existing, str):
# Overwrite with Lidarr data if available (corrects old MB-sourced or Navidrome-native MBIDs)
lidarr_match = self._lidarr_album_index.get(cache_key)
if not lidarr_match:
clean_key = f"{_normalize(_clean_album_name(album.name))}:{_normalize(album.artist)}"
@ -557,7 +848,6 @@ class NavidromeLibraryService:
resolved_albums += 1
continue
if isinstance(existing, tuple):
# Override negative entries when Lidarr now has a match
lidarr_hit = self._lidarr_album_index.get(cache_key)
if not lidarr_hit:
clean_key = f"{_normalize(_clean_album_name(album.name))}:{_normalize(album.artist)}"
@ -596,7 +886,6 @@ class NavidromeLibraryService:
resolved_albums, resolved_artists, loaded_from_disk, bool(self._lidarr_album_index),
)
# Phase 5: Persist to SQLite
if self._mbid_store and (self._dirty or stale_album_keys or stale_artist_keys):
try:
serializable_albums = {k: (v if isinstance(v, str) else None) for k, v in self._album_mbid_cache.items()}
@ -611,13 +900,135 @@ class NavidromeLibraryService:
except Exception: # noqa: BLE001
logger.warning("Failed to persist Navidrome MBID cache to disk", exc_info=True)
# Phase 6: Rebuild MBID→navidrome_id reverse index from scratch
self._mbid_to_navidrome_id.clear()
for album in all_albums:
if not album.name or album.name == "Unknown":
continue
cache_key = f"{_normalize(album.name)}:{_normalize(album.artist)}"
# Only use Lidarr-resolved MBIDs for reverse index
mbid = _cache_get_mbid(self._album_mbid_cache, cache_key)
if mbid:
self._mbid_to_navidrome_id[mbid] = album.id
async def get_top_songs(self, artist_name: str, count: int = 20) -> list[NavidromeTrackInfo]:
try:
songs = await self._navidrome.get_top_songs(artist_name, count=count)
return [
NavidromeTrackInfo(
navidrome_id=s.id,
title=s.title,
track_number=s.track,
duration_seconds=s.duration,
disc_number=s.discNumber,
album_name=s.album,
artist_name=s.artist,
)
for s in songs
]
except Exception: # noqa: BLE001
logger.debug("Top songs unavailable for %s (Last.fm may not be configured)", artist_name)
return []
async def get_similar_songs(self, song_id: str, count: int = 20) -> list[NavidromeTrackInfo]:
try:
songs = await self._navidrome.get_similar_songs(song_id, count=count)
return [
NavidromeTrackInfo(
navidrome_id=s.id,
title=s.title,
track_number=s.track,
duration_seconds=s.duration,
disc_number=s.discNumber,
album_name=s.album,
artist_name=s.artist,
)
for s in songs
]
except Exception: # noqa: BLE001
logger.debug("Similar songs unavailable for %s (Last.fm may not be configured)", song_id)
return []
async def get_artist_info(self, artist_id: str) -> NavidromeArtistInfoSchema | None:
try:
info = await self._navidrome.get_artist_info(artist_id)
if info is None:
return None
artist = await self._navidrome.get_artist(artist_id)
artist_name = artist.name if artist else ""
similar = [
NavidromeArtistSummary(
navidrome_id=a.id,
name=a.name,
)
for a in info.similarArtist
]
image = ""
if info.largeImageUrl:
image = info.largeImageUrl
elif info.mediumImageUrl:
image = info.mediumImageUrl
elif info.smallImageUrl:
image = info.smallImageUrl
return NavidromeArtistInfoSchema(
navidrome_id=artist_id,
name=artist_name,
biography=clean_lastfm_bio(info.biography),
image_url=image,
similar_artists=similar,
)
except Exception: # noqa: BLE001
logger.debug("Artist info unavailable for %s (Last.fm may not be configured)", artist_id)
return None
async def get_album_info(self, album_id: str) -> NavidromeAlbumInfoSchema | None:
try:
info = await self._navidrome.get_album_info(album_id)
if info is None:
return None
if not info.notes and not info.musicBrainzId and not info.lastFmUrl:
return None
image = ""
if info.largeImageUrl:
image = info.largeImageUrl
elif info.mediumImageUrl:
image = info.mediumImageUrl
elif info.smallImageUrl:
image = info.smallImageUrl
return NavidromeAlbumInfoSchema(
album_id=album_id,
notes=clean_lastfm_bio(info.notes),
musicbrainz_id=info.musicBrainzId,
lastfm_url=info.lastFmUrl,
image_url=image,
)
except Exception: # noqa: BLE001
logger.debug("Album info unavailable for %s", album_id)
return None
async def get_lyrics(
self, song_id: str, artist: str = "", title: str = ""
) -> NavidromeLyricsResponse | None:
try:
lyrics = await self._navidrome.get_lyrics_by_song_id(song_id)
if lyrics and (lyrics.value.strip() or lyrics.lines):
lines = [
NavidromeLyricLine(
text=l.value,
start_seconds=l.start / 1000.0 if l.start is not None else None,
)
for l in lyrics.lines
] if lyrics.lines else []
return NavidromeLyricsResponse(
text=lyrics.value,
is_synced=lyrics.is_synced,
lines=lines,
)
except Exception: # noqa: BLE001
logger.debug("getLyricsBySongId fallback for %s", song_id)
if artist and title:
try:
lyrics = await self._navidrome.get_lyrics(artist, title)
if lyrics and (lyrics.value.strip() or lyrics.lines):
return NavidromeLyricsResponse(text=lyrics.value, is_synced=False)
except Exception: # noqa: BLE001
logger.debug("getLyrics also failed for %s - %s", artist, title)
return None

View file

@ -5,15 +5,23 @@ import time
from fastapi.responses import Response, StreamingResponse
from infrastructure.cache.memory_cache import CacheInterface
from repositories.navidrome_models import StreamProxyResult
from repositories.protocols import NavidromeRepositoryProtocol
logger = logging.getLogger(__name__)
_playback_start_times: dict[str, float] = {}
class NavidromePlaybackService:
def __init__(self, navidrome_repo: NavidromeRepositoryProtocol) -> None:
def __init__(
self,
navidrome_repo: NavidromeRepositoryProtocol,
cache: CacheInterface | None = None,
) -> None:
self._navidrome = navidrome_repo
self._cache = cache
def get_stream_url(self, song_id: str) -> str:
return self._navidrome.build_stream_url(song_id)
@ -40,14 +48,35 @@ class NavidromePlaybackService:
async def scrobble(self, song_id: str) -> bool:
time_ms = int(time.time() * 1000)
try:
return await self._navidrome.scrobble(song_id, time_ms=time_ms)
ok = await self._navidrome.scrobble(song_id, time_ms=time_ms)
_playback_start_times.pop(song_id, None)
if self._cache:
await self._cache.delete("navidrome:now_playing")
return ok
except Exception: # noqa: BLE001
logger.warning("Navidrome scrobble failed for %s", song_id, exc_info=True)
return False
async def report_now_playing(self, song_id: str) -> bool:
try:
return await self._navidrome.now_playing(song_id)
ok = await self._navidrome.now_playing(song_id)
_playback_start_times[song_id] = time.time()
if self._cache:
await self._cache.delete("navidrome:now_playing")
return ok
except Exception: # noqa: BLE001
logger.warning("Navidrome now-playing failed for %s", song_id, exc_info=True)
return False
async def clear_now_playing(self, song_id: str) -> None:
"""Stop tracking estimated position for a song (player closed/paused)."""
_playback_start_times.pop(song_id, None)
if self._cache:
await self._cache.delete("navidrome:now_playing")
@staticmethod
def get_estimated_position(song_id: str) -> float | None:
started = _playback_start_times.get(song_id)
if started is None:
return None
return time.time() - started

View file

@ -4,7 +4,7 @@ import re
from collections import defaultdict
from difflib import SequenceMatcher
from pathlib import Path
from typing import Optional
from typing import Any, Optional
from core.exceptions import InvalidPlaylistDataError, PlaylistNotFoundError, SourceResolutionError
from infrastructure.cache.cache_keys import SOURCE_RESOLUTION_PREFIX
@ -20,10 +20,10 @@ from repositories.playlist_repository import (
logger = logging.getLogger(__name__)
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp"}
MAX_COVER_SIZE = 2 * 1024 * 1024 # 2 MB
MAX_COVER_SIZE = 2 * 1024 * 1024
_MIME_TO_EXT = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}
_SAFE_ID_RE = re.compile(r"^[a-f0-9\-]+$")
VALID_SOURCE_TYPES = {"local", "jellyfin", "navidrome", "youtube", ""}
VALID_SOURCE_TYPES = {"local", "jellyfin", "navidrome", "plex", "youtube", ""}
MAX_NAME_LENGTH = 100
_SOURCE_TYPE_ALIASES = {
@ -31,6 +31,7 @@ _SOURCE_TYPE_ALIASES = {
"howler": "local",
"jellyfin": "jellyfin",
"navidrome": "navidrome",
"plex": "plex",
"youtube": "youtube",
"": "",
}
@ -80,16 +81,22 @@ class PlaylistService:
self._cache = cache
async def create_playlist(self, name: str) -> PlaylistRecord:
async def create_playlist(self, name: str, *, source_ref: str | None = None) -> PlaylistRecord:
stripped = name.strip() if name else ""
if not stripped:
raise InvalidPlaylistDataError("Playlist name must not be empty")
if len(stripped) > MAX_NAME_LENGTH:
raise InvalidPlaylistDataError(f"Playlist name must not exceed {MAX_NAME_LENGTH} characters")
result = await self._repo.create_playlist(stripped)
logger.info("Playlist created: id=%s name=%s", result.id, result.name)
result = await self._repo.create_playlist(stripped, source_ref=source_ref)
logger.info("Playlist created: id=%s name=%s source_ref=%s", result.id, result.name, source_ref)
return result
async def get_by_source_ref(self, source_ref: str) -> PlaylistRecord | None:
return await self._repo.get_by_source_ref(source_ref)
async def get_imported_source_ids(self, prefix: str) -> set[str]:
return await self._repo.get_imported_source_ids(prefix)
async def get_playlist(self, playlist_id: str) -> PlaylistRecord:
result = await self._repo.get_playlist(playlist_id)
if result is None:
@ -208,6 +215,7 @@ class PlaylistService:
jf_service: object = None,
local_service: object = None,
nd_service: object = None,
plex_service: object = None,
) -> PlaylistTrackRecord:
if source_type is not None and source_type not in _SOURCE_TYPE_ALIASES:
raise InvalidPlaylistDataError(
@ -226,18 +234,28 @@ class PlaylistService:
normalized_available_sources.append(_SOURCE_TYPE_ALIASES[source])
new_track_source_id: Optional[str] = None
new_plex_rating_key_resolved = False
new_plex_rating_key: Optional[str] = None
if normalized_source:
current_track = await self._repo.get_track(playlist_id, track_id)
if current_track is None:
raise PlaylistNotFoundError(f"Track {track_id} not found in playlist {playlist_id}")
if normalized_source != current_track.source_type:
new_track_source_id = await self._resolve_new_source_id(
new_track_source_id, new_plex_rating_key = await self._resolve_new_source_id(
current_track, normalized_source, jf_service, local_service, nd_service,
plex_service,
)
new_plex_rating_key_resolved = True
repo_kwargs: dict[str, Any] = {
"track_source_id": new_track_source_id,
}
if new_plex_rating_key_resolved:
repo_kwargs["plex_rating_key"] = new_plex_rating_key
result = await self._repo.update_track_source(
playlist_id, track_id, normalized_source, normalized_available_sources,
track_source_id=new_track_source_id,
**repo_kwargs,
)
if result is None:
raise PlaylistNotFoundError(f"Track {track_id} not found in playlist {playlist_id}")
@ -259,6 +277,7 @@ class PlaylistService:
jf_service: object = None,
local_service: object = None,
nd_service: object = None,
plex_service: object = None,
) -> dict[str, list[str]]:
await self.get_playlist(playlist_id)
tracks = await self._repo.get_tracks(playlist_id)
@ -276,8 +295,8 @@ class PlaylistService:
result: dict[str, list[str]] = {}
for album_id, album_tracks in album_groups.items():
representative = album_tracks[0]
jf_by_num, local_by_num, nd_by_num = await self._resolve_album_sources(
album_id, jf_service, local_service, nd_service,
jf_by_num, local_by_num, nd_by_num, plex_by_num = await self._resolve_album_sources(
album_id, jf_service, local_service, nd_service, plex_service,
album_name=representative.album_name or "",
artist_name=representative.artist_name or "",
)
@ -298,6 +317,10 @@ class PlaylistService:
if nd_track and _fuzzy_name_match(t.track_name, nd_track[0]):
sources.add("navidrome")
plex_track = plex_by_num.get(t.track_number)
if plex_track and _fuzzy_name_match(t.track_name, plex_track[0]):
sources.add("plex")
result[t.id] = sorted(sources)
for t in no_album_tracks:
@ -322,24 +345,34 @@ class PlaylistService:
jf_service: object,
local_service: object,
nd_service: object = None,
plex_service: object = None,
album_name: str = "",
artist_name: str = "",
) -> tuple[dict[int, tuple[str, str]], dict[int, tuple[str, str]], dict[int, tuple[str, str]]]:
) -> tuple[dict[int, tuple[str, str]], dict[int, tuple[str, str]], dict[int, tuple[str, str]], dict[int, tuple[str, str, str]]]:
cache_key = f"{SOURCE_RESOLUTION_PREFIX}:{album_id}"
if self._cache:
cached = await self._cache.get(cache_key)
if cached is not None:
if len(cached) == 2:
return (_normalize_source_map(cached[0]), _normalize_source_map(cached[1]), {})
return (_normalize_source_map(cached[0]), _normalize_source_map(cached[1]), {}, {})
if len(cached) == 3:
return (
_normalize_source_map(cached[0]),
_normalize_source_map(cached[1]),
_normalize_source_map(cached[2]),
{},
)
return (
_normalize_source_map(cached[0]),
_normalize_source_map(cached[1]),
_normalize_source_map(cached[2]),
_normalize_source_map(cached[3]),
)
jf_by_num: dict[int, tuple[str, str]] = {}
local_by_num: dict[int, tuple[str, str]] = {}
nd_by_num: dict[int, tuple[str, str]] = {}
plex_by_num: dict[int, tuple[str, str, str]] = {}
if jf_service is not None:
try:
@ -376,7 +409,20 @@ class PlaylistService:
except Exception: # noqa: BLE001
logger.debug("Navidrome source resolution failed for album %s", album_id, exc_info=True)
resolved = (jf_by_num, local_by_num, nd_by_num)
if plex_service is not None:
try:
match = await plex_service.get_album_match(
album_id=album_id, album_name=album_name, artist_name=artist_name,
)
if match.found:
for t in match.tracks:
key = _safe_track_number(t.track_number)
if key is not None:
plex_by_num[key] = (t.title, t.part_key or t.plex_id, t.plex_id)
except Exception: # noqa: BLE001
logger.debug("Plex source resolution failed for album %s", album_id, exc_info=True)
resolved = (jf_by_num, local_by_num, nd_by_num, plex_by_num)
if self._cache:
await self._cache.set(cache_key, resolved, ttl_seconds=3600)
return resolved
@ -388,14 +434,16 @@ class PlaylistService:
jf_service: object,
local_service: object,
nd_service: object = None,
) -> str:
plex_service: object = None,
) -> tuple[str, str | None]:
"""Return (source_id, plex_rating_key_or_none)."""
if not track.album_id or track.track_number is None:
raise SourceResolutionError(
f"Cannot switch source for track '{track.track_name}': missing album_id or track_number"
)
jf_by_num, local_by_num, nd_by_num = await self._resolve_album_sources(
track.album_id, jf_service, local_service, nd_service,
jf_by_num, local_by_num, nd_by_num, plex_by_num = await self._resolve_album_sources(
track.album_id, jf_service, local_service, nd_service, plex_service,
album_name=track.album_name or "",
artist_name=track.artist_name or "",
)
@ -403,7 +451,7 @@ class PlaylistService:
if new_source_type == "jellyfin":
match_info = jf_by_num.get(track.track_number)
if match_info and _fuzzy_name_match(track.track_name, match_info[0]):
return match_info[1]
return (match_info[1], None)
raise SourceResolutionError(
f"Track '{track.track_name}' not found in Jellyfin for album {track.album_id}"
)
@ -411,7 +459,7 @@ class PlaylistService:
if new_source_type == "local":
match_info = local_by_num.get(track.track_number)
if match_info and _fuzzy_name_match(track.track_name, match_info[0]):
return match_info[1]
return (match_info[1], None)
raise SourceResolutionError(
f"Track '{track.track_name}' not found in local files for album {track.album_id}"
)
@ -419,11 +467,19 @@ class PlaylistService:
if new_source_type == "navidrome":
match_info = nd_by_num.get(track.track_number)
if match_info and _fuzzy_name_match(track.track_name, match_info[0]):
return match_info[1]
return (match_info[1], None)
raise SourceResolutionError(
f"Track '{track.track_name}' not found in Navidrome for album {track.album_id}"
)
if new_source_type == "plex":
match_info = plex_by_num.get(track.track_number)
if match_info and _fuzzy_name_match(track.track_name, match_info[0]):
return (match_info[1], match_info[2])
raise SourceResolutionError(
f"Track '{track.track_name}' not found in Plex for album {track.album_id}"
)
raise SourceResolutionError(f"Unsupported source type for resolution: {new_source_type}")
@ -438,7 +494,7 @@ class PlaylistService:
f"Invalid image type. Allowed: {', '.join(ALLOWED_IMAGE_TYPES)}"
)
if len(data) > MAX_COVER_SIZE:
raise InvalidPlaylistDataError("Image too large. Maximum size is 2 MB") # defence-in-depth
raise InvalidPlaylistDataError("Image too large. Maximum size is 2 MB")
ext = _MIME_TO_EXT.get(content_type, ".jpg")
file_path = self._cover_dir / f"{playlist_id}{ext}"

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,71 @@
from __future__ import annotations
import logging
from fastapi.responses import Response, StreamingResponse
from infrastructure.cache.memory_cache import CacheInterface
from repositories.plex_models import StreamProxyResult
from repositories.protocols.plex import PlexRepositoryProtocol
logger = logging.getLogger(__name__)
class PlexPlaybackService:
def __init__(
self,
plex_repo: PlexRepositoryProtocol,
cache: CacheInterface | None = None,
) -> None:
self._plex = plex_repo
self._cache = cache
async def proxy_head(self, part_key: str) -> Response:
result: StreamProxyResult = await self._plex.proxy_head_stream(part_key)
return Response(status_code=result.status_code, headers=result.headers)
async def proxy_stream(
self, part_key: str, range_header: str | None = None
) -> StreamingResponse:
result: StreamProxyResult = await self._plex.proxy_get_stream(
part_key, range_header=range_header
)
return StreamingResponse(
content=result.body_chunks,
status_code=result.status_code,
headers=result.headers,
media_type=result.media_type,
)
async def scrobble(self, rating_key: str) -> bool:
try:
ok = await self._plex.scrobble(rating_key)
if self._cache:
await self._cache.delete("plex:sessions")
return ok
except Exception: # noqa: BLE001
logger.warning("Plex scrobble failed for %s", rating_key, exc_info=True)
return False
async def report_now_playing(self, rating_key: str) -> bool:
try:
ok = await self._plex.now_playing(rating_key)
if self._cache:
await self._cache.delete("plex:sessions")
return ok
except Exception: # noqa: BLE001
logger.warning("Plex now-playing failed for %s", rating_key, exc_info=True)
return False
async def report_stopped(self, rating_key: str) -> bool:
try:
ok = await self._plex.now_playing(rating_key, state="stopped")
if self._cache:
await self._cache.delete("plex:sessions")
return ok
except Exception: # noqa: BLE001
logger.warning("Plex stopped report failed for %s", rating_key, exc_info=True)
return False
async def proxy_thumb(self, rating_key: str, size: int = 500) -> tuple[bytes, str]:
return await self._plex.proxy_thumb(rating_key, size=size)

View file

@ -19,6 +19,8 @@ from api.v1.schemas.settings import (
LASTFM_SECRET_MASK,
NavidromeConnectionSettings,
NAVIDROME_PASSWORD_MASK,
PlexConnectionSettings,
PLEX_TOKEN_MASK,
)
from api.v1.schemas.profile import ProfileSettings
from api.v1.schemas.advanced_settings import AdvancedSettings
@ -219,6 +221,53 @@ class PreferencesService:
logger.error("Failed to save Navidrome connection settings: %s", e)
raise ConfigurationError(f"Failed to save Navidrome connection settings: {e}")
def get_plex_connection(self) -> PlexConnectionSettings:
config = self._load_config()
plex_data = config.get("plex_settings", {})
settings = PlexConnectionSettings(
plex_url=plex_data.get("plex_url", ""),
plex_token=plex_data.get("plex_token", ""),
enabled=plex_data.get("enabled", False),
music_library_ids=plex_data.get("music_library_ids", []),
scrobble_to_plex=plex_data.get("scrobble_to_plex", True),
)
if settings.plex_token:
settings.plex_token = PLEX_TOKEN_MASK
return settings
def get_plex_connection_raw(self) -> PlexConnectionSettings:
config = self._load_config()
plex_data = config.get("plex_settings", {})
return PlexConnectionSettings(
plex_url=plex_data.get("plex_url", ""),
plex_token=plex_data.get("plex_token", ""),
enabled=plex_data.get("enabled", False),
music_library_ids=plex_data.get("music_library_ids", []),
scrobble_to_plex=plex_data.get("scrobble_to_plex", True),
)
def save_plex_connection(self, settings: PlexConnectionSettings) -> None:
try:
config = self._load_config().copy()
current_data = config.get("plex_settings", {})
token = settings.plex_token
if token == PLEX_TOKEN_MASK:
token = current_data.get("plex_token", "")
config["plex_settings"] = {
"plex_url": settings.plex_url,
"plex_token": token,
"enabled": settings.enabled,
"music_library_ids": settings.music_library_ids,
"scrobble_to_plex": settings.scrobble_to_plex,
}
self._save_config(config)
logger.info("Saved Plex connection settings to %s", self._config_path)
except Exception as e: # noqa: BLE001
logger.error("Failed to save Plex connection settings: %s", e)
raise ConfigurationError(f"Failed to save Plex connection settings: {e}")
def get_listenbrainz_connection(self) -> ListenBrainzConnectionSettings:
config = self._load_config()
lb_data = config.get("listenbrainz_settings", {})
@ -388,3 +437,19 @@ class PreferencesService:
internal[key] = value
config["_internal"] = internal
self._save_config(config)
def get_or_create_setting(self, key: str, factory: Any) -> Any:
"""Atomically get or create an internal setting under the cache lock."""
with self._cache_lock:
config = self._load_config()
internal = config.get("_internal", {})
value = internal.get(key)
if value:
return value
value = factory() if callable(factory) else factory
config = config.copy()
internal = internal.copy()
internal[key] = value
config["_internal"] = internal
self._save_config(config)
return value

View file

@ -16,6 +16,8 @@ from api.v1.schemas.settings import (
LidarrRootFolderSummary,
NAVIDROME_PASSWORD_MASK,
LASTFM_SECRET_MASK,
PlexConnectionSettings,
PLEX_TOKEN_MASK,
)
from core.config import Settings, get_settings
from core.exceptions import ExternalServiceError
@ -25,6 +27,7 @@ from infrastructure.cache.cache_keys import (
JELLYFIN_PREFIX,
LOCAL_FILES_PREFIX,
SOURCE_RESOLUTION_PREFIX,
PLEX_PREFIX,
musicbrainz_prefixes,
listenbrainz_prefixes,
lastfm_prefixes,
@ -53,6 +56,12 @@ class NavidromeVerifyResult(msgspec.Struct):
message: str
class PlexVerifyResult(msgspec.Struct):
valid: bool
message: str
libraries: list[tuple[str, str]] = []
class YouTubeVerifyResult(msgspec.Struct):
valid: bool
message: str
@ -135,11 +144,11 @@ class SettingsService:
detail = str(e)
logger.warning(f"Lidarr connection test failed: {detail}")
if "No address associated with hostname" in detail or "Name or service not known" in detail:
hint = "DNS resolution failed — check the hostname is reachable from inside the container"
hint = "DNS resolution failed. Check that the hostname is reachable from inside the container"
elif "Connection refused" in detail:
hint = "Connection refused — check the port and that Lidarr is running"
hint = "Connection refused. Check the port and make sure Lidarr is running"
elif "timed out" in detail.lower() or "timeout" in detail.lower():
hint = "Connection timed out — check network/firewall settings"
hint = "Connection timed out. Check your network and firewall settings"
else:
hint = detail
return LidarrVerifyResponse(
@ -149,7 +158,7 @@ class SettingsService:
metadata_profiles=[],
root_folders=[]
)
except Exception as e:
except Exception as e: # noqa: BLE001
logger.exception(f"Failed to verify Lidarr connection: {e}")
return LidarrVerifyResponse(
success=False,
@ -187,7 +196,7 @@ class SettingsService:
users = [JellyfinUser(id=u.id, name=u.name) for u in jf_users]
return JellyfinVerifyResult(success=success, message=message, users=users)
except Exception as e:
except Exception as e: # noqa: BLE001
logger.exception(f"Failed to verify Jellyfin connection: {e}")
return JellyfinVerifyResult(
success=False,
@ -216,7 +225,7 @@ class SettingsService:
valid, message = await temp_repo.validate_username(settings.username)
return ListenBrainzVerifyResult(valid=valid, message=message)
except Exception as e:
except Exception as e: # noqa: BLE001
logger.exception(f"Failed to verify ListenBrainz connection: {e}")
return ListenBrainzVerifyResult(
valid=False,
@ -254,8 +263,6 @@ class SettingsService:
logger.info(f"Cleared {cleared} source-resolution cache entries")
return cleared
# Lifecycle methods — one per integration settings change
async def on_jellyfin_settings_changed(self) -> None:
"""Full cache/state reset when Jellyfin settings change."""
from repositories.jellyfin_repository import JellyfinRepository
@ -514,8 +521,6 @@ class SettingsService:
logger.info(f"Updated Lidarr metadata profile '{result.get('name')}' (ID: {resolved_id})")
return self._lidarr_profile_to_preferences(result)
# Verify methods — Navidrome, YouTube, Last.fm
async def verify_navidrome(
self, settings: NavidromeConnectionSettings
) -> NavidromeVerifyResult:
@ -553,7 +558,7 @@ class SettingsService:
valid=False,
message="Navidrome didn't respond. Check the URL and credentials.",
)
except Exception as e:
except Exception as e: # noqa: BLE001
logger.exception("Failed to verify Navidrome connection: %s", e)
return NavidromeVerifyResult(
valid=False,
@ -575,7 +580,7 @@ class SettingsService:
)
valid, message = await temp_repo.verify_api_key(settings.api_key.strip())
return YouTubeVerifyResult(valid=valid, message=message)
except Exception as e:
except Exception as e: # noqa: BLE001
logger.exception("Failed to verify YouTube connection: %s", e)
return YouTubeVerifyResult(
valid=False,
@ -621,8 +626,102 @@ class SettingsService:
return LastFmVerifyResult(valid=True, message=session_message)
return LastFmVerifyResult(valid=valid, message=message)
except Exception as e:
except Exception as e: # noqa: BLE001
logger.exception("Failed to verify Last.fm connection: %s", e)
return LastFmVerifyResult(
valid=False, message="Couldn't finish the Last.fm connection test"
)
async def verify_plex(
self, settings: PlexConnectionSettings
) -> PlexVerifyResult:
try:
from infrastructure.validators import validate_service_url
validate_service_url(settings.plex_url, label="Plex URL")
from repositories.plex_repository import PlexRepository
PlexRepository.reset_circuit_breaker()
app_settings = get_settings()
http_client = get_http_client(app_settings)
temp_cache = InMemoryCache(max_entries=100)
token = settings.plex_token
if token == PLEX_TOKEN_MASK:
raw = self._preferences_service.get_plex_connection_raw()
token = raw.plex_token
client_id = self._preferences_service.get_setting("plex_client_id") or ""
temp_repo = PlexRepository(http_client=http_client, cache=temp_cache)
temp_repo.configure(
url=settings.plex_url,
token=token,
client_id=client_id,
)
ok, message = await temp_repo.validate_connection()
libs: list[tuple[str, str]] = []
if ok:
try:
sections = await temp_repo.get_music_libraries()
libs = [(s.key, s.title) for s in sections]
except Exception: # noqa: BLE001
logger.warning("Plex verify succeeded but library fetch failed")
return PlexVerifyResult(valid=ok, message=message, libraries=libs)
except Exception as e: # noqa: BLE001
logger.exception("Failed to verify Plex connection: %s", e)
return PlexVerifyResult(
valid=False,
message="Couldn't finish the Plex connection test",
)
async def on_plex_settings_changed(self, enabled: bool = False) -> None:
"""Full cache/state reset when Plex settings change."""
from repositories.plex_repository import PlexRepository
from core.dependencies import (
get_plex_repository, get_plex_library_service,
get_plex_playback_service, get_home_service,
get_home_charts_service, get_mbid_store,
)
PlexRepository.reset_circuit_breaker()
get_plex_repository.cache_clear()
get_plex_library_service.cache_clear()
get_plex_playback_service.cache_clear()
get_home_service.cache_clear()
get_home_charts_service.cache_clear()
mbid_store = get_mbid_store()
await mbid_store.clear_plex_mbid_indexes()
new_repo = get_plex_repository()
await new_repo.clear_cache()
await self.clear_home_cache()
await self.clear_source_resolution_cache()
if enabled:
import asyncio
from core.tasks import warm_plex_mbid_cache
from core.task_registry import TaskRegistry
registry = TaskRegistry.get_instance()
if not registry.is_running("plex-mbid-warmup"):
_plex_task = asyncio.create_task(warm_plex_mbid_cache())
try:
registry.register("plex-mbid-warmup", _plex_task)
except RuntimeError:
pass
logger.info("Plex settings change: all caches/singletons reset")
async def get_plex_libraries(self) -> list[tuple[str, str]]:
raw = self._preferences_service.get_plex_connection_raw()
if not raw.plex_url or not raw.plex_token:
raise ValueError("Plex is not configured")
from repositories.plex_repository import PlexRepository
app_settings = get_settings()
http_client = get_http_client(app_settings)
temp_cache = InMemoryCache(max_entries=100)
client_id = self._preferences_service.get_setting("plex_client_id") or ""
temp_repo = PlexRepository(http_client=http_client, cache=temp_cache)
temp_repo.configure(url=raw.plex_url, token=raw.plex_token, client_id=client_id)
sections = await temp_repo.get_music_libraries()
return [(s.key, s.title) for s in sections]

View file

@ -0,0 +1,836 @@
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from core.exceptions import ExternalServiceError, PlexApiError, PlexAuthError
from repositories.plex_models import (
PlexAlbum,
PlexArtist,
PlexLibrarySection,
PlexOAuthPin,
PlexTrack,
StreamProxyResult,
)
from repositories.plex_repository import PlexRepository, _plex_circuit_breaker
def _make_cache() -> MagicMock:
cache = AsyncMock()
cache.get = AsyncMock(return_value=None)
cache.set = AsyncMock()
cache.clear_prefix = AsyncMock(return_value=0)
return cache
def _make_repo(configured: bool = True) -> tuple[PlexRepository, AsyncMock, MagicMock]:
client = AsyncMock(spec=httpx.AsyncClient)
cache = _make_cache()
repo = PlexRepository(http_client=client, cache=cache)
if configured:
repo.configure("http://plex:32400", "test-token", "client-id-123")
_plex_circuit_breaker.reset()
return repo, client, cache
def _mock_response(
json_data: dict | None = None,
status_code: int = 200,
content: bytes = b"",
content_type: str = "application/json",
) -> MagicMock:
resp = MagicMock(spec=httpx.Response)
resp.status_code = status_code
resp.json.return_value = json_data or {}
resp.content = content
resp.headers = {"content-type": content_type}
return resp
def _plex_container(metadata: list | None = None, total_size: int | None = None, directory: list | None = None) -> dict:
container: dict = {"MediaContainer": {}}
if metadata is not None:
container["MediaContainer"]["Metadata"] = metadata
if total_size is not None:
container["MediaContainer"]["totalSize"] = total_size
if directory is not None:
container["MediaContainer"]["Directory"] = directory
return container
def _plex_hub_container(
albums: list | None = None,
tracks: list | None = None,
artists: list | None = None,
) -> dict:
"""Build a /hubs/search style response with typed Hub[] arrays."""
hubs: list[dict] = []
for hub_type, items in [("album", albums), ("track", tracks), ("artist", artists)]:
hub: dict = {"type": hub_type, "size": len(items) if items else 0}
if items:
hub["Metadata"] = items
hubs.append(hub)
return {"MediaContainer": {"Hub": hubs}}
class TestConfigure:
def test_configure_sets_state(self):
repo, _, _ = _make_repo(configured=False)
assert repo.is_configured() is False
repo.configure("http://plex:32400", "my-token", "my-client")
assert repo.is_configured() is True
def test_configure_strips_trailing_slash(self):
repo, _, _ = _make_repo(configured=False)
repo.configure("http://plex:32400/", "tok")
assert repo._url == "http://plex:32400"
def test_configure_empty_url_not_configured(self):
repo, _, _ = _make_repo(configured=False)
repo.configure("", "tok")
assert repo.is_configured() is False
def test_configure_empty_token_not_configured(self):
repo, _, _ = _make_repo(configured=False)
repo.configure("http://plex:32400", "")
assert repo.is_configured() is False
class TestBuildHeaders:
def test_contains_required_keys(self):
repo, _, _ = _make_repo()
headers = repo._build_headers()
assert headers["X-Plex-Token"] == "test-token"
assert headers["X-Plex-Product"] == "MusicSeerr"
assert headers["X-Plex-Version"] == "1.0"
assert headers["Accept"] == "application/json"
def test_client_identifier_included_when_set(self):
repo, _, _ = _make_repo()
headers = repo._build_headers()
assert headers["X-Plex-Client-Identifier"] == "client-id-123"
def test_client_identifier_omitted_when_empty(self):
repo, _, _ = _make_repo(configured=False)
repo.configure("http://plex:32400", "tok", "")
headers = repo._build_headers()
assert "X-Plex-Client-Identifier" not in headers
class TestCacheTTLs:
def test_configure_cache_ttls_sets_values(self):
repo, _, _ = _make_repo()
repo.configure_cache_ttls(list_ttl=60, search_ttl=30, genres_ttl=120, detail_ttl=90, stats_ttl=45)
assert repo._ttl_list == 60
assert repo._ttl_search == 30
assert repo._ttl_genres == 120
assert repo._ttl_detail == 90
assert repo._ttl_stats == 45
def test_configure_cache_ttls_none_keeps_defaults(self):
repo, _, _ = _make_repo()
original_list = repo._ttl_list
repo.configure_cache_ttls()
assert repo._ttl_list == original_list
class TestPing:
@pytest.mark.asyncio
async def test_ping_success(self):
repo, client, _ = _make_repo()
client.get = AsyncMock(return_value=_mock_response(status_code=200))
assert await repo.ping() is True
@pytest.mark.asyncio
async def test_ping_failure_status(self):
repo, client, _ = _make_repo()
client.get = AsyncMock(return_value=_mock_response(status_code=500))
assert await repo.ping() is False
@pytest.mark.asyncio
async def test_ping_exception(self):
repo, client, _ = _make_repo()
client.get = AsyncMock(side_effect=httpx.ConnectError("refused"))
assert await repo.ping() is False
@pytest.mark.asyncio
async def test_ping_not_configured(self):
repo, _, _ = _make_repo(configured=False)
assert await repo.ping() is False
class TestGetLibraries:
@pytest.mark.asyncio
async def test_returns_sections(self):
repo, client, cache = _make_repo()
response_data = _plex_container(directory=[
{"key": "1", "title": "Music", "type": "artist"},
{"key": "2", "title": "Movies", "type": "movie"},
])
client.get = AsyncMock(return_value=_mock_response(json_data=response_data))
sections = await repo.get_libraries()
assert len(sections) >= 1
cache.set.assert_awaited_once()
@pytest.mark.asyncio
async def test_returns_cached(self):
repo, client, cache = _make_repo()
cached_sections = [PlexLibrarySection(key="1", title="Music", type="artist")]
cache.get = AsyncMock(return_value=cached_sections)
result = await repo.get_libraries()
assert result == cached_sections
client.get.assert_not_called()
class TestGetMusicLibraries:
@pytest.mark.asyncio
async def test_filters_to_artist_type(self):
repo, client, cache = _make_repo()
response_data = _plex_container(directory=[
{"key": "1", "title": "Music", "type": "artist"},
{"key": "2", "title": "Movies", "type": "movie"},
])
client.get = AsyncMock(return_value=_mock_response(json_data=response_data))
sections = await repo.get_music_libraries()
assert all(s.type == "artist" for s in sections)
class TestGetAlbums:
@pytest.mark.asyncio
async def test_returns_albums_and_total(self):
repo, client, cache = _make_repo()
response_data = _plex_container(
metadata=[
{"ratingKey": "100", "title": "Album1", "type": "album", "parentTitle": "Artist1"},
],
total_size=42,
)
client.get = AsyncMock(return_value=_mock_response(json_data=response_data))
albums, total = await repo.get_albums("1")
assert len(albums) == 1
assert total == 42
@pytest.mark.asyncio
async def test_empty_response(self):
repo, client, _ = _make_repo()
response_data = _plex_container(metadata=[], total_size=0)
client.get = AsyncMock(return_value=_mock_response(json_data=response_data))
albums, total = await repo.get_albums("1")
assert albums == []
assert total == 0
@pytest.mark.asyncio
async def test_uses_cache(self):
repo, client, cache = _make_repo()
cached = ([PlexAlbum(ratingKey="100", title="Cached")], 1)
cache.get = AsyncMock(return_value=cached)
result = await repo.get_albums("1")
assert result == cached
client.get.assert_not_called()
class TestGetArtists:
@pytest.mark.asyncio
async def test_returns_artists(self):
repo, client, _ = _make_repo()
response_data = _plex_container(metadata=[
{"ratingKey": "50", "title": "Artist1", "type": "artist"},
])
client.get = AsyncMock(return_value=_mock_response(json_data=response_data))
artists = await repo.get_artists("1")
assert len(artists) == 1
@pytest.mark.asyncio
async def test_empty(self):
repo, client, _ = _make_repo()
response_data = _plex_container(metadata=[])
client.get = AsyncMock(return_value=_mock_response(json_data=response_data))
artists = await repo.get_artists("1")
assert artists == []
class TestGetTrackCount:
@pytest.mark.asyncio
async def test_returns_total(self):
repo, client, _ = _make_repo()
response_data = _plex_container(total_size=500)
client.get = AsyncMock(return_value=_mock_response(json_data=response_data))
count = await repo.get_track_count("1")
assert count == 500
class TestGetArtistCount:
@pytest.mark.asyncio
async def test_returns_total(self):
repo, client, _ = _make_repo()
response_data = _plex_container(total_size=42)
client.get = AsyncMock(return_value=_mock_response(json_data=response_data))
count = await repo.get_artist_count("1")
assert count == 42
@pytest.mark.asyncio
async def test_uses_cache(self):
repo, client, cache = _make_repo()
cache.get = AsyncMock(return_value=10)
count = await repo.get_artist_count("1")
assert count == 10
client.get.assert_not_called()
class TestGetGenres:
@pytest.mark.asyncio
async def test_returns_genre_titles(self):
repo, client, _ = _make_repo()
response_data = _plex_container(directory=[
{"title": "Rock"},
{"title": "Jazz"},
{"title": ""},
])
client.get = AsyncMock(return_value=_mock_response(json_data=response_data))
genres = await repo.get_genres("1")
assert "Rock" in genres
assert "Jazz" in genres
assert "" not in genres
class TestGetRecentlyViewed:
@pytest.mark.asyncio
async def test_returns_albums_with_lastviewedat(self):
repo, client, _ = _make_repo()
response_data = _plex_container(metadata=[
{"ratingKey": "10", "title": "Played Album", "parentTitle": "Artist", "lastViewedAt": 1700000000},
])
client.get = AsyncMock(return_value=_mock_response(json_data=response_data))
albums = await repo.get_recently_viewed("1", limit=5)
assert len(albums) == 1
assert albums[0].title == "Played Album"
assert albums[0].lastViewedAt == 1700000000
@pytest.mark.asyncio
async def test_empty_when_no_history(self):
repo, client, _ = _make_repo()
response_data = _plex_container(metadata=[])
client.get = AsyncMock(return_value=_mock_response(json_data=response_data))
albums = await repo.get_recently_viewed("1")
assert albums == []
@pytest.mark.asyncio
async def test_calls_recentlyviewed_endpoint(self):
repo, client, _ = _make_repo()
response_data = _plex_container(metadata=[])
client.get = AsyncMock(return_value=_mock_response(json_data=response_data))
await repo.get_recently_viewed("1")
call_args = client.get.call_args
assert "recentlyViewed" in str(call_args)
class TestSearch:
@pytest.mark.asyncio
async def test_categorizes_results(self):
repo, client, _ = _make_repo()
response_data = _plex_hub_container(
albums=[{"ratingKey": "1", "title": "Album X", "type": "album", "parentTitle": "A"}],
tracks=[{"ratingKey": "2", "title": "Track Y", "type": "track"}],
artists=[{"ratingKey": "3", "title": "Artist Z", "type": "artist"}],
)
client.get = AsyncMock(return_value=_mock_response(json_data=response_data))
result = await repo.search("test", section_id="1")
assert len(result["albums"]) == 1
assert len(result["tracks"]) == 1
assert len(result["artists"]) == 1
@pytest.mark.asyncio
async def test_global_search_without_section(self):
repo, client, _ = _make_repo()
response_data = _plex_hub_container()
client.get = AsyncMock(return_value=_mock_response(json_data=response_data))
await repo.search("test")
call_args = client.get.call_args
assert "/hubs/search" in str(call_args)
class TestScrobble:
@pytest.mark.asyncio
async def test_success(self):
repo, client, _ = _make_repo()
client.get = AsyncMock(return_value=_mock_response(status_code=200))
assert await repo.scrobble("12345") is True
@pytest.mark.asyncio
async def test_failure_status(self):
repo, client, _ = _make_repo()
client.get = AsyncMock(return_value=_mock_response(status_code=500))
assert await repo.scrobble("12345") is False
@pytest.mark.asyncio
async def test_exception_returns_false(self):
repo, client, _ = _make_repo()
client.get = AsyncMock(side_effect=httpx.ConnectError("down"))
assert await repo.scrobble("12345") is False
@pytest.mark.asyncio
async def test_not_configured(self):
repo, _, _ = _make_repo(configured=False)
assert await repo.scrobble("12345") is False
class TestNowPlaying:
@pytest.mark.asyncio
async def test_success(self):
repo, client, _ = _make_repo()
client.get = AsyncMock(return_value=_mock_response(status_code=200))
assert await repo.now_playing("12345") is True
@pytest.mark.asyncio
async def test_exception_returns_false(self):
repo, client, _ = _make_repo()
client.get = AsyncMock(side_effect=httpx.TimeoutException("slow"))
assert await repo.now_playing("12345") is False
@pytest.mark.asyncio
async def test_not_configured(self):
repo, _, _ = _make_repo(configured=False)
assert await repo.now_playing("12345") is False
class TestBuildStreamUrl:
def test_builds_url(self):
repo, _, _ = _make_repo()
track = MagicMock(spec=PlexTrack)
part = MagicMock()
part.key = "/library/parts/100/1234/file.flac"
media = MagicMock()
media.Part = [part]
track.Media = [media]
track.ratingKey = "99"
url = repo.build_stream_url(track)
assert url == "http://plex:32400/library/parts/100/1234/file.flac"
def test_raises_when_no_media(self):
repo, _, _ = _make_repo()
track = MagicMock(spec=PlexTrack)
track.Media = []
track.ratingKey = "99"
with pytest.raises(ValueError, match="no streamable media"):
repo.build_stream_url(track)
def test_raises_when_not_configured(self):
repo, _, _ = _make_repo(configured=False)
track = MagicMock(spec=PlexTrack)
with pytest.raises(ValueError, match="not configured"):
repo.build_stream_url(track)
class TestProxyThumb:
@pytest.mark.asyncio
async def test_returns_bytes_and_content_type(self):
repo, client, _ = _make_repo()
image_bytes = b"\x89PNG\r\n"
resp = _mock_response(status_code=200, content=image_bytes, content_type="image/png")
client.get = AsyncMock(return_value=resp)
content, ctype = await repo.proxy_thumb("123", size=300)
assert content == image_bytes
assert ctype == "image/png"
@pytest.mark.asyncio
async def test_raises_on_non_200(self):
repo, client, _ = _make_repo()
client.get = AsyncMock(return_value=_mock_response(status_code=404))
with pytest.raises(ExternalServiceError, match="failed"):
await repo.proxy_thumb("123")
@pytest.mark.asyncio
async def test_raises_on_timeout(self):
repo, client, _ = _make_repo()
client.get = AsyncMock(side_effect=httpx.TimeoutException("slow"))
with pytest.raises(ExternalServiceError, match="timed out"):
await repo.proxy_thumb("123")
@pytest.mark.asyncio
async def test_raises_when_not_configured(self):
repo, _, _ = _make_repo(configured=False)
with pytest.raises(ExternalServiceError, match="not configured"):
await repo.proxy_thumb("123")
class TestValidateConnection:
@pytest.mark.asyncio
async def test_success(self):
repo, client, _ = _make_repo()
json_data = {"MediaContainer": {"friendlyName": "My Plex", "version": "1.40"}}
client.get = AsyncMock(return_value=_mock_response(json_data=json_data))
ok, msg = await repo.validate_connection()
assert ok is True
assert "My Plex" in msg
assert "1.40" in msg
@pytest.mark.asyncio
async def test_auth_failure(self):
repo, client, _ = _make_repo()
client.get = AsyncMock(return_value=_mock_response(status_code=401))
ok, msg = await repo.validate_connection()
assert ok is False
assert "authentication" in msg.lower() or "token" in msg.lower()
@pytest.mark.asyncio
async def test_timeout(self):
repo, client, _ = _make_repo()
client.get = AsyncMock(side_effect=httpx.TimeoutException("slow"))
ok, msg = await repo.validate_connection()
assert ok is False
assert "timed out" in msg.lower()
@pytest.mark.asyncio
async def test_connect_error(self):
repo, client, _ = _make_repo()
client.get = AsyncMock(side_effect=httpx.ConnectError("Connection refused"))
ok, msg = await repo.validate_connection()
assert ok is False
@pytest.mark.asyncio
async def test_not_configured(self):
repo, _, _ = _make_repo(configured=False)
ok, msg = await repo.validate_connection()
assert ok is False
assert "not configured" in msg.lower()
class TestOAuthPin:
@pytest.mark.asyncio
async def test_create_pin(self):
repo, _, _ = _make_repo()
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 201
mock_response.json.return_value = {"id": 42, "code": "ABCD1234"}
with patch("httpx.AsyncClient") as MockClient:
instance = AsyncMock()
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
instance.post = AsyncMock(return_value=mock_response)
MockClient.return_value = instance
pin = await repo.create_oauth_pin("client-123")
assert pin.id == 42
assert pin.code == "ABCD1234"
@pytest.mark.asyncio
async def test_create_pin_failure(self):
repo, _, _ = _make_repo()
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 500
with patch("httpx.AsyncClient") as MockClient:
instance = AsyncMock()
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
instance.post = AsyncMock(return_value=mock_response)
MockClient.return_value = instance
with pytest.raises(PlexApiError, match="Failed to create"):
await repo.create_oauth_pin("client-123")
@pytest.mark.asyncio
async def test_poll_pin_token_found(self):
repo, _, _ = _make_repo()
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {"authToken": "fresh-token"}
with patch("httpx.AsyncClient") as MockClient:
instance = AsyncMock()
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
instance.get = AsyncMock(return_value=mock_response)
MockClient.return_value = instance
token = await repo.poll_oauth_pin(42, "client-123")
assert token == "fresh-token"
@pytest.mark.asyncio
async def test_poll_pin_not_ready(self):
repo, _, _ = _make_repo()
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {"authToken": ""}
with patch("httpx.AsyncClient") as MockClient:
instance = AsyncMock()
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
instance.get = AsyncMock(return_value=mock_response)
MockClient.return_value = instance
token = await repo.poll_oauth_pin(42, "client-123")
assert token is None
@pytest.mark.asyncio
async def test_poll_pin_non_200(self):
repo, _, _ = _make_repo()
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 404
with patch("httpx.AsyncClient") as MockClient:
instance = AsyncMock()
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
instance.get = AsyncMock(return_value=mock_response)
MockClient.return_value = instance
token = await repo.poll_oauth_pin(42, "client-123")
assert token is None
class TestClearCache:
@pytest.mark.asyncio
async def test_clears_plex_prefix(self):
repo, _, cache = _make_repo()
await repo.clear_cache()
cache.clear_prefix.assert_awaited_once()
class TestCircuitBreaker:
def test_reset_circuit_breaker(self):
PlexRepository.reset_circuit_breaker()
assert _plex_circuit_breaker.failure_count == 0
class TestUnconfigured:
@pytest.mark.asyncio
async def test_request_raises_when_not_configured(self):
repo, _, _ = _make_repo(configured=False)
with pytest.raises(ExternalServiceError, match="not configured"):
await repo._request("/test")
@pytest.mark.asyncio
async def test_proxy_head_raises(self):
repo, _, _ = _make_repo(configured=False)
with pytest.raises(ExternalServiceError, match="not configured"):
await repo.proxy_head_stream("/part/key")
@pytest.mark.asyncio
async def test_proxy_get_raises(self):
repo, _, _ = _make_repo(configured=False)
with pytest.raises(ExternalServiceError, match="not configured"):
await repo.proxy_get_stream("/part/key")
class TestErrorHandling:
@pytest.mark.asyncio
async def test_401_raises_auth_error(self):
repo, client, _ = _make_repo()
client.get = AsyncMock(return_value=_mock_response(status_code=401))
with pytest.raises(PlexAuthError, match="authentication"):
await repo._request("/test")
@pytest.mark.asyncio
async def test_403_raises_auth_error(self):
repo, client, _ = _make_repo()
client.get = AsyncMock(return_value=_mock_response(status_code=403))
with pytest.raises(PlexAuthError, match="authentication"):
await repo._request("/test")
@pytest.mark.asyncio
async def test_500_raises_api_error(self):
repo, client, _ = _make_repo()
client.get = AsyncMock(return_value=_mock_response(status_code=500))
with pytest.raises(PlexApiError, match="failed"):
await repo._request("/test")
@pytest.mark.asyncio
async def test_timeout_raises_external_error(self):
repo, client, _ = _make_repo()
client.get = AsyncMock(side_effect=httpx.TimeoutException("timeout"))
with pytest.raises(ExternalServiceError, match="timed out"):
await repo._request("/test")
@pytest.mark.asyncio
async def test_http_error_raises_external_error(self):
repo, client, _ = _make_repo()
client.get = AsyncMock(side_effect=httpx.ConnectError("refused"))
with pytest.raises(ExternalServiceError, match="failed"):
await repo._request("/test")
@pytest.mark.asyncio
async def test_invalid_json_raises_api_error(self):
repo, client, _ = _make_repo()
resp = _mock_response(status_code=200)
resp.json.side_effect = ValueError("invalid json")
client.get = AsyncMock(return_value=resp)
with pytest.raises(PlexApiError, match="invalid JSON"):
await repo._request("/test")
class TestStreamProxyValidation:
@pytest.mark.asyncio
async def test_head_rejects_traversal(self):
repo, _, _ = _make_repo()
with pytest.raises(ValueError, match="Invalid Plex part key"):
await repo.proxy_head_stream("/library/parts/../../library/sections")
@pytest.mark.asyncio
async def test_get_rejects_traversal(self):
repo, _, _ = _make_repo()
with pytest.raises(ValueError, match="Invalid Plex part key"):
await repo.proxy_get_stream("/library/parts/../../library/sections")
@pytest.mark.asyncio
async def test_head_rejects_non_library_parts_prefix(self):
repo, _, _ = _make_repo()
with pytest.raises(ValueError, match="Invalid Plex part key"):
await repo.proxy_head_stream("/some/other/path")
@pytest.mark.asyncio
async def test_get_rejects_non_library_parts_prefix(self):
repo, _, _ = _make_repo()
with pytest.raises(ValueError, match="Invalid Plex part key"):
await repo.proxy_get_stream("/some/other/path")
@pytest.mark.asyncio
async def test_head_normalises_missing_leading_slash(self):
repo, _, _ = _make_repo()
with patch("repositories.plex_repository.httpx.AsyncClient") as mock_cls:
mock_client = AsyncMock()
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.headers = {"Content-Type": "audio/flac", "Content-Length": "999"}
mock_client.head = AsyncMock(return_value=mock_resp)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_cls.return_value = mock_client
result = await repo.proxy_head_stream("library/parts/100/file.flac")
assert result.status_code == 200
mock_client.head.assert_called_once()
called_url = mock_client.head.call_args[0][0]
assert "/library/parts/100/file.flac" in called_url
@pytest.mark.asyncio
async def test_get_rejects_traversal_without_leading_slash(self):
repo, _, _ = _make_repo()
with pytest.raises(ValueError, match="Invalid Plex part key"):
await repo.proxy_get_stream("library/parts/../../etc/passwd")
@pytest.mark.asyncio
async def test_head_rejects_embedded_double_dot(self):
repo, _, _ = _make_repo()
with pytest.raises(ValueError, match="Invalid Plex part key"):
await repo.proxy_head_stream("/library/parts/100/../../../etc/passwd")
class TestGetStreamProxy:
@pytest.mark.asyncio
async def test_get_stream_happy_path(self):
repo, _, _ = _make_repo()
with patch("repositories.plex_repository.httpx.AsyncClient") as mock_cls:
mock_client = AsyncMock()
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.headers = {"Content-Type": "audio/flac", "Content-Length": "5000"}
mock_resp.aiter_bytes = MagicMock(return_value=AsyncMock().__aiter__())
mock_resp.aclose = AsyncMock()
mock_client.build_request = MagicMock(return_value=MagicMock())
mock_client.send = AsyncMock(return_value=mock_resp)
mock_client.aclose = AsyncMock()
mock_cls.return_value = mock_client
result = await repo.proxy_get_stream("/library/parts/200/file.flac")
assert result.status_code == 200
assert result.headers.get("Content-Type") == "audio/flac"
@pytest.mark.asyncio
async def test_get_stream_206_partial(self):
repo, _, _ = _make_repo()
with patch("repositories.plex_repository.httpx.AsyncClient") as mock_cls:
mock_client = AsyncMock()
mock_resp = MagicMock()
mock_resp.status_code = 206
mock_resp.headers = {
"Content-Type": "audio/flac",
"Content-Range": "bytes 0-999/5000",
"Content-Length": "1000",
}
mock_resp.aiter_bytes = MagicMock(return_value=AsyncMock().__aiter__())
mock_resp.aclose = AsyncMock()
mock_client.build_request = MagicMock(return_value=MagicMock())
mock_client.send = AsyncMock(return_value=mock_resp)
mock_client.aclose = AsyncMock()
mock_cls.return_value = mock_client
result = await repo.proxy_get_stream(
"/library/parts/200/file.flac", range_header="bytes=0-999"
)
assert result.status_code == 206
@pytest.mark.asyncio
async def test_get_stream_416_raises(self):
repo, _, _ = _make_repo()
with patch("repositories.plex_repository.httpx.AsyncClient") as mock_cls:
mock_client = AsyncMock()
mock_resp = MagicMock()
mock_resp.status_code = 416
mock_resp.headers = {}
mock_resp.aclose = AsyncMock()
mock_client.build_request = MagicMock(return_value=MagicMock())
mock_client.send = AsyncMock(return_value=mock_resp)
mock_client.aclose = AsyncMock()
mock_cls.return_value = mock_client
with pytest.raises(ExternalServiceError, match="416"):
await repo.proxy_get_stream("/library/parts/200/file.flac")
@pytest.mark.asyncio
async def test_get_stream_transport_error_raises_external(self):
repo, _, _ = _make_repo()
with patch("repositories.plex_repository.httpx.AsyncClient") as mock_cls:
mock_client = AsyncMock()
mock_client.build_request = MagicMock(return_value=MagicMock())
mock_client.send = AsyncMock(
side_effect=httpx.ConnectError("connection refused")
)
mock_client.aclose = AsyncMock()
mock_cls.return_value = mock_client
with pytest.raises(ExternalServiceError, match="Plex streaming failed"):
await repo.proxy_get_stream("/library/parts/200/file.flac")
@pytest.mark.asyncio
async def test_get_stream_timeout_raises_external(self):
repo, _, _ = _make_repo()
with patch("repositories.plex_repository.httpx.AsyncClient") as mock_cls:
mock_client = AsyncMock()
mock_client.build_request = MagicMock(return_value=MagicMock())
mock_client.send = AsyncMock(
side_effect=httpx.ReadTimeout("read timed out")
)
mock_client.aclose = AsyncMock()
mock_cls.return_value = mock_client
with pytest.raises(ExternalServiceError, match="Plex streaming failed"):
await repo.proxy_get_stream("/library/parts/200/file.flac")
@pytest.mark.asyncio
async def test_get_stream_range_header_forwarded(self):
repo, _, _ = _make_repo()
with patch("repositories.plex_repository.httpx.AsyncClient") as mock_cls:
mock_client = AsyncMock()
mock_resp = MagicMock()
mock_resp.status_code = 206
mock_resp.headers = {"Content-Type": "audio/flac"}
mock_resp.aiter_bytes = MagicMock(return_value=AsyncMock().__aiter__())
mock_resp.aclose = AsyncMock()
mock_client.build_request = MagicMock(return_value=MagicMock())
mock_client.send = AsyncMock(return_value=mock_resp)
mock_client.aclose = AsyncMock()
mock_cls.return_value = mock_client
await repo.proxy_get_stream(
"/library/parts/200/file.flac", range_header="bytes=100-200"
)
build_call = mock_client.build_request.call_args
headers = build_call[1].get("headers") or build_call[0][2] if len(build_call[0]) > 2 else build_call[1].get("headers", {})
assert "Range" in headers
assert headers["Range"] == "bytes=100-200"

View file

@ -0,0 +1,153 @@
"""Route tests for discovery endpoints across Navidrome, Jellyfin, and Plex."""
from __future__ import annotations
import os
import tempfile
os.environ.setdefault("ROOT_APP_DIR", tempfile.mkdtemp())
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api.v1.routes.navidrome_library import router as navidrome_router
from api.v1.routes.jellyfin_library import router as jellyfin_router
from api.v1.routes.plex_library import router as plex_router
from api.v1.schemas.navidrome import NavidromeTrackInfo
from api.v1.schemas.jellyfin import JellyfinTrackInfo
from api.v1.schemas.plex import PlexDiscoveryAlbum, PlexDiscoveryHub, PlexDiscoveryResponse
from core.dependencies import (
get_navidrome_library_service,
get_jellyfin_library_service,
get_plex_library_service,
get_plex_repository,
)
def _nd_track(id: str = "t1", title: str = "Track") -> NavidromeTrackInfo:
return NavidromeTrackInfo(navidrome_id=id, title=title, track_number=1, duration_seconds=200.0)
def _jf_track(id: str = "jt1", title: str = "JTrack") -> JellyfinTrackInfo:
return JellyfinTrackInfo(
jellyfin_id=id, title=title, track_number=1, duration_seconds=180.0,
album_name="Album", artist_name="Artist",
)
class TestNavidromeRandomRoute:
@pytest.fixture
def _setup(self):
self.mock_svc = MagicMock()
self.mock_svc.get_random_songs = AsyncMock(return_value=[_nd_track()])
app = FastAPI()
app.include_router(navidrome_router)
app.dependency_overrides[get_navidrome_library_service] = lambda: self.mock_svc
self.client = TestClient(app)
def test_random_default(self, _setup):
resp = self.client.get("/navidrome/random")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["navidrome_id"] == "t1"
def test_random_with_params(self, _setup):
self.mock_svc.get_random_songs = AsyncMock(return_value=[_nd_track(), _nd_track(id="t2")])
resp = self.client.get("/navidrome/random?size=5&genre=Rock")
assert resp.status_code == 200
self.mock_svc.get_random_songs.assert_awaited_once_with(size=5, genre="Rock")
def test_random_empty(self, _setup):
self.mock_svc.get_random_songs = AsyncMock(return_value=[])
resp = self.client.get("/navidrome/random")
assert resp.status_code == 200
assert resp.json() == []
class TestJellyfinInstantMixRoutes:
@pytest.fixture
def _setup(self):
self.mock_svc = MagicMock()
self.mock_svc.get_instant_mix = AsyncMock(return_value=[_jf_track()])
self.mock_svc.get_instant_mix_by_artist = AsyncMock(return_value=[_jf_track()])
self.mock_svc.get_instant_mix_by_genre = AsyncMock(return_value=[_jf_track()])
app = FastAPI()
app.include_router(jellyfin_router)
app.dependency_overrides[get_jellyfin_library_service] = lambda: self.mock_svc
self.client = TestClient(app)
def test_instant_mix_by_item(self, _setup):
resp = self.client.get("/jellyfin/instant-mix/album-123")
assert resp.status_code == 200
assert len(resp.json()) == 1
self.mock_svc.get_instant_mix.assert_awaited_once_with("album-123", limit=50)
def test_instant_mix_by_item_custom_limit(self, _setup):
resp = self.client.get("/jellyfin/instant-mix/item-1?limit=10")
assert resp.status_code == 200
self.mock_svc.get_instant_mix.assert_awaited_once_with("item-1", limit=10)
def test_instant_mix_by_artist(self, _setup):
resp = self.client.get("/jellyfin/instant-mix/artist/art-456")
assert resp.status_code == 200
assert len(resp.json()) == 1
self.mock_svc.get_instant_mix_by_artist.assert_awaited_once_with("art-456", limit=50)
def test_instant_mix_by_genre(self, _setup):
resp = self.client.get("/jellyfin/instant-mix/genre?genre=Rock")
assert resp.status_code == 200
self.mock_svc.get_instant_mix_by_genre.assert_awaited_once_with("Rock", limit=50)
def test_instant_mix_by_genre_missing_param(self, _setup):
resp = self.client.get("/jellyfin/instant-mix/genre")
assert resp.status_code == 422
def test_instant_mix_empty(self, _setup):
self.mock_svc.get_instant_mix = AsyncMock(return_value=[])
resp = self.client.get("/jellyfin/instant-mix/no-tracks")
assert resp.status_code == 200
assert resp.json() == []
class TestPlexDiscoveryRoute:
@pytest.fixture
def _setup(self):
self.mock_svc = MagicMock()
self.mock_svc.get_discovery_hubs = AsyncMock(return_value=PlexDiscoveryResponse(
hubs=[PlexDiscoveryHub(
title="Recommended",
hub_type="album",
albums=[PlexDiscoveryAlbum(
plex_id="p1", name="Album", artist_name="Artist",
year=2024, image_url="/cover",
)],
)]
))
self.mock_repo = MagicMock()
app = FastAPI()
app.include_router(plex_router)
app.dependency_overrides[get_plex_library_service] = lambda: self.mock_svc
app.dependency_overrides[get_plex_repository] = lambda: self.mock_repo
self.client = TestClient(app)
def test_discovery_returns_hubs(self, _setup):
resp = self.client.get("/plex/discovery")
assert resp.status_code == 200
data = resp.json()
assert len(data["hubs"]) == 1
assert data["hubs"][0]["title"] == "Recommended"
assert data["hubs"][0]["albums"][0]["plex_id"] == "p1"
def test_discovery_empty(self, _setup):
self.mock_svc.get_discovery_hubs = AsyncMock(return_value=PlexDiscoveryResponse(hubs=[]))
resp = self.client.get("/plex/discovery")
assert resp.status_code == 200
assert resp.json()["hubs"] == []
def test_discovery_custom_count(self, _setup):
resp = self.client.get("/plex/discovery?count=5")
assert resp.status_code == 200
self.mock_svc.get_discovery_hubs.assert_awaited_once_with(count=5)

View file

@ -0,0 +1,155 @@
"""Route tests for now-playing and session endpoints."""
from __future__ import annotations
import os
import tempfile
os.environ.setdefault("ROOT_APP_DIR", tempfile.mkdtemp())
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api.v1.routes.plex_library import router as plex_router
from api.v1.routes.navidrome_library import router as navidrome_router
from api.v1.routes.jellyfin_library import router as jellyfin_router
from api.v1.schemas.plex import PlexSessionInfo, PlexSessionsResponse
from api.v1.schemas.navidrome import NavidromeNowPlayingEntrySchema, NavidromeNowPlayingResponse
from api.v1.schemas.jellyfin import JellyfinSessionInfo, JellyfinSessionsResponse
from core.dependencies import (
get_plex_library_service,
get_navidrome_library_service,
get_jellyfin_library_service,
get_plex_repository,
)
class TestPlexSessionsRoute:
@pytest.fixture
def _setup(self):
self.mock_svc = MagicMock()
self.mock_svc.get_sessions = AsyncMock(return_value=PlexSessionsResponse(sessions=[
PlexSessionInfo(
session_id="s1",
user_name="alice",
track_title="Song A",
artist_name="Artist A",
album_name="Album A",
cover_url="/api/v1/plex/thumb/200",
player_device="iPhone",
player_platform="iOS",
player_state="playing",
is_direct_play=True,
progress_ms=60000,
duration_ms=180000,
audio_codec="flac",
audio_channels=2,
bitrate=1411,
)
]))
app = FastAPI()
app.include_router(plex_router)
app.dependency_overrides[get_plex_library_service] = lambda: self.mock_svc
app.dependency_overrides[get_plex_repository] = lambda: MagicMock()
self.client = TestClient(app)
def test_sessions_returns_200(self, _setup):
resp = self.client.get("/plex/sessions")
assert resp.status_code == 200
data = resp.json()
assert len(data["sessions"]) == 1
assert data["sessions"][0]["session_id"] == "s1"
assert data["sessions"][0]["track_title"] == "Song A"
def test_sessions_empty(self, _setup):
self.mock_svc.get_sessions = AsyncMock(return_value=PlexSessionsResponse(sessions=[]))
resp = self.client.get("/plex/sessions")
assert resp.status_code == 200
assert resp.json()["sessions"] == []
class TestNavidromeNowPlayingRoute:
@pytest.fixture
def _setup(self):
self.mock_svc = MagicMock()
self.mock_svc.get_now_playing = AsyncMock(return_value=NavidromeNowPlayingResponse(entries=[
NavidromeNowPlayingEntrySchema(
user_name="bob",
minutes_ago=2,
player_name="Firefox",
track_name="Song N",
artist_name="Artist N",
album_name="Album N",
album_id="al1",
cover_art_id="cov1",
duration_seconds=240,
)
]))
app = FastAPI()
app.include_router(navidrome_router)
app.dependency_overrides[get_navidrome_library_service] = lambda: self.mock_svc
self.client = TestClient(app)
def test_now_playing_returns_200(self, _setup):
resp = self.client.get("/navidrome/now-playing")
assert resp.status_code == 200
data = resp.json()
assert len(data["entries"]) == 1
assert data["entries"][0]["user_name"] == "bob"
assert data["entries"][0]["track_name"] == "Song N"
def test_now_playing_empty(self, _setup):
self.mock_svc.get_now_playing = AsyncMock(
return_value=NavidromeNowPlayingResponse(entries=[])
)
resp = self.client.get("/navidrome/now-playing")
assert resp.status_code == 200
assert resp.json()["entries"] == []
class TestJellyfinSessionsRoute:
@pytest.fixture
def _setup(self):
self.mock_svc = MagicMock()
self.mock_svc.get_sessions = AsyncMock(return_value=JellyfinSessionsResponse(sessions=[
JellyfinSessionInfo(
session_id="js1",
user_name="carol",
device_name="Chrome",
client_name="Jellyfin Web",
track_name="Song J",
artist_name="Artist J",
album_name="Album J",
album_id="jalb1",
cover_url="/api/v1/jellyfin/image/jitem1",
position_seconds=60.0,
duration_seconds=300.0,
is_paused=False,
play_method="DirectPlay",
audio_codec="aac",
bitrate=256,
)
]))
app = FastAPI()
app.include_router(jellyfin_router)
app.dependency_overrides[get_jellyfin_library_service] = lambda: self.mock_svc
self.client = TestClient(app)
def test_sessions_returns_200(self, _setup):
resp = self.client.get("/jellyfin/sessions")
assert resp.status_code == 200
data = resp.json()
assert len(data["sessions"]) == 1
assert data["sessions"][0]["session_id"] == "js1"
assert data["sessions"][0]["track_name"] == "Song J"
assert data["sessions"][0]["position_seconds"] == 60.0
def test_sessions_empty(self, _setup):
self.mock_svc.get_sessions = AsyncMock(
return_value=JellyfinSessionsResponse(sessions=[])
)
resp = self.client.get("/jellyfin/sessions")
assert resp.status_code == 200
assert resp.json()["sessions"] == []

View file

@ -0,0 +1,99 @@
from __future__ import annotations
import os
import tempfile
os.environ.setdefault("ROOT_APP_DIR", tempfile.mkdtemp())
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api.v1.routes.plex_auth import router as auth_router
from api.v1.schemas.settings import PlexOAuthPinResponse, PlexOAuthPollResponse
from core.dependencies import get_plex_repository, get_preferences_service
from core.exceptions import PlexApiError
@pytest.fixture
def mock_preferences():
mock = MagicMock()
mock.get_setting = MagicMock(return_value="existing-client-id")
mock.save_setting = MagicMock()
mock.get_or_create_setting = MagicMock(return_value="existing-client-id")
return mock
@pytest.fixture
def mock_repo():
mock = MagicMock()
pin = MagicMock()
pin.id = 12345
pin.code = "ABCD"
mock.create_oauth_pin = AsyncMock(return_value=pin)
mock.poll_oauth_pin = AsyncMock(return_value=None)
return mock
@pytest.fixture
def auth_client(mock_preferences, mock_repo):
app = FastAPI()
app.include_router(auth_router)
app.dependency_overrides[get_preferences_service] = lambda: mock_preferences
app.dependency_overrides[get_plex_repository] = lambda: mock_repo
return TestClient(app)
class TestCreatePlexPin:
def test_creates_pin(self, auth_client):
resp = auth_client.post("/plex/auth/pin")
assert resp.status_code == 200
data = resp.json()
assert data["pin_id"] == 12345
assert data["pin_code"] == "ABCD"
assert "auth_url" in data
assert "clientID=existing-client-id" in data["auth_url"]
def test_generates_client_id_if_missing(self, auth_client, mock_preferences):
import uuid
generated = str(uuid.uuid4())
mock_preferences.get_or_create_setting = MagicMock(return_value=generated)
resp = auth_client.post("/plex/auth/pin")
assert resp.status_code == 200
mock_preferences.get_or_create_setting.assert_called_once()
call_args = mock_preferences.get_or_create_setting.call_args
assert call_args[0][0] == "plex_client_id"
def test_502_on_plex_error(self, auth_client, mock_repo):
mock_repo.create_oauth_pin = AsyncMock(side_effect=PlexApiError("timeout"))
resp = auth_client.post("/plex/auth/pin")
assert resp.status_code == 502
def test_500_on_unexpected_error(self, auth_client, mock_repo):
mock_repo.create_oauth_pin = AsyncMock(side_effect=RuntimeError("bad"))
resp = auth_client.post("/plex/auth/pin")
assert resp.status_code == 500
class TestPollPlexPin:
def test_poll_pending(self, auth_client):
resp = auth_client.get("/plex/auth/poll?pin_id=12345")
assert resp.status_code == 200
data = resp.json()
assert data["completed"] is False
assert data["auth_token"] == ""
def test_poll_completed(self, auth_client, mock_repo):
mock_repo.poll_oauth_pin = AsyncMock(return_value="auth-token-abc")
resp = auth_client.get("/plex/auth/poll?pin_id=12345")
assert resp.status_code == 200
data = resp.json()
assert data["completed"] is True
assert data["auth_token"] == "auth-token-abc"
def test_poll_502_on_error(self, auth_client, mock_repo):
mock_repo.poll_oauth_pin = AsyncMock(side_effect=Exception("network error"))
resp = auth_client.get("/plex/auth/poll?pin_id=12345")
assert resp.status_code == 502

View file

@ -0,0 +1,280 @@
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api.v1.routes.plex_library import router as plex_library_router
from api.v1.routes.stream import router as stream_router
from api.v1.schemas.plex import (
PlexAlbumDetail,
PlexAlbumMatch,
PlexAlbumSummary,
PlexArtistSummary,
PlexLibraryStats,
PlexSearchResponse,
PlexTrackInfo,
)
from core.dependencies import get_plex_library_service, get_plex_playback_service, get_plex_repository
from core.exceptions import ExternalServiceError
def _album_summary(id: str = "100", name: str = "Album") -> PlexAlbumSummary:
return PlexAlbumSummary(plex_id=id, name=name, artist_name="Artist")
def _track_info(id: str = "200", title: str = "Track") -> PlexTrackInfo:
return PlexTrackInfo(
plex_id=id, title=title, track_number=1, disc_number=1,
duration_seconds=200.0, part_key="/library/parts/200/file.flac",
)
def _artist_summary(id: str = "50", name: str = "Artist") -> PlexArtistSummary:
return PlexArtistSummary(plex_id=id, name=name)
@pytest.fixture
def mock_library_service():
mock = MagicMock()
mock.get_albums = AsyncMock(return_value=([_album_summary()], 42))
mock.get_album_detail = AsyncMock(return_value=PlexAlbumDetail(
plex_id="100", name="Album", tracks=[_track_info()],
))
mock.get_artists = AsyncMock(return_value=[_artist_summary()])
mock.search = AsyncMock(return_value=PlexSearchResponse(
albums=[_album_summary()], artists=[_artist_summary()], tracks=[_track_info()],
))
mock.get_recent = AsyncMock(return_value=[_album_summary()])
mock.get_genres = AsyncMock(return_value=["Rock", "Jazz"])
mock.get_stats = AsyncMock(return_value=PlexLibraryStats(
total_tracks=100, total_albums=10, total_artists=5,
))
mock.get_album_match = AsyncMock(return_value=PlexAlbumMatch(found=True, plex_album_id="100"))
return mock
@pytest.fixture
def mock_repo():
mock = MagicMock()
mock.proxy_thumb = AsyncMock(return_value=(b"\x89PNG", "image/png"))
return mock
@pytest.fixture
def mock_playback_service():
mock = MagicMock()
mock.scrobble = AsyncMock(return_value=True)
mock.report_now_playing = AsyncMock(return_value=True)
mock.proxy_head = AsyncMock(return_value=MagicMock(status_code=200))
mock.proxy_stream = AsyncMock(return_value=MagicMock())
return mock
@pytest.fixture
def library_client(mock_library_service, mock_repo):
app = FastAPI()
app.include_router(plex_library_router)
app.dependency_overrides[get_plex_library_service] = lambda: mock_library_service
app.dependency_overrides[get_plex_repository] = lambda: mock_repo
return TestClient(app)
@pytest.fixture
def stream_client(mock_playback_service):
app = FastAPI()
app.include_router(stream_router)
app.dependency_overrides[get_plex_playback_service] = lambda: mock_playback_service
return TestClient(app)
class TestPlexAlbums:
def test_get_albums(self, library_client):
resp = library_client.get("/plex/albums")
assert resp.status_code == 200
data = resp.json()
assert len(data["items"]) == 1
assert data["items"][0]["plex_id"] == "100"
assert data["total"] == 42
def test_get_albums_with_sort(self, library_client, mock_library_service):
resp = library_client.get("/plex/albums?sort_by=year&sort_order=desc")
assert resp.status_code == 200
call_kwargs = mock_library_service.get_albums.call_args
assert "year:desc" in str(call_kwargs)
def test_get_albums_with_genre(self, library_client, mock_library_service):
resp = library_client.get("/plex/albums?genre=Rock")
assert resp.status_code == 200
call_kwargs = mock_library_service.get_albums.call_args
assert call_kwargs.kwargs.get("genre") == "Rock" or "Rock" in str(call_kwargs)
def test_get_albums_502_on_external_error(self, library_client, mock_library_service):
mock_library_service.get_albums = AsyncMock(side_effect=ExternalServiceError("down"))
resp = library_client.get("/plex/albums")
assert resp.status_code == 502
def test_get_album_detail(self, library_client):
resp = library_client.get("/plex/albums/100")
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "Album"
assert len(data["tracks"]) == 1
def test_get_album_detail_not_found(self, library_client, mock_library_service):
mock_library_service.get_album_detail = AsyncMock(return_value=None)
resp = library_client.get("/plex/albums/missing")
assert resp.status_code == 404
class TestPlexArtists:
def test_get_artists(self, library_client):
resp = library_client.get("/plex/artists")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["name"] == "Artist"
class TestPlexSearch:
def test_search(self, library_client):
resp = library_client.get("/plex/search?q=test")
assert resp.status_code == 200
data = resp.json()
assert "albums" in data
assert "artists" in data
assert "tracks" in data
def test_search_missing_query(self, library_client):
resp = library_client.get("/plex/search")
assert resp.status_code == 422
class TestPlexRecent:
def test_get_recent(self, library_client):
resp = library_client.get("/plex/recent")
assert resp.status_code == 200
assert len(resp.json()) == 1
class TestPlexGenres:
def test_get_genres(self, library_client):
resp = library_client.get("/plex/genres")
assert resp.status_code == 200
assert resp.json() == ["Rock", "Jazz"]
def test_genres_502_on_external_error(self, library_client, mock_library_service):
mock_library_service.get_genres = AsyncMock(side_effect=ExternalServiceError("down"))
resp = library_client.get("/plex/genres")
assert resp.status_code == 502
class TestPlexStats:
def test_get_stats(self, library_client):
resp = library_client.get("/plex/stats")
assert resp.status_code == 200
data = resp.json()
assert data["total_tracks"] == 100
assert data["total_albums"] == 10
assert data["total_artists"] == 5
class TestPlexThumb:
def test_get_thumb(self, library_client):
resp = library_client.get("/plex/thumb/100")
assert resp.status_code == 200
assert resp.headers["content-type"] == "image/png"
def test_thumb_502_on_error(self, library_client, mock_repo):
mock_repo.proxy_thumb = AsyncMock(side_effect=ExternalServiceError("timeout"))
resp = library_client.get("/plex/thumb/100")
assert resp.status_code == 502
class TestPlexAlbumMatch:
def test_match_found(self, library_client):
resp = library_client.get("/plex/album-match/mbid-1?name=Album&artist=Artist")
assert resp.status_code == 200
data = resp.json()
assert data["found"] is True
assert data["plex_album_id"] == "100"
def test_match_not_found(self, library_client, mock_library_service):
mock_library_service.get_album_match = AsyncMock(
return_value=PlexAlbumMatch(found=False),
)
resp = library_client.get("/plex/album-match/mbid-1?name=Album&artist=Artist")
assert resp.status_code == 200
assert resp.json()["found"] is False
def test_match_502_on_error(self, library_client, mock_library_service):
mock_library_service.get_album_match = AsyncMock(
side_effect=ExternalServiceError("down"),
)
resp = library_client.get("/plex/album-match/mbid-1")
assert resp.status_code == 502
class TestPlexStreamRoutes:
def test_scrobble(self, stream_client):
resp = stream_client.post("/stream/plex/12345/scrobble")
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
def test_now_playing(self, stream_client):
resp = stream_client.post("/stream/plex/12345/now-playing")
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
def test_scrobble_error(self, stream_client, mock_playback_service):
mock_playback_service.scrobble = AsyncMock(return_value=False)
resp = stream_client.post("/stream/plex/12345/scrobble")
assert resp.status_code == 200
assert resp.json()["status"] == "error"
def test_stream_head(self, stream_client, mock_playback_service):
from fastapi.responses import Response
mock_playback_service.proxy_head = AsyncMock(
return_value=Response(status_code=200, headers={"Content-Length": "1000"}),
)
resp = stream_client.head("/stream/plex/library/parts/200/file.flac")
assert resp.status_code == 200
def test_stream_head_bad_request(self, stream_client, mock_playback_service):
mock_playback_service.proxy_head = AsyncMock(side_effect=ValueError("bad"))
resp = stream_client.head("/stream/plex/library/parts/200/file.flac")
assert resp.status_code == 400
def test_stream_502(self, stream_client, mock_playback_service):
mock_playback_service.proxy_head = AsyncMock(side_effect=ExternalServiceError("down"))
resp = stream_client.head("/stream/plex/library/parts/200/file.flac")
assert resp.status_code == 502
def test_get_stream_success(self, stream_client, mock_playback_service):
from starlette.responses import StreamingResponse
mock_playback_service.proxy_stream = AsyncMock(
return_value=StreamingResponse(content=iter([b"audio"]), status_code=200),
)
resp = stream_client.get("/stream/plex/library/parts/200/file.flac")
assert resp.status_code == 200
def test_get_stream_bad_request(self, stream_client, mock_playback_service):
mock_playback_service.proxy_stream = AsyncMock(side_effect=ValueError("bad"))
resp = stream_client.get("/stream/plex/library/parts/200/file.flac")
assert resp.status_code == 400
def test_get_stream_502(self, stream_client, mock_playback_service):
mock_playback_service.proxy_stream = AsyncMock(
side_effect=ExternalServiceError("Plex streaming failed"),
)
resp = stream_client.get("/stream/plex/library/parts/200/file.flac")
assert resp.status_code == 502
def test_get_stream_416(self, stream_client, mock_playback_service):
mock_playback_service.proxy_stream = AsyncMock(
side_effect=ExternalServiceError("416 Range not satisfiable"),
)
resp = stream_client.get("/stream/plex/library/parts/200/file.flac")
assert resp.status_code == 416

View file

@ -0,0 +1,143 @@
from __future__ import annotations
import os
import tempfile
os.environ.setdefault("ROOT_APP_DIR", tempfile.mkdtemp())
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api.v1.routes.settings import router as settings_router
from api.v1.schemas.settings import PlexConnectionSettings
from core.dependencies import get_preferences_service, get_settings_service
from core.exceptions import ConfigurationError
@pytest.fixture
def mock_preferences():
mock = MagicMock()
mock.get_plex_connection.return_value = PlexConnectionSettings(
enabled=True,
plex_url="http://plex:32400",
plex_token="tok-123",
music_library_ids=["1"],
scrobble_to_plex=True,
)
mock.get_plex_connection_raw.return_value = MagicMock(
plex_url="http://plex:32400",
plex_token="tok-123",
)
mock.save_plex_connection = MagicMock()
mock.get_setting = MagicMock(return_value="client-id-123")
return mock
@pytest.fixture
def mock_settings_service():
mock = MagicMock()
mock.on_plex_settings_changed = AsyncMock()
verify_result = MagicMock()
verify_result.valid = True
verify_result.message = "Connected"
verify_result.libraries = [("1", "Music")]
mock.verify_plex = AsyncMock(return_value=verify_result)
return mock
@pytest.fixture
def settings_client(mock_preferences, mock_settings_service):
app = FastAPI()
app.include_router(settings_router)
app.dependency_overrides[get_preferences_service] = lambda: mock_preferences
app.dependency_overrides[get_settings_service] = lambda: mock_settings_service
return TestClient(app)
class TestGetPlexSettings:
def test_returns_settings(self, settings_client):
resp = settings_client.get("/settings/plex")
assert resp.status_code == 200
data = resp.json()
assert data["enabled"] is True
assert data["plex_url"] == "http://plex:32400"
assert data["music_library_ids"] == ["1"]
class TestUpdatePlexSettings:
def test_saves_and_returns(self, settings_client, mock_preferences, mock_settings_service):
payload = {
"enabled": True,
"plex_url": "http://plex:32400",
"plex_token": "new-tok",
"music_library_ids": ["1", "2"],
"scrobble_to_plex": False,
}
resp = settings_client.put("/settings/plex", json=payload)
assert resp.status_code == 200
mock_preferences.save_plex_connection.assert_called_once()
mock_settings_service.on_plex_settings_changed.assert_awaited_once()
def test_400_on_config_error(self, settings_client, mock_preferences):
mock_preferences.save_plex_connection.side_effect = ConfigurationError("bad")
payload = {
"enabled": True,
"plex_url": "",
"plex_token": "",
}
resp = settings_client.put("/settings/plex", json=payload)
assert resp.status_code == 400
class TestVerifyPlexConnection:
def test_verify_success(self, settings_client):
payload = {
"enabled": True,
"plex_url": "http://plex:32400",
"plex_token": "tok",
}
resp = settings_client.post("/settings/plex/verify", json=payload)
assert resp.status_code == 200
data = resp.json()
assert data["valid"] is True
assert len(data["libraries"]) == 1
assert data["libraries"][0]["key"] == "1"
def test_verify_invalid(self, settings_client, mock_settings_service):
result = MagicMock()
result.valid = False
result.message = "Connection refused"
result.libraries = []
mock_settings_service.verify_plex = AsyncMock(return_value=result)
payload = {
"enabled": True,
"plex_url": "http://bad:32400",
"plex_token": "tok",
}
resp = settings_client.post("/settings/plex/verify", json=payload)
assert resp.status_code == 200
assert resp.json()["valid"] is False
class TestGetPlexLibraries:
def test_returns_libraries(self, settings_client, mock_settings_service):
mock_settings_service.get_plex_libraries = AsyncMock(return_value=[("1", "Music")])
resp = settings_client.get("/settings/plex/libraries")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["title"] == "Music"
def test_400_when_not_configured(self, settings_client, mock_settings_service):
mock_settings_service.get_plex_libraries = AsyncMock(
side_effect=ValueError("Plex is not configured")
)
resp = settings_client.get("/settings/plex/libraries")
assert resp.status_code == 400

View file

@ -0,0 +1,230 @@
"""Tests for lyrics, album info, and audio-quality enrichment."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, PropertyMock
import pytest
from repositories.navidrome_models import SubsonicAlbumInfo, SubsonicLyrics
from repositories.jellyfin_models import JellyfinLyrics, JellyfinLyricLine
from services.navidrome_library_service import NavidromeLibraryService
from services.jellyfin_library_service import JellyfinLibraryService
from services.plex_library_service import PlexLibraryService
from repositories.plex_models import PlexTrack, PlexMedia, PlexPart
def _navidrome_service(
album_info=None,
lyrics=None,
lyrics_by_id=None,
) -> NavidromeLibraryService:
repo = MagicMock()
repo.get_albums = AsyncMock(return_value=[])
repo.get_recently_played = AsyncMock(return_value=[])
repo.get_recently_added = AsyncMock(return_value=[])
repo.get_starred_albums = AsyncMock(return_value=[])
repo.get_starred_artists = AsyncMock(return_value=[])
repo.get_starred_songs = AsyncMock(return_value=[])
repo.get_genres = AsyncMock(return_value=[])
repo.get_album_count = AsyncMock(return_value=0)
repo.get_track_count = AsyncMock(return_value=0)
repo.get_artist_count = AsyncMock(return_value=0)
repo.get_album_info = AsyncMock(return_value=album_info)
repo.get_lyrics = AsyncMock(return_value=lyrics)
repo.get_lyrics_by_song_id = AsyncMock(return_value=lyrics_by_id)
prefs = MagicMock()
return NavidromeLibraryService(navidrome_repo=repo, preferences_service=prefs)
def _jellyfin_service(lyrics=None) -> JellyfinLibraryService:
repo = MagicMock()
repo.get_albums = AsyncMock(return_value=([], 0))
repo.get_recently_added = AsyncMock(return_value=[])
repo.get_most_played = AsyncMock(return_value=[])
repo.get_genres = AsyncMock(return_value=[])
repo.get_track_count = AsyncMock(return_value=0)
repo.get_artist_count = AsyncMock(return_value=0)
repo.get_lyrics = AsyncMock(return_value=lyrics)
prefs = MagicMock()
return JellyfinLibraryService(jellyfin_repo=repo, preferences_service=prefs)
def _plex_service() -> PlexLibraryService:
repo = MagicMock()
repo.get_albums = AsyncMock(return_value=([], 0))
repo.get_recently_added = AsyncMock(return_value=[])
repo.get_recently_viewed = AsyncMock(return_value=[])
repo.get_genres = AsyncMock(return_value=[])
repo.get_track_count = AsyncMock(return_value=0)
repo.get_artist_count = AsyncMock(return_value=0)
type(repo).stats_ttl = PropertyMock(return_value=600)
prefs = MagicMock()
conn = MagicMock()
conn.enabled = True
conn.plex_url = "http://plex:32400"
conn.plex_token = "tok"
conn.music_library_ids = ["1"]
prefs.get_plex_connection_raw.return_value = conn
return PlexLibraryService(plex_repo=repo, preferences_service=prefs)
@pytest.mark.asyncio
async def test_navidrome_album_info_returns_schema():
info = SubsonicAlbumInfo(
notes="Great album with <a>link</a>.",
musicBrainzId="mb-123",
lastFmUrl="https://last.fm/music/album",
smallImageUrl="https://img/s.jpg",
mediumImageUrl="https://img/m.jpg",
largeImageUrl="https://img/l.jpg",
)
svc = _navidrome_service(album_info=info)
result = await svc.get_album_info("album-1")
assert result is not None
assert result.album_id == "album-1"
assert result.musicbrainz_id == "mb-123"
assert result.lastfm_url == "https://last.fm/music/album"
assert result.image_url == "https://img/l.jpg"
assert "<a>" not in result.notes
@pytest.mark.asyncio
async def test_navidrome_album_info_returns_none_when_empty():
info = SubsonicAlbumInfo()
svc = _navidrome_service(album_info=info)
result = await svc.get_album_info("album-1")
assert result is None
@pytest.mark.asyncio
async def test_navidrome_album_info_returns_none_on_error():
svc = _navidrome_service()
svc._navidrome.get_album_info = AsyncMock(side_effect=RuntimeError("fail"))
result = await svc.get_album_info("album-1")
assert result is None
@pytest.mark.asyncio
async def test_navidrome_lyrics_by_song_id_first():
lyrics = SubsonicLyrics(value="Hello world\nLine two", artist="A", title="T")
svc = _navidrome_service(lyrics_by_id=lyrics)
result = await svc.get_lyrics("song-1", artist="A", title="T")
assert result is not None
assert "Hello world" in result.text
@pytest.mark.asyncio
async def test_navidrome_lyrics_fallback_to_artist_title():
lyrics = SubsonicLyrics(value="Fallback lyrics", artist="A", title="T")
svc = _navidrome_service(lyrics_by_id=None, lyrics=lyrics)
svc._navidrome.get_lyrics_by_song_id = AsyncMock(side_effect=RuntimeError("not supported"))
result = await svc.get_lyrics("song-1", artist="A", title="T")
assert result is not None
assert result.text == "Fallback lyrics"
@pytest.mark.asyncio
async def test_navidrome_lyrics_returns_none_when_empty():
lyrics = SubsonicLyrics(value="", artist="A", title="T")
svc = _navidrome_service(lyrics_by_id=lyrics)
svc._navidrome.get_lyrics = AsyncMock(return_value=SubsonicLyrics(value="", artist="A", title="T"))
result = await svc.get_lyrics("song-1", artist="A", title="T")
assert result is None
@pytest.mark.asyncio
async def test_navidrome_lyrics_returns_none_on_all_errors():
svc = _navidrome_service()
svc._navidrome.get_lyrics_by_song_id = AsyncMock(side_effect=RuntimeError("fail"))
svc._navidrome.get_lyrics = AsyncMock(side_effect=RuntimeError("fail"))
result = await svc.get_lyrics("song-1", artist="A", title="T")
assert result is None
@pytest.mark.asyncio
async def test_jellyfin_lyrics_synced():
lyrics = JellyfinLyrics(lines=[
JellyfinLyricLine(text="Line one", start=50_000_000),
JellyfinLyricLine(text="Line two", start=100_000_000),
])
svc = _jellyfin_service(lyrics=lyrics)
result = await svc.get_lyrics("item-1")
assert result is not None
assert result.is_synced is True
assert len(result.lines) == 2
assert result.lines[0].start_seconds == pytest.approx(5.0)
assert result.lines[1].start_seconds == pytest.approx(10.0)
assert "Line one" in result.lyrics_text
@pytest.mark.asyncio
async def test_jellyfin_lyrics_unsynced():
lyrics = JellyfinLyrics(lines=[
JellyfinLyricLine(text="Plain text", start=None),
])
svc = _jellyfin_service(lyrics=lyrics)
result = await svc.get_lyrics("item-1")
assert result is not None
assert result.is_synced is False
assert result.lines[0].start_seconds is None
@pytest.mark.asyncio
async def test_jellyfin_lyrics_returns_none_when_unavailable():
svc = _jellyfin_service(lyrics=None)
result = await svc.get_lyrics("item-1")
assert result is None
@pytest.mark.asyncio
async def test_jellyfin_lyrics_returns_none_on_error():
svc = _jellyfin_service()
svc._jellyfin.get_lyrics = AsyncMock(side_effect=RuntimeError("fail"))
result = await svc.get_lyrics("item-1")
assert result is None
def test_plex_track_to_info_extracts_audio_quality():
track = PlexTrack(
ratingKey="1",
title="Song",
index=1,
parentIndex=1,
duration=180000,
parentTitle="Album",
grandparentTitle="Artist",
Media=[
PlexMedia(
bitrate=1411,
audioCodec="flac",
audioChannels=2,
container="flac",
Part=[PlexPart(key="/part/1")],
)
],
)
svc = _plex_service()
info = svc._track_to_info(track)
assert info.codec == "flac"
assert info.bitrate == 1411
assert info.audio_channels == 2
assert info.container == "flac"
def test_plex_track_to_info_handles_missing_media():
track = PlexTrack(
ratingKey="2",
title="Song2",
index=2,
parentIndex=1,
duration=120000,
parentTitle="Album",
grandparentTitle="Artist",
Media=[],
)
svc = _plex_service()
info = svc._track_to_info(track)
assert info.codec is None
assert info.bitrate is None
assert info.audio_channels is None
assert info.container is None

View file

@ -0,0 +1,269 @@
"""Tests for deep discovery and analytics features."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from api.v1.schemas.navidrome import NavidromeArtistInfoSchema, NavidromeTrackInfo
from api.v1.schemas.jellyfin import JellyfinAlbumSummary
from api.v1.schemas.plex import PlexAnalyticsResponse, PlexHistoryResponse
from repositories.navidrome_models import SubsonicArtist, SubsonicArtistInfo, SubsonicSong
from repositories.jellyfin_models import JellyfinItem
from repositories.plex_models import PlexHistoryEntry
from services.navidrome_library_service import NavidromeLibraryService
from services.jellyfin_library_service import JellyfinLibraryService
from services.plex_library_service import PlexLibraryService
def _make_navidrome_service() -> tuple[NavidromeLibraryService, MagicMock]:
repo = MagicMock()
repo.get_album_list = AsyncMock(return_value=[])
repo.get_album = AsyncMock()
repo.get_artists = AsyncMock(return_value=[])
repo.get_artist = AsyncMock()
repo.get_starred = AsyncMock()
repo.get_genres = AsyncMock(return_value=[])
repo.search = AsyncMock()
repo.get_top_songs = AsyncMock(return_value=[])
repo.get_similar_songs = AsyncMock(return_value=[])
repo.get_artist_info = AsyncMock(return_value=None)
prefs = MagicMock()
prefs.get_advanced_settings.return_value = MagicMock()
service = NavidromeLibraryService(navidrome_repo=repo, preferences_service=prefs)
return service, repo
def _make_jellyfin_service() -> tuple[JellyfinLibraryService, MagicMock]:
repo = MagicMock()
repo.is_configured.return_value = True
repo.get_similar_items = AsyncMock(return_value=[])
repo.get_image_url = MagicMock(return_value=None)
prefs = MagicMock()
prefs.get_advanced_settings.return_value = MagicMock()
service = JellyfinLibraryService(jellyfin_repo=repo, preferences_service=prefs)
return service, repo
def _make_plex_service() -> tuple[PlexLibraryService, MagicMock]:
repo = MagicMock()
repo.is_configured.return_value = True
repo.get_listening_history = AsyncMock(return_value=([], 0))
repo.get_music_libraries = AsyncMock(return_value=[])
prefs = MagicMock()
prefs.get_advanced_settings.return_value = MagicMock()
service = PlexLibraryService(plex_repo=repo, preferences_service=prefs)
return service, repo
def _subsonic_song(id: str = "s1", title: str = "Song", artist: str = "Artist",
album: str = "Album", track: int = 1, duration: int = 200) -> SubsonicSong:
return SubsonicSong(id=id, title=title, artist=artist, album=album,
track=track, duration=duration)
def _jellyfin_item(id: str = "j1", name: str = "Album", type: str = "MusicAlbum",
artist_name: str = "Artist") -> JellyfinItem:
return JellyfinItem(id=id, name=name, type=type, artist_name=artist_name)
def _plex_history_entry(
rating_key: str = "100",
track_title: str = "Song",
artist_name: str = "Artist",
album_name: str = "Album",
viewed_at: int = 1700000000,
duration_ms: int = 200000,
) -> PlexHistoryEntry:
return PlexHistoryEntry(
rating_key=rating_key,
track_title=track_title,
artist_name=artist_name,
album_name=album_name,
album_rating_key="200",
viewed_at=viewed_at,
duration_ms=duration_ms,
device_name="Chrome",
player_platform="Web",
)
class TestNavidromeTopSongs:
@pytest.mark.asyncio
async def test_returns_mapped_tracks(self):
service, repo = _make_navidrome_service()
repo.get_top_songs.return_value = [
_subsonic_song("s1", "Hit Song", "Radiohead"),
_subsonic_song("s2", "Another Hit", "Radiohead"),
]
result = await service.get_top_songs("Radiohead")
assert len(result) == 2
assert isinstance(result[0], NavidromeTrackInfo)
assert result[0].title == "Hit Song"
repo.get_top_songs.assert_awaited_once_with("Radiohead", count=20)
@pytest.mark.asyncio
async def test_returns_empty_on_error(self):
service, repo = _make_navidrome_service()
repo.get_top_songs.side_effect = Exception("Last.fm unavailable")
result = await service.get_top_songs("Unknown Artist")
assert result == []
class TestNavidromeSimilarSongs:
@pytest.mark.asyncio
async def test_returns_mapped_tracks(self):
service, repo = _make_navidrome_service()
repo.get_similar_songs.return_value = [_subsonic_song("s3", "Similar")]
result = await service.get_similar_songs("s1")
assert len(result) == 1
assert result[0].title == "Similar"
@pytest.mark.asyncio
async def test_returns_empty_on_error(self):
service, repo = _make_navidrome_service()
repo.get_similar_songs.side_effect = Exception("Fail")
result = await service.get_similar_songs("s1")
assert result == []
class TestNavidromeArtistInfo:
@pytest.mark.asyncio
async def test_returns_info_schema(self):
service, repo = _make_navidrome_service()
repo.get_artist_info.return_value = SubsonicArtistInfo(
biography="A great band.",
musicBrainzId="mbid-123",
smallImageUrl="http://img/sm.jpg",
mediumImageUrl="http://img/md.jpg",
largeImageUrl="http://img/lg.jpg",
similarArtist=[SubsonicArtist(id="ar2", name="Similar Band")],
)
result = await service.get_artist_info("ar1")
assert result is not None
assert isinstance(result, NavidromeArtistInfoSchema)
assert result.biography == "A great band."
assert result.image_url == "http://img/lg.jpg"
assert len(result.similar_artists) == 1
@pytest.mark.asyncio
async def test_returns_none_when_not_available(self):
service, repo = _make_navidrome_service()
repo.get_artist_info.return_value = None
result = await service.get_artist_info("ar1")
assert result is None
@pytest.mark.asyncio
async def test_returns_none_on_error(self):
service, repo = _make_navidrome_service()
repo.get_artist_info.side_effect = Exception("Fail")
result = await service.get_artist_info("ar1")
assert result is None
class TestJellyfinSimilarItems:
@pytest.mark.asyncio
async def test_returns_album_summaries(self):
service, repo = _make_jellyfin_service()
repo.get_similar_items.return_value = [
_jellyfin_item("j1", "Similar Album"),
_jellyfin_item("j2", "Another Similar"),
]
result = await service.get_similar_items("seed-id")
assert len(result) == 2
assert isinstance(result[0], JellyfinAlbumSummary)
assert result[0].name == "Similar Album"
@pytest.mark.asyncio
async def test_filters_non_music(self):
service, repo = _make_jellyfin_service()
repo.get_similar_items.return_value = [
_jellyfin_item("j1", "Album", "MusicAlbum"),
_jellyfin_item("j2", "Video", "Video"),
]
result = await service.get_similar_items("seed-id")
assert len(result) == 1
assert result[0].name == "Album"
@pytest.mark.asyncio
async def test_returns_empty_on_error(self):
service, repo = _make_jellyfin_service()
repo.get_similar_items.side_effect = Exception("Fail")
result = await service.get_similar_items("seed-id")
assert result == []
class TestPlexHistory:
@pytest.mark.asyncio
async def test_returns_history_response(self):
service, repo = _make_plex_service()
repo.get_listening_history.return_value = (
[_plex_history_entry("100", "Song 1"), _plex_history_entry("101", "Song 2")],
50,
)
result = await service.get_history(limit=10)
assert isinstance(result, PlexHistoryResponse)
assert len(result.entries) == 2
assert result.total == 50
assert result.entries[0].track_title == "Song 1"
@pytest.mark.asyncio
async def test_returns_empty_history(self):
service, repo = _make_plex_service()
repo.get_listening_history.return_value = ([], 0)
result = await service.get_history()
assert result.entries == []
assert result.total == 0
class TestPlexAnalytics:
@pytest.mark.asyncio
async def test_aggregation_with_entries(self):
import time
now_ts = int(time.time())
entries = [
_plex_history_entry("1", "Song A", "Artist X", "Album 1", now_ts, 180000),
_plex_history_entry("2", "Song A", "Artist X", "Album 1", now_ts, 180000),
_plex_history_entry("3", "Song B", "Artist Y", "Album 2", now_ts, 240000),
]
service, repo = _make_plex_service()
repo.get_listening_history.return_value = (entries, 3)
result = await service.get_analytics()
assert isinstance(result, PlexAnalyticsResponse)
assert result.total_listens == 3
assert result.entries_analyzed == 3
assert result.is_complete is True
assert result.top_artists[0].name == "Artist X"
assert result.top_artists[0].play_count == 2
assert result.top_tracks[0].name == "Song A"
assert result.listens_last_7_days == 3
assert result.total_hours > 0
@pytest.mark.asyncio
async def test_empty_history_analytics(self):
service, repo = _make_plex_service()
repo.get_listening_history.return_value = ([], 0)
result = await service.get_analytics()
assert result.total_listens == 0
assert result.top_artists == []
assert result.is_complete is True
@pytest.mark.asyncio
async def test_incomplete_flag_set(self):
entries = [_plex_history_entry(str(i), f"Song {i}") for i in range(500)]
service, repo = _make_plex_service()
call_count = 0
async def mock_history(limit: int = 50, offset: int = 0):
nonlocal call_count
call_count += 1
if offset == 0:
return (entries, 10000)
return ([], 10000)
repo.get_listening_history = AsyncMock(side_effect=mock_history)
result = await service.get_analytics()
assert result.entries_analyzed == 500
assert result.is_complete is False

View file

@ -0,0 +1,263 @@
"""Tests for discovery features across Navidrome, Jellyfin, and Plex."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, PropertyMock
import pytest
from api.v1.schemas.jellyfin import JellyfinTrackInfo
from api.v1.schemas.navidrome import NavidromeTrackInfo
from api.v1.schemas.plex import PlexDiscoveryAlbum, PlexDiscoveryHub, PlexDiscoveryResponse
from repositories.jellyfin_models import JellyfinItem
from repositories.navidrome_models import SubsonicSong
from services.jellyfin_library_service import JellyfinLibraryService
from services.navidrome_library_service import NavidromeLibraryService
from services.plex_library_service import PlexLibraryService
def _make_navidrome_service() -> tuple[NavidromeLibraryService, MagicMock]:
repo = MagicMock()
repo.get_random_songs = AsyncMock(return_value=[])
prefs = MagicMock()
prefs.get_advanced_settings.return_value = MagicMock()
service = NavidromeLibraryService(navidrome_repo=repo, preferences_service=prefs)
return service, repo
def _song(id: str = "s1", title: str = "Song", album: str = "Album",
artist: str = "Artist", track: int = 1, duration: int = 200,
suffix: str = "mp3", bit_rate: int = 320) -> SubsonicSong:
return SubsonicSong(
id=id, title=title, album=album, artist=artist,
track=track, duration=duration, suffix=suffix, bitRate=bit_rate,
)
def _make_jellyfin_service() -> tuple[JellyfinLibraryService, MagicMock]:
repo = MagicMock()
repo.is_configured.return_value = True
repo.get_instant_mix = AsyncMock(return_value=[])
repo.get_instant_mix_by_artist = AsyncMock(return_value=[])
repo.get_instant_mix_by_genre = AsyncMock(return_value=[])
repo.get_image_url = MagicMock(return_value=None)
prefs = MagicMock()
prefs.get_advanced_settings.return_value = MagicMock()
service = JellyfinLibraryService(jellyfin_repo=repo, preferences_service=prefs)
return service, repo
def _jf_item(id: str = "jf-1", name: str = "Track", type: str = "Audio",
artist_name: str = "Artist", album: str = "Album",
album_id: str = "alb-1", ticks: int = 3_000_000_000,
index: int = 1) -> JellyfinItem:
return JellyfinItem(
id=id, name=name, type=type,
artist_name=artist_name, album_name=album, album_id=album_id,
duration_ticks=ticks, index_number=index,
)
def _make_plex_service(section_ids: list[str] | None = None) -> tuple[PlexLibraryService, MagicMock]:
repo = MagicMock()
repo.get_hubs = AsyncMock(return_value=[])
type(repo).stats_ttl = PropertyMock(return_value=600)
prefs = MagicMock()
ids = section_ids if section_ids is not None else ["1"]
prefs.get_plex_connection_raw.return_value = MagicMock(
enabled=True, plex_url="http://plex:32400", plex_token="tok",
music_library_ids=ids,
)
prefs.get_advanced_settings.return_value = MagicMock()
service = PlexLibraryService(plex_repo=repo, preferences_service=prefs)
return service, repo
class TestNavidromeRandomSongs:
@pytest.mark.asyncio
async def test_returns_mapped_tracks(self):
service, repo = _make_navidrome_service()
repo.get_random_songs = AsyncMock(return_value=[
_song(id="s1", title="Track A"),
_song(id="s2", title="Track B"),
])
tracks = await service.get_random_songs(size=10)
assert len(tracks) == 2
assert tracks[0].title == "Track A"
assert tracks[1].navidrome_id == "s2"
repo.get_random_songs.assert_awaited_once_with(size=10, genre=None)
@pytest.mark.asyncio
async def test_forwards_genre_filter(self):
service, repo = _make_navidrome_service()
repo.get_random_songs = AsyncMock(return_value=[_song()])
await service.get_random_songs(size=5, genre="Rock")
repo.get_random_songs.assert_awaited_once_with(size=5, genre="Rock")
@pytest.mark.asyncio
async def test_returns_empty_on_error(self):
service, repo = _make_navidrome_service()
repo.get_random_songs = AsyncMock(side_effect=Exception("fail"))
result = await service.get_random_songs()
assert result == []
@pytest.mark.asyncio
async def test_default_size_is_20(self):
service, repo = _make_navidrome_service()
repo.get_random_songs = AsyncMock(return_value=[])
await service.get_random_songs()
repo.get_random_songs.assert_awaited_once_with(size=20, genre=None)
class TestJellyfinInstantMix:
@pytest.mark.asyncio
async def test_instant_mix_returns_tracks(self):
service, repo = _make_jellyfin_service()
repo.get_instant_mix = AsyncMock(return_value=[
_jf_item(id="t1", name="Mix Track 1"),
_jf_item(id="t2", name="Mix Track 2"),
])
tracks = await service.get_instant_mix("album-1", limit=20)
assert len(tracks) == 2
assert tracks[0].title == "Mix Track 1"
repo.get_instant_mix.assert_awaited_once_with("album-1", limit=20)
@pytest.mark.asyncio
async def test_instant_mix_returns_empty_on_error(self):
service, repo = _make_jellyfin_service()
repo.get_instant_mix = AsyncMock(side_effect=Exception("boom"))
result = await service.get_instant_mix("album-1")
assert result == []
@pytest.mark.asyncio
async def test_instant_mix_by_artist(self):
service, repo = _make_jellyfin_service()
repo.get_instant_mix_by_artist = AsyncMock(return_value=[_jf_item()])
tracks = await service.get_instant_mix_by_artist("artist-1", limit=30)
assert len(tracks) == 1
repo.get_instant_mix_by_artist.assert_awaited_once_with("artist-1", limit=30)
@pytest.mark.asyncio
async def test_instant_mix_by_artist_error(self):
service, repo = _make_jellyfin_service()
repo.get_instant_mix_by_artist = AsyncMock(side_effect=Exception("err"))
result = await service.get_instant_mix_by_artist("a1")
assert result == []
@pytest.mark.asyncio
async def test_instant_mix_by_genre(self):
service, repo = _make_jellyfin_service()
repo.get_instant_mix_by_genre = AsyncMock(return_value=[
_jf_item(id="g1"), _jf_item(id="g2"),
])
tracks = await service.get_instant_mix_by_genre("Rock", limit=40)
assert len(tracks) == 2
repo.get_instant_mix_by_genre.assert_awaited_once_with("Rock", limit=40)
@pytest.mark.asyncio
async def test_instant_mix_by_genre_error(self):
service, repo = _make_jellyfin_service()
repo.get_instant_mix_by_genre = AsyncMock(side_effect=Exception("err"))
result = await service.get_instant_mix_by_genre("Jazz")
assert result == []
@pytest.mark.asyncio
async def test_instant_mix_converts_ticks_to_seconds(self):
service, repo = _make_jellyfin_service()
repo.get_instant_mix = AsyncMock(return_value=[
_jf_item(ticks=5_000_000_000),
])
tracks = await service.get_instant_mix("item-1")
assert tracks[0].duration_seconds == pytest.approx(500.0)
class TestPlexDiscoveryHubs:
@pytest.mark.asyncio
async def test_returns_album_hubs(self):
service, repo = _make_plex_service()
repo.get_hubs = AsyncMock(return_value=[
{
"type": "album",
"title": "Recently Added",
"Metadata": [
{
"ratingKey": "123",
"title": "Great Album",
"parentTitle": "Cool Artist",
"year": 2024,
"thumb": "/library/metadata/123/thumb",
}
],
}
])
result = await service.get_discovery_hubs(count=5)
assert len(result.hubs) == 1
assert result.hubs[0].title == "Recently Added"
assert len(result.hubs[0].albums) == 1
assert result.hubs[0].albums[0].name == "Great Album"
assert result.hubs[0].albums[0].artist_name == "Cool Artist"
@pytest.mark.asyncio
async def test_filters_non_album_hubs(self):
service, repo = _make_plex_service()
repo.get_hubs = AsyncMock(return_value=[
{"type": "artist", "title": "Top Artists", "Metadata": [{"ratingKey": "1", "title": "A"}]},
{"type": "album", "title": "New Albums", "Metadata": [
{"ratingKey": "2", "title": "B", "parentTitle": "ArtB", "year": 2023, "thumb": "/t"},
]},
])
result = await service.get_discovery_hubs()
assert len(result.hubs) == 1
assert result.hubs[0].title == "New Albums"
@pytest.mark.asyncio
async def test_skips_empty_hubs(self):
service, repo = _make_plex_service()
repo.get_hubs = AsyncMock(return_value=[
{"type": "album", "title": "Empty Hub", "Metadata": []},
])
result = await service.get_discovery_hubs()
assert len(result.hubs) == 0
@pytest.mark.asyncio
async def test_returns_empty_on_error(self):
service, repo = _make_plex_service()
repo.get_hubs = AsyncMock(side_effect=Exception("timeout"))
result = await service.get_discovery_hubs()
assert result.hubs == []
@pytest.mark.asyncio
async def test_returns_empty_when_no_section(self):
service, repo = _make_plex_service(section_ids=[])
result = await service.get_discovery_hubs()
assert result.hubs == []
repo.get_hubs.assert_not_awaited()
@pytest.mark.asyncio
async def test_image_url_includes_cover_proxy(self):
service, repo = _make_plex_service()
repo.get_hubs = AsyncMock(return_value=[
{
"type": "album",
"title": "Hub",
"Metadata": [
{"ratingKey": "9", "title": "X", "parentTitle": "Y", "thumb": "/lib/9/thumb"},
],
}
])
result = await service.get_discovery_hubs()
assert "/api/v1/plex/thumb/" in result.hubs[0].albums[0].image_url
@pytest.mark.asyncio
async def test_no_thumb_gives_none_image(self):
service, repo = _make_plex_service()
repo.get_hubs = AsyncMock(return_value=[
{
"type": "album",
"title": "Hub",
"Metadata": [
{"ratingKey": "10", "title": "NoThumb", "parentTitle": "A"},
],
}
])
result = await service.get_discovery_hubs()
assert result.hubs[0].albums[0].image_url is None

View file

@ -0,0 +1,218 @@
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from api.v1.schemas.jellyfin import (
JellyfinAlbumSummary,
JellyfinArtistSummary,
JellyfinFavoritesExpanded,
JellyfinHubResponse,
)
from repositories.jellyfin_models import JellyfinItem
from services.jellyfin_library_service import JellyfinLibraryService
def _make_service() -> tuple[JellyfinLibraryService, MagicMock]:
repo = MagicMock()
repo.is_configured.return_value = True
repo.get_albums = AsyncMock(return_value=([], 0))
repo.get_album_tracks = AsyncMock(return_value=[])
repo.get_album_detail = AsyncMock(return_value=None)
repo.get_album_by_mbid = AsyncMock(return_value=None)
repo.get_artists = AsyncMock(return_value=[])
repo.get_recently_played = AsyncMock(return_value=[])
repo.get_recently_added = AsyncMock(return_value=[])
repo.get_favorite_albums = AsyncMock(return_value=[])
repo.get_favorite_artists = AsyncMock(return_value=[])
repo.get_most_played_artists = AsyncMock(return_value=[])
repo.get_most_played_albums = AsyncMock(return_value=[])
repo.get_genres = AsyncMock(return_value=[])
repo.get_library_stats = AsyncMock(return_value={"Albums": 0, "Artists": 0, "Songs": 0})
repo.search_items = AsyncMock(return_value=[])
repo.get_image_url = MagicMock(return_value=None)
repo.get_playlists = AsyncMock(return_value=[])
prefs = MagicMock()
prefs.get_advanced_settings.return_value = MagicMock()
service = JellyfinLibraryService(
jellyfin_repo=repo,
preferences_service=prefs,
)
return service, repo
def _item(
id: str = "jf-1",
name: str = "Album",
type: str = "MusicAlbum",
artist_name: str = "Artist",
play_count: int = 0,
year: int | None = 2024,
child_count: int | None = 10,
album_count: int | None = None,
provider_ids: dict[str, str] | None = None,
image_tag: str | None = None,
) -> JellyfinItem:
return JellyfinItem(
id=id,
name=name,
type=type,
artist_name=artist_name,
play_count=play_count,
year=year,
child_count=child_count,
album_count=album_count,
provider_ids=provider_ids,
image_tag=image_tag,
)
class TestGetRecentlyAdded:
@pytest.mark.asyncio
async def test_returns_album_summaries(self):
service, repo = _make_service()
repo.get_recently_added = AsyncMock(return_value=[_item(id="a1", name="New Album")])
result = await service.get_recently_added(limit=10)
assert len(result) == 1
assert isinstance(result[0], JellyfinAlbumSummary)
assert result[0].jellyfin_id == "a1"
assert result[0].name == "New Album"
@pytest.mark.asyncio
async def test_returns_empty_list_when_none(self):
service, repo = _make_service()
repo.get_recently_added = AsyncMock(return_value=[])
result = await service.get_recently_added()
assert result == []
class TestGetMostPlayedArtists:
@pytest.mark.asyncio
async def test_returns_artist_summaries_with_play_count(self):
service, repo = _make_service()
repo.get_most_played_artists = AsyncMock(return_value=[
_item(id="ar1", name="Top Artist", type="MusicArtist", album_count=5, play_count=42),
])
result = await service.get_most_played_artists(limit=10)
assert len(result) == 1
assert isinstance(result[0], JellyfinArtistSummary)
assert result[0].jellyfin_id == "ar1"
assert result[0].name == "Top Artist"
assert result[0].play_count == 42
assert result[0].album_count == 5
@pytest.mark.asyncio
async def test_returns_empty_list_when_none(self):
service, repo = _make_service()
repo.get_most_played_artists = AsyncMock(return_value=[])
result = await service.get_most_played_artists()
assert result == []
class TestGetMostPlayedAlbums:
@pytest.mark.asyncio
async def test_returns_album_summaries_with_play_count(self):
service, repo = _make_service()
repo.get_most_played_albums = AsyncMock(return_value=[
_item(id="a1", name="Hot Album", play_count=99),
])
result = await service.get_most_played_albums(limit=10)
assert len(result) == 1
assert isinstance(result[0], JellyfinAlbumSummary)
assert result[0].play_count == 99
@pytest.mark.asyncio
async def test_returns_empty_list_when_none(self):
service, repo = _make_service()
repo.get_most_played_albums = AsyncMock(return_value=[])
result = await service.get_most_played_albums()
assert result == []
class TestHubDataNewShelves:
@pytest.mark.asyncio
async def test_hub_includes_recently_added(self):
service, repo = _make_service()
repo.get_recently_added = AsyncMock(return_value=[_item(id="ra1", name="New")])
hub = await service.get_hub_data()
assert isinstance(hub, JellyfinHubResponse)
assert len(hub.recently_added) == 1
assert hub.recently_added[0].jellyfin_id == "ra1"
@pytest.mark.asyncio
async def test_hub_includes_most_played_artists(self):
service, repo = _make_service()
repo.get_most_played_artists = AsyncMock(return_value=[
_item(id="ar1", name="Top", type="MusicArtist", album_count=3, play_count=10),
])
hub = await service.get_hub_data()
assert len(hub.most_played_artists) == 1
assert hub.most_played_artists[0].play_count == 10
@pytest.mark.asyncio
async def test_hub_includes_most_played_albums(self):
service, repo = _make_service()
repo.get_most_played_albums = AsyncMock(return_value=[
_item(id="a1", name="Hot", play_count=50),
])
hub = await service.get_hub_data()
assert len(hub.most_played_albums) == 1
assert hub.most_played_albums[0].play_count == 50
@pytest.mark.asyncio
async def test_hub_graceful_on_recently_added_error(self):
service, repo = _make_service()
repo.get_recently_added = AsyncMock(side_effect=Exception("timeout"))
hub = await service.get_hub_data()
assert hub.recently_added == []
@pytest.mark.asyncio
async def test_hub_graceful_on_most_played_errors(self):
service, repo = _make_service()
repo.get_most_played_artists = AsyncMock(side_effect=Exception("fail"))
repo.get_most_played_albums = AsyncMock(side_effect=Exception("fail"))
hub = await service.get_hub_data()
assert hub.most_played_artists == []
assert hub.most_played_albums == []
class TestGetFavoritesExpanded:
@pytest.mark.asyncio
async def test_returns_albums_and_artists(self):
service, repo = _make_service()
repo.get_favorite_albums = AsyncMock(return_value=[
_item(id="a1", name="Fav Album", type="MusicAlbum"),
])
repo.get_favorite_artists = AsyncMock(return_value=[
_item(id="ar1", name="Fav Artist", type="MusicArtist", album_count=3),
])
result = await service.get_favorites_expanded(limit=50)
assert isinstance(result, JellyfinFavoritesExpanded)
assert len(result.albums) == 1
assert result.albums[0].jellyfin_id == "a1"
assert len(result.artists) == 1
assert result.artists[0].jellyfin_id == "ar1"
assert result.artists[0].name == "Fav Artist"
assert result.artists[0].album_count == 3
@pytest.mark.asyncio
async def test_returns_empty_when_no_favorites(self):
service, repo = _make_service()
repo.get_favorite_albums = AsyncMock(return_value=[])
repo.get_favorite_artists = AsyncMock(return_value=[])
result = await service.get_favorites_expanded(limit=50)
assert result.albums == []
assert result.artists == []
@pytest.mark.asyncio
async def test_artist_summary_has_play_count(self):
service, repo = _make_service()
repo.get_favorite_artists = AsyncMock(return_value=[
_item(id="ar2", name="Played Artist", type="MusicArtist", play_count=77, album_count=2),
])
result = await service.get_favorites_expanded(limit=10)
assert len(result.artists) == 1
assert result.artists[0].play_count == 77

View file

@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from api.v1.schemas.navidrome import NavidromeArtistSummary, NavidromeTrackInfo
from repositories.navidrome_models import (
SubsonicAlbum,
SubsonicArtist,
@ -87,7 +88,6 @@ class TestGetAlbumDetail:
assert result.navidrome_id == "a1"
assert result.track_count == 2
assert result.tracks[0].title == "Comfortably Numb"
# Navidrome-native MBID is NOT exposed; only Lidarr-resolved MBIDs are canonical
assert result.musicbrainz_id is None
@pytest.mark.asyncio
@ -205,14 +205,13 @@ class TestGetStats:
async def test_aggregates_counts(self):
service, repo = _make_service()
repo.get_artists = AsyncMock(return_value=[_artist(), _artist(id="ar2")])
repo.get_album_list = AsyncMock(return_value=[_album()])
repo.get_genres = AsyncMock(return_value=[
SubsonicGenre(name="Rock", songCount=50),
SubsonicGenre(name="Pop", songCount=30),
repo.get_album_list = AsyncMock(return_value=[
_album(id="al1", song_count=50),
_album(id="al2", song_count=30),
])
result = await service.get_stats()
assert result.total_artists == 2
assert result.total_albums == 1
assert result.total_albums == 2
assert result.total_tracks == 80
@ -296,12 +295,11 @@ class TestLidarrAlbumMatching:
service._lidarr_album_index = {}
result1 = await service._resolve_album_mbid("Missing", "Artist")
assert result1 is None
# Second call should hit negative cache
service._lidarr_album_index = {
f"{_normalize('Missing')}:{_normalize('Artist')}": ("mbid-late", "mbid-a"),
}
result2 = await service._resolve_album_mbid("Missing", "Artist")
assert result2 is None # Still negative-cached
assert result2 is None
@pytest.mark.asyncio
async def test_empty_name_returns_none(self):
@ -375,10 +373,8 @@ class TestWarmMbidCacheLifecycle:
@pytest.mark.asyncio
async def test_negative_cache_overridden_when_lidarr_match_exists(self):
service, repo, cache = _make_service_with_cache()
# Seed a negative cache entry
key = f"{_normalize('Buzz')}:{_normalize('NIKI')}"
service._album_mbid_cache[key] = (None, 0.0)
# Lidarr now has a match
cache.get_all_albums_for_matching = AsyncMock(return_value=[
("Buzz", "NIKI", "mbid-buzz", "mbid-niki"),
])
@ -412,11 +408,55 @@ class TestWarmMbidCacheLifecycle:
key = f"{_normalize('Album')}:{_normalize('Artist')}"
cache.load_navidrome_album_mbid_index = AsyncMock(return_value={key: "mbid-disk"})
cache.load_navidrome_artist_mbid_index = AsyncMock(return_value={_normalize("Artist"): "mbid-ar-disk"})
# Provide Navidrome albums so reconciliation keeps disk entries and reverse index is built
repo.get_album_list = AsyncMock(return_value=[_album(id="nd-1", name="Album", artist="Artist")])
await service.warm_mbid_cache()
# Disk cache should be loaded even though Lidarr index is empty
assert service._album_mbid_cache[key] == "mbid-disk"
assert service._artist_mbid_cache[_normalize("Artist")] == "mbid-ar-disk"
# Reverse index should be built from disk cache (M2 fix)
assert service._mbid_to_navidrome_id.get("mbid-disk") == "nd-1"
class TestHubFavoritesExpansion:
@pytest.mark.asyncio
async def test_hub_includes_favorite_artists_and_tracks(self):
service, repo = _make_service()
starred = SubsonicSearchResult(
album=[_album(id="a1", name="Fav Album")],
artist=[_artist(id="ar1", name="Fav Artist")],
song=[_song(id="s1", title="Fav Song")],
)
repo.get_starred = AsyncMock(return_value=starred)
repo.get_album_list = AsyncMock(return_value=[])
repo.get_artists = AsyncMock(return_value=[])
repo.get_genres = AsyncMock(return_value=[])
hub = await service.get_hub_data()
assert len(hub.favorites) == 1
assert len(hub.favorite_artists) == 1
assert len(hub.favorite_tracks) == 1
assert isinstance(hub.favorite_artists[0], NavidromeArtistSummary)
assert isinstance(hub.favorite_tracks[0], NavidromeTrackInfo)
assert hub.favorite_artists[0].name == "Fav Artist"
assert hub.favorite_tracks[0].title == "Fav Song"
@pytest.mark.asyncio
async def test_hub_favorites_empty_when_no_starred(self):
service, repo = _make_service()
repo.get_starred = AsyncMock(return_value=SubsonicSearchResult())
repo.get_album_list = AsyncMock(return_value=[])
repo.get_artists = AsyncMock(return_value=[])
repo.get_genres = AsyncMock(return_value=[])
hub = await service.get_hub_data()
assert hub.favorites == []
assert hub.favorite_artists == []
assert hub.favorite_tracks == []
@pytest.mark.asyncio
async def test_hub_favorites_graceful_on_error(self):
service, repo = _make_service()
repo.get_starred = AsyncMock(side_effect=Exception("network error"))
repo.get_album_list = AsyncMock(return_value=[])
repo.get_artists = AsyncMock(return_value=[])
repo.get_genres = AsyncMock(return_value=[])
hub = await service.get_hub_data()
assert hub.favorites == []
assert hub.favorite_artists == []
assert hub.favorite_tracks == []

View file

@ -0,0 +1,321 @@
"""Tests for now-playing and session service methods."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, PropertyMock
import pytest
from repositories.plex_models import PlexSession
from repositories.navidrome_models import SubsonicNowPlayingEntry
from repositories.jellyfin_models import JellyfinSession
from services.plex_library_service import PlexLibraryService
from services.navidrome_library_service import NavidromeLibraryService
from services.jellyfin_library_service import JellyfinLibraryService
def _make_plex_service() -> tuple[PlexLibraryService, MagicMock]:
repo = MagicMock()
repo.get_sessions = AsyncMock(return_value=[])
repo.get_albums = AsyncMock(return_value=([], 0))
repo.get_artists = AsyncMock(return_value=[])
repo.get_album_metadata = AsyncMock()
repo.get_album_tracks = AsyncMock(return_value=[])
repo.get_recently_added = AsyncMock(return_value=[])
repo.get_recently_viewed = AsyncMock(return_value=[])
repo.get_genres = AsyncMock(return_value=[])
repo.get_track_count = AsyncMock(return_value=0)
repo.get_artist_count = AsyncMock(return_value=0)
repo.search = AsyncMock(return_value={"albums": [], "tracks": [], "artists": []})
type(repo).stats_ttl = PropertyMock(return_value=600)
prefs = MagicMock()
conn = MagicMock()
conn.enabled = True
conn.plex_url = "http://plex:32400"
conn.plex_token = "tok"
conn.music_library_ids = ["1"]
prefs.get_plex_connection_raw.return_value = conn
svc = PlexLibraryService(plex_repo=repo, preferences_service=prefs)
return svc, repo
def _plex_session(**overrides) -> PlexSession:
defaults = dict(
session_id="sess1",
user_name="alice",
track_title="Song A",
artist_name="Artist A",
album_name="Album A",
album_thumb="/library/metadata/200/thumb",
player_device="iPhone",
player_platform="iOS",
player_state="playing",
is_direct_play=True,
progress_ms=60000,
duration_ms=180000,
audio_codec="flac",
audio_channels=2,
bitrate=1411,
)
defaults.update(overrides)
return PlexSession(**defaults)
def _make_navidrome_service() -> tuple[NavidromeLibraryService, MagicMock]:
repo = MagicMock()
repo.get_now_playing = AsyncMock(return_value=[])
repo.get_albums = AsyncMock(return_value=[])
repo.get_album_info = AsyncMock()
repo.get_album_tracks = AsyncMock(return_value=[])
repo.get_starred = AsyncMock()
repo.get_artists = AsyncMock(return_value=[])
repo.get_artist = AsyncMock()
repo.get_artist_info = AsyncMock()
repo.search = AsyncMock()
repo.get_genres = AsyncMock(return_value=[])
repo.now_playing = AsyncMock(return_value=True)
repo.get_playlists = AsyncMock(return_value=[])
repo.get_playlist = AsyncMock()
repo.get_random_songs = AsyncMock(return_value=[])
prefs = MagicMock()
prefs.get_navidrome_connection_raw.return_value = MagicMock(enabled=True)
svc = NavidromeLibraryService(navidrome_repo=repo, preferences_service=prefs)
return svc, repo
def _navidrome_entry(**overrides) -> SubsonicNowPlayingEntry:
defaults = dict(
id="np1",
title="Song N",
artist="Artist N",
album="Album N",
albumId="al1",
artistId="ar1",
coverArt="cov1",
duration=240,
bitRate=320,
suffix="mp3",
username="bob",
minutesAgo=2,
playerId=1,
playerName="Firefox",
)
defaults.update(overrides)
return SubsonicNowPlayingEntry(**defaults)
def _make_jellyfin_service() -> tuple[JellyfinLibraryService, MagicMock]:
repo = MagicMock()
repo.get_sessions = AsyncMock(return_value=[])
repo.get_recently_played = AsyncMock(return_value=[])
repo.get_favorites = AsyncMock(return_value=[])
repo.get_albums = AsyncMock(return_value=[])
repo.get_artists = AsyncMock(return_value=[])
repo.get_album = AsyncMock()
repo.get_album_tracks = AsyncMock(return_value=[])
repo.search = AsyncMock()
repo.get_genres = AsyncMock(return_value=[])
repo.get_recently_added = AsyncMock(return_value=[])
repo.get_most_played_artists = AsyncMock(return_value=[])
repo.get_most_played_albums = AsyncMock(return_value=[])
repo.get_playlists = AsyncMock(return_value=[])
repo.get_playlist = AsyncMock()
repo.get_image_url = MagicMock(return_value="https://jellyfin/Items/img/Primary")
prefs = MagicMock()
prefs.get_jellyfin_connection_raw.return_value = MagicMock(enabled=True)
svc = JellyfinLibraryService(jellyfin_repo=repo, preferences_service=prefs)
return svc, repo
def _jellyfin_session(**overrides) -> JellyfinSession:
defaults = dict(
session_id="jsess1",
user_name="carol",
device_name="Chrome",
client_name="Jellyfin Web",
now_playing_name="Song J",
now_playing_artist="Artist J",
now_playing_album="Album J",
now_playing_album_id="jalb1",
now_playing_item_id="jitem1",
now_playing_image_tag="tag1",
position_ticks=600_000_000,
runtime_ticks=3_000_000_000,
is_paused=False,
is_muted=False,
play_method="DirectPlay",
audio_codec="aac",
bitrate=256,
)
defaults.update(overrides)
return JellyfinSession(**defaults)
class TestPlexGetSessions:
@pytest.mark.asyncio
async def test_returns_mapped_sessions(self):
svc, repo = _make_plex_service()
repo.get_sessions.return_value = [_plex_session()]
result = await svc.get_sessions()
assert len(result.sessions) == 1
s = result.sessions[0]
assert s.session_id == "sess1"
assert s.user_name == "alice"
assert s.track_title == "Song A"
assert s.artist_name == "Artist A"
assert s.cover_url == "/api/v1/plex/thumb//library/metadata/200/thumb"
assert s.player_state == "playing"
assert s.progress_ms == 60000
assert s.duration_ms == 180000
@pytest.mark.asyncio
async def test_empty_sessions(self):
svc, repo = _make_plex_service()
repo.get_sessions.return_value = []
result = await svc.get_sessions()
assert result.sessions == []
@pytest.mark.asyncio
async def test_error_returns_empty(self):
svc, repo = _make_plex_service()
repo.get_sessions.side_effect = RuntimeError("Connection refused")
result = await svc.get_sessions()
assert result.sessions == []
@pytest.mark.asyncio
async def test_no_cover_when_no_album_thumb(self):
svc, repo = _make_plex_service()
repo.get_sessions.return_value = [_plex_session(album_thumb="")]
result = await svc.get_sessions()
assert result.sessions[0].cover_url == ""
class TestNavidromeGetNowPlaying:
@pytest.mark.asyncio
async def test_returns_mapped_entries(self):
svc, repo = _make_navidrome_service()
repo.get_now_playing.return_value = [_navidrome_entry()]
result = await svc.get_now_playing()
assert len(result.entries) == 1
e = result.entries[0]
assert e.user_name == "bob"
assert e.track_name == "Song N"
assert e.artist_name == "Artist N"
assert e.album_name == "Album N"
assert e.cover_art_id == "cov1"
assert e.duration_seconds == 240
assert e.minutes_ago == 2
assert e.player_name == "Firefox"
@pytest.mark.asyncio
async def test_empty_entries(self):
svc, repo = _make_navidrome_service()
repo.get_now_playing.return_value = []
result = await svc.get_now_playing()
assert result.entries == []
@pytest.mark.asyncio
async def test_error_returns_empty(self):
svc, repo = _make_navidrome_service()
repo.get_now_playing.side_effect = RuntimeError("timeout")
result = await svc.get_now_playing()
assert result.entries == []
@pytest.mark.asyncio
async def test_multiple_entries(self):
svc, repo = _make_navidrome_service()
repo.get_now_playing.return_value = [
_navidrome_entry(username="bob"),
_navidrome_entry(username="charlie", title="Song X"),
]
result = await svc.get_now_playing()
assert len(result.entries) == 2
assert result.entries[0].user_name == "bob"
assert result.entries[1].user_name == "charlie"
class TestJellyfinGetSessions:
@pytest.mark.asyncio
async def test_returns_mapped_sessions(self):
svc, repo = _make_jellyfin_service()
repo.get_sessions.return_value = [_jellyfin_session()]
result = await svc.get_sessions()
assert len(result.sessions) == 1
s = result.sessions[0]
assert s.session_id == "jsess1"
assert s.user_name == "carol"
assert s.track_name == "Song J"
assert s.artist_name == "Artist J"
assert s.device_name == "Chrome"
assert s.cover_url == "/api/v1/jellyfin/image/jitem1"
assert s.is_paused is False
assert s.play_method == "DirectPlay"
@pytest.mark.asyncio
async def test_ticks_to_seconds_conversion(self):
svc, repo = _make_jellyfin_service()
repo.get_sessions.return_value = [_jellyfin_session(
position_ticks=600_000_000,
runtime_ticks=3_000_000_000,
)]
result = await svc.get_sessions()
s = result.sessions[0]
assert s.position_seconds == pytest.approx(60.0, rel=1e-3)
assert s.duration_seconds == pytest.approx(300.0, rel=1e-3)
@pytest.mark.asyncio
async def test_empty_sessions(self):
svc, repo = _make_jellyfin_service()
repo.get_sessions.return_value = []
result = await svc.get_sessions()
assert result.sessions == []
@pytest.mark.asyncio
async def test_error_returns_empty(self):
svc, repo = _make_jellyfin_service()
repo.get_sessions.side_effect = RuntimeError("conn refused")
result = await svc.get_sessions()
assert result.sessions == []
@pytest.mark.asyncio
async def test_zero_ticks_returns_zero_seconds(self):
svc, repo = _make_jellyfin_service()
repo.get_sessions.return_value = [_jellyfin_session(
position_ticks=0,
runtime_ticks=0,
)]
result = await svc.get_sessions()
s = result.sessions[0]
assert s.position_seconds == 0.0
assert s.duration_seconds == 0.0

View file

@ -49,7 +49,7 @@ class TestCreatePlaylist:
service, repo = _make_service(tmp_path)
result = await service.create_playlist("My Playlist")
assert result.name == "Test"
repo.create_playlist.assert_called_once_with("My Playlist")
repo.create_playlist.assert_called_once_with("My Playlist", None)
@pytest.mark.asyncio
async def test_empty_name(self, tmp_path):
@ -67,7 +67,7 @@ class TestCreatePlaylist:
async def test_strips_whitespace(self, tmp_path):
service, repo = _make_service(tmp_path)
await service.create_playlist(" Hello ")
repo.create_playlist.assert_called_once_with("Hello")
repo.create_playlist.assert_called_once_with("Hello", None)
class TestGetPlaylist:

View file

@ -267,7 +267,7 @@ class TestStringTrackNumberRegression:
)
await cache.set("source_resolution:mbid-abc", stale_data, ttl_seconds=3600)
jf, local, nd = await service._resolve_album_sources(
jf, local, nd, plex = await service._resolve_album_sources(
"mbid-abc", None, None, None,
)

View file

@ -0,0 +1,61 @@
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from services.home.integration_helpers import HomeIntegrationHelpers
def _make_plex_conn(enabled=True, url="http://plex:32400", token="tok", library_ids=None):
conn = MagicMock()
conn.enabled = enabled
conn.plex_url = url
conn.plex_token = token
conn.music_library_ids = library_ids if library_ids is not None else ["1"]
return conn
def _make_helpers(**overrides):
prefs = MagicMock()
prefs.get_plex_connection.return_value = _make_plex_conn(**overrides)
prefs.get_jellyfin_connection.return_value = MagicMock(enabled=False)
prefs.get_navidrome_connection.return_value = MagicMock(enabled=False, navidrome_url="", username="", password="")
prefs.get_listenbrainz_connection.return_value = MagicMock(enabled=False)
prefs.is_lastfm_enabled.return_value = False
prefs.get_lastfm_connection.return_value = MagicMock(enabled=False)
prefs.get_local_files_connection.return_value = MagicMock(enabled=False, music_path="")
return HomeIntegrationHelpers(prefs)
class TestIsPlexEnabled:
def test_enabled_with_all_fields(self):
helpers = _make_helpers(enabled=True, url="http://plex:32400", token="tok", library_ids=["1"])
assert helpers.is_plex_enabled() is True
def test_disabled_when_flag_off(self):
helpers = _make_helpers(enabled=False)
assert helpers.is_plex_enabled() is False
def test_disabled_when_no_url(self):
helpers = _make_helpers(url="")
assert helpers.is_plex_enabled() is False
def test_disabled_when_no_token(self):
helpers = _make_helpers(token="")
assert helpers.is_plex_enabled() is False
def test_disabled_when_no_library_ids(self):
helpers = _make_helpers(library_ids=[])
assert helpers.is_plex_enabled() is False
def test_disabled_when_library_ids_none(self):
conn = MagicMock()
conn.enabled = True
conn.plex_url = "http://plex:32400"
conn.plex_token = "tok"
conn.music_library_ids = None
prefs = MagicMock()
prefs.get_plex_connection.return_value = conn
helpers = HomeIntegrationHelpers(prefs)
assert helpers.is_plex_enabled() is False

View file

@ -0,0 +1,411 @@
from __future__ import annotations
import time
from unittest.mock import AsyncMock, MagicMock, PropertyMock
import pytest
from api.v1.schemas.plex import (
PlexAlbumDetail,
PlexAlbumMatch,
PlexAlbumSummary,
PlexArtistSummary,
PlexLibraryStats,
PlexSearchResponse,
PlexTrackInfo,
)
from repositories.plex_models import PlexAlbum, PlexArtist, PlexTrack
from services.plex_library_service import PlexLibraryService
def _make_plex_conn(enabled: bool = True, url: str = "http://plex:32400", token: str = "tok", library_ids: list[str] | None = None):
conn = MagicMock()
conn.enabled = enabled
conn.plex_url = url
conn.plex_token = token
conn.music_library_ids = library_ids or ["1"]
return conn
def _make_service(
configured: bool = True,
section_ids: list[str] | None = None,
) -> tuple[PlexLibraryService, MagicMock, MagicMock]:
repo = MagicMock()
repo.get_albums = AsyncMock(return_value=([], 0))
repo.get_artists = AsyncMock(return_value=[])
repo.get_album_metadata = AsyncMock()
repo.get_album_tracks = AsyncMock(return_value=[])
repo.get_recently_added = AsyncMock(return_value=[])
repo.get_recently_viewed = AsyncMock(return_value=[])
repo.get_genres = AsyncMock(return_value=[])
repo.get_track_count = AsyncMock(return_value=0)
repo.get_artist_count = AsyncMock(return_value=0)
repo.search = AsyncMock(return_value={"albums": [], "tracks": [], "artists": []})
type(repo).stats_ttl = PropertyMock(return_value=600)
prefs = MagicMock()
ids = section_ids if section_ids is not None else (["1"] if configured else [])
prefs.get_plex_connection_raw.return_value = _make_plex_conn(
enabled=configured, library_ids=ids,
)
service = PlexLibraryService(
plex_repo=repo,
preferences_service=prefs,
)
return service, repo, prefs
def _album(key: str = "100", title: str = "Album", parent: str = "Artist") -> PlexAlbum:
return PlexAlbum(
ratingKey=key,
title=title,
parentTitle=parent,
leafCount=10,
year=2024,
)
def _artist(key: str = "50", title: str = "Artist") -> PlexArtist:
return PlexArtist(ratingKey=key, title=title)
def _track(key: str = "200", title: str = "Track 1") -> PlexTrack:
return PlexTrack(
ratingKey=key,
title=title,
index=1,
duration=180000,
)
class TestGetAlbums:
@pytest.mark.asyncio
async def test_returns_albums_and_total(self):
service, repo, _ = _make_service()
repo.get_albums = AsyncMock(return_value=([_album()], 42))
items, total = await service.get_albums(size=50, offset=0)
assert len(items) == 1
assert total == 42
assert isinstance(items[0], PlexAlbumSummary)
@pytest.mark.asyncio
async def test_empty_when_no_sections(self):
service, _, _ = _make_service(configured=False)
items, total = await service.get_albums()
assert items == []
assert total == 0
@pytest.mark.asyncio
async def test_deduplicates_across_sections(self):
service, repo, _ = _make_service(section_ids=["1", "2"])
album = _album(key="100")
repo.get_albums = AsyncMock(return_value=([album], 1))
items, total = await service.get_albums()
assert len(items) == 1
@pytest.mark.asyncio
async def test_filters_unknown_titles(self):
service, repo, _ = _make_service()
albums = [_album(key="1", title="Unknown"), _album(key="2", title="Real Album")]
repo.get_albums = AsyncMock(return_value=(albums, 2))
items, _ = await service.get_albums()
assert all(i.name != "Unknown" for i in items)
class TestGetAlbumDetail:
@pytest.mark.asyncio
async def test_found(self):
service, repo, _ = _make_service()
repo.get_album_metadata = AsyncMock(return_value=_album())
repo.get_album_tracks = AsyncMock(return_value=[_track()])
detail = await service.get_album_detail("100")
assert detail is not None
assert isinstance(detail, PlexAlbumDetail)
assert detail.plex_id == "100"
assert len(detail.tracks) == 1
@pytest.mark.asyncio
async def test_not_found(self):
service, repo, _ = _make_service()
repo.get_album_metadata = AsyncMock(side_effect=Exception("not found"))
detail = await service.get_album_detail("missing")
assert detail is None
class TestGetArtists:
@pytest.mark.asyncio
async def test_returns_artists(self):
service, repo, _ = _make_service()
repo.get_artists = AsyncMock(return_value=[_artist()])
result = await service.get_artists()
assert len(result) == 1
assert isinstance(result[0], PlexArtistSummary)
@pytest.mark.asyncio
async def test_empty_when_no_sections(self):
service, _, _ = _make_service(configured=False)
result = await service.get_artists()
assert result == []
class TestSearch:
@pytest.mark.asyncio
async def test_returns_search_results(self):
service, repo, _ = _make_service()
repo.search = AsyncMock(return_value={
"albums": [_album()],
"tracks": [_track()],
"artists": [_artist()],
})
result = await service.search("test")
assert isinstance(result, PlexSearchResponse)
assert len(result.albums) == 1
assert len(result.tracks) == 1
assert len(result.artists) == 1
@pytest.mark.asyncio
async def test_empty_when_no_sections(self):
service, _, _ = _make_service(configured=False)
result = await service.search("test")
assert result.albums == []
assert result.artists == []
assert result.tracks == []
@pytest.mark.asyncio
async def test_deduplicates_across_sections(self):
service, repo, _ = _make_service(section_ids=["1", "2"])
repo.search = AsyncMock(return_value={
"albums": [_album(key="100")],
"tracks": [],
"artists": [],
})
result = await service.search("test")
assert len(result.albums) == 1
class TestGetRecent:
@pytest.mark.asyncio
async def test_returns_recently_viewed_when_available(self):
service, repo, _ = _make_service()
viewed_album = _album(key="200", title="Viewed Album")
viewed_album.lastViewedAt = 9999
repo.get_recently_viewed = AsyncMock(return_value=[viewed_album])
repo.get_recently_added = AsyncMock(return_value=[_album()])
result = await service.get_recent(limit=20)
assert len(result) == 1
assert result[0].name == "Viewed Album"
repo.get_recently_added.assert_not_called()
@pytest.mark.asyncio
async def test_falls_back_to_recently_added_when_viewed_empty(self):
service, repo, _ = _make_service()
repo.get_recently_viewed = AsyncMock(return_value=[])
repo.get_recently_added = AsyncMock(return_value=[_album()])
result = await service.get_recent(limit=20)
assert len(result) == 1
repo.get_recently_added.assert_called_once()
@pytest.mark.asyncio
async def test_empty_when_no_sections(self):
service, _, _ = _make_service(configured=False)
result = await service.get_recent()
assert result == []
class TestGetGenres:
@pytest.mark.asyncio
async def test_returns_sorted_genres(self):
service, repo, _ = _make_service()
repo.get_genres = AsyncMock(return_value=["Rock", "Jazz", "Blues"])
result = await service.get_genres()
assert result == ["Blues", "Jazz", "Rock"]
@pytest.mark.asyncio
async def test_deduplicates_across_sections(self):
service, repo, _ = _make_service(section_ids=["1", "2"])
call_count = 0
async def side_effect(section_id: str):
nonlocal call_count
call_count += 1
if call_count == 1:
return ["Rock", "Jazz"]
return ["Jazz", "Pop"]
repo.get_genres = AsyncMock(side_effect=side_effect)
result = await service.get_genres()
assert result == ["Jazz", "Pop", "Rock"]
@pytest.mark.asyncio
async def test_empty_when_no_sections(self):
service, _, _ = _make_service(configured=False)
result = await service.get_genres()
assert result == []
class TestGetStats:
@pytest.mark.asyncio
async def test_returns_stats(self):
service, repo, _ = _make_service()
repo.get_albums = AsyncMock(return_value=([], 10))
repo.get_track_count = AsyncMock(return_value=100)
repo.get_artist_count = AsyncMock(return_value=5)
stats = await service.get_stats()
assert isinstance(stats, PlexLibraryStats)
assert stats.total_albums == 10
assert stats.total_tracks == 100
assert stats.total_artists == 5
@pytest.mark.asyncio
async def test_caching(self):
service, repo, _ = _make_service()
repo.get_albums = AsyncMock(return_value=([], 5))
repo.get_track_count = AsyncMock(return_value=50)
repo.get_artist_count = AsyncMock(return_value=3)
stats1 = await service.get_stats()
repo.get_albums.reset_mock()
repo.get_track_count.reset_mock()
repo.get_artist_count.reset_mock()
stats2 = await service.get_stats()
assert stats1 == stats2
repo.get_albums.assert_not_awaited()
repo.get_track_count.assert_not_awaited()
repo.get_artist_count.assert_not_awaited()
@pytest.mark.asyncio
async def test_cache_expiry(self):
service, repo, _ = _make_service()
repo.get_albums = AsyncMock(return_value=([], 5))
repo.get_track_count = AsyncMock(return_value=50)
repo.get_artist_count = AsyncMock(return_value=3)
await service.get_stats()
repo.get_albums.reset_mock()
repo.get_track_count.reset_mock()
repo.get_artist_count.reset_mock()
service._stats_cache_ts -= 700
await service.get_stats()
repo.get_albums.assert_awaited()
@pytest.mark.asyncio
async def test_empty_when_no_sections(self):
service, _, _ = _make_service(configured=False)
stats = await service.get_stats()
assert stats.total_tracks == 0
assert stats.total_albums == 0
assert stats.total_artists == 0
class TestGetAlbumMatch:
@pytest.mark.asyncio
async def test_mbid_cache_hit(self):
service, repo, _ = _make_service()
service._mbid_to_plex_id["mbid-123"] = "plex-456"
repo.get_album_metadata = AsyncMock(return_value=_album(key="plex-456"))
repo.get_album_tracks = AsyncMock(return_value=[_track()])
result = await service.get_album_match("mbid-123", "Album", "Artist")
assert result.found is True
assert result.plex_album_id == "plex-456"
@pytest.mark.asyncio
async def test_not_found(self):
service, repo, _ = _make_service()
repo.search = AsyncMock(return_value={"albums": [], "tracks": [], "artists": []})
result = await service.get_album_match("", "Nonexistent", "Nobody")
assert result.found is False
@pytest.mark.asyncio
async def test_no_sections_returns_not_found(self):
service, _, _ = _make_service(configured=False)
result = await service.get_album_match("mbid", "Album", "Artist")
assert result.found is False
class TestLookupPlexId:
def test_returns_cached_id(self):
service, _, _ = _make_service()
service._mbid_to_plex_id["mbid-1"] = "plex-1"
assert service.lookup_plex_id("mbid-1") == "plex-1"
def test_returns_none_when_not_cached(self):
service, _, _ = _make_service()
assert service.lookup_plex_id("mbid-missing") is None
class TestGetRecentlyPlayed:
@pytest.mark.asyncio
async def test_returns_sorted_by_last_viewed(self):
service, repo, _ = _make_service()
a1 = _album(key="1", title="Older")
a1.lastViewedAt = 1000
a2 = _album(key="2", title="Newer")
a2.lastViewedAt = 2000
repo.get_recently_viewed = AsyncMock(return_value=[a1, a2])
result = await service.get_recently_played(limit=20)
assert len(result) == 2
assert result[0].plex_id == "2"
assert result[1].plex_id == "1"
@pytest.mark.asyncio
async def test_empty_when_no_sections(self):
service, _, _ = _make_service(configured=False)
result = await service.get_recently_played()
assert result == []
@pytest.mark.asyncio
async def test_filters_unknown_titles(self):
service, repo, _ = _make_service()
a = _album(key="1", title="Unknown")
a.lastViewedAt = 1000
repo.get_recently_viewed = AsyncMock(return_value=[a])
result = await service.get_recently_played()
assert result == []
@pytest.mark.asyncio
async def test_maps_last_viewed_at(self):
service, repo, _ = _make_service()
a = _album(key="1", title="Album")
a.lastViewedAt = 5000
repo.get_recently_viewed = AsyncMock(return_value=[a])
result = await service.get_recently_played()
assert len(result) == 1
assert result[0].last_viewed_at == 5000
class TestGetRecentlyAddedAlbums:
@pytest.mark.asyncio
async def test_returns_sorted_by_added_at(self):
service, repo, _ = _make_service()
a1 = _album(key="1", title="Old")
a1.addedAt = 100
a2 = _album(key="2", title="New")
a2.addedAt = 200
repo.get_recently_added = AsyncMock(return_value=[a1, a2])
result = await service.get_recently_added_albums(limit=20)
assert len(result) == 2
assert result[0].plex_id == "2"
assert result[1].plex_id == "1"
@pytest.mark.asyncio
async def test_empty_when_no_sections(self):
service, _, _ = _make_service(configured=False)
result = await service.get_recently_added_albums()
assert result == []
@pytest.mark.asyncio
async def test_returns_plex_album_summaries(self):
service, repo, _ = _make_service()
a = _album(key="10", title="Fresh")
a.addedAt = 999
repo.get_recently_added = AsyncMock(return_value=[a])
result = await service.get_recently_added_albums()
assert len(result) == 1
assert isinstance(result[0], PlexAlbumSummary)
assert result[0].plex_id == "10"

View file

@ -0,0 +1,128 @@
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from repositories.plex_models import StreamProxyResult
from services.plex_playback_service import PlexPlaybackService
def _make_service() -> tuple[PlexPlaybackService, MagicMock]:
repo = MagicMock()
repo.scrobble = AsyncMock(return_value=True)
repo.now_playing = AsyncMock(return_value=True)
repo.proxy_thumb = AsyncMock(return_value=(b"\x89PNG", "image/png"))
repo.proxy_head_stream = AsyncMock(
return_value=StreamProxyResult(status_code=200, headers={"Content-Type": "audio/flac"}, media_type="audio/flac")
)
repo.proxy_get_stream = AsyncMock(
return_value=StreamProxyResult(status_code=200, headers={}, media_type="audio/mpeg", body_chunks=iter([b"data"]))
)
service = PlexPlaybackService(plex_repo=repo)
return service, repo
class TestScrobble:
@pytest.mark.asyncio
async def test_success(self):
service, repo = _make_service()
result = await service.scrobble("12345")
assert result is True
repo.scrobble.assert_awaited_once_with("12345")
@pytest.mark.asyncio
async def test_exception_returns_false(self):
service, repo = _make_service()
repo.scrobble = AsyncMock(side_effect=Exception("network error"))
result = await service.scrobble("12345")
assert result is False
@pytest.mark.asyncio
async def test_repo_returns_false(self):
service, repo = _make_service()
repo.scrobble = AsyncMock(return_value=False)
result = await service.scrobble("12345")
assert result is False
class TestReportNowPlaying:
@pytest.mark.asyncio
async def test_success(self):
service, repo = _make_service()
result = await service.report_now_playing("12345")
assert result is True
repo.now_playing.assert_awaited_once_with("12345")
@pytest.mark.asyncio
async def test_exception_returns_false(self):
service, repo = _make_service()
repo.now_playing = AsyncMock(side_effect=Exception("timeout"))
result = await service.report_now_playing("12345")
assert result is False
class TestProxyThumb:
@pytest.mark.asyncio
async def test_delegates_to_repo(self):
service, repo = _make_service()
content, ctype = await service.proxy_thumb("123", size=300)
assert content == b"\x89PNG"
assert ctype == "image/png"
repo.proxy_thumb.assert_awaited_once_with("123", size=300)
class TestProxyHead:
@pytest.mark.asyncio
async def test_returns_response(self):
service, _ = _make_service()
response = await service.proxy_head("/library/parts/1/2/file.flac")
assert response.status_code == 200
@pytest.mark.asyncio
async def test_returns_non_200_status(self):
service, repo = _make_service()
repo.proxy_head_stream = AsyncMock(
return_value=StreamProxyResult(status_code=404, headers={}, media_type=None)
)
response = await service.proxy_head("/library/parts/1/2/file.flac")
assert response.status_code == 404
class TestProxyStream:
@pytest.mark.asyncio
async def test_returns_streaming_response(self):
service, repo = _make_service()
async def _chunks():
yield b"audio-data"
repo.proxy_get_stream = AsyncMock(
return_value=StreamProxyResult(
status_code=200,
headers={"Content-Type": "audio/flac"},
media_type="audio/flac",
body_chunks=_chunks(),
)
)
response = await service.proxy_stream("/library/parts/1/2/file.flac")
assert response.status_code == 200
@pytest.mark.asyncio
async def test_passes_range_header(self):
service, repo = _make_service()
async def _chunks():
yield b"partial"
repo.proxy_get_stream = AsyncMock(
return_value=StreamProxyResult(
status_code=206,
headers={"Content-Range": "bytes 0-100/200"},
media_type="audio/mpeg",
body_chunks=_chunks(),
)
)
response = await service.proxy_stream("/part/key", range_header="bytes=0-100")
repo.proxy_get_stream.assert_awaited_once_with("/part/key", range_header="bytes=0-100")
assert response.status_code == 206

View file

@ -0,0 +1,171 @@
from __future__ import annotations
import os
import tempfile
os.environ.setdefault("ROOT_APP_DIR", tempfile.mkdtemp())
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from api.v1.schemas.settings import PlexConnectionSettings
from services.settings_service import SettingsService
def _make_settings_service() -> tuple[SettingsService, MagicMock, MagicMock]:
cache = MagicMock()
cache.clear_prefix = AsyncMock()
prefs = MagicMock()
service = SettingsService(
preferences_service=prefs,
cache=cache,
)
return service, cache, prefs
def _patch_plex_dependencies():
"""Return a context manager that patches all dependencies used inside on_plex_settings_changed."""
mock_repo_class = MagicMock()
mock_repo_class.reset_circuit_breaker = MagicMock()
mock_new_repo = MagicMock()
mock_new_repo.clear_cache = AsyncMock()
mock_get_repo = MagicMock(return_value=mock_new_repo)
mock_get_repo.cache_clear = MagicMock()
mock_get_lib = MagicMock()
mock_get_lib.cache_clear = MagicMock()
mock_get_pb = MagicMock()
mock_get_pb.cache_clear = MagicMock()
mock_get_home = MagicMock()
mock_get_home.cache_clear = MagicMock()
mock_get_charts = MagicMock()
mock_get_charts.cache_clear = MagicMock()
mbid_store = MagicMock()
mbid_store.clear_plex_mbid_indexes = AsyncMock()
mock_get_mbid = MagicMock(return_value=mbid_store)
return {
"repo_class": mock_repo_class,
"new_repo": mock_new_repo,
"get_repo": mock_get_repo,
"get_lib": mock_get_lib,
"get_pb": mock_get_pb,
"get_home": mock_get_home,
"get_charts": mock_get_charts,
"mbid_store": mbid_store,
"get_mbid": mock_get_mbid,
}
class TestOnPlexSettingsChanged:
@pytest.mark.asyncio
async def test_resets_caches(self):
service, cache, _ = _make_settings_service()
mocks = _patch_plex_dependencies()
with patch("repositories.plex_repository.PlexRepository", mocks["repo_class"]), \
patch("core.dependencies.get_plex_repository", mocks["get_repo"]), \
patch("core.dependencies.get_plex_library_service", mocks["get_lib"]), \
patch("core.dependencies.get_plex_playback_service", mocks["get_pb"]), \
patch("core.dependencies.get_home_service", mocks["get_home"]), \
patch("core.dependencies.get_home_charts_service", mocks["get_charts"]), \
patch("core.dependencies.get_mbid_store", mocks["get_mbid"]):
await service.on_plex_settings_changed(enabled=False)
mocks["repo_class"].reset_circuit_breaker.assert_called_once()
mocks["get_repo"].cache_clear.assert_called_once()
mocks["get_lib"].cache_clear.assert_called_once()
mocks["get_pb"].cache_clear.assert_called_once()
mocks["new_repo"].clear_cache.assert_awaited_once()
mocks["mbid_store"].clear_plex_mbid_indexes.assert_awaited_once()
@pytest.mark.asyncio
async def test_triggers_warmup_when_enabled(self):
service, cache, _ = _make_settings_service()
mocks = _patch_plex_dependencies()
registry = MagicMock()
registry.is_running.return_value = False
with patch("repositories.plex_repository.PlexRepository", mocks["repo_class"]), \
patch("core.dependencies.get_plex_repository", mocks["get_repo"]), \
patch("core.dependencies.get_plex_library_service", mocks["get_lib"]), \
patch("core.dependencies.get_plex_playback_service", mocks["get_pb"]), \
patch("core.dependencies.get_home_service", mocks["get_home"]), \
patch("core.dependencies.get_home_charts_service", mocks["get_charts"]), \
patch("core.dependencies.get_mbid_store", mocks["get_mbid"]), \
patch("core.task_registry.TaskRegistry.get_instance", return_value=registry), \
patch("core.tasks.warm_plex_mbid_cache", new=AsyncMock()):
await service.on_plex_settings_changed(enabled=True)
registry.is_running.assert_called_with("plex-mbid-warmup")
@pytest.mark.asyncio
async def test_skips_warmup_if_already_running(self):
service, cache, _ = _make_settings_service()
mocks = _patch_plex_dependencies()
registry = MagicMock()
registry.is_running.return_value = True
with patch("repositories.plex_repository.PlexRepository", mocks["repo_class"]), \
patch("core.dependencies.get_plex_repository", mocks["get_repo"]), \
patch("core.dependencies.get_plex_library_service", mocks["get_lib"]), \
patch("core.dependencies.get_plex_playback_service", mocks["get_pb"]), \
patch("core.dependencies.get_home_service", mocks["get_home"]), \
patch("core.dependencies.get_home_charts_service", mocks["get_charts"]), \
patch("core.dependencies.get_mbid_store", mocks["get_mbid"]), \
patch("core.task_registry.TaskRegistry.get_instance", return_value=registry):
await service.on_plex_settings_changed(enabled=True)
registry.register.assert_not_called()
class TestVerifyPlex:
@pytest.mark.asyncio
async def test_returns_valid_on_success(self):
service, _, prefs = _make_settings_service()
prefs.get_setting.return_value = "client-id"
mock_repo = MagicMock()
mock_repo.configure = MagicMock()
mock_repo.validate_connection = AsyncMock(return_value=(True, "OK"))
section = MagicMock(key="1", title="Music")
mock_repo.get_music_libraries = AsyncMock(return_value=[section])
mock_cls = MagicMock(return_value=mock_repo)
mock_cls.reset_circuit_breaker = MagicMock()
with patch("repositories.plex_repository.PlexRepository", mock_cls), \
patch("core.config.get_settings"), \
patch("infrastructure.http.client.get_http_client"):
settings = PlexConnectionSettings(plex_url="http://plex:32400", plex_token="tok")
result = await service.verify_plex(settings)
assert result.valid is True
assert result.libraries == [("1", "Music")]
@pytest.mark.asyncio
async def test_returns_invalid_on_connection_failure(self):
service, _, prefs = _make_settings_service()
prefs.get_setting.return_value = "client-id"
mock_repo = MagicMock()
mock_repo.configure = MagicMock()
mock_repo.validate_connection = AsyncMock(return_value=(False, "refused"))
mock_cls = MagicMock(return_value=mock_repo)
mock_cls.reset_circuit_breaker = MagicMock()
with patch("repositories.plex_repository.PlexRepository", mock_cls), \
patch("core.config.get_settings"), \
patch("infrastructure.http.client.get_http_client"):
settings = PlexConnectionSettings(plex_url="http://plex:32400", plex_token="tok")
result = await service.verify_plex(settings)
assert result.valid is False
assert result.libraries == []

View file

@ -0,0 +1,330 @@
"""Tests for source playlist list, detail, and import across Plex, Navidrome, and Jellyfin."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, PropertyMock
import pytest
from repositories.plex_models import PlexPlaylist, PlexTrack
from repositories.navidrome_models import SubsonicPlaylist, SubsonicSong
from repositories.jellyfin_models import JellyfinItem
from repositories.playlist_repository import PlaylistRecord
from services.plex_library_service import PlexLibraryService
from services.navidrome_library_service import NavidromeLibraryService
from services.jellyfin_library_service import JellyfinLibraryService
def _mock_playlist_service(existing: PlaylistRecord | None = None) -> MagicMock:
svc = MagicMock()
svc.get_by_source_ref = AsyncMock(return_value=existing)
created = PlaylistRecord(id="new-pl-1", name="Imported", cover_image_path=None, created_at="2024-01-01", updated_at="2024-01-01")
svc.create_playlist = AsyncMock(return_value=created)
svc.add_tracks = AsyncMock(return_value=[])
svc.delete_playlist = AsyncMock()
return svc
def _plex_service(playlists=None, items=None) -> PlexLibraryService:
repo = MagicMock()
repo.get_playlists = AsyncMock(return_value=playlists or [])
repo.get_playlist_items = AsyncMock(return_value=items or [])
repo.get_albums = AsyncMock(return_value=([], 0))
repo.get_recently_added = AsyncMock(return_value=[])
repo.get_recently_viewed = AsyncMock(return_value=[])
repo.get_genres = AsyncMock(return_value=[])
repo.get_track_count = AsyncMock(return_value=0)
repo.get_artist_count = AsyncMock(return_value=0)
type(repo).stats_ttl = PropertyMock(return_value=600)
prefs = MagicMock()
conn = MagicMock()
conn.enabled = True
conn.plex_url = "http://plex:32400"
conn.plex_token = "tok"
conn.music_library_ids = ["1"]
prefs.get_plex_connection_raw.return_value = conn
return PlexLibraryService(plex_repo=repo, preferences_service=prefs)
def _navidrome_service(playlists=None, playlist_detail=None) -> NavidromeLibraryService:
repo = MagicMock()
repo.get_playlists = AsyncMock(return_value=playlists or [])
repo.get_playlist = AsyncMock(return_value=playlist_detail)
repo.get_albums = AsyncMock(return_value=[])
repo.get_recently_played = AsyncMock(return_value=[])
repo.get_starred = AsyncMock(return_value=[])
repo.get_starred_artists = AsyncMock(return_value=[])
repo.get_starred_songs = AsyncMock(return_value=[])
repo.get_genres = AsyncMock(return_value=[])
repo.get_album_count = AsyncMock(return_value=0)
repo.get_artist_count = AsyncMock(return_value=0)
repo.get_song_count = AsyncMock(return_value=0)
type(repo).stats_ttl = PropertyMock(return_value=600)
prefs = MagicMock()
conn = MagicMock()
conn.enabled = True
prefs.get_navidrome_connection_raw.return_value = conn
return NavidromeLibraryService(navidrome_repo=repo, preferences_service=prefs)
def _jellyfin_service(playlists=None, items=None) -> JellyfinLibraryService:
repo = MagicMock()
repo.get_playlists = AsyncMock(return_value=playlists or [])
repo.get_playlist = AsyncMock(return_value=(playlists[0] if playlists else None))
repo.get_playlist_items = AsyncMock(return_value=items or [])
repo.get_recently_played = AsyncMock(return_value=[])
repo.get_recently_added = AsyncMock(return_value=[])
repo.get_favorites = AsyncMock(return_value=[])
repo.get_genres = AsyncMock(return_value=[])
repo.get_most_played_artists = AsyncMock(return_value=[])
repo.get_most_played_albums = AsyncMock(return_value=[])
repo.get_library_stats = AsyncMock(return_value={"album_count": 0, "artist_count": 0, "track_count": 0})
repo.get_albums = AsyncMock(return_value=([], 0))
type(repo).stats_ttl = PropertyMock(return_value=600)
prefs = MagicMock()
conn = MagicMock()
conn.enabled = True
prefs.get_jellyfin_connection_raw.return_value = conn
return JellyfinLibraryService(jellyfin_repo=repo, preferences_service=prefs)
def _plex_playlist(key="pl-1", title="My Plex Playlist", leaf=3, dur=180000, smart=False) -> PlexPlaylist:
return PlexPlaylist(ratingKey=key, title=title, leafCount=leaf, duration=dur, smart=smart, composite="/art/1")
def _plex_track(key="t-1", title="Song", artist="Artist", album="Album", parent_key="a-1") -> PlexTrack:
return PlexTrack(ratingKey=key, title=title, grandparentTitle=artist, parentTitle=album, parentRatingKey=parent_key, duration=200000, index=1, parentIndex=1)
def _navidrome_playlist(pid="nd-pl-1", name="ND Playlist", songs=2, dur=300) -> SubsonicPlaylist:
return SubsonicPlaylist(id=pid, name=name, songCount=songs, duration=dur)
def _navidrome_song(sid="ns-1", title="Song", artist="Artist", album="Album") -> SubsonicSong:
return SubsonicSong(id=sid, title=title, artist=artist, album=album, albumId="alb-1", artistId="art-1", duration=180, track=1, discNumber=1)
def _jellyfin_item(iid="jf-1", name="JF Item", item_type="Playlist", child_count=5, ticks=3_000_000_000) -> JellyfinItem:
return JellyfinItem(id=iid, name=name, type=item_type, child_count=child_count, duration_ticks=ticks, image_tag="abc", date_created="2024-01-01")
def _jellyfin_track(iid="jft-1", name="JF Track", artist="Artist", album="Album") -> JellyfinItem:
return JellyfinItem(id=iid, name=name, type="Audio", artist_name=artist, album_name=album, album_id="ja-1", artist_id="jar-1", duration_ticks=2_000_000_000, index_number=1, parent_index_number=1)
class TestPlexListPlaylists:
@pytest.mark.asyncio
async def test_returns_summaries(self):
svc = _plex_service(playlists=[_plex_playlist(), _plex_playlist(key="pl-2", title="Second")])
result = await svc.list_playlists(limit=10)
assert len(result) == 2
assert result[0].id == "pl-1"
assert result[0].name == "My Plex Playlist"
assert result[0].duration_seconds == 180
assert result[0].cover_url == "/api/v1/plex/playlist-thumb/pl-1"
@pytest.mark.asyncio
async def test_empty_playlists(self):
svc = _plex_service(playlists=[])
result = await svc.list_playlists()
assert result == []
@pytest.mark.asyncio
async def test_limit_respected(self):
playlists = [_plex_playlist(key=f"pl-{i}") for i in range(10)]
svc = _plex_service(playlists=playlists)
result = await svc.list_playlists(limit=3)
assert len(result) == 3
class TestPlexPlaylistDetail:
@pytest.mark.asyncio
async def test_returns_detail_with_tracks(self):
pl = _plex_playlist()
tracks = [_plex_track(), _plex_track(key="t-2", title="Song 2")]
svc = _plex_service(playlists=[pl], items=tracks)
detail = await svc.get_playlist_detail("pl-1")
assert detail.id == "pl-1"
assert detail.name == "My Plex Playlist"
assert len(detail.tracks) == 2
assert detail.tracks[0].track_name == "Song"
assert detail.tracks[0].duration_seconds == 200
@pytest.mark.asyncio
async def test_not_found_raises(self):
svc = _plex_service(playlists=[])
with pytest.raises(Exception, match="not found"):
await svc.get_playlist_detail("nonexistent")
class TestPlexImportPlaylist:
@pytest.mark.asyncio
async def test_import_new_playlist(self):
pl = _plex_playlist()
tracks = [_plex_track()]
svc = _plex_service(playlists=[pl], items=tracks)
ps = _mock_playlist_service()
result = await svc.import_playlist("pl-1", ps)
assert result.musicseerr_playlist_id == "new-pl-1"
assert result.tracks_imported == 1
assert result.already_imported is False
ps.create_playlist.assert_awaited_once()
ps.add_tracks.assert_awaited_once()
@pytest.mark.asyncio
async def test_import_idempotent(self):
existing = PlaylistRecord(id="existing-1", name="Already", cover_image_path=None, created_at="2024-01-01", updated_at="2024-01-01")
svc = _plex_service()
ps = _mock_playlist_service(existing=existing)
result = await svc.import_playlist("pl-1", ps)
assert result.already_imported is True
assert result.musicseerr_playlist_id == "existing-1"
ps.create_playlist.assert_not_awaited()
@pytest.mark.asyncio
async def test_import_track_keys_correct(self):
pl = _plex_playlist()
tracks = [_plex_track()]
svc = _plex_service(playlists=[pl], items=tracks)
ps = _mock_playlist_service()
await svc.import_playlist("pl-1", ps)
call_args = ps.add_tracks.call_args[0]
track_dicts = call_args[1]
assert track_dicts[0]["track_name"] == "Song"
assert track_dicts[0]["artist_name"] == "Artist"
assert track_dicts[0]["album_name"] == "Album"
assert track_dicts[0]["source_type"] == "plex"
assert track_dicts[0]["track_source_id"] == "t-1"
@pytest.mark.asyncio
async def test_import_rollback_on_add_tracks_failure(self):
pl = _plex_playlist()
tracks = [_plex_track()]
svc = _plex_service(playlists=[pl], items=tracks)
ps = _mock_playlist_service()
ps.add_tracks = AsyncMock(side_effect=Exception("DB error"))
with pytest.raises(Exception):
await svc.import_playlist("pl-1", ps)
ps.delete_playlist.assert_awaited_once_with("new-pl-1")
class TestNavidromeListPlaylists:
@pytest.mark.asyncio
async def test_returns_summaries(self):
svc = _navidrome_service(playlists=[_navidrome_playlist()])
result = await svc.list_playlists()
assert len(result) == 1
assert result[0].id == "nd-pl-1"
assert result[0].name == "ND Playlist"
assert result[0].cover_url == "/api/v1/navidrome/cover/nd-pl-1"
class TestNavidromePlaylistDetail:
@pytest.mark.asyncio
async def test_returns_detail(self):
detail_raw = _navidrome_playlist()
detail_raw.entry = [_navidrome_song()]
svc = _navidrome_service(playlist_detail=detail_raw)
detail = await svc.get_playlist_detail("nd-pl-1")
assert detail.id == "nd-pl-1"
assert len(detail.tracks) == 1
assert detail.tracks[0].track_name == "Song"
assert detail.tracks[0].source_type if hasattr(detail.tracks[0], "source_type") else True
@pytest.mark.asyncio
async def test_not_found_raises(self):
svc = _navidrome_service(playlist_detail=None)
with pytest.raises(Exception, match="not found"):
await svc.get_playlist_detail("missing")
class TestNavidromeImportPlaylist:
@pytest.mark.asyncio
async def test_import_new(self):
detail_raw = _navidrome_playlist()
detail_raw.entry = [_navidrome_song()]
svc = _navidrome_service(playlist_detail=detail_raw)
ps = _mock_playlist_service()
result = await svc.import_playlist("nd-pl-1", ps)
assert result.tracks_imported == 1
assert result.already_imported is False
@pytest.mark.asyncio
async def test_import_track_keys_correct(self):
detail_raw = _navidrome_playlist()
detail_raw.entry = [_navidrome_song()]
svc = _navidrome_service(playlist_detail=detail_raw)
ps = _mock_playlist_service()
await svc.import_playlist("nd-pl-1", ps)
track_dicts = ps.add_tracks.call_args[0][1]
assert track_dicts[0]["track_name"] == "Song"
assert track_dicts[0]["source_type"] == "navidrome"
assert track_dicts[0]["track_source_id"] == "ns-1"
class TestJellyfinListPlaylists:
@pytest.mark.asyncio
async def test_returns_summaries(self):
svc = _jellyfin_service(playlists=[_jellyfin_item()])
result = await svc.list_playlists()
assert len(result) == 1
assert result[0].id == "jf-1"
assert result[0].name == "JF Item"
assert result[0].duration_seconds == 300
assert result[0].cover_url == "/api/v1/jellyfin/image/jf-1"
class TestJellyfinPlaylistDetail:
@pytest.mark.asyncio
async def test_returns_detail(self):
pl = _jellyfin_item()
tracks = [_jellyfin_track()]
svc = _jellyfin_service(playlists=[pl], items=tracks)
detail = await svc.get_playlist_detail("jf-1")
assert detail.id == "jf-1"
assert len(detail.tracks) == 1
assert detail.tracks[0].track_name == "JF Track"
assert detail.tracks[0].duration_seconds == 200
class TestJellyfinImportPlaylist:
@pytest.mark.asyncio
async def test_import_new(self):
pl = _jellyfin_item()
tracks = [_jellyfin_track()]
svc = _jellyfin_service(playlists=[pl], items=tracks)
ps = _mock_playlist_service()
result = await svc.import_playlist("jf-1", ps)
assert result.tracks_imported == 1
assert result.already_imported is False
@pytest.mark.asyncio
async def test_import_track_keys_correct(self):
pl = _jellyfin_item()
tracks = [_jellyfin_track()]
svc = _jellyfin_service(playlists=[pl], items=tracks)
ps = _mock_playlist_service()
await svc.import_playlist("jf-1", ps)
track_dicts = ps.add_tracks.call_args[0][1]
assert track_dicts[0]["track_name"] == "JF Track"
assert track_dicts[0]["source_type"] == "jellyfin"
assert track_dicts[0]["track_source_id"] == "jft-1"
@pytest.mark.asyncio
async def test_import_idempotent(self):
existing = PlaylistRecord(id="ex-1", name="Exists", cover_image_path=None, created_at="2024-01-01", updated_at="2024-01-01")
svc = _jellyfin_service()
ps = _mock_playlist_service(existing=existing)
result = await svc.import_playlist("jf-1", ps)
assert result.already_imported is True
ps.create_playlist.assert_not_awaited()
@pytest.mark.asyncio
async def test_rollback_on_failure(self):
pl = _jellyfin_item()
tracks = [_jellyfin_track()]
svc = _jellyfin_service(playlists=[pl], items=tracks)
ps = _mock_playlist_service()
ps.add_tracks = AsyncMock(side_effect=Exception("fail"))
with pytest.raises(Exception):
await svc.import_playlist("jf-1", ps)
ps.delete_playlist.assert_awaited_once_with("new-pl-1")

View file

@ -1,4 +1,4 @@
"""Tests for Phase 7: AudioDB settings — API key masking, round-trips, validation."""
"""Tests for AudioDB settings, API key masking, round-trips, and validation."""
import pytest
import msgspec
@ -13,10 +13,10 @@ from api.v1.schemas.advanced_settings import (
class TestMaskApiKey:
def test_long_key_shows_last_three(self) -> None:
assert _mask_api_key("myapikey123") == "***123"
assert _mask_api_key("myapikey123") == "***...123"
def test_four_char_key(self) -> None:
assert _mask_api_key("1234") == "***234"
assert _mask_api_key("1234") == "***...234"
def test_three_char_key_fully_masked(self) -> None:
assert _mask_api_key("123") == "***"
@ -33,7 +33,7 @@ class TestMaskApiKey:
class TestIsMaskedApiKey:
def test_masked_with_suffix(self) -> None:
assert _is_masked_api_key("***123") is True
assert _is_masked_api_key("***...123") is True
def test_masked_short(self) -> None:
assert _is_masked_api_key("***") is True
@ -55,7 +55,7 @@ class TestApiKeyRoundTrip:
def test_from_backend_masks_long_key(self) -> None:
backend = AdvancedSettings(audiodb_api_key="secretkey")
frontend = AdvancedSettingsFrontend.from_backend(backend)
assert frontend.audiodb_api_key == "***key"
assert frontend.audiodb_api_key == "***...key"
def test_from_backend_masks_default_key(self) -> None:
backend = AdvancedSettings(audiodb_api_key="123")
@ -63,9 +63,9 @@ class TestApiKeyRoundTrip:
assert frontend.audiodb_api_key == "***"
def test_to_backend_passes_masked_key_through(self) -> None:
frontend = AdvancedSettingsFrontend(audiodb_api_key="***key")
frontend = AdvancedSettingsFrontend(audiodb_api_key="***...key")
backend = frontend.to_backend()
assert backend.audiodb_api_key == "***key"
assert backend.audiodb_api_key == "***...key"
def test_to_backend_passes_new_plaintext_key(self) -> None:
frontend = AdvancedSettingsFrontend(audiodb_api_key="newkey456")

View file

@ -9,7 +9,7 @@ from core.dependencies._registry import _singleton_registry, clear_all_singleton
class TestSingletonRegistry:
def test_registry_has_expected_count(self):
assert len(_singleton_registry) == 52
assert len(_singleton_registry) == 55
def test_all_entries_have_cache_clear(self):
for fn in _singleton_registry:

View file

@ -0,0 +1,266 @@
"""Tests for peer-review fixes: route collision, byYear contract, audio-only filter,
favorites/expanded error handling, timed lyrics, and Plex accountID removal."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api.v1.routes.navidrome_library import router as navidrome_router
from api.v1.routes.jellyfin_library import router as jellyfin_router
from api.v1.schemas.navidrome import (
NavidromeArtistIndexResponse,
NavidromeArtistIndexEntry,
NavidromeArtistSummary,
NavidromeAlbumSummary,
NavidromeAlbumPage,
)
from api.v1.schemas.jellyfin import JellyfinFavoritesExpanded
from core.dependencies import get_navidrome_library_service, get_jellyfin_library_service
from core.exceptions import ExternalServiceError
from repositories.navidrome_models import SubsonicLyrics, SubsonicLyricLine
def _navidrome_app(mock_service) -> TestClient:
"""Router already has prefix=/navidrome, so routes are at /navidrome/..."""
app = FastAPI()
app.include_router(navidrome_router)
app.dependency_overrides[get_navidrome_library_service] = lambda: mock_service
return TestClient(app)
def _jellyfin_app(mock_service) -> TestClient:
"""Router already has prefix=/jellyfin, so routes are at /jellyfin/..."""
app = FastAPI()
app.include_router(jellyfin_router)
app.dependency_overrides[get_jellyfin_library_service] = lambda: mock_service
return TestClient(app)
class TestNavidromeArtistIndexRouteOrder:
"""Verify /artists/index resolves to the index handler, not the artist-detail handler."""
def test_artists_index_resolves_correctly(self):
mock = MagicMock()
mock.get_artists_index = AsyncMock(return_value=NavidromeArtistIndexResponse(
index=[
NavidromeArtistIndexEntry(
name="A",
artists=[NavidromeArtistSummary(navidrome_id="ar1", name="ABBA")],
),
]
))
client = _navidrome_app(mock)
resp = client.get("/navidrome/artists/index")
assert resp.status_code == 200
assert "index" in resp.json()
mock.get_artists_index.assert_awaited_once()
mock.get_artist_detail = AsyncMock()
mock.get_artist_detail.assert_not_awaited()
def test_artist_detail_still_works(self):
mock = MagicMock()
mock.get_artist_detail = AsyncMock(return_value={
"artist": {"navidrome_id": "real-id", "name": "Artist"},
"albums": [],
})
client = _navidrome_app(mock)
resp = client.get("/navidrome/artists/real-id")
assert resp.status_code == 200
mock.get_artist_detail.assert_awaited_once_with("real-id")
class TestNavidromeByYearSort:
"""Verify that year sort sends fromYear/toYear to the service."""
def test_year_sort_asc_sends_year_params(self):
mock = MagicMock()
mock.get_albums = AsyncMock(return_value=[])
mock.get_stats = AsyncMock(side_effect=ExternalServiceError("unavailable"))
client = _navidrome_app(mock)
resp = client.get("/navidrome/albums", params={"sort_by": "year", "sort_order": ""})
assert resp.status_code == 200
call_kwargs = mock.get_albums.call_args.kwargs
assert call_kwargs.get("from_year") == 0
assert call_kwargs.get("to_year") == 9999
def test_year_sort_desc_sends_reversed_year_params(self):
mock = MagicMock()
mock.get_albums = AsyncMock(return_value=[])
mock.get_stats = AsyncMock(side_effect=ExternalServiceError("unavailable"))
client = _navidrome_app(mock)
resp = client.get("/navidrome/albums", params={"sort_by": "year", "sort_order": "desc"})
assert resp.status_code == 200
call_kwargs = mock.get_albums.call_args.kwargs
assert call_kwargs.get("from_year") == 9999
assert call_kwargs.get("to_year") == 0
def test_name_sort_does_not_send_year_params(self):
mock = MagicMock()
mock.get_albums = AsyncMock(return_value=[])
mock.get_stats = AsyncMock(side_effect=ExternalServiceError("unavailable"))
client = _navidrome_app(mock)
resp = client.get("/navidrome/albums", params={"sort_by": "name"})
assert resp.status_code == 200
call_kwargs = mock.get_albums.call_args.kwargs
assert "from_year" not in call_kwargs
assert "to_year" not in call_kwargs
class TestJellyfinPlaylistAudioFilter:
"""Verify that non-audio items are filtered out of playlist responses."""
@pytest.mark.asyncio
async def test_get_playlist_items_filters_non_audio(self):
from repositories.jellyfin_models import JellyfinItem
from repositories.jellyfin_repository import JellyfinRepository
repo = MagicMock(spec=JellyfinRepository)
repo._configured = True
repo._user_id = "u1"
repo._cache = MagicMock()
repo._cache.get = AsyncMock(return_value=None)
repo._cache.set = AsyncMock()
mixed_items = {
"Items": [
{"Id": "a1", "Name": "Song 1", "Type": "Audio", "RunTimeTicks": 1800000000},
{"Id": "v1", "Name": "Video", "Type": "Video", "RunTimeTicks": 9000000000},
{"Id": "a2", "Name": "Song 2", "Type": "Audio", "RunTimeTicks": 2000000000},
]
}
repo._get = AsyncMock(return_value=mixed_items)
result = await JellyfinRepository.get_playlist_items(repo, "pl-1")
assert len(result) == 2
assert all(item.type == "Audio" for item in result)
assert result[0].name == "Song 1"
assert result[1].name == "Song 2"
class TestJellyfinFavoritesExpandedErrorHandling:
"""Verify that unexpected errors in favorites/expanded return a proper HTTP error."""
def test_unexpected_error_returns_500(self):
mock = MagicMock()
mock.get_favorites_expanded = AsyncMock(side_effect=RuntimeError("unexpected"))
client = _jellyfin_app(mock)
resp = client.get("/jellyfin/favorites/expanded")
assert resp.status_code == 500
def test_external_service_error_returns_502(self):
mock = MagicMock()
mock.get_favorites_expanded = AsyncMock(side_effect=ExternalServiceError("Jellyfin down"))
client = _jellyfin_app(mock)
resp = client.get("/jellyfin/favorites/expanded")
assert resp.status_code == 502
def test_success_returns_200(self):
mock = MagicMock()
mock.get_favorites_expanded = AsyncMock(return_value=JellyfinFavoritesExpanded(albums=[], artists=[]))
client = _jellyfin_app(mock)
resp = client.get("/jellyfin/favorites/expanded")
assert resp.status_code == 200
class TestNavidromeLyricsTimedPreservation:
"""Verify that timed lyrics lines are preserved through the backend contract."""
@pytest.mark.asyncio
async def test_synced_lyrics_preserve_timing(self):
from services.navidrome_library_service import NavidromeLibraryService
lyrics = SubsonicLyrics(
value="Line one\nLine two",
lines=[
SubsonicLyricLine(value="Line one", start=0),
SubsonicLyricLine(value="Line two", start=5000),
],
is_synced=True,
)
repo = MagicMock()
repo.get_lyrics_by_song_id = AsyncMock(return_value=lyrics)
repo.get_lyrics = AsyncMock(return_value=None)
repo.get_albums = AsyncMock(return_value=[])
repo.get_recently_played = AsyncMock(return_value=[])
repo.get_recently_added = AsyncMock(return_value=[])
repo.get_starred_albums = AsyncMock(return_value=[])
repo.get_starred_artists = AsyncMock(return_value=[])
repo.get_starred_songs = AsyncMock(return_value=[])
repo.get_genres = AsyncMock(return_value=[])
repo.get_album_count = AsyncMock(return_value=0)
repo.get_track_count = AsyncMock(return_value=0)
repo.get_artist_count = AsyncMock(return_value=0)
prefs = MagicMock()
svc = NavidromeLibraryService(navidrome_repo=repo, preferences_service=prefs)
result = await svc.get_lyrics("song-1")
assert result is not None
assert result.is_synced is True
assert len(result.lines) == 2
assert result.lines[0].text == "Line one"
assert result.lines[0].start_seconds == pytest.approx(0.0)
assert result.lines[1].text == "Line two"
assert result.lines[1].start_seconds == pytest.approx(5.0)
assert "Line one" in result.text
@pytest.mark.asyncio
async def test_unsynced_lyrics_have_no_timing(self):
from services.navidrome_library_service import NavidromeLibraryService
lyrics = SubsonicLyrics(
value="Plain lyrics\nSecond line",
lines=[
SubsonicLyricLine(value="Plain lyrics", start=None),
SubsonicLyricLine(value="Second line", start=None),
],
is_synced=False,
)
repo = MagicMock()
repo.get_lyrics_by_song_id = AsyncMock(return_value=lyrics)
repo.get_lyrics = AsyncMock(return_value=None)
repo.get_albums = AsyncMock(return_value=[])
repo.get_recently_played = AsyncMock(return_value=[])
repo.get_recently_added = AsyncMock(return_value=[])
repo.get_starred_albums = AsyncMock(return_value=[])
repo.get_starred_artists = AsyncMock(return_value=[])
repo.get_starred_songs = AsyncMock(return_value=[])
repo.get_genres = AsyncMock(return_value=[])
repo.get_album_count = AsyncMock(return_value=0)
repo.get_track_count = AsyncMock(return_value=0)
repo.get_artist_count = AsyncMock(return_value=0)
prefs = MagicMock()
svc = NavidromeLibraryService(navidrome_repo=repo, preferences_service=prefs)
result = await svc.get_lyrics("song-1")
assert result is not None
assert result.is_synced is False
assert all(l.start_seconds is None for l in result.lines)
class TestPlexHistoryNoHardcodedAccount:
"""Verify that the Plex history endpoint does not hardcode accountID."""
@pytest.mark.asyncio
async def test_history_params_exclude_account_id(self):
from repositories.plex_repository import PlexRepository
repo = MagicMock(spec=PlexRepository)
repo._configured = True
repo._cache = MagicMock()
repo._cache.get = AsyncMock(return_value=None)
repo._cache.set = AsyncMock()
repo._request = AsyncMock(return_value={
"MediaContainer": {
"size": 0,
"totalSize": 0,
"Metadata": [],
}
})
await PlexRepository.get_listening_history(repo)
call_args = repo._request.call_args
params = call_args[1].get("params") or call_args[0][1] if len(call_args[0]) > 1 else call_args.kwargs.get("params")
assert "accountID" not in params

View file

@ -9,6 +9,11 @@
"port": 4243,
"audiodb_api_key": "",
"audiodb_premium": false,
"plex_url": "",
"plex_token": "",
"plex_enabled": false,
"music_library_ids": [],
"scrobble_to_plex": true,
"user_preferences": {
"primary_types": ["album", "ep", "single"],
"secondary_types": ["studio"],

View file

@ -171,6 +171,7 @@
--brand-localfiles: 20 184 166;
--brand-discover: 56 189 248;
--brand-navidrome: 99 102 241;
--brand-plex: 229 160 13;
--brand-lastfm: 213 16 7;
--brand-hero: 161 161 170;
--color-youtube: #ff0000;
@ -342,4 +343,76 @@
opacity: 1 !important;
transform: none !important;
}
.scroll-reveal {
opacity: 1 !important;
transform: none !important;
}
}
:root {
--ease-spring: cubic-bezier(0.23, 1, 0.32, 1);
--ease-overshoot: cubic-bezier(0.34, 1.56, 0.64, 1);
}
.scroll-reveal {
opacity: 0;
transform: translateY(1rem);
transition:
opacity 0.6s var(--ease-spring),
transform 0.6s var(--ease-spring);
}
.scroll-reveal.revealed {
opacity: 1;
transform: translateY(0);
}
@utility section-divider-glow {
height: 2px;
background: linear-gradient(to right, transparent, oklch(var(--p) / 0.15), transparent);
box-shadow: 0 0 8px oklch(var(--p) / 0.06);
}
.now-playing-bars {
display: flex;
align-items: flex-end;
gap: 2px;
height: 16px;
}
.now-playing-bars span {
display: block;
width: 3px;
border-radius: 1px;
background: oklch(var(--p));
animation: now-playing-bar 1.2s ease-in-out infinite;
}
.now-playing-bars span:nth-child(1) {
height: 60%;
animation-delay: 0s;
}
.now-playing-bars span:nth-child(2) {
height: 100%;
animation-delay: 0.2s;
}
.now-playing-bars span:nth-child(3) {
height: 40%;
animation-delay: 0.4s;
}
@keyframes now-playing-bar {
0%,
100% {
transform: scaleY(0.3);
}
50% {
transform: scaleY(1);
}
}
.now-playing-bars--paused span {
animation-play-state: paused;
opacity: 0.4;
}
.now-playing-bars--sm {
height: 10px;
}
.now-playing-bars--sm span {
width: 2px;
}

View file

@ -0,0 +1,48 @@
let reducedMotion =
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (typeof window !== 'undefined') {
window.matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', (e) => {
reducedMotion = e.matches;
});
}
export function reveal(node: HTMLElement, options?: { threshold?: number; stagger?: number }) {
const threshold = options?.threshold ?? 0.2;
const stagger = options?.stagger ?? 50;
if (reducedMotion) {
node.classList.add('revealed');
return { destroy() {} };
}
node.classList.add('scroll-reveal');
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const children = Array.from(node.children) as HTMLElement[];
children.forEach((child, i) => {
child.style.transitionDelay = `${i * stagger}ms`;
});
node.classList.add('revealed');
observer.unobserve(node);
}
}
},
{ threshold }
);
observer.observe(node);
return {
destroy() {
observer.disconnect();
const children = Array.from(node.children) as HTMLElement[];
children.forEach((child) => {
child.style.transitionDelay = '';
});
}
};
}

View file

@ -277,7 +277,8 @@ describe('playlists API client', () => {
format: 'aac',
track_number: 3,
disc_number: null,
duration: 240
duration: 240,
plex_rating_key: null
});
});

View file

@ -19,6 +19,7 @@ export interface PlaylistTrack {
disc_number: number | null;
duration: number | null;
created_at: string;
plex_rating_key: string | null;
}
export interface PlaylistSummary {
@ -28,6 +29,7 @@ export interface PlaylistSummary {
total_duration: number | null;
cover_urls: string[];
custom_cover_url: string | null;
source_ref: string | null;
created_at: string;
updated_at: string;
}
@ -50,6 +52,7 @@ export interface TrackData {
track_number?: number | null;
disc_number?: number | null;
duration?: number | null;
plex_rating_key?: string | null;
}
export function queueItemToTrackData(item: QueueItem): TrackData {
@ -66,7 +69,8 @@ export function queueItemToTrackData(item: QueueItem): TrackData {
format: item.format ?? null,
track_number: item.trackNumber ?? null,
disc_number: item.discNumber ?? null,
duration: item.duration ?? null
duration: item.duration ?? null,
plex_rating_key: item.plexRatingKey ?? null
};
}

View file

@ -86,7 +86,7 @@
membership = await checkTrackMembership(trackIdentifiers);
}
} catch {
fetchError = "Couldn't load playlists";
fetchError = "Couldn't load your playlists.";
} finally {
loading = false;
}
@ -122,14 +122,14 @@
const addedCount = trackData.length;
if (existingIndices.size > 0) {
showStatus(
`Added ${addedCount} new track${addedCount === 1 ? '' : 's'} to '${playlist.name}' (${existingIndices.size} already existed)`,
`Added ${addedCount} track${addedCount === 1 ? '' : 's'} to "${playlist.name}". ${existingIndices.size} ${existingIndices.size === 1 ? 'track was' : 'tracks were'} already in it.`,
'success'
);
} else {
showStatus(`Added to '${playlist.name}'`, 'success');
showStatus(`Added tracks to "${playlist.name}".`, 'success');
}
} catch {
showStatus("Couldn't add those tracks", 'error');
showStatus("Couldn't add those tracks.", 'error');
} finally {
addingSet.delete(playlist.id);
}
@ -154,15 +154,16 @@
total_duration: detail.total_duration,
cover_urls: detail.cover_urls,
custom_cover_url: detail.custom_cover_url,
source_ref: detail.source_ref,
created_at: detail.created_at,
updated_at: detail.updated_at
},
...playlists
];
newName = '';
showStatus(`Created '${name}' and added tracks`, 'success');
showStatus(`Created "${name}" and added the tracks.`, 'success');
} catch {
showStatus("Couldn't create the playlist", 'error');
showStatus("Couldn't create that playlist.", 'error');
} finally {
creating = false;
}
@ -181,14 +182,14 @@
<dialog bind:this={dialogEl} class="modal">
<div class="modal-box max-w-md">
<h3 class="text-lg font-bold">Add to Playlist</h3>
<h3 class="text-lg font-bold">Add to playlist</h3>
<p class="text-sm text-base-content/60 mt-1">{trackLabel}</p>
<div class="flex items-center gap-2 mt-4">
<input
type="text"
class="input input-sm flex-1"
placeholder="New playlist name..."
placeholder="New playlist name"
bind:value={newName}
onkeydown={handleKeydown}
disabled={creating}
@ -220,7 +221,7 @@
<span>{fetchError}</span>
</div>
{:else if playlists.length === 0}
<p class="text-center text-base-content/50 py-4">No playlists yet</p>
<p class="text-center text-base-content/50 py-4">You haven't created any playlists yet.</p>
{:else}
<div class="max-h-64 overflow-y-auto">
{#each playlists as playlist (playlist.id)}
@ -237,12 +238,12 @@
<button
class="btn btn-ghost btn-sm btn-circle text-success"
disabled
aria-label="Already in playlist"
aria-label="Already added"
>
<Check class="h-4 w-4" />
</button>
{:else if addingSet.has(playlist.id)}
<button class="btn btn-ghost btn-sm btn-circle" disabled aria-label="Adding">
<button class="btn btn-ghost btn-sm btn-circle" disabled aria-label="Adding tracks">
<span class="loading loading-spinner loading-sm"></span>
</button>
{:else if someTracksExist(playlist.id)}
@ -250,8 +251,10 @@
class="btn btn-ghost btn-sm btn-circle text-warning"
onclick={() => handleAdd(playlist)}
disabled={!canAdd}
aria-label="Add new tracks to {playlist.name}"
title="{existingCount(playlist.id)} of {trackCount} already in playlist"
aria-label="Add the remaining tracks to {playlist.name}"
title="{existingCount(
playlist.id
)} of {trackCount} tracks are already in this playlist"
>
<CircleCheck class="h-4 w-4" />
</button>
@ -284,11 +287,11 @@
</div>
{/if}
<form method="dialog">
<button class="btn btn-ghost">Done</button>
<button class="btn btn-ghost">Close</button>
</form>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
<button>Close</button>
</form>
</dialog>

View file

@ -0,0 +1,93 @@
<script lang="ts">
import AlbumImage from '$lib/components/AlbumImage.svelte';
import { ChevronRight } from 'lucide-svelte';
import { reveal } from '$lib/actions/reveal';
interface AlbumItem {
name: string;
artist_name: string;
image_url?: string | null;
year?: number | null;
musicbrainz_id?: string | null;
[key: string]: unknown;
}
interface Props {
albums: AlbumItem[];
idKey: string;
maxItems?: number;
seeAllHref?: string;
seeAllLabel?: string;
onAlbumClick?: (album: AlbumItem) => void;
}
let {
albums,
idKey,
maxItems = 12,
seeAllHref,
seeAllLabel = 'View all',
onAlbumClick
}: Props = $props();
let visible = $derived(albums.slice(0, maxItems));
function getId(album: AlbumItem): string {
return String(album[idKey] ?? album.name);
}
let hoveredIdx = $state<number | null>(null);
</script>
<div use:reveal={{ stagger: 60 }}>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{#each visible as album, i (getId(album))}
{@const isSibling = hoveredIdx !== null && hoveredIdx !== i}
<button
class="group cursor-pointer text-left transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-2 focus-visible:ring-offset-base-100"
style="transition: transform 0.25s var(--ease-overshoot), opacity 0.2s ease, box-shadow 0.3s var(--ease-spring); will-change: transform; transform: {hoveredIdx ===
i
? 'scale(1.04)'
: isSibling
? 'scale(0.98)'
: 'scale(1)'}; {hoveredIdx === i ? 'z-index: 1;' : ''} {isSibling
? 'opacity: 0.8;'
: ''}"
aria-label="Open {album.name} by {album.artist_name}"
onpointerenter={() => (hoveredIdx = i)}
onpointerleave={() => (hoveredIdx = null)}
onclick={() => onAlbumClick?.(album)}
>
<div
class="aspect-square overflow-hidden rounded-xl shadow-sm transition-shadow duration-300 {hoveredIdx ===
i
? 'shadow-2xl'
: ''}"
>
<AlbumImage
mbid={album.musicbrainz_id ?? getId(album)}
customUrl={album.image_url}
alt={album.name}
size="full"
rounded="none"
className="h-full w-full"
/>
</div>
<p class="mt-1.5 line-clamp-1 text-sm font-medium">{album.name}</p>
<p class="line-clamp-1 text-xs text-base-content/50">{album.artist_name}</p>
</button>
{/each}
</div>
{#if seeAllHref && albums.length > maxItems}
<div class="mt-4 flex justify-center">
<a
href={seeAllHref}
class="btn btn-ghost btn-sm gap-1 text-xs font-medium text-base-content/60 hover:text-base-content"
>
{seeAllLabel}
<ChevronRight class="h-4 w-4" />
</a>
</div>
{/if}
</div>

View file

@ -0,0 +1,103 @@
<script lang="ts">
import type { ArtistIndexEntry, ArtistIndexArtist } from '$lib/types';
import ArtistImage from './ArtistImage.svelte';
type Props = {
index: ArtistIndexEntry[];
onselect?: (artist: ArtistIndexArtist) => void;
};
let { index, onselect }: Props = $props();
let activeLetter = $state<string | null>(null);
let container = $state<HTMLDivElement | null>(null);
let sectionEls: Record<string, HTMLDivElement> = {};
function scrollToLetter(letter: string) {
activeLetter = letter;
if (!container) return;
const el = sectionEls[letter];
if (!el) return;
container.scrollTo({
top: el.offsetTop - container.offsetTop,
behavior: 'smooth'
});
}
function handleContainerScroll() {
if (!container) return;
const scrollTop = container.scrollTop + container.offsetTop;
let current: string | null = null;
for (const entry of index) {
if (entry.artists.length === 0) continue;
const el = sectionEls[entry.name];
if (el && el.offsetTop <= scrollTop + 20) {
current = entry.name;
}
}
if (current && current !== activeLetter) {
activeLetter = current;
}
}
const letters = $derived(index.map((e) => e.name).filter(Boolean));
</script>
<div class="flex gap-2">
<nav
class="flex flex-col items-center gap-0.5 sticky top-0 self-start pt-1"
aria-label="Alphabetical index"
>
{#each letters as letter (letter)}
<button
class="text-xs font-bold px-1 py-0.5 rounded transition-colors hover:text-primary {activeLetter ===
letter
? 'text-primary'
: 'text-base-content/50'}"
onclick={() => scrollToLetter(letter)}
aria-label="Jump to {letter}">{letter}</button
>
{/each}
</nav>
<div
bind:this={container}
class="flex-1 max-h-[60vh] overflow-y-auto rounded-lg pr-1"
onscroll={handleContainerScroll}
>
{#each index as entry (entry.name)}
{#if entry.artists.length > 0}
<div bind:this={sectionEls[entry.name]} class="mb-3">
<h3 class="text-sm font-bold text-primary sticky top-0 bg-base-100 py-1 z-10">
{entry.name}
</h3>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
{#each entry.artists as artist (artist.id)}
<button
class="flex items-center gap-2 p-2 rounded-lg hover:bg-base-200 transition-colors cursor-pointer text-left"
onclick={() => onselect?.(artist)}
>
<div class="w-8 h-8 rounded-full overflow-hidden shrink-0">
<ArtistImage
mbid={artist.musicbrainz_id ?? artist.id}
remoteUrl={artist.image_url}
alt={artist.name}
size="full"
/>
</div>
<div class="min-w-0">
<p class="text-sm font-medium truncate">{artist.name}</p>
{#if artist.album_count != null}
<p class="text-xs text-base-content/50">
{artist.album_count} album{artist.album_count !== 1 ? 's' : ''}
</p>
{/if}
</div>
</button>
{/each}
</div>
</div>
{/if}
{/each}
</div>
</div>

View file

@ -0,0 +1,61 @@
<script lang="ts">
interface Props {
codec?: string | null;
bitrate?: number | null;
audioChannels?: number | null;
container?: string | null;
compact?: boolean;
}
let {
codec = null,
bitrate = null,
audioChannels = null,
container = null,
compact = false
}: Props = $props();
const LOSSLESS_CODECS = ['flac', 'alac', 'wav', 'aiff', 'dsd', 'ape', 'wv', 'pcm'];
const isLossless = $derived(codec ? LOSSLESS_CODECS.includes(codec.toLowerCase()) : false);
const bitrateLabel = $derived(() => {
if (!bitrate) return '';
if (bitrate >= 1000) return `${(bitrate / 1000).toFixed(1)} Mbps`;
return `${bitrate} kbps`;
});
const channelLabel = $derived(() => {
if (!audioChannels) return '';
if (audioChannels === 1) return 'Mono';
if (audioChannels === 2) return 'Stereo';
return `${audioChannels}.${audioChannels > 6 ? '1' : '0'}`;
});
const hasAnyInfo = $derived(!!codec || !!bitrate);
</script>
{#if hasAnyInfo}
<div class="inline-flex items-center gap-1 flex-wrap">
{#if codec}
<span
class="badge badge-xs uppercase font-mono {isLossless ? 'badge-success' : 'badge-ghost'}"
title={isLossless ? 'Lossless' : 'Lossy'}
>
{codec}
</span>
{/if}
{#if !compact}
{#if bitrate}
<span class="badge badge-xs badge-ghost font-mono">{bitrateLabel()}</span>
{/if}
{#if audioChannels}
<span class="badge badge-xs badge-ghost">{channelLabel()}</span>
{/if}
{#if container && container !== codec}
<span class="badge badge-xs badge-ghost uppercase font-mono">{container}</span>
{/if}
{/if}
</div>
{/if}

View file

@ -0,0 +1,182 @@
<script lang="ts">
import type { BrowseHeroCard } from '$lib/types';
import { Disc3, Users, Music, ChevronRight } from 'lucide-svelte';
import { tweened, type Tweened } from 'svelte/motion';
import { cubicOut } from 'svelte/easing';
interface Props {
cards: BrowseHeroCard[];
}
let { cards }: Props = $props();
const reducedMotion =
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const iconMap = { disc: Disc3, users: Users, music: Music } as const;
const colorClasses: Record<
BrowseHeroCard['colorScheme'],
{ gradient: string; border: string; glow: string; text: string }
> = {
primary: {
gradient: 'from-primary/15 via-primary/5 to-transparent',
border: 'border-primary/15',
glow: 'shadow-[0_8px_32px_oklch(var(--p)/0.12)]',
text: 'text-primary'
},
secondary: {
gradient: 'from-secondary/15 via-secondary/5 to-transparent',
border: 'border-secondary/15',
glow: 'shadow-[0_8px_32px_oklch(var(--s)/0.12)]',
text: 'text-secondary'
},
accent: {
gradient: 'from-accent/15 via-accent/5 to-transparent',
border: 'border-accent/15',
glow: 'shadow-[0_8px_32px_oklch(var(--a)/0.12)]',
text: 'text-accent'
}
};
const glowHover: Record<BrowseHeroCard['colorScheme'], string> = {
primary: 'shadow-[0_12px_48px_oklch(var(--p)/0.25)]',
secondary: 'shadow-[0_12px_48px_oklch(var(--s)/0.25)]',
accent: 'shadow-[0_12px_48px_oklch(var(--a)/0.25)]'
};
let hoveredIndex = $state<number | null>(null);
let cardEls: HTMLAnchorElement[] = [];
let cardCount = $derived(cards.length);
let tiltStyles: string[] = $state(Array(cardCount).fill(''));
let specularStyles: string[] = $state(Array(cardCount).fill(''));
const tweenDuration = reducedMotion ? 0 : 1200;
const counters: Tweened<number>[] = Array.from({ length: cardCount }, () =>
tweened(0, { duration: tweenDuration, easing: cubicOut })
);
let counterValues: number[] = $state(Array(cardCount).fill(0));
$effect(() => {
const unsubs = counters.map((c, i) =>
c.subscribe((v) => {
counterValues[i] = v;
})
);
return () => unsubs.forEach((u) => u());
});
$effect(() => {
cards.forEach((card, i) => {
if (card.value !== null && card.value !== undefined) counters[i].set(card.value);
});
});
function handlePointerMove(e: PointerEvent, i: number) {
if (reducedMotion) return;
const el = cardEls[i];
if (!el) return;
const rect = el.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const centerX = rect.width / 2;
const centerY = rect.height / 2;
const rotateY = ((x - centerX) / centerX) * 4;
const rotateX = ((centerY - y) / centerY) * 3;
tiltStyles[i] =
`rotateX(${rotateX.toFixed(1)}deg) rotateY(${rotateY.toFixed(1)}deg) translateZ(0)`;
const pctX = ((x / rect.width) * 100).toFixed(0);
const pctY = ((y / rect.height) * 100).toFixed(0);
specularStyles[i] =
`radial-gradient(circle at ${pctX}% ${pctY}%, rgba(255,255,255,0.08) 0%, transparent 60%)`;
}
function handlePointerLeave(i: number) {
hoveredIndex = null;
tiltStyles[i] = '';
specularStyles[i] = '';
}
function formatNumber(value: number): string {
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`;
return value.toLocaleString();
}
function getCounterValue(i: number): number {
return counterValues[i];
}
</script>
<div
class="grid grid-cols-1 gap-5 sm:grid-cols-2 sm:gap-6 md:grid-cols-3"
style="perspective: 1200px;"
>
{#each cards as card, i (card.label)}
{@const colors = colorClasses[card.colorScheme]}
{@const Icon = iconMap[card.icon]}
{@const isHovered = hoveredIndex === i}
{@const isSibling = hoveredIndex !== null && hoveredIndex !== i}
<a
href={card.href}
bind:this={cardEls[i]}
class="group relative flex min-h-[160px] flex-col justify-center overflow-hidden rounded-2xl border bg-gradient-to-br px-6 py-5 text-center backdrop-blur-sm transition-all sm:min-h-[180px] {colors.gradient} {colors.border} {isHovered
? glowHover[card.colorScheme]
: colors.glow} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-2 focus-visible:ring-offset-base-100"
style="transform-style: preserve-3d; transform: {tiltStyles[i] ||
'rotateX(0) rotateY(0)'}; transition: transform 0.5s var(--ease-spring), box-shadow 0.5s var(--ease-spring), opacity 0.3s ease, scale 0.3s var(--ease-spring); {!reducedMotion &&
isSibling
? 'scale: 0.97; opacity: 0.7;'
: ''}"
onpointermove={(e) => {
hoveredIndex = i;
handlePointerMove(e, i);
}}
onpointerleave={() => handlePointerLeave(i)}
>
<div
class="pointer-events-none absolute inset-0 rounded-2xl bg-gradient-to-br from-white/[0.06] to-transparent"
></div>
{#if specularStyles[i]}
<div
class="pointer-events-none absolute inset-0 rounded-2xl"
style="background: {specularStyles[i]};"
></div>
{/if}
<div class="pointer-events-none absolute -right-2 -top-2 opacity-[0.06]">
<Icon size={72} strokeWidth={1} />
</div>
{#if card.value !== null}
<div
class="text-4xl font-bold tabular-nums tracking-tight sm:text-5xl {colors.text}"
style="text-shadow: 0 0 30px oklch(var(--{card.colorScheme === 'primary'
? 'p'
: card.colorScheme === 'secondary'
? 's'
: 'a'}) / 0.25);"
>
{formatNumber(Math.round(getCounterValue(i)))}
</div>
{:else}
<div class="mx-auto h-10 w-24 animate-glow-pulse rounded-lg bg-base-200/40 sm:h-12"></div>
{/if}
<div class="mt-2 text-sm font-medium uppercase tracking-[0.15em] text-base-content/60">
{card.label}
</div>
{#if card.subtitle}
<div class="mt-0.5 text-xs text-base-content/40">{card.subtitle}</div>
{/if}
<div
class="absolute right-4 top-1/2 -translate-y-1/2 translate-x-2 opacity-30 transition-all duration-300 group-hover:translate-x-0 group-hover:opacity-70"
>
<ChevronRight class="h-5 w-5 text-base-content" />
</div>
</a>
{/each}
</div>

View file

@ -0,0 +1,59 @@
<script lang="ts">
import CarouselSkeleton from '$lib/components/CarouselSkeleton.svelte';
import { RefreshCw, Sparkles } from 'lucide-svelte';
import type { Snippet } from 'svelte';
interface Props {
title: string;
loading?: boolean;
empty?: boolean;
emptyMessage?: string;
onrefresh?: () => void;
actions?: Snippet;
children: Snippet;
}
let {
title,
loading = false,
empty = false,
emptyMessage = 'Nothing here yet.',
onrefresh,
actions,
children
}: Props = $props();
</script>
<section class="space-y-3">
<div class="flex items-center justify-between px-1">
<div class="flex items-center gap-2">
<Sparkles class="h-5 w-5 text-secondary" />
<h2 class="text-lg font-semibold text-base-content sm:text-xl">{title}</h2>
</div>
{#if onrefresh}
<button
class="btn btn-ghost btn-sm gap-1 text-xs font-medium text-base-content/60 hover:text-base-content"
onclick={onrefresh}
disabled={loading}
>
<span class:animate-spin={loading}>
<RefreshCw class="h-4 w-4" />
</span>
Refresh
</button>
{/if}
</div>
{#if loading}
<CarouselSkeleton />
{:else}
{#if actions}{@render actions()}{/if}
{#if empty}
<div class="rounded-lg bg-base-200 p-6 text-center">
<p class="text-sm text-base-content/50">{emptyMessage}</p>
</div>
{:else}
{@render children()}
{/if}
{/if}
</section>

View file

@ -0,0 +1,79 @@
<script lang="ts">
import AlbumImage from '$lib/components/AlbumImage.svelte';
import { formatDurationSec } from '$lib/utils/formatting';
import { Play } from 'lucide-svelte';
export type DiscoveryTrack = {
id: string;
title: string;
artist_name: string;
album_name: string;
album_id?: string;
image_url?: string | null;
duration_seconds: number;
};
interface Props {
tracks: DiscoveryTrack[];
onplay: (index: number) => void;
formatDuration?: (seconds: number) => string;
}
let { tracks, onplay, formatDuration = formatDurationSec }: Props = $props();
</script>
<div class="max-h-[32rem] overflow-y-auto rounded-xl">
<table class="table table-sm w-full">
<thead class="sticky top-0 z-10 bg-base-200/80 backdrop-blur-sm">
<tr class="text-xs uppercase tracking-wider text-base-content/40">
<th class="w-10">#</th>
<th>Title</th>
<th class="hidden sm:table-cell">Album</th>
<th class="w-16 text-right">Time</th>
</tr>
</thead>
<tbody>
{#each tracks as track, i (track.id)}
<tr
class="group cursor-pointer border-l-2 border-l-transparent transition-all duration-150 hover:border-l-primary hover:bg-base-content/[0.03]"
onclick={() => onplay(i)}
>
<td class="w-10">
<span class="text-base-content/40 group-hover:hidden">{i + 1}</span>
<span class="hidden text-primary group-hover:inline">
<Play class="h-3.5 w-3.5 fill-current" />
</span>
</td>
<td>
<div class="flex items-center gap-3">
<div class="hidden h-8 w-8 shrink-0 overflow-hidden rounded sm:block">
<AlbumImage
mbid={track.album_id ?? track.id}
customUrl={track.image_url}
alt={track.album_name}
size="xs"
rounded="none"
className="h-full w-full object-cover"
/>
</div>
<div class="min-w-0">
<p class="truncate text-sm font-medium text-base-content">
{track.title}
</p>
<p class="truncate text-xs text-base-content/50">
{track.artist_name}
</p>
</div>
</div>
</td>
<td class="hidden text-sm text-base-content/50 sm:table-cell">
<span class="line-clamp-1">{track.album_name}</span>
</td>
<td class="w-16 text-right font-mono text-xs text-base-content/40">
{formatDuration(track.duration_seconds)}
</td>
</tr>
{/each}
</tbody>
</table>
</div>

View file

@ -0,0 +1,32 @@
<script lang="ts">
import { Sparkles } from 'lucide-svelte';
import { reveal } from '$lib/actions/reveal';
import type { Snippet } from 'svelte';
interface Props {
children: Snippet;
}
let { children }: Props = $props();
</script>
<div use:reveal class="relative mt-8 mb-4">
<div
class="relative overflow-hidden rounded-2xl border border-base-content/5 bg-base-200/20 shadow-sm backdrop-blur-sm"
>
<div
class="pointer-events-none absolute inset-x-6 top-0 h-px bg-gradient-to-r from-transparent via-primary/30 to-transparent"
></div>
<div class="p-6 space-y-5">
<div class="flex items-center gap-2.5">
<div class="flex items-center justify-center rounded-lg bg-primary/10 p-1.5">
<Sparkles class="h-4.5 w-4.5 text-primary" />
</div>
<h2 class="text-lg font-semibold tracking-tight text-base-content sm:text-xl">Discover</h2>
</div>
{@render children()}
</div>
</div>
</div>

View file

@ -0,0 +1,142 @@
<script lang="ts">
import AlbumImage from '$lib/components/AlbumImage.svelte';
interface AlbumSummary {
name: string;
artist_name: string;
image_url?: string | null;
year?: number | null;
musicbrainz_id?: string | null;
[key: string]: unknown;
}
interface Props {
albums: AlbumSummary[];
idKey: string;
onAlbumClick?: (album: AlbumSummary) => void;
}
let { albums, idKey, onAlbumClick }: Props = $props();
let hero = $derived(albums[0] ?? null);
let thumbnails = $derived(albums.slice(1, 9));
let glowColor = $state('');
let canvasEl: HTMLCanvasElement | undefined = $state();
function extractColor(imgEl: HTMLImageElement) {
if (!canvasEl) return;
try {
const ctx = canvasEl.getContext('2d', { willReadFrequently: true });
if (!ctx) return;
canvasEl.width = 1;
canvasEl.height = 1;
ctx.drawImage(imgEl, 0, 0, 1, 1);
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
glowColor = `${r}, ${g}, ${b}`;
} catch {
glowColor = '';
}
}
function handleHeroLoad(e: Event) {
const img = e.target as HTMLImageElement;
if (img?.complete && img.naturalWidth > 0) {
extractColor(img);
}
}
function getId(album: AlbumSummary): string {
return String(album[idKey] ?? album.name);
}
</script>
{#if hero}
<canvas bind:this={canvasEl} class="hidden" width="1" height="1"></canvas>
<section class="animate-fade-in-up space-y-0 overflow-hidden rounded-2xl">
<div
class="relative flex min-h-[220px] items-center gap-6 overflow-hidden px-6 py-6 sm:min-h-[260px] sm:px-8 sm:py-8"
>
{#if hero.image_url}
<img
src={hero.image_url}
alt=""
aria-hidden="true"
crossorigin="anonymous"
class="pointer-events-none absolute inset-0 h-full w-full scale-110 object-cover blur-2xl brightness-[0.25]"
onload={handleHeroLoad}
/>
{/if}
{#if glowColor}
<div
class="pointer-events-none absolute inset-0"
style="background: radial-gradient(ellipse at 30% 80%, rgba({glowColor}, 0.22), transparent 70%);"
></div>
{/if}
<div
class="pointer-events-none absolute inset-0 bg-gradient-to-r from-base-100/70 via-base-100/40 to-transparent"
></div>
<div class="relative z-10 flex items-center gap-6">
<button
class="shrink-0 overflow-hidden rounded-xl shadow-[0_20px_60px_rgba(0,0,0,0.5)] transition-transform duration-500 hover:scale-[1.03]"
style="transform-style: preserve-3d; transform: perspective(600px) rotateY(-2deg);"
aria-label="Play {hero.name} by {hero.artist_name}"
onclick={() => onAlbumClick?.(hero)}
>
<div class="h-[120px] w-[120px] sm:h-[150px] sm:w-[150px]">
<AlbumImage
mbid={hero.musicbrainz_id ?? getId(hero)}
customUrl={hero.image_url}
alt={hero.name}
size="full"
rounded="none"
className="h-full w-full"
/>
</div>
</button>
<div class="min-w-0 space-y-1">
<p class="text-[10px] font-semibold uppercase tracking-[0.2em] text-base-content/50">
Continue Listening
</p>
<h3 class="line-clamp-2 text-xl font-bold text-base-content sm:text-2xl">
{hero.name}
</h3>
<p class="line-clamp-1 text-sm text-base-content/70">{hero.artist_name}</p>
{#if hero.year}
<span class="badge badge-sm badge-ghost mt-1">{hero.year}</span>
{/if}
</div>
</div>
</div>
{#if thumbnails.length > 0}
<div
class="-mt-5 flex gap-3 overflow-x-auto px-6 pb-4 pt-5 scrollbar-hide"
style="-webkit-mask-image: linear-gradient(to bottom, transparent, black 30%); mask-image: linear-gradient(to bottom, transparent, black 30%);"
>
{#each thumbnails as album (getId(album))}
<button
class="shrink-0 overflow-hidden rounded-lg ring-2 ring-base-100/30 transition-all duration-300 hover:scale-105 hover:ring-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
style="transition-timing-function: var(--ease-overshoot);"
aria-label="Play {album.name}"
onclick={() => onAlbumClick?.(album)}
>
<div class="h-[72px] w-[72px] sm:h-[80px] sm:w-[80px]">
<AlbumImage
mbid={album.musicbrainz_id ?? getId(album)}
customUrl={album.image_url}
alt={album.name}
size="full"
rounded="none"
className="h-full w-full"
/>
</div>
</button>
{/each}
</div>
{/if}
</section>
{/if}

View file

@ -0,0 +1,79 @@
<script lang="ts">
interface Props {
genres: string[];
selected?: string;
selectedMultiple?: string[];
loading?: boolean;
showAll?: boolean;
multiSelect?: boolean;
maxVisible?: number;
onselect: (genre: string | undefined) => void;
}
let {
genres,
selected,
selectedMultiple,
loading = false,
showAll = false,
multiSelect = false,
maxVisible = 0,
onselect
}: Props = $props();
let expanded = $state(false);
function isActive(genre: string): boolean {
if (multiSelect && selectedMultiple) {
return selectedMultiple.includes(genre);
}
return selected === genre;
}
function noneSelected(): boolean {
if (multiSelect && selectedMultiple) {
return selectedMultiple.length === 0;
}
return !selected;
}
let visibleGenres = $derived(maxVisible > 0 && !expanded ? genres.slice(0, maxVisible) : genres);
let hasOverflow = $derived(maxVisible > 0 && genres.length > maxVisible);
</script>
<div class="flex flex-wrap gap-2" role="group" aria-label="Genre filter">
{#if showAll}
<button
class="rounded-full border px-3.5 py-1.5 text-xs font-medium transition-all duration-200
{noneSelected()
? 'border-primary/40 bg-primary/15 text-primary shadow-sm shadow-primary/10'
: 'border-base-content/10 bg-base-100/50 text-base-content/60 hover:border-base-content/20 hover:bg-base-100/80 hover:text-base-content'}"
disabled={loading}
aria-pressed={noneSelected()}
onclick={() => onselect(undefined)}
>
All
</button>
{/if}
{#each visibleGenres as genre (genre)}
<button
class="rounded-full border px-3.5 py-1.5 text-xs font-medium transition-all duration-200
{isActive(genre)
? 'border-primary/40 bg-primary/15 text-primary shadow-sm shadow-primary/10'
: 'border-base-content/10 bg-base-100/50 text-base-content/60 hover:border-base-content/20 hover:bg-base-100/80 hover:text-base-content'}"
disabled={loading}
aria-pressed={isActive(genre)}
onclick={() => onselect(genre)}
>
{genre}
</button>
{/each}
{#if hasOverflow && !expanded}
<button
class="rounded-full border border-base-content/10 bg-base-100/50 px-3.5 py-1.5 text-xs font-medium text-base-content/60 hover:border-base-content/20 hover:bg-base-100/80 hover:text-base-content transition-all duration-200"
onclick={() => (expanded = true)}
>
+{genres.length - maxVisible} more
</button>
{/if}
</div>

View file

@ -0,0 +1,147 @@
<script lang="ts">
import { playerStore } from '$lib/stores/player.svelte';
import { formatDurationSec } from '$lib/utils/formatting';
import GenrePillFilter from './GenrePillFilter.svelte';
import type { QueueItem } from '$lib/player/types';
export type BrowseTrack = {
id: string;
title: string;
artist_name: string;
album_name: string;
duration_seconds: number;
image_url?: string | null;
};
type Props = {
genres: string[];
fetchSongs: (genres: string[], limit: number, offset: number) => Promise<BrowseTrack[]>;
buildQueue: (tracks: BrowseTrack[]) => QueueItem[];
multiSelect?: boolean;
};
let { genres, fetchSongs, buildQueue, multiSelect = false }: Props = $props();
let selectedGenres = $state<string[]>([]);
let songs = $state<BrowseTrack[]>([]);
let loading = $state(false);
let offset = $state(0);
const PAGE_SIZE = 50;
async function loadSongs(genreList: string[], append = false) {
if (genreList.length === 0) return;
if (!append) {
offset = 0;
songs = [];
}
loading = true;
try {
const result = await fetchSongs(genreList, PAGE_SIZE, offset);
songs = append ? [...songs, ...result] : result;
} finally {
loading = false;
}
}
function handleSelect(genre: string | undefined) {
if (!genre) {
selectedGenres = [];
songs = [];
return;
}
if (multiSelect) {
const idx = selectedGenres.indexOf(genre);
if (idx >= 0) {
selectedGenres = selectedGenres.filter((g) => g !== genre);
} else {
selectedGenres = [...selectedGenres, genre];
}
if (selectedGenres.length > 0) loadSongs(selectedGenres);
else songs = [];
} else {
selectedGenres = [genre];
loadSongs([genre]);
}
}
function loadMore() {
if (selectedGenres.length === 0) return;
offset += PAGE_SIZE;
loadSongs(selectedGenres, true);
}
function playSongs(startIndex = 0) {
if (songs.length === 0) return;
const items = buildQueue(songs);
playerStore.playQueue(items, startIndex);
}
function shuffleSongs() {
if (songs.length === 0) return;
const items = buildQueue(songs);
playerStore.playQueue(items, 0, true);
}
</script>
<div class="space-y-3">
<GenrePillFilter
{genres}
selected={multiSelect ? undefined : selectedGenres[0]}
selectedMultiple={multiSelect ? selectedGenres : undefined}
{loading}
{multiSelect}
onselect={handleSelect}
/>
{#if loading && songs.length === 0}
<div class="flex justify-center py-4">
<span class="loading loading-spinner loading-md"></span>
</div>
{:else if selectedGenres.length > 0 && songs.length > 0}
<div class="flex items-center gap-2 mb-2">
<button class="btn btn-primary btn-sm" onclick={() => playSongs()}>Play All</button>
<button class="btn btn-ghost btn-sm" onclick={shuffleSongs}>Shuffle</button>
</div>
<div class="max-h-[32rem] overflow-y-auto overflow-x-auto rounded-lg">
<table class="table table-sm">
<thead>
<tr>
<th>#</th>
<th>Title</th>
<th>Artist</th>
<th>Album</th>
<th class="text-right">Duration</th>
</tr>
</thead>
<tbody>
{#each songs as track, i (track.id + '-' + i)}
<tr class="hover cursor-pointer" onclick={() => playSongs(i)}>
<td class="text-base-content/50">{i + 1}</td>
<td class="font-medium">{track.title}</td>
<td class="text-base-content/60">{track.artist_name}</td>
<td class="text-base-content/60">{track.album_name}</td>
<td class="text-right text-base-content/50"
>{formatDurationSec(track.duration_seconds)}</td
>
</tr>
{/each}
</tbody>
</table>
</div>
{#if songs.length >= offset + PAGE_SIZE}
<div class="flex justify-center">
<button class="btn btn-ghost btn-sm" onclick={loadMore} disabled={loading}>
{#if loading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
Show more
{/if}
</button>
</div>
{/if}
{:else if selectedGenres.length > 0}
<p class="text-sm text-base-content/50">No songs found for {selectedGenres.join(', ')}.</p>
{:else}
<p class="text-sm text-base-content/50">Pick a genre to browse tracks.</p>
{/if}
</div>

View file

@ -0,0 +1,96 @@
<script lang="ts">
import { Music } from 'lucide-svelte';
import { nowPlayingMerged } from '$lib/stores/nowPlayingMerged.svelte';
const session = $derived(nowPlayingMerged.primarySession);
const progressPct = $derived(
session?.progress_ms && session?.duration_ms
? Math.min((session.progress_ms / session.duration_ms) * 100, 100)
: 0
);
function formatTime(ms?: number): string {
if (!ms) return '0:00';
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
const sourceLabels: Record<string, string> = {
jellyfin: 'Jellyfin',
navidrome: 'Navidrome',
plex: 'Plex'
};
</script>
{#if session}
<section
class="mb-6 flex items-center gap-5 rounded-2xl border border-base-content/5 bg-base-200/30 p-4 shadow-lg backdrop-blur-md"
aria-label="Now playing"
>
<div
class="relative h-24 w-24 flex-shrink-0 overflow-hidden rounded-xl shadow-md sm:h-32 sm:w-32"
>
{#if session.cover_url}
<img
src={session.cover_url}
alt="{session.album_name} cover"
class="h-full w-full object-cover"
loading="lazy"
/>
{:else}
<div class="flex h-full w-full items-center justify-center bg-base-300">
<Music class="h-10 w-10 text-base-content/30" />
</div>
{/if}
</div>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<div class="flex items-center gap-2">
<div
class="now-playing-bars now-playing-bars--sm {session.is_paused
? 'now-playing-bars--paused'
: ''}"
>
<span></span><span></span><span></span>
</div>
<span class="text-xs font-medium uppercase tracking-wide text-base-content/50">
{session.is_paused ? 'Paused' : 'Now Playing'}
{#if session.source}
<span class="ml-1 opacity-60">on {sourceLabels[session.source] ?? session.source}</span>
{/if}
</span>
</div>
<h2 class="truncate text-lg font-bold leading-tight sm:text-xl">{session.track_name}</h2>
<p class="truncate text-sm text-base-content/70">{session.artist_name}</p>
{#if session.album_name}
<p class="truncate text-xs text-base-content/50">{session.album_name}</p>
{/if}
{#if session.duration_ms}
<div class="mt-1 flex items-center gap-2">
<span class="text-xs tabular-nums text-base-content/50"
>{formatTime(session.progress_ms)}</span
>
<div class="relative h-1 flex-1 overflow-hidden rounded-full bg-base-content/10">
<div
class="absolute inset-y-0 left-0 rounded-full bg-primary transition-[width] duration-1000 ease-linear"
style="width: {progressPct}%"
></div>
</div>
<span class="text-xs tabular-nums text-base-content/50"
>{formatTime(session.duration_ms)}</span
>
</div>
{/if}
{#if session.user_name || session.device_name}
<p class="mt-0.5 truncate text-xs text-base-content/40">
{[session.user_name, session.device_name].filter(Boolean).join(' - ')}
</p>
{/if}
</div>
</section>
{/if}

View file

@ -0,0 +1,22 @@
<script lang="ts">
import CarouselSkeleton from '$lib/components/CarouselSkeleton.svelte';
</script>
<div class="space-y-8 animate-pulse" role="status" aria-busy="true">
<span class="sr-only">Loading hub...</span>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
{#each Array(3) as _, i (i)}
<div class="skeleton skeleton-shimmer h-28 rounded-xl"></div>
{/each}
</div>
{#each ['Recently Added', 'Favorites', 'Browse Albums'] as title (title)}
<section class="space-y-3">
<div class="skeleton skeleton-shimmer h-6 w-40 rounded"></div>
<div class="rounded-xl bg-base-100/40 p-4 shadow-sm">
<CarouselSkeleton />
</div>
</section>
{/each}
</div>

View file

@ -0,0 +1,50 @@
<script lang="ts">
import CarouselSkeleton from '$lib/components/CarouselSkeleton.svelte';
import { ChevronRight } from 'lucide-svelte';
import type { Snippet } from 'svelte';
interface Props {
title: string;
seeAllHref?: string;
loading?: boolean;
children: Snippet;
}
let { title, seeAllHref, loading = false, children }: Props = $props();
</script>
<section class="space-y-3">
<div class="flex items-center justify-between px-1">
{#if seeAllHref}
<a
href={seeAllHref}
class="group/title flex items-center gap-1 transition-colors hover:text-primary"
>
<h2
class="text-lg font-semibold text-base-content group-hover/title:text-primary sm:text-xl"
>
{title}
</h2>
</a>
{:else}
<h2 class="text-lg font-semibold text-base-content sm:text-xl">{title}</h2>
{/if}
{#if seeAllHref}
<a
href={seeAllHref}
class="btn btn-ghost btn-sm gap-1 text-xs font-medium text-base-content/60 hover:text-base-content"
>
View all
<ChevronRight class="h-4 w-4" />
</a>
{/if}
</div>
<div class="rounded-xl bg-base-100/40 p-4 shadow-sm">
{#if loading}
<CarouselSkeleton />
{:else}
{@render children()}
{/if}
</div>
</section>

View file

@ -0,0 +1,18 @@
<script lang="ts">
import { Radio } from 'lucide-svelte';
interface Props {
label: string;
loading?: boolean;
onclick: () => void;
}
let { label, loading = false, onclick }: Props = $props();
</script>
<button class="btn btn-sm btn-outline gap-1.5 whitespace-nowrap" {onclick} disabled={loading}>
<span class:animate-pulse={loading}>
<Radio class="h-4 w-4" />
</span>
{label}
</button>

View file

@ -15,6 +15,16 @@
genres?: string[];
selectedGenre?: string;
onGenreChange?: (value: string) => void;
moods?: string[];
selectedMood?: string;
onMoodChange?: (value: string) => void;
moodLabel?: string;
decades?: string[];
selectedDecade?: string;
onDecadeChange?: (value: string) => void;
tags?: string[];
selectedTag?: string;
onTagChange?: (value: string) => void;
resultCount?: number | null;
loading?: boolean;
}
@ -22,7 +32,7 @@
let {
searchQuery = $bindable(),
onSearchInput,
placeholder = 'Search albums...',
placeholder = 'Search albums',
ariaLabel = 'Search albums',
sortOptions,
sortBy,
@ -33,6 +43,16 @@
genres,
selectedGenre,
onGenreChange,
moods,
selectedMood,
onMoodChange,
moodLabel = 'Mood',
decades,
selectedDecade,
onDecadeChange,
tags,
selectedTag,
onTagChange,
resultCount,
loading = false
}: Props = $props();
@ -40,7 +60,12 @@
let isSearching = $derived(searchQuery.trim().length > 0);
let hasSortControls = $derived(sortOptions && sortOptions.length > 0);
let hasGenreFilter = $derived(genres && genres.length > 0);
let hasSecondRow = $derived(hasSortControls || hasGenreFilter || resultCount != null);
let hasMoodFilter = $derived(moods && moods.length > 0);
let hasDecadeChips = $derived(decades && decades.length > 0);
let hasTagChips = $derived(tags && tags.length > 0);
let hasSecondRow = $derived(
hasSortControls || hasGenreFilter || hasMoodFilter || resultCount != null
);
let isAscending = $derived(sortOrder === ascValue);
function clearSearch(): void {
@ -57,6 +82,11 @@
const value = (e.target as HTMLSelectElement).value;
onGenreChange?.(value);
}
function handleMoodSelect(e: Event): void {
const value = (e.target as HTMLSelectElement).value;
onMoodChange?.(value);
}
</script>
<div class="mb-6">
@ -120,16 +150,80 @@
onchange={handleGenreSelect}
aria-label="Filter by genre"
>
<option value="">All Genres</option>
<option value="">All genres</option>
{#each genres! as genre (genre)}
<option value={genre} selected={selectedGenre === genre}>{genre}</option>
{/each}
</select>
{/if}
{#if hasMoodFilter}
<select
class="select select-sm rounded-full bg-base-200/50 border-base-content/10
focus:border-primary transition-all duration-200"
onchange={handleMoodSelect}
aria-label="Filter by {moodLabel.toLowerCase()}"
>
<option value="">All {moodLabel.toLowerCase()}s</option>
{#each moods! as mood (mood)}
<option value={mood} selected={selectedMood === mood}>{mood}</option>
{/each}
</select>
{/if}
{#if resultCount != null && !loading}
<span class="text-sm text-base-content/50">{resultCount} results</span>
{/if}
</div>
{/if}
{#if hasDecadeChips}
<div class="flex flex-wrap gap-2 mt-3" role="group" aria-label="Filter by decade">
<button
type="button"
class="btn btn-xs rounded-full {selectedDecade === ''
? 'btn-primary'
: 'btn-ghost bg-base-200/50'}"
onclick={() => onDecadeChange?.('')}
>
All
</button>
{#each decades! as decade (decade)}
<button
type="button"
class="btn btn-xs rounded-full {selectedDecade === decade
? 'btn-primary'
: 'btn-ghost bg-base-200/50'}"
onclick={() => onDecadeChange?.(decade)}
>
{decade}
</button>
{/each}
</div>
{/if}
{#if hasTagChips}
<div class="flex flex-wrap gap-2 mt-3" role="group" aria-label="Filter by tag">
<button
type="button"
class="btn btn-xs rounded-full {selectedTag === ''
? 'btn-primary'
: 'btn-ghost bg-base-200/50'}"
onclick={() => onTagChange?.('')}
>
All tags
</button>
{#each tags! as tag (tag)}
<button
type="button"
class="btn btn-xs rounded-full {selectedTag === tag
? 'btn-primary'
: 'btn-ghost bg-base-200/50'}"
onclick={() => onTagChange?.(tag)}
>
{tag}
</button>
{/each}
</div>
{/if}
</div>

View file

@ -1,45 +1,60 @@
<script lang="ts">
<script
lang="ts"
generics="TAlbum extends JellyfinAlbumSummary | LocalAlbumSummary | NavidromeAlbumSummary | PlexAlbumSummary"
>
import type { Snippet } from 'svelte';
import type { LibraryController } from '$lib/utils/libraryController.svelte';
import type {
JellyfinAlbumSummary,
LocalAlbumSummary,
NavidromeAlbumSummary,
PlexAlbumSummary
} from '$lib/types';
import AlbumImage from '$lib/components/AlbumImage.svelte';
import ContextMenu from '$lib/components/ContextMenu.svelte';
import AddToPlaylistModal from '$lib/components/AddToPlaylistModal.svelte';
import SourceAlbumModal from '$lib/components/SourceAlbumModal.svelte';
import LibraryFilterBar from '$lib/components/LibraryFilterBar.svelte';
import { CircleX, Play, Shuffle } from 'lucide-svelte';
import { ChevronLeft, CircleX, Play, Shuffle } from 'lucide-svelte';
import { onMount, onDestroy } from 'svelte';
/* eslint-disable @typescript-eslint/no-explicit-any */
interface Props {
ctrl: LibraryController<any>;
ctrl: LibraryController<TAlbum>;
headerIcon: Snippet;
headerTitle: string;
recentLabel?: string;
moodLabel?: string;
statsPanel?: Snippet;
recentCardOverlay?: Snippet<[any]>;
cardTopLeftBadge: Snippet<[any]>;
cardTopRightExtra?: Snippet<[any]>;
cardBottomLeft?: Snippet<[any]>;
cardBodyExtra?: Snippet<[any]>;
recentCardOverlay?: Snippet<[TAlbum]>;
cardTopLeftBadge: Snippet<[TAlbum]>;
cardTopRightExtra?: Snippet<[TAlbum]>;
cardBottomLeft?: Snippet<[TAlbum]>;
cardBodyExtra?: Snippet<[TAlbum]>;
backHref?: string;
contextMenuBackdrop?: boolean;
decades?: string[];
tags?: string[];
emptyIcon: Snippet;
emptyTitle: string;
emptyDescription: string;
}
/* eslint-enable @typescript-eslint/no-explicit-any */
let {
ctrl,
headerIcon,
headerTitle,
recentLabel = 'Recently Played',
recentLabel = 'Recently played',
moodLabel = 'Mood',
statsPanel,
recentCardOverlay,
cardTopLeftBadge,
cardTopRightExtra,
cardBottomLeft,
cardBodyExtra,
backHref,
contextMenuBackdrop = false,
decades,
tags,
emptyIcon,
emptyTitle,
emptyDescription
@ -53,6 +68,11 @@
<div class="container mx-auto p-6">
<div class="flex items-center gap-3 mb-2">
{#if backHref}
<a href={backHref} class="btn btn-ghost btn-circle btn-sm" aria-label="Back">
<ChevronLeft class="h-5 w-5" />
</a>
{/if}
{@render headerIcon()}
<h1 class="text-2xl font-bold">{headerTitle}</h1>
{#if ctrl.stats}
@ -124,7 +144,7 @@
{/if}
<div class="mb-6">
<h2 class="text-lg font-semibold mb-3 opacity-80">All Albums</h2>
<h2 class="text-lg font-semibold mb-3 opacity-80">All albums</h2>
<LibraryFilterBar
bind:searchQuery={ctrl.searchQuery}
onSearchInput={ctrl.handleSearch}
@ -137,6 +157,16 @@
genres={a.supportsGenres ? ctrl.genres : undefined}
selectedGenre={a.supportsGenres ? ctrl.selectedGenre : undefined}
onGenreChange={a.supportsGenres ? ctrl.handleGenreChange : undefined}
moods={a.supportsMoods ? ctrl.moods : undefined}
selectedMood={a.supportsMoods ? ctrl.selectedMood : undefined}
onMoodChange={a.supportsMoods ? ctrl.handleMoodChange : undefined}
{moodLabel}
decades={a.supportsDecades ? decades : undefined}
selectedDecade={a.supportsDecades ? ctrl.selectedDecade : undefined}
onDecadeChange={a.supportsDecades ? ctrl.handleDecadeChange : undefined}
tags={a.supportsTags ? tags : undefined}
selectedTag={a.supportsTags ? ctrl.selectedTag : undefined}
onTagChange={a.supportsTags ? ctrl.handleTagChange : undefined}
resultCount={ctrl.loading ? null : ctrl.total}
loading={ctrl.loading}
/>
@ -148,7 +178,7 @@
<div class="flex flex-col gap-1">
<span>{ctrl.fetchError}</span>
{#if ctrl.fetchErrorCode === 'CIRCUIT_BREAKER_OPEN' || /connection|DNS|not configured/i.test(ctrl.fetchError)}
<a href="/settings" class="link link-primary text-sm">Check your settings →</a>
<a href="/settings" class="link link-primary text-sm">Open settings</a>
{/if}
</div>
<button class="btn btn-sm btn-ghost" onclick={() => ctrl.fetchAlbums(true)}>Retry</button>
@ -267,7 +297,7 @@
{#if ctrl.loadingMore}
<span class="loading loading-spinner loading-sm"></span>
{/if}
Load More
Show more
</button>
</div>
{/if}

View file

@ -0,0 +1,162 @@
<script lang="ts">
import { X, Music2, Loader2, AlertCircle } from 'lucide-svelte';
import { slide } from 'svelte/transition';
import type { LyricLine } from '$lib/types';
interface Props {
open: boolean;
lyricsText: string;
lines?: LyricLine[];
isSynced?: boolean;
isLoading?: boolean;
hasError?: boolean;
currentTime?: number;
trackName?: string;
artistName?: string;
onclose: () => void;
}
let {
open = $bindable(),
lyricsText,
lines = [],
isSynced = false,
isLoading = false,
hasError = false,
currentTime = 0,
trackName = '',
artistName = '',
onclose
}: Props = $props();
let scrollContainer: HTMLDivElement | undefined = $state();
let userScrolling = $state(false);
let scrollTimeout: ReturnType<typeof setTimeout> | undefined;
const timedLines = $derived(
isSynced && lines.length > 0 ? lines.filter((l) => l.start_seconds !== null) : []
);
const activeLineIndex = $derived.by(() => {
if (timedLines.length === 0) return -1;
let idx = -1;
for (let i = 0; i < timedLines.length; i++) {
if ((timedLines[i].start_seconds ?? 0) <= currentTime) {
idx = i;
} else {
break;
}
}
return idx;
});
$effect(() => {
if (activeLineIndex < 0 || userScrolling || !scrollContainer) return;
const el = scrollContainer.querySelector(`[data-line="${activeLineIndex}"]`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
});
function onUserScroll() {
userScrolling = true;
clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
userScrolling = false;
}, 3000);
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
open = false;
onclose();
}
}
</script>
{#if open}
<div
class="fixed inset-x-0 bottom-[90px] z-40 flex justify-center pointer-events-none"
role="dialog"
aria-label="Lyrics"
aria-modal="true"
tabindex="-1"
onkeydown={handleKeydown}
>
<div
class="pointer-events-auto w-full max-w-lg mx-4 bg-base-200 rounded-t-2xl shadow-xl border border-base-300 flex flex-col max-h-[60vh]"
transition:slide={{ duration: 250 }}
>
<div class="flex items-center justify-between px-4 py-3 border-b border-base-300">
<div class="flex items-center gap-2 min-w-0">
<Music2 class="h-4 w-4 text-primary shrink-0" />
<div class="min-w-0">
{#if trackName}
<p class="text-sm font-semibold truncate">{trackName}</p>
{/if}
{#if artistName}
<p class="text-xs text-base-content/60 truncate">{artistName}</p>
{/if}
</div>
</div>
<button
class="btn btn-ghost btn-sm btn-circle"
onclick={() => {
open = false;
onclose();
}}
aria-label="Close lyrics"
>
<X class="h-4 w-4" />
</button>
</div>
<div
bind:this={scrollContainer}
class="overflow-y-auto px-6 py-4 flex-1"
onscroll={onUserScroll}
>
{#if isLoading}
<div class="flex flex-col items-center justify-center py-12 gap-3">
<Loader2 class="h-6 w-6 animate-spin text-primary" />
<p class="text-sm text-base-content/50">Loading lyrics...</p>
</div>
{:else if hasError}
<div class="flex flex-col items-center justify-center py-8 gap-2">
<AlertCircle class="h-5 w-5 text-warning" />
<p class="text-center text-base-content/50 text-sm">
Couldn't load the lyrics. Try again in a bit.
</p>
</div>
{:else if timedLines.length > 0}
<div class="space-y-2">
{#each timedLines as line, i (i)}
<p
data-line={i}
class="text-sm leading-relaxed transition-all duration-300
{i === activeLineIndex ? 'text-primary font-semibold' : ''}
{i !== activeLineIndex && i < activeLineIndex ? 'opacity-80' : ''}
{i > activeLineIndex ? 'opacity-40' : ''}"
>
{line.text}
</p>
{/each}
</div>
{:else if lyricsText.trim()}
<pre
class="whitespace-pre-wrap font-sans text-sm leading-relaxed text-base-content/80">{lyricsText}</pre>
{:else}
<p class="text-center text-base-content/40 py-8">
Lyrics aren't available for this track.
</p>
{/if}
</div>
{#if isSynced}
<div class="px-4 py-2 border-t border-base-300">
<span class="badge badge-xs badge-primary">Synced</span>
</div>
{/if}
</div>
</div>
{/if}

View file

@ -0,0 +1,115 @@
<script lang="ts">
import { X, Info, ExternalLink } from 'lucide-svelte';
import { slide } from 'svelte/transition';
interface Props {
open: boolean;
title: string;
notes?: string;
imageUrl?: string;
lastfmUrl?: string;
musicbrainzId?: string;
onclose: () => void;
}
let {
open = $bindable(),
title,
notes = '',
imageUrl = '',
lastfmUrl = '',
musicbrainzId = '',
onclose
}: Props = $props();
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
open = false;
onclose();
}
}
const musicbrainzUrl = $derived(
musicbrainzId ? `https://musicbrainz.org/release-group/${musicbrainzId}` : ''
);
</script>
{#if open}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
role="dialog"
aria-label="Album details"
aria-modal="true"
tabindex="-1"
onkeydown={handleKeydown}
onclick={() => {
open = false;
onclose();
}}
>
<div
class="bg-base-200 rounded-xl shadow-xl border border-base-300 w-full max-w-md mx-4 max-h-[70vh] flex flex-col"
transition:slide={{ duration: 200 }}
onkeydown={(e) => e.stopPropagation()}
onclick={(e) => e.stopPropagation()}
>
<div class="flex items-center justify-between px-4 py-3 border-b border-base-300">
<div class="flex items-center gap-2">
<Info class="h-4 w-4 text-primary" />
<span class="font-semibold text-sm">{title}</span>
</div>
<button
class="btn btn-ghost btn-sm btn-circle"
onclick={() => {
open = false;
onclose();
}}
aria-label="Close details"
>
<X class="h-4 w-4" />
</button>
</div>
<div class="overflow-y-auto px-4 py-4 flex-1 space-y-4">
{#if imageUrl}
<img src={imageUrl} alt={title} class="w-full rounded-lg object-cover max-h-48" />
{/if}
{#if notes}
<!-- eslint-disable-next-line svelte/no-at-html-tags -- Notes from Last.fm contain HTML -->
<p class="text-sm leading-relaxed text-base-content/80">{@html notes}</p>
{:else}
<p class="text-center text-base-content/40 py-4">No notes available for this release.</p>
{/if}
{#if lastfmUrl || musicbrainzUrl}
<div class="flex flex-wrap gap-2 pt-2">
{#if lastfmUrl}
<a
href={lastfmUrl}
target="_blank"
rel="noopener noreferrer"
class="btn btn-ghost btn-xs gap-1"
>
<ExternalLink class="h-3 w-3" />
Last.fm
</a>
{/if}
{#if musicbrainzUrl}
<a
href={musicbrainzUrl}
target="_blank"
rel="noopener noreferrer"
class="btn btn-ghost btn-xs gap-1"
>
<ExternalLink class="h-3 w-3" />
MusicBrainz
</a>
{/if}
</div>
{/if}
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,109 @@
<script lang="ts">
import ArtistImage from '$lib/components/ArtistImage.svelte';
import AlbumImage from '$lib/components/AlbumImage.svelte';
interface MostPlayedArtist {
id: string;
name: string;
image_url?: string | null;
musicbrainz_id?: string | null;
play_count?: number | null;
album_count?: number;
}
interface MostPlayedAlbum {
id: string;
name: string;
artist_name?: string;
image_url?: string | null;
musicbrainz_id?: string | null;
play_count?: number | null;
}
interface Props {
artists: MostPlayedArtist[];
albums: MostPlayedAlbum[];
onAlbumClick?: (album: MostPlayedAlbum) => void;
}
let { artists, albums, onAlbumClick }: Props = $props();
</script>
{#if artists.length === 0 && albums.length === 0}
<p class="text-sm text-base-content/50">No listening data yet.</p>
{:else}
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
{#if artists.length > 0}
<div class="space-y-2">
<h3 class="text-sm font-semibold uppercase tracking-wider text-base-content/50">Artists</h3>
<ol class="space-y-1">
{#each artists as artist (artist.id)}
<li
class="flex items-center gap-3 rounded-lg px-2 py-1.5 transition-colors hover:bg-base-200/50"
>
<span class="w-5 text-right text-xs font-bold text-base-content/40"
>{artists.indexOf(artist) + 1}</span
>
<div class="h-9 w-9 shrink-0 overflow-hidden rounded-full">
<ArtistImage
mbid={artist.musicbrainz_id ?? artist.id}
remoteUrl={artist.image_url}
alt={artist.name}
size="xs"
rounded="full"
className="h-full w-full"
/>
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">{artist.name}</p>
<p class="text-xs text-base-content/50">
{artist.play_count ?? 0} plays{artist.album_count != null
? `, ${artist.album_count} album${artist.album_count !== 1 ? 's' : ''}`
: ''}
</p>
</div>
</li>
{/each}
</ol>
</div>
{/if}
{#if albums.length > 0}
<div class="space-y-2">
<h3 class="text-sm font-semibold uppercase tracking-wider text-base-content/50">Albums</h3>
<ol class="space-y-1">
{#each albums as album (album.id)}
<li>
<button
class="flex w-full items-center gap-3 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-base-200/50"
onclick={() => onAlbumClick?.(album)}
>
<span class="w-5 text-right text-xs font-bold text-base-content/40"
>{albums.indexOf(album) + 1}</span
>
<div class="h-9 w-9 shrink-0 overflow-hidden rounded-md">
<AlbumImage
mbid={album.musicbrainz_id ?? album.id}
customUrl={album.image_url}
alt={album.name}
size="xs"
rounded="none"
className="h-full w-full"
/>
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">{album.name}</p>
<p class="truncate text-xs text-base-content/50">
{album.artist_name ?? ''}{album.play_count != null
? `, ${album.play_count} plays`
: ''}
</p>
</div>
</button>
</li>
{/each}
</ol>
</div>
{/if}
</div>
{/if}

View file

@ -0,0 +1,107 @@
<script lang="ts">
import { Music, Pause } from 'lucide-svelte';
import AudioQualityBadge from '$lib/components/AudioQualityBadge.svelte';
import type { NowPlayingSession } from '$lib/types';
interface Props {
sessions: NowPlayingSession[];
coverBuilder?: (session: NowPlayingSession) => string;
}
let { sessions, coverBuilder }: Props = $props();
function formatTime(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const m = Math.floor(totalSeconds / 60);
const s = totalSeconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
function progressPercent(session: NowPlayingSession): number | null {
if (session.progress_ms == null || session.duration_ms == null || session.duration_ms <= 0)
return null;
return Math.min(100, (session.progress_ms / session.duration_ms) * 100);
}
function getCoverUrl(session: NowPlayingSession): string {
if (coverBuilder) return coverBuilder(session);
return session.cover_url || '';
}
</script>
{#if sessions.length > 0}
<section class="space-y-3">
<div class="flex items-center gap-2 px-1">
<div class="now-playing-bars">
<span></span><span></span><span></span>
</div>
<h2 class="text-lg font-semibold text-base-content sm:text-xl">Now Playing</h2>
</div>
<div class="flex gap-3 overflow-x-auto pb-2">
{#each sessions as session (session.id)}
{@const cover = getCoverUrl(session)}
{@const progress = progressPercent(session)}
<div
class="flex min-w-[280px] max-w-[340px] shrink-0 items-center gap-3 rounded-xl bg-base-200/60 p-3 backdrop-blur-sm transition-all"
>
{#if cover}
<div class="relative h-14 w-14 shrink-0 overflow-hidden rounded-lg">
<img
src={cover}
alt={session.album_name}
class="h-full w-full object-cover"
loading="lazy"
/>
{#if session.is_paused}
<div class="absolute inset-0 flex items-center justify-center bg-black/40">
<Pause class="h-5 w-5 text-white" />
</div>
{/if}
</div>
{:else}
<div class="flex h-14 w-14 shrink-0 items-center justify-center rounded-lg bg-base-300">
<Music class="h-6 w-6 text-base-content/40" />
</div>
{/if}
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-base-content">
{session.track_name}
</p>
<p class="truncate text-xs text-base-content/60">
{session.artist_name}
</p>
<div class="mt-0.5 flex items-center gap-1.5 text-[10px] text-base-content/40">
<span>{session.user_name}</span>
{#if session.device_name}
<span>-</span>
<span>{session.device_name}</span>
{/if}
{#if session.audio_codec}
<span>-</span>
<AudioQualityBadge codec={session.audio_codec} bitrate={session.bitrate} compact />
{/if}
</div>
{#if progress !== null}
<div class="mt-1.5 flex items-center gap-1.5">
<div class="h-1 flex-1 overflow-hidden rounded-full bg-base-300">
<div
class="h-full rounded-full bg-primary transition-all duration-1000"
style="width: {progress}%"
></div>
</div>
{#if session.progress_ms != null && session.duration_ms != null}
<span class="shrink-0 text-[10px] tabular-nums text-base-content/40">
{formatTime(session.progress_ms)}/{formatTime(session.duration_ms)}
</span>
{/if}
</div>
{/if}
</div>
</div>
{/each}
</div>
</section>
{/if}

View file

@ -5,10 +5,14 @@
import YouTubePlayer from '$lib/components/YouTubePlayer.svelte';
import JellyfinIcon from '$lib/components/JellyfinIcon.svelte';
import NavidromeIcon from '$lib/components/NavidromeIcon.svelte';
import PlexIcon from '$lib/components/PlexIcon.svelte';
import QueueDrawer from '$lib/components/QueueDrawer.svelte';
import EqPanel from '$lib/components/EqPanel.svelte';
import LyricsPanel from '$lib/components/LyricsPanel.svelte';
import AudioQualityBadge from '$lib/components/AudioQualityBadge.svelte';
import NowPlayingIndicator from '$lib/components/NowPlayingIndicator.svelte';
import { getCoverUrl } from '$lib/utils/errorHandling';
import { API } from '$lib/constants';
import {
X,
Music,
@ -23,13 +27,78 @@
Check,
CircleX,
ListMusic,
SlidersHorizontal
SlidersHorizontal,
Music2
} from 'lucide-svelte';
let coverImgError = $state(false);
let lastCoverKey = '';
let eqPanelOpen = $state(false);
let queueDrawerOpen = $state(false);
import type { LyricLine } from '$lib/types';
let lyricsPanelOpen = $state(false);
let lyricsText = $state('');
let lyricsLines: LyricLine[] = $state([]);
let lyricsIsSynced = $state(false);
let lyricsLoading = $state(false);
let lyricsError = $state(false);
const supportsLyrics = $derived(
playerStore.nowPlaying?.sourceType === 'navidrome' ||
playerStore.nowPlaying?.sourceType === 'jellyfin'
);
async function fetchLyrics() {
const np = playerStore.nowPlaying;
if (!np?.trackSourceId) return;
lyricsLoading = true;
lyricsError = false;
try {
let url: string;
if (np.sourceType === 'navidrome') {
url = API.navidromeLibrary.lyrics(np.trackSourceId, np.artistName, np.trackName);
} else if (np.sourceType === 'jellyfin') {
url = API.jellyfinLibrary.lyrics(np.trackSourceId);
} else {
return;
}
const res = await fetch(url);
if (!res.ok) {
lyricsText = '';
lyricsLines = [];
lyricsIsSynced = false;
if (res.status !== 404) lyricsError = true;
return;
}
const data = await res.json();
if (np.sourceType === 'navidrome') {
lyricsText = data.text ?? '';
lyricsIsSynced = data.is_synced ?? false;
lyricsLines = data.lines ?? [];
} else {
lyricsText = data.lyrics_text ?? '';
lyricsIsSynced = data.is_synced ?? false;
lyricsLines = data.lines ?? [];
}
} catch {
lyricsText = '';
lyricsLines = [];
lyricsIsSynced = false;
lyricsError = true;
} finally {
lyricsLoading = false;
}
}
function toggleLyrics() {
if (lyricsPanelOpen) {
lyricsPanelOpen = false;
return;
}
fetchLyrics();
lyricsPanelOpen = true;
}
function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
@ -123,7 +192,7 @@
{:else}
{playerStore.nowPlaying.albumName}
{/if}
-
{#if playerStore.nowPlaying.artistId}
<a href="/artist/{playerStore.nowPlaying.artistId}" class="hover:underline"
>{playerStore.nowPlaying.artistName}</a
@ -157,8 +226,11 @@
Track {playerStore.currentTrackNumber} of {playerStore.queueLength}
</p>
{/if}
{#if playerStore.nowPlaying.format}
<AudioQualityBadge codec={playerStore.nowPlaying.format} compact />
{/if}
{#if playerStore.playbackState === 'error'}
<p class="text-xs text-error truncate">Track unavailable</p>
<p class="text-xs text-error truncate">This track isn't available right now.</p>
{/if}
</div>
</div>
@ -241,7 +313,7 @@
>
</div>
{#if !playerStore.isSeekable}
<p class="text-[10px] text-base-content/60">Seeking unavailable for this stream format</p>
<p class="text-[10px] text-base-content/60">This stream doesn't support seeking.</p>
{/if}
</div>
@ -262,6 +334,20 @@
</button>
</div>
{#if supportsLyrics}
<div class="tooltip tooltip-left" data-tip="Lyrics">
<button
class="btn btn-ghost btn-sm btn-circle"
class:text-accent={lyricsPanelOpen}
class:loading={lyricsLoading}
onclick={toggleLyrics}
aria-label="Toggle lyrics"
>
<Music2 class="h-4 w-4" />
</button>
</div>
{/if}
<div
class="tooltip tooltip-left"
data-tip={playerStore.nowPlaying?.sourceType === 'youtube'
@ -330,6 +416,11 @@
<NavidromeIcon class="h-5 w-5" />
<span class="text-sm font-medium">Navidrome</span>
</div>
{:else if playerStore.nowPlaying.sourceType === 'plex'}
<div class="hidden sm:flex items-center gap-2" style="color: rgb(var(--brand-plex))">
<PlexIcon class="h-5 w-5" />
<span class="text-sm font-medium">Plex</span>
</div>
{:else if playerStore.nowPlaying.sourceType === 'local'}
<div
class="hidden sm:flex items-center gap-2"
@ -350,4 +441,16 @@
<QueueDrawer bind:open={queueDrawerOpen} onclose={() => (queueDrawerOpen = false)} />
<EqPanel bind:open={eqPanelOpen} onclose={() => (eqPanelOpen = false)} />
<LyricsPanel
bind:open={lyricsPanelOpen}
{lyricsText}
lines={lyricsLines}
isSynced={lyricsIsSynced}
isLoading={lyricsLoading}
hasError={lyricsError}
currentTime={playerStore.progress}
trackName={playerStore.nowPlaying?.trackName ?? ''}
artistName={playerStore.nowPlaying?.artistName ?? ''}
onclose={() => (lyricsPanelOpen = false)}
/>
{/if}

View file

@ -6,7 +6,10 @@
import { playerStore } from '$lib/stores/player.svelte';
import { toastStore } from '$lib/stores/toast';
import { formatTotalDurationSec } from '$lib/utils/formatting';
import { Play, Shuffle, Trash2 } from 'lucide-svelte';
import { getSourceColor, getSourceLabel } from '$lib/utils/sources';
import { Play, Shuffle, Trash2, Tv } from 'lucide-svelte';
import NavidromeIcon from '$lib/components/NavidromeIcon.svelte';
import PlexIcon from '$lib/components/PlexIcon.svelte';
import PlaylistMosaic from './PlaylistMosaic.svelte';
interface Props {
@ -22,12 +25,16 @@
let deleting = $state(false);
let confirmTimer: ReturnType<typeof setTimeout> | undefined;
let sourceType = $derived(playlist.source_ref?.split(':')[0] ?? null);
let sourceColor = $derived(sourceType ? getSourceColor(sourceType) : null);
let sourceLabel = $derived(sourceType ? getSourceLabel(sourceType) : null);
let durationText = $derived(
playlist.total_duration ? formatTotalDurationSec(playlist.total_duration) : ''
);
let subtitle = $derived(
`${playlist.track_count} track${playlist.track_count === 1 ? '' : 's'}${durationText ? ` · ${durationText}` : ''}`
`${playlist.track_count} track${playlist.track_count === 1 ? '' : 's'}${durationText ? ` - ${durationText}` : ''}${sourceLabel ? ` - from ${sourceLabel}` : ''}`
);
let hasPlayableTracks = $derived(playlist.track_count > 0);
@ -43,12 +50,15 @@
.map(playlistTrackToQueueItem)
.filter((item): item is NonNullable<typeof item> => item !== null);
if (items.length === 0) {
toastStore.show({ message: 'Nothing here can be played right now', type: 'info' });
toastStore.show({
message: "This playlist doesn't have anything playable yet.",
type: 'info'
});
return;
}
playerStore.playQueue(items, 0, false);
} catch {
toastStore.show({ message: "Couldn't load this playlist", type: 'error' });
toastStore.show({ message: "Couldn't load that playlist.", type: 'error' });
} finally {
playLoading = false;
}
@ -65,12 +75,15 @@
.map(playlistTrackToQueueItem)
.filter((item): item is NonNullable<typeof item> => item !== null);
if (items.length === 0) {
toastStore.show({ message: 'Nothing here can be played right now', type: 'info' });
toastStore.show({
message: "This playlist doesn't have anything playable yet.",
type: 'info'
});
return;
}
playerStore.playQueue(items, 0, true);
} catch {
toastStore.show({ message: "Couldn't load this playlist", type: 'error' });
toastStore.show({ message: "Couldn't load that playlist.", type: 'error' });
} finally {
shuffleLoading = false;
}
@ -97,10 +110,10 @@
deleting = true;
try {
await deletePlaylist(playlist.id);
toastStore.show({ message: 'Playlist deleted', type: 'success' });
toastStore.show({ message: 'Playlist deleted.', type: 'success' });
ondelete?.(playlist.id);
} catch {
toastStore.show({ message: "Couldn't delete the playlist", type: 'error' });
toastStore.show({ message: "Couldn't delete that playlist.", type: 'error' });
} finally {
deleting = false;
deleteConfirming = false;
@ -113,7 +126,14 @@
</script>
<div
class="card card-sm bg-base-100 w-full shadow-sm shrink-0 group relative transition-all hover:shadow-[0_0_20px_rgba(174,213,242,0.15)]"
class="card card-sm w-full shadow-sm shrink-0 group relative transition-all duration-200"
class:bg-base-100={!sourceType}
style={sourceColor
? `background: color-mix(in srgb, ${sourceColor} 6%, var(--color-base-100)); border-left: 3px solid color-mix(in srgb, ${sourceColor} 50%, transparent);`
: ''}
style:--source-glow={sourceColor
? `color-mix(in srgb, ${sourceColor} 20%, transparent)`
: 'rgba(174,213,242,0.15)'}
>
<a
href="/playlists/{playlist.id}"
@ -131,6 +151,21 @@
rounded="none"
/>
</div>
{#if sourceType}
<div
class="absolute top-2 right-2 flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider shadow-md backdrop-blur-sm"
style="background: color-mix(in srgb, {sourceColor} 85%, black); color: white;"
>
{#if sourceType === 'jellyfin'}
<Tv class="h-3 w-3" />
{:else if sourceType === 'navidrome'}
<NavidromeIcon class="h-3 w-3" />
{:else if sourceType === 'plex'}
<PlexIcon class="h-3 w-3" />
{/if}
<span>{sourceLabel}</span>
</div>
{/if}
</figure>
<div class="px-3 pt-3 pb-1">
<h3 class="text-sm font-semibold line-clamp-1">{playlist.name}</h3>
@ -144,7 +179,7 @@
onclick={handlePlay}
disabled={!hasPlayableTracks || playLoading}
aria-label="Play {playlist.name}"
title={hasPlayableTracks ? `Play ${playlist.name}` : 'No tracks to play'}
title={hasPlayableTracks ? `Play ${playlist.name}` : 'No playable tracks'}
>
{#if playLoading}
<span class="loading loading-spinner loading-xs"></span>
@ -158,7 +193,7 @@
onclick={handleShuffle}
disabled={!hasPlayableTracks || shuffleLoading}
aria-label="Shuffle {playlist.name}"
title={hasPlayableTracks ? `Shuffle ${playlist.name}` : 'No tracks to shuffle'}
title={hasPlayableTracks ? `Shuffle ${playlist.name}` : 'No playable tracks'}
>
{#if shuffleLoading}
<span class="loading loading-spinner loading-xs"></span>
@ -177,7 +212,7 @@
aria-label={deleteConfirming
? `Confirm delete ${playlist.name}`
: `Delete ${playlist.name}`}
title={deleteConfirming ? 'Click again to confirm' : `Delete ${playlist.name}`}
title={deleteConfirming ? 'Click again to delete' : `Delete ${playlist.name}`}
>
{#if deleting}
<span class="loading loading-spinner loading-xs"></span>
@ -188,3 +223,9 @@
</div>
</div>
</div>
<style>
div.card:hover {
box-shadow: 0 0 20px var(--source-glow, rgba(174, 213, 242, 0.15));
}
</style>

View file

@ -11,6 +11,7 @@ const basePlaylist: PlaylistSummary = {
total_duration: 1234,
cover_urls: ['a.jpg', 'b.jpg'],
custom_cover_url: null,
source_ref: null,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-02T00:00:00Z'
};
@ -52,6 +53,6 @@ describe('PlaylistCard.svelte', () => {
const subtitle = page.getByText(/5 tracks/);
await expect.element(subtitle).toBeInTheDocument();
const el = await subtitle.element();
expect(el.textContent).not.toContain('·');
expect(el.textContent).not.toContain(' - ');
});
});

View file

@ -0,0 +1,167 @@
<script lang="ts">
import type { SourcePlaylistSummary } from '$lib/types';
import type { Snippet } from 'svelte';
import { ListMusic, ChevronRight, CheckCircle2, ArrowRight } from 'lucide-svelte';
import { reveal } from '$lib/actions/reveal';
interface Props {
playlists: SourcePlaylistSummary[];
sourceLabel: string;
playlistsHref: string;
sourceIcon: Snippet;
}
let { playlists, sourceLabel, playlistsHref, sourceIcon }: Props = $props();
let totalCount = $derived(playlists.length);
let importedCount = $derived(playlists.filter((p) => p.is_imported).length);
let allImported = $derived(totalCount > 0 && importedCount === totalCount);
let noneImported = $derived(importedCount === 0);
let coverUrls = $derived(
playlists
.slice(0, 4)
.map((p) => p.cover_url)
.filter(Boolean)
);
let progressPct = $derived(totalCount > 0 ? (importedCount / totalCount) * 100 : 0);
const RING_RADIUS = 28;
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS;
let strokeDashoffset = $derived(RING_CIRCUMFERENCE - (progressPct / 100) * RING_CIRCUMFERENCE);
let isHovered = $state(false);
const fanAngles = [-8, -3, 3, 8];
const fanOffsets = [-12, -4, 4, 12];
</script>
{#if totalCount > 0}
<div use:reveal>
<a
href={playlistsHref}
class="group relative flex w-full overflow-hidden rounded-2xl border border-base-content/5 bg-base-200/30 p-5 backdrop-blur-md transition-all duration-300 hover:border-base-content/10 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-2 focus-visible:ring-offset-base-100 sm:p-6"
onpointerenter={() => (isHovered = true)}
onpointerleave={() => (isHovered = false)}
>
<div
class="pointer-events-none absolute inset-0 rounded-2xl bg-gradient-to-br from-primary/[0.04] via-transparent to-secondary/[0.04]"
></div>
<div class="relative mr-6 hidden h-28 w-36 shrink-0 items-center justify-center sm:flex">
{#if coverUrls.length > 0}
{#each coverUrls as url, i (url)}
{@const angle = fanAngles[i] ?? 0}
{@const offset = fanOffsets[i] ?? 0}
<div
class="absolute h-20 w-20 overflow-hidden rounded-xl border-2 border-base-100 shadow-md transition-all duration-500"
style="
transform: rotate({isHovered ? angle * 1.4 : angle}deg) translateX({isHovered
? offset * 1.3
: offset}px);
z-index: {i};
"
>
<img src={url} alt="" class="h-full w-full object-cover" loading="lazy" />
</div>
{/each}
{:else}
{#each [0, 1, 2] as i (i)}
{@const angle = [-6, 0, 6][i]}
<div
class="absolute flex h-20 w-20 items-center justify-center overflow-hidden rounded-xl border-2 border-base-100 bg-gradient-to-br from-primary/15 to-secondary/15 shadow-md transition-all duration-500"
style="
transform: rotate({isHovered ? angle * 1.4 : angle}deg);
z-index: {i};
"
>
<ListMusic class="h-8 w-8 text-base-content/20" />
</div>
{/each}
{/if}
</div>
<div class="flex min-w-0 flex-1 items-center gap-4 sm:gap-5">
<div class="min-w-0 flex-1">
<div class="mb-1.5 flex items-center gap-1.5">
{@render sourceIcon()}
<span class="text-xs font-medium uppercase tracking-wider text-base-content/50"
>{sourceLabel}</span
>
</div>
{#if allImported}
<h3 class="text-lg font-bold leading-tight sm:text-xl">
All {totalCount} playlists imported!
</h3>
<p class="mt-1 text-sm text-base-content/50">
Your {sourceLabel} playlists are now in MusicSeerr.
</p>
{:else}
<h3 class="text-lg font-bold leading-tight sm:text-xl">
Bring your {totalCount}
{sourceLabel} playlist{totalCount === 1 ? '' : 's'} to MusicSeerr
</h3>
<p class="mt-1 text-sm text-base-content/50">
{#if noneImported}
Browse and import your playlists in one click.
{:else}
{importedCount} of {totalCount} imported so far.
{/if}
</p>
{/if}
</div>
<div class="relative shrink-0">
<svg class="h-16 w-16 -rotate-90 sm:h-[72px] sm:w-[72px]" viewBox="0 0 64 64" fill="none">
<circle
cx="32"
cy="32"
r={RING_RADIUS}
stroke="currentColor"
stroke-width="4"
class="text-base-content/10"
/>
<circle
cx="32"
cy="32"
r={RING_RADIUS}
stroke="currentColor"
stroke-width="4"
stroke-linecap="round"
stroke-dasharray={RING_CIRCUMFERENCE}
stroke-dashoffset={strokeDashoffset}
class="transition-all duration-700 {allImported ? 'text-success' : 'text-primary'}"
/>
</svg>
<div class="absolute inset-0 flex items-center justify-center">
{#if allImported}
<CheckCircle2 class="h-6 w-6 text-success" />
{:else}
<span class="text-xs font-bold tabular-nums text-base-content/70">
{importedCount}/{totalCount}
</span>
{/if}
</div>
</div>
<div class="hidden shrink-0 sm:flex">
<div
class="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 transition-all duration-300 group-hover:bg-primary/20 group-hover:scale-110"
>
{#if allImported}
<ArrowRight
class="h-5 w-5 text-primary transition-transform duration-300 group-hover:translate-x-0.5"
/>
{:else}
<ChevronRight
class="h-5 w-5 text-primary transition-transform duration-300 group-hover:translate-x-0.5"
/>
{/if}
</div>
</div>
</div>
</a>
</div>
{/if}

Some files were not shown because too many files have changed in this diff Show more