mirror of
https://github.com/HabiRabbu/Musicseerr
synced 2026-04-21 13:37:27 +00:00
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:
parent
90b7b67a10
commit
0f25ebc26d
177 changed files with 21156 additions and 769 deletions
79
Makefile
79
Makefile
|
|
@ -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'
|
||||
|
|
|
|||
19
README.md
19
README.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1,28 +1,120 @@
|
|||
import logging
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
|
||||
from fastapi.responses import Response
|
||||
|
||||
from api.v1.schemas.jellyfin import (
|
||||
JellyfinAlbumDetail,
|
||||
JellyfinAlbumMatch,
|
||||
JellyfinAlbumSummary,
|
||||
JellyfinArtistIndexResponse,
|
||||
JellyfinArtistPage,
|
||||
JellyfinArtistSummary,
|
||||
JellyfinFavoritesExpanded,
|
||||
JellyfinFilterFacets,
|
||||
JellyfinHubResponse,
|
||||
JellyfinImportResult,
|
||||
JellyfinLibraryStats,
|
||||
JellyfinLyricsResponse,
|
||||
JellyfinPaginatedResponse,
|
||||
JellyfinPlaylistDetail,
|
||||
JellyfinPlaylistSummary,
|
||||
JellyfinSearchResponse,
|
||||
JellyfinSessionsResponse,
|
||||
JellyfinTrackInfo,
|
||||
JellyfinTrackPage,
|
||||
)
|
||||
from core.dependencies import get_jellyfin_library_service
|
||||
from core.exceptions import ExternalServiceError
|
||||
from core.dependencies import (
|
||||
get_jellyfin_library_service,
|
||||
get_jellyfin_repository,
|
||||
get_local_files_service,
|
||||
get_navidrome_library_service,
|
||||
get_plex_library_service,
|
||||
get_playlist_service,
|
||||
)
|
||||
from core.exceptions import ExternalServiceError, ResourceNotFoundError
|
||||
from infrastructure.msgspec_fastapi import MsgSpecRoute
|
||||
from repositories.jellyfin_repository import JellyfinRepository
|
||||
from services.jellyfin_library_service import JellyfinLibraryService
|
||||
from services.playlist_service import PlaylistService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(route_class=MsgSpecRoute, prefix="/jellyfin", tags=["jellyfin-library"])
|
||||
|
||||
|
||||
@router.get("/hub", response_model=JellyfinHubResponse)
|
||||
async def get_jellyfin_hub(
|
||||
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
||||
playlist_service: PlaylistService = Depends(get_playlist_service),
|
||||
) -> JellyfinHubResponse:
|
||||
try:
|
||||
hub = await service.get_hub_data()
|
||||
imported_ids = await playlist_service.get_imported_source_ids("jellyfin:")
|
||||
for p in hub.playlists:
|
||||
if p.id in imported_ids:
|
||||
p.is_imported = True
|
||||
return hub
|
||||
except ExternalServiceError as e:
|
||||
logger.error("Jellyfin service error getting hub data: %s", e)
|
||||
raise HTTPException(status_code=502, detail="Failed to communicate with Jellyfin")
|
||||
|
||||
|
||||
@router.get("/image/{item_id}")
|
||||
async def get_jellyfin_image(
|
||||
item_id: str,
|
||||
size: int = Query(default=500, ge=32, le=1200),
|
||||
repo: JellyfinRepository = Depends(get_jellyfin_repository),
|
||||
) -> Response:
|
||||
try:
|
||||
image_bytes, content_type = await repo.proxy_image(item_id, size)
|
||||
return Response(
|
||||
content=image_bytes,
|
||||
media_type=content_type,
|
||||
headers={"Cache-Control": "public, max-age=31536000, immutable"},
|
||||
)
|
||||
except ExternalServiceError as e:
|
||||
logger.warning("Jellyfin image failed for %s: %s", item_id, e)
|
||||
raise HTTPException(status_code=502, detail="Failed to fetch image")
|
||||
|
||||
|
||||
@router.get("/recently-added", response_model=list[JellyfinAlbumSummary])
|
||||
async def get_jellyfin_recently_added(
|
||||
limit: int = Query(default=20, ge=1, le=50),
|
||||
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
||||
) -> list[JellyfinAlbumSummary]:
|
||||
try:
|
||||
return await service.get_recently_added(limit=limit)
|
||||
except ExternalServiceError as e:
|
||||
logger.error("Jellyfin service error getting recently added: %s", e)
|
||||
raise HTTPException(status_code=502, detail="Failed to communicate with Jellyfin")
|
||||
|
||||
|
||||
@router.get("/most-played/artists", response_model=list[JellyfinArtistSummary])
|
||||
async def get_jellyfin_most_played_artists(
|
||||
limit: int = Query(default=10, ge=1, le=50),
|
||||
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
||||
) -> list[JellyfinArtistSummary]:
|
||||
try:
|
||||
return await service.get_most_played_artists(limit=limit)
|
||||
except ExternalServiceError as e:
|
||||
logger.error("Jellyfin service error getting most played artists: %s", e)
|
||||
raise HTTPException(status_code=502, detail="Failed to communicate with Jellyfin")
|
||||
|
||||
|
||||
@router.get("/most-played/albums", response_model=list[JellyfinAlbumSummary])
|
||||
async def get_jellyfin_most_played_albums(
|
||||
limit: int = Query(default=10, ge=1, le=50),
|
||||
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
||||
) -> list[JellyfinAlbumSummary]:
|
||||
try:
|
||||
return await service.get_most_played_albums(limit=limit)
|
||||
except ExternalServiceError as e:
|
||||
logger.error("Jellyfin service error getting most played albums: %s", e)
|
||||
raise HTTPException(status_code=502, detail="Failed to communicate with Jellyfin")
|
||||
|
||||
|
||||
@router.get("/albums", response_model=JellyfinPaginatedResponse)
|
||||
async def get_jellyfin_albums(
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
|
|
@ -30,11 +122,15 @@ async def get_jellyfin_albums(
|
|||
sort_by: Literal["SortName", "DateCreated", "PlayCount", "ProductionYear"] = Query(default="SortName"),
|
||||
sort_order: Literal["Ascending", "Descending"] = Query(default="Ascending"),
|
||||
genre: str | None = Query(default=None),
|
||||
year: int | None = Query(default=None),
|
||||
tags: str | None = Query(default=None),
|
||||
studios: str | None = Query(default=None),
|
||||
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
||||
) -> JellyfinPaginatedResponse:
|
||||
try:
|
||||
items, total = await service.get_albums(
|
||||
limit=limit, offset=offset, sort_by=sort_by, sort_order=sort_order, genre=genre
|
||||
limit=limit, offset=offset, sort_by=sort_by, sort_order=sort_order,
|
||||
genre=genre, year=year, tags=tags, studios=studios,
|
||||
)
|
||||
return JellyfinPaginatedResponse(
|
||||
items=items, total=total, offset=offset, limit=limit
|
||||
|
|
@ -83,6 +179,25 @@ async def match_jellyfin_album(
|
|||
raise HTTPException(status_code=502, detail="Failed to match Jellyfin album")
|
||||
|
||||
|
||||
@router.get("/artists/browse", response_model=JellyfinArtistPage)
|
||||
async def browse_jellyfin_artists(
|
||||
limit: int = Query(48, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
sort_by: str = Query("SortName"),
|
||||
sort_order: str = Query("Ascending"),
|
||||
search: str = Query(""),
|
||||
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
||||
) -> JellyfinArtistPage:
|
||||
try:
|
||||
items, total = await service.browse_artists(
|
||||
limit=limit, offset=offset, sort_by=sort_by, sort_order=sort_order, search=search
|
||||
)
|
||||
return JellyfinArtistPage(items=items, total=total, offset=offset, limit=limit)
|
||||
except ExternalServiceError as e:
|
||||
logger.error("Jellyfin service error browsing artists: %s", e)
|
||||
raise HTTPException(status_code=502, detail="Failed to communicate with Jellyfin")
|
||||
|
||||
|
||||
@router.get("/artists", response_model=list[JellyfinArtistSummary])
|
||||
async def get_jellyfin_artists(
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
|
|
@ -92,6 +207,36 @@ async def get_jellyfin_artists(
|
|||
return await service.get_artists(limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.get("/artists/index", response_model=JellyfinArtistIndexResponse)
|
||||
async def get_jellyfin_artists_index(
|
||||
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
||||
) -> JellyfinArtistIndexResponse:
|
||||
try:
|
||||
return await service.get_artists_index()
|
||||
except ExternalServiceError as e:
|
||||
logger.error("Jellyfin service error getting artist index: %s", e)
|
||||
raise HTTPException(status_code=502, detail="Failed to communicate with Jellyfin")
|
||||
|
||||
|
||||
@router.get("/tracks", response_model=JellyfinTrackPage)
|
||||
async def browse_jellyfin_tracks(
|
||||
limit: int = Query(48, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
sort_by: str = Query("SortName"),
|
||||
sort_order: str = Query("Ascending"),
|
||||
search: str = Query(""),
|
||||
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
||||
) -> JellyfinTrackPage:
|
||||
try:
|
||||
items, total = await service.browse_tracks(
|
||||
limit=limit, offset=offset, sort_by=sort_by, sort_order=sort_order, search=search
|
||||
)
|
||||
return JellyfinTrackPage(items=items, total=total, offset=offset, limit=limit)
|
||||
except ExternalServiceError as e:
|
||||
logger.error("Jellyfin service error browsing tracks: %s", e)
|
||||
raise HTTPException(status_code=502, detail="Failed to communicate with Jellyfin")
|
||||
|
||||
|
||||
@router.get("/search", response_model=JellyfinSearchResponse)
|
||||
async def search_jellyfin(
|
||||
q: str = Query(..., min_length=1),
|
||||
|
|
@ -116,6 +261,32 @@ async def get_jellyfin_favorites(
|
|||
return await service.get_favorites(limit=limit)
|
||||
|
||||
|
||||
@router.get("/favorites/expanded", response_model=JellyfinFavoritesExpanded)
|
||||
async def get_jellyfin_favorites_expanded(
|
||||
limit: int = Query(default=50, ge=1, le=100),
|
||||
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
||||
) -> JellyfinFavoritesExpanded:
|
||||
try:
|
||||
return await service.get_favorites_expanded(limit=limit)
|
||||
except ExternalServiceError as e:
|
||||
logger.error("Jellyfin service error getting expanded favorites: %s", e)
|
||||
raise HTTPException(status_code=502, detail="Failed to communicate with Jellyfin")
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("Unexpected error in expanded favorites")
|
||||
raise HTTPException(status_code=500, detail="Internal error fetching expanded favorites")
|
||||
|
||||
|
||||
@router.get("/filters", response_model=JellyfinFilterFacets)
|
||||
async def get_jellyfin_filter_facets(
|
||||
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
||||
) -> JellyfinFilterFacets:
|
||||
try:
|
||||
return await service.get_filter_facets()
|
||||
except ExternalServiceError as e:
|
||||
logger.error("Jellyfin service error getting filter facets: %s", e)
|
||||
raise HTTPException(status_code=502, detail="Failed to communicate with Jellyfin")
|
||||
|
||||
|
||||
@router.get("/genres", response_model=list[str])
|
||||
async def get_jellyfin_genres(
|
||||
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
||||
|
|
@ -127,8 +298,135 @@ async def get_jellyfin_genres(
|
|||
raise HTTPException(status_code=502, detail="Failed to communicate with Jellyfin")
|
||||
|
||||
|
||||
@router.get("/genres/songs", response_model=JellyfinTrackPage)
|
||||
async def get_jellyfin_genre_songs(
|
||||
genre: str = Query(..., min_length=1),
|
||||
limit: int = Query(default=50, ge=1, le=100),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
||||
) -> JellyfinTrackPage:
|
||||
tracks, total = await service.get_songs_by_genre(genre, limit=limit, offset=offset)
|
||||
return JellyfinTrackPage(items=tracks, total=total, offset=offset, limit=limit)
|
||||
|
||||
|
||||
@router.get("/instant-mix/artist/{artist_id}", response_model=list[JellyfinTrackInfo])
|
||||
async def get_jellyfin_instant_mix_by_artist(
|
||||
artist_id: str,
|
||||
limit: int = Query(default=50, ge=1, le=100),
|
||||
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
||||
) -> list[JellyfinTrackInfo]:
|
||||
return await service.get_instant_mix_by_artist(artist_id, limit=limit)
|
||||
|
||||
|
||||
@router.get("/instant-mix/genre", response_model=list[JellyfinTrackInfo])
|
||||
async def get_jellyfin_instant_mix_by_genre(
|
||||
genre: str = Query(..., min_length=1),
|
||||
limit: int = Query(default=50, ge=1, le=100),
|
||||
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
||||
) -> list[JellyfinTrackInfo]:
|
||||
return await service.get_instant_mix_by_genre(genre, limit=limit)
|
||||
|
||||
|
||||
@router.get("/instant-mix/{item_id}", response_model=list[JellyfinTrackInfo])
|
||||
async def get_jellyfin_instant_mix(
|
||||
item_id: str,
|
||||
limit: int = Query(default=50, ge=1, le=100),
|
||||
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
||||
) -> list[JellyfinTrackInfo]:
|
||||
return await service.get_instant_mix(item_id, limit=limit)
|
||||
|
||||
|
||||
@router.get("/stats", response_model=JellyfinLibraryStats)
|
||||
async def get_jellyfin_stats(
|
||||
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
||||
) -> JellyfinLibraryStats:
|
||||
return await service.get_stats()
|
||||
|
||||
|
||||
@router.get("/playlists", response_model=list[JellyfinPlaylistSummary])
|
||||
async def get_jellyfin_playlists(
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
||||
playlist_service: PlaylistService = Depends(get_playlist_service),
|
||||
) -> list[JellyfinPlaylistSummary]:
|
||||
try:
|
||||
playlists = await service.list_playlists(limit=limit)
|
||||
imported_ids = await playlist_service.get_imported_source_ids("jellyfin:")
|
||||
for p in playlists:
|
||||
if p.id in imported_ids:
|
||||
p.is_imported = True
|
||||
return playlists
|
||||
except ExternalServiceError as e:
|
||||
logger.error("Failed to get Jellyfin playlists: %s", e)
|
||||
raise HTTPException(status_code=502, detail="Failed to get Jellyfin playlists")
|
||||
|
||||
|
||||
@router.get("/playlists/{playlist_id}", response_model=JellyfinPlaylistDetail)
|
||||
async def get_jellyfin_playlist_detail(
|
||||
playlist_id: str,
|
||||
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
||||
) -> JellyfinPlaylistDetail:
|
||||
try:
|
||||
return await service.get_playlist_detail(playlist_id)
|
||||
except ResourceNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Jellyfin playlist not found")
|
||||
except ExternalServiceError as e:
|
||||
logger.error("Failed to get Jellyfin playlist %s: %s", playlist_id, e)
|
||||
raise HTTPException(status_code=502, detail="Failed to get Jellyfin playlist")
|
||||
|
||||
|
||||
@router.post("/playlists/{playlist_id}/import", response_model=JellyfinImportResult)
|
||||
async def import_jellyfin_playlist(
|
||||
playlist_id: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
||||
playlist_service: PlaylistService = Depends(get_playlist_service),
|
||||
local_service=Depends(get_local_files_service),
|
||||
nd_service=Depends(get_navidrome_library_service),
|
||||
plex_service=Depends(get_plex_library_service),
|
||||
) -> JellyfinImportResult:
|
||||
try:
|
||||
result = await service.import_playlist(playlist_id, playlist_service)
|
||||
except ResourceNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Jellyfin playlist not found")
|
||||
except ExternalServiceError as e:
|
||||
logger.error("Failed to import Jellyfin playlist %s: %s", playlist_id, e)
|
||||
raise HTTPException(status_code=502, detail="Failed to import Jellyfin playlist")
|
||||
|
||||
if not result.already_imported:
|
||||
background_tasks.add_task(
|
||||
playlist_service.resolve_track_sources,
|
||||
result.musicseerr_playlist_id,
|
||||
jf_service=service,
|
||||
local_service=local_service,
|
||||
nd_service=nd_service,
|
||||
plex_service=plex_service,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/sessions", response_model=JellyfinSessionsResponse)
|
||||
async def get_jellyfin_sessions(
|
||||
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
||||
) -> JellyfinSessionsResponse:
|
||||
return await service.get_sessions()
|
||||
|
||||
|
||||
@router.get("/similar/{item_id}", response_model=list[JellyfinAlbumSummary])
|
||||
async def get_jellyfin_similar_items(
|
||||
item_id: str,
|
||||
limit: int = Query(10, ge=1, le=50),
|
||||
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
||||
) -> list[JellyfinAlbumSummary]:
|
||||
return await service.get_similar_items(item_id, limit=limit)
|
||||
|
||||
|
||||
@router.get("/lyrics/{item_id}", response_model=JellyfinLyricsResponse)
|
||||
async def get_jellyfin_lyrics(
|
||||
item_id: str,
|
||||
service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
|
||||
) -> JellyfinLyricsResponse:
|
||||
lyrics = await service.get_lyrics(item_id)
|
||||
if lyrics is None:
|
||||
raise HTTPException(status_code=404, detail="Lyrics not available")
|
||||
return lyrics
|
||||
|
|
|
|||
|
|
@ -1,23 +1,45 @@
|
|||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Path, Query
|
||||
from fastapi.responses import Response
|
||||
|
||||
from api.v1.schemas.navidrome import (
|
||||
NavidromeAlbumDetail,
|
||||
NavidromeAlbumInfoSchema,
|
||||
NavidromeAlbumMatch,
|
||||
NavidromeAlbumPage,
|
||||
NavidromeAlbumSummary,
|
||||
NavidromeArtistIndexResponse,
|
||||
NavidromeArtistInfoSchema,
|
||||
NavidromeArtistPage,
|
||||
NavidromeArtistSummary,
|
||||
NavidromeGenreSongsResponse,
|
||||
NavidromeHubResponse,
|
||||
NavidromeImportResult,
|
||||
NavidromeLibraryStats,
|
||||
NavidromeLyricsResponse,
|
||||
NavidromeMusicFolder,
|
||||
NavidromeNowPlayingResponse,
|
||||
NavidromePlaylistDetail,
|
||||
NavidromePlaylistSummary,
|
||||
NavidromeSearchResponse,
|
||||
NavidromeTrackInfo,
|
||||
NavidromeTrackPage,
|
||||
)
|
||||
from core.dependencies import get_navidrome_library_service, get_navidrome_repository
|
||||
from core.exceptions import ExternalServiceError
|
||||
from core.dependencies import (
|
||||
get_jellyfin_library_service,
|
||||
get_local_files_service,
|
||||
get_navidrome_library_service,
|
||||
get_navidrome_repository,
|
||||
get_plex_library_service,
|
||||
get_playlist_service,
|
||||
)
|
||||
from core.exceptions import ExternalServiceError, ResourceNotFoundError
|
||||
from infrastructure.msgspec_fastapi import MsgSpecRoute
|
||||
from infrastructure.resilience.retry import CircuitOpenError
|
||||
from repositories.navidrome_repository import NavidromeRepository
|
||||
from services.navidrome_library_service import NavidromeLibraryService
|
||||
from services.playlist_service import PlaylistService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -27,27 +49,61 @@ router = APIRouter(route_class=MsgSpecRoute, prefix="/navidrome", tags=["navidro
|
|||
_SORT_MAP: dict[str, str] = {
|
||||
"name": "alphabeticalByName",
|
||||
"date_added": "newest",
|
||||
"year": "alphabeticalByName",
|
||||
"year": "byYear",
|
||||
}
|
||||
|
||||
_NEEDS_REVERSE: dict[tuple[str, str], bool] = {
|
||||
("name", "desc"): True,
|
||||
("date_added", ""): True,
|
||||
("date_added", "asc"): True,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/hub", response_model=NavidromeHubResponse)
|
||||
async def get_navidrome_hub(
|
||||
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
|
||||
playlist_service: PlaylistService = Depends(get_playlist_service),
|
||||
) -> NavidromeHubResponse:
|
||||
try:
|
||||
hub = await service.get_hub_data()
|
||||
imported_ids = await playlist_service.get_imported_source_ids("navidrome:")
|
||||
for p in hub.playlists:
|
||||
if p.id in imported_ids:
|
||||
p.is_imported = True
|
||||
return hub
|
||||
except ExternalServiceError as e:
|
||||
logger.error("Navidrome service error getting hub data: %s", e)
|
||||
raise HTTPException(status_code=502, detail="Failed to communicate with Navidrome")
|
||||
|
||||
|
||||
@router.get("/albums", response_model=NavidromeAlbumPage)
|
||||
async def get_navidrome_albums(
|
||||
limit: int = Query(default=48, ge=1, le=500, alias="limit"),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
sort_by: str = Query(default="name"),
|
||||
sort_order: str = Query(default=""),
|
||||
genre: str = Query(default=""),
|
||||
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
|
||||
) -> NavidromeAlbumPage:
|
||||
subsonic_type = "byGenre" if genre else _SORT_MAP.get(sort_by, "alphabeticalByName")
|
||||
year_kwargs: dict[str, int] = {}
|
||||
if subsonic_type == "byYear":
|
||||
if sort_order == "desc":
|
||||
year_kwargs = {"from_year": 9999, "to_year": 0}
|
||||
else:
|
||||
year_kwargs = {"from_year": 0, "to_year": 9999}
|
||||
try:
|
||||
items = await service.get_albums(
|
||||
type=subsonic_type, size=limit, offset=offset, genre=genre if genre else None,
|
||||
**year_kwargs,
|
||||
)
|
||||
except ExternalServiceError as e:
|
||||
logger.error("Navidrome service error getting albums: %s", e)
|
||||
raise HTTPException(status_code=502, detail="Failed to communicate with Navidrome")
|
||||
|
||||
if not genre and _NEEDS_REVERSE.get((sort_by, sort_order), False):
|
||||
items = list(reversed(items))
|
||||
|
||||
try:
|
||||
stats = await service.get_stats()
|
||||
total = stats.total_albums if len(items) >= limit else offset + len(items)
|
||||
|
|
@ -69,6 +125,21 @@ async def get_navidrome_album_detail(
|
|||
return result
|
||||
|
||||
|
||||
@router.get("/artists/browse", response_model=NavidromeArtistPage)
|
||||
async def browse_navidrome_artists(
|
||||
limit: int = Query(48, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
search: str = Query(""),
|
||||
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
|
||||
) -> NavidromeArtistPage:
|
||||
try:
|
||||
items, total = await service.browse_artists(size=limit, offset=offset, search=search)
|
||||
return NavidromeArtistPage(items=items, total=total, offset=offset, limit=limit)
|
||||
except ExternalServiceError as e:
|
||||
logger.error("Navidrome service error browsing artists: %s", e)
|
||||
raise HTTPException(status_code=502, detail="Failed to communicate with Navidrome")
|
||||
|
||||
|
||||
@router.get("/artists", response_model=list[NavidromeArtistSummary])
|
||||
async def get_navidrome_artists(
|
||||
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
|
||||
|
|
@ -76,6 +147,17 @@ async def get_navidrome_artists(
|
|||
return await service.get_artists()
|
||||
|
||||
|
||||
@router.get("/artists/index", response_model=NavidromeArtistIndexResponse)
|
||||
async def get_navidrome_artists_index(
|
||||
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
|
||||
) -> NavidromeArtistIndexResponse:
|
||||
try:
|
||||
return await service.get_artists_index()
|
||||
except ExternalServiceError as e:
|
||||
logger.error("Navidrome service error getting artist index: %s", e)
|
||||
raise HTTPException(status_code=502, detail="Failed to communicate with Navidrome")
|
||||
|
||||
|
||||
@router.get("/artists/{artist_id}")
|
||||
async def get_navidrome_artist_detail(
|
||||
artist_id: str,
|
||||
|
|
@ -87,6 +169,21 @@ async def get_navidrome_artist_detail(
|
|||
return result
|
||||
|
||||
|
||||
@router.get("/tracks", response_model=NavidromeTrackPage)
|
||||
async def browse_navidrome_tracks(
|
||||
limit: int = Query(48, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
search: str = Query(""),
|
||||
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
|
||||
) -> NavidromeTrackPage:
|
||||
try:
|
||||
items, total = await service.browse_tracks(size=limit, offset=offset, search=search)
|
||||
return NavidromeTrackPage(items=items, total=total, offset=offset, limit=limit)
|
||||
except ExternalServiceError as e:
|
||||
logger.error("Navidrome service error browsing tracks: %s", e)
|
||||
raise HTTPException(status_code=502, detail="Failed to communicate with Navidrome")
|
||||
|
||||
|
||||
@router.get("/search", response_model=NavidromeSearchResponse)
|
||||
async def search_navidrome(
|
||||
q: str = Query(..., min_length=1),
|
||||
|
|
@ -111,6 +208,13 @@ async def get_navidrome_favorites(
|
|||
return result.albums
|
||||
|
||||
|
||||
@router.get("/favorites/expanded", response_model=NavidromeSearchResponse)
|
||||
async def get_navidrome_favorites_expanded(
|
||||
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
|
||||
) -> NavidromeSearchResponse:
|
||||
return await service.get_favorites()
|
||||
|
||||
|
||||
@router.get("/genres", response_model=list[str])
|
||||
async def get_navidrome_genres(
|
||||
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
|
||||
|
|
@ -122,6 +226,55 @@ async def get_navidrome_genres(
|
|||
raise HTTPException(status_code=502, detail="Failed to communicate with Navidrome")
|
||||
|
||||
|
||||
@router.get("/genres/songs", response_model=NavidromeGenreSongsResponse)
|
||||
async def get_navidrome_multi_genre_songs(
|
||||
genres: str = Query(..., min_length=1),
|
||||
count: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
|
||||
) -> NavidromeGenreSongsResponse:
|
||||
genre_list = [g.strip() for g in genres.split(",") if g.strip()]
|
||||
if not genre_list:
|
||||
return NavidromeGenreSongsResponse(songs=[], genre="")
|
||||
if len(genre_list) == 1:
|
||||
return await service.get_songs_by_genre(genre=genre_list[0], count=count, offset=offset)
|
||||
return await service.get_songs_by_genres(genres=genre_list, count=count, offset=offset)
|
||||
|
||||
|
||||
@router.get("/genres/{genre}/songs", response_model=NavidromeGenreSongsResponse)
|
||||
async def get_navidrome_genre_songs(
|
||||
genre: str = Path(...),
|
||||
count: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
|
||||
) -> NavidromeGenreSongsResponse:
|
||||
try:
|
||||
return await service.get_songs_by_genre(genre=genre, count=count, offset=offset)
|
||||
except ExternalServiceError as e:
|
||||
logger.error("Navidrome service error getting songs by genre: %s", e)
|
||||
raise HTTPException(status_code=502, detail="Failed to communicate with Navidrome")
|
||||
|
||||
|
||||
@router.get("/music-folders", response_model=list[NavidromeMusicFolder])
|
||||
async def get_navidrome_music_folders(
|
||||
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
|
||||
) -> list[NavidromeMusicFolder]:
|
||||
try:
|
||||
return await service.get_music_folders()
|
||||
except ExternalServiceError as e:
|
||||
logger.error("Navidrome service error getting music folders: %s", e)
|
||||
raise HTTPException(status_code=502, detail="Failed to communicate with Navidrome")
|
||||
|
||||
|
||||
@router.get("/random", response_model=list[NavidromeTrackInfo])
|
||||
async def get_navidrome_random(
|
||||
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
|
||||
size: int = Query(default=20, ge=1, le=50),
|
||||
genre: str | None = Query(default=None),
|
||||
) -> list[NavidromeTrackInfo]:
|
||||
return await service.get_random_songs(size=size, genre=genre)
|
||||
|
||||
|
||||
@router.get("/stats", response_model=NavidromeLibraryStats)
|
||||
async def get_navidrome_stats(
|
||||
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
|
||||
|
|
@ -161,3 +314,125 @@ async def match_navidrome_album(
|
|||
except ExternalServiceError as e:
|
||||
logger.error("Failed to match Navidrome album %s: %s", album_id, e)
|
||||
raise HTTPException(status_code=502, detail="Failed to match Navidrome album")
|
||||
|
||||
|
||||
@router.get("/playlists", response_model=list[NavidromePlaylistSummary])
|
||||
async def get_navidrome_playlists(
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
|
||||
playlist_service: PlaylistService = Depends(get_playlist_service),
|
||||
) -> list[NavidromePlaylistSummary]:
|
||||
try:
|
||||
playlists = await service.list_playlists(limit=limit)
|
||||
imported_ids = await playlist_service.get_imported_source_ids("navidrome:")
|
||||
for p in playlists:
|
||||
if p.id in imported_ids:
|
||||
p.is_imported = True
|
||||
return playlists
|
||||
except ExternalServiceError as e:
|
||||
logger.error("Failed to get Navidrome playlists: %s", e)
|
||||
raise HTTPException(status_code=502, detail="Failed to get Navidrome playlists")
|
||||
|
||||
|
||||
@router.get("/playlists/{playlist_id}", response_model=NavidromePlaylistDetail)
|
||||
async def get_navidrome_playlist_detail(
|
||||
playlist_id: str,
|
||||
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
|
||||
) -> NavidromePlaylistDetail:
|
||||
try:
|
||||
return await service.get_playlist_detail(playlist_id)
|
||||
except ResourceNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Navidrome playlist not found")
|
||||
except ExternalServiceError as e:
|
||||
logger.error("Failed to get Navidrome playlist %s: %s", playlist_id, e)
|
||||
raise HTTPException(status_code=502, detail="Failed to get Navidrome playlist")
|
||||
|
||||
|
||||
@router.post("/playlists/{playlist_id}/import", response_model=NavidromeImportResult)
|
||||
async def import_navidrome_playlist(
|
||||
playlist_id: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
|
||||
playlist_service: PlaylistService = Depends(get_playlist_service),
|
||||
jf_service=Depends(get_jellyfin_library_service),
|
||||
local_service=Depends(get_local_files_service),
|
||||
plex_service=Depends(get_plex_library_service),
|
||||
) -> NavidromeImportResult:
|
||||
try:
|
||||
result = await service.import_playlist(playlist_id, playlist_service)
|
||||
except ResourceNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Navidrome playlist not found")
|
||||
except ExternalServiceError as e:
|
||||
logger.error("Failed to import Navidrome playlist %s: %s", playlist_id, e)
|
||||
raise HTTPException(status_code=502, detail="Failed to import Navidrome playlist")
|
||||
|
||||
if not result.already_imported:
|
||||
background_tasks.add_task(
|
||||
playlist_service.resolve_track_sources,
|
||||
result.musicseerr_playlist_id,
|
||||
jf_service=jf_service,
|
||||
local_service=local_service,
|
||||
nd_service=service,
|
||||
plex_service=plex_service,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/now-playing", response_model=NavidromeNowPlayingResponse)
|
||||
async def get_navidrome_now_playing(
|
||||
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
|
||||
) -> NavidromeNowPlayingResponse:
|
||||
return await service.get_now_playing()
|
||||
|
||||
|
||||
@router.get("/top-songs/{artist_name}", response_model=list[NavidromeTrackInfo])
|
||||
async def get_navidrome_top_songs(
|
||||
artist_name: str = Path(..., min_length=1, max_length=256),
|
||||
count: int = Query(20, ge=1, le=50),
|
||||
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
|
||||
) -> list[NavidromeTrackInfo]:
|
||||
return await service.get_top_songs(artist_name, count=count)
|
||||
|
||||
|
||||
@router.get("/similar-songs/{song_id}", response_model=list[NavidromeTrackInfo])
|
||||
async def get_navidrome_similar_songs(
|
||||
song_id: str,
|
||||
count: int = Query(20, ge=1, le=50),
|
||||
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
|
||||
) -> list[NavidromeTrackInfo]:
|
||||
return await service.get_similar_songs(song_id, count=count)
|
||||
|
||||
|
||||
@router.get("/artist-info/{artist_id}", response_model=NavidromeArtistInfoSchema)
|
||||
async def get_navidrome_artist_info(
|
||||
artist_id: str,
|
||||
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
|
||||
) -> NavidromeArtistInfoSchema:
|
||||
info = await service.get_artist_info(artist_id)
|
||||
if info is None:
|
||||
return NavidromeArtistInfoSchema(navidrome_id=artist_id)
|
||||
return info
|
||||
|
||||
|
||||
@router.get("/album-info/{album_id}", response_model=NavidromeAlbumInfoSchema)
|
||||
async def get_navidrome_album_info(
|
||||
album_id: str,
|
||||
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
|
||||
) -> NavidromeAlbumInfoSchema:
|
||||
info = await service.get_album_info(album_id)
|
||||
if info is None:
|
||||
return NavidromeAlbumInfoSchema(album_id=album_id)
|
||||
return info
|
||||
|
||||
|
||||
@router.get("/lyrics/{song_id}", response_model=NavidromeLyricsResponse)
|
||||
async def get_navidrome_lyrics(
|
||||
song_id: str,
|
||||
artist: str = Query("", description="Artist name for fallback lookup"),
|
||||
title: str = Query("", description="Track title for fallback lookup"),
|
||||
service: NavidromeLibraryService = Depends(get_navidrome_library_service),
|
||||
) -> NavidromeLyricsResponse:
|
||||
lyrics = await service.get_lyrics(song_id, artist=artist, title=title)
|
||||
if lyrics is None:
|
||||
raise HTTPException(status_code=404, detail="Lyrics not available")
|
||||
return lyrics
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ from api.v1.schemas.playlists import (
|
|||
UpdatePlaylistRequest,
|
||||
UpdateTrackRequest,
|
||||
)
|
||||
from core.dependencies import JellyfinLibraryServiceDep, LocalFilesServiceDep, NavidromeLibraryServiceDep, PlaylistServiceDep
|
||||
from core.dependencies import JellyfinLibraryServiceDep, LocalFilesServiceDep, NavidromeLibraryServiceDep, PlexLibraryServiceDep, PlaylistServiceDep
|
||||
from core.exceptions import PlaylistNotFoundError
|
||||
from infrastructure.msgspec_fastapi import MsgSpecBody, MsgSpecRoute
|
||||
|
||||
|
|
@ -74,6 +74,7 @@ def _track_to_response(t) -> PlaylistTrackResponse:
|
|||
disc_number=t.disc_number,
|
||||
duration=t.duration,
|
||||
created_at=t.created_at,
|
||||
plex_rating_key=getattr(t, "plex_rating_key", None),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -91,6 +92,7 @@ async def list_playlists(
|
|||
total_duration=s.total_duration,
|
||||
cover_urls=[_normalize_cover_url(u) for u in s.cover_urls] if s.cover_urls else [],
|
||||
custom_cover_url=_custom_cover_url(s.id, s.cover_image_path),
|
||||
source_ref=s.source_ref,
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
)
|
||||
|
|
@ -119,6 +121,7 @@ async def create_playlist(
|
|||
id=playlist.id,
|
||||
name=playlist.name,
|
||||
custom_cover_url=_custom_cover_url(playlist.id, playlist.cover_image_path),
|
||||
source_ref=playlist.source_ref,
|
||||
tracks=[],
|
||||
track_count=0,
|
||||
total_duration=None,
|
||||
|
|
@ -141,6 +144,7 @@ async def get_playlist(
|
|||
name=playlist.name,
|
||||
cover_urls=cover_urls,
|
||||
custom_cover_url=_custom_cover_url(playlist.id, playlist.cover_image_path),
|
||||
source_ref=playlist.source_ref,
|
||||
tracks=track_responses,
|
||||
track_count=len(tracks),
|
||||
total_duration=total_duration or None,
|
||||
|
|
@ -164,6 +168,7 @@ async def update_playlist(
|
|||
name=playlist.name,
|
||||
cover_urls=cover_urls,
|
||||
custom_cover_url=_custom_cover_url(playlist.id, playlist.cover_image_path),
|
||||
source_ref=playlist.source_ref,
|
||||
tracks=track_responses,
|
||||
track_count=len(tracks),
|
||||
total_duration=total_duration or None,
|
||||
|
|
@ -206,6 +211,7 @@ async def add_tracks(
|
|||
"track_number": t.track_number,
|
||||
"disc_number": t.disc_number,
|
||||
"duration": int(t.duration) if t.duration is not None else None,
|
||||
"plex_rating_key": t.plex_rating_key,
|
||||
}
|
||||
for t in body.tracks
|
||||
]
|
||||
|
|
@ -269,6 +275,7 @@ async def update_track(
|
|||
jf_service: JellyfinLibraryServiceDep,
|
||||
local_service: LocalFilesServiceDep,
|
||||
nd_service: NavidromeLibraryServiceDep,
|
||||
plex_service: PlexLibraryServiceDep,
|
||||
body: UpdateTrackRequest = MsgSpecBody(UpdateTrackRequest),
|
||||
) -> PlaylistTrackResponse:
|
||||
result = await service.update_track_source(
|
||||
|
|
@ -278,6 +285,7 @@ async def update_track(
|
|||
jf_service=jf_service,
|
||||
local_service=local_service,
|
||||
nd_service=nd_service,
|
||||
plex_service=plex_service,
|
||||
)
|
||||
return _track_to_response(result)
|
||||
|
||||
|
|
@ -292,9 +300,11 @@ async def resolve_sources(
|
|||
jf_service: JellyfinLibraryServiceDep,
|
||||
local_service: LocalFilesServiceDep,
|
||||
nd_service: NavidromeLibraryServiceDep,
|
||||
plex_service: PlexLibraryServiceDep,
|
||||
) -> ResolveSourcesResponse:
|
||||
sources = await service.resolve_track_sources(
|
||||
playlist_id, jf_service=jf_service, local_service=local_service, nd_service=nd_service,
|
||||
playlist_id, jf_service=jf_service, local_service=local_service,
|
||||
nd_service=nd_service, plex_service=plex_service,
|
||||
)
|
||||
return ResolveSourcesResponse(sources=sources)
|
||||
|
||||
|
|
@ -305,7 +315,7 @@ async def upload_cover(
|
|||
service: PlaylistServiceDep,
|
||||
cover_image: UploadFile = File(...),
|
||||
) -> CoverUploadResponse:
|
||||
max_size = 2 * 1024 * 1024 # 2 MB
|
||||
max_size = 2 * 1024 * 1024
|
||||
chunk_size = 8192
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
|
|
|
|||
62
backend/api/v1/routes/plex_auth.py
Normal file
62
backend/api/v1/routes/plex_auth.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from api.v1.schemas.settings import PlexOAuthPinResponse, PlexOAuthPollResponse
|
||||
from core.dependencies import get_plex_repository, get_preferences_service
|
||||
from core.exceptions import PlexApiError
|
||||
from infrastructure.msgspec_fastapi import MsgSpecRoute
|
||||
from repositories.plex_repository import PlexRepository
|
||||
from services.preferences_service import PreferencesService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(route_class=MsgSpecRoute, prefix="/plex", tags=["plex-auth"])
|
||||
|
||||
|
||||
def _get_or_create_client_id(preferences: PreferencesService) -> str:
|
||||
return preferences.get_or_create_setting("plex_client_id", lambda: str(uuid.uuid4()))
|
||||
|
||||
|
||||
@router.post("/auth/pin", response_model=PlexOAuthPinResponse)
|
||||
async def create_plex_pin(
|
||||
preferences: PreferencesService = Depends(get_preferences_service),
|
||||
repo: PlexRepository = Depends(get_plex_repository),
|
||||
):
|
||||
try:
|
||||
client_id = _get_or_create_client_id(preferences)
|
||||
pin = await repo.create_oauth_pin(client_id)
|
||||
auth_url = (
|
||||
f"https://app.plex.tv/auth#?clientID={client_id}"
|
||||
f"&code={pin.code}"
|
||||
f"&context%5Bdevice%5D%5Bproduct%5D=MusicSeerr"
|
||||
)
|
||||
return PlexOAuthPinResponse(
|
||||
pin_id=pin.id,
|
||||
pin_code=pin.code,
|
||||
auth_url=auth_url,
|
||||
)
|
||||
except PlexApiError as e:
|
||||
logger.error("Failed to create Plex OAuth pin: %s", e)
|
||||
raise HTTPException(status_code=502, detail="Could not start Plex authentication")
|
||||
except Exception as e:
|
||||
logger.exception("Unexpected error creating Plex pin: %s", e)
|
||||
raise HTTPException(status_code=500, detail="Internal error during Plex authentication")
|
||||
|
||||
|
||||
@router.get("/auth/poll", response_model=PlexOAuthPollResponse)
|
||||
async def poll_plex_pin(
|
||||
pin_id: int,
|
||||
preferences: PreferencesService = Depends(get_preferences_service),
|
||||
repo: PlexRepository = Depends(get_plex_repository),
|
||||
):
|
||||
try:
|
||||
client_id = _get_or_create_client_id(preferences)
|
||||
token = await repo.poll_oauth_pin(pin_id, client_id)
|
||||
if token:
|
||||
return PlexOAuthPollResponse(completed=True, auth_token=token)
|
||||
return PlexOAuthPollResponse(completed=False)
|
||||
except Exception as e:
|
||||
logger.exception("Error polling Plex pin %d: %s", pin_id, e)
|
||||
raise HTTPException(status_code=502, detail="Error polling Plex authentication status")
|
||||
386
backend/api/v1/routes/plex_library.py
Normal file
386
backend/api/v1/routes/plex_library.py
Normal 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()
|
||||
|
|
@ -22,7 +22,10 @@ from api.v1.schemas.settings import (
|
|||
LastFmVerifyResponse,
|
||||
ScrobbleSettings,
|
||||
PrimaryMusicSourceSettings,
|
||||
PlexConnectionSettings,
|
||||
PlexVerifyResponse,
|
||||
)
|
||||
from api.v1.schemas.plex import PlexLibrarySectionInfo
|
||||
from api.v1.schemas.common import VerifyConnectionResponse
|
||||
from api.v1.schemas.advanced_settings import AdvancedSettingsFrontend, FrontendCacheTTLs, _is_masked_api_key
|
||||
from core.dependencies import (
|
||||
|
|
@ -99,6 +102,7 @@ async def get_frontend_cache_ttls(
|
|||
search=backend_settings.frontend_ttl_search,
|
||||
local_files_sidebar=backend_settings.frontend_ttl_local_files_sidebar,
|
||||
jellyfin_sidebar=backend_settings.frontend_ttl_jellyfin_sidebar,
|
||||
plex_sidebar=backend_settings.frontend_ttl_plex_sidebar,
|
||||
playlist_sources=backend_settings.frontend_ttl_playlist_sources,
|
||||
discover_queue_polling_interval=backend_settings.discover_queue_polling_interval,
|
||||
discover_queue_auto_generate=backend_settings.discover_queue_auto_generate,
|
||||
|
|
@ -287,6 +291,53 @@ async def verify_navidrome_connection(
|
|||
return VerifyConnectionResponse(valid=result.valid, message=result.message)
|
||||
|
||||
|
||||
@router.get("/plex", response_model=PlexConnectionSettings)
|
||||
async def get_plex_settings(
|
||||
preferences_service: PreferencesService = Depends(get_preferences_service),
|
||||
):
|
||||
return preferences_service.get_plex_connection()
|
||||
|
||||
|
||||
@router.put("/plex", response_model=PlexConnectionSettings)
|
||||
async def update_plex_settings(
|
||||
settings: PlexConnectionSettings = MsgSpecBody(PlexConnectionSettings),
|
||||
preferences_service: PreferencesService = Depends(get_preferences_service),
|
||||
settings_service: SettingsService = Depends(get_settings_service),
|
||||
):
|
||||
try:
|
||||
preferences_service.save_plex_connection(settings)
|
||||
await settings_service.on_plex_settings_changed(enabled=settings.enabled)
|
||||
logger.info("Updated Plex connection settings")
|
||||
return preferences_service.get_plex_connection()
|
||||
except ConfigurationError as e:
|
||||
logger.warning("Configuration error updating Plex settings: %s", e)
|
||||
raise HTTPException(status_code=400, detail="Plex settings are incomplete or invalid")
|
||||
|
||||
|
||||
@router.post("/plex/verify", response_model=PlexVerifyResponse)
|
||||
async def verify_plex_connection(
|
||||
settings: PlexConnectionSettings = MsgSpecBody(PlexConnectionSettings),
|
||||
settings_service: SettingsService = Depends(get_settings_service),
|
||||
):
|
||||
result = await settings_service.verify_plex(settings)
|
||||
libs = [PlexLibrarySectionInfo(key=k, title=t) for k, t in result.libraries]
|
||||
return PlexVerifyResponse(valid=result.valid, message=result.message, libraries=libs)
|
||||
|
||||
|
||||
@router.get("/plex/libraries", response_model=list[PlexLibrarySectionInfo])
|
||||
async def get_plex_libraries(
|
||||
settings_service: SettingsService = Depends(get_settings_service),
|
||||
):
|
||||
try:
|
||||
libs = await settings_service.get_plex_libraries()
|
||||
return [PlexLibrarySectionInfo(key=k, title=t) for k, t in libs]
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.exception("Failed to fetch Plex libraries: %s", e)
|
||||
raise HTTPException(status_code=502, detail="Could not fetch libraries from Plex")
|
||||
|
||||
|
||||
@router.get("/listenbrainz", response_model=ListenBrainzConnectionSettings)
|
||||
async def get_listenbrainz_settings(
|
||||
preferences_service: PreferencesService = Depends(get_preferences_service),
|
||||
|
|
|
|||
|
|
@ -13,12 +13,14 @@ from core.dependencies import (
|
|||
get_jellyfin_playback_service,
|
||||
get_local_files_service,
|
||||
get_navidrome_playback_service,
|
||||
get_plex_playback_service,
|
||||
)
|
||||
from core.exceptions import ExternalServiceError, PlaybackNotAllowedError, ResourceNotFoundError
|
||||
from infrastructure.msgspec_fastapi import MsgSpecBody, MsgSpecRoute
|
||||
from services.jellyfin_playback_service import JellyfinPlaybackService
|
||||
from services.local_files_service import LocalFilesService
|
||||
from services.navidrome_playback_service import NavidromePlaybackService
|
||||
from services.plex_playback_service import PlexPlaybackService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -138,7 +140,7 @@ async def head_local_file(
|
|||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Track file not found on disk")
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=403, detail="Access denied — path outside music directory")
|
||||
raise HTTPException(status_code=403, detail="Access denied: path is outside the music directory")
|
||||
except ExternalServiceError as e:
|
||||
logger.error("Local head error for track %s: %s", track_id, e)
|
||||
raise HTTPException(status_code=502, detail="Failed to check local file")
|
||||
|
|
@ -170,7 +172,7 @@ async def stream_local_file(
|
|||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Track file not found on disk")
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=403, detail="Access denied — path outside music directory")
|
||||
raise HTTPException(status_code=403, detail="Access denied: path is outside the music directory")
|
||||
except ExternalServiceError as e:
|
||||
detail = str(e)
|
||||
if "Range not satisfiable" in detail:
|
||||
|
|
@ -228,3 +230,69 @@ async def navidrome_now_playing(
|
|||
) -> dict[str, str]:
|
||||
ok = await playback_service.report_now_playing(item_id)
|
||||
return {"status": "ok" if ok else "error"}
|
||||
|
||||
|
||||
@router.post("/navidrome/{item_id}/stopped")
|
||||
async def navidrome_stopped(
|
||||
item_id: str,
|
||||
playback_service: NavidromePlaybackService = Depends(get_navidrome_playback_service),
|
||||
) -> dict[str, str]:
|
||||
await playback_service.clear_now_playing(item_id)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.head("/plex/{part_key:path}")
|
||||
async def head_plex_audio(
|
||||
part_key: str,
|
||||
playback_service: PlexPlaybackService = Depends(get_plex_playback_service),
|
||||
) -> Response:
|
||||
try:
|
||||
return await playback_service.proxy_head(part_key)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid stream request")
|
||||
except ExternalServiceError:
|
||||
raise HTTPException(status_code=502, detail="Failed to stream from Plex")
|
||||
|
||||
|
||||
@router.get("/plex/{part_key:path}")
|
||||
async def stream_plex_audio(
|
||||
part_key: str,
|
||||
request: Request,
|
||||
playback_service: PlexPlaybackService = Depends(get_plex_playback_service),
|
||||
) -> StreamingResponse:
|
||||
try:
|
||||
return await playback_service.proxy_stream(part_key, request.headers.get("Range"))
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid stream request")
|
||||
except ExternalServiceError as e:
|
||||
detail = str(e)
|
||||
if "416" in detail or "Range not satisfiable" in detail:
|
||||
raise HTTPException(status_code=416, detail="Range not satisfiable")
|
||||
raise HTTPException(status_code=502, detail="Failed to stream from Plex")
|
||||
|
||||
|
||||
@router.post("/plex/{rating_key}/scrobble")
|
||||
async def scrobble_plex(
|
||||
rating_key: str,
|
||||
playback_service: PlexPlaybackService = Depends(get_plex_playback_service),
|
||||
) -> dict[str, str]:
|
||||
ok = await playback_service.scrobble(rating_key)
|
||||
return {"status": "ok" if ok else "error"}
|
||||
|
||||
|
||||
@router.post("/plex/{rating_key}/now-playing")
|
||||
async def plex_now_playing(
|
||||
rating_key: str,
|
||||
playback_service: PlexPlaybackService = Depends(get_plex_playback_service),
|
||||
) -> dict[str, str]:
|
||||
ok = await playback_service.report_now_playing(rating_key)
|
||||
return {"status": "ok" if ok else "error"}
|
||||
|
||||
|
||||
@router.post("/plex/{rating_key}/stopped")
|
||||
async def plex_stopped(
|
||||
rating_key: str,
|
||||
playback_service: PlexPlaybackService = Depends(get_plex_playback_service),
|
||||
) -> dict[str, str]:
|
||||
ok = await playback_service.report_stopped(rating_key)
|
||||
return {"status": "ok" if ok else "error"}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ def _coerce_positive_int(value: object, field_name: str) -> int:
|
|||
|
||||
def _mask_api_key(key: str) -> str:
|
||||
if len(key) > 3:
|
||||
return f"***…{key[-3:]}"
|
||||
return f"***...{key[-3:]}"
|
||||
return "***"
|
||||
|
||||
|
||||
|
|
@ -53,6 +53,10 @@ class AdvancedSettings(AppStruct):
|
|||
cache_ttl_navidrome_search: int = 120
|
||||
cache_ttl_navidrome_genres: int = 3600
|
||||
cache_ttl_navidrome_stats: int = 600
|
||||
cache_ttl_plex_albums: int = 300
|
||||
cache_ttl_plex_search: int = 120
|
||||
cache_ttl_plex_genres: int = 3600
|
||||
cache_ttl_plex_stats: int = 600
|
||||
http_timeout: int = 10
|
||||
http_connect_timeout: int = 5
|
||||
http_max_connections: int = 200
|
||||
|
|
@ -92,6 +96,7 @@ class AdvancedSettings(AppStruct):
|
|||
frontend_ttl_search: int = 300000
|
||||
frontend_ttl_local_files_sidebar: int = 120000
|
||||
frontend_ttl_jellyfin_sidebar: int = 120000
|
||||
frontend_ttl_plex_sidebar: int = 120000
|
||||
frontend_ttl_playlist_sources: int = 900000
|
||||
audiodb_enabled: bool = True
|
||||
audiodb_name_search_fallback: bool = False
|
||||
|
|
@ -136,6 +141,10 @@ class AdvancedSettings(AppStruct):
|
|||
"cache_ttl_navidrome_search": (60, 3600),
|
||||
"cache_ttl_navidrome_genres": (60, 86400),
|
||||
"cache_ttl_navidrome_stats": (60, 3600),
|
||||
"cache_ttl_plex_albums": (60, 3600),
|
||||
"cache_ttl_plex_search": (60, 3600),
|
||||
"cache_ttl_plex_genres": (60, 86400),
|
||||
"cache_ttl_plex_stats": (60, 3600),
|
||||
"http_timeout": (5, 60),
|
||||
"http_connect_timeout": (1, 30),
|
||||
"http_max_connections": (50, 500),
|
||||
|
|
@ -178,6 +187,7 @@ class AdvancedSettings(AppStruct):
|
|||
"frontend_ttl_search": (60000, 3600000),
|
||||
"frontend_ttl_local_files_sidebar": (60000, 3600000),
|
||||
"frontend_ttl_jellyfin_sidebar": (60000, 3600000),
|
||||
"frontend_ttl_plex_sidebar": (60000, 3600000),
|
||||
"frontend_ttl_playlist_sources": (60000, 3600000),
|
||||
"cache_ttl_audiodb_found": (3600, 2592000),
|
||||
"cache_ttl_audiodb_not_found": (3600, 604800),
|
||||
|
|
@ -202,6 +212,7 @@ class FrontendCacheTTLs(AppStruct):
|
|||
search: int = 300000
|
||||
local_files_sidebar: int = 120000
|
||||
jellyfin_sidebar: int = 120000
|
||||
plex_sidebar: int = 120000
|
||||
playlist_sources: int = 900000
|
||||
discover_queue_polling_interval: int = 4000
|
||||
discover_queue_auto_generate: bool = True
|
||||
|
|
@ -228,6 +239,10 @@ class AdvancedSettingsFrontend(AppStruct):
|
|||
cache_ttl_navidrome_search: int = 2
|
||||
cache_ttl_navidrome_genres: int = 60
|
||||
cache_ttl_navidrome_stats: int = 10
|
||||
cache_ttl_plex_albums: int = 5
|
||||
cache_ttl_plex_search: int = 2
|
||||
cache_ttl_plex_genres: int = 60
|
||||
cache_ttl_plex_stats: int = 10
|
||||
http_timeout: int = 10
|
||||
http_connect_timeout: int = 5
|
||||
http_max_connections: int = 200
|
||||
|
|
@ -266,6 +281,7 @@ class AdvancedSettingsFrontend(AppStruct):
|
|||
frontend_ttl_search: int = 5
|
||||
frontend_ttl_local_files_sidebar: int = 2
|
||||
frontend_ttl_jellyfin_sidebar: int = 2
|
||||
frontend_ttl_plex_sidebar: int = 2
|
||||
frontend_ttl_playlist_sources: int = 15
|
||||
audiodb_enabled: bool = True
|
||||
audiodb_name_search_fallback: bool = False
|
||||
|
|
@ -309,6 +325,10 @@ class AdvancedSettingsFrontend(AppStruct):
|
|||
"cache_ttl_navidrome_search",
|
||||
"cache_ttl_navidrome_genres",
|
||||
"cache_ttl_navidrome_stats",
|
||||
"cache_ttl_plex_albums",
|
||||
"cache_ttl_plex_search",
|
||||
"cache_ttl_plex_genres",
|
||||
"cache_ttl_plex_stats",
|
||||
"cache_ttl_audiodb_found",
|
||||
"cache_ttl_audiodb_not_found",
|
||||
"cache_ttl_audiodb_library",
|
||||
|
|
@ -343,6 +363,10 @@ class AdvancedSettingsFrontend(AppStruct):
|
|||
"cache_ttl_navidrome_search": (1, 60),
|
||||
"cache_ttl_navidrome_genres": (1, 1440),
|
||||
"cache_ttl_navidrome_stats": (1, 60),
|
||||
"cache_ttl_plex_albums": (1, 60),
|
||||
"cache_ttl_plex_search": (1, 60),
|
||||
"cache_ttl_plex_genres": (1, 1440),
|
||||
"cache_ttl_plex_stats": (1, 60),
|
||||
"http_timeout": (5, 60),
|
||||
"http_connect_timeout": (1, 30),
|
||||
"http_max_connections": (50, 500),
|
||||
|
|
@ -379,6 +403,7 @@ class AdvancedSettingsFrontend(AppStruct):
|
|||
"frontend_ttl_search": (1, 60),
|
||||
"frontend_ttl_local_files_sidebar": (1, 60),
|
||||
"frontend_ttl_jellyfin_sidebar": (1, 60),
|
||||
"frontend_ttl_plex_sidebar": (1, 60),
|
||||
"frontend_ttl_playlist_sources": (1, 60),
|
||||
"cache_ttl_audiodb_found": (1, 720),
|
||||
"cache_ttl_audiodb_not_found": (1, 168),
|
||||
|
|
@ -422,6 +447,10 @@ class AdvancedSettingsFrontend(AppStruct):
|
|||
cache_ttl_navidrome_search=settings.cache_ttl_navidrome_search // 60,
|
||||
cache_ttl_navidrome_genres=settings.cache_ttl_navidrome_genres // 60,
|
||||
cache_ttl_navidrome_stats=settings.cache_ttl_navidrome_stats // 60,
|
||||
cache_ttl_plex_albums=settings.cache_ttl_plex_albums // 60,
|
||||
cache_ttl_plex_search=settings.cache_ttl_plex_search // 60,
|
||||
cache_ttl_plex_genres=settings.cache_ttl_plex_genres // 60,
|
||||
cache_ttl_plex_stats=settings.cache_ttl_plex_stats // 60,
|
||||
http_timeout=settings.http_timeout,
|
||||
http_connect_timeout=settings.http_connect_timeout,
|
||||
http_max_connections=settings.http_max_connections,
|
||||
|
|
@ -460,6 +489,7 @@ class AdvancedSettingsFrontend(AppStruct):
|
|||
frontend_ttl_search=settings.frontend_ttl_search // 60000,
|
||||
frontend_ttl_local_files_sidebar=settings.frontend_ttl_local_files_sidebar // 60000,
|
||||
frontend_ttl_jellyfin_sidebar=settings.frontend_ttl_jellyfin_sidebar // 60000,
|
||||
frontend_ttl_plex_sidebar=settings.frontend_ttl_plex_sidebar // 60000,
|
||||
frontend_ttl_playlist_sources=settings.frontend_ttl_playlist_sources // 60000,
|
||||
audiodb_enabled=settings.audiodb_enabled,
|
||||
audiodb_name_search_fallback=settings.audiodb_name_search_fallback,
|
||||
|
|
@ -504,6 +534,10 @@ class AdvancedSettingsFrontend(AppStruct):
|
|||
cache_ttl_navidrome_search=self.cache_ttl_navidrome_search * 60,
|
||||
cache_ttl_navidrome_genres=self.cache_ttl_navidrome_genres * 60,
|
||||
cache_ttl_navidrome_stats=self.cache_ttl_navidrome_stats * 60,
|
||||
cache_ttl_plex_albums=self.cache_ttl_plex_albums * 60,
|
||||
cache_ttl_plex_search=self.cache_ttl_plex_search * 60,
|
||||
cache_ttl_plex_genres=self.cache_ttl_plex_genres * 60,
|
||||
cache_ttl_plex_stats=self.cache_ttl_plex_stats * 60,
|
||||
http_timeout=self.http_timeout,
|
||||
http_connect_timeout=self.http_connect_timeout,
|
||||
http_max_connections=self.http_max_connections,
|
||||
|
|
@ -542,6 +576,7 @@ class AdvancedSettingsFrontend(AppStruct):
|
|||
frontend_ttl_search=self.frontend_ttl_search * 60000,
|
||||
frontend_ttl_local_files_sidebar=self.frontend_ttl_local_files_sidebar * 60000,
|
||||
frontend_ttl_jellyfin_sidebar=self.frontend_ttl_jellyfin_sidebar * 60000,
|
||||
frontend_ttl_plex_sidebar=self.frontend_ttl_plex_sidebar * 60000,
|
||||
frontend_ttl_playlist_sources=self.frontend_ttl_playlist_sources * 60000,
|
||||
audiodb_enabled=self.audiodb_enabled,
|
||||
audiodb_name_search_fallback=self.audiodb_name_search_fallback,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ class IntegrationStatus(AppStruct):
|
|||
lastfm: bool
|
||||
navidrome: bool = False
|
||||
youtube_api: bool = False
|
||||
plex: bool = False
|
||||
|
||||
|
||||
class StatusReport(AppStruct):
|
||||
|
|
|
|||
|
|
@ -9,8 +9,10 @@ class JellyfinTrackInfo(AppStruct):
|
|||
disc_number: int = 1
|
||||
album_name: str = ""
|
||||
artist_name: str = ""
|
||||
album_id: str = ""
|
||||
codec: str | None = None
|
||||
bitrate: int | None = None
|
||||
image_url: str | None = None
|
||||
|
||||
|
||||
class JellyfinAlbumSummary(AppStruct):
|
||||
|
|
@ -22,6 +24,7 @@ class JellyfinAlbumSummary(AppStruct):
|
|||
image_url: str | None = None
|
||||
musicbrainz_id: str | None = None
|
||||
artist_musicbrainz_id: str | None = None
|
||||
play_count: int = 0
|
||||
|
||||
|
||||
class JellyfinAlbumDetail(AppStruct):
|
||||
|
|
@ -48,6 +51,7 @@ class JellyfinArtistSummary(AppStruct):
|
|||
image_url: str | None = None
|
||||
album_count: int = 0
|
||||
musicbrainz_id: str | None = None
|
||||
play_count: int = 0
|
||||
|
||||
|
||||
class JellyfinLibraryStats(AppStruct):
|
||||
|
|
@ -62,8 +66,127 @@ class JellyfinSearchResponse(AppStruct):
|
|||
tracks: list[JellyfinTrackInfo] = []
|
||||
|
||||
|
||||
class JellyfinPlaylistSummary(AppStruct):
|
||||
id: str
|
||||
name: str
|
||||
track_count: int = 0
|
||||
duration_seconds: int = 0
|
||||
cover_url: str = ""
|
||||
created_at: str = ""
|
||||
is_imported: bool = False
|
||||
|
||||
|
||||
class JellyfinHubResponse(AppStruct):
|
||||
stats: JellyfinLibraryStats | None = None
|
||||
recently_played: list[JellyfinAlbumSummary] = []
|
||||
recently_added: list[JellyfinAlbumSummary] = []
|
||||
favorites: list[JellyfinAlbumSummary] = []
|
||||
most_played_artists: list[JellyfinArtistSummary] = []
|
||||
most_played_albums: list[JellyfinAlbumSummary] = []
|
||||
all_albums_preview: list[JellyfinAlbumSummary] = []
|
||||
genres: list[str] = []
|
||||
playlists: list[JellyfinPlaylistSummary] = []
|
||||
|
||||
|
||||
class JellyfinPaginatedResponse(AppStruct):
|
||||
items: list[JellyfinAlbumSummary] = []
|
||||
total: int = 0
|
||||
offset: int = 0
|
||||
limit: int = 50
|
||||
|
||||
|
||||
class JellyfinArtistPage(AppStruct):
|
||||
items: list[JellyfinArtistSummary] = []
|
||||
total: int = 0
|
||||
offset: int = 0
|
||||
limit: int = 50
|
||||
|
||||
|
||||
class JellyfinArtistIndexEntry(AppStruct):
|
||||
name: str = ""
|
||||
artists: list[JellyfinArtistSummary] = []
|
||||
|
||||
|
||||
class JellyfinArtistIndexResponse(AppStruct):
|
||||
index: list[JellyfinArtistIndexEntry] = []
|
||||
|
||||
|
||||
class JellyfinTrackPage(AppStruct):
|
||||
items: list[JellyfinTrackInfo] = []
|
||||
total: int = 0
|
||||
offset: int = 0
|
||||
limit: int = 50
|
||||
|
||||
|
||||
class JellyfinPlaylistTrack(AppStruct):
|
||||
id: str
|
||||
track_name: str
|
||||
artist_name: str = ""
|
||||
album_name: str = ""
|
||||
album_id: str = ""
|
||||
artist_id: str = ""
|
||||
duration_seconds: int = 0
|
||||
track_number: int = 0
|
||||
disc_number: int = 1
|
||||
cover_url: str = ""
|
||||
|
||||
|
||||
class JellyfinPlaylistDetail(AppStruct):
|
||||
id: str
|
||||
name: str
|
||||
track_count: int = 0
|
||||
duration_seconds: int = 0
|
||||
cover_url: str = ""
|
||||
created_at: str = ""
|
||||
tracks: list[JellyfinPlaylistTrack] = []
|
||||
|
||||
|
||||
class JellyfinImportResult(AppStruct):
|
||||
musicseerr_playlist_id: str = ""
|
||||
tracks_imported: int = 0
|
||||
tracks_failed: int = 0
|
||||
already_imported: bool = False
|
||||
|
||||
|
||||
class JellyfinSessionInfo(AppStruct):
|
||||
session_id: str = ""
|
||||
user_name: str = ""
|
||||
device_name: str = ""
|
||||
client_name: str = ""
|
||||
track_name: str = ""
|
||||
artist_name: str = ""
|
||||
album_name: str = ""
|
||||
album_id: str = ""
|
||||
cover_url: str = ""
|
||||
position_seconds: float = 0.0
|
||||
duration_seconds: float = 0.0
|
||||
is_paused: bool = False
|
||||
play_method: str = ""
|
||||
audio_codec: str = ""
|
||||
bitrate: int = 0
|
||||
|
||||
|
||||
class JellyfinSessionsResponse(AppStruct):
|
||||
sessions: list[JellyfinSessionInfo] = []
|
||||
|
||||
|
||||
class JellyfinLyricsLineSchema(AppStruct):
|
||||
text: str = ""
|
||||
start_seconds: float | None = None
|
||||
|
||||
|
||||
class JellyfinLyricsResponse(AppStruct):
|
||||
lines: list[JellyfinLyricsLineSchema] = []
|
||||
is_synced: bool = False
|
||||
lyrics_text: str = ""
|
||||
|
||||
|
||||
class JellyfinFavoritesExpanded(AppStruct):
|
||||
albums: list[JellyfinAlbumSummary] = []
|
||||
artists: list[JellyfinArtistSummary] = []
|
||||
|
||||
|
||||
class JellyfinFilterFacets(AppStruct):
|
||||
years: list[int] = []
|
||||
tags: list[str] = []
|
||||
studios: list[str] = []
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ class NavidromeTrackInfo(AppStruct):
|
|||
artist_name: str = ""
|
||||
codec: str | None = None
|
||||
bitrate: int | None = None
|
||||
image_url: str | None = None
|
||||
|
||||
|
||||
class NavidromeAlbumSummary(AppStruct):
|
||||
|
|
@ -64,6 +65,135 @@ class NavidromeSearchResponse(AppStruct):
|
|||
tracks: list[NavidromeTrackInfo] = []
|
||||
|
||||
|
||||
class NavidromePlaylistSummary(AppStruct):
|
||||
id: str
|
||||
name: str
|
||||
track_count: int = 0
|
||||
duration_seconds: int = 0
|
||||
cover_url: str = ""
|
||||
owner: str = ""
|
||||
is_public: bool = False
|
||||
updated_at: str = ""
|
||||
is_imported: bool = False
|
||||
|
||||
|
||||
class NavidromeHubResponse(AppStruct):
|
||||
stats: NavidromeLibraryStats | None = None
|
||||
recently_played: list[NavidromeAlbumSummary] = []
|
||||
favorites: list[NavidromeAlbumSummary] = []
|
||||
favorite_artists: list[NavidromeArtistSummary] = []
|
||||
favorite_tracks: list[NavidromeTrackInfo] = []
|
||||
all_albums_preview: list[NavidromeAlbumSummary] = []
|
||||
genres: list[str] = []
|
||||
playlists: list[NavidromePlaylistSummary] = []
|
||||
|
||||
|
||||
class NavidromeAlbumPage(AppStruct):
|
||||
items: list[NavidromeAlbumSummary] = []
|
||||
total: int = 0
|
||||
|
||||
|
||||
class NavidromeArtistPage(AppStruct):
|
||||
items: list[NavidromeArtistSummary] = []
|
||||
total: int = 0
|
||||
offset: int = 0
|
||||
limit: int = 50
|
||||
|
||||
|
||||
class NavidromeTrackPage(AppStruct):
|
||||
items: list[NavidromeTrackInfo] = []
|
||||
total: int = 0
|
||||
offset: int = 0
|
||||
limit: int = 50
|
||||
|
||||
|
||||
class NavidromePlaylistTrack(AppStruct):
|
||||
id: str
|
||||
track_name: str
|
||||
artist_name: str = ""
|
||||
album_name: str = ""
|
||||
album_id: str = ""
|
||||
artist_id: str = ""
|
||||
duration_seconds: int = 0
|
||||
track_number: int = 0
|
||||
disc_number: int = 1
|
||||
cover_url: str = ""
|
||||
|
||||
|
||||
class NavidromePlaylistDetail(AppStruct):
|
||||
id: str
|
||||
name: str
|
||||
track_count: int = 0
|
||||
duration_seconds: int = 0
|
||||
cover_url: str = ""
|
||||
tracks: list[NavidromePlaylistTrack] = []
|
||||
|
||||
|
||||
class NavidromeImportResult(AppStruct):
|
||||
musicseerr_playlist_id: str = ""
|
||||
tracks_imported: int = 0
|
||||
tracks_failed: int = 0
|
||||
already_imported: bool = False
|
||||
|
||||
|
||||
class NavidromeNowPlayingEntrySchema(AppStruct):
|
||||
user_name: str = ""
|
||||
minutes_ago: int = 0
|
||||
player_name: str = ""
|
||||
track_name: str = ""
|
||||
artist_name: str = ""
|
||||
album_name: str = ""
|
||||
album_id: str = ""
|
||||
cover_art_id: str = ""
|
||||
duration_seconds: int = 0
|
||||
estimated_position_seconds: float = 0.0
|
||||
|
||||
|
||||
class NavidromeNowPlayingResponse(AppStruct):
|
||||
entries: list[NavidromeNowPlayingEntrySchema] = []
|
||||
|
||||
|
||||
class NavidromeArtistInfoSchema(AppStruct):
|
||||
navidrome_id: str = ""
|
||||
name: str = ""
|
||||
biography: str = ""
|
||||
image_url: str = ""
|
||||
similar_artists: list[NavidromeArtistSummary] = []
|
||||
|
||||
|
||||
class NavidromeAlbumInfoSchema(AppStruct):
|
||||
album_id: str = ""
|
||||
notes: str = ""
|
||||
musicbrainz_id: str = ""
|
||||
lastfm_url: str = ""
|
||||
image_url: str = ""
|
||||
|
||||
|
||||
class NavidromeLyricLine(AppStruct):
|
||||
text: str = ""
|
||||
start_seconds: float | None = None
|
||||
|
||||
|
||||
class NavidromeLyricsResponse(AppStruct):
|
||||
text: str = ""
|
||||
is_synced: bool = False
|
||||
lines: list[NavidromeLyricLine] = []
|
||||
|
||||
|
||||
class NavidromeArtistIndexEntry(AppStruct):
|
||||
name: str = ""
|
||||
artists: list[NavidromeArtistSummary] = []
|
||||
|
||||
|
||||
class NavidromeArtistIndexResponse(AppStruct):
|
||||
index: list[NavidromeArtistIndexEntry] = []
|
||||
|
||||
|
||||
class NavidromeGenreSongsResponse(AppStruct):
|
||||
songs: list[NavidromeTrackInfo] = []
|
||||
genre: str = ""
|
||||
|
||||
|
||||
class NavidromeMusicFolder(AppStruct):
|
||||
id: str = ""
|
||||
name: str = ""
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ class PlaylistTrackResponse(AppStruct):
|
|||
disc_number: int | None = None
|
||||
duration: int | None = None
|
||||
created_at: str = ""
|
||||
plex_rating_key: str | None = None
|
||||
|
||||
|
||||
class PlaylistSummaryResponse(AppStruct):
|
||||
|
|
@ -28,16 +29,18 @@ class PlaylistSummaryResponse(AppStruct):
|
|||
total_duration: int | None = None
|
||||
cover_urls: list[str] = msgspec.field(default_factory=list)
|
||||
custom_cover_url: str | None = None
|
||||
source_ref: str | None = None
|
||||
created_at: str = ""
|
||||
updated_at: str = ""
|
||||
|
||||
|
||||
class PlaylistDetailResponse(AppStruct):
|
||||
# Frontend PlaylistDetail extends PlaylistSummary — keep fields in sync with PlaylistSummaryResponse
|
||||
# Keep these fields in sync with PlaylistSummaryResponse because the frontend extends PlaylistSummary.
|
||||
id: str
|
||||
name: str
|
||||
cover_urls: list[str] = msgspec.field(default_factory=list)
|
||||
custom_cover_url: str | None = None
|
||||
source_ref: str | None = None
|
||||
tracks: list[PlaylistTrackResponse] = msgspec.field(default_factory=list)
|
||||
track_count: int = 0
|
||||
total_duration: int | None = None
|
||||
|
|
@ -71,6 +74,7 @@ class TrackDataRequest(AppStruct):
|
|||
track_number: int | None = None
|
||||
disc_number: int | None = None
|
||||
duration: float | int | None = None
|
||||
plex_rating_key: str | None = None
|
||||
|
||||
|
||||
class AddTracksRequest(AppStruct):
|
||||
|
|
|
|||
231
backend/api/v1/schemas/plex.py
Normal file
231
backend/api/v1/schemas/plex.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
26
backend/infrastructure/cache/cache_keys.py
vendored
26
backend/infrastructure/cache/cache_keys.py
vendored
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
313
backend/repositories/plex_models.py
Normal file
313
backend/repositories/plex_models.py
Normal 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
|
||||
864
backend/repositories/plex_repository.py
Normal file
864
backend/repositories/plex_repository.py
Normal 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)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
...
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
...
|
||||
|
|
|
|||
157
backend/repositories/protocols/plex.py
Normal file
157
backend/repositories/protocols/plex.py
Normal 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]:
|
||||
...
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
1021
backend/services/plex_library_service.py
Normal file
1021
backend/services/plex_library_service.py
Normal file
File diff suppressed because it is too large
Load diff
71
backend/services/plex_playback_service.py
Normal file
71
backend/services/plex_playback_service.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
836
backend/tests/repositories/test_plex_repository.py
Normal file
836
backend/tests/repositories/test_plex_repository.py
Normal 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"
|
||||
153
backend/tests/routes/test_discovery_routes.py
Normal file
153
backend/tests/routes/test_discovery_routes.py
Normal 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)
|
||||
155
backend/tests/routes/test_now_playing_routes.py
Normal file
155
backend/tests/routes/test_now_playing_routes.py
Normal 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"] == []
|
||||
99
backend/tests/routes/test_plex_auth.py
Normal file
99
backend/tests/routes/test_plex_auth.py
Normal 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
|
||||
280
backend/tests/routes/test_plex_routes.py
Normal file
280
backend/tests/routes/test_plex_routes.py
Normal 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
|
||||
143
backend/tests/routes/test_plex_settings.py
Normal file
143
backend/tests/routes/test_plex_settings.py
Normal 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
|
||||
230
backend/tests/services/test_content_enrichment.py
Normal file
230
backend/tests/services/test_content_enrichment.py
Normal 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
|
||||
269
backend/tests/services/test_deep_discovery.py
Normal file
269
backend/tests/services/test_deep_discovery.py
Normal 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
|
||||
263
backend/tests/services/test_discovery.py
Normal file
263
backend/tests/services/test_discovery.py
Normal 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
|
||||
218
backend/tests/services/test_jellyfin_library_service.py
Normal file
218
backend/tests/services/test_jellyfin_library_service.py
Normal 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
|
||||
|
|
@ -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 == []
|
||||
|
|
|
|||
321
backend/tests/services/test_now_playing.py
Normal file
321
backend/tests/services/test_now_playing.py
Normal 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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
61
backend/tests/services/test_plex_integration_status.py
Normal file
61
backend/tests/services/test_plex_integration_status.py
Normal 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
|
||||
411
backend/tests/services/test_plex_library_service.py
Normal file
411
backend/tests/services/test_plex_library_service.py
Normal 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"
|
||||
128
backend/tests/services/test_plex_playback_service.py
Normal file
128
backend/tests/services/test_plex_playback_service.py
Normal 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
|
||||
171
backend/tests/services/test_plex_settings_lifecycle.py
Normal file
171
backend/tests/services/test_plex_settings_lifecycle.py
Normal 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 == []
|
||||
330
backend/tests/services/test_source_playlist_import.py
Normal file
330
backend/tests/services/test_source_playlist_import.py
Normal 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")
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
266
backend/tests/test_peer_review_fixes.py
Normal file
266
backend/tests/test_peer_review_fixes.py
Normal 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
|
||||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
48
frontend/src/lib/actions/reveal.ts
Normal file
48
frontend/src/lib/actions/reveal.ts
Normal 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 = '';
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -277,7 +277,8 @@ describe('playlists API client', () => {
|
|||
format: 'aac',
|
||||
track_number: 3,
|
||||
disc_number: null,
|
||||
duration: 240
|
||||
duration: 240,
|
||||
plex_rating_key: null
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
93
frontend/src/lib/components/AlbumGrid.svelte
Normal file
93
frontend/src/lib/components/AlbumGrid.svelte
Normal 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>
|
||||
103
frontend/src/lib/components/ArtistIndexSidebar.svelte
Normal file
103
frontend/src/lib/components/ArtistIndexSidebar.svelte
Normal 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>
|
||||
61
frontend/src/lib/components/AudioQualityBadge.svelte
Normal file
61
frontend/src/lib/components/AudioQualityBadge.svelte
Normal 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}
|
||||
182
frontend/src/lib/components/BrowseHeroCards.svelte
Normal file
182
frontend/src/lib/components/BrowseHeroCards.svelte
Normal 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>
|
||||
59
frontend/src/lib/components/DiscoveryShelf.svelte
Normal file
59
frontend/src/lib/components/DiscoveryShelf.svelte
Normal 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>
|
||||
79
frontend/src/lib/components/DiscoveryTrackTable.svelte
Normal file
79
frontend/src/lib/components/DiscoveryTrackTable.svelte
Normal 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>
|
||||
32
frontend/src/lib/components/DiscoveryZone.svelte
Normal file
32
frontend/src/lib/components/DiscoveryZone.svelte
Normal 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>
|
||||
142
frontend/src/lib/components/FeaturedAlbumHero.svelte
Normal file
142
frontend/src/lib/components/FeaturedAlbumHero.svelte
Normal 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}
|
||||
79
frontend/src/lib/components/GenrePillFilter.svelte
Normal file
79
frontend/src/lib/components/GenrePillFilter.svelte
Normal 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>
|
||||
147
frontend/src/lib/components/GenreSongsBrowser.svelte
Normal file
147
frontend/src/lib/components/GenreSongsBrowser.svelte
Normal 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>
|
||||
96
frontend/src/lib/components/HomeSectionNowPlaying.svelte
Normal file
96
frontend/src/lib/components/HomeSectionNowPlaying.svelte
Normal 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}
|
||||
22
frontend/src/lib/components/HubPageSkeleton.svelte
Normal file
22
frontend/src/lib/components/HubPageSkeleton.svelte
Normal 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>
|
||||
50
frontend/src/lib/components/HubShelf.svelte
Normal file
50
frontend/src/lib/components/HubShelf.svelte
Normal 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>
|
||||
18
frontend/src/lib/components/InstantMixButton.svelte
Normal file
18
frontend/src/lib/components/InstantMixButton.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
162
frontend/src/lib/components/LyricsPanel.svelte
Normal file
162
frontend/src/lib/components/LyricsPanel.svelte
Normal 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}
|
||||
115
frontend/src/lib/components/MetadataPanel.svelte
Normal file
115
frontend/src/lib/components/MetadataPanel.svelte
Normal 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}
|
||||
109
frontend/src/lib/components/MostPlayedSection.svelte
Normal file
109
frontend/src/lib/components/MostPlayedSection.svelte
Normal 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}
|
||||
107
frontend/src/lib/components/NowPlayingWidget.svelte
Normal file
107
frontend/src/lib/components/NowPlayingWidget.svelte
Normal 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}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(' - ');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
167
frontend/src/lib/components/PlaylistImportBanner.svelte
Normal file
167
frontend/src/lib/components/PlaylistImportBanner.svelte
Normal 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
Loading…
Reference in a new issue