From 0f25ebc26d80eb734099f3662c6a0c225c9c19d8 Mon Sep 17 00:00:00 2001
From: Harvey <64276030+HabiRabbu@users.noreply.github.com>
Date: Mon, 13 Apr 2026 23:39:01 +0100
Subject: [PATCH] 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
---
Makefile | 79 +-
README.md | 19 +-
backend/api/v1/routes/jellyfin_library.py | 306 ++++-
backend/api/v1/routes/navidrome_library.py | 283 ++++-
backend/api/v1/routes/playlists.py | 16 +-
backend/api/v1/routes/plex_auth.py | 62 +
backend/api/v1/routes/plex_library.py | 386 +++++++
backend/api/v1/routes/settings.py | 51 +
backend/api/v1/routes/stream.py | 72 +-
backend/api/v1/schemas/advanced_settings.py | 37 +-
backend/api/v1/schemas/common.py | 1 +
backend/api/v1/schemas/jellyfin.py | 123 ++
backend/api/v1/schemas/navidrome.py | 130 +++
backend/api/v1/schemas/playlists.py | 6 +-
backend/api/v1/schemas/plex.py | 231 ++++
backend/api/v1/schemas/settings.py | 30 +
backend/core/dependencies/__init__.py | 6 +
backend/core/dependencies/repo_providers.py | 29 +-
.../core/dependencies/service_providers.py | 28 +-
backend/core/dependencies/type_aliases.py | 9 +
backend/core/exceptions.py | 15 +
backend/core/tasks.py | 20 +-
backend/infrastructure/cache/cache_keys.py | 26 +-
.../infrastructure/persistence/mbid_store.py | 77 +-
backend/main.py | 15 +
.../repositories/async_playlist_repository.py | 13 +-
backend/repositories/jellyfin_models.py | 81 ++
backend/repositories/jellyfin_repository.py | 404 ++++++-
backend/repositories/navidrome_models.py | 149 +++
backend/repositories/navidrome_repository.py | 234 +++-
backend/repositories/playlist_repository.py | 78 +-
backend/repositories/plex_models.py | 313 +++++
backend/repositories/plex_repository.py | 864 ++++++++++++++
backend/repositories/protocols/__init__.py | 2 +
backend/repositories/protocols/jellyfin.py | 49 +-
backend/repositories/protocols/navidrome.py | 42 +
backend/repositories/protocols/plex.py | 157 +++
backend/services/home/facade.py | 3 +-
backend/services/home/integration_helpers.py | 9 +
backend/services/jellyfin_library_service.py | 442 ++++++-
backend/services/jellyfin_playback_service.py | 19 +-
backend/services/navidrome_library_service.py | 459 +++++++-
.../services/navidrome_playback_service.py | 35 +-
backend/services/playlist_service.py | 96 +-
backend/services/plex_library_service.py | 1021 +++++++++++++++++
backend/services/plex_playback_service.py | 71 ++
backend/services/preferences_service.py | 65 ++
backend/services/settings_service.py | 125 +-
.../repositories/test_plex_repository.py | 836 ++++++++++++++
backend/tests/routes/test_discovery_routes.py | 153 +++
.../tests/routes/test_now_playing_routes.py | 155 +++
backend/tests/routes/test_plex_auth.py | 99 ++
backend/tests/routes/test_plex_routes.py | 280 +++++
backend/tests/routes/test_plex_settings.py | 143 +++
.../tests/services/test_content_enrichment.py | 230 ++++
backend/tests/services/test_deep_discovery.py | 269 +++++
backend/tests/services/test_discovery.py | 263 +++++
.../services/test_jellyfin_library_service.py | 218 ++++
.../test_navidrome_library_service.py | 66 +-
backend/tests/services/test_now_playing.py | 321 ++++++
.../tests/services/test_playlist_service.py | 4 +-
.../test_playlist_source_resolution.py | 2 +-
.../services/test_plex_integration_status.py | 61 +
.../services/test_plex_library_service.py | 411 +++++++
.../services/test_plex_playback_service.py | 128 +++
.../services/test_plex_settings_lifecycle.py | 171 +++
.../services/test_source_playlist_import.py | 330 ++++++
backend/tests/test_audiodb_settings.py | 14 +-
backend/tests/test_dependencies_package.py | 2 +-
backend/tests/test_peer_review_fixes.py | 266 +++++
config/config.example.json | 5 +
frontend/src/app.css | 73 ++
frontend/src/lib/actions/reveal.ts | 48 +
frontend/src/lib/api/playlists.spec.ts | 3 +-
frontend/src/lib/api/playlists.ts | 6 +-
.../lib/components/AddToPlaylistModal.svelte | 33 +-
frontend/src/lib/components/AlbumGrid.svelte | 93 ++
.../lib/components/ArtistIndexSidebar.svelte | 103 ++
.../lib/components/AudioQualityBadge.svelte | 61 +
.../src/lib/components/BrowseHeroCards.svelte | 182 +++
.../src/lib/components/DiscoveryShelf.svelte | 59 +
.../lib/components/DiscoveryTrackTable.svelte | 79 ++
.../src/lib/components/DiscoveryZone.svelte | 32 +
.../lib/components/FeaturedAlbumHero.svelte | 142 +++
.../src/lib/components/GenrePillFilter.svelte | 79 ++
.../lib/components/GenreSongsBrowser.svelte | 147 +++
.../components/HomeSectionNowPlaying.svelte | 96 ++
.../src/lib/components/HubPageSkeleton.svelte | 22 +
frontend/src/lib/components/HubShelf.svelte | 50 +
.../lib/components/InstantMixButton.svelte | 18 +
.../lib/components/LibraryFilterBar.svelte | 100 +-
.../src/lib/components/LibraryPage.svelte | 58 +-
.../src/lib/components/LyricsPanel.svelte | 162 +++
.../src/lib/components/MetadataPanel.svelte | 115 ++
.../lib/components/MostPlayedSection.svelte | 109 ++
.../lib/components/NowPlayingWidget.svelte | 107 ++
frontend/src/lib/components/Player.svelte | 111 +-
.../src/lib/components/PlaylistCard.svelte | 65 +-
.../components/PlaylistCard.svelte.spec.ts | 3 +-
.../components/PlaylistImportBanner.svelte | 167 +++
frontend/src/lib/components/PlexIcon.svelte | 14 +
.../src/lib/components/QueueDrawer.svelte | 5 +
.../src/lib/components/QuickStatsBar.svelte | 45 +
.../lib/components/SidebarVisualiser.svelte | 32 +
.../components/SourceAlbumCardCompact.svelte | 32 +
.../lib/components/SourceAlbumModal.svelte | 193 +++-
.../lib/components/SourceArtistCard.svelte | 74 ++
.../src/lib/components/SourceHubHeader.svelte | 47 +
.../components/SourcePickerDropdown.svelte | 3 +
.../lib/components/SourcePlaylistCard.svelte | 66 ++
.../components/SourcePlaylistDetail.svelte | 150 +++
.../lib/components/SourceTrackTable.svelte | 74 ++
.../settings/SettingsBackendCache.svelte | 36 +-
.../settings/SettingsFrontendCache.svelte | 12 +-
.../components/settings/SettingsPlex.svelte | 326 ++++++
.../settings/advanced-settings-types.ts | 5 +
frontend/src/lib/constants.ts | 154 ++-
frontend/src/lib/player/NativeAudioSource.ts | 2 +-
frontend/src/lib/player/createSource.ts | 3 +
.../src/lib/player/launchPlexPlayback.spec.ts | 245 ++++
frontend/src/lib/player/launchPlexPlayback.ts | 46 +
.../src/lib/player/navidromePlaybackApi.ts | 9 +
.../src/lib/player/plexPlaybackApi.spec.ts | 187 +++
frontend/src/lib/player/plexPlaybackApi.ts | 52 +
frontend/src/lib/player/queueHelpers.spec.ts | 3 +-
frontend/src/lib/player/queueHelpers.ts | 103 +-
frontend/src/lib/player/types.ts | 8 +-
frontend/src/lib/stores/cacheTtl.ts | 14 +-
frontend/src/lib/stores/integration.ts | 3 +
.../src/lib/stores/nowPlayingMerged.svelte.ts | 133 +++
.../lib/stores/nowPlayingSessions.svelte.ts | 281 +++++
frontend/src/lib/stores/player.svelte.ts | 101 +-
.../src/lib/stores/playerJellyfinReporting.ts | 16 +-
.../src/lib/stores/playerPlaybackMethods.ts | 6 +-
.../src/lib/stores/playerSourceResolver.ts | 6 +
frontend/src/lib/types.ts | 450 ++++++++
frontend/src/lib/utils/albumCardPlayback.ts | 57 +-
frontend/src/lib/utils/albumDetailCache.ts | 4 +-
frontend/src/lib/utils/formatting.ts | 5 +-
.../src/lib/utils/libraryController.svelte.ts | 70 +-
.../lib/utils/libraryTrackLoader.svelte.ts | 194 ++++
frontend/src/lib/utils/plexLibraryCache.ts | 36 +
frontend/src/lib/utils/sources.ts | 4 +-
frontend/src/routes/+layout.svelte | 80 +-
frontend/src/routes/+page.svelte | 30 +-
frontend/src/routes/album/[id]/+page.svelte | 8 +
.../routes/album/[id]/AlbumSourceBars.svelte | 34 +-
.../routes/album/[id]/AlbumTrackList.svelte | 45 +-
.../src/routes/album/[id]/albumFetchers.ts | 12 +
.../album/[id]/albumPageState.svelte.ts | 92 +-
.../album/[id]/albumPlaybackHandlers.ts | 57 +-
frontend/src/routes/library/+page.svelte | 33 +-
.../src/routes/library/jellyfin/+page.svelte | 672 ++++++++---
.../library/jellyfin/albums/+page.svelte | 210 ++++
.../library/jellyfin/artists/+page.svelte | 180 +++
.../library/jellyfin/playlists/+page.svelte | 52 +
.../jellyfin/playlists/[id]/+page.svelte | 19 +
.../library/jellyfin/tracks/+page.svelte | 372 ++++++
.../src/routes/library/local/+page.svelte | 7 +-
.../src/routes/library/navidrome/+page.svelte | 807 ++++++++++---
.../library/navidrome/albums/+page.svelte | 205 ++++
.../library/navidrome/artists/+page.svelte | 144 +++
.../library/navidrome/playlists/+page.svelte | 52 +
.../navidrome/playlists/[id]/+page.svelte | 19 +
.../library/navidrome/tracks/+page.svelte | 334 ++++++
frontend/src/routes/library/plex/+page.svelte | 483 ++++++++
.../routes/library/plex/activity/+page.svelte | 209 ++++
.../routes/library/plex/albums/+page.svelte | 208 ++++
.../routes/library/plex/artists/+page.svelte | 173 +++
.../library/plex/playlists/+page.svelte | 52 +
.../library/plex/playlists/[id]/+page.svelte | 19 +
.../routes/library/plex/tracks/+page.svelte | 396 +++++++
.../playlists/[id]/PlaylistHeader.svelte | 47 +-
.../playlists/[id]/PlaylistTrackList.svelte | 3 +-
.../routes/playlists/[id]/page.svelte.spec.ts | 9 +-
.../src/routes/playlists/page.svelte.spec.ts | 1 +
frontend/src/routes/settings/+page.svelte | 17 +-
177 files changed, 21156 insertions(+), 769 deletions(-)
create mode 100644 backend/api/v1/routes/plex_auth.py
create mode 100644 backend/api/v1/routes/plex_library.py
create mode 100644 backend/api/v1/schemas/plex.py
create mode 100644 backend/repositories/plex_models.py
create mode 100644 backend/repositories/plex_repository.py
create mode 100644 backend/repositories/protocols/plex.py
create mode 100644 backend/services/plex_library_service.py
create mode 100644 backend/services/plex_playback_service.py
create mode 100644 backend/tests/repositories/test_plex_repository.py
create mode 100644 backend/tests/routes/test_discovery_routes.py
create mode 100644 backend/tests/routes/test_now_playing_routes.py
create mode 100644 backend/tests/routes/test_plex_auth.py
create mode 100644 backend/tests/routes/test_plex_routes.py
create mode 100644 backend/tests/routes/test_plex_settings.py
create mode 100644 backend/tests/services/test_content_enrichment.py
create mode 100644 backend/tests/services/test_deep_discovery.py
create mode 100644 backend/tests/services/test_discovery.py
create mode 100644 backend/tests/services/test_jellyfin_library_service.py
create mode 100644 backend/tests/services/test_now_playing.py
create mode 100644 backend/tests/services/test_plex_integration_status.py
create mode 100644 backend/tests/services/test_plex_library_service.py
create mode 100644 backend/tests/services/test_plex_playback_service.py
create mode 100644 backend/tests/services/test_plex_settings_lifecycle.py
create mode 100644 backend/tests/services/test_source_playlist_import.py
create mode 100644 backend/tests/test_peer_review_fixes.py
create mode 100644 frontend/src/lib/actions/reveal.ts
create mode 100644 frontend/src/lib/components/AlbumGrid.svelte
create mode 100644 frontend/src/lib/components/ArtistIndexSidebar.svelte
create mode 100644 frontend/src/lib/components/AudioQualityBadge.svelte
create mode 100644 frontend/src/lib/components/BrowseHeroCards.svelte
create mode 100644 frontend/src/lib/components/DiscoveryShelf.svelte
create mode 100644 frontend/src/lib/components/DiscoveryTrackTable.svelte
create mode 100644 frontend/src/lib/components/DiscoveryZone.svelte
create mode 100644 frontend/src/lib/components/FeaturedAlbumHero.svelte
create mode 100644 frontend/src/lib/components/GenrePillFilter.svelte
create mode 100644 frontend/src/lib/components/GenreSongsBrowser.svelte
create mode 100644 frontend/src/lib/components/HomeSectionNowPlaying.svelte
create mode 100644 frontend/src/lib/components/HubPageSkeleton.svelte
create mode 100644 frontend/src/lib/components/HubShelf.svelte
create mode 100644 frontend/src/lib/components/InstantMixButton.svelte
create mode 100644 frontend/src/lib/components/LyricsPanel.svelte
create mode 100644 frontend/src/lib/components/MetadataPanel.svelte
create mode 100644 frontend/src/lib/components/MostPlayedSection.svelte
create mode 100644 frontend/src/lib/components/NowPlayingWidget.svelte
create mode 100644 frontend/src/lib/components/PlaylistImportBanner.svelte
create mode 100644 frontend/src/lib/components/PlexIcon.svelte
create mode 100644 frontend/src/lib/components/QuickStatsBar.svelte
create mode 100644 frontend/src/lib/components/SidebarVisualiser.svelte
create mode 100644 frontend/src/lib/components/SourceAlbumCardCompact.svelte
create mode 100644 frontend/src/lib/components/SourceArtistCard.svelte
create mode 100644 frontend/src/lib/components/SourceHubHeader.svelte
create mode 100644 frontend/src/lib/components/SourcePlaylistCard.svelte
create mode 100644 frontend/src/lib/components/SourcePlaylistDetail.svelte
create mode 100644 frontend/src/lib/components/SourceTrackTable.svelte
create mode 100644 frontend/src/lib/components/settings/SettingsPlex.svelte
create mode 100644 frontend/src/lib/player/launchPlexPlayback.spec.ts
create mode 100644 frontend/src/lib/player/launchPlexPlayback.ts
create mode 100644 frontend/src/lib/player/plexPlaybackApi.spec.ts
create mode 100644 frontend/src/lib/player/plexPlaybackApi.ts
create mode 100644 frontend/src/lib/stores/nowPlayingMerged.svelte.ts
create mode 100644 frontend/src/lib/stores/nowPlayingSessions.svelte.ts
create mode 100644 frontend/src/lib/utils/libraryTrackLoader.svelte.ts
create mode 100644 frontend/src/lib/utils/plexLibraryCache.ts
create mode 100644 frontend/src/routes/library/jellyfin/albums/+page.svelte
create mode 100644 frontend/src/routes/library/jellyfin/artists/+page.svelte
create mode 100644 frontend/src/routes/library/jellyfin/playlists/+page.svelte
create mode 100644 frontend/src/routes/library/jellyfin/playlists/[id]/+page.svelte
create mode 100644 frontend/src/routes/library/jellyfin/tracks/+page.svelte
create mode 100644 frontend/src/routes/library/navidrome/albums/+page.svelte
create mode 100644 frontend/src/routes/library/navidrome/artists/+page.svelte
create mode 100644 frontend/src/routes/library/navidrome/playlists/+page.svelte
create mode 100644 frontend/src/routes/library/navidrome/playlists/[id]/+page.svelte
create mode 100644 frontend/src/routes/library/navidrome/tracks/+page.svelte
create mode 100644 frontend/src/routes/library/plex/+page.svelte
create mode 100644 frontend/src/routes/library/plex/activity/+page.svelte
create mode 100644 frontend/src/routes/library/plex/albums/+page.svelte
create mode 100644 frontend/src/routes/library/plex/artists/+page.svelte
create mode 100644 frontend/src/routes/library/plex/playlists/+page.svelte
create mode 100644 frontend/src/routes/library/plex/playlists/[id]/+page.svelte
create mode 100644 frontend/src/routes/library/plex/tracks/+page.svelte
diff --git a/Makefile b/Makefile
index 16cd35b..ad62a7b 100644
--- a/Makefile
+++ b/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'
diff --git a/README.md b/README.md
index 0e04486..1936cc0 100644
--- a/README.md
+++ b/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.
diff --git a/backend/api/v1/routes/jellyfin_library.py b/backend/api/v1/routes/jellyfin_library.py
index c7ac4ba..ecca7b5 100644
--- a/backend/api/v1/routes/jellyfin_library.py
+++ b/backend/api/v1/routes/jellyfin_library.py
@@ -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
diff --git a/backend/api/v1/routes/navidrome_library.py b/backend/api/v1/routes/navidrome_library.py
index f82f26f..e50eb10 100644
--- a/backend/api/v1/routes/navidrome_library.py
+++ b/backend/api/v1/routes/navidrome_library.py
@@ -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
diff --git a/backend/api/v1/routes/playlists.py b/backend/api/v1/routes/playlists.py
index b3fde6b..4fc7f17 100644
--- a/backend/api/v1/routes/playlists.py
+++ b/backend/api/v1/routes/playlists.py
@@ -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
diff --git a/backend/api/v1/routes/plex_auth.py b/backend/api/v1/routes/plex_auth.py
new file mode 100644
index 0000000..e3b3b4f
--- /dev/null
+++ b/backend/api/v1/routes/plex_auth.py
@@ -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")
diff --git a/backend/api/v1/routes/plex_library.py b/backend/api/v1/routes/plex_library.py
new file mode 100644
index 0000000..289a711
--- /dev/null
+++ b/backend/api/v1/routes/plex_library.py
@@ -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()
diff --git a/backend/api/v1/routes/settings.py b/backend/api/v1/routes/settings.py
index c95567e..e0b4fbb 100644
--- a/backend/api/v1/routes/settings.py
+++ b/backend/api/v1/routes/settings.py
@@ -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),
diff --git a/backend/api/v1/routes/stream.py b/backend/api/v1/routes/stream.py
index 2841c36..2e1a097 100644
--- a/backend/api/v1/routes/stream.py
+++ b/backend/api/v1/routes/stream.py
@@ -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"}
diff --git a/backend/api/v1/schemas/advanced_settings.py b/backend/api/v1/schemas/advanced_settings.py
index 123e9c8..7c5ba4a 100644
--- a/backend/api/v1/schemas/advanced_settings.py
+++ b/backend/api/v1/schemas/advanced_settings.py
@@ -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,
diff --git a/backend/api/v1/schemas/common.py b/backend/api/v1/schemas/common.py
index f2fc568..8e8d3ff 100644
--- a/backend/api/v1/schemas/common.py
+++ b/backend/api/v1/schemas/common.py
@@ -15,6 +15,7 @@ class IntegrationStatus(AppStruct):
lastfm: bool
navidrome: bool = False
youtube_api: bool = False
+ plex: bool = False
class StatusReport(AppStruct):
diff --git a/backend/api/v1/schemas/jellyfin.py b/backend/api/v1/schemas/jellyfin.py
index 565869c..6783a42 100644
--- a/backend/api/v1/schemas/jellyfin.py
+++ b/backend/api/v1/schemas/jellyfin.py
@@ -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] = []
diff --git a/backend/api/v1/schemas/navidrome.py b/backend/api/v1/schemas/navidrome.py
index b8b3a74..7f9cd98 100644
--- a/backend/api/v1/schemas/navidrome.py
+++ b/backend/api/v1/schemas/navidrome.py
@@ -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 = ""
diff --git a/backend/api/v1/schemas/playlists.py b/backend/api/v1/schemas/playlists.py
index 973d41d..06daee9 100644
--- a/backend/api/v1/schemas/playlists.py
+++ b/backend/api/v1/schemas/playlists.py
@@ -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):
diff --git a/backend/api/v1/schemas/plex.py b/backend/api/v1/schemas/plex.py
new file mode 100644
index 0000000..8ba0ff7
--- /dev/null
+++ b/backend/api/v1/schemas/plex.py
@@ -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
diff --git a/backend/api/v1/schemas/settings.py b/backend/api/v1/schemas/settings.py
index 6c716f6..d7b88c2 100644
--- a/backend/api/v1/schemas/settings.py
+++ b/backend/api/v1/schemas/settings.py
@@ -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
diff --git a/backend/core/dependencies/__init__.py b/backend/core/dependencies/__init__.py
index ab48af5..cf0a18a 100644
--- a/backend/core/dependencies/__init__.py
+++ b/backend/core/dependencies/__init__.py
@@ -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,
)
diff --git a/backend/core/dependencies/repo_providers.py b/backend/core/dependencies/repo_providers.py
index 90001c2..a43611d 100644
--- a/backend/core/dependencies/repo_providers.py
+++ b/backend/core/dependencies/repo_providers.py
@@ -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
diff --git a/backend/core/dependencies/service_providers.py b/backend/core/dependencies/service_providers.py
index 5f6aa6f..ff8302a 100644
--- a/backend/core/dependencies/service_providers.py
+++ b/backend/core/dependencies/service_providers.py
@@ -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)
diff --git a/backend/core/dependencies/type_aliases.py b/backend/core/dependencies/type_aliases.py
index 8f64915..ae05caf 100644
--- a/backend/core/dependencies/type_aliases.py
+++ b/backend/core/dependencies/type_aliases.py
@@ -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)]
diff --git a/backend/core/exceptions.py b/backend/core/exceptions.py
index e324c4d..daecc90 100644
--- a/backend/core/exceptions.py
+++ b/backend/core/exceptions.py
@@ -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,
diff --git a/backend/core/tasks.py b/backend/core/tasks.py
index 95a025b..e83aaec 100644
--- a/backend/core/tasks.py
+++ b/backend/core/tasks.py
@@ -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',
diff --git a/backend/infrastructure/cache/cache_keys.py b/backend/infrastructure/cache/cache_keys.py
index 385c74a..d09dc56 100644
--- a/backend/infrastructure/cache/cache_keys.py
+++ b/backend/infrastructure/cache/cache_keys.py
@@ -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"
diff --git a/backend/infrastructure/persistence/mbid_store.py b/backend/infrastructure/persistence/mbid_store.py
index 60d5a90..1eca418 100644
--- a/backend/infrastructure/persistence/mbid_store.py
+++ b/backend/infrastructure/persistence/mbid_store.py
@@ -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
diff --git a/backend/main.py b/backend/main.py
index 72e2545..02eacad 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -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)
diff --git a/backend/repositories/async_playlist_repository.py b/backend/repositories/async_playlist_repository.py
index ebd1128..41fd139 100644
--- a/backend/repositories/async_playlist_repository.py
+++ b/backend/repositories/async_playlist_repository.py
@@ -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(
diff --git a/backend/repositories/jellyfin_models.py b/backend/repositories/jellyfin_models.py
index 98b861a..0ef38b3 100644
--- a/backend/repositories/jellyfin_models.py
+++ b/backend/repositories/jellyfin_models.py
@@ -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
diff --git a/backend/repositories/jellyfin_repository.py b/backend/repositories/jellyfin_repository.py
index cd42ae1..19fbecd 100644
--- a/backend/repositories/jellyfin_repository.py
+++ b/backend/repositories/jellyfin_repository.py
@@ -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()
diff --git a/backend/repositories/navidrome_models.py b/backend/repositories/navidrome_models.py
index 7e45efa..c4fcfd3 100644
--- a/backend/repositories/navidrome_models.py
+++ b/backend/repositories/navidrome_models.py
@@ -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 []
diff --git a/backend/repositories/navidrome_repository.py b/backend/repositories/navidrome_repository.py
index e8aad30..a73928b 100644
--- a/backend/repositories/navidrome_repository.py
+++ b/backend/repositories/navidrome_repository.py
@@ -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"
diff --git a/backend/repositories/playlist_repository.py b/backend/repositories/playlist_repository.py
index 7ebdaca..dd4a253 100644
--- a/backend/repositories/playlist_repository.py
+++ b/backend/repositories/playlist_repository.py
@@ -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,
)
diff --git a/backend/repositories/plex_models.py b/backend/repositories/plex_models.py
new file mode 100644
index 0000000..2967a99
--- /dev/null
+++ b/backend/repositories/plex_models.py
@@ -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
diff --git a/backend/repositories/plex_repository.py b/backend/repositories/plex_repository.py
new file mode 100644
index 0000000..9e4f9c8
--- /dev/null
+++ b/backend/repositories/plex_repository.py
@@ -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)
diff --git a/backend/repositories/protocols/__init__.py b/backend/repositories/protocols/__init__.py
index 3fea8f7..de46911 100644
--- a/backend/repositories/protocols/__init__.py
+++ b/backend/repositories/protocols/__init__.py
@@ -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",
diff --git a/backend/repositories/protocols/jellyfin.py b/backend/repositories/protocols/jellyfin.py
index b1b31ff..0f2681c 100644
--- a/backend/repositories/protocols/jellyfin.py
+++ b/backend/repositories/protocols/jellyfin.py
@@ -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]:
+ ...
diff --git a/backend/repositories/protocols/navidrome.py b/backend/repositories/protocols/navidrome.py
index b6912cb..67464c0 100644
--- a/backend/repositories/protocols/navidrome.py
+++ b/backend/repositories/protocols/navidrome.py
@@ -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:
+ ...
diff --git a/backend/repositories/protocols/plex.py b/backend/repositories/protocols/plex.py
new file mode 100644
index 0000000..3952882
--- /dev/null
+++ b/backend/repositories/protocols/plex.py
@@ -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]:
+ ...
diff --git a/backend/services/home/facade.py b/backend/services/home/facade.py
index 29d2f35..147f7ee 100644
--- a/backend/services/home/facade.py
+++ b/backend/services/home/facade.py
@@ -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(
diff --git a/backend/services/home/integration_helpers.py b/backend/services/home/integration_helpers.py
index 28c2d7b..88a60c4 100644
--- a/backend/services/home/integration_helpers.py
+++ b/backend/services/home/integration_helpers.py
@@ -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()
diff --git a/backend/services/jellyfin_library_service.py b/backend/services/jellyfin_library_service.py
index e979af4..8fd37d3 100644
--- a/backend/services/jellyfin_library_service.py
+++ b/backend/services/jellyfin_library_service.py
@@ -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
diff --git a/backend/services/jellyfin_playback_service.py b/backend/services/jellyfin_playback_service.py
index 3e3d594..e5d8674 100644
--- a/backend/services/jellyfin_playback_service.py
+++ b/backend/services/jellyfin_playback_service.py
@@ -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)
diff --git a/backend/services/navidrome_library_service.py b/backend/services/navidrome_library_service.py
index 6ec1992..bf407c2 100644
--- a/backend/services/navidrome_library_service.py
+++ b/backend/services/navidrome_library_service.py
@@ -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
diff --git a/backend/services/navidrome_playback_service.py b/backend/services/navidrome_playback_service.py
index 9116265..fe3d0ab 100644
--- a/backend/services/navidrome_playback_service.py
+++ b/backend/services/navidrome_playback_service.py
@@ -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
diff --git a/backend/services/playlist_service.py b/backend/services/playlist_service.py
index f10074d..70677a0 100644
--- a/backend/services/playlist_service.py
+++ b/backend/services/playlist_service.py
@@ -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}"
diff --git a/backend/services/plex_library_service.py b/backend/services/plex_library_service.py
new file mode 100644
index 0000000..bf796da
--- /dev/null
+++ b/backend/services/plex_library_service.py
@@ -0,0 +1,1021 @@
+from __future__ import annotations
+
+import asyncio
+import logging
+import re
+import time
+import unicodedata
+from typing import TYPE_CHECKING
+
+from api.v1.schemas.plex import (
+ PlexAlbumDetail,
+ PlexAlbumMatch,
+ PlexAlbumSummary,
+ PlexAnalyticsItem,
+ PlexAnalyticsResponse,
+ PlexArtistIndexEntry,
+ PlexArtistIndexResponse,
+ PlexArtistSummary,
+ PlexDiscoveryAlbum,
+ PlexDiscoveryHub,
+ PlexDiscoveryResponse,
+ PlexHistoryEntrySchema,
+ PlexHistoryResponse,
+ PlexHubResponse,
+ PlexImportResult,
+ PlexLibraryStats,
+ PlexPlaylistDetail,
+ PlexPlaylistSummary,
+ PlexPlaylistTrack,
+ PlexSearchResponse,
+ PlexSessionInfo,
+ PlexSessionsResponse,
+ PlexTrackInfo,
+)
+from infrastructure.cover_urls import prefer_artist_cover_url, prefer_release_group_cover_url
+from core.exceptions import ExternalServiceError
+from repositories.plex_models import PlexAlbum, PlexArtist, PlexTrack, extract_mbid_from_guids
+from repositories.protocols.plex import PlexRepositoryProtocol
+from services.preferences_service import PreferencesService
+
+if TYPE_CHECKING:
+ from infrastructure.persistence import LibraryDB, MBIDStore
+
+logger = logging.getLogger(__name__)
+
+_CONCURRENCY_LIMIT = 5
+_NEGATIVE_CACHE_TTL = 14400
+
+
+def _clean_album_name(name: str) -> str:
+ cleaned = name.strip()
+ cleaned = re.sub(
+ r'\s*[\(\[][^)\]]*(?:remaster|deluxe|edition|bonus|expanded|mono|stereo|anniversary)[^)\]]*[\)\]]',
+ '', cleaned, flags=re.IGNORECASE,
+ )
+ cleaned = re.sub(r'^\d{4}\s*[-–—]\s*', '', cleaned)
+ cleaned = re.sub(r'\s*-\s*EP$', '', cleaned, flags=re.IGNORECASE)
+ cleaned = re.sub(r'\s*\[[^\]]*\]\s*$', '', cleaned)
+ return cleaned.strip()
+
+
+def _normalize(text: str) -> str:
+ text = unicodedata.normalize("NFKD", text)
+ text = text.encode("ascii", "ignore").decode("ascii")
+ text = re.sub(r"[^a-z0-9]", "", text.lower())
+ return text
+
+
+def _parse_decade(decade: str | None) -> tuple[int | None, int]:
+ if not decade:
+ return None, 0
+ stripped = decade.rstrip("s")
+ try:
+ start = int(stripped)
+ except ValueError:
+ return None, 0
+ return start, start + 9
+
+
+def _sort_albums(albums: list[PlexAlbum], sort: str) -> list[PlexAlbum]:
+ parts = sort.split(":", 1)
+ field = parts[0] if parts else "titleSort"
+ direction = parts[1] if len(parts) > 1 else "asc"
+ reverse = direction == "desc"
+
+ if field in ("titleSort", "title"):
+ return sorted(albums, key=lambda a: a.title.lower(), reverse=reverse)
+ if field == "addedAt":
+ return sorted(albums, key=lambda a: a.addedAt, reverse=reverse)
+ if field == "year":
+ return sorted(albums, key=lambda a: a.year, reverse=reverse)
+ if field == "viewCount":
+ return sorted(albums, key=lambda a: a.viewCount, reverse=reverse)
+ if field == "userRating":
+ return sorted(albums, key=lambda a: a.userRating, reverse=reverse)
+ if field == "lastViewedAt":
+ return sorted(albums, key=lambda a: a.lastViewedAt, reverse=reverse)
+ return sorted(albums, key=lambda a: a.title.lower(), reverse=reverse)
+
+
+class PlexLibraryService:
+
+ def __init__(
+ self,
+ plex_repo: PlexRepositoryProtocol,
+ preferences_service: PreferencesService,
+ library_db: 'LibraryDB | None' = None,
+ mbid_store: 'MBIDStore | None' = None,
+ ):
+ self._plex = plex_repo
+ self._preferences = preferences_service
+ self._library_db = library_db
+ self._mbid_store = mbid_store
+ self._album_mbid_cache: dict[str, str | tuple[None, float]] = {}
+ self._artist_mbid_cache: dict[str, str | tuple[None, float]] = {}
+ self._mbid_to_plex_id: dict[str, str] = {}
+ self._lidarr_album_index: dict[str, tuple[str, str]] = {}
+ self._analytics_cache: PlexAnalyticsResponse | None = None
+ self._analytics_cache_ts: float = 0.0
+ self._lidarr_artist_index: dict[str, str] = {}
+ self._dirty = False
+ self._stats_cache: PlexLibraryStats | None = None
+ self._stats_cache_ts: float = 0.0
+
+ def lookup_plex_id(self, mbid: str) -> str | None:
+ return self._mbid_to_plex_id.get(mbid)
+
+ def _get_configured_section_ids(self) -> list[str]:
+ try:
+ conn = self._preferences.get_plex_connection_raw()
+ if conn and conn.enabled and conn.music_library_ids:
+ return list(conn.music_library_ids)
+ except Exception: # noqa: BLE001
+ pass
+ return []
+
+ async def _resolve_album_mbid(self, name: str, artist: str) -> str | None:
+ if not name or not artist:
+ return None
+ cache_key = f"{_normalize(name)}:{_normalize(artist)}"
+ if cache_key in self._album_mbid_cache:
+ cached = self._album_mbid_cache[cache_key]
+ if isinstance(cached, str):
+ return cached
+ if isinstance(cached, tuple):
+ _, ts = cached
+ if time.time() - ts < _NEGATIVE_CACHE_TTL:
+ return None
+ del self._album_mbid_cache[cache_key]
+ elif cached is None:
+ del self._album_mbid_cache[cache_key]
+
+ match = self._lidarr_album_index.get(cache_key)
+ if match:
+ self._album_mbid_cache[cache_key] = match[0]
+ self._dirty = True
+ return match[0]
+
+ clean_key = f"{_normalize(_clean_album_name(name))}:{_normalize(artist)}"
+ if clean_key != cache_key:
+ match = self._lidarr_album_index.get(clean_key)
+ if match:
+ self._album_mbid_cache[cache_key] = match[0]
+ self._dirty = True
+ return match[0]
+
+ self._album_mbid_cache[cache_key] = (None, time.time())
+ self._dirty = True
+ return None
+
+ async def _resolve_artist_mbid(self, name: str) -> str | None:
+ if not name:
+ return None
+ cache_key = _normalize(name)
+ if cache_key in self._artist_mbid_cache:
+ cached = self._artist_mbid_cache[cache_key]
+ if isinstance(cached, str):
+ return cached
+ if isinstance(cached, tuple):
+ _, ts = cached
+ if time.time() - ts < _NEGATIVE_CACHE_TTL:
+ return None
+ del self._artist_mbid_cache[cache_key]
+ elif cached is None:
+ del self._artist_mbid_cache[cache_key]
+
+ match = self._lidarr_artist_index.get(cache_key)
+ if match:
+ self._artist_mbid_cache[cache_key] = match
+ self._dirty = True
+ return match
+
+ self._artist_mbid_cache[cache_key] = (None, time.time())
+ self._dirty = True
+ return None
+
+ def _track_to_info(self, track: PlexTrack) -> PlexTrackInfo:
+ codec: str | None = None
+ bitrate: int | None = None
+ audio_channels: int | None = None
+ container: str | None = None
+ part_key: str | None = None
+ if track.Media:
+ media = track.Media[0]
+ codec = media.audioCodec or None
+ bitrate = media.bitrate or None
+ audio_channels = media.audioChannels or None
+ container = media.container or None
+ if media.Part:
+ part_key = media.Part[0].key
+ return PlexTrackInfo(
+ plex_id=track.ratingKey,
+ title=track.title,
+ track_number=track.index,
+ disc_number=track.parentIndex or 1,
+ duration_seconds=track.duration / 1000.0 if track.duration else 0.0,
+ album_name=track.parentTitle,
+ artist_name=track.grandparentTitle,
+ codec=codec,
+ bitrate=bitrate,
+ audio_channels=audio_channels,
+ container=container,
+ part_key=part_key,
+ image_url=f"/api/v1/plex/thumb/{track.parentRatingKey}" if track.parentRatingKey else None,
+ )
+
+ async def _album_to_summary(self, album: PlexAlbum) -> PlexAlbumSummary:
+ plex_mbid = extract_mbid_from_guids(album.Guid)
+ mbid = plex_mbid or await self._resolve_album_mbid(album.title, album.parentTitle)
+ if mbid:
+ self._mbid_to_plex_id[mbid] = album.ratingKey
+ artist_mbid = await self._resolve_artist_mbid(album.parentTitle) if album.parentTitle else None
+
+ fallback = f"/api/v1/plex/thumb/{album.ratingKey}" if album.thumb else None
+ image_url = prefer_release_group_cover_url(mbid, fallback, size=500)
+
+ return PlexAlbumSummary(
+ plex_id=album.ratingKey,
+ name=album.title,
+ artist_name=album.parentTitle,
+ year=album.year or None,
+ track_count=album.leafCount,
+ image_url=image_url,
+ musicbrainz_id=mbid or None,
+ artist_musicbrainz_id=artist_mbid,
+ last_viewed_at=album.lastViewedAt or 0,
+ )
+
+ async def _build_artist_summary(self, artist: PlexArtist) -> PlexArtistSummary:
+ plex_mbid = extract_mbid_from_guids(artist.Guid)
+ mbid = plex_mbid or await self._resolve_artist_mbid(artist.title)
+ image_url = prefer_artist_cover_url(mbid, None, size=500)
+ return PlexArtistSummary(
+ plex_id=artist.ratingKey,
+ name=artist.title,
+ image_url=image_url,
+ musicbrainz_id=mbid or None,
+ )
+
+ async def get_albums(
+ self,
+ size: int = 50,
+ offset: int = 0,
+ sort: str = "titleSort:asc",
+ genre: str | None = None,
+ mood: str | None = None,
+ decade: str | None = None,
+ ) -> tuple[list[PlexAlbumSummary], int]:
+ section_ids = self._get_configured_section_ids()
+ if not section_ids:
+ return [], 0
+
+ decade_start, decade_end = _parse_decade(decade)
+
+ if len(section_ids) == 1:
+ albums, total = await self._plex.get_albums(
+ section_id=section_ids[0], size=size, offset=offset, sort=sort, genre=genre, mood=mood, decade=decade,
+ )
+ filtered = [a for a in albums if a.title and a.title != "Unknown"]
+ summaries = await asyncio.gather(*(self._album_to_summary(a) for a in filtered))
+ return list(summaries), total
+
+ fetch_limit = offset + size
+ all_albums: list[PlexAlbum] = []
+ total = 0
+ seen: set[str] = set()
+ for sid in section_ids:
+ albums, section_total = await self._plex.get_albums(
+ section_id=sid, size=fetch_limit, offset=0, sort=sort, genre=genre, mood=mood, decade=decade,
+ )
+ for a in albums:
+ if a.ratingKey not in seen:
+ seen.add(a.ratingKey)
+ all_albums.append(a)
+ total += section_total
+
+ all_albums = _sort_albums(all_albums, sort)
+
+ if decade_start is not None:
+ all_albums = [a for a in all_albums if a.year and decade_start <= a.year <= decade_end]
+ total = len(all_albums)
+
+ page = all_albums[offset : offset + size]
+ filtered = [a for a in page if a.title and a.title != "Unknown"]
+ summaries = await asyncio.gather(*(self._album_to_summary(a) for a in filtered))
+ return list(summaries), total
+
+ async def get_album_detail(self, rating_key: str) -> PlexAlbumDetail | None:
+ try:
+ album = await self._plex.get_album_metadata(rating_key)
+ tracks_raw = await self._plex.get_album_tracks(rating_key)
+ except Exception: # noqa: BLE001
+ logger.warning("Failed to fetch Plex album %s", rating_key, exc_info=True)
+ return None
+
+ tracks = [self._track_to_info(t) for t in tracks_raw]
+
+ plex_mbid = extract_mbid_from_guids(album.Guid)
+ mbid = plex_mbid or await self._resolve_album_mbid(album.title, album.parentTitle)
+ artist_mbid = await self._resolve_artist_mbid(album.parentTitle) if album.parentTitle else None
+
+ fallback = f"/api/v1/plex/thumb/{album.ratingKey}" if album.thumb else None
+ image_url = prefer_release_group_cover_url(mbid, fallback, size=500)
+
+ genres = [g.tag for g in album.Genre if g.tag]
+
+ return PlexAlbumDetail(
+ plex_id=album.ratingKey,
+ name=album.title,
+ artist_name=album.parentTitle,
+ year=album.year or None,
+ track_count=len(tracks),
+ image_url=image_url,
+ musicbrainz_id=mbid or None,
+ artist_musicbrainz_id=artist_mbid,
+ tracks=tracks,
+ genres=genres,
+ )
+
+ async def get_artists(self) -> list[PlexArtistSummary]:
+ section_ids = self._get_configured_section_ids()
+ if not section_ids:
+ return []
+
+ all_artists: list[PlexArtist] = []
+ for sid in section_ids:
+ offset = 0
+ while True:
+ batch = await self._plex.get_artists(section_id=sid, size=500, offset=offset)
+ if not batch:
+ break
+ all_artists.extend(batch)
+ if len(batch) < 500:
+ break
+ offset += 500
+
+ summaries = await asyncio.gather(*(self._build_artist_summary(a) for a in all_artists))
+ return list(summaries)
+
+ async def browse_artists(
+ self,
+ size: int = 48,
+ offset: int = 0,
+ sort: str = "titleSort:asc",
+ search: str = "",
+ ) -> tuple[list[PlexArtistSummary], int]:
+ section_ids = self._get_configured_section_ids()
+ if not section_ids:
+ return [], 0
+
+ sid = section_ids[0]
+ artists = await self._plex.get_artists(section_id=sid, size=size, offset=offset, search=search)
+ total = await self._plex.get_artist_count(sid)
+ summaries = await asyncio.gather(*(self._build_artist_summary(a) for a in artists))
+ return list(summaries), total
+
+ async def get_artists_index(self) -> PlexArtistIndexResponse:
+ all_summaries = await self.get_artists()
+
+ groups: dict[str, list[PlexArtistSummary]] = {}
+ for artist in all_summaries:
+ letter = artist.name[0].upper() if artist.name else "#"
+ if not letter.isalpha():
+ letter = "#"
+ groups.setdefault(letter, []).append(artist)
+
+ entries = [
+ PlexArtistIndexEntry(name=letter, artists=artists)
+ for letter, artists in sorted(groups.items())
+ ]
+ return PlexArtistIndexResponse(index=entries)
+
+ async def browse_tracks(
+ self,
+ size: int = 48,
+ offset: int = 0,
+ sort: str = "titleSort:asc",
+ search: str = "",
+ ) -> tuple[list[PlexTrackInfo], int]:
+ section_ids = self._get_configured_section_ids()
+ if not section_ids:
+ return [], 0
+
+ sid = section_ids[0]
+ tracks, total = await self._plex.get_tracks(
+ section_id=sid, size=size, offset=offset, sort=sort, search=search
+ )
+ return [self._track_to_info(t) for t in tracks], total
+
+ async def search(self, query: str) -> PlexSearchResponse:
+ section_ids = self._get_configured_section_ids()
+ if not section_ids:
+ return PlexSearchResponse()
+
+ all_albums_raw: list[PlexAlbum] = []
+ all_artists_raw: list[PlexArtist] = []
+ all_tracks_raw: list[PlexTrack] = []
+ seen_albums: set[str] = set()
+ seen_artists: set[str] = set()
+ seen_tracks: set[str] = set()
+
+ for sid in section_ids:
+ result = await self._plex.search(query, section_id=sid)
+ for a in result.get("albums", []):
+ if a.ratingKey not in seen_albums:
+ seen_albums.add(a.ratingKey)
+ all_albums_raw.append(a)
+ for a in result.get("artists", []):
+ if a.ratingKey not in seen_artists:
+ seen_artists.add(a.ratingKey)
+ all_artists_raw.append(a)
+ for t in result.get("tracks", []):
+ if t.ratingKey not in seen_tracks:
+ seen_tracks.add(t.ratingKey)
+ all_tracks_raw.append(t)
+
+ filtered_albums = [a for a in all_albums_raw if a.title and a.title != "Unknown"]
+ albums_task = asyncio.gather(*(self._album_to_summary(a) for a in filtered_albums))
+ artists_task = asyncio.gather(*(self._build_artist_summary(a) for a in all_artists_raw))
+ albums, artists = await asyncio.gather(albums_task, artists_task)
+ tracks = [self._track_to_info(t) for t in all_tracks_raw]
+
+ return PlexSearchResponse(
+ albums=list(albums),
+ artists=list(artists),
+ tracks=tracks,
+ )
+
+ async def get_recent(self, limit: int = 20) -> list[PlexAlbumSummary]:
+ section_ids = self._get_configured_section_ids()
+ if not section_ids:
+ return []
+
+ viewed: list[PlexAlbum] = []
+ for sid in section_ids:
+ albums = await self._plex.get_recently_viewed(section_id=sid, limit=limit)
+ viewed.extend(albums)
+
+ if viewed:
+ viewed.sort(key=lambda a: a.lastViewedAt, reverse=True)
+ filtered = [a for a in viewed[:limit] if a.title and a.title != "Unknown"]
+ else:
+ added: list[PlexAlbum] = []
+ for sid in section_ids:
+ albums = await self._plex.get_recently_added(section_id=sid, limit=limit)
+ added.extend(albums)
+ added.sort(key=lambda a: a.addedAt, reverse=True)
+ filtered = [a for a in added[:limit] if a.title and a.title != "Unknown"]
+
+ summaries = await asyncio.gather(*(self._album_to_summary(a) for a in filtered))
+ return list(summaries)
+
+ async def get_recently_played(self, limit: int = 20) -> list[PlexAlbumSummary]:
+ section_ids = self._get_configured_section_ids()
+ if not section_ids:
+ return []
+
+ viewed: list[PlexAlbum] = []
+ for sid in section_ids:
+ albums = await self._plex.get_recently_viewed(section_id=sid, limit=limit)
+ viewed.extend(albums)
+
+ if not viewed:
+ return []
+
+ viewed.sort(key=lambda a: a.lastViewedAt, reverse=True)
+ filtered = [a for a in viewed[:limit] if a.title and a.title != "Unknown"]
+ summaries = await asyncio.gather(*(self._album_to_summary(a) for a in filtered))
+ return list(summaries)
+
+ async def get_recently_added_albums(self, limit: int = 20) -> list[PlexAlbumSummary]:
+ section_ids = self._get_configured_section_ids()
+ if not section_ids:
+ return []
+
+ added: list[PlexAlbum] = []
+ for sid in section_ids:
+ albums = await self._plex.get_recently_added(section_id=sid, limit=limit)
+ added.extend(albums)
+
+ added.sort(key=lambda a: a.addedAt, reverse=True)
+ filtered = [a for a in added[:limit] if a.title and a.title != "Unknown"]
+ summaries = await asyncio.gather(*(self._album_to_summary(a) for a in filtered))
+ return list(summaries)
+
+ async def get_genres(self) -> list[str]:
+ section_ids = self._get_configured_section_ids()
+ if not section_ids:
+ return []
+
+ all_genres: set[str] = set()
+ for sid in section_ids:
+ genres = await self._plex.get_genres(section_id=sid)
+ all_genres.update(genres)
+
+ return sorted(all_genres)
+
+ async def get_songs_by_genre(
+ self, genre: str, limit: int = 50, offset: int = 0
+ ) -> tuple[list[PlexTrackInfo], int]:
+ section_ids = self._get_configured_section_ids()
+ if not section_ids:
+ return [], 0
+ sid = section_ids[0]
+ tracks_raw, total = await self._plex.get_tracks(
+ section_id=sid, size=limit, offset=offset,
+ sort="titleSort:asc", genre=genre,
+ )
+ return [self._track_to_info(t) for t in tracks_raw], total
+
+ async def get_moods(self) -> list[str]:
+ section_ids = self._get_configured_section_ids()
+ if not section_ids:
+ return []
+
+ all_moods: set[str] = set()
+ for sid in section_ids:
+ moods = await self._plex.get_moods(section_id=sid)
+ all_moods.update(moods)
+
+ return sorted(all_moods)
+
+ async def get_stats(self) -> PlexLibraryStats:
+ stats_ttl = self._plex.stats_ttl
+ if self._stats_cache is not None and (time.monotonic() - self._stats_cache_ts) < stats_ttl:
+ return self._stats_cache
+
+ section_ids = self._get_configured_section_ids()
+ if not section_ids:
+ return PlexLibraryStats()
+
+ total_albums = 0
+ total_artists = 0
+ total_tracks = 0
+
+ for sid in section_ids:
+ _, album_total = await self._plex.get_albums(section_id=sid, size=1, offset=0)
+ total_albums += album_total
+
+ track_total = await self._plex.get_track_count(section_id=sid)
+ total_tracks += track_total
+
+ artist_total = await self._plex.get_artist_count(section_id=sid)
+ total_artists += artist_total
+
+ result = PlexLibraryStats(
+ total_tracks=total_tracks,
+ total_albums=total_albums,
+ total_artists=total_artists,
+ )
+ self._stats_cache = result
+ self._stats_cache_ts = time.monotonic()
+ return result
+
+ async def get_album_match(
+ self,
+ album_id: str,
+ album_name: str,
+ artist_name: str,
+ ) -> PlexAlbumMatch:
+ sem = asyncio.Semaphore(_CONCURRENCY_LIMIT)
+
+ async def _fetch_detail(rk: str) -> PlexAlbumDetail | None:
+ async with sem:
+ return await self.get_album_detail(rk)
+
+ if album_id and album_id in self._mbid_to_plex_id:
+ plex_id = self._mbid_to_plex_id[album_id]
+ detail = await _fetch_detail(plex_id)
+ if detail:
+ return PlexAlbumMatch(
+ found=True,
+ plex_album_id=detail.plex_id,
+ tracks=detail.tracks,
+ )
+
+ if album_name:
+ section_ids = self._get_configured_section_ids()
+ candidates: list[PlexAlbum] = []
+ seen: set[str] = set()
+ for sid in section_ids:
+ result = await self._plex.search(album_name, section_id=sid, limit=50)
+ for a in result.get("albums", []):
+ if a.ratingKey not in seen:
+ seen.add(a.ratingKey)
+ candidates.append(a)
+
+ if album_id:
+ for candidate in candidates:
+ candidate_mbid = extract_mbid_from_guids(candidate.Guid)
+ if candidate_mbid and candidate_mbid == album_id:
+ detail = await _fetch_detail(candidate.ratingKey)
+ if detail:
+ return PlexAlbumMatch(
+ found=True,
+ plex_album_id=detail.plex_id,
+ tracks=detail.tracks,
+ )
+
+ if artist_name:
+ norm_album = _normalize(album_name)
+ norm_artist = _normalize(artist_name)
+ for candidate in candidates:
+ if (
+ _normalize(candidate.title) == norm_album
+ and _normalize(candidate.parentTitle) == norm_artist
+ ):
+ detail = await _fetch_detail(candidate.ratingKey)
+ if detail:
+ return PlexAlbumMatch(
+ found=True,
+ plex_album_id=detail.plex_id,
+ tracks=detail.tracks,
+ )
+
+ return PlexAlbumMatch(found=False)
+
+ async def list_playlists(self, limit: int = 50) -> list[PlexPlaylistSummary]:
+ raw = await self._plex.get_playlists()
+ summaries = []
+ for p in raw[:limit]:
+ cover = f"/api/v1/plex/playlist-thumb/{p.ratingKey}"
+ summaries.append(PlexPlaylistSummary(
+ id=p.ratingKey,
+ name=p.title,
+ track_count=p.leafCount,
+ duration_seconds=p.duration // 1000 if p.duration else 0,
+ is_smart=p.smart,
+ cover_url=cover,
+ updated_at=str(p.updatedAt) if p.updatedAt else "",
+ ))
+ return summaries
+
+ async def get_playlist_detail(self, playlist_id: str) -> PlexPlaylistDetail:
+ raw = await self._plex.get_playlists()
+ playlist = next((p for p in raw if p.ratingKey == playlist_id), None)
+ if playlist is None:
+ from core.exceptions import ResourceNotFoundError
+ raise ResourceNotFoundError(f"Plex playlist {playlist_id} not found")
+
+ items = await self._plex.get_playlist_items(playlist_id)
+ tracks = [
+ PlexPlaylistTrack(
+ id=t.ratingKey,
+ track_name=t.title,
+ artist_name=t.grandparentTitle,
+ album_name=t.parentTitle,
+ album_id=str(t.parentRatingKey) if t.parentRatingKey else "",
+ plex_rating_key=t.ratingKey,
+ duration_seconds=t.duration // 1000 if t.duration else 0,
+ track_number=t.index,
+ disc_number=t.parentIndex if t.parentIndex else 1,
+ cover_url=f"/api/v1/plex/thumb/{t.parentRatingKey}" if t.parentRatingKey else "",
+ )
+ for t in items
+ ]
+ cover = f"/api/v1/plex/playlist-thumb/{playlist.ratingKey}"
+ return PlexPlaylistDetail(
+ id=playlist.ratingKey,
+ name=playlist.title,
+ track_count=playlist.leafCount,
+ duration_seconds=playlist.duration // 1000 if playlist.duration else 0,
+ is_smart=playlist.smart,
+ cover_url=cover,
+ updated_at=str(playlist.updatedAt) if playlist.updatedAt else "",
+ tracks=tracks,
+ )
+
+ async def import_playlist(
+ self,
+ playlist_id: str,
+ playlist_service: 'PlaylistService',
+ ) -> PlexImportResult:
+ source_ref = f"plex:{playlist_id}"
+ existing = await playlist_service.get_by_source_ref(source_ref)
+ if existing:
+ return PlexImportResult(
+ 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 PlexImportResult(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": "plex",
+ "album_id": t.album_id,
+ "plex_rating_key": t.plex_rating_key,
+ "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 Plex playlist import %s", playlist_id, exc_info=True)
+ await playlist_service.delete_playlist(created.id)
+ raise ExternalServiceError(f"Failed to import Plex playlist {playlist_id}")
+
+ return PlexImportResult(
+ musicseerr_playlist_id=created.id,
+ tracks_imported=len(track_dicts),
+ tracks_failed=failed,
+ )
+
+ async def get_sessions(self) -> PlexSessionsResponse:
+ try:
+ raw_sessions = await self._plex.get_sessions()
+ sessions = [
+ PlexSessionInfo(
+ session_id=s.session_id,
+ user_name=s.user_name,
+ track_title=s.track_title,
+ artist_name=s.artist_name,
+ album_name=s.album_name,
+ cover_url=f"/api/v1/plex/thumb/{s.album_thumb}" if s.album_thumb else "",
+ player_device=s.player_device,
+ player_platform=s.player_platform,
+ player_state=s.player_state,
+ is_direct_play=s.is_direct_play,
+ progress_ms=s.progress_ms,
+ duration_ms=s.duration_ms,
+ audio_codec=s.audio_codec,
+ audio_channels=s.audio_channels,
+ bitrate=s.bitrate,
+ )
+ for s in raw_sessions
+ ]
+ return PlexSessionsResponse(sessions=sessions)
+ except Exception: # noqa: BLE001
+ logger.warning("get_sessions failed", exc_info=True)
+ return PlexSessionsResponse(sessions=[], available=False)
+
+ async def get_discovery_hubs(self, count: int = 10) -> PlexDiscoveryResponse:
+ section_ids = self._get_configured_section_ids()
+ if not section_ids:
+ return PlexDiscoveryResponse(hubs=[])
+ try:
+ raw_hubs = await self._plex.get_hubs(section_ids[0], count=count)
+ except Exception: # noqa: BLE001
+ logger.warning("get_discovery_hubs failed", exc_info=True)
+ return PlexDiscoveryResponse(hubs=[])
+ hubs: list[PlexDiscoveryHub] = []
+ for hub in raw_hubs:
+ hub_type = hub.get("type", "")
+ title = hub.get("title", "")
+ if hub_type != "album":
+ continue
+ albums: list[PlexDiscoveryAlbum] = []
+ for item in hub.get("Metadata", []):
+ rating_key = str(item.get("ratingKey", ""))
+ image_url = (
+ f"/api/v1/plex/thumb/{rating_key}" if item.get("thumb") else None
+ )
+ albums.append(PlexDiscoveryAlbum(
+ plex_id=rating_key,
+ name=item.get("title", ""),
+ artist_name=item.get("parentTitle", ""),
+ year=item.get("year"),
+ image_url=image_url,
+ ))
+ if albums:
+ hubs.append(PlexDiscoveryHub(
+ title=title,
+ hub_type=hub_type,
+ albums=albums,
+ ))
+ return PlexDiscoveryResponse(hubs=hubs)
+
+ async def get_hub_data(self) -> PlexHubResponse:
+ _HUB_TIMEOUT = 10
+
+ results = await asyncio.gather(
+ asyncio.wait_for(self.get_recently_played(limit=20), 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.get_recently_added_albums(limit=20), 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 Plex 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])
+
+ albums_result = results[1]
+ if isinstance(albums_result, BaseException):
+ logger.warning("Hub: get_albums failed: %s", albums_result)
+ all_albums_preview: list[PlexAlbumSummary] = []
+ else:
+ all_albums_preview = albums_result[0]
+
+ stats = results[2] if not isinstance(results[2], BaseException) else None
+ if isinstance(results[2], BaseException):
+ logger.warning("Hub: get_stats failed: %s", results[2])
+
+ recently_added = results[3] if not isinstance(results[3], BaseException) else []
+ if isinstance(results[3], BaseException):
+ logger.warning("Hub: get_recently_added_albums 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 PlexHubResponse(
+ stats=stats,
+ recently_played=recently_played,
+ recently_added=recently_added,
+ all_albums_preview=all_albums_preview,
+ playlists=playlists,
+ genres=genres,
+ )
+
+ async def warm_mbid_cache(self) -> None:
+ if self._library_db:
+ try:
+ lidarr_albums = await self._library_db.get_all_albums_for_matching()
+ self._lidarr_album_index = {}
+ self._lidarr_artist_index = {}
+ for title, artist_name, album_mbid, artist_mbid in lidarr_albums:
+ key = f"{_normalize(title)}:{_normalize(artist_name)}"
+ clean_key = f"{_normalize(_clean_album_name(title))}:{_normalize(artist_name)}"
+ self._lidarr_album_index[key] = (album_mbid, artist_mbid)
+ if clean_key != key:
+ self._lidarr_album_index[clean_key] = (album_mbid, artist_mbid)
+ norm_artist = _normalize(artist_name)
+ if norm_artist and artist_mbid:
+ self._lidarr_artist_index[norm_artist] = artist_mbid
+ logger.info(
+ "Built Plex Lidarr matching indices: %d album entries, %d artist entries",
+ len(self._lidarr_album_index), len(self._lidarr_artist_index),
+ )
+ except Exception: # noqa: BLE001
+ logger.warning("Failed to build Plex Lidarr matching indices", exc_info=True)
+
+ if self._mbid_store:
+ try:
+ disk_albums = await self._mbid_store.load_plex_album_mbid_index(max_age_seconds=86400)
+ disk_artists = await self._mbid_store.load_plex_artist_mbid_index(max_age_seconds=86400)
+ if disk_albums or disk_artists:
+ self._album_mbid_cache.update(disk_albums)
+ self._artist_mbid_cache.update(disk_artists)
+ logger.info(
+ "Loaded Plex MBID cache from disk: %d albums, %d artists",
+ len(disk_albums), len(disk_artists),
+ )
+ except Exception: # noqa: BLE001
+ logger.warning("Failed to load Plex MBID cache from disk", exc_info=True)
+
+ async def persist_if_dirty(self) -> None:
+ if not self._dirty or not self._mbid_store:
+ return
+ try:
+ serializable_albums = {k: (v if isinstance(v, str) else None) for k, v in self._album_mbid_cache.items()}
+ serializable_artists = {k: (v if isinstance(v, str) else None) for k, v in self._artist_mbid_cache.items()}
+ await self._mbid_store.save_plex_album_mbid_index(serializable_albums)
+ await self._mbid_store.save_plex_artist_mbid_index(serializable_artists)
+ self._dirty = False
+ logger.debug("Persisted dirty Plex MBID cache to disk")
+ except Exception: # noqa: BLE001
+ logger.warning("Failed to persist dirty Plex MBID cache", exc_info=True)
+
+ async def get_history(
+ self, limit: int = 50, offset: int = 0
+ ) -> PlexHistoryResponse:
+ try:
+ entries, total = await self._plex.get_listening_history(limit=limit, offset=offset)
+ except Exception: # noqa: BLE001
+ logger.warning("get_history failed", exc_info=True)
+ return PlexHistoryResponse(available=False)
+ items = [
+ PlexHistoryEntrySchema(
+ rating_key=e.rating_key,
+ track_title=e.track_title,
+ artist_name=e.artist_name,
+ album_name=e.album_name,
+ cover_url=f"/api/v1/plex/thumb/{e.album_rating_key}" if e.album_rating_key else "",
+ viewed_at=str(e.viewed_at),
+ device_name=e.device_name,
+ )
+ for e in entries
+ ]
+ return PlexHistoryResponse(
+ entries=items,
+ total=total,
+ limit=limit,
+ offset=offset,
+ )
+
+ async def get_analytics(self) -> PlexAnalyticsResponse:
+ from collections import Counter
+ import time as _time
+
+ if self._analytics_cache is not None and (_time.monotonic() - self._analytics_cache_ts) < 300:
+ return self._analytics_cache
+
+ max_entries = 5000
+ all_entries = []
+ offset = 0
+ batch_size = 500
+ total_available = 0
+ deadline = _time.monotonic() + 30
+
+ while len(all_entries) < max_entries:
+ if _time.monotonic() > deadline:
+ break
+ entries, total = await self._plex.get_listening_history(
+ limit=batch_size, offset=offset
+ )
+ if total > 0:
+ total_available = total
+ if not entries:
+ break
+ all_entries.extend(entries)
+ offset += batch_size
+ if offset >= total:
+ break
+
+ is_complete = len(all_entries) >= total_available or total_available <= max_entries
+
+ now = _time.time()
+ seven_days_ago = now - (7 * 86400)
+ thirty_days_ago = now - (30 * 86400)
+
+ artist_counts: Counter[str] = Counter()
+ album_counts: Counter[tuple[str, str]] = Counter()
+ track_counts: Counter[tuple[str, str]] = Counter()
+ total_ms = 0
+ last_7 = 0
+ last_30 = 0
+
+ for e in all_entries:
+ artist_counts[e.artist_name] += 1
+ album_counts[(e.album_name, e.artist_name)] += 1
+ track_counts[(e.track_title, e.artist_name)] += 1
+ total_ms += e.duration_ms
+
+ try:
+ viewed_ts = int(e.viewed_at)
+ except (ValueError, TypeError):
+ continue
+ if viewed_ts >= seven_days_ago:
+ last_7 += 1
+ if viewed_ts >= thirty_days_ago:
+ last_30 += 1
+
+ top_artists = [
+ PlexAnalyticsItem(name=name, play_count=count)
+ for name, count in artist_counts.most_common(10)
+ ]
+ top_albums = [
+ PlexAnalyticsItem(name=name, subtitle=artist, play_count=count)
+ for (name, artist), count in album_counts.most_common(10)
+ ]
+ top_tracks = [
+ PlexAnalyticsItem(name=name, subtitle=artist, play_count=count)
+ for (name, artist), count in track_counts.most_common(10)
+ ]
+
+ result = PlexAnalyticsResponse(
+ top_artists=top_artists,
+ top_albums=top_albums,
+ top_tracks=top_tracks,
+ total_listens=len(all_entries),
+ listens_last_7_days=last_7,
+ listens_last_30_days=last_30,
+ total_hours=round(total_ms / 3_600_000, 1),
+ is_complete=is_complete,
+ entries_analyzed=len(all_entries),
+ )
+ self._analytics_cache = result
+ self._analytics_cache_ts = _time.monotonic()
+ return result
diff --git a/backend/services/plex_playback_service.py b/backend/services/plex_playback_service.py
new file mode 100644
index 0000000..a3dae3c
--- /dev/null
+++ b/backend/services/plex_playback_service.py
@@ -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)
diff --git a/backend/services/preferences_service.py b/backend/services/preferences_service.py
index ae4fa4a..6cd2898 100644
--- a/backend/services/preferences_service.py
+++ b/backend/services/preferences_service.py
@@ -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
diff --git a/backend/services/settings_service.py b/backend/services/settings_service.py
index f130fb3..0f3aec9 100644
--- a/backend/services/settings_service.py
+++ b/backend/services/settings_service.py
@@ -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]
diff --git a/backend/tests/repositories/test_plex_repository.py b/backend/tests/repositories/test_plex_repository.py
new file mode 100644
index 0000000..f12f4eb
--- /dev/null
+++ b/backend/tests/repositories/test_plex_repository.py
@@ -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"
diff --git a/backend/tests/routes/test_discovery_routes.py b/backend/tests/routes/test_discovery_routes.py
new file mode 100644
index 0000000..635d41d
--- /dev/null
+++ b/backend/tests/routes/test_discovery_routes.py
@@ -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)
diff --git a/backend/tests/routes/test_now_playing_routes.py b/backend/tests/routes/test_now_playing_routes.py
new file mode 100644
index 0000000..b50015b
--- /dev/null
+++ b/backend/tests/routes/test_now_playing_routes.py
@@ -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"] == []
diff --git a/backend/tests/routes/test_plex_auth.py b/backend/tests/routes/test_plex_auth.py
new file mode 100644
index 0000000..02e211c
--- /dev/null
+++ b/backend/tests/routes/test_plex_auth.py
@@ -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
diff --git a/backend/tests/routes/test_plex_routes.py b/backend/tests/routes/test_plex_routes.py
new file mode 100644
index 0000000..edd03aa
--- /dev/null
+++ b/backend/tests/routes/test_plex_routes.py
@@ -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
diff --git a/backend/tests/routes/test_plex_settings.py b/backend/tests/routes/test_plex_settings.py
new file mode 100644
index 0000000..69cb96b
--- /dev/null
+++ b/backend/tests/routes/test_plex_settings.py
@@ -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
diff --git a/backend/tests/services/test_content_enrichment.py b/backend/tests/services/test_content_enrichment.py
new file mode 100644
index 0000000..a7d00a3
--- /dev/null
+++ b/backend/tests/services/test_content_enrichment.py
@@ -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 link.",
+ 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 "" 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
diff --git a/backend/tests/services/test_deep_discovery.py b/backend/tests/services/test_deep_discovery.py
new file mode 100644
index 0000000..0b71fed
--- /dev/null
+++ b/backend/tests/services/test_deep_discovery.py
@@ -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
diff --git a/backend/tests/services/test_discovery.py b/backend/tests/services/test_discovery.py
new file mode 100644
index 0000000..6f89dd3
--- /dev/null
+++ b/backend/tests/services/test_discovery.py
@@ -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
diff --git a/backend/tests/services/test_jellyfin_library_service.py b/backend/tests/services/test_jellyfin_library_service.py
new file mode 100644
index 0000000..720aaea
--- /dev/null
+++ b/backend/tests/services/test_jellyfin_library_service.py
@@ -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
diff --git a/backend/tests/services/test_navidrome_library_service.py b/backend/tests/services/test_navidrome_library_service.py
index 12f458f..4df1c7b 100644
--- a/backend/tests/services/test_navidrome_library_service.py
+++ b/backend/tests/services/test_navidrome_library_service.py
@@ -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 == []
diff --git a/backend/tests/services/test_now_playing.py b/backend/tests/services/test_now_playing.py
new file mode 100644
index 0000000..df13eeb
--- /dev/null
+++ b/backend/tests/services/test_now_playing.py
@@ -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
diff --git a/backend/tests/services/test_playlist_service.py b/backend/tests/services/test_playlist_service.py
index f978874..f7009f6 100644
--- a/backend/tests/services/test_playlist_service.py
+++ b/backend/tests/services/test_playlist_service.py
@@ -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:
diff --git a/backend/tests/services/test_playlist_source_resolution.py b/backend/tests/services/test_playlist_source_resolution.py
index dbaecd6..de8f286 100644
--- a/backend/tests/services/test_playlist_source_resolution.py
+++ b/backend/tests/services/test_playlist_source_resolution.py
@@ -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,
)
diff --git a/backend/tests/services/test_plex_integration_status.py b/backend/tests/services/test_plex_integration_status.py
new file mode 100644
index 0000000..a0497b1
--- /dev/null
+++ b/backend/tests/services/test_plex_integration_status.py
@@ -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
diff --git a/backend/tests/services/test_plex_library_service.py b/backend/tests/services/test_plex_library_service.py
new file mode 100644
index 0000000..05d734e
--- /dev/null
+++ b/backend/tests/services/test_plex_library_service.py
@@ -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"
diff --git a/backend/tests/services/test_plex_playback_service.py b/backend/tests/services/test_plex_playback_service.py
new file mode 100644
index 0000000..fb71b2f
--- /dev/null
+++ b/backend/tests/services/test_plex_playback_service.py
@@ -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
diff --git a/backend/tests/services/test_plex_settings_lifecycle.py b/backend/tests/services/test_plex_settings_lifecycle.py
new file mode 100644
index 0000000..d9227d2
--- /dev/null
+++ b/backend/tests/services/test_plex_settings_lifecycle.py
@@ -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 == []
diff --git a/backend/tests/services/test_source_playlist_import.py b/backend/tests/services/test_source_playlist_import.py
new file mode 100644
index 0000000..5d175ca
--- /dev/null
+++ b/backend/tests/services/test_source_playlist_import.py
@@ -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")
diff --git a/backend/tests/test_audiodb_settings.py b/backend/tests/test_audiodb_settings.py
index 63dd7a2..8fa2cd6 100644
--- a/backend/tests/test_audiodb_settings.py
+++ b/backend/tests/test_audiodb_settings.py
@@ -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")
diff --git a/backend/tests/test_dependencies_package.py b/backend/tests/test_dependencies_package.py
index ad9194a..05554bd 100644
--- a/backend/tests/test_dependencies_package.py
+++ b/backend/tests/test_dependencies_package.py
@@ -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:
diff --git a/backend/tests/test_peer_review_fixes.py b/backend/tests/test_peer_review_fixes.py
new file mode 100644
index 0000000..e7c762e
--- /dev/null
+++ b/backend/tests/test_peer_review_fixes.py
@@ -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
diff --git a/config/config.example.json b/config/config.example.json
index 8246b08..360cd10 100644
--- a/config/config.example.json
+++ b/config/config.example.json
@@ -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"],
diff --git a/frontend/src/app.css b/frontend/src/app.css
index 30705b7..865db9e 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -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;
}
diff --git a/frontend/src/lib/actions/reveal.ts b/frontend/src/lib/actions/reveal.ts
new file mode 100644
index 0000000..6cd1b68
--- /dev/null
+++ b/frontend/src/lib/actions/reveal.ts
@@ -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 = '';
+ });
+ }
+ };
+}
diff --git a/frontend/src/lib/api/playlists.spec.ts b/frontend/src/lib/api/playlists.spec.ts
index 18590d1..d51b042 100644
--- a/frontend/src/lib/api/playlists.spec.ts
+++ b/frontend/src/lib/api/playlists.spec.ts
@@ -277,7 +277,8 @@ describe('playlists API client', () => {
format: 'aac',
track_number: 3,
disc_number: null,
- duration: 240
+ duration: 240,
+ plex_rating_key: null
});
});
diff --git a/frontend/src/lib/api/playlists.ts b/frontend/src/lib/api/playlists.ts
index 8e8b14e..f853d4b 100644
--- a/frontend/src/lib/api/playlists.ts
+++ b/frontend/src/lib/api/playlists.ts
@@ -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
};
}
diff --git a/frontend/src/lib/components/AddToPlaylistModal.svelte b/frontend/src/lib/components/AddToPlaylistModal.svelte
index b6da7c8..591dcec 100644
--- a/frontend/src/lib/components/AddToPlaylistModal.svelte
+++ b/frontend/src/lib/components/AddToPlaylistModal.svelte
@@ -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 @@
diff --git a/frontend/src/lib/components/AlbumGrid.svelte b/frontend/src/lib/components/AlbumGrid.svelte
new file mode 100644
index 0000000..f28d274
--- /dev/null
+++ b/frontend/src/lib/components/AlbumGrid.svelte
@@ -0,0 +1,93 @@
+
+
+ {album.name} {album.artist_name} {artist.name}
+ {artist.album_count} album{artist.album_count !== 1 ? 's' : ''}
+ {emptyMessage}
+ {track.title}
+
+ {track.artist_name}
+
+ Continue Listening
+ {hero.artist_name} No songs found for {selectedGenres.join(', ')}. Pick a genre to browse tracks. {session.artist_name} {session.album_name}
+ {[session.user_name, session.device_name].filter(Boolean).join(' - ')}
+ {trackName} {artistName} Loading lyrics...
+ Couldn't load the lyrics. Try again in a bit.
+
+ {line.text}
+
+ Lyrics aren't available for this track.
+ {@html notes} No notes available for this release. No listening data yet. {artist.name}
+ {artist.play_count ?? 0} plays{artist.album_count != null
+ ? `, ${artist.album_count} album${artist.album_count !== 1 ? 's' : ''}`
+ : ''}
+ {album.name}
+ {album.artist_name ?? ''}{album.play_count != null
+ ? `, ${album.play_count} plays`
+ : ''}
+
+ {session.track_name}
+
+ {session.artist_name}
+
+ {entry.name}
+
+ {title}
+
+
+
+
+
+
+
+ {#each tracks as track, i (track.id)}
+ #
+ Title
+ Album
+ Time
+ onplay(i)}
+ >
+
+ {/each}
+
+
+ {i + 1}
+
+
+
+
+
+ {track.album_name}
+
+
+ {formatDuration(track.duration_seconds)}
+
+ Discover
+
+ {hero.name}
+
+
+
+
+
+
+
+
+ {#each songs as track, i (track.id + '-' + i)}
+ #
+ Title
+ Artist
+ Album
+ Duration
+ playSongs(i)}>
+
+ {/each}
+
+ {i + 1}
+ {track.title}
+ {track.artist_name}
+ {track.album_name}
+ {formatDurationSec(track.duration_seconds)}
+
+ {:else}
+
{session.track_name}
+
+ {title}
+
+
+ {:else}
+ {title}
+ {/if}
+ {#if seeAllHref}
+
+ View all
+ {lyricsText}
+ {:else}
+
+ {/if}
+
+ {#if notes}
+
+
Artists
+
+ {#each artists as artist (artist.id)}
+
+ Albums
+
+ {#each albums as album (album.id)}
+
+ Now Playing
+
+ {#if session.is_paused}
+
Track unavailable
+This track isn't available right now.
{/if} @@ -241,7 +313,7 @@ > {#if !playerStore.isSeekable} -Seeking unavailable for this stream format
+This stream doesn't support seeking.
{/if} @@ -262,6 +334,20 @@ + {#if supportsLyrics} +{name}
+{artistName}
+{albumCount} album{albumCount !== 1 ? 's' : ''}
+ {/if} +{albumCount} album{albumCount !== 1 ? 's' : ''}
+ {/if} ++ {detail.track_count} track{detail.track_count === 1 ? '' : 's'} + {#if detail.duration_seconds} + - {formatTotalDurationSec(detail.duration_seconds)} + {/if} +
++ Imported {importResult.tracks_imported} tracks + {#if importResult.tracks_failed > 0} + ({importResult.tracks_failed} skipped) + {/if} +
+ {/if} +| # | +Title | +Artist | +Album | +Duration | +
|---|---|---|---|---|
| {i + 1} | +{track.track_name} | +{track.artist_name} | +{track.album_name} | ++ {#if track.duration_seconds} + {Math.floor(track.duration_seconds / 60)}:{String( + track.duration_seconds % 60 + ).padStart(2, '0')} + {/if} + | +
| # | +Title | +Artist | +Album | +
+
+ |
+ Quality | +
|---|---|---|---|---|---|
| {i + 1} | +
+ {track.title}
+
+ {track.artist_name}
+
+ |
+ + {track.artist_name} + | ++ {track.album_name} + | ++ {formatDuration(track.duration_seconds)} + | ++ {#if track.codec} + {track.codec.toUpperCase()} + {#if track.bitrate} + - {Math.round(track.bitrate / 1000)} kbps + {/if} + {/if} + | +
+ Connect Plex to browse your library, play music, and keep your listening history in sync. +
+ + {#if form.loading} ++ Run a connection test to load your Plex libraries. +
+ {/if} +- To get started, connect your Lidarr server. This is required to manage your music - library, request albums, and track your collection. + Get started by connecting your Lidarr server. You need it to manage your library, + request albums, and keep track of your collection.
- Your music library appears to be empty. Add some albums in Lidarr to get started, or - connect additional services for personalized recommendations. + Your library looks empty. Add a few albums in Lidarr to get started, or connect more + services for recommendations.
- Settings + Open settings