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 @@ + + +
+
+ {#each visible as album, i (getId(album))} + {@const isSibling = hoveredIdx !== null && hoveredIdx !== i} + + {/each} +
+ + {#if seeAllHref && albums.length > maxItems} +
+ + {seeAllLabel} + + +
+ {/if} +
diff --git a/frontend/src/lib/components/ArtistIndexSidebar.svelte b/frontend/src/lib/components/ArtistIndexSidebar.svelte new file mode 100644 index 0000000..23724c7 --- /dev/null +++ b/frontend/src/lib/components/ArtistIndexSidebar.svelte @@ -0,0 +1,103 @@ + + +
+ + +
+ {#each index as entry (entry.name)} + {#if entry.artists.length > 0} +
+

+ {entry.name} +

+
+ {#each entry.artists as artist (artist.id)} + + {/each} +
+
+ {/if} + {/each} +
+
diff --git a/frontend/src/lib/components/AudioQualityBadge.svelte b/frontend/src/lib/components/AudioQualityBadge.svelte new file mode 100644 index 0000000..b68acc5 --- /dev/null +++ b/frontend/src/lib/components/AudioQualityBadge.svelte @@ -0,0 +1,61 @@ + + +{#if hasAnyInfo} +
+ {#if codec} + + {codec} + + {/if} + + {#if !compact} + {#if bitrate} + {bitrateLabel()} + {/if} + {#if audioChannels} + {channelLabel()} + {/if} + {#if container && container !== codec} + {container} + {/if} + {/if} +
+{/if} diff --git a/frontend/src/lib/components/BrowseHeroCards.svelte b/frontend/src/lib/components/BrowseHeroCards.svelte new file mode 100644 index 0000000..77e8ea7 --- /dev/null +++ b/frontend/src/lib/components/BrowseHeroCards.svelte @@ -0,0 +1,182 @@ + + +
+ {#each cards as card, i (card.label)} + {@const colors = colorClasses[card.colorScheme]} + {@const Icon = iconMap[card.icon]} + {@const isHovered = hoveredIndex === i} + {@const isSibling = hoveredIndex !== null && hoveredIndex !== i} + { + hoveredIndex = i; + handlePointerMove(e, i); + }} + onpointerleave={() => handlePointerLeave(i)} + > +
+ + {#if specularStyles[i]} +
+ {/if} + +
+ +
+ + {#if card.value !== null} +
+ {formatNumber(Math.round(getCounterValue(i)))} +
+ {:else} +
+ {/if} + +
+ {card.label} +
+ + {#if card.subtitle} +
{card.subtitle}
+ {/if} + +
+ +
+
+ {/each} +
diff --git a/frontend/src/lib/components/DiscoveryShelf.svelte b/frontend/src/lib/components/DiscoveryShelf.svelte new file mode 100644 index 0000000..9b533bf --- /dev/null +++ b/frontend/src/lib/components/DiscoveryShelf.svelte @@ -0,0 +1,59 @@ + + +
+
+
+ +

{title}

+
+ {#if onrefresh} + + {/if} +
+ + {#if loading} + + {:else} + {#if actions}{@render actions()}{/if} + {#if empty} +
+

{emptyMessage}

+
+ {:else} + {@render children()} + {/if} + {/if} +
diff --git a/frontend/src/lib/components/DiscoveryTrackTable.svelte b/frontend/src/lib/components/DiscoveryTrackTable.svelte new file mode 100644 index 0000000..7bb9f0e --- /dev/null +++ b/frontend/src/lib/components/DiscoveryTrackTable.svelte @@ -0,0 +1,79 @@ + + +
+ + + + + + + + + + + {#each tracks as track, i (track.id)} + onplay(i)} + > + + + + + + {/each} + +
#TitleTime
+ {i + 1} + + +
+ +
+

+ {track.title} +

+

+ {track.artist_name} +

+
+
+
+ {formatDuration(track.duration_seconds)} +
+
diff --git a/frontend/src/lib/components/DiscoveryZone.svelte b/frontend/src/lib/components/DiscoveryZone.svelte new file mode 100644 index 0000000..1449b26 --- /dev/null +++ b/frontend/src/lib/components/DiscoveryZone.svelte @@ -0,0 +1,32 @@ + + +
+
+
+ +
+
+
+ +
+

Discover

+
+ + {@render children()} +
+
+
diff --git a/frontend/src/lib/components/FeaturedAlbumHero.svelte b/frontend/src/lib/components/FeaturedAlbumHero.svelte new file mode 100644 index 0000000..2de0756 --- /dev/null +++ b/frontend/src/lib/components/FeaturedAlbumHero.svelte @@ -0,0 +1,142 @@ + + +{#if hero} + +
+
+ {#if hero.image_url} + + {/if} + + {#if glowColor} +
+ {/if} + +
+ +
+ + +
+

+ Continue Listening +

+

+ {hero.name} +

+

{hero.artist_name}

+ {#if hero.year} + {hero.year} + {/if} +
+
+
+ + {#if thumbnails.length > 0} +
+ {#each thumbnails as album (getId(album))} + + {/each} +
+ {/if} +
+{/if} diff --git a/frontend/src/lib/components/GenrePillFilter.svelte b/frontend/src/lib/components/GenrePillFilter.svelte new file mode 100644 index 0000000..18e5aad --- /dev/null +++ b/frontend/src/lib/components/GenrePillFilter.svelte @@ -0,0 +1,79 @@ + + +
+ {#if showAll} + + {/if} + {#each visibleGenres as genre (genre)} + + {/each} + {#if hasOverflow && !expanded} + + {/if} +
diff --git a/frontend/src/lib/components/GenreSongsBrowser.svelte b/frontend/src/lib/components/GenreSongsBrowser.svelte new file mode 100644 index 0000000..d0224d9 --- /dev/null +++ b/frontend/src/lib/components/GenreSongsBrowser.svelte @@ -0,0 +1,147 @@ + + +
+ + + {#if loading && songs.length === 0} +
+ +
+ {:else if selectedGenres.length > 0 && songs.length > 0} +
+ + +
+
+ + + + + + + + + + + + {#each songs as track, i (track.id + '-' + i)} + playSongs(i)}> + + + + + + + {/each} + +
#TitleArtistAlbumDuration
{i + 1}{track.title}{track.artist_name}{track.album_name}{formatDurationSec(track.duration_seconds)}
+
+ {#if songs.length >= offset + PAGE_SIZE} +
+ +
+ {/if} + {:else if selectedGenres.length > 0} +

No songs found for {selectedGenres.join(', ')}.

+ {:else} +

Pick a genre to browse tracks.

+ {/if} +
diff --git a/frontend/src/lib/components/HomeSectionNowPlaying.svelte b/frontend/src/lib/components/HomeSectionNowPlaying.svelte new file mode 100644 index 0000000..028c166 --- /dev/null +++ b/frontend/src/lib/components/HomeSectionNowPlaying.svelte @@ -0,0 +1,96 @@ + + +{#if session} +
+
+ {#if session.cover_url} + {session.album_name} cover + {:else} +
+ +
+ {/if} +
+ +
+
+
+ +
+ + {session.is_paused ? 'Paused' : 'Now Playing'} + {#if session.source} + on {sourceLabels[session.source] ?? session.source} + {/if} + +
+ +

{session.track_name}

+

{session.artist_name}

+ {#if session.album_name} +

{session.album_name}

+ {/if} + + {#if session.duration_ms} +
+ {formatTime(session.progress_ms)} +
+
+
+ {formatTime(session.duration_ms)} +
+ {/if} + + {#if session.user_name || session.device_name} +

+ {[session.user_name, session.device_name].filter(Boolean).join(' - ')} +

+ {/if} +
+
+{/if} diff --git a/frontend/src/lib/components/HubPageSkeleton.svelte b/frontend/src/lib/components/HubPageSkeleton.svelte new file mode 100644 index 0000000..f42e78f --- /dev/null +++ b/frontend/src/lib/components/HubPageSkeleton.svelte @@ -0,0 +1,22 @@ + + +
+ Loading hub... + +
+ {#each Array(3) as _, i (i)} +
+ {/each} +
+ + {#each ['Recently Added', 'Favorites', 'Browse Albums'] as title (title)} +
+
+
+ +
+
+ {/each} +
diff --git a/frontend/src/lib/components/HubShelf.svelte b/frontend/src/lib/components/HubShelf.svelte new file mode 100644 index 0000000..bbd1615 --- /dev/null +++ b/frontend/src/lib/components/HubShelf.svelte @@ -0,0 +1,50 @@ + + +
+
+ {#if seeAllHref} + +

+ {title} +

+
+ {:else} +

{title}

+ {/if} + {#if seeAllHref} + + View all + + + {/if} +
+ +
+ {#if loading} + + {:else} + {@render children()} + {/if} +
+
diff --git a/frontend/src/lib/components/InstantMixButton.svelte b/frontend/src/lib/components/InstantMixButton.svelte new file mode 100644 index 0000000..6fab2d6 --- /dev/null +++ b/frontend/src/lib/components/InstantMixButton.svelte @@ -0,0 +1,18 @@ + + + diff --git a/frontend/src/lib/components/LibraryFilterBar.svelte b/frontend/src/lib/components/LibraryFilterBar.svelte index 59588f1..c728a34 100644 --- a/frontend/src/lib/components/LibraryFilterBar.svelte +++ b/frontend/src/lib/components/LibraryFilterBar.svelte @@ -15,6 +15,16 @@ genres?: string[]; selectedGenre?: string; onGenreChange?: (value: string) => void; + moods?: string[]; + selectedMood?: string; + onMoodChange?: (value: string) => void; + moodLabel?: string; + decades?: string[]; + selectedDecade?: string; + onDecadeChange?: (value: string) => void; + tags?: string[]; + selectedTag?: string; + onTagChange?: (value: string) => void; resultCount?: number | null; loading?: boolean; } @@ -22,7 +32,7 @@ let { searchQuery = $bindable(), onSearchInput, - placeholder = 'Search albums...', + placeholder = 'Search albums', ariaLabel = 'Search albums', sortOptions, sortBy, @@ -33,6 +43,16 @@ genres, selectedGenre, onGenreChange, + moods, + selectedMood, + onMoodChange, + moodLabel = 'Mood', + decades, + selectedDecade, + onDecadeChange, + tags, + selectedTag, + onTagChange, resultCount, loading = false }: Props = $props(); @@ -40,7 +60,12 @@ let isSearching = $derived(searchQuery.trim().length > 0); let hasSortControls = $derived(sortOptions && sortOptions.length > 0); let hasGenreFilter = $derived(genres && genres.length > 0); - let hasSecondRow = $derived(hasSortControls || hasGenreFilter || resultCount != null); + let hasMoodFilter = $derived(moods && moods.length > 0); + let hasDecadeChips = $derived(decades && decades.length > 0); + let hasTagChips = $derived(tags && tags.length > 0); + let hasSecondRow = $derived( + hasSortControls || hasGenreFilter || hasMoodFilter || resultCount != null + ); let isAscending = $derived(sortOrder === ascValue); function clearSearch(): void { @@ -57,6 +82,11 @@ const value = (e.target as HTMLSelectElement).value; onGenreChange?.(value); } + + function handleMoodSelect(e: Event): void { + const value = (e.target as HTMLSelectElement).value; + onMoodChange?.(value); + }
@@ -120,16 +150,80 @@ onchange={handleGenreSelect} aria-label="Filter by genre" > - + {#each genres! as genre (genre)} {/each} {/if} + {#if hasMoodFilter} + + {/if} + {#if resultCount != null && !loading} {resultCount} results {/if}
{/if} + + {#if hasDecadeChips} +
+ + {#each decades! as decade (decade)} + + {/each} +
+ {/if} + + {#if hasTagChips} +
+ + {#each tags! as tag (tag)} + + {/each} +
+ {/if} diff --git a/frontend/src/lib/components/LibraryPage.svelte b/frontend/src/lib/components/LibraryPage.svelte index 795a33b..480cace 100644 --- a/frontend/src/lib/components/LibraryPage.svelte +++ b/frontend/src/lib/components/LibraryPage.svelte @@ -1,45 +1,60 @@ - + +{#if open} + +{/if} diff --git a/frontend/src/lib/components/MetadataPanel.svelte b/frontend/src/lib/components/MetadataPanel.svelte new file mode 100644 index 0000000..8555906 --- /dev/null +++ b/frontend/src/lib/components/MetadataPanel.svelte @@ -0,0 +1,115 @@ + + +{#if open} + + +{/if} diff --git a/frontend/src/lib/components/MostPlayedSection.svelte b/frontend/src/lib/components/MostPlayedSection.svelte new file mode 100644 index 0000000..e4db9f0 --- /dev/null +++ b/frontend/src/lib/components/MostPlayedSection.svelte @@ -0,0 +1,109 @@ + + +{#if artists.length === 0 && albums.length === 0} +

No listening data yet.

+{:else} +
+ {#if artists.length > 0} +
+

Artists

+
    + {#each artists as artist (artist.id)} +
  1. + {artists.indexOf(artist) + 1} +
    + +
    +
    +

    {artist.name}

    +

    + {artist.play_count ?? 0} plays{artist.album_count != null + ? `, ${artist.album_count} album${artist.album_count !== 1 ? 's' : ''}` + : ''} +

    +
    +
  2. + {/each} +
+
+ {/if} + + {#if albums.length > 0} +
+

Albums

+
    + {#each albums as album (album.id)} +
  1. + +
  2. + {/each} +
+
+ {/if} +
+{/if} diff --git a/frontend/src/lib/components/NowPlayingWidget.svelte b/frontend/src/lib/components/NowPlayingWidget.svelte new file mode 100644 index 0000000..4086635 --- /dev/null +++ b/frontend/src/lib/components/NowPlayingWidget.svelte @@ -0,0 +1,107 @@ + + +{#if sessions.length > 0} +
+
+
+ +
+

Now Playing

+
+ +
+ {#each sessions as session (session.id)} + {@const cover = getCoverUrl(session)} + {@const progress = progressPercent(session)} +
+ {#if cover} +
+ {session.album_name} + {#if session.is_paused} +
+ +
+ {/if} +
+ {:else} +
+ +
+ {/if} + +
+

+ {session.track_name} +

+

+ {session.artist_name} +

+
+ {session.user_name} + {#if session.device_name} + - + {session.device_name} + {/if} + {#if session.audio_codec} + - + + {/if} +
+ + {#if progress !== null} +
+
+
+
+ {#if session.progress_ms != null && session.duration_ms != null} + + {formatTime(session.progress_ms)}/{formatTime(session.duration_ms)} + + {/if} +
+ {/if} +
+
+ {/each} +
+
+{/if} diff --git a/frontend/src/lib/components/Player.svelte b/frontend/src/lib/components/Player.svelte index a1aa553..e56528f 100644 --- a/frontend/src/lib/components/Player.svelte +++ b/frontend/src/lib/components/Player.svelte @@ -5,10 +5,14 @@ import YouTubePlayer from '$lib/components/YouTubePlayer.svelte'; import JellyfinIcon from '$lib/components/JellyfinIcon.svelte'; import NavidromeIcon from '$lib/components/NavidromeIcon.svelte'; + import PlexIcon from '$lib/components/PlexIcon.svelte'; import QueueDrawer from '$lib/components/QueueDrawer.svelte'; import EqPanel from '$lib/components/EqPanel.svelte'; + import LyricsPanel from '$lib/components/LyricsPanel.svelte'; + import AudioQualityBadge from '$lib/components/AudioQualityBadge.svelte'; import NowPlayingIndicator from '$lib/components/NowPlayingIndicator.svelte'; import { getCoverUrl } from '$lib/utils/errorHandling'; + import { API } from '$lib/constants'; import { X, Music, @@ -23,13 +27,78 @@ Check, CircleX, ListMusic, - SlidersHorizontal + SlidersHorizontal, + Music2 } from 'lucide-svelte'; let coverImgError = $state(false); let lastCoverKey = ''; let eqPanelOpen = $state(false); let queueDrawerOpen = $state(false); + import type { LyricLine } from '$lib/types'; + + let lyricsPanelOpen = $state(false); + let lyricsText = $state(''); + let lyricsLines: LyricLine[] = $state([]); + let lyricsIsSynced = $state(false); + let lyricsLoading = $state(false); + let lyricsError = $state(false); + + const supportsLyrics = $derived( + playerStore.nowPlaying?.sourceType === 'navidrome' || + playerStore.nowPlaying?.sourceType === 'jellyfin' + ); + + async function fetchLyrics() { + const np = playerStore.nowPlaying; + if (!np?.trackSourceId) return; + lyricsLoading = true; + lyricsError = false; + try { + let url: string; + if (np.sourceType === 'navidrome') { + url = API.navidromeLibrary.lyrics(np.trackSourceId, np.artistName, np.trackName); + } else if (np.sourceType === 'jellyfin') { + url = API.jellyfinLibrary.lyrics(np.trackSourceId); + } else { + return; + } + const res = await fetch(url); + if (!res.ok) { + lyricsText = ''; + lyricsLines = []; + lyricsIsSynced = false; + if (res.status !== 404) lyricsError = true; + return; + } + const data = await res.json(); + if (np.sourceType === 'navidrome') { + lyricsText = data.text ?? ''; + lyricsIsSynced = data.is_synced ?? false; + lyricsLines = data.lines ?? []; + } else { + lyricsText = data.lyrics_text ?? ''; + lyricsIsSynced = data.is_synced ?? false; + lyricsLines = data.lines ?? []; + } + } catch { + lyricsText = ''; + lyricsLines = []; + lyricsIsSynced = false; + lyricsError = true; + } finally { + lyricsLoading = false; + } + } + + function toggleLyrics() { + if (lyricsPanelOpen) { + lyricsPanelOpen = false; + return; + } + fetchLyrics(); + lyricsPanelOpen = true; + } function formatTime(seconds: number): string { if (!seconds || isNaN(seconds)) return '0:00'; @@ -123,7 +192,7 @@ {:else} {playerStore.nowPlaying.albumName} {/if} - — + - {#if playerStore.nowPlaying.artistId} {playerStore.nowPlaying.artistName} {/if} + {#if playerStore.nowPlaying.format} + + {/if} {#if playerStore.playbackState === 'error'} -

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} +
+ +
+ {/if} +
Navidrome
+ {:else if playerStore.nowPlaying.sourceType === 'plex'} + {:else if playerStore.nowPlaying.sourceType === 'local'} + + diff --git a/frontend/src/lib/components/PlaylistCard.svelte.spec.ts b/frontend/src/lib/components/PlaylistCard.svelte.spec.ts index d3b2a5a..8408958 100644 --- a/frontend/src/lib/components/PlaylistCard.svelte.spec.ts +++ b/frontend/src/lib/components/PlaylistCard.svelte.spec.ts @@ -11,6 +11,7 @@ const basePlaylist: PlaylistSummary = { total_duration: 1234, cover_urls: ['a.jpg', 'b.jpg'], custom_cover_url: null, + source_ref: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-02T00:00:00Z' }; @@ -52,6 +53,6 @@ describe('PlaylistCard.svelte', () => { const subtitle = page.getByText(/5 tracks/); await expect.element(subtitle).toBeInTheDocument(); const el = await subtitle.element(); - expect(el.textContent).not.toContain('·'); + expect(el.textContent).not.toContain(' - '); }); }); diff --git a/frontend/src/lib/components/PlaylistImportBanner.svelte b/frontend/src/lib/components/PlaylistImportBanner.svelte new file mode 100644 index 0000000..5dfc5a1 --- /dev/null +++ b/frontend/src/lib/components/PlaylistImportBanner.svelte @@ -0,0 +1,167 @@ + + +{#if totalCount > 0} +
+ (isHovered = true)} + onpointerleave={() => (isHovered = false)} + > +
+ + + +
+
+
+ {@render sourceIcon()} + {sourceLabel} +
+ + {#if allImported} +

+ All {totalCount} playlists imported! +

+

+ Your {sourceLabel} playlists are now in MusicSeerr. +

+ {:else} +

+ Bring your {totalCount} + {sourceLabel} playlist{totalCount === 1 ? '' : 's'} to MusicSeerr +

+

+ {#if noneImported} + Browse and import your playlists in one click. + {:else} + {importedCount} of {totalCount} imported so far. + {/if} +

+ {/if} +
+ +
+ + + + +
+ {#if allImported} + + {:else} + + {importedCount}/{totalCount} + + {/if} +
+
+ + +
+
+
+{/if} diff --git a/frontend/src/lib/components/PlexIcon.svelte b/frontend/src/lib/components/PlexIcon.svelte new file mode 100644 index 0000000..4ebf5c4 --- /dev/null +++ b/frontend/src/lib/components/PlexIcon.svelte @@ -0,0 +1,14 @@ + + + diff --git a/frontend/src/lib/components/QueueDrawer.svelte b/frontend/src/lib/components/QueueDrawer.svelte index ce5f7a0..40ca7ff 100644 --- a/frontend/src/lib/components/QueueDrawer.svelte +++ b/frontend/src/lib/components/QueueDrawer.svelte @@ -8,6 +8,7 @@ import JellyfinIcon from '$lib/components/JellyfinIcon.svelte'; import LocalFilesIcon from '$lib/components/LocalFilesIcon.svelte'; import NavidromeIcon from '$lib/components/NavidromeIcon.svelte'; + import PlexIcon from '$lib/components/PlexIcon.svelte'; import NowPlayingIndicator from '$lib/components/NowPlayingIndicator.svelte'; interface Props { @@ -325,6 +326,10 @@ {:else if item.sourceType === 'youtube'} YT + {:else if item.sourceType === 'plex'} + + + {/if} diff --git a/frontend/src/lib/components/QuickStatsBar.svelte b/frontend/src/lib/components/QuickStatsBar.svelte new file mode 100644 index 0000000..ce54254 --- /dev/null +++ b/frontend/src/lib/components/QuickStatsBar.svelte @@ -0,0 +1,45 @@ + + +
+ {#each stats as stat (stat.label)} + {@const Tag = stat.href ? 'a' : 'div'} + + {#if stat.value !== null} +
+ {formatNumber(stat.value)} +
+ {:else} +
+ {/if} +
+ {stat.label} +
+ {#if stat.href} + + {/if} +
+ {/each} +
diff --git a/frontend/src/lib/components/SidebarVisualiser.svelte b/frontend/src/lib/components/SidebarVisualiser.svelte new file mode 100644 index 0000000..894ea32 --- /dev/null +++ b/frontend/src/lib/components/SidebarVisualiser.svelte @@ -0,0 +1,32 @@ + + +{#if nowPlayingMerged.primarySession} + {@const session = nowPlayingMerged.primarySession} + {@const isPaused = session.is_paused} + {@const href = sourceHub[session.source ?? ''] ?? '#'} +
  • + +
    + +
    + + {session.track_name} + +
    +
  • +{/if} diff --git a/frontend/src/lib/components/SourceAlbumCardCompact.svelte b/frontend/src/lib/components/SourceAlbumCardCompact.svelte new file mode 100644 index 0000000..d689514 --- /dev/null +++ b/frontend/src/lib/components/SourceAlbumCardCompact.svelte @@ -0,0 +1,32 @@ + + + diff --git a/frontend/src/lib/components/SourceAlbumModal.svelte b/frontend/src/lib/components/SourceAlbumModal.svelte index cc311a4..b5dfd97 100644 --- a/frontend/src/lib/components/SourceAlbumModal.svelte +++ b/frontend/src/lib/components/SourceAlbumModal.svelte @@ -1,21 +1,25 @@ + +{#if href} + +
    + {#if mbid} + + {:else if imageUrl} + {name} + {:else} +
    + +
    + {/if} +
    +
    +

    {name}

    + {#if albumCount !== undefined} +

    {albumCount} album{albumCount !== 1 ? 's' : ''}

    + {/if} +
    +
    +{:else} +
    +
    + {#if mbid} + + {:else if imageUrl} + {name} + {:else} +
    + +
    + {/if} +
    +
    +

    {name}

    + {#if albumCount !== undefined} +

    {albumCount} album{albumCount !== 1 ? 's' : ''}

    + {/if} +
    +
    +{/if} diff --git a/frontend/src/lib/components/SourceHubHeader.svelte b/frontend/src/lib/components/SourceHubHeader.svelte new file mode 100644 index 0000000..5dd34cc --- /dev/null +++ b/frontend/src/lib/components/SourceHubHeader.svelte @@ -0,0 +1,47 @@ + + +
    +
    + + {@render icon()} + +

    {title}

    + {#if albumCount !== null} + + {albumCount.toLocaleString()} album{albumCount === 1 ? '' : 's'} + + {:else} + + {/if} +
    +
    + {#if onrefresh} + + {/if} + {#if settingsSnippet} + {@render settingsSnippet()} + {/if} +
    +
    diff --git a/frontend/src/lib/components/SourcePickerDropdown.svelte b/frontend/src/lib/components/SourcePickerDropdown.svelte index 2862157..88a9937 100644 --- a/frontend/src/lib/components/SourcePickerDropdown.svelte +++ b/frontend/src/lib/components/SourcePickerDropdown.svelte @@ -5,6 +5,7 @@ import LocalFilesIcon from '$lib/components/LocalFilesIcon.svelte'; import YouTubeIcon from '$lib/components/YouTubeIcon.svelte'; import NavidromeIcon from '$lib/components/NavidromeIcon.svelte'; + import PlexIcon from '$lib/components/PlexIcon.svelte'; interface Props { currentSource: string; @@ -47,6 +48,8 @@ {:else if source === 'youtube'} + {:else if source === 'plex'} + {/if} {/snippet} diff --git a/frontend/src/lib/components/SourcePlaylistCard.svelte b/frontend/src/lib/components/SourcePlaylistCard.svelte new file mode 100644 index 0000000..5770aa3 --- /dev/null +++ b/frontend/src/lib/components/SourcePlaylistCard.svelte @@ -0,0 +1,66 @@ + + + diff --git a/frontend/src/lib/components/SourcePlaylistDetail.svelte b/frontend/src/lib/components/SourcePlaylistDetail.svelte new file mode 100644 index 0000000..4406267 --- /dev/null +++ b/frontend/src/lib/components/SourcePlaylistDetail.svelte @@ -0,0 +1,150 @@ + + +
    +
    + + {@render icon()} +
    + + {#if loading} +
    + +
    + {:else if error} +
    {error}
    + {:else if detail} +
    +
    + {#if detail.cover_url} + {detail.name} + {:else} +
    + +
    + {/if} +
    +
    +

    {detail.name}

    +

    + {detail.track_count} track{detail.track_count === 1 ? '' : 's'} + {#if detail.duration_seconds} + - {formatTotalDurationSec(detail.duration_seconds)} + {/if} +

    + + {#if importResult && !importResult.already_imported} +

    + Imported {importResult.tracks_imported} tracks + {#if importResult.tracks_failed > 0} + ({importResult.tracks_failed} skipped) + {/if} +

    + {/if} +
    +
    + + {#if detail.tracks.length > 0} +
    + + + + + + + + + + + + {#each detail.tracks as track, i (track.id)} + + + + + + + + {/each} + +
    #TitleArtistAlbumDuration
    {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} +
    +
    + {/if} + {/if} +
    diff --git a/frontend/src/lib/components/SourceTrackTable.svelte b/frontend/src/lib/components/SourceTrackTable.svelte new file mode 100644 index 0000000..253b9cf --- /dev/null +++ b/frontend/src/lib/components/SourceTrackTable.svelte @@ -0,0 +1,74 @@ + + +
    + + + + + + + + + + + + + {#each tracks as track, i (track.id)} + + + + + + + + + {/each} + +
    #Title
    {i + 1} +
    {track.title}
    +
    + {track.artist_name} +
    +
    +
    diff --git a/frontend/src/lib/components/settings/SettingsBackendCache.svelte b/frontend/src/lib/components/settings/SettingsBackendCache.svelte index 5ab2fab..f8d43c5 100644 --- a/frontend/src/lib/components/settings/SettingsBackendCache.svelte +++ b/frontend/src/lib/components/settings/SettingsBackendCache.svelte @@ -7,8 +7,8 @@
    Server-side TTLs control API/data cache freshness for all clients. Lower values fetch from - upstream services more often; higher values reduce backend/API load.These settings control how long shared server cache entries stay fresh. Lower values update + sooner. Higher values put less load on upstream services.
    @@ -124,4 +124,36 @@ max={60} unit="min" /> + + + +
    diff --git a/frontend/src/lib/components/settings/SettingsFrontendCache.svelte b/frontend/src/lib/components/settings/SettingsFrontendCache.svelte index 2a2fde3..407dd2f 100644 --- a/frontend/src/lib/components/settings/SettingsFrontendCache.svelte +++ b/frontend/src/lib/components/settings/SettingsFrontendCache.svelte @@ -7,8 +7,8 @@
    Choose how long pages stay fresh in your browser. Lower values update sooner. Higher values - feel faster when you come back.Choose how long pages stay fresh in your browser. Lower values check for updates sooner. Higher + values can make repeat visits feel faster.
    @@ -76,6 +76,14 @@ max={60} unit="min" /> + + import { createSettingsForm } from '$lib/utils/settingsForm.svelte'; + import { onDestroy } from 'svelte'; + import { API } from '$lib/constants'; + import { api } from '$lib/api/client'; + import { resetPlexScrobblePreference } from '$lib/player/plexPlaybackApi'; + import type { PlexConnectionSettings, PlexLibrarySection } from '$lib/types'; + + type PlexTestResult = { valid: boolean; message: string; libraries?: PlexLibrarySection[] }; + type PlexSettingsForm = ReturnType> & { + testResult: PlexTestResult | null; + }; + + const form = createSettingsForm({ + loadEndpoint: API.settingsPlex(), + saveEndpoint: API.settingsPlex(), + testEndpoint: API.settingsPlexVerify(), + enabledField: 'enabled', + refreshIntegration: true, + afterTest: (result) => { + const typed = result as PlexTestResult; + if (typed.valid) { + libraries = typed.libraries ?? []; + if (form.data?.music_library_ids) { + const validKeys = new Set(libraries.map((l) => l.key)); + form.data.music_library_ids = form.data.music_library_ids.filter((id) => + validKeys.has(id) + ); + } + } + } + }) as PlexSettingsForm; + + let showToken = $state(false); + let oauthPending = $state(false); + let oauthUrl = $state(null); + let libraries = $state([]); + let loadingLibraries = $state(false); + + async function save() { + await form.save(); + resetPlexScrobblePreference(); + } + + async function test() { + await form.test(); + } + + async function startOAuth() { + oauthPending = true; + oauthUrl = null; + try { + const res = await api.global.post<{ pin_id: number; pin_code: string; auth_url: string }>( + API.plexAuthPin() + ); + oauthUrl = res.auth_url; + window.open(res.auth_url, '_blank', 'noopener'); + await pollForToken(res.pin_id); + } catch { + oauthPending = false; + } + } + + async function pollForToken(pinId: number) { + const maxAttempts = 60; + for (let i = 0; i < maxAttempts; i++) { + await new Promise((r) => setTimeout(r, 3000)); + if (!oauthPending) return; + try { + const res = await api.global.get<{ completed: boolean; auth_token: string }>( + API.plexAuthPoll(pinId) + ); + if (!oauthPending) return; + if (res.completed && res.auth_token) { + if (form.data) form.data.plex_token = res.auth_token; + oauthPending = false; + oauthUrl = null; + await test(); + return; + } + } catch { + break; + } + } + oauthPending = false; + oauthUrl = null; + } + + function cancelOAuth() { + oauthPending = false; + oauthUrl = null; + } + + async function fetchLibraries() { + loadingLibraries = true; + try { + libraries = await api.global.get(API.settingsPlexLibraries()); + } catch { + libraries = []; + } + loadingLibraries = false; + } + + function toggleLibrary(key: string) { + if (!form.data) return; + const ids = form.data.music_library_ids ?? []; + if (ids.includes(key)) { + form.data.music_library_ids = ids.filter((k) => k !== key); + } else { + form.data.music_library_ids = [...ids, key]; + } + } + + let hasCredentials = $derived(Boolean(form.data?.plex_url && form.data?.plex_token)); + let hasLibrarySelected = $derived(Boolean(form.data?.music_library_ids?.length)); + + $effect(() => { + (async () => { + await form.load(); + if (form.data?.plex_url && form.data?.plex_token) { + await fetchLibraries(); + } + })(); + }); + + onDestroy(() => { + form.cleanup(); + oauthPending = false; + }); + + +
    +
    +

    Plex Connection

    +

    + Connect Plex to browse your library, play music, and keep your listening history in sync. +

    + + {#if form.loading} +
    + +
    + {:else if form.data} +
    +
    + + +
    + +
    + +
    + + +
    +
    + + Sign in with Plex below, or paste a token here. + +
    +
    + +
    + {#if oauthPending} + + Finish signing in to Plex to continue. + {#if oauthUrl} + + Open sign-in page + + {/if} + + {:else} + + {/if} +
    + + {#if hasCredentials || libraries.length > 0 || loadingLibraries} +
    + + {#if loadingLibraries} + + {:else if libraries.length > 0} +
    + {#each libraries as lib (lib.key)} + + {/each} +
    + {:else} +

    + Run a connection test to load your Plex libraries. +

    + {/if} +
    + + Choose the Plex libraries that contain your music. + +
    +
    + {/if} + +
    + +
    + + {#if form.testResult} +
    + {form.testResult.message} +
    + {/if} + +
    + +
    + + {#if form.message} +
    + {form.message} +
    + {/if} + +
    + + +
    +
    + {/if} +
    +
    diff --git a/frontend/src/lib/components/settings/advanced-settings-types.ts b/frontend/src/lib/components/settings/advanced-settings-types.ts index c5ebec7..f3821ba 100644 --- a/frontend/src/lib/components/settings/advanced-settings-types.ts +++ b/frontend/src/lib/components/settings/advanced-settings-types.ts @@ -12,6 +12,10 @@ export interface AdvancedSettingsForm { cache_ttl_jellyfin_favorites: number; cache_ttl_jellyfin_genres: number; cache_ttl_jellyfin_library_stats: number; + cache_ttl_plex_albums: number; + cache_ttl_plex_search: number; + cache_ttl_plex_genres: number; + cache_ttl_plex_stats: number; http_timeout: number; http_connect_timeout: number; http_max_connections: number; @@ -50,6 +54,7 @@ export interface AdvancedSettingsForm { frontend_ttl_search: number; frontend_ttl_local_files_sidebar: number; frontend_ttl_jellyfin_sidebar: number; + frontend_ttl_plex_sidebar: number; frontend_ttl_playlist_sources: number; audiodb_enabled: boolean; audiodb_api_key: string; diff --git a/frontend/src/lib/constants.ts b/frontend/src/lib/constants.ts index 899944a..d9a7a17 100644 --- a/frontend/src/lib/constants.ts +++ b/frontend/src/lib/constants.ts @@ -15,6 +15,8 @@ export const CACHE_KEY_GROUPS = { JELLYFIN_ALBUMS_LIST: 'musicseerr_jellyfin_albums_list', NAVIDROME_SIDEBAR: 'musicseerr_navidrome_sidebar', NAVIDROME_ALBUMS_LIST: 'musicseerr_navidrome_albums_list', + PLEX_SIDEBAR: 'musicseerr_plex_sidebar', + PLEX_ALBUMS_LIST: 'musicseerr_plex_albums_list', LOCAL_FILES_ALBUMS_LIST: 'musicseerr_local_files_albums_list' }, detail: { @@ -66,6 +68,8 @@ export const CACHE_TTL_GROUPS = { JELLYFIN_ALBUMS_LIST: 2 * 60 * 1000, NAVIDROME_SIDEBAR: 2 * 60 * 1000, NAVIDROME_ALBUMS_LIST: 2 * 60 * 1000, + PLEX_SIDEBAR: 2 * 60 * 1000, + PLEX_ALBUMS_LIST: 2 * 60 * 1000, LOCAL_FILES_ALBUMS_LIST: 2 * 60 * 1000, PLAYLIST_SOURCES: 15 * 60 * 1000 }, @@ -214,6 +218,11 @@ export const API = { settingsPrimarySource: () => '/api/v1/settings/primary-source', settingsNavidrome: () => '/api/v1/settings/navidrome', settingsNavidromeVerify: () => '/api/v1/settings/navidrome/verify', + settingsPlex: () => '/api/v1/settings/plex', + settingsPlexVerify: () => '/api/v1/settings/plex/verify', + settingsPlexLibraries: () => '/api/v1/settings/plex/libraries', + plexAuthPin: () => '/api/v1/plex/auth/pin', + plexAuthPoll: (pinId: number) => `/api/v1/plex/auth/poll?pin_id=${pinId}`, settingsLocalFiles: () => '/api/v1/settings/local-files', settingsLocalFilesVerify: () => '/api/v1/settings/local-files/verify', profile: { @@ -247,6 +256,11 @@ export const API = { navidrome: (id: string) => `/api/v1/stream/navidrome/${id}`, navidromeScrobble: (id: string) => `/api/v1/stream/navidrome/${id}/scrobble`, navidromeNowPlaying: (id: string) => `/api/v1/stream/navidrome/${id}/now-playing`, + navidromeStopped: (id: string) => `/api/v1/stream/navidrome/${id}/stopped`, + plex: (partKey: string) => `/api/v1/stream/plex/${partKey}`, + plexScrobble: (ratingKey: string) => `/api/v1/stream/plex/${ratingKey}/scrobble`, + plexNowPlaying: (ratingKey: string) => `/api/v1/stream/plex/${ratingKey}/now-playing`, + plexStopped: (ratingKey: string) => `/api/v1/stream/plex/${ratingKey}/stopped`, local: (trackId: number | string) => `/api/v1/stream/local/${trackId}` }, jellyfinLibrary: { @@ -256,10 +270,16 @@ export const API = { offset = 0, sortBy = 'SortName', genre?: string, - sortOrder = 'Ascending' + sortOrder = 'Ascending', + year?: number, + tags?: string, + studios?: string ) => { let url = `/api/v1/jellyfin/albums?limit=${limit}&offset=${offset}&sort_by=${sortBy}&sort_order=${sortOrder}`; if (genre) url += `&genre=${encodeURIComponent(genre)}`; + if (year) url += `&year=${year}`; + if (tags) url += `&tags=${encodeURIComponent(tags)}`; + if (studios) url += `&studios=${encodeURIComponent(studios)}`; return url; }, albumDetail: (id: string) => `/api/v1/jellyfin/albums/${id}`, @@ -269,7 +289,46 @@ export const API = { recent: () => '/api/v1/jellyfin/recent', favorites: () => '/api/v1/jellyfin/favorites', genres: () => '/api/v1/jellyfin/genres', - stats: () => '/api/v1/jellyfin/stats' + stats: () => '/api/v1/jellyfin/stats', + hub: () => '/api/v1/jellyfin/hub', + recentlyAdded: (limit = 20) => `/api/v1/jellyfin/recently-added?limit=${limit}`, + mostPlayedArtists: (limit = 10) => `/api/v1/jellyfin/most-played/artists?limit=${limit}`, + mostPlayedAlbums: (limit = 10) => `/api/v1/jellyfin/most-played/albums?limit=${limit}`, + playlists: (limit = 50) => `/api/v1/jellyfin/playlists?limit=${limit}`, + playlistDetail: (id: string) => `/api/v1/jellyfin/playlists/${id}`, + playlistImport: (id: string) => `/api/v1/jellyfin/playlists/${id}/import`, + instantMix: (itemId: string, limit = 50) => + `/api/v1/jellyfin/instant-mix/${itemId}?limit=${limit}`, + instantMixByArtist: (artistId: string, limit = 50) => + `/api/v1/jellyfin/instant-mix/artist/${artistId}?limit=${limit}`, + instantMixByGenre: (genre: string, limit = 50) => + `/api/v1/jellyfin/instant-mix/genre?genre=${encodeURIComponent(genre)}&limit=${limit}`, + sessions: () => '/api/v1/jellyfin/sessions', + similar: (itemId: string, limit = 10) => `/api/v1/jellyfin/similar/${itemId}?limit=${limit}`, + lyrics: (itemId: string) => `/api/v1/jellyfin/lyrics/${itemId}`, + favoritesExpanded: (limit = 50) => `/api/v1/jellyfin/favorites/expanded?limit=${limit}`, + filters: () => '/api/v1/jellyfin/filters', + artistsBrowse: ( + limit = 48, + offset = 0, + sortBy = 'SortName', + sortOrder = 'Ascending', + search = '' + ) => { + let url = `/api/v1/jellyfin/artists/browse?limit=${limit}&offset=${offset}&sort_by=${sortBy}&sort_order=${sortOrder}`; + if (search) url += `&search=${encodeURIComponent(search)}`; + return url; + }, + tracks: (limit = 48, offset = 0, sortBy = 'SortName', sortOrder = 'Ascending', search = '') => { + let url = `/api/v1/jellyfin/tracks?limit=${limit}&offset=${offset}&sort_by=${sortBy}&sort_order=${sortOrder}`; + if (search) url += `&search=${encodeURIComponent(search)}`; + return url; + }, + artistsIndex: () => '/api/v1/jellyfin/artists/index', + genreSongs: (genres: string | string[], limit = 50, offset = 0) => { + const g = Array.isArray(genres) ? genres.join('|') : genres; + return `/api/v1/jellyfin/genres/songs?genre=${encodeURIComponent(g)}&limit=${limit}&offset=${offset}`; + } }, navidromeLibrary: { albums: () => '/api/v1/navidrome/albums', @@ -281,7 +340,96 @@ export const API = { favorites: () => '/api/v1/navidrome/favorites', genres: () => '/api/v1/navidrome/genres', stats: () => '/api/v1/navidrome/stats', - albumMatch: (albumId: string) => `/api/v1/navidrome/album-match/${albumId}` + albumMatch: (albumId: string) => `/api/v1/navidrome/album-match/${albumId}`, + hub: () => '/api/v1/navidrome/hub', + favoritesExpanded: () => '/api/v1/navidrome/favorites/expanded', + playlists: (limit = 50) => `/api/v1/navidrome/playlists?limit=${limit}`, + playlistDetail: (id: string) => `/api/v1/navidrome/playlists/${id}`, + playlistImport: (id: string) => `/api/v1/navidrome/playlists/${id}/import`, + random: (size = 20, genre?: string) => { + let url = `/api/v1/navidrome/random?size=${size}`; + if (genre) url += `&genre=${encodeURIComponent(genre)}`; + return url; + }, + nowPlaying: () => '/api/v1/navidrome/now-playing', + topSongs: (artistName: string, count = 20) => + `/api/v1/navidrome/top-songs/${encodeURIComponent(artistName)}?count=${count}`, + similarSongs: (songId: string, count = 20) => + `/api/v1/navidrome/similar-songs/${songId}?count=${count}`, + artistInfo: (artistId: string) => `/api/v1/navidrome/artist-info/${artistId}`, + albumInfo: (albumId: string) => `/api/v1/navidrome/album-info/${albumId}`, + lyrics: (songId: string, artist = '', title = '') => { + let url = `/api/v1/navidrome/lyrics/${songId}`; + const params: string[] = []; + if (artist) params.push(`artist=${encodeURIComponent(artist)}`); + if (title) params.push(`title=${encodeURIComponent(title)}`); + if (params.length) url += `?${params.join('&')}`; + return url; + }, + artistsIndex: () => '/api/v1/navidrome/artists/index', + genreSongs: (genre: string, count = 50, offset = 0) => + `/api/v1/navidrome/genres/${encodeURIComponent(genre)}/songs?count=${count}&offset=${offset}`, + multiGenreSongs: (genres: string[], count = 50, offset = 0) => + `/api/v1/navidrome/genres/songs?genres=${encodeURIComponent(genres.join(','))}&count=${count}&offset=${offset}`, + musicFolders: () => '/api/v1/navidrome/music-folders', + artistsBrowse: (limit = 48, offset = 0, search = '') => { + let url = `/api/v1/navidrome/artists/browse?limit=${limit}&offset=${offset}`; + if (search) url += `&search=${encodeURIComponent(search)}`; + return url; + }, + tracks: (limit = 48, offset = 0, search = '') => { + let url = `/api/v1/navidrome/tracks?limit=${limit}&offset=${offset}`; + if (search) url += `&search=${encodeURIComponent(search)}`; + return url; + } + }, + plexLibrary: { + albums: ( + limit = 48, + offset = 0, + sortBy = 'name', + genre?: string, + sortOrder?: string, + mood?: string, + decade?: string + ) => { + let url = `/api/v1/plex/albums?limit=${limit}&offset=${offset}&sort_by=${sortBy}`; + if (sortOrder) url += `&sort_order=${sortOrder}`; + if (genre) url += `&genre=${encodeURIComponent(genre)}`; + if (mood) url += `&mood=${encodeURIComponent(mood)}`; + if (decade) url += `&decade=${encodeURIComponent(decade)}`; + return url; + }, + albumDetail: (id: string) => `/api/v1/plex/albums/${id}`, + search: (q: string) => `/api/v1/plex/search?q=${encodeURIComponent(q)}`, + recent: (limit = 20) => `/api/v1/plex/recent?limit=${limit}`, + genres: () => '/api/v1/plex/genres', + moods: () => '/api/v1/plex/moods', + stats: () => '/api/v1/plex/stats', + thumb: (ratingKey: string, size = 500) => `/api/v1/plex/thumb/${ratingKey}?size=${size}`, + albumMatch: (albumId: string) => `/api/v1/plex/album-match/${albumId}`, + hub: () => '/api/v1/plex/hub', + recentlyAdded: (limit = 20) => `/api/v1/plex/recently-added?limit=${limit}`, + playlists: (limit = 50) => `/api/v1/plex/playlists?limit=${limit}`, + playlistDetail: (id: string) => `/api/v1/plex/playlists/${id}`, + playlistImport: (id: string) => `/api/v1/plex/playlists/${id}/import`, + discovery: (count = 10) => `/api/v1/plex/discovery?count=${count}`, + sessions: () => '/api/v1/plex/sessions', + history: (limit = 50, offset = 0) => `/api/v1/plex/history?limit=${limit}&offset=${offset}`, + analytics: () => '/api/v1/plex/analytics', + artistsBrowse: (limit = 48, offset = 0, sort = 'titleSort:asc', search = '') => { + let url = `/api/v1/plex/artists/browse?limit=${limit}&offset=${offset}&sort=${encodeURIComponent(sort)}`; + if (search) url += `&search=${encodeURIComponent(search)}`; + return url; + }, + tracks: (limit = 48, offset = 0, sort = 'titleSort:asc', search = '') => { + let url = `/api/v1/plex/tracks?limit=${limit}&offset=${offset}&sort=${encodeURIComponent(sort)}`; + if (search) url += `&search=${encodeURIComponent(search)}`; + return url; + }, + artistsIndex: () => '/api/v1/plex/artists/index', + genreSongs: (genre: string, limit = 50, offset = 0) => + `/api/v1/plex/genres/songs?genre=${encodeURIComponent(genre)}&limit=${limit}&offset=${offset}` }, local: { albumMatch: (mbid: string) => `/api/v1/local/albums/match/${mbid}`, diff --git a/frontend/src/lib/player/NativeAudioSource.ts b/frontend/src/lib/player/NativeAudioSource.ts index b08f835..7525dfd 100644 --- a/frontend/src/lib/player/NativeAudioSource.ts +++ b/frontend/src/lib/player/NativeAudioSource.ts @@ -4,7 +4,7 @@ import { getAudioElement } from './audioElement'; const LOAD_TIMEOUT_MS = 15_000; const STALL_TIMEOUT_MS = 15_000; -type NativeSourceType = 'jellyfin' | 'local' | 'navidrome'; +type NativeSourceType = 'jellyfin' | 'local' | 'navidrome' | 'plex'; export class NativeAudioSource implements PlaybackSource { readonly type: NativeSourceType; diff --git a/frontend/src/lib/player/createSource.ts b/frontend/src/lib/player/createSource.ts index b1e224c..ff986a9 100644 --- a/frontend/src/lib/player/createSource.ts +++ b/frontend/src/lib/player/createSource.ts @@ -21,6 +21,9 @@ export function createPlaybackSource(type: SourceType, opts?: NativeSourceOption case 'navidrome': if (!opts) throw new Error('Navidrome playback source requires url and seekable options'); return new NativeAudioSource('navidrome', opts); + case 'plex': + if (!opts) throw new Error('Plex playback source requires url and seekable options'); + return new NativeAudioSource('plex', opts); default: { const _exhaustive: never = type; throw new Error(`Unknown source type: ${_exhaustive}`); diff --git a/frontend/src/lib/player/launchPlexPlayback.spec.ts b/frontend/src/lib/player/launchPlexPlayback.spec.ts new file mode 100644 index 0000000..45f355e --- /dev/null +++ b/frontend/src/lib/player/launchPlexPlayback.spec.ts @@ -0,0 +1,245 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { PlexTrackInfo } from '$lib/types'; +import type { PlaybackMeta, QueueItem } from '$lib/player/types'; + +vi.mock('$lib/stores/player.svelte', () => ({ + playerStore: { playQueue: vi.fn() } +})); + +vi.mock('$lib/constants', () => ({ + API: { + stream: { + plex: (id: string) => `/api/v1/stream/plex/${id}` + } + } +})); + +vi.mock('$lib/utils/errorHandling', () => ({ + getCoverUrl: (url: string | null, albumId: string) => url || `/api/v1/covers/${albumId}` +})); + +vi.mock('$lib/player/queueHelpers', () => ({ + normalizeCodec: (codec: string | null | undefined) => (codec ? codec.toLowerCase() : null), + normalizeDiscNumber: (disc: number | null | undefined) => disc ?? 1 +})); + +import { playerStore } from '$lib/stores/player.svelte'; +import { launchPlexPlayback } from './launchPlexPlayback'; + +const meta: PlaybackMeta = { + albumId: 'album-1', + albumName: 'Test Album', + artistName: 'Test Artist', + coverUrl: '/cover.jpg', + artistId: 'artist-1' +}; + +describe('launchPlexPlayback', () => { + beforeEach(() => vi.clearAllMocks()); + + it('maps PlexTrackInfo[] to QueueItem[] with sourceType plex', () => { + expect.assertions(6); + const tracks: PlexTrackInfo[] = [ + { + plex_id: 'pk-100', + title: 'Song A', + track_number: 1, + disc_number: 1, + duration_seconds: 180, + album_name: 'Test Album', + artist_name: 'Test Artist', + part_key: '/library/parts/100/1234/file.flac', + codec: 'FLAC', + bitrate: 1411 + } + ]; + + launchPlexPlayback(tracks, 0, false, meta); + + const call = vi.mocked(playerStore.playQueue).mock.calls[0]; + const items: QueueItem[] = call[0]; + + expect(items).toHaveLength(1); + expect(items[0].trackSourceId).toBe('/library/parts/100/1234/file.flac'); + expect(items[0].trackName).toBe('Song A'); + expect(items[0].sourceType).toBe('plex'); + expect(items[0].streamUrl).toBe('/api/v1/stream/plex//library/parts/100/1234/file.flac'); + expect(items[0].plexRatingKey).toBe('pk-100'); + }); + + it('does not start playback when all tracks have null part_key', () => { + expect.assertions(1); + const tracks: PlexTrackInfo[] = [ + { + plex_id: 'pk-200', + title: 'Song B', + track_number: 2, + disc_number: 1, + duration_seconds: 200, + album_name: 'Test Album', + artist_name: 'Test Artist', + part_key: null, + codec: null, + bitrate: null + } + ]; + + launchPlexPlayback(tracks, 0, false, meta); + + expect(vi.mocked(playerStore.playQueue)).not.toHaveBeenCalled(); + }); + + it('sets plexRatingKey on all queue items', () => { + expect.assertions(2); + const tracks: PlexTrackInfo[] = [ + { + plex_id: 'pk-1', + title: 'A', + track_number: 1, + disc_number: 1, + duration_seconds: 100, + album_name: 'Test Album', + artist_name: 'Test Artist', + part_key: '/p/1', + codec: 'mp3', + bitrate: 320 + }, + { + plex_id: 'pk-2', + title: 'B', + track_number: 2, + disc_number: 1, + duration_seconds: 200, + album_name: 'Test Album', + artist_name: 'Test Artist', + part_key: '/p/2', + codec: 'flac', + bitrate: 1411 + } + ]; + + launchPlexPlayback(tracks, 0, false, meta); + + const items: QueueItem[] = vi.mocked(playerStore.playQueue).mock.calls[0][0]; + expect(items[0].plexRatingKey).toBe('pk-1'); + expect(items[1].plexRatingKey).toBe('pk-2'); + }); + + it('adjusts startIndex for filtered streamable tracks', () => { + expect.assertions(2); + const tracks: PlexTrackInfo[] = [ + { + plex_id: 'pk-0', + title: 'No Stream', + track_number: 1, + disc_number: 1, + duration_seconds: 100, + album_name: 'Test Album', + artist_name: 'Test Artist', + part_key: null, + codec: null, + bitrate: null + }, + { + plex_id: 'pk-1', + title: 'A', + track_number: 2, + disc_number: 1, + duration_seconds: 100, + album_name: 'Test Album', + artist_name: 'Test Artist', + part_key: '/p/1', + codec: 'mp3', + bitrate: 320 + }, + { + plex_id: 'pk-2', + title: 'B', + track_number: 3, + disc_number: 1, + duration_seconds: 100, + album_name: 'Test Album', + artist_name: 'Test Artist', + part_key: '/p/2', + codec: 'mp3', + bitrate: 320 + } + ]; + + launchPlexPlayback(tracks, 2, true, meta); + + const call = vi.mocked(playerStore.playQueue).mock.calls[0]; + expect(call[1]).toBe(1); + expect(call[2]).toBe(true); + }); + + it('normalizes codec format', () => { + expect.assertions(1); + const tracks: PlexTrackInfo[] = [ + { + plex_id: 'pk-1', + title: 'A', + track_number: 1, + disc_number: 1, + duration_seconds: 100, + album_name: 'Test Album', + artist_name: 'Test Artist', + part_key: '/p/1', + codec: 'FLAC', + bitrate: 1411 + } + ]; + + launchPlexPlayback(tracks, 0, false, meta); + + const items: QueueItem[] = vi.mocked(playerStore.playQueue).mock.calls[0][0]; + expect(items[0].format).toBe('flac'); + }); + + it('handles null coverUrl in meta', () => { + expect.assertions(1); + const tracks: PlexTrackInfo[] = [ + { + plex_id: 'pk-1', + title: 'A', + track_number: 1, + disc_number: 1, + duration_seconds: 100, + album_name: 'Test Album', + artist_name: 'Test Artist', + part_key: '/p/1', + codec: 'mp3', + bitrate: 320 + } + ]; + const metaWithNullCover: PlaybackMeta = { ...meta, coverUrl: null }; + + launchPlexPlayback(tracks, 0, false, metaWithNullCover); + + const items: QueueItem[] = vi.mocked(playerStore.playQueue).mock.calls[0][0]; + expect(items[0].coverUrl).toBeTruthy(); + }); + + it('sets disc_number from track data', () => { + expect.assertions(1); + const tracks: PlexTrackInfo[] = [ + { + plex_id: 'pk-1', + title: 'A', + track_number: 3, + disc_number: 2, + duration_seconds: 100, + album_name: 'Test Album', + artist_name: 'Test Artist', + part_key: '/p/1', + codec: 'mp3', + bitrate: 320 + } + ]; + + launchPlexPlayback(tracks, 0, false, meta); + + const items: QueueItem[] = vi.mocked(playerStore.playQueue).mock.calls[0][0]; + expect(items[0].discNumber).toBe(2); + }); +}); diff --git a/frontend/src/lib/player/launchPlexPlayback.ts b/frontend/src/lib/player/launchPlexPlayback.ts new file mode 100644 index 0000000..893e817 --- /dev/null +++ b/frontend/src/lib/player/launchPlexPlayback.ts @@ -0,0 +1,46 @@ +import { playerStore } from '$lib/stores/player.svelte'; +import { API } from '$lib/constants'; +import type { PlaybackMeta, QueueItem } from '$lib/player/types'; +import type { PlexTrackInfo } from '$lib/types'; +import { getCoverUrl } from '$lib/utils/errorHandling'; +import { normalizeCodec, normalizeDiscNumber } from '$lib/player/queueHelpers'; + +export function launchPlexPlayback( + tracks: PlexTrackInfo[], + startIndex: number = 0, + shuffle: boolean = false, + meta: PlaybackMeta +): void { + const normalizedCoverUrl = getCoverUrl(meta.coverUrl, meta.albumId); + + const selectedTrack = tracks[startIndex]; + const streamable = tracks.filter((t) => t.part_key); + if (!streamable.length) return; + + let adjustedIndex = 0; + if (selectedTrack?.part_key) { + const found = streamable.indexOf(selectedTrack); + adjustedIndex = found >= 0 ? found : 0; + } + + const items: QueueItem[] = streamable.map((t) => { + const format = normalizeCodec(t.codec); + return { + trackSourceId: t.part_key!, + trackName: t.title, + artistName: meta.artistName, + trackNumber: t.track_number, + discNumber: normalizeDiscNumber(t.disc_number), + albumId: meta.albumId, + albumName: meta.albumName, + coverUrl: normalizedCoverUrl, + sourceType: 'plex' as const, + artistId: meta.artistId, + streamUrl: API.stream.plex(t.part_key!), + format, + plexRatingKey: t.plex_id + }; + }); + + playerStore.playQueue(items, adjustedIndex, shuffle); +} diff --git a/frontend/src/lib/player/navidromePlaybackApi.ts b/frontend/src/lib/player/navidromePlaybackApi.ts index 4967aa4..2f64933 100644 --- a/frontend/src/lib/player/navidromePlaybackApi.ts +++ b/frontend/src/lib/player/navidromePlaybackApi.ts @@ -21,3 +21,12 @@ export async function reportNavidromeNowPlaying(itemId: string): Promise { console.warn(`[Navidrome] now-playing failed: ${detail}`); } } + +export async function reportNavidromeStopped(itemId: string): Promise { + try { + await api.global.post(API.stream.navidromeStopped(itemId)); + } catch (e) { + const detail = e instanceof ApiError ? String(e.status) : 'network error'; + console.warn(`[Navidrome] stopped report failed: ${detail}`); + } +} diff --git a/frontend/src/lib/player/plexPlaybackApi.spec.ts b/frontend/src/lib/player/plexPlaybackApi.spec.ts new file mode 100644 index 0000000..a8f86a7 --- /dev/null +++ b/frontend/src/lib/player/plexPlaybackApi.spec.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockPost = vi.fn(); +const mockGet = vi.fn(); + +vi.mock('$lib/api/client', () => { + class _ApiError extends Error { + status: number; + code: string; + details: unknown; + constructor(status: number, code: string, message: string, details?: unknown) { + super(message); + this.status = status; + this.code = code; + this.details = details; + } + } + return { + api: { + global: { + post: (...args: unknown[]) => mockPost(...args), + get: (...args: unknown[]) => mockGet(...args) + } + }, + ApiError: _ApiError + }; +}); + +vi.mock('$lib/constants', () => ({ + API: { + stream: { + plexScrobble: (key: string) => `/api/v1/stream/plex/${key}/scrobble`, + plexNowPlaying: (key: string) => `/api/v1/stream/plex/${key}/now-playing`, + plexStopped: (key: string) => `/api/v1/stream/plex/${key}/stopped` + }, + settingsPlex: () => '/api/v1/settings/plex' + } +})); + +import { + reportPlexScrobble, + reportPlexNowPlaying, + reportPlexStopped, + isPlexScrobbleEnabled, + resetPlexScrobblePreference +} from './plexPlaybackApi'; + +describe('plexPlaybackApi', () => { + beforeEach(() => { + vi.resetAllMocks(); + resetPlexScrobblePreference(); + }); + + describe('reportPlexScrobble', () => { + it('calls scrobble endpoint when enabled', async () => { + expect.assertions(2); + mockGet.mockResolvedValueOnce({ scrobble_to_plex: true }); + mockPost.mockResolvedValueOnce(undefined); + + await reportPlexScrobble('12345'); + + expect(mockPost).toHaveBeenCalledWith('/api/v1/stream/plex/12345/scrobble'); + expect(mockGet).toHaveBeenCalledWith('/api/v1/settings/plex'); + }); + + it('does not call scrobble when disabled', async () => { + expect.assertions(1); + mockGet.mockResolvedValueOnce({ scrobble_to_plex: false }); + + await reportPlexScrobble('12345'); + + expect(mockPost).not.toHaveBeenCalled(); + }); + + it('warns on error without throwing', async () => { + expect.assertions(1); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + mockGet.mockResolvedValueOnce({ scrobble_to_plex: true }); + mockPost.mockRejectedValueOnce(new Error('Network down')); + + await reportPlexScrobble('12345'); + + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it('defaults to disabled when settings fetch fails', async () => { + expect.assertions(1); + mockGet.mockRejectedValueOnce(new Error('fetch failed')); + + await reportPlexScrobble('12345'); + + expect(mockPost).not.toHaveBeenCalled(); + }); + }); + + describe('reportPlexNowPlaying', () => { + it('calls now-playing endpoint unconditionally', async () => { + expect.assertions(1); + mockPost.mockResolvedValueOnce(undefined); + + await reportPlexNowPlaying('67890'); + + expect(mockPost).toHaveBeenCalledWith('/api/v1/stream/plex/67890/now-playing'); + }); + + it('warns on error without throwing', async () => { + expect.assertions(1); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + mockPost.mockRejectedValueOnce(new Error('timeout')); + + await reportPlexNowPlaying('67890'); + + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + }); + + describe('isPlexScrobbleEnabled', () => { + it('defaults to false before any load', () => { + expect.assertions(1); + expect(isPlexScrobbleEnabled()).toBe(false); + }); + + it('reflects loaded preference', async () => { + expect.assertions(1); + mockGet.mockResolvedValueOnce({ scrobble_to_plex: false }); + mockPost.mockResolvedValueOnce(undefined); + + await reportPlexScrobble('test'); + + expect(isPlexScrobbleEnabled()).toBe(false); + }); + }); + + describe('resetPlexScrobblePreference', () => { + it('resets cache so next call re-fetches', async () => { + expect.assertions(2); + mockGet.mockResolvedValueOnce({ scrobble_to_plex: false }); + await reportPlexScrobble('test'); + + resetPlexScrobblePreference(); + + mockGet.mockResolvedValueOnce({ scrobble_to_plex: true }); + mockPost.mockResolvedValueOnce(undefined); + await reportPlexScrobble('test2'); + + expect(mockGet).toHaveBeenCalledTimes(2); + expect(mockPost).toHaveBeenCalledTimes(1); + }); + }); + + describe('preference caching', () => { + it('fetches only once across multiple scrobble calls', async () => { + expect.assertions(1); + mockGet.mockResolvedValueOnce({ scrobble_to_plex: true }); + mockPost.mockResolvedValue(undefined); + + await reportPlexScrobble('a'); + await reportPlexScrobble('b'); + + expect(mockGet).toHaveBeenCalledTimes(1); + }); + }); + + describe('reportPlexStopped', () => { + it('calls stopped endpoint', async () => { + expect.assertions(1); + mockPost.mockResolvedValueOnce(undefined); + + await reportPlexStopped('99999'); + + expect(mockPost).toHaveBeenCalledWith('/api/v1/stream/plex/99999/stopped'); + }); + + it('warns on error without throwing', async () => { + expect.assertions(1); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + mockPost.mockRejectedValueOnce(new Error('fail')); + + await reportPlexStopped('99999'); + + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + }); +}); diff --git a/frontend/src/lib/player/plexPlaybackApi.ts b/frontend/src/lib/player/plexPlaybackApi.ts new file mode 100644 index 0000000..5c9124c --- /dev/null +++ b/frontend/src/lib/player/plexPlaybackApi.ts @@ -0,0 +1,52 @@ +import { API } from '$lib/constants'; +import { api, ApiError } from '$lib/api/client'; +import type { PlexConnectionSettings } from '$lib/types'; + +let scrobbleEnabled: boolean | null = null; + +async function loadScrobblePreference(): Promise { + if (scrobbleEnabled !== null) return scrobbleEnabled; + try { + const settings = await api.global.get(API.settingsPlex()); + scrobbleEnabled = settings.scrobble_to_plex ?? false; + } catch { + return false; + } + return scrobbleEnabled; +} + +export function isPlexScrobbleEnabled(): boolean { + return scrobbleEnabled ?? false; +} + +export function resetPlexScrobblePreference(): void { + scrobbleEnabled = null; +} + +export async function reportPlexScrobble(ratingKey: string): Promise { + if (!(await loadScrobblePreference())) return; + try { + await api.global.post(API.stream.plexScrobble(ratingKey)); + } catch (e) { + const detail = e instanceof ApiError ? String(e.status) : 'network error'; + console.warn(`[Plex] scrobble failed: ${detail}`); + } +} + +export async function reportPlexNowPlaying(ratingKey: string): Promise { + try { + await api.global.post(API.stream.plexNowPlaying(ratingKey)); + } catch (e) { + const detail = e instanceof ApiError ? String(e.status) : 'network error'; + console.warn(`[Plex] now-playing failed: ${detail}`); + } +} + +export async function reportPlexStopped(ratingKey: string): Promise { + try { + await api.global.post(API.stream.plexStopped(ratingKey)); + } catch (e) { + const detail = e instanceof ApiError ? String(e.status) : 'network error'; + console.warn(`[Plex] stopped report failed: ${detail}`); + } +} diff --git a/frontend/src/lib/player/queueHelpers.spec.ts b/frontend/src/lib/player/queueHelpers.spec.ts index 5d37f5d..0b93963 100644 --- a/frontend/src/lib/player/queueHelpers.spec.ts +++ b/frontend/src/lib/player/queueHelpers.spec.ts @@ -331,7 +331,8 @@ describe('playlistTrackToQueueItem', () => { track_number: 1, disc_number: 2, duration: 240, - created_at: '2026-01-01T00:00:00Z' + created_at: '2026-01-01T00:00:00Z', + plex_rating_key: null }; it('maps local track to QueueItem with correct streamUrl', () => { diff --git a/frontend/src/lib/player/queueHelpers.ts b/frontend/src/lib/player/queueHelpers.ts index 4cebbee..41e667d 100644 --- a/frontend/src/lib/player/queueHelpers.ts +++ b/frontend/src/lib/player/queueHelpers.ts @@ -3,6 +3,7 @@ import type { JellyfinTrackInfo, LocalTrackInfo, NavidromeTrackInfo, + PlexTrackInfo, YouTubeTrackLink } from '$lib/types'; import type { PlaylistTrack } from '$lib/api/playlists'; @@ -35,6 +36,7 @@ export interface TrackSourceData { jellyfinTrack?: JellyfinTrackInfo | null; navidromeTrack?: NavidromeTrackInfo | null; localTrack?: LocalTrackInfo | null; + plexTrack?: PlexTrackInfo | null; } export function normalizeDiscNumber(discNumber: number | null | undefined): number { @@ -90,6 +92,16 @@ export function selectBestSource( format }; } + if (data.plexTrack) { + if (!data.plexTrack.part_key) return null; + const format = normalizeCodec(data.plexTrack.codec); + return { + sourceType: 'plex', + trackSourceId: data.plexTrack.part_key, + streamUrl: API.stream.plex(data.plexTrack.part_key), + format + }; + } return null; } @@ -98,6 +110,7 @@ export function getAvailableSources(data: TrackSourceData): SourceType[] { if (data.localTrack) sources.push('local'); if (data.navidromeTrack) sources.push('navidrome'); if (data.jellyfinTrack) sources.push('jellyfin'); + if (data.plexTrack?.part_key) sources.push('plex'); return sources; } @@ -111,6 +124,7 @@ export function buildQueueItem(meta: TrackMeta, data: TrackSourceData): QueueIte if (data.localTrack) sourceIds.local = String(data.localTrack.track_file_id); if (data.navidromeTrack) sourceIds.navidrome = data.navidromeTrack.navidrome_id; if (data.jellyfinTrack) sourceIds.jellyfin = data.jellyfinTrack.jellyfin_id; + if (data.plexTrack?.part_key) sourceIds.plex = data.plexTrack.part_key; return { trackSourceId: best.trackSourceId, @@ -127,7 +141,8 @@ export function buildQueueItem(meta: TrackMeta, data: TrackSourceData): QueueIte format: best.format, availableSources: getAvailableSources(data), sourceIds, - duration: data.trackLength + duration: data.trackLength, + plexRatingKey: data.plexTrack?.plex_id }; } @@ -203,6 +218,32 @@ export function buildQueueItemsFromLocal(tracks: LocalTrackInfo[], meta: TrackMe })); } +export function buildQueueItemsFromPlex(tracks: PlexTrackInfo[], meta: TrackMeta): QueueItem[] { + const normalizedCoverUrl = getCoverUrl(meta.coverUrl, meta.albumId); + return tracks + .filter((t) => t.part_key) + .map((t) => { + const format = normalizeCodec(t.codec); + return { + trackSourceId: t.part_key!, + trackName: t.title, + artistName: meta.artistName, + trackNumber: t.track_number, + discNumber: normalizeDiscNumber(t.disc_number), + albumId: meta.albumId, + albumName: meta.albumName, + coverUrl: normalizedCoverUrl, + sourceType: 'plex' as const, + artistId: meta.artistId, + streamUrl: API.stream.plex(t.part_key!), + format, + availableSources: ['plex'] as SourceType[], + duration: t.duration_seconds, + plexRatingKey: t.plex_id + }; + }); +} + export function buildQueueItemFromYouTube(track: YouTubeTrackLink, meta: TrackMeta): QueueItem { const normalizedCoverUrl = getCoverUrl(meta.coverUrl, meta.albumId); return { @@ -231,6 +272,7 @@ function resolveStreamUrl(sourceType: string, trackSourceId: string): string | u if (sourceType === 'local') return API.stream.local(trackSourceId); if (sourceType === 'navidrome') return API.stream.navidrome(trackSourceId); if (sourceType === 'jellyfin') return API.stream.jellyfin(trackSourceId); + if (sourceType === 'plex') return API.stream.plex(trackSourceId); return undefined; } @@ -257,6 +299,63 @@ export function playlistTrackToQueueItem(track: PlaylistTrack): QueueItem | null format: track.format ?? undefined, availableSources, duration: track.duration ?? undefined, - playlistTrackId: track.id + playlistTrackId: track.id, + plexRatingKey: sourceType === 'plex' ? (track.plex_rating_key ?? undefined) : undefined }; } + +export function buildDiscoveryQueueFromNavidrome(tracks: NavidromeTrackInfo[]): QueueItem[] { + return tracks.map((t) => ({ + trackSourceId: t.navidrome_id, + trackName: t.title, + artistName: t.artist_name, + trackNumber: t.track_number, + discNumber: normalizeDiscNumber(t.disc_number), + albumId: '', + albumName: t.album_name, + coverUrl: t.image_url ?? null, + sourceType: 'navidrome' as const, + streamUrl: API.stream.navidrome(t.navidrome_id), + format: normalizeCodec(t.codec), + availableSources: ['navidrome'] as SourceType[], + duration: t.duration_seconds + })); +} + +export function buildDiscoveryQueueFromJellyfin(tracks: JellyfinTrackInfo[]): QueueItem[] { + return tracks.map((t) => ({ + trackSourceId: t.jellyfin_id, + trackName: t.title, + artistName: t.artist_name, + trackNumber: t.track_number, + discNumber: normalizeDiscNumber(t.disc_number), + albumId: t.album_id ?? '', + albumName: t.album_name, + coverUrl: t.image_url ?? null, + sourceType: 'jellyfin' as const, + streamUrl: API.stream.jellyfin(t.jellyfin_id), + format: normalizeCodec(t.codec), + availableSources: ['jellyfin'] as SourceType[], + duration: t.duration_seconds + })); +} + +export function buildDiscoveryQueueFromPlex(tracks: PlexTrackInfo[]): QueueItem[] { + return tracks + .filter((t) => t.part_key) + .map((t) => ({ + trackSourceId: t.part_key!, + trackName: t.title, + artistName: t.artist_name, + trackNumber: t.track_number, + discNumber: normalizeDiscNumber(t.disc_number), + albumId: '', + albumName: t.album_name, + coverUrl: t.image_url ?? null, + sourceType: 'plex' as const, + streamUrl: API.stream.plex(t.part_key!), + format: normalizeCodec(t.codec), + availableSources: ['plex'] as SourceType[], + duration: t.duration_seconds + })); +} diff --git a/frontend/src/lib/player/types.ts b/frontend/src/lib/player/types.ts index 3507ce0..d45bb9a 100644 --- a/frontend/src/lib/player/types.ts +++ b/frontend/src/lib/player/types.ts @@ -7,7 +7,7 @@ export type PlaybackState = | 'buffering' | 'error'; -export type SourceType = 'youtube' | 'local' | 'jellyfin' | 'navidrome'; +export type SourceType = 'youtube' | 'local' | 'jellyfin' | 'navidrome' | 'plex'; export type QueueOrigin = 'context' | 'manual'; @@ -61,7 +61,7 @@ export type PlaybackMeta = { }; export interface QueueItem { - /** Source-specific item identifier (Jellyfin item ID / local file ID / YouTube video ID) */ + /** Source-specific item identifier (Jellyfin item ID, local file ID, or YouTube video ID). */ trackSourceId: string; trackName: string; artistName: string; @@ -78,7 +78,9 @@ export interface QueueItem { sourceIds?: Partial>; duration?: number; playSessionId?: string; + /** Plex ratingKey used for scrobble and now-playing calls. Streaming uses part_key in trackSourceId. */ + plexRatingKey?: string; queueOrigin?: QueueOrigin; - /** Stable playlist-level track identifier — survives source changes */ + /** Stable playlist-level track identifier that survives source changes. */ playlistTrackId?: string; } diff --git a/frontend/src/lib/stores/cacheTtl.ts b/frontend/src/lib/stores/cacheTtl.ts index 62378da..3a2a53c 100644 --- a/frontend/src/lib/stores/cacheTtl.ts +++ b/frontend/src/lib/stores/cacheTtl.ts @@ -7,6 +7,10 @@ import { updateDiscoveryCacheTTL } from '$lib/stores/discoveryCache'; import { updateDiscoverQueueCacheTTL } from '$lib/utils/discoverQueueCache'; import { updateSearchCacheTTL } from '$lib/stores/search'; import { updateJellyfinSidebarCacheTTL } from '$lib/utils/jellyfinLibraryCache'; +import { + updatePlexSidebarCacheTTL, + updatePlexAlbumsListCacheTTL +} from '$lib/utils/plexLibraryCache'; import { updateLocalFilesSidebarCacheTTL } from '$lib/utils/localFilesCache'; import { libraryStore } from '$lib/stores/library'; import { recentlyAddedStore } from '$lib/stores/recentlyAdded'; @@ -20,6 +24,7 @@ export interface CacheTTLs { search: number; localFilesSidebar: number; jellyfinSidebar: number; + plexSidebar: number; playlistSources: number; discoverQueuePollingInterval: number; discoverQueueAutoGenerate: boolean; @@ -34,6 +39,7 @@ const DEFAULTS: CacheTTLs = { search: CACHE_TTL.SEARCH, localFilesSidebar: CACHE_TTL.LOCAL_FILES_SIDEBAR, jellyfinSidebar: CACHE_TTL.JELLYFIN_SIDEBAR, + plexSidebar: CACHE_TTL.PLEX_SIDEBAR, playlistSources: CACHE_TTL.PLAYLIST_SOURCES, discoverQueuePollingInterval: 4000, discoverQueueAutoGenerate: true @@ -52,6 +58,8 @@ function applyTTLs(ttls: CacheTTLs): void { updateSearchCacheTTL(ttls.search); updateLocalFilesSidebarCacheTTL(ttls.localFilesSidebar); updateJellyfinSidebarCacheTTL(ttls.jellyfinSidebar); + updatePlexSidebarCacheTTL(ttls.plexSidebar); + updatePlexAlbumsListCacheTTL(ttls.plexSidebar); } export async function initCacheTTLs(): Promise { @@ -69,6 +77,7 @@ export async function initCacheTTLs(): Promise { search: (data.search as number) ?? DEFAULTS.search, localFilesSidebar: (data.local_files_sidebar as number) ?? DEFAULTS.localFilesSidebar, jellyfinSidebar: (data.jellyfin_sidebar as number) ?? DEFAULTS.jellyfinSidebar, + plexSidebar: (data.plex_sidebar as number) ?? DEFAULTS.plexSidebar, playlistSources: (data.playlist_sources as number) ?? DEFAULTS.playlistSources, discoverQueuePollingInterval: (data.discover_queue_polling_interval as number) ?? DEFAULTS.discoverQueuePollingInterval, @@ -76,8 +85,9 @@ export async function initCacheTTLs(): Promise { (data.discover_queue_auto_generate as boolean) ?? DEFAULTS.discoverQueueAutoGenerate }; applyTTLs(resolved); - } catch (e) { - console.warn('[cacheTtl] Failed to load cache TTL settings, using defaults', e); + } catch { + resolved = { ...DEFAULTS }; + applyTTLs(resolved); } } diff --git a/frontend/src/lib/stores/integration.ts b/frontend/src/lib/stores/integration.ts index 8e4da2b..ee63640 100644 --- a/frontend/src/lib/stores/integration.ts +++ b/frontend/src/lib/stores/integration.ts @@ -6,6 +6,7 @@ interface IntegrationStatus { lidarr: boolean; jellyfin: boolean; navidrome: boolean; + plex: boolean; listenbrainz: boolean; youtube: boolean; youtube_api: boolean; @@ -19,6 +20,7 @@ function createIntegrationStore() { lidarr: false, jellyfin: false, navidrome: false, + plex: false, listenbrainz: false, youtube: false, youtube_api: false, @@ -41,6 +43,7 @@ function createIntegrationStore() { lidarr: false, jellyfin: false, navidrome: false, + plex: false, listenbrainz: false, youtube: false, youtube_api: false, diff --git a/frontend/src/lib/stores/nowPlayingMerged.svelte.ts b/frontend/src/lib/stores/nowPlayingMerged.svelte.ts new file mode 100644 index 0000000..90be468 --- /dev/null +++ b/frontend/src/lib/stores/nowPlayingMerged.svelte.ts @@ -0,0 +1,133 @@ +import { nowPlayingStore } from '$lib/stores/nowPlayingSessions.svelte'; +import { playerStore } from '$lib/stores/player.svelte'; +import type { NowPlayingSession } from '$lib/types'; +import { SvelteMap } from 'svelte/reactivity'; + +type SourceKey = 'jellyfin' | 'navidrome' | 'plex'; + +const GRACE_MS: Record = { + jellyfin: 30_000, + navidrome: 180_000, + plex: 30_000 +}; + +type OwnedEntry = { track: string; idleSince: number }; + +function buildLocalSession(): NowPlayingSession | null { + const state = playerStore.playbackState; + if (state === 'idle' || state === 'error') return null; + const np = playerStore.nowPlaying; + if (!np) return null; + + const src = np.sourceType; + if (src !== 'jellyfin' && src !== 'navidrome' && src !== 'plex') return null; + + return { + id: `local-${src}-${np.trackSourceId ?? np.albumId}`, + user_name: '', + track_name: np.trackName ?? '', + artist_name: np.artistName, + album_name: np.albumName, + cover_url: np.coverUrl ?? '', + device_name: 'MusicSeerr', + is_paused: state === 'paused' || state === 'buffering' || state === 'loading', + source: src, + progress_ms: playerStore.progress * 1000, + duration_ms: playerStore.duration * 1000, + _isLocal: true + }; +} + +const ownedSessions = new SvelteMap(); +let currentOwnedSource: SourceKey | null = null; + +function createMergedStore() { + const mergedSessions = $derived.by(() => { + const local = buildLocalSession(); + const server = nowPlayingStore.sessions; + + if (local) { + const src = local.source as SourceKey; + if (currentOwnedSource && currentOwnedSource !== src) { + const prev = ownedSessions.get(currentOwnedSource); + if (prev && !prev.idleSince) { + ownedSessions.set(currentOwnedSource, { ...prev, idleSince: Date.now() }); + } + } + currentOwnedSource = src; + ownedSessions.set(src, { track: local.track_name, idleSince: 0 }); + } else if (currentOwnedSource) { + const entry = ownedSessions.get(currentOwnedSource); + if (entry && !entry.idleSince) { + ownedSessions.set(currentOwnedSource, { ...entry, idleSince: Date.now() }); + } + currentOwnedSource = null; + } + + const now = Date.now(); + + for (const [src, entry] of ownedSessions) { + if (entry.idleSince && now - entry.idleSince >= GRACE_MS[src]) { + ownedSessions.delete(src); + } + } + + if (!local) { + const hasGraceEntries = ownedSessions.size > 0; + if (hasGraceEntries) { + return server.filter((s) => { + const src = s.source as SourceKey; + const owned = ownedSessions.get(src); + if (owned && owned.idleSince && s.track_name === owned.track) return false; + return true; + }); + } + return server; + } + + const localSource = local.source as SourceKey; + const filtered = server.filter((s) => { + if (s.source === localSource && s.track_name === local.track_name) return false; + const src = s.source as SourceKey; + const owned = ownedSessions.get(src); + if (owned && owned.idleSince && s.track_name === owned.track) return false; + return true; + }); + return [local, ...filtered]; + }); + + const activeSessions = $derived(mergedSessions.filter((s) => !s.is_paused)); + const primarySession = $derived(activeSessions[0] ?? mergedSessions[0] ?? null); + + function isSourcePlaying(source: SourceKey): boolean { + return mergedSessions.some((s) => s.source === source && !s.is_paused); + } + + function sourceHasSessions(source: SourceKey): boolean { + return mergedSessions.some((s) => s.source === source); + } + + function sessionsForSource(source: SourceKey): NowPlayingSession[] { + return mergedSessions.filter((s) => s.source === source); + } + + return { + get sessions() { + return mergedSessions; + }, + get activeSessions() { + return activeSessions; + }, + get primarySession() { + return primarySession; + }, + isSourcePlaying, + sourceHasSessions, + sessionsForSource, + start: nowPlayingStore.start, + stop: nowPlayingStore.stop, + refresh: nowPlayingStore.refresh + }; +} + +export const nowPlayingMerged = createMergedStore(); diff --git a/frontend/src/lib/stores/nowPlayingSessions.svelte.ts b/frontend/src/lib/stores/nowPlayingSessions.svelte.ts new file mode 100644 index 0000000..acd2261 --- /dev/null +++ b/frontend/src/lib/stores/nowPlayingSessions.svelte.ts @@ -0,0 +1,281 @@ +import { API } from '$lib/constants'; +import { integrationStore } from '$lib/stores/integration'; +import { get } from 'svelte/store'; +import { SvelteMap, SvelteSet } from 'svelte/reactivity'; +import type { + NowPlayingSession, + JellyfinSessionInfo, + JellyfinSessionsResponse, + NavidromeNowPlayingEntry, + NavidromeNowPlayingResponse, + PlexSessionInfo, + PlexSessionsResponse +} from '$lib/types'; + +const POLL_INTERVAL_MS = 3_000; +const STALE_SESSION_EXPIRY_MS = 30_000; +const STALE_PROGRESS_THRESHOLD_MS = POLL_INTERVAL_MS * 2.5; +const MAX_INTERPOLATION_ADVANCE_MS = POLL_INTERVAL_MS * 3; +const FROZEN_BASIS_MS = 15_000; + +type SourceKey = 'jellyfin' | 'navidrome' | 'plex'; +type InterpolationBasis = { serverProgress: number; updatedAt: number }; + +function jellyfinToSession(s: JellyfinSessionInfo): NowPlayingSession { + return { + id: s.session_id, + user_name: s.user_name, + track_name: s.track_name, + artist_name: s.artist_name, + album_name: s.album_name, + cover_url: s.cover_url, + device_name: s.device_name, + is_paused: s.is_paused, + source: 'jellyfin', + progress_ms: s.position_seconds * 1000, + duration_ms: s.duration_seconds * 1000, + audio_codec: s.audio_codec, + bitrate: s.bitrate + }; +} + +function navidromeToSession(e: NavidromeNowPlayingEntry): NowPlayingSession { + const progressMs = + e.estimated_position_seconds != null && e.estimated_position_seconds > 0 + ? e.estimated_position_seconds * 1000 + : e.minutes_ago > 0 + ? Math.max(0, e.duration_seconds * 1000 - e.minutes_ago * 60_000) + : 0; + return { + id: `${e.user_name}-${e.player_name}-${e.album_id}-${e.track_name}`, + user_name: e.user_name, + track_name: e.track_name, + artist_name: e.artist_name, + album_name: e.album_name, + cover_url: e.cover_art_id ? `/api/v1/navidrome/cover/${e.cover_art_id}` : '', + device_name: e.player_name, + is_paused: false, + source: 'navidrome', + progress_ms: progressMs, + duration_ms: e.duration_seconds * 1000 + }; +} + +function plexToSession(s: PlexSessionInfo): NowPlayingSession { + return { + id: s.session_id, + user_name: s.user_name, + track_name: s.track_title, + artist_name: s.artist_name, + album_name: s.album_name, + cover_url: s.cover_url, + device_name: s.player_device, + is_paused: s.player_state === 'paused', + source: 'plex', + progress_ms: s.progress_ms, + duration_ms: s.duration_ms, + audio_codec: s.audio_codec, + bitrate: s.bitrate + }; +} + +const FETCH_FAILED = Symbol('fetch_failed'); + +function createNowPlayingStore() { + let sessions = $state([]); + let pollTimer: ReturnType | undefined; + let tickTimer: ReturnType | undefined; + let running = false; + + const lastGoodSessions = new SvelteMap(); + const interpBasis = new SvelteMap(); + + const activeSessions = $derived(sessions.filter((s) => !s.is_paused)); + const primarySession = $derived(activeSessions[0] ?? sessions[0] ?? null); + + async function fetchSource( + url: string, + mapper: (data: T) => NowPlayingSession[], + source: SourceKey + ): Promise { + try { + const r = await fetch(url); + if (!r.ok) return FETCH_FAILED; + const data: T = await r.json(); + const mapped = mapper(data); + lastGoodSessions.set(source, mapped); + return mapped; + } catch { + return FETCH_FAILED; + } + } + + async function fetchAll() { + if (typeof document !== 'undefined' && document.hidden) return; + + const integrations = get(integrationStore); + const fetches: Promise<{ + source: SourceKey; + result: NowPlayingSession[] | typeof FETCH_FAILED; + }>[] = []; + + if (integrations.jellyfin) { + fetches.push( + fetchSource( + API.jellyfinLibrary.sessions(), + (d) => (d?.sessions ?? []).map(jellyfinToSession), + 'jellyfin' + ).then((result) => ({ source: 'jellyfin' as SourceKey, result })) + ); + } + if (integrations.navidrome) { + fetches.push( + fetchSource( + API.navidromeLibrary.nowPlaying(), + (d) => (d?.entries ?? []).map(navidromeToSession), + 'navidrome' + ).then((result) => ({ source: 'navidrome' as SourceKey, result })) + ); + } + if (integrations.plex) { + fetches.push( + fetchSource( + API.plexLibrary.sessions(), + (d) => (d?.sessions ?? []).map(plexToSession), + 'plex' + ).then((result) => ({ source: 'plex' as SourceKey, result })) + ); + } + + if (fetches.length === 0) { + sessions = []; + return; + } + + const results = await Promise.all(fetches); + const now = Date.now(); + const incoming: NowPlayingSession[] = []; + + for (const { source, result } of results) { + if (result === FETCH_FAILED) { + const stale = lastGoodSessions.get(source); + if (stale && stale.length > 0) { + const basis = interpBasis.get(stale[0].id); + if (!basis || now - basis.updatedAt < STALE_SESSION_EXPIRY_MS) { + incoming.push(...stale); + } + } + } else { + incoming.push(...result); + } + } + + const newIds = new SvelteSet(); + for (const s of incoming) { + newIds.add(s.id); + const prev = interpBasis.get(s.id); + if (s.progress_ms != null) { + if (!prev || prev.serverProgress !== s.progress_ms) { + interpBasis.set(s.id, { serverProgress: s.progress_ms, updatedAt: now }); + } + } + } + for (const key of interpBasis.keys()) { + if (!newIds.has(key)) interpBasis.delete(key); + } + + for (const s of incoming) { + if (s.is_paused) continue; + const basis = interpBasis.get(s.id); + if (basis && now - basis.updatedAt > STALE_PROGRESS_THRESHOLD_MS) { + s.is_paused = true; + } + } + + sessions = incoming; + } + + function tick() { + if (typeof document !== 'undefined' && document.hidden) return; + const now = Date.now(); + const updated = sessions.map((s) => { + if (s.is_paused || !s.duration_ms || s.progress_ms == null) return s; + const basis = interpBasis.get(s.id); + if (!basis) { + const next = Math.min(s.progress_ms + 1000, s.duration_ms); + return next === s.progress_ms ? s : { ...s, progress_ms: next }; + } + const basisAge = now - basis.updatedAt; + if (basisAge > FROZEN_BASIS_MS) return s; + const elapsed = Math.min(basisAge, MAX_INTERPOLATION_ADVANCE_MS); + const interpolated = Math.min(basis.serverProgress + elapsed, s.duration_ms); + if (interpolated === s.progress_ms) return s; + return { ...s, progress_ms: interpolated }; + }); + sessions = updated; + } + + function start() { + if (running) return; + running = true; + fetchAll(); + pollTimer = setInterval(fetchAll, POLL_INTERVAL_MS); + tickTimer = setInterval(tick, 1000); + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', onVisibility); + } + } + + function stop() { + running = false; + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = undefined; + } + if (tickTimer) { + clearInterval(tickTimer); + tickTimer = undefined; + } + if (typeof document !== 'undefined') { + document.removeEventListener('visibilitychange', onVisibility); + } + } + + function onVisibility() { + if (!document.hidden && running) { + fetchAll(); + } + } + + function isSourcePlaying(source: SourceKey): boolean { + return sessions.some((s) => s.source === source && !s.is_paused); + } + + function sourceHasSessions(source: SourceKey): boolean { + return sessions.some((s) => s.source === source); + } + + function sessionsForSource(source: SourceKey): NowPlayingSession[] { + return sessions.filter((s) => s.source === source); + } + + return { + get sessions() { + return sessions; + }, + get activeSessions() { + return activeSessions; + }, + get primarySession() { + return primarySession; + }, + start, + stop, + refresh: fetchAll, + isSourcePlaying, + sourceHasSessions, + sessionsForSource + }; +} + +export const nowPlayingStore = createNowPlayingStore(); diff --git a/frontend/src/lib/stores/player.svelte.ts b/frontend/src/lib/stores/player.svelte.ts index ada8bac..237aec6 100644 --- a/frontend/src/lib/stores/player.svelte.ts +++ b/frontend/src/lib/stores/player.svelte.ts @@ -15,8 +15,14 @@ import { } from '$lib/player/jellyfinPlaybackApi'; import { reportNavidromeScrobble, - reportNavidromeNowPlaying + reportNavidromeNowPlaying, + reportNavidromeStopped } from '$lib/player/navidromePlaybackApi'; +import { + reportPlexScrobble, + reportPlexNowPlaying, + reportPlexStopped +} from '$lib/player/plexPlaybackApi'; import { playbackToast } from '$lib/stores/playbackToast.svelte'; import { getStoredVolume, @@ -118,7 +124,8 @@ function createPlayerStore() { const handleBeforeUnload = createBeforeUnloadHandler( () => ({ jellyfinItem: getJellyfinItem(), currentItem: queue[currentIndex] ?? null, progress }), API.stream.jellyfinStop, - API.stream.navidromeScrobble + API.stream.navidromeScrobble, + API.stream.plexScrobble ); function getNextIndex(): number | null { @@ -131,6 +138,9 @@ function createPlayerStore() { const item = queue[currentIndex]; return item?.sourceType === 'jellyfin' ? item : null; } + function getCurrentItem(): QueueItem | null { + return queue[currentIndex] ?? null; + } function persist(): void { doPersistSession(nowPlaying, queue, currentIndex, progress, shuffleEnabled, shuffleOrder); } @@ -145,11 +155,17 @@ function createPlayerStore() { window.removeEventListener('beforeunload', handleBeforeUnload); beforeUnloadRegistered = false; } - async function stopJellyfinSession(item: QueueItem | null, posSeconds: number): Promise { + async function stopPreviousSession(item: QueueItem | null, posSeconds: number): Promise { progressReporter.stop(); unregisterBeforeUnload(); - if (!item || item.sourceType !== 'jellyfin' || !item.playSessionId) return; - await reportJellyfinStop(item.trackSourceId, item.playSessionId, posSeconds); + if (!item) return; + if (item.sourceType === 'jellyfin' && item.playSessionId) { + await reportJellyfinStop(item.trackSourceId, item.playSessionId, posSeconds); + } else if (item.sourceType === 'navidrome') { + void reportNavidromeStopped(item.trackSourceId); + } else if (item.sourceType === 'plex' && item.plexRatingKey) { + void reportPlexStopped(item.plexRatingKey); + } } function applyResetState(): void { @@ -191,6 +207,14 @@ function createPlayerStore() { loadUrl: url }; } + if (item.sourceType === 'plex') { + isSeekable = true; + if (item.plexRatingKey) void reportPlexNowPlaying(item.plexRatingKey); + return { + source: createPlaybackSource('plex', { url: url!, seekable: true }), + loadUrl: url + }; + } isSeekable = true; return { source: createPlaybackSource('jellyfin', { url: url!, seekable: true }), @@ -227,7 +251,7 @@ function createPlayerStore() { playbackState = 'loading'; progress = 0; duration = 0; - await stopJellyfinSession(prevItem, prevProgress); + await stopPreviousSession(prevItem, prevProgress); currentSource?.destroy(); const gen = ++loadGeneration; let source: PlaybackSource, @@ -264,13 +288,13 @@ function createPlayerStore() { playbackState = 'error'; const trackName = nowPlaying?.trackName ?? 'Unknown track'; if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { - playbackToast.show('Multiple tracks failed — playback stopped', 'error'); + playbackToast.show('Several tracks failed, so playback stopped.', 'error'); applyResetState(); return; } const nextIdx = getNextIndex(); if (nextIdx !== null) { - playbackToast.show(`"${trackName}" unavailable — skipping…`, 'warning'); + playbackToast.show(`"${trackName}" is unavailable, skipping...`, 'warning'); errorSkipTimeout = setTimeout(() => { errorSkipTimeout = null; if (gen === loadGeneration) void loadQueueItem(nextIdx); @@ -309,9 +333,12 @@ function createPlayerStore() { void reportJellyfinProgress(jf.trackSourceId, jf.playSessionId, progress, true); } if (state === 'ended') { - const ci = queue[currentIndex] ?? null; - void stopJellyfinSession(getJellyfinItem(), progress); - if (ci?.sourceType === 'navidrome') void reportNavidromeScrobble(ci.trackSourceId); + const endedItem = getCurrentItem(); + void stopPreviousSession(endedItem, progress); + if (endedItem?.sourceType === 'plex' && endedItem.plexRatingKey) + void reportPlexScrobble(endedItem.plexRatingKey); + else if (endedItem?.sourceType === 'navidrome') + void reportNavidromeScrobble(endedItem.trackSourceId); const nextIdx = getNextIndex(); if (nextIdx !== null) { void loadQueueItem(nextIdx).then(() => { @@ -414,7 +441,7 @@ function createPlayerStore() { }, playAlbum(source: PlaybackSource, metadata: NowPlaying): void { - void stopJellyfinSession(getJellyfinItem(), progress); + void stopPreviousSession(getCurrentItem(), progress); currentSource?.destroy(); const gen = ++loadGeneration; currentSource = source; @@ -502,6 +529,27 @@ function createPlayerStore() { showQueueMutationToast('queue', items.length); }, + appendQueueSilent(items: QueueItem[]): void { + if (items.length === 0) return; + const r = addMultipleItems(queue, items, shuffleEnabled, shuffleOrder); + queue = r.newQueue; + shuffleOrder = r.newShuffleOrder; + persist(); + }, + + regenerateShuffleOrder(): void { + if (!shuffleEnabled || queue.length === 0) return; + const allIndices = Array.from({ length: queue.length }, (_, i) => i); + const upcoming = allIndices.filter((i) => i !== currentIndex && i > currentIndex); + const played = allIndices.filter((i) => i < currentIndex); + for (let i = upcoming.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [upcoming[i], upcoming[j]] = [upcoming[j], upcoming[i]]; + } + shuffleOrder = [...played, currentIndex, ...upcoming]; + persist(); + }, + playNext(item: QueueItem): void { if (queue.length === 0) { this.playQueue([stampSingleOrigin(item, 'manual')], 0, false); @@ -590,7 +638,8 @@ function createPlayerStore() { playlistTrackId: string, newSourceType: SourceType, newTrackSourceId: string, - newFormat?: string + newFormat?: string, + plexRatingKey?: string ): void { const r = updateItemByPlaylistTrackId( queue, @@ -598,7 +647,8 @@ function createPlayerStore() { currentIndex, newSourceType, newTrackSourceId, - newFormat + newFormat, + plexRatingKey ); if (r) { queue = r; @@ -615,12 +665,27 @@ function createPlayerStore() { const jf = getJellyfinItem(); if (jf?.playSessionId) void reportJellyfinProgress(jf.trackSourceId, jf.playSessionId, progress, true); + const item = getCurrentItem(); + if (item?.sourceType === 'plex' && item.plexRatingKey) + void reportPlexStopped(item.plexRatingKey); + if (item?.sourceType === 'navidrome') void reportNavidromeStopped(item.trackSourceId); persist(); }, togglePlay(): void { - if (isPlaying) currentSource?.pause(); - else currentSource?.play(); + if (isPlaying) { + currentSource?.pause(); + const jf = getJellyfinItem(); + if (jf?.playSessionId) + void reportJellyfinProgress(jf.trackSourceId, jf.playSessionId, progress, true); + const item = getCurrentItem(); + if (item?.sourceType === 'plex' && item.plexRatingKey) + void reportPlexStopped(item.plexRatingKey); + if (item?.sourceType === 'navidrome') void reportNavidromeStopped(item.trackSourceId); + persist(); + } else { + currentSource?.play(); + } }, seekTo(seconds: number): void { currentSource?.seekTo(seconds); @@ -636,7 +701,7 @@ function createPlayerStore() { }, stop(): void { - void stopJellyfinSession(getJellyfinItem(), progress); + void stopPreviousSession(getCurrentItem(), progress); if (errorSkipTimeout) { clearTimeout(errorSkipTimeout); errorSkipTimeout = null; @@ -660,7 +725,7 @@ function createPlayerStore() { shuffleOrder = resume.shuffleOrder; isPlayerVisible = true; consecutiveErrors = 0; - void stopJellyfinSession(getJellyfinItem(), progress); + void stopPreviousSession(getCurrentItem(), progress); currentSource?.destroy(); currentIndex = resume.currentIndex; playbackState = 'loading'; diff --git a/frontend/src/lib/stores/playerJellyfinReporting.ts b/frontend/src/lib/stores/playerJellyfinReporting.ts index 50bcc5e..f0a18f1 100644 --- a/frontend/src/lib/stores/playerJellyfinReporting.ts +++ b/frontend/src/lib/stores/playerJellyfinReporting.ts @@ -1,4 +1,5 @@ import type { QueueItem } from '$lib/player/types'; +import { isPlexScrobbleEnabled } from '$lib/player/plexPlaybackApi'; export interface ProgressReporterState { jellyfinItem: QueueItem | null; @@ -76,7 +77,8 @@ export function createBeforeUnloadHandler( progress: number; }, jellyfinStopUrl: (trackSourceId: string) => string, - navidromeScrobbleUrl: (trackSourceId: string) => string + navidromeScrobbleUrl: (trackSourceId: string) => string, + plexScrobbleUrl: (ratingKey: string) => string ): () => void { return () => { if (typeof navigator === 'undefined' || typeof navigator.sendBeacon !== 'function') return; @@ -96,5 +98,17 @@ export function createBeforeUnloadHandler( new Blob([], { type: 'application/json' }) ); } + + if ( + currentItem?.sourceType === 'plex' && + currentItem.plexRatingKey && + progress > 30 && + isPlexScrobbleEnabled() + ) { + navigator.sendBeacon( + plexScrobbleUrl(currentItem.plexRatingKey), + new Blob([], { type: 'application/json' }) + ); + } }; } diff --git a/frontend/src/lib/stores/playerPlaybackMethods.ts b/frontend/src/lib/stores/playerPlaybackMethods.ts index 839ed5d..f698b15 100644 --- a/frontend/src/lib/stores/playerPlaybackMethods.ts +++ b/frontend/src/lib/stores/playerPlaybackMethods.ts @@ -119,7 +119,8 @@ export function updateItemByPlaylistTrackId( currentIndex: number, newSourceType: SourceType, newTrackSourceId: string, - newFormat?: string + newFormat?: string, + plexRatingKey?: string ): QueueItem[] | null { const index = queue.findIndex((item) => item.playlistTrackId === playlistTrackId); if (index < 0 || index === currentIndex) return null; @@ -132,7 +133,8 @@ export function updateItemByPlaylistTrackId( trackSourceId: newTrackSourceId, streamUrl, format: newFormat, - playSessionId: undefined + playSessionId: undefined, + plexRatingKey: newSourceType === 'plex' ? plexRatingKey : undefined }; return newQueue; } diff --git a/frontend/src/lib/stores/playerSourceResolver.ts b/frontend/src/lib/stores/playerSourceResolver.ts index 87a8a39..596f2be 100644 --- a/frontend/src/lib/stores/playerSourceResolver.ts +++ b/frontend/src/lib/stores/playerSourceResolver.ts @@ -11,6 +11,8 @@ export function resolveSourceUrl(item: QueueItem): string | undefined { return item.streamUrl ?? API.stream.navidrome(item.trackSourceId); case 'jellyfin': return API.stream.jellyfin(item.trackSourceId); + case 'plex': + return item.streamUrl ?? API.stream.plex(item.trackSourceId); } } @@ -22,6 +24,8 @@ export function buildPrefetchUrl(item: QueueItem): string | null { return API.stream.jellyfin(item.trackSourceId); case 'navidrome': return API.stream.navidrome(item.trackSourceId); + case 'plex': + return API.stream.plex(item.trackSourceId); case 'local': return API.stream.local(item.trackSourceId); default: @@ -40,6 +44,8 @@ export function buildStreamUrlForSource( return API.stream.navidrome(trackSourceId); case 'jellyfin': return API.stream.jellyfin(trackSourceId); + case 'plex': + return API.stream.plex(trackSourceId); default: return undefined; } diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 79066e8..2bbbd31 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -766,8 +766,10 @@ export type JellyfinTrackInfo = { duration_seconds: number; album_name: string; artist_name: string; + album_id?: string; codec?: string | null; bitrate?: number | null; + image_url?: string | null; }; export type JellyfinAlbumMatch = { @@ -785,6 +787,7 @@ export type JellyfinAlbumSummary = { image_url?: string | null; musicbrainz_id?: string | null; artist_musicbrainz_id?: string | null; + play_count?: number; }; export type JellyfinPaginatedResponse = { @@ -812,6 +815,30 @@ export type JellyfinArtistSummary = { image_url?: string | null; album_count: number; musicbrainz_id?: string | null; + play_count?: number; +}; + +export type JellyfinArtistPage = { + items: JellyfinArtistSummary[]; + total: number; + offset: number; + limit: number; +}; + +export type JellyfinArtistIndexEntry = { + name: string; + artists: JellyfinArtistSummary[]; +}; + +export type JellyfinArtistIndexResponse = { + index: JellyfinArtistIndexEntry[]; +}; + +export type JellyfinTrackPage = { + items: JellyfinTrackInfo[]; + total: number; + offset: number; + limit: number; }; export type NavidromeConnectionSettings = { @@ -831,6 +858,7 @@ export type NavidromeTrackInfo = { artist_name: string; codec?: string | null; bitrate?: number | null; + image_url?: string | null; }; export type NavidromeAlbumSummary = { @@ -868,6 +896,39 @@ export type NavidromeSearchResponse = { tracks: NavidromeTrackInfo[]; }; +export type NavidromeArtistIndexEntry = { + name: string; + artists: NavidromeArtistSummary[]; +}; + +export type NavidromeArtistIndexResponse = { + index: NavidromeArtistIndexEntry[]; +}; + +export type NavidromeArtistPage = { + items: NavidromeArtistSummary[]; + total: number; + offset: number; + limit: number; +}; + +export type NavidromeTrackPage = { + items: NavidromeTrackInfo[]; + total: number; + offset: number; + limit: number; +}; + +export type NavidromeGenreSongsResponse = { + songs: NavidromeTrackInfo[]; + genre: string; +}; + +export type NavidromeMusicFolder = { + id: string; + name: string; +}; + export type NavidromeLibraryStats = { total_tracks: number; total_albums: number; @@ -879,6 +940,183 @@ export type NavidromePaginatedResponse = { total: number; }; +export type PlexConnectionSettings = { + plex_url: string; + plex_token: string; + enabled: boolean; + music_library_ids: string[]; + scrobble_to_plex: boolean; +}; + +export type PlexTrackInfo = { + plex_id: string; + title: string; + track_number: number; + duration_seconds: number; + disc_number: number; + album_name: string; + artist_name: string; + codec?: string | null; + bitrate?: number | null; + audio_channels?: number | null; + container?: string | null; + part_key?: string | null; + image_url?: string | null; +}; + +export type PlexAlbumSummary = { + plex_id: string; + name: string; + artist_name: string; + year?: number | null; + track_count: number; + image_url?: string | null; + musicbrainz_id?: string | null; + artist_musicbrainz_id?: string | null; + last_viewed_at?: number; +}; + +export type PlexAlbumDetail = PlexAlbumSummary & { + tracks: PlexTrackInfo[]; + genres: string[]; +}; + +export type PlexAlbumMatch = { + found: boolean; + plex_album_id?: string | null; + tracks: PlexTrackInfo[]; +}; + +export type PlexArtistSummary = { + plex_id: string; + name: string; + image_url?: string | null; + musicbrainz_id?: string | null; +}; + +export type PlexSearchResponse = { + albums: PlexAlbumSummary[]; + artists: PlexArtistSummary[]; + tracks: PlexTrackInfo[]; +}; + +export type PlexLibraryStats = { + total_tracks: number; + total_albums: number; + total_artists: number; +}; + +export type PlexPaginatedResponse = { + items: PlexAlbumSummary[]; + total: number; +}; + +export type PlexArtistPage = { + items: PlexArtistSummary[]; + total: number; + offset: number; + limit: number; +}; + +export type PlexArtistIndexEntry = { + name: string; + artists: PlexArtistSummary[]; +}; + +export type PlexArtistIndexResponse = { + index: PlexArtistIndexEntry[]; +}; + +export type PlexTrackPage = { + items: PlexTrackInfo[]; + total: number; + offset: number; + limit: number; +}; + +export type PlexLibrarySection = { + key: string; + title: string; +}; + +export type HubStat = { + label: string; + value: number | null; + href?: string; +}; + +export type BrowseHeroCard = { + label: string; + value: number | null; + href: string; + subtitle?: string; + colorScheme: 'primary' | 'secondary' | 'accent'; + icon: 'disc' | 'users' | 'music'; +}; + +export type ArtistIndexArtist = { + id: string; + name: string; + image_url?: string | null; + album_count?: number; + musicbrainz_id?: string | null; +}; + +export type ArtistIndexEntry = { + name: string; + artists: ArtistIndexArtist[]; +}; + +export type PlexHubResponse = { + stats: PlexLibraryStats | null; + recently_played: PlexAlbumSummary[]; + recently_added: PlexAlbumSummary[]; + all_albums_preview: PlexAlbumSummary[]; + playlists: SourcePlaylistSummary[]; + genres: string[]; +}; + +export type PlexDiscoveryAlbum = { + plex_id: string; + name: string; + artist_name: string; + year?: number | null; + image_url?: string | null; +}; + +export type PlexDiscoveryHub = { + title: string; + hub_type: string; + albums: PlexDiscoveryAlbum[]; +}; + +export type PlexDiscoveryResponse = { + hubs: PlexDiscoveryHub[]; +}; + +export type NavidromeHubResponse = { + stats: NavidromeLibraryStats | null; + recently_played: NavidromeAlbumSummary[]; + favorites: NavidromeAlbumSummary[]; + favorite_artists: NavidromeArtistSummary[]; + favorite_tracks: NavidromeTrackInfo[]; + all_albums_preview: NavidromeAlbumSummary[]; + playlists: SourcePlaylistSummary[]; + genres: string[]; +}; + +export type JellyfinHubResponse = { + stats: JellyfinLibraryStats | null; + recently_played: JellyfinAlbumSummary[]; + recently_added: JellyfinAlbumSummary[]; + favorites: JellyfinAlbumSummary[]; + most_played_artists: JellyfinArtistSummary[]; + most_played_albums: JellyfinAlbumSummary[]; + all_albums_preview: JellyfinAlbumSummary[]; + playlists: SourcePlaylistSummary[]; + genres: string[]; +}; + export type LocalTrackInfo = { track_file_id: number; title: string; @@ -1035,3 +1273,215 @@ export type LastFmAlbumEnrichment = { playcount: number; url?: string | null; }; + +export type SourcePlaylistSummary = { + id: string; + name: string; + track_count: number; + duration_seconds: number; + cover_url: string; + is_smart?: boolean; + is_imported?: boolean; + owner?: string; + is_public?: boolean; + updated_at?: string; + created_at?: string; +}; + +export type SourcePlaylistTrack = { + id: string; + track_name: string; + artist_name: string; + album_name: string; + album_id: string; + artist_id?: string; + plex_rating_key?: string; + duration_seconds: number; + track_number: number; + disc_number: number; + cover_url: string; +}; + +export type SourcePlaylistDetail = { + id: string; + name: string; + track_count: number; + duration_seconds: number; + cover_url: string; + is_smart?: boolean; + updated_at?: string; + created_at?: string; + tracks: SourcePlaylistTrack[]; +}; + +export type SourceImportResult = { + musicseerr_playlist_id: string; + tracks_imported: number; + tracks_failed: number; + already_imported: boolean; +}; + +export type PlexSessionInfo = { + session_id: string; + user_name: string; + track_title: string; + artist_name: string; + album_name: string; + cover_url: string; + player_device: string; + player_platform: string; + player_state: string; + is_direct_play: boolean; + progress_ms: number; + duration_ms: number; + audio_codec: string; + audio_channels: number; + bitrate: number; +}; + +export type PlexSessionsResponse = { + sessions: PlexSessionInfo[]; + available: boolean; +}; + +export type NavidromeNowPlayingEntry = { + user_name: string; + minutes_ago: number; + player_name: string; + track_name: string; + artist_name: string; + album_name: string; + album_id: string; + cover_art_id: string; + duration_seconds: number; + estimated_position_seconds?: number; +}; + +export type NavidromeNowPlayingResponse = { + entries: NavidromeNowPlayingEntry[]; +}; + +export type JellyfinSessionInfo = { + session_id: string; + user_name: string; + device_name: string; + client_name: string; + track_name: string; + artist_name: string; + album_name: string; + album_id: string; + cover_url: string; + position_seconds: number; + duration_seconds: number; + is_paused: boolean; + play_method: string; + audio_codec: string; + bitrate: number; +}; + +export type JellyfinSessionsResponse = { + sessions: JellyfinSessionInfo[]; +}; + +export type NowPlayingSession = { + id: string; + user_name: string; + track_name: string; + artist_name: string; + album_name: string; + cover_url: string; + device_name: string; + is_paused: boolean; + source?: 'jellyfin' | 'navidrome' | 'plex'; + progress_ms?: number; + duration_ms?: number; + audio_codec?: string; + bitrate?: number; + _isLocal?: boolean; +}; + +export type NavidromeArtistInfo = { + navidrome_id: string; + name: string; + biography: string; + image_url: string; + similar_artists: NavidromeArtistSummary[]; +}; + +export type PlexHistoryEntry = { + rating_key: string; + track_title: string; + artist_name: string; + album_name: string; + cover_url: string; + viewed_at: string; + device_name: string; +}; + +export type PlexHistoryResponse = { + entries: PlexHistoryEntry[]; + total: number; + limit: number; + offset: number; + available: boolean; +}; + +export type PlexAnalyticsItem = { + name: string; + subtitle: string; + play_count: number; + cover_url: string | null; +}; + +export type PlexAnalyticsResponse = { + top_artists: PlexAnalyticsItem[]; + top_albums: PlexAnalyticsItem[]; + top_tracks: PlexAnalyticsItem[]; + total_listens: number; + listens_last_7_days: number; + listens_last_30_days: number; + total_hours: number; + is_complete: boolean; + entries_analyzed: number; +}; + +export type NavidromeAlbumInfo = { + album_id: string; + notes: string; + musicbrainz_id: string; + lastfm_url: string; + image_url: string; +}; + +export type LyricLine = { + text: string; + start_seconds: number | null; +}; + +export type NavidromeLyricsResponse = { + text: string; + is_synced: boolean; + lines: LyricLine[]; +}; + +export type JellyfinLyricsLine = { + text: string; + start_seconds: number | null; +}; + +export type JellyfinLyricsResponse = { + lines: JellyfinLyricsLine[]; + is_synced: boolean; + lyrics_text: string; +}; + +export type JellyfinFavoritesExpanded = { + albums: JellyfinAlbumSummary[]; + artists: JellyfinArtistSummary[]; +}; + +export type JellyfinFilterFacets = { + years: number[]; + tags: string[]; + studios: string[]; +}; diff --git a/frontend/src/lib/utils/albumCardPlayback.ts b/frontend/src/lib/utils/albumCardPlayback.ts index a6a062e..a17064e 100644 --- a/frontend/src/lib/utils/albumCardPlayback.ts +++ b/frontend/src/lib/utils/albumCardPlayback.ts @@ -11,9 +11,11 @@ import type { JellyfinAlbumMatch, LocalAlbumMatch, NavidromeAlbumMatch, + PlexAlbumMatch, JellyfinTrackInfo, LocalTrackInfo, - NavidromeTrackInfo + NavidromeTrackInfo, + PlexTrackInfo } from '$lib/types'; export interface AlbumCardMeta { @@ -24,7 +26,7 @@ export interface AlbumCardMeta { artistId?: string; } -type SourceResult = { source: 'local' | 'navidrome' | 'jellyfin'; items: QueueItem[] }; +type SourceResult = { source: 'local' | 'navidrome' | 'jellyfin' | 'plex'; items: QueueItem[] }; function buildLocalItems(tracks: LocalTrackInfo[], meta: AlbumCardMeta): QueueItem[] { const cover = getCoverUrl(meta.coverUrl, meta.mbid); @@ -77,10 +79,28 @@ function buildJellyfinItems(tracks: JellyfinTrackInfo[], meta: AlbumCardMeta): Q })); } -/** - * Probes configured sources in parallel and returns QueueItems from the - * highest-priority source that has tracks (local > navidrome > jellyfin). - */ +function buildPlexItems(tracks: PlexTrackInfo[], meta: AlbumCardMeta): QueueItem[] { + const cover = getCoverUrl(meta.coverUrl, meta.mbid); + return tracks + .filter((t) => t.part_key) + .map((t) => { + return { + trackSourceId: t.part_key!, + trackName: t.title, + artistName: meta.artistName, + trackNumber: t.track_number, + albumId: meta.mbid, + albumName: meta.albumName, + coverUrl: cover, + sourceType: 'plex' as const, + artistId: meta.artistId, + streamUrl: API.stream.plex(t.part_key!), + format: normalizeCodec(t.codec), + plexRatingKey: t.plex_id + }; + }); +} + export async function fetchAlbumQueueItems( meta: AlbumCardMeta, signal?: AbortSignal @@ -133,10 +153,33 @@ export async function fetchAlbumQueueItems( ); } + if (status.plex) { + const plexUrl = new URL(API.plexLibrary.albumMatch(meta.mbid), window.location.origin); + if (meta.albumName) plexUrl.searchParams.set('name', meta.albumName); + if (meta.artistName) plexUrl.searchParams.set('artist', meta.artistName); + probes.push( + api.global + .get(plexUrl.toString(), { signal }) + .then((data) => { + if (!data?.found || data.tracks.length === 0) return null; + return { + source: 'plex' as const, + items: buildPlexItems(data.tracks, meta) + }; + }) + .catch(() => null) + ); + } + if (probes.length === 0) return []; const results = await Promise.all(probes); - const priority: Array<'local' | 'navidrome' | 'jellyfin'> = ['local', 'navidrome', 'jellyfin']; + const priority: Array<'local' | 'navidrome' | 'jellyfin' | 'plex'> = [ + 'local', + 'navidrome', + 'jellyfin', + 'plex' + ]; for (const src of priority) { const hit = results.find((r) => r?.source === src); if (hit) return hit.items; diff --git a/frontend/src/lib/utils/albumDetailCache.ts b/frontend/src/lib/utils/albumDetailCache.ts index 89ffbe7..a9262e3 100644 --- a/frontend/src/lib/utils/albumDetailCache.ts +++ b/frontend/src/lib/utils/albumDetailCache.ts @@ -9,7 +9,8 @@ import type { YouTubeTrackLink, JellyfinAlbumMatch, LocalAlbumMatch, - NavidromeAlbumMatch + NavidromeAlbumMatch, + PlexAlbumMatch } from '$lib/types'; import { createLocalStorageCache } from '$lib/utils/localStorageCache'; @@ -29,6 +30,7 @@ export type AlbumSourceMatchCachePayload = { jellyfin: JellyfinAlbumMatch | null; local: LocalAlbumMatch | null; navidrome: NavidromeAlbumMatch | null; + plex: PlexAlbumMatch | null; }; export const albumBasicCache = createLocalStorageCache( diff --git a/frontend/src/lib/utils/formatting.ts b/frontend/src/lib/utils/formatting.ts index 7c09e47..9c62c54 100644 --- a/frontend/src/lib/utils/formatting.ts +++ b/frontend/src/lib/utils/formatting.ts @@ -33,8 +33,9 @@ export function formatDuration(ms?: number | null): string { export function formatDurationSec(sec?: number | null): string { if (!sec && sec !== 0) return '--:--'; - const minutes = Math.floor(sec / 60); - const seconds = sec % 60; + const total = Math.floor(sec); + const minutes = Math.floor(total / 60); + const seconds = total % 60; return `${minutes}:${seconds.toString().padStart(2, '0')}`; } diff --git a/frontend/src/lib/utils/libraryController.svelte.ts b/frontend/src/lib/utils/libraryController.svelte.ts index b03b95c..3af39e9 100644 --- a/frontend/src/lib/utils/libraryController.svelte.ts +++ b/frontend/src/lib/utils/libraryController.svelte.ts @@ -12,11 +12,12 @@ export interface SidebarData { recentAlbums: TAlbum[]; favoriteAlbums: TAlbum[]; genres: string[]; + moods: string[]; stats: Record | null; } export interface LibraryAdapter { - sourceType: 'jellyfin' | 'navidrome' | 'local'; + sourceType: 'jellyfin' | 'navidrome' | 'local' | 'plex'; getAlbumId(album: TAlbum): string | number; getAlbumName(album: TAlbum): string; @@ -31,6 +32,9 @@ export interface LibraryAdapter { sortBy: string; sortOrder: string; genre?: string; + mood?: string; + decade?: string; + tag?: string; search?: string; signal: AbortSignal; }): Promise<{ items: TAlbum[]; total: number }>; @@ -58,16 +62,23 @@ export interface LibraryAdapter { descValue: string; getDefaultSortOrder(field: string): string; supportsGenres: boolean; + supportsMoods: boolean; + supportsDecades: boolean; + supportsTags: boolean; supportsFavorites: boolean; supportsShuffle: boolean; errorMessage: string; } -export function createLibraryController(adapter: LibraryAdapter) { +export function createLibraryController( + adapter: LibraryAdapter, + options?: { initialSortBy?: string; initialSortOrder?: string } +) { let albums = $state([]); let recentAlbums = $state([]); let favoriteAlbums = $state([]); let genres = $state([]); + let moods = $state([]); let stats = $state | null>(null); let total = $state(0); let loading = $state(true); @@ -75,9 +86,17 @@ export function createLibraryController(adapter: LibraryAdapter) let fetchError = $state(''); let fetchErrorCode = $state(''); - let sortBy = $state(adapter.defaultSortBy); - let sortOrder = $state(adapter.ascValue); + let sortBy = $state(options?.initialSortBy ?? adapter.defaultSortBy); + let sortOrder = $state( + options?.initialSortOrder ?? + (options?.initialSortBy + ? adapter.getDefaultSortOrder(options.initialSortBy) + : adapter.ascValue) + ); let selectedGenre = $state(''); + let selectedMood = $state(''); + let selectedDecade = $state(''); + let selectedTag = $state(''); let searchQuery = $state(''); let searchTimeout: ReturnType | null = null; let fetchId = 0; @@ -93,7 +112,10 @@ export function createLibraryController(adapter: LibraryAdapter) function getCacheKey(offset: number): string { const search = searchQuery.trim() || ''; const genre = selectedGenre || ''; - return `${sortBy}:${sortOrder}:${genre}:${search}:${PAGE_SIZE}:${offset}`; + const mood = selectedMood || ''; + const decade = selectedDecade || ''; + const tag = selectedTag || ''; + return `${sortBy}:${sortOrder}:${genre}:${mood}:${decade}:${tag}:${search}:${PAGE_SIZE}:${offset}`; } async function fetchAlbums(reset = false): Promise { @@ -125,7 +147,6 @@ export function createLibraryController(adapter: LibraryAdapter) loadingMore = false; return; } - // Stale: show cached data, but keep loadingMore true to prevent re-trigger loading = false; } @@ -135,6 +156,9 @@ export function createLibraryController(adapter: LibraryAdapter) sortBy, sortOrder, genre: selectedGenre || undefined, + mood: selectedMood || undefined, + decade: selectedDecade || undefined, + tag: selectedTag || undefined, search: searchQuery.trim() || undefined, signal }); @@ -163,6 +187,7 @@ export function createLibraryController(adapter: LibraryAdapter) recentAlbums = cached.data.recentAlbums; favoriteAlbums = cached.data.favoriteAlbums; genres = cached.data.genres; + moods = cached.data.moods ?? []; stats = cached.data.stats; if (!adapter.isSidebarCacheStale(cached.timestamp)) return; } @@ -173,11 +198,12 @@ export function createLibraryController(adapter: LibraryAdapter) try { const { data: result, hasFreshData } = await adapter.fetchSidebarData( sidebarAbortController.signal, - { recentAlbums, favoriteAlbums, genres, stats } + { recentAlbums, favoriteAlbums, genres, moods, stats } ); recentAlbums = result.recentAlbums; favoriteAlbums = result.favoriteAlbums; genres = result.genres; + moods = result.moods ?? []; stats = result.stats; if (hasFreshData) adapter.setSidebarCached(result); } catch (e) { @@ -212,6 +238,21 @@ export function createLibraryController(adapter: LibraryAdapter) fetchAlbums(true); } + function handleMoodChange(value: string): void { + selectedMood = value; + fetchAlbums(true); + } + + function handleDecadeChange(value: string): void { + selectedDecade = value; + fetchAlbums(true); + } + + function handleTagChange(value: string): void { + selectedTag = value; + fetchAlbums(true); + } + function handleSearch(): void { if (searchTimeout) clearTimeout(searchTimeout); searchTimeout = setTimeout(() => fetchAlbums(true), 300); @@ -350,6 +391,9 @@ export function createLibraryController(adapter: LibraryAdapter) get genres() { return genres; }, + get moods() { + return moods; + }, get stats() { return stats; }, @@ -377,6 +421,15 @@ export function createLibraryController(adapter: LibraryAdapter) get selectedGenre() { return selectedGenre; }, + get selectedMood() { + return selectedMood; + }, + get selectedDecade() { + return selectedDecade; + }, + get selectedTag() { + return selectedTag; + }, get searchQuery() { return searchQuery; }, @@ -411,6 +464,9 @@ export function createLibraryController(adapter: LibraryAdapter) handleSortChange, toggleSortOrder, handleGenreChange, + handleMoodChange, + handleDecadeChange, + handleTagChange, handleSearch, loadMore, quickPlay, diff --git a/frontend/src/lib/utils/libraryTrackLoader.svelte.ts b/frontend/src/lib/utils/libraryTrackLoader.svelte.ts new file mode 100644 index 0000000..68567bd --- /dev/null +++ b/frontend/src/lib/utils/libraryTrackLoader.svelte.ts @@ -0,0 +1,194 @@ +import type { QueueItem } from '$lib/player/types'; +import { api } from '$lib/api/client'; + +const BACKGROUND_BATCH_SIZE = 100; + +export interface TrackPageResponse { + items: T[]; + total: number; + offset: number; + limit: number; +} + +export interface LibraryTrackLoaderConfig { + fetchPageUrl(limit: number, offset: number): string; + buildQueue(tracks: T[]): QueueItem[]; + pageSize: number; + resolveShuffleStartIndex?(tracks: T[], requestedIndex: number, queue: QueueItem[]): number; +} + +function shuffleInPlace(arr: T[]): T[] { + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr; +} + +export function createLibraryTrackLoader( + config: LibraryTrackLoaderConfig, + onQueueAppend: (items: QueueItem[]) => void, + onPlayQueue: (items: QueueItem[], startIndex: number, shuffle: boolean) => void, + onShuffleRegenerate: () => void, + onToast: (message: string, type: 'info' | 'error') => void +) { + let controller: AbortController | null = null; + let loading = $state(false); + let loaded = $state(0); + let total = $state(0); + + function abort() { + controller?.abort(); + controller = null; + loading = false; + } + + function playAll(currentItems: T[], currentTotal: number) { + abort(); + const queue = config.buildQueue(currentItems); + if (queue.length === 0) return; + + if (currentTotal <= currentItems.length) { + onPlayQueue(queue, 0, false); + return; + } + + onPlayQueue(queue, 0, false); + + const ac = new AbortController(); + controller = ac; + loading = true; + loaded = currentItems.length; + total = currentTotal; + + const snapshotTotal = currentTotal; + let offset = currentItems.length; + + (async () => { + try { + while (offset < snapshotTotal) { + if (ac.signal.aborted) return; + const url = config.fetchPageUrl(BACKGROUND_BATCH_SIZE, offset); + const page = await api.global.get>(url, { signal: ac.signal }); + if (ac.signal.aborted) return; + if (page.items.length === 0) break; + + const batch = config.buildQueue(page.items); + if (batch.length > 0) { + onQueueAppend(batch); + } + offset += page.items.length; + loaded = offset; + } + onShuffleRegenerate(); + } catch (_e: unknown) { + if (ac.signal.aborted) return; + onToast( + `Couldn't load the rest of the tracks. ${loaded} of ${snapshotTotal} are ready.`, + 'error' + ); + } finally { + if (controller === ac) { + loading = false; + controller = null; + } + } + })(); + } + + function shuffleAll(currentItems: T[], currentTotal: number) { + abort(); + + if (currentTotal <= config.pageSize) { + const queue = config.buildQueue(currentItems); + if (queue.length > 0) onPlayQueue(queue, 0, true); + return; + } + + const ac = new AbortController(); + controller = ac; + loading = true; + loaded = 0; + total = currentTotal; + + const snapshotTotal = currentTotal; + + (async () => { + try { + const randomTrackIndex = Math.floor(Math.random() * snapshotTotal); + const initialOffset = randomTrackIndex - (randomTrackIndex % config.pageSize); + + const initialUrl = config.fetchPageUrl(config.pageSize, initialOffset); + const initialPage = await api.global.get>(initialUrl, { + signal: ac.signal + }); + if (ac.signal.aborted) return; + + const initialQueue = config.buildQueue(initialPage.items); + if (initialQueue.length === 0) return; + const requestedIndex = randomTrackIndex - initialOffset; + const startIndex = config.resolveShuffleStartIndex + ? config.resolveShuffleStartIndex(initialPage.items, requestedIndex, initialQueue) + : Math.min(requestedIndex, initialQueue.length - 1); + onPlayQueue(initialQueue, Math.max(startIndex, 0), true); + loaded = initialPage.items.length; + + const allOffsets: number[] = []; + for (let o = 0; o < snapshotTotal; o += config.pageSize) { + if (o !== initialOffset) allOffsets.push(o); + } + shuffleInPlace(allOffsets); + + for (const offset of allOffsets) { + if (ac.signal.aborted) return; + const batchUrl = config.fetchPageUrl(config.pageSize, offset); + const batchPage = await api.global.get>(batchUrl, { + signal: ac.signal + }); + if (ac.signal.aborted) return; + + const batchQueue = config.buildQueue(batchPage.items); + if (batchQueue.length > 0) { + onQueueAppend(batchQueue); + onShuffleRegenerate(); + } + loaded += batchPage.items.length; + } + } catch (_e: unknown) { + if (ac.signal.aborted) return; + onToast("Couldn't finish loading the shuffled tracks.", 'error'); + } finally { + if (controller === ac) { + loading = false; + controller = null; + } + } + })(); + } + + function getProgressText(): string | null { + if (!loading) return null; + return `Loading ${loaded.toLocaleString()} of ${total.toLocaleString()}`; + } + + return { + get loading() { + return loading; + }, + get loaded() { + return loaded; + }, + get total() { + return total; + }, + get progressText() { + return getProgressText(); + }, + playAll, + shuffleAll, + abort, + get controller() { + return controller; + } + }; +} diff --git a/frontend/src/lib/utils/plexLibraryCache.ts b/frontend/src/lib/utils/plexLibraryCache.ts new file mode 100644 index 0000000..c38f88b --- /dev/null +++ b/frontend/src/lib/utils/plexLibraryCache.ts @@ -0,0 +1,36 @@ +import { CACHE_KEYS, CACHE_TTL } from '$lib/constants'; +import type { PlexAlbumSummary, PlexLibraryStats } from '$lib/types'; +import { createLocalStorageCache } from '$lib/utils/localStorageCache'; + +type PlexSidebarData = { + recentAlbums: PlexAlbumSummary[]; + genres: string[]; + moods: string[]; + stats: PlexLibraryStats | null; +}; + +type PlexAlbumsListData = { + items: PlexAlbumSummary[]; + total: number; +}; + +export const plexSidebarCache = createLocalStorageCache( + CACHE_KEYS.PLEX_SIDEBAR, + CACHE_TTL.PLEX_SIDEBAR +); + +export const plexAlbumsListCache = createLocalStorageCache( + CACHE_KEYS.PLEX_ALBUMS_LIST, + CACHE_TTL.PLEX_ALBUMS_LIST, + { maxEntries: 80 } +); + +export const getPlexSidebarCachedData = plexSidebarCache.get; +export const setPlexSidebarCachedData = plexSidebarCache.set; +export const isPlexSidebarCacheStale = plexSidebarCache.isStale; +export const updatePlexSidebarCacheTTL = plexSidebarCache.updateTTL; + +export const getPlexAlbumsListCachedData = plexAlbumsListCache.get; +export const setPlexAlbumsListCachedData = plexAlbumsListCache.set; +export const isPlexAlbumsListCacheStale = plexAlbumsListCache.isStale; +export const updatePlexAlbumsListCacheTTL = plexAlbumsListCache.updateTTL; diff --git a/frontend/src/lib/utils/sources.ts b/frontend/src/lib/utils/sources.ts index 17f3dae..1c1d713 100644 --- a/frontend/src/lib/utils/sources.ts +++ b/frontend/src/lib/utils/sources.ts @@ -1,9 +1,10 @@ -export type SourceType = 'jellyfin' | 'local' | 'youtube' | 'navidrome'; +export type SourceType = 'jellyfin' | 'local' | 'youtube' | 'navidrome' | 'plex'; export function getSourceLabel(sourceType: string): string { if (sourceType === 'local') return 'Local'; if (sourceType === 'jellyfin') return 'Jellyfin'; if (sourceType === 'navidrome') return 'Navidrome'; + if (sourceType === 'plex') return 'Plex'; if (sourceType === 'youtube') return 'YouTube'; return 'Unknown'; } @@ -11,6 +12,7 @@ export function getSourceLabel(sourceType: string): string { export function getSourceColor(sourceType: string): string { if (sourceType === 'jellyfin') return 'rgb(var(--brand-jellyfin))'; if (sourceType === 'navidrome') return 'rgb(var(--brand-navidrome))'; + if (sourceType === 'plex') return 'rgb(var(--brand-plex))'; if (sourceType === 'local') return 'rgb(var(--brand-localfiles))'; if (sourceType === 'youtube') return 'var(--color-youtube)'; return 'currentColor'; diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index e598f5d..f4d30d7 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -25,6 +25,7 @@ import YouTubeIcon from '$lib/components/YouTubeIcon.svelte'; import NavidromeIcon from '$lib/components/NavidromeIcon.svelte'; import JellyfinIcon from '$lib/components/JellyfinIcon.svelte'; + import PlexIcon from '$lib/components/PlexIcon.svelte'; import SidebarServiceHint from '$lib/components/SidebarServiceHint.svelte'; import DegradedBanner from '$lib/components/DegradedBanner.svelte'; import SearchSuggestions from '$lib/components/SearchSuggestions.svelte'; @@ -33,6 +34,9 @@ import { cancelPendingImages } from '$lib/utils/lazyImage'; import { abortAllPageRequests } from '$lib/utils/navigationAbort'; import { requestCountStore } from '$lib/stores/requestCountStore.svelte'; + import { nowPlayingMerged } from '$lib/stores/nowPlayingMerged.svelte'; + import { nowPlayingStore } from '$lib/stores/nowPlayingSessions.svelte'; + import SidebarVisualiser from '$lib/components/SidebarVisualiser.svelte'; import { createNavigationProgressController } from '$lib/utils/navigationProgress'; import { fromStore } from 'svelte/store'; import { @@ -99,7 +103,6 @@ eqStore.replayToEngine(); } - // Resume AudioContext on first user gesture (browser autoplay policy) const resumeAudioContext = () => { tryGetAudioEngine()?.resume(); cleanupResumeListeners?.(); @@ -128,13 +131,15 @@ }; deferInit(() => { libraryStore.initialize(); - void integrationStore.ensureLoaded(); void imageSettingsStore.load(); void restorePlayerSession(); void scrobbleManager.init(); requestCountStore.startPolling(); syncStatus.connect(); }); + integrationStore.ensureLoaded().then(() => { + nowPlayingStore.start(); + }); }); onDestroy(() => { @@ -146,6 +151,7 @@ } requestCountStore.stopPolling(); syncStatus.disconnect(); + nowPlayingStore.stop(); unregisterPlaylistModal(); }); @@ -197,7 +203,7 @@ playerStore.resumeSession(); } } catch { - // Ignore errors + return; } } @@ -381,8 +387,22 @@ class="is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Jellyfin" > - +
    + + {#if nowPlayingMerged.isSourcePlaying('jellyfin')} + + {/if} +
    Jellyfin + {#if nowPlayingMerged.isSourcePlaying('jellyfin')} +
    + +
    + {/if} {:else if integrations.current.loaded} @@ -398,8 +418,22 @@ class="is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Navidrome" > - +
    + + {#if nowPlayingMerged.isSourcePlaying('navidrome')} + + {/if} +
    Navidrome + {#if nowPlayingMerged.isSourcePlaying('navidrome')} +
    + +
    + {/if} {:else if integrations.current.loaded} @@ -408,6 +442,40 @@ {/if} + {#if integrations.current.plex} +
  • + +
    + + {#if nowPlayingMerged.isSourcePlaying('plex')} + + {/if} +
    + Plex + {#if nowPlayingMerged.isSourcePlaying('plex')} +
    + +
    + {/if} +
    +
  • + {:else if integrations.current.loaded} + + {#snippet icon()}{/snippet} + + {/if} + {#if integrations.current.localfiles}
  • {/if} + + {#if lidarrConfigured}
  • diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 709a247..6b79d2b 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -15,6 +15,7 @@ import { getGreeting } from '$lib/utils/homeCache'; import { removeQueueCachedData } from '$lib/utils/discoverQueueCache'; import { isDismissed } from '$lib/utils/dismissedPrompts'; + import HomeSectionNowPlaying from '$lib/components/HomeSectionNowPlaying.svelte'; import { getHomeQuery } from '$lib/queries/HomeQuery.svelte'; import type { PageProps } from './$types'; import { PersistedState } from 'runed'; @@ -187,31 +188,14 @@ diff --git a/frontend/src/routes/album/[id]/+page.svelte b/frontend/src/routes/album/[id]/+page.svelte index 472f74d..f2df9f1 100644 --- a/frontend/src/routes/album/[id]/+page.svelte +++ b/frontend/src/routes/album/[id]/+page.svelte @@ -111,17 +111,21 @@ jellyfinMatch={state.jellyfinMatch} localMatch={state.localMatch} navidromeMatch={state.navidromeMatch} + plexMatch={state.plexMatch} loadingJellyfin={state.loadingJellyfin} loadingLocal={state.loadingLocal} loadingNavidrome={state.loadingNavidrome} + loadingPlex={state.loadingPlex} youtubeEnabled={$integrationStore.youtube} youtubeApiConfigured={$integrationStore.youtube_api} jellyfinEnabled={$integrationStore.jellyfin} localfilesEnabled={$integrationStore.localfiles} navidromeEnabled={$integrationStore.navidrome} + plexEnabled={$integrationStore.plex} jellyfinCallbacks={state.jellyfinCallbacks} localCallbacks={state.localCallbacks} navidromeCallbacks={state.navidromeCallbacks} + plexCallbacks={state.plexCallbacks} onTrackLinksUpdate={state.handleTrackLinksUpdate} onAlbumLinkUpdate={state.handleAlbumLinkUpdate} onQuotaUpdate={state.handleQuotaUpdate} @@ -134,18 +138,22 @@ jellyfinMatch={state.jellyfinMatch} localMatch={state.localMatch} navidromeMatch={state.navidromeMatch} + plexMatch={state.plexMatch} jellyfinTrackMap={state.jellyfinTrackMap} localTrackMap={state.localTrackMap} navidromeTrackMap={state.navidromeTrackMap} + plexTrackMap={state.plexTrackMap} jellyfinTracks={state.jellyfinTracks} localTracks={state.localTracks} navidromeTracks={state.navidromeTracks} + plexTracks={state.plexTracks} trackLinks={state.trackLinks} youtubeEnabled={$integrationStore.youtube} youtubeApiConfigured={$integrationStore.youtube_api} jellyfinEnabled={$integrationStore.jellyfin} localfilesEnabled={$integrationStore.localfiles} navidromeEnabled={$integrationStore.navidrome} + plexEnabled={$integrationStore.plex} onPlaySourceTrack={state.playSourceTrack} onTrackGenerated={state.handleTrackGenerated} onQuotaUpdate={state.handleQuotaUpdate} diff --git a/frontend/src/routes/album/[id]/AlbumSourceBars.svelte b/frontend/src/routes/album/[id]/AlbumSourceBars.svelte index d2d1966..0859e30 100644 --- a/frontend/src/routes/album/[id]/AlbumSourceBars.svelte +++ b/frontend/src/routes/album/[id]/AlbumSourceBars.svelte @@ -7,7 +7,8 @@ YouTubeQuotaStatus, JellyfinAlbumMatch, LocalAlbumMatch, - NavidromeAlbumMatch + NavidromeAlbumMatch, + PlexAlbumMatch } from '$lib/types'; import type { SourceCallbacks } from './albumPageState.svelte'; import AlbumYouTubeBar from '$lib/components/AlbumYouTubeBar.svelte'; @@ -15,6 +16,7 @@ import JellyfinIcon from '$lib/components/JellyfinIcon.svelte'; import LocalFilesIcon from '$lib/components/LocalFilesIcon.svelte'; import NavidromeIcon from '$lib/components/NavidromeIcon.svelte'; + import PlexIcon from '$lib/components/PlexIcon.svelte'; interface Props { album: AlbumBasicInfo; @@ -24,17 +26,21 @@ jellyfinMatch: JellyfinAlbumMatch | null; localMatch: LocalAlbumMatch | null; navidromeMatch: NavidromeAlbumMatch | null; + plexMatch: PlexAlbumMatch | null; loadingJellyfin: boolean; loadingLocal: boolean; loadingNavidrome: boolean; + loadingPlex: boolean; youtubeEnabled: boolean; youtubeApiConfigured: boolean; jellyfinEnabled: boolean; localfilesEnabled: boolean; navidromeEnabled: boolean; + plexEnabled: boolean; jellyfinCallbacks: SourceCallbacks; localCallbacks: SourceCallbacks; navidromeCallbacks: SourceCallbacks; + plexCallbacks: SourceCallbacks; onTrackLinksUpdate: (links: YouTubeTrackLink[]) => void; onAlbumLinkUpdate: (link: YouTubeLink) => void; onQuotaUpdate: (q: YouTubeQuotaStatus) => void; @@ -48,17 +54,21 @@ jellyfinMatch, localMatch, navidromeMatch, + plexMatch, loadingJellyfin, loadingLocal, loadingNavidrome, + loadingPlex, youtubeEnabled, youtubeApiConfigured, jellyfinEnabled, localfilesEnabled, navidromeEnabled, + plexEnabled, jellyfinCallbacks, localCallbacks, navidromeCallbacks, + plexCallbacks, onTrackLinksUpdate, onAlbumLinkUpdate, onQuotaUpdate @@ -148,3 +158,25 @@ {/if} {/if} + +{#if plexEnabled} + {#if loadingPlex} +
    + {:else if plexMatch?.found} + + {#snippet icon()} + + {/snippet} + + {/if} +{/if} diff --git a/frontend/src/routes/album/[id]/AlbumTrackList.svelte b/frontend/src/routes/album/[id]/AlbumTrackList.svelte index 9f07929..87bcb96 100644 --- a/frontend/src/routes/album/[id]/AlbumTrackList.svelte +++ b/frontend/src/routes/album/[id]/AlbumTrackList.svelte @@ -8,7 +8,9 @@ LocalAlbumMatch, LocalTrackInfo, NavidromeAlbumMatch, - NavidromeTrackInfo + NavidromeTrackInfo, + PlexAlbumMatch, + PlexTrackInfo } from '$lib/types'; import type { MenuItem } from '$lib/components/ContextMenu.svelte'; import type { RenderedTrackSection } from './albumTrackResolvers'; @@ -24,6 +26,7 @@ import JellyfinIcon from '$lib/components/JellyfinIcon.svelte'; import LocalFilesIcon from '$lib/components/LocalFilesIcon.svelte'; import NavidromeIcon from '$lib/components/NavidromeIcon.svelte'; + import PlexIcon from '$lib/components/PlexIcon.svelte'; interface Props { album: AlbumBasicInfo; @@ -32,20 +35,24 @@ jellyfinMatch: JellyfinAlbumMatch | null; localMatch: LocalAlbumMatch | null; navidromeMatch: NavidromeAlbumMatch | null; + plexMatch: PlexAlbumMatch | null; jellyfinTrackMap: Map; localTrackMap: Map; navidromeTrackMap: Map; + plexTrackMap: Map; jellyfinTracks: JellyfinTrackInfo[]; localTracks: LocalTrackInfo[]; navidromeTracks: NavidromeTrackInfo[]; + plexTracks: PlexTrackInfo[]; trackLinks: YouTubeTrackLink[]; youtubeEnabled: boolean; youtubeApiConfigured: boolean; jellyfinEnabled: boolean; localfilesEnabled: boolean; navidromeEnabled: boolean; + plexEnabled: boolean; onPlaySourceTrack: ( - source: 'jellyfin' | 'local' | 'navidrome', + source: 'jellyfin' | 'local' | 'navidrome' | 'plex', trackPosition: number, discNumber: number, title: string @@ -56,7 +63,8 @@ track: { position: number; disc_number?: number | null; title: string }, resolvedLocal: LocalTrackInfo | null, resolvedJellyfin: JellyfinTrackInfo | null, - resolvedNavidrome: NavidromeTrackInfo | null + resolvedNavidrome: NavidromeTrackInfo | null, + resolvedPlex: PlexTrackInfo | null ) => MenuItem[]; } @@ -67,18 +75,22 @@ jellyfinMatch, localMatch, navidromeMatch, + plexMatch, jellyfinTrackMap, localTrackMap, navidromeTrackMap, + plexTrackMap, jellyfinTracks, localTracks, navidromeTracks, + plexTracks, trackLinks, youtubeEnabled, youtubeApiConfigured, jellyfinEnabled, localfilesEnabled, navidromeEnabled, + plexEnabled, onPlaySourceTrack, onTrackGenerated, onQuotaUpdate, @@ -124,6 +136,13 @@ navidromeTrackMap, navidromeTracks )} + {@const plexTrack = resolveSourceTrack( + trackDiscNumber, + track.position, + row.globalIndex, + plexTrackMap, + plexTracks + )} {@const isCurrentlyPlaying = playerStore.nowPlaying?.albumId === album.musicbrainz_id && (playerStore.currentQueueItem?.discNumber ?? 1) === trackDiscNumber && @@ -132,6 +151,7 @@ {@const showJellyfinBtn = jellyfinEnabled && jellyfinMatch?.found} {@const showLocalBtn = localfilesEnabled && localMatch?.found} {@const showNavidromeBtn = navidromeEnabled && navidromeMatch?.found} + {@const showPlexBtn = plexEnabled && plexMatch?.found}
  • - {#if youtubeEnabled || showJellyfinBtn || showLocalBtn || showNavidromeBtn} + {#if youtubeEnabled || showJellyfinBtn || showLocalBtn || showNavidromeBtn || showPlexBtn}
    {#if youtubeEnabled} {/if} + {#if showPlexBtn} + + onPlaySourceTrack('plex', track.position, trackDiscNumber, track.title)} + ariaLabel={plexTrack ? 'Play on Plex' : 'Not available on Plex'} + > + {#snippet icon()} + + {/snippet} + + {/if} +
    (matchUrl.toString(), { signal }); } +export async function fetchPlexMatch( + albumId: string, + opts: { albumTitle?: string; artistName?: string }, + signal?: AbortSignal +): Promise { + const matchUrl = new URL(API.plexLibrary.albumMatch(albumId), window.location.origin); + if (opts.albumTitle) matchUrl.searchParams.set('name', opts.albumTitle); + if (opts.artistName) matchUrl.searchParams.set('artist', opts.artistName); + return api.get(matchUrl.toString(), { signal }); +} + export async function fetchLastFm( albumId: string, opts: { artistName: string; albumName: string }, diff --git a/frontend/src/routes/album/[id]/albumPageState.svelte.ts b/frontend/src/routes/album/[id]/albumPageState.svelte.ts index c8bc728..e8c62f2 100644 --- a/frontend/src/routes/album/[id]/albumPageState.svelte.ts +++ b/frontend/src/routes/album/[id]/albumPageState.svelte.ts @@ -1,6 +1,7 @@ import { browser } from '$app/environment'; import { get } from 'svelte/store'; import { untrack } from 'svelte'; +import { SvelteMap } from 'svelte/reactivity'; import type { AlbumBasicInfo, AlbumTracksInfo, @@ -15,6 +16,8 @@ import type { LocalTrackInfo, NavidromeAlbumMatch, NavidromeTrackInfo, + PlexAlbumMatch, + PlexTrackInfo, LastFmAlbumEnrichment } from '$lib/types'; import { libraryStore } from '$lib/stores/library'; @@ -37,6 +40,7 @@ import type { QueueItem } from '$lib/player/types'; import { launchJellyfinPlayback } from '$lib/player/launchJellyfinPlayback'; import { launchLocalPlayback } from '$lib/player/launchLocalPlayback'; import { launchNavidromePlayback } from '$lib/player/launchNavidromePlayback'; +import { launchPlexPlayback } from '$lib/player/launchPlexPlayback'; import type { MenuItem } from '$lib/components/ContextMenu.svelte'; import { fetchAlbumBasic, @@ -47,6 +51,7 @@ import { fetchJellyfinMatch, fetchLocalMatch, fetchNavidromeMatch, + fetchPlexMatch, fetchLastFm, refreshAlbum } from './albumFetchers'; @@ -92,9 +97,11 @@ export function createAlbumPageState(albumIdGetter: () => string) { let jellyfinMatch = $state(null); let localMatch = $state(null); let navidromeMatch = $state(null); + let plexMatch = $state(null); let loadingJellyfin = $state(false); let loadingLocal = $state(false); let loadingNavidrome = $state(false); + let loadingPlex = $state(false); let lastfmEnrichment = $state(null); let loadingLastfm = $state(true); let renderedTrackSections = $state([]); @@ -106,14 +113,17 @@ export function createAlbumPageState(albumIdGetter: () => string) { let artistInLidarr = $state(false); let artistMonitored = $state(false); - // eslint-disable-next-line svelte/prefer-svelte-reactivity -- derived Map is recreated each time, reactive by nature - const trackLinkMap = $derived(new Map(trackLinks.map((tl) => [getDiscTrackKey(tl), tl]))); + const trackLinkMap = $derived.by( + () => new SvelteMap(trackLinks.map((tl) => [getDiscTrackKey(tl), tl])) + ); const jellyfinTracks = $derived([...(jellyfinMatch?.tracks ?? [])].sort(compareDiscTrack)); const localTracks = $derived([...(localMatch?.tracks ?? [])].sort(compareDiscTrack)); const navidromeTracks = $derived([...(navidromeMatch?.tracks ?? [])].sort(compareDiscTrack)); + const plexTracks = $derived([...(plexMatch?.tracks ?? [])].sort(compareDiscTrack)); const jellyfinTrackMap = $derived(buildSortedTrackMap(jellyfinMatch?.tracks ?? [])); const localTrackMap = $derived(buildSortedTrackMap(localMatch?.tracks ?? [])); const navidromeTrackMap = $derived(buildSortedTrackMap(navidromeMatch?.tracks ?? [])); + const plexTrackMap = $derived(buildSortedTrackMap(plexMatch?.tracks ?? [])); const inLibrary = $derived( libraryStore.isInLibrary(album?.musicbrainz_id) || album?.in_library || false ); @@ -143,9 +153,11 @@ export function createAlbumPageState(albumIdGetter: () => string) { jellyfinMatch = null; localMatch = null; navidromeMatch = null; + plexMatch = null; loadingJellyfin = false; loadingLocal = false; loadingNavidrome = false; + loadingPlex = false; lastfmEnrichment = null; loadingLastfm = true; refreshing = false; @@ -192,9 +204,11 @@ export function createAlbumPageState(albumIdGetter: () => string) { jellyfinMatch = cached.data.jellyfin; localMatch = cached.data.local; navidromeMatch = cached.data.navidrome; + plexMatch = cached.data.plex; loadingJellyfin = false; loadingLocal = false; loadingNavidrome = false; + loadingPlex = false; return false; } return true; @@ -279,7 +293,7 @@ export function createAlbumPageState(albumIdGetter: () => string) { loadingSetter: (v: boolean) => void, label: string, albumId: string, - cacheField: 'jellyfin' | 'local' | 'navidrome' + cacheField: 'jellyfin' | 'local' | 'navidrome' | 'plex' ) { loadingSetter(true); try { @@ -288,12 +302,12 @@ export function createAlbumPageState(albumIdGetter: () => string) { const existing = albumSourceMatchCache.get(albumId)?.data ?? { jellyfin: null, local: null, - navidrome: null + navidrome: null, + plex: null }; albumSourceMatchCache.set({ ...existing, [cacheField]: result }, albumId); } catch (e) { if (isAbortError(e)) return; - console.error(`Failed to fetch ${label} album data:`, e); } finally { if (!signal.aborted) loadingSetter(false); } @@ -322,7 +336,6 @@ export function createAlbumPageState(albumIdGetter: () => string) { } } catch (e) { if (isAbortError(e)) return; - console.error('Failed to fetch Last.fm album data:', e); } finally { if (!signal.aborted) loadingLastfm = false; } @@ -340,8 +353,8 @@ export function createAlbumPageState(albumIdGetter: () => string) { if (signal.aborted) return; artistInLidarr = info.in_lidarr ?? false; artistMonitored = info.monitored ?? false; - } catch (e) { - console.debug('Artist monitoring fetch failed:', e); + } catch { + return; } } @@ -355,7 +368,6 @@ export function createAlbumPageState(albumIdGetter: () => string) { artistInLidarr = false; artistMonitored = false; - // Fire source matches that only need albumId immediately (before basic loads) if (refreshSourceMatch) { void (async () => { try { @@ -383,7 +395,7 @@ export function createAlbumPageState(albumIdGetter: () => string) { 'local' ); } catch { - /* ignore integration loading errors */ + return; } })(); } @@ -401,7 +413,6 @@ export function createAlbumPageState(albumIdGetter: () => string) { if (refreshDiscovery) void doFetchDiscovery(albumId, signal); if (!refreshBasic) void doFetchYouTube(albumId, signal); if (refreshLastfm) void doFetchLastFm(albumId, signal); - // Navidrome match needs album title/artist - fire after basic loads if (refreshSourceMatch) { void (async () => { try { @@ -423,8 +434,23 @@ export function createAlbumPageState(albumIdGetter: () => string) { albumId, 'navidrome' ); + if (integrations.plex) + void doFetchSourceMatch( + signal, + () => + fetchPlexMatch( + albumId, + { albumTitle: album?.title, artistName: album?.artist_name }, + signal + ), + (v) => (plexMatch = v), + (v) => (loadingPlex = v), + 'Plex', + albumId, + 'plex' + ); } catch { - /* ignore integration loading errors */ + return; } })(); } @@ -439,7 +465,12 @@ export function createAlbumPageState(albumIdGetter: () => string) { } function hasAnySourceFound(): boolean { - return !!(jellyfinMatch?.found || localMatch?.found || navidromeMatch?.found); + return !!( + jellyfinMatch?.found || + localMatch?.found || + navidromeMatch?.found || + plexMatch?.found + ); } async function forceLoadAlbum(albumId: string): Promise { @@ -459,7 +490,7 @@ export function createAlbumPageState(albumIdGetter: () => string) { albumBasicCache.set(album, albumId); } } catch { - /* refresh endpoint failure is non-fatal, loadAlbum will re-fetch */ + void signal.aborted; } if (signal.aborted) return; @@ -558,13 +589,14 @@ export function createAlbumPageState(albumIdGetter: () => string) { const tracksGetters = { jellyfin: () => jellyfinTracks, local: () => localTracks, - navidrome: () => navidromeTracks + navidrome: () => navidromeTracks, + plex: () => plexTracks }; const albumGetter = () => album; const playlistRefGetter = () => playlistModalRef; function playSourceTrack( - source: 'jellyfin' | 'local' | 'navidrome', + source: 'jellyfin' | 'local' | 'navidrome' | 'plex', trackPosition: number, discNumber: number, title: string @@ -578,7 +610,8 @@ export function createAlbumPageState(albumIdGetter: () => string) { album, jellyfinMatch, localMatch, - navidromeMatch + navidromeMatch, + plexMatch ); } @@ -586,7 +619,8 @@ export function createAlbumPageState(albumIdGetter: () => string) { track: { position: number; disc_number?: number | null; title: string }, resolvedLocal: LocalTrackInfo | null, resolvedJellyfin: JellyfinTrackInfo | null, - resolvedNavidrome: NavidromeTrackInfo | null = null + resolvedNavidrome: NavidromeTrackInfo | null = null, + resolvedPlex: PlexTrackInfo | null = null ): MenuItem[] { if (!album) return []; return getTrackContextMenuItemsImpl( @@ -595,6 +629,7 @@ export function createAlbumPageState(albumIdGetter: () => string) { resolvedLocal, resolvedJellyfin, resolvedNavidrome, + resolvedPlex, playlistModalRef ); } @@ -623,6 +658,14 @@ export function createAlbumPageState(albumIdGetter: () => string) { tracksGetters, playlistRefGetter ); + const plexCallbacks: SourceCallbacks = buildSourceCallbacks( + () => plexMatch, + launchPlexPlayback, + 'plex', + albumGetter, + tracksGetters, + playlistRefGetter + ); return { get album() { @@ -700,6 +743,9 @@ export function createAlbumPageState(albumIdGetter: () => string) { get navidromeMatch() { return navidromeMatch; }, + get plexMatch() { + return plexMatch; + }, get loadingJellyfin() { return loadingJellyfin; }, @@ -709,6 +755,9 @@ export function createAlbumPageState(albumIdGetter: () => string) { get loadingNavidrome() { return loadingNavidrome; }, + get loadingPlex() { + return loadingPlex; + }, get lastfmEnrichment() { return lastfmEnrichment; }, @@ -730,6 +779,9 @@ export function createAlbumPageState(albumIdGetter: () => string) { get navidromeTracks() { return navidromeTracks; }, + get plexTracks() { + return plexTracks; + }, get jellyfinTrackMap() { return jellyfinTrackMap; }, @@ -739,6 +791,9 @@ export function createAlbumPageState(albumIdGetter: () => string) { get navidromeTrackMap() { return navidromeTrackMap; }, + get plexTrackMap() { + return plexTrackMap; + }, get inLibrary() { return inLibrary; }, @@ -766,6 +821,7 @@ export function createAlbumPageState(albumIdGetter: () => string) { jellyfinCallbacks, localCallbacks, navidromeCallbacks, + plexCallbacks, ...eventHandlers, retryTracks, refreshAll, diff --git a/frontend/src/routes/album/[id]/albumPlaybackHandlers.ts b/frontend/src/routes/album/[id]/albumPlaybackHandlers.ts index 5cda71f..b4e46de 100644 --- a/frontend/src/routes/album/[id]/albumPlaybackHandlers.ts +++ b/frontend/src/routes/album/[id]/albumPlaybackHandlers.ts @@ -5,7 +5,9 @@ import type { LocalAlbumMatch, LocalTrackInfo, NavidromeAlbumMatch, - NavidromeTrackInfo + NavidromeTrackInfo, + PlexAlbumMatch, + PlexTrackInfo } from '$lib/types'; import type { QueueItem, PlaybackMeta } from '$lib/player/types'; import type { TrackMeta, TrackSourceData } from '$lib/player/queueHelpers'; @@ -14,6 +16,7 @@ import { buildQueueItemsFromJellyfin, buildQueueItemsFromLocal, buildQueueItemsFromNavidrome, + buildQueueItemsFromPlex, compareDiscTrack, normalizeDiscNumber } from '$lib/player/queueHelpers'; @@ -21,6 +24,7 @@ import { getCoverUrl } from '$lib/utils/errorHandling'; import { launchJellyfinPlayback } from '$lib/player/launchJellyfinPlayback'; import { launchLocalPlayback } from '$lib/player/launchLocalPlayback'; import { launchNavidromePlayback } from '$lib/player/launchNavidromePlayback'; +import { launchPlexPlayback } from '$lib/player/launchPlexPlayback'; import { playerStore } from '$lib/stores/player.svelte'; import type { MenuItem } from '$lib/components/ContextMenu.svelte'; import { ListPlus, ListStart, ListMusic } from 'lucide-svelte'; @@ -88,18 +92,20 @@ export function playSource< } export function playSourceTrack( - source: 'jellyfin' | 'local' | 'navidrome', + source: 'jellyfin' | 'local' | 'navidrome' | 'plex', trackPosition: number, discNumber: number, title: string, album: AlbumBasicInfo, jellyfinMatch: JellyfinAlbumMatch | null, localMatch: LocalAlbumMatch | null, - navidromeMatch: NavidromeAlbumMatch | null + navidromeMatch: NavidromeAlbumMatch | null, + plexMatch: PlexAlbumMatch | null ): void { const opts = { startTrack: trackPosition, startDisc: discNumber, startTitle: title }; if (source === 'jellyfin') playSource(jellyfinMatch, launchJellyfinPlayback, album, opts); else if (source === 'local') playSource(localMatch, launchLocalPlayback, album, opts); + else if (source === 'plex') playSource(plexMatch, launchPlexPlayback, album, opts); else playSource(navidromeMatch, launchNavidromePlayback, album, opts); } @@ -108,7 +114,8 @@ export function buildTrackQueueItem( album: AlbumBasicInfo, resolvedLocal: LocalTrackInfo | null, resolvedJellyfin: JellyfinTrackInfo | null, - resolvedNavidrome: NavidromeTrackInfo | null = null + resolvedNavidrome: NavidromeTrackInfo | null = null, + resolvedPlex: PlexTrackInfo | null = null ): QueueItem | null { const sourceData: TrackSourceData = { trackPosition: track.position, @@ -118,10 +125,12 @@ export function buildTrackQueueItem( resolvedLocal?.duration_seconds ?? resolvedNavidrome?.duration_seconds ?? resolvedJellyfin?.duration_seconds ?? + resolvedPlex?.duration_seconds ?? undefined, localTrack: resolvedLocal, navidromeTrack: resolvedNavidrome, - jellyfinTrack: resolvedJellyfin + jellyfinTrack: resolvedJellyfin, + plexTrack: resolvedPlex }; return buildQueueItem(getTrackMeta(album), sourceData); } @@ -132,6 +141,7 @@ export function getTrackContextMenuItems( resolvedLocal: LocalTrackInfo | null, resolvedJellyfin: JellyfinTrackInfo | null, resolvedNavidrome: NavidromeTrackInfo | null, + resolvedPlex: PlexTrackInfo | null, playlistModalRef: { open: (tracks: QueueItem[]) => void } | null ): MenuItem[] { const queueItem = buildTrackQueueItem( @@ -139,7 +149,8 @@ export function getTrackContextMenuItems( album, resolvedLocal, resolvedJellyfin, - resolvedNavidrome + resolvedNavidrome, + resolvedPlex ); const hasSource = queueItem !== null; return [ @@ -171,30 +182,41 @@ export function getTrackContextMenuItems( } function getSourceQueueItems( - source: 'jellyfin' | 'local' | 'navidrome', + source: 'jellyfin' | 'local' | 'navidrome' | 'plex', album: AlbumBasicInfo, jellyfinTracks: JellyfinTrackInfo[], localTracks: LocalTrackInfo[], - navidromeTracks: NavidromeTrackInfo[] + navidromeTracks: NavidromeTrackInfo[], + plexTracks: PlexTrackInfo[] ): QueueItem[] { const meta = getTrackMeta(album); if (source === 'jellyfin') return buildQueueItemsFromJellyfin([...jellyfinTracks].sort(compareDiscTrack), meta); if (source === 'navidrome') return buildQueueItemsFromNavidrome([...navidromeTracks].sort(compareDiscTrack), meta); + if (source === 'plex') + return buildQueueItemsFromPlex([...plexTracks].sort(compareDiscTrack), meta); return buildQueueItemsFromLocal([...localTracks].sort(compareDiscTrack), meta); } -export function buildSourceCallbacks( - matchGetter: () => JellyfinAlbumMatch | LocalAlbumMatch | NavidromeAlbumMatch | null, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- launcher generics vary by source - launcher: (tracks: any[], startIndex: number, shuffle: boolean, meta: PlaybackMeta) => void, - source: 'jellyfin' | 'local' | 'navidrome', +export function buildSourceCallbacks< + TTrack extends { track_number: number; disc_number?: number | null; title: string }, + TMatch extends { tracks: TTrack[] } | null +>( + matchGetter: () => TMatch, + launcher: ( + tracks: TTrack[], + startIndex: number | undefined, + shuffle: boolean | undefined, + meta: PlaybackMeta + ) => void, + source: 'jellyfin' | 'local' | 'navidrome' | 'plex', albumGetter: () => AlbumBasicInfo | null, tracksGetters: { jellyfin: () => JellyfinTrackInfo[]; local: () => LocalTrackInfo[]; navidrome: () => NavidromeTrackInfo[]; + plex: () => PlexTrackInfo[]; }, playlistModalRefGetter: () => { open: (tracks: QueueItem[]) => void } | null ): SourceCallbacks { @@ -215,7 +237,8 @@ export function buildSourceCallbacks( a, tracksGetters.jellyfin(), tracksGetters.local(), - tracksGetters.navidrome() + tracksGetters.navidrome(), + tracksGetters.plex() ); if (items.length > 0) playerStore.addMultipleToQueue(items); }, @@ -227,7 +250,8 @@ export function buildSourceCallbacks( a, tracksGetters.jellyfin(), tracksGetters.local(), - tracksGetters.navidrome() + tracksGetters.navidrome(), + tracksGetters.plex() ); if (items.length > 0) playerStore.playMultipleNext(items); }, @@ -239,7 +263,8 @@ export function buildSourceCallbacks( a, tracksGetters.jellyfin(), tracksGetters.local(), - tracksGetters.navidrome() + tracksGetters.navidrome(), + tracksGetters.plex() ); if (items.length > 0) playlistModalRefGetter()?.open(items); } diff --git a/frontend/src/routes/library/+page.svelte b/frontend/src/routes/library/+page.svelte index 4d87c1d..7b010b2 100644 --- a/frontend/src/routes/library/+page.svelte +++ b/frontend/src/routes/library/+page.svelte @@ -133,7 +133,7 @@ const data = await api.global.get<{ sync_frequency: string }>('/api/v1/settings/lidarr'); syncFrequencyLabel = FREQ_LABELS[data.sync_frequency] ?? null; } catch { - // Silently omit frequency hint if settings can't be loaded + // non-critical; use default label } } @@ -275,7 +275,7 @@
    {error} {#if isConnectionError} - Check Lidarr settings → + Open Lidarr settings {/if}
    @@ -308,7 +308,7 @@ {#if loadingStats} {:else} - {stats.artist_count} artists • {stats.album_count} albums • Last sync: {lastSyncText} + {stats.artist_count} artists, {stats.album_count} albums, last sync {lastSyncText} {/if}

    {#if syncFrequencyLabel} @@ -342,7 +342,7 @@ stroke-linecap="round" stroke-linejoin="round"> - + {#if syncStatus.isDismissed} {/if} {:else}
    - {/snippet} + {/if} - {#snippet emptyIcon()} - - {/snippet} - + {#if loading && !hub} + + {:else} + openAlbumDetail(a as JellyfinAlbumSummary)} + /> + + {#if similarSeedName || similarLoading || similarAlbums.length > 0} +
    + + {#if similarAlbums.length > 0} + + {#each similarAlbums as album (album.jellyfin_id)} + openAlbumDetail(album)} + /> + {/each} + + {:else if !similarLoading} +

    No similar albums found.

    + {/if} +
    +
    + {/if} + + {#if loading || (favExpanded && (favExpanded.albums.length > 0 || favExpanded.artists.length > 0))} +
    + +
    + {#if !favExpanded || favExpanded.albums.length > 0} + + {/if} + {#if !favExpanded || favExpanded.artists.length > 0} + + {/if} +
    + {#if favTab === 'albums'} + {#if favExpanded && favExpanded.albums.length > 0} + + {#each favExpanded.albums as album (album.jellyfin_id)} + openAlbumDetail(album)} + /> + {/each} + + {/if} + {:else if favExpanded && favExpanded.artists.length > 0} + + {#each favExpanded.artists as artist (artist.jellyfin_id)} +
    +
    + +
    +

    {artist.name}

    +

    + {artist.album_count} album{artist.album_count !== 1 ? 's' : ''} +

    +
    + {/each} +
    + {/if} +
    +
    + {/if} + + {#if (hub && hub.genres.length > 0) || mixTracks.length > 0} + + loadInstantMixByGenre(mixLabel) : undefined} + > + {#snippet actions()} + {#if hub && hub.genres.length > 0} + { + if (g) loadInstantMixByGenre(g); + }} + /> + {/if} + {/snippet} + {#if mixTracks.length > 0} +
    + + +
    + playMixTracks(i)} + {formatDuration} + /> + {/if} +
    + + {#if hub && hub.genres.length > 0} + + + + {/if} +
    + {/if} + +
    + + {#if hub && hub.recently_added.length > 0} + openAlbumDetail(a as JellyfinAlbumSummary)} + /> + {:else if hub} +

    Nothing new yet.

    + {/if} +
    +
    + + {#if hub && (hub.most_played_artists.length > 0 || hub.most_played_albums.length > 0)} +
    + + ({ + id: a.jellyfin_id, + name: a.name, + image_url: a.image_url, + musicbrainz_id: a.musicbrainz_id, + play_count: a.play_count, + album_count: a.album_count + }))} + albums={hub.most_played_albums.map((a) => ({ + id: a.jellyfin_id, + name: a.name, + artist_name: a.artist_name, + image_url: a.image_url, + musicbrainz_id: a.musicbrainz_id, + play_count: a.play_count + }))} + onAlbumClick={(album) => { + const orig = hub?.most_played_albums.find((a) => a.jellyfin_id === album.id); + if (orig) openAlbumDetail(orig); + }} + /> + +
    + {/if} + +
    + +
    + + {#if genericArtistIndex.length > 0} + { + if (artist.musicbrainz_id) { + goto(`/artist/${artist.musicbrainz_id}`); + } else { + goto(`/library/jellyfin/artists?search=${encodeURIComponent(artist.name)}`); + } + }} + /> + {:else if !artistIndexLoading} +

    No artists found.

    + {/if} +
    + + + {#if hub && hub.all_albums_preview.length > 0} + + {#each hub?.all_albums_preview ?? [] as album (album.jellyfin_id)} + openAlbumDetail(album)} + /> + {/each} + + {:else if hub} +

    No albums found.

    + {/if} +
    +
    + {/if} +
    + + { + modalOpen = false; + selectedAlbum = null; + }} +/> diff --git a/frontend/src/routes/library/jellyfin/albums/+page.svelte b/frontend/src/routes/library/jellyfin/albums/+page.svelte new file mode 100644 index 0000000..5c650e7 --- /dev/null +++ b/frontend/src/routes/library/jellyfin/albums/+page.svelte @@ -0,0 +1,210 @@ + + + + {#snippet headerIcon()} + + {/snippet} + + {#snippet cardTopLeftBadge(_album)} +
    + +
    + {/snippet} + + {#snippet emptyIcon()} + + {/snippet} +
    diff --git a/frontend/src/routes/library/jellyfin/artists/+page.svelte b/frontend/src/routes/library/jellyfin/artists/+page.svelte new file mode 100644 index 0000000..e79cfd4 --- /dev/null +++ b/frontend/src/routes/library/jellyfin/artists/+page.svelte @@ -0,0 +1,180 @@ + + +
    +
    + +
    + + + Back + + +

    Jellyfin Artists

    + {#if artists.total > 0} + {artists.total} + {/if} +
    + + + + {#if loading} +
    +
    + {#each Array(PAGE_SIZE) as _, i (i)} + + {/each} +
    +
    + {:else if artists.items.length === 0} +
    + +

    {searchQuery ? 'No results found' : 'No artists found'}

    +

    + {searchQuery + ? `Try a different search term` + : 'Make sure Jellyfin has music in its library'} +

    +
    + {:else} +
    +
    + {#each artists.items as artist (artist.jellyfin_id)} + + {/each} +
    +
    + + {#if totalPages > 1} +
    + + + Page {currentPage + 1} of {totalPages} + + +
    + {/if} + {/if} +
    diff --git a/frontend/src/routes/library/jellyfin/playlists/+page.svelte b/frontend/src/routes/library/jellyfin/playlists/+page.svelte new file mode 100644 index 0000000..d04b46e --- /dev/null +++ b/frontend/src/routes/library/jellyfin/playlists/+page.svelte @@ -0,0 +1,52 @@ + + +
    +
    + + +

    Jellyfin Playlists

    +
    + + {#if loading} +
    + +
    + {:else if error} +
    {error}
    + {:else if playlists.length === 0} +

    No playlists were found in Jellyfin.

    + {:else} +
    + {#each playlists as playlist (playlist.id)} + + {/each} +
    + {/if} +
    diff --git a/frontend/src/routes/library/jellyfin/playlists/[id]/+page.svelte b/frontend/src/routes/library/jellyfin/playlists/[id]/+page.svelte new file mode 100644 index 0000000..836ab6d --- /dev/null +++ b/frontend/src/routes/library/jellyfin/playlists/[id]/+page.svelte @@ -0,0 +1,19 @@ + + + + {#snippet icon()} + + {/snippet} + diff --git a/frontend/src/routes/library/jellyfin/tracks/+page.svelte b/frontend/src/routes/library/jellyfin/tracks/+page.svelte new file mode 100644 index 0000000..201b85d --- /dev/null +++ b/frontend/src/routes/library/jellyfin/tracks/+page.svelte @@ -0,0 +1,372 @@ + + +
    +
    + +
    + + + Back + + +

    Jellyfin Tracks

    + {#if data.total > 0} + {data.total} + {/if} +
    + + + + {#if loading} +
    +
    + {#each Array(12) as _, i (i)} +
    +
    +
    + + +
    +
    + {/each} +
    +
    + {:else if data.items.length === 0} +
    + +

    {searchQuery ? 'No matches' : 'No tracks yet'}

    +

    + {searchQuery ? 'Try another search term.' : 'Add music to Jellyfin to see tracks here.'} +

    +
    + {:else} + {#if data.items.length > 0} +
    + {#if loader.loading} + + {:else} + + + {/if} +
    + {/if} + +
    + + + + + + + + + + + + + {#each data.items as track, i (track.jellyfin_id)} + {@const playing = isTrackPlaying(track)} + playTrack(i)} + onkeydown={(e) => + (e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), playTrack(i))} + tabindex="0" + role="button" + aria-label="Play {track.title}" + > + + + + + + + + {/each} + +
    #Title
    +
    + {#if playing} + + {:else} + {i + 1} +
    +
    +
    + {track.title} +
    +
    + {track.artist_name} +
    +
    +
    + + {#if totalPages > 1} +
    + + + Page {currentPage + 1} of {totalPages} + + +
    + {/if} + {/if} +
    diff --git a/frontend/src/routes/library/local/+page.svelte b/frontend/src/routes/library/local/+page.svelte index 5b653fe..f3e43d9 100644 --- a/frontend/src/routes/library/local/+page.svelte +++ b/frontend/src/routes/library/local/+page.svelte @@ -59,6 +59,7 @@ recentAlbums: recentRes.status === 'fulfilled' ? recentRes.value : current.recentAlbums, favoriteAlbums: [], genres: [], + moods: [], stats: statsRes.status === 'fulfilled' ? (statsRes.value as unknown as Record) @@ -106,6 +107,7 @@ recentAlbums: c.data.recentAlbums, favoriteAlbums: [], genres: [], + moods: [], stats: c.data.stats as unknown as Record | null } as SidebarData, timestamp: c.timestamp @@ -128,6 +130,9 @@ descValue: 'desc', getDefaultSortOrder: (field) => (field === 'name' ? 'asc' : 'desc'), supportsGenres: false, + supportsMoods: false, + supportsDecades: false, + supportsTags: false, supportsFavorites: false, supportsShuffle: true, errorMessage: "Couldn't reach the local files service" @@ -143,7 +148,7 @@ recentLabel="Recently Added" contextMenuBackdrop emptyTitle="No local files found" - emptyDescription="Make sure your music directory is mounted and configured in Settings." + emptyDescription="Check that your music folder is mounted and set up in Settings." > {#snippet headerIcon()} diff --git a/frontend/src/routes/library/navidrome/+page.svelte b/frontend/src/routes/library/navidrome/+page.svelte index b2f9d68..29baa27 100644 --- a/frontend/src/routes/library/navidrome/+page.svelte +++ b/frontend/src/routes/library/navidrome/+page.svelte @@ -1,192 +1,651 @@ - - {#snippet headerIcon()} - - - - {/snippet} +
    +
    - {#snippet cardTopLeftBadge(album)} -
    - + + {#snippet icon()} + + + + {/snippet} + + + + + {#if hub && hub.playlists.length > 0} + + {#snippet sourceIcon()} + + + + {/snippet} + + {/if} + + + + {#if error} + - {#if !album.musicbrainz_id} -
    - ? + {/if} + + {#if loading && !hub} + + {:else} + openAlbumDetail(a as NavidromeAlbumSummary)} + /> + + {#if hub && (hub.favorites.length > 0 || hub.favorite_artists.length > 0 || hub.favorite_tracks.length > 0)} +
    + + {#if hub && (hub.favorites.length > 0 || hub.favorite_artists.length > 0 || hub.favorite_tracks.length > 0)} +
    + + + +
    + + {#if favTab === 'albums'} + {#if hub.favorites.length > 0} + + {#each hub.favorites as album (album.navidrome_id)} + openAlbumDetail(album)} + /> + {/each} + + {:else} +

    No favorite albums yet.

    + {/if} + {:else if favTab === 'artists'} + {#if hub.favorite_artists.length > 0} + + {#each hub.favorite_artists as artist (artist.navidrome_id)} +
    +
    + +
    +

    {artist.name}

    +

    + {artist.album_count} album{artist.album_count !== 1 ? 's' : ''} +

    +
    + {/each} +
    + {:else} +

    No favorite artists yet.

    + {/if} + {:else if favTab === 'tracks'} + {#if hub.favorite_tracks.length > 0} +
    + + + + + + + + + + + + {#each hub.favorite_tracks as track, i (track.navidrome_id)} + + + + + + + + {/each} + +
    #TitleArtistAlbumDuration
    {i + 1}{track.title}{track.artist_name}{track.album_name}{formatDuration(track.duration_seconds)}
    +
    + {:else} +

    No favorite tracks yet.

    + {/if} + {/if} + {/if} +
    {/if} - {/snippet} - {#snippet emptyIcon()} - - {/snippet} - + + + {#snippet actions()} + {#if hub && hub.genres.length > 0} + { + selectedGenre = g; + loadRandomTracks(); + }} + /> + {/if} + {/snippet} + {#if randomTracks.length > 0} +
    + + +
    + playRandomTracks(i)} + {formatDuration} + /> + {/if} +
    + +
    + + + {#if hub && hub.genres.length > 0} + + {:else if hub} +

    No genres found.

    + {/if} +
    +
    + + {#if topSongsArtist} +
    + + {#if topSongs.length > 0} +
    + +
    + playTopSongs(i)} + {formatDuration} + /> + {/if} +
    +
    + {/if} + + {#if hub?.favorite_tracks?.length} +
    + + {#if similarSongs.length > 0} +
    + +
    + playSimilarSongs(i)} + {formatDuration} + /> + {/if} +
    +
    + {/if} + + {#if artistInfo} +
    + +
    + {#if artistInfo.image_url} + {artistInfo.name} + {/if} +
    + {#if artistInfo.biography} +

    {artistInfo.biography}

    + {/if} + {#if artistInfo.similar_artists.length > 0} +
    +

    Similar Artists

    +
    + {#each artistInfo.similar_artists.slice(0, 8) as sa (sa.navidrome_id || sa.name)} + {sa.name} + {/each} +
    +
    + {/if} +
    +
    +
    +
    + {/if} + +
    + +
    + + {#if genericArtistIndex.length > 0} + { + if (artist.musicbrainz_id) { + goto(`/artist/${artist.musicbrainz_id}`); + } else { + goto(`/library/navidrome/albums?search=${encodeURIComponent(artist.name)}`); + } + }} + /> + {:else if !artistIndexLoading} +

    No artists found.

    + {/if} +
    + + + {#if hub && hub.all_albums_preview.length > 0} + + {#each hub?.all_albums_preview ?? [] as album (album.navidrome_id)} + openAlbumDetail(album)} + /> + {/each} + + {:else if hub} +

    No albums found.

    + {/if} +
    +
    + {/if} +
    + + { + modalOpen = false; + selectedAlbum = null; + }} +/> diff --git a/frontend/src/routes/library/navidrome/albums/+page.svelte b/frontend/src/routes/library/navidrome/albums/+page.svelte new file mode 100644 index 0000000..a690391 --- /dev/null +++ b/frontend/src/routes/library/navidrome/albums/+page.svelte @@ -0,0 +1,205 @@ + + + + {#snippet headerIcon()} + + + + {/snippet} + + {#snippet cardTopLeftBadge(album)} +
    + +
    + {#if !album.musicbrainz_id} +
    + ? +
    + {/if} + {/snippet} + + {#snippet emptyIcon()} + + {/snippet} +
    diff --git a/frontend/src/routes/library/navidrome/artists/+page.svelte b/frontend/src/routes/library/navidrome/artists/+page.svelte new file mode 100644 index 0000000..20c1c6f --- /dev/null +++ b/frontend/src/routes/library/navidrome/artists/+page.svelte @@ -0,0 +1,144 @@ + + +
    +
    + +
    + + + Back + + +

    Navidrome Artists

    + {#if artists.total > 0} + {artists.total} + {/if} +
    + + + + {#if loading} +
    +
    + {#each Array(PAGE_SIZE) as _, i (i)} + + {/each} +
    +
    + {:else if artists.items.length === 0} +
    + +

    {searchQuery ? 'No results found' : 'No artists found'}

    +

    + {searchQuery + ? `Try a different search term` + : 'Make sure Navidrome has music in its library'} +

    +
    + {:else} +
    +
    + {#each artists.items as artist (artist.navidrome_id)} + + {/each} +
    +
    + + {#if totalPages > 1} +
    + + + Page {currentPage + 1} of {totalPages} + + +
    + {/if} + {/if} +
    diff --git a/frontend/src/routes/library/navidrome/playlists/+page.svelte b/frontend/src/routes/library/navidrome/playlists/+page.svelte new file mode 100644 index 0000000..7c3c908 --- /dev/null +++ b/frontend/src/routes/library/navidrome/playlists/+page.svelte @@ -0,0 +1,52 @@ + + +
    +
    + + +

    Navidrome Playlists

    +
    + + {#if loading} +
    + +
    + {:else if error} +
    {error}
    + {:else if playlists.length === 0} +

    No playlists were found in Navidrome.

    + {:else} +
    + {#each playlists as playlist (playlist.id)} + + {/each} +
    + {/if} +
    diff --git a/frontend/src/routes/library/navidrome/playlists/[id]/+page.svelte b/frontend/src/routes/library/navidrome/playlists/[id]/+page.svelte new file mode 100644 index 0000000..4b033a9 --- /dev/null +++ b/frontend/src/routes/library/navidrome/playlists/[id]/+page.svelte @@ -0,0 +1,19 @@ + + + + {#snippet icon()} + + {/snippet} + diff --git a/frontend/src/routes/library/navidrome/tracks/+page.svelte b/frontend/src/routes/library/navidrome/tracks/+page.svelte new file mode 100644 index 0000000..835f70a --- /dev/null +++ b/frontend/src/routes/library/navidrome/tracks/+page.svelte @@ -0,0 +1,334 @@ + + +
    +
    + +
    + + + Back + + +

    Navidrome Tracks

    + {#if data.total > 0} + {data.total} + {/if} +
    + + + + {#if loading} +
    +
    + {#each Array(12) as _, i (i)} +
    +
    +
    + + +
    +
    + {/each} +
    +
    + {:else if data.items.length === 0} +
    + +

    {searchQuery ? 'No matches' : 'No tracks yet'}

    +

    + {searchQuery ? 'Try another search term.' : 'Add music to Navidrome to see tracks here.'} +

    +
    + {:else} + {#if data.items.length > 0} +
    + {#if loader.loading} + + {:else} + + + {/if} +
    + {/if} + +
    + + + + + + + + + + + + + {#each data.items as track, i (track.navidrome_id)} + {@const playing = isTrackPlaying(track)} + playTrack(i)} + onkeydown={(e) => + (e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), playTrack(i))} + tabindex="0" + role="button" + aria-label="Play {track.title}" + > + + + + + + + + {/each} + +
    #Title
    +
    + {#if playing} + + {:else} + {i + 1} +
    +
    +
    + {track.title} +
    +
    + {track.artist_name} +
    +
    +
    + + {#if totalPages > 1} +
    + + + Page {currentPage + 1} of {totalPages} + + +
    + {/if} + {/if} +
    diff --git a/frontend/src/routes/library/plex/+page.svelte b/frontend/src/routes/library/plex/+page.svelte new file mode 100644 index 0000000..7489ab8 --- /dev/null +++ b/frontend/src/routes/library/plex/+page.svelte @@ -0,0 +1,483 @@ + + +
    +
    + + + {#snippet icon()} + + {/snippet} + {#snippet settingsSnippet()} +
    + +
    + {/snippet} +
    + + + + {#if hub && hub.playlists.length > 0} + + {#snippet sourceIcon()} + + {/snippet} + + {/if} + + + + {#if error} + + {/if} + + {#if loading && !hub} + + {:else} + openAlbumDetail(a as PlexAlbumSummary)} + /> + + + + {#each discoveryHubs as dHub (dHub.title)} +
    +

    {dHub.title}

    + + {#each dHub.albums as album (album.plex_id)} + + openAlbumDetail({ + plex_id: album.plex_id, + name: album.name, + artist_name: album.artist_name, + year: album.year, + image_url: album.image_url, + track_count: 0 + })} + /> + {/each} + +
    + {/each} +
    + + {#if hub && hub.genres.length > 0} + + + + {/if} +
    + +
    + + {#if hub && hub.recently_added.length > 0} + openAlbumDetail(a as PlexAlbumSummary)} + /> + {:else if hub} +

    Nothing new yet.

    + {/if} +
    +
    + +
    + +
    + + {#if historyEntries.length > 0} +
    + + + + + + + + + + + + {#each historyEntries as entry (entry.rating_key + entry.viewed_at)} + + + + + + + + {/each} + +
    TrackArtistAlbumWhenDevice
    {entry.track_title}{entry.artist_name}{entry.album_name}{formatViewedAt(entry.viewed_at)}{entry.device_name}
    +
    + {#if historyTotal > 10} + + {/if} + {:else if !historyLoading} +

    No listening history is available yet.

    + {/if} +
    +
    + +
    + +
    + + {#if genericArtistIndex.length > 0} + { + if (artist.musicbrainz_id) { + goto(`/artist/${artist.musicbrainz_id}`); + } else { + goto(`/library/plex/artists?search=${encodeURIComponent(artist.name)}`); + } + }} + /> + {:else if !artistIndexLoading} +

    No artists found.

    + {/if} +
    + + + {#if hub && hub.all_albums_preview.length > 0} + + {#each hub?.all_albums_preview ?? [] as album (album.plex_id)} + openAlbumDetail(album)} + /> + {/each} + + {:else if hub} +

    No albums found.

    + {/if} +
    +
    + {/if} +
    + + { + modalOpen = false; + selectedAlbum = null; + }} +/> diff --git a/frontend/src/routes/library/plex/activity/+page.svelte b/frontend/src/routes/library/plex/activity/+page.svelte new file mode 100644 index 0000000..69d0ccd --- /dev/null +++ b/frontend/src/routes/library/plex/activity/+page.svelte @@ -0,0 +1,209 @@ + + +
    +
    + ← Back + +

    Plex activity and analytics

    +
    + + {#if analyticsLoading} +
    + +
    + {:else if analytics} +
    +
    +
    Total Listens
    +
    {analytics.total_listens.toLocaleString()}
    +
    +
    +
    Last 7 Days
    +
    {analytics.listens_last_7_days.toLocaleString()}
    +
    +
    +
    Last 30 Days
    +
    {analytics.listens_last_30_days.toLocaleString()}
    +
    +
    +
    Hours Listened
    +
    {analytics.total_hours}
    +
    +
    + + {#if !analytics.is_complete} +
    + These stats are based on {analytics.entries_analyzed.toLocaleString()} of {analytics.total_listens.toLocaleString()} + total plays. +
    + {/if} + +
    + {#if analytics.top_artists.length > 0} +
    +

    Top Artists

    +
    + {#each analytics.top_artists as item, i (item.name)} +
    + {i + 1} +
    +

    {item.name}

    +
    + {item.play_count} +
    + {/each} +
    +
    + {/if} + + {#if analytics.top_albums.length > 0} +
    +

    Top Albums

    +
    + {#each analytics.top_albums as item, i (item.name)} +
    + {i + 1} +
    +

    {item.name}

    +

    {item.subtitle}

    +
    + {item.play_count} +
    + {/each} +
    +
    + {/if} + + {#if analytics.top_tracks.length > 0} +
    +

    Top Tracks

    +
    + {#each analytics.top_tracks as item, i (item.name)} +
    + {i + 1} +
    +

    {item.name}

    +

    {item.subtitle}

    +
    + {item.play_count} +
    + {/each} +
    +
    + {/if} +
    + {/if} + +
    +

    Full listening history

    + {#if historyLoading} +
    + +
    + {:else if history.length > 0} +
    + + + + + + + + + + + + {#each history as entry (entry.rating_key + entry.viewed_at)} + + + + + + + + {/each} + +
    TrackArtistAlbumWhenDevice
    {entry.track_title}{entry.artist_name}{entry.album_name}{formatViewedAt(entry.viewed_at)}{entry.device_name}
    +
    + +
    + + + Page {currentPage} of {totalPages} ({historyTotal.toLocaleString()} total plays) + + +
    + {:else} +

    No listening history is available yet.

    + {/if} +
    +
    diff --git a/frontend/src/routes/library/plex/albums/+page.svelte b/frontend/src/routes/library/plex/albums/+page.svelte new file mode 100644 index 0000000..ab87e3a --- /dev/null +++ b/frontend/src/routes/library/plex/albums/+page.svelte @@ -0,0 +1,208 @@ + + + + {#snippet headerIcon()} + + + + {/snippet} + + {#snippet cardTopLeftBadge(album)} +
    + +
    + {#if !album.musicbrainz_id} +
    + ? +
    + {/if} + {/snippet} + + {#snippet emptyIcon()} + + {/snippet} +
    diff --git a/frontend/src/routes/library/plex/artists/+page.svelte b/frontend/src/routes/library/plex/artists/+page.svelte new file mode 100644 index 0000000..df03024 --- /dev/null +++ b/frontend/src/routes/library/plex/artists/+page.svelte @@ -0,0 +1,173 @@ + + +
    +
    + +
    + + + Back + + + + +

    Plex Artists

    + {#if artists.total > 0} + {artists.total} + {/if} +
    + + + + {#if loading} +
    +
    + {#each Array(PAGE_SIZE) as _, i (i)} + + {/each} +
    +
    + {:else if artists.items.length === 0} +
    + +

    {searchQuery ? 'No results found' : 'No artists found'}

    +

    + {searchQuery ? `Try a different search term` : 'Make sure Plex has music in its library'} +

    +
    + {:else} +
    +
    + {#each artists.items as artist (artist.plex_id)} + + {/each} +
    +
    + + {#if totalPages > 1} +
    + + + Page {currentPage + 1} of {totalPages} + + +
    + {/if} + {/if} +
    diff --git a/frontend/src/routes/library/plex/playlists/+page.svelte b/frontend/src/routes/library/plex/playlists/+page.svelte new file mode 100644 index 0000000..584e621 --- /dev/null +++ b/frontend/src/routes/library/plex/playlists/+page.svelte @@ -0,0 +1,52 @@ + + +
    +
    + + +

    Plex Playlists

    +
    + + {#if loading} +
    + +
    + {:else if error} +
    {error}
    + {:else if playlists.length === 0} +

    No playlists were found in Plex.

    + {:else} +
    + {#each playlists as playlist (playlist.id)} + + {/each} +
    + {/if} +
    diff --git a/frontend/src/routes/library/plex/playlists/[id]/+page.svelte b/frontend/src/routes/library/plex/playlists/[id]/+page.svelte new file mode 100644 index 0000000..dcc7a8d --- /dev/null +++ b/frontend/src/routes/library/plex/playlists/[id]/+page.svelte @@ -0,0 +1,19 @@ + + + + {#snippet icon()} + + {/snippet} + diff --git a/frontend/src/routes/library/plex/tracks/+page.svelte b/frontend/src/routes/library/plex/tracks/+page.svelte new file mode 100644 index 0000000..2511e51 --- /dev/null +++ b/frontend/src/routes/library/plex/tracks/+page.svelte @@ -0,0 +1,396 @@ + + +
    +
    + +
    + + + Back + + + + +

    Plex Tracks

    + {#if data.total > 0} + {data.total} + {/if} +
    + + + + {#if loading} +
    +
    + {#each Array(12) as _, i (i)} +
    +
    +
    + + +
    +
    + {/each} +
    +
    + {:else if data.items.length === 0} +
    + +

    {searchQuery ? 'No matches' : 'No tracks yet'}

    +

    + {searchQuery ? 'Try another search term.' : 'Add music to Plex to see tracks here.'} +

    +
    + {:else} + {#if playableTracks.length > 0} +
    + {#if loader.loading} + + {:else} + + + {/if} +
    + {/if} + +
    + + + + + + + + + + + + + {#each data.items as track, i (track.plex_id)} + {@const playing = isTrackPlaying(track)} + {@const playable = !!track.part_key} + playable && playTrack(playableTracks.indexOf(track))} + onkeydown={(e) => + playable && + (e.key === 'Enter' || e.key === ' ') && + (e.preventDefault(), playTrack(playableTracks.indexOf(track)))} + tabindex={playable ? 0 : -1} + role={playable ? 'button' : undefined} + aria-label={playable ? 'Play {track.title}' : undefined} + > + + + + + + + + {/each} + +
    #Title
    +
    + {#if playing} + + {:else if playable} + {i + 1} +
    +
    +
    + {track.title} +
    +
    + {track.artist_name} +
    +
    +
    + + {#if totalPages > 1} +
    + + + Page {currentPage + 1} of {totalPages} + + +
    + {/if} + {/if} +
    diff --git a/frontend/src/routes/playlists/[id]/PlaylistHeader.svelte b/frontend/src/routes/playlists/[id]/PlaylistHeader.svelte index d6569db..25db139 100644 --- a/frontend/src/routes/playlists/[id]/PlaylistHeader.svelte +++ b/frontend/src/routes/playlists/[id]/PlaylistHeader.svelte @@ -10,7 +10,10 @@ import PlaylistMosaic from '$lib/components/PlaylistMosaic.svelte'; import ContextMenu from '$lib/components/ContextMenu.svelte'; import type { MenuItem } from '$lib/components/ContextMenu.svelte'; - import { Play, Shuffle, Pencil, Check, X } from 'lucide-svelte'; + import { Play, Shuffle, Pencil, Check, X, Tv } from 'lucide-svelte'; + import NavidromeIcon from '$lib/components/NavidromeIcon.svelte'; + import PlexIcon from '$lib/components/PlexIcon.svelte'; + import { getSourceColor, getSourceLabel } from '$lib/utils/sources'; interface Props { playlist: PlaylistDetail; @@ -24,6 +27,10 @@ import { Trash2 } from 'lucide-svelte'; + let sourceType = $derived(playlist.source_ref?.split(':')[0] ?? null); + let sourceColor = $derived(sourceType ? getSourceColor(sourceType) : null); + let sourceLabel = $derived(sourceType ? getSourceLabel(sourceType) : null); + let editingName = $state(false); let nameInput = $state(''); let savingName = $state(false); @@ -81,9 +88,9 @@ updated_at: updated.updated_at }) ); - toastStore.show({ message: 'Playlist renamed', type: 'success' }); + toastStore.show({ message: 'Playlist renamed.', type: 'success' }); } catch { - toastStore.show({ message: "Couldn't rename the playlist", type: 'error' }); + toastStore.show({ message: "Couldn't rename that playlist.", type: 'error' }); } finally { savingName = false; } @@ -109,12 +116,12 @@ if (!file) return; if (!file.type.match(/^image\/(jpeg|png|webp)$/)) { - toastStore.show({ message: 'Please select a JPEG, PNG, or WebP image', type: 'error' }); + toastStore.show({ message: 'Choose a JPEG, PNG, or WebP image.', type: 'error' }); input.value = ''; return; } if (file.size > 2 * 1024 * 1024) { - toastStore.show({ message: 'Image must be under 2MB', type: 'error' }); + toastStore.show({ message: 'The image must be smaller than 2 MB.', type: 'error' }); input.value = ''; return; } @@ -128,9 +135,9 @@ custom_cover_url: result.cover_url + '?t=' + Date.now() }) ); - toastStore.show({ message: 'Cover updated', type: 'success' }); + toastStore.show({ message: 'Cover updated.', type: 'success' }); } catch { - toastStore.show({ message: "Couldn't upload the cover", type: 'error' }); + toastStore.show({ message: "Couldn't upload that cover image.", type: 'error' }); } finally { if (coverPreview) { URL.revokeObjectURL(coverPreview); @@ -145,16 +152,16 @@ try { await deletePlaylistCover(playlist.id); onplaylistupdate(buildUpdatedPlaylist({ custom_cover_url: null })); - toastStore.show({ message: 'Cover removed', type: 'success' }); + toastStore.show({ message: 'Cover removed.', type: 'success' }); } catch { - toastStore.show({ message: "Couldn't remove the cover", type: 'error' }); + toastStore.show({ message: "Couldn't remove that cover image.", type: 'error' }); } } function getHeaderMenuItems(): MenuItem[] { return [ { - label: 'Delete Playlist', + label: 'Delete playlist', icon: Trash2, className: 'text-error', onclick: ondeleteclick @@ -268,11 +275,27 @@
    {playlist.track_count} track{playlist.track_count === 1 ? '' : 's'} {#if playlist.total_duration} - + - {formatTotalDurationSec(playlist.total_duration)} {/if} - + - Created {formatRelativeTime(new Date(playlist.created_at))} + {#if sourceType && sourceColor && sourceLabel} + - + + {#if sourceType === 'jellyfin'} + + {:else if sourceType === 'navidrome'} + + {:else if sourceType === 'plex'} + + {/if} + Imported from {sourceLabel} + + {/if}
    diff --git a/frontend/src/routes/playlists/[id]/PlaylistTrackList.svelte b/frontend/src/routes/playlists/[id]/PlaylistTrackList.svelte index 34276b7..4ed2fc8 100644 --- a/frontend/src/routes/playlists/[id]/PlaylistTrackList.svelte +++ b/frontend/src/routes/playlists/[id]/PlaylistTrackList.svelte @@ -180,7 +180,8 @@ track.id, updated.source_type as SourceType, updated.track_source_id, - updated.format ?? undefined + updated.format ?? undefined, + updated.plex_rating_key ?? undefined ); } onsourcechange?.(); diff --git a/frontend/src/routes/playlists/[id]/page.svelte.spec.ts b/frontend/src/routes/playlists/[id]/page.svelte.spec.ts index f713a5b..1a8149d 100644 --- a/frontend/src/routes/playlists/[id]/page.svelte.spec.ts +++ b/frontend/src/routes/playlists/[id]/page.svelte.spec.ts @@ -3,6 +3,10 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; import { render } from 'vitest-browser-svelte'; import type { PlaylistDetail, PlaylistTrack } from '$lib/api/playlists'; +vi.mock('$env/dynamic/public', () => ({ + env: { PUBLIC_API_URL: '' } +})); + const mockFetchPlaylist = vi.fn(); const mockDeletePlaylist = vi.fn(); const mockRemoveTrackFromPlaylist = vi.fn(); @@ -78,6 +82,7 @@ function makeTrack(overrides: Partial = {}): PlaylistTrack { disc_number: null, duration: 240, created_at: '2026-01-01T00:00:00Z', + plex_rating_key: null, ...overrides }; } @@ -90,6 +95,7 @@ function makePlaylist(overrides: Partial = {}): PlaylistDetail { total_duration: 480, cover_urls: [], custom_cover_url: null, + source_ref: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-02T00:00:00Z', tracks: [ @@ -126,7 +132,7 @@ describe('Playlist detail page', () => { try { localStorage.clear(); } catch { - /* ignore in non-browser */ + // migh throw in environments without localStorage } }); @@ -308,7 +314,6 @@ describe('Playlist detail page', () => { .element(page.getByRole('heading', { name: 'My Playlist', level: 1 })) .toBeVisible(); - // Modal exists in DOM but is not visible until opened await expect.element(page.getByText(/This will permanently remove/)).not.toBeVisible(); expect(mockDeletePlaylist).not.toHaveBeenCalled(); }); diff --git a/frontend/src/routes/playlists/page.svelte.spec.ts b/frontend/src/routes/playlists/page.svelte.spec.ts index c31b825..7f4471f 100644 --- a/frontend/src/routes/playlists/page.svelte.spec.ts +++ b/frontend/src/routes/playlists/page.svelte.spec.ts @@ -36,6 +36,7 @@ function makePlaylist(overrides: Partial = {}): PlaylistSummary total_duration: 900, cover_urls: [], custom_cover_url: null, + source_ref: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-02T00:00:00Z', ...overrides diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte index 0c4c348..2cc3e73 100644 --- a/frontend/src/routes/settings/+page.svelte +++ b/frontend/src/routes/settings/+page.svelte @@ -9,6 +9,7 @@ import SettingsLibrarySync from '$lib/components/settings/SettingsLibrarySync.svelte'; import SettingsJellyfin from '$lib/components/settings/SettingsJellyfin.svelte'; import SettingsNavidrome from '$lib/components/settings/SettingsNavidrome.svelte'; + import SettingsPlex from '$lib/components/settings/SettingsPlex.svelte'; import SettingsListenBrainz from '$lib/components/settings/SettingsListenBrainz.svelte'; import SettingsYouTube from '$lib/components/settings/SettingsYouTube.svelte'; import SettingsLocalFiles from '$lib/components/settings/SettingsLocalFiles.svelte'; @@ -30,17 +31,26 @@ } from 'lucide-svelte'; import JellyfinIcon from '$lib/components/JellyfinIcon.svelte'; import NavidromeIcon from '$lib/components/NavidromeIcon.svelte'; + import PlexIcon from '$lib/components/PlexIcon.svelte'; const integration = fromStore(integrationStore); const connectionMap: Record< string, - 'lastfm' | 'listenbrainz' | 'jellyfin' | 'navidrome' | 'youtube' | 'localfiles' | 'lidarr' + | 'lastfm' + | 'listenbrainz' + | 'jellyfin' + | 'navidrome' + | 'plex' + | 'youtube' + | 'localfiles' + | 'lidarr' > = { lastfm: 'lastfm', listenbrainz: 'listenbrainz', jellyfin: 'jellyfin', navidrome: 'navidrome', + plex: 'plex', youtube: 'youtube', 'local-files': 'localfiles', 'lidarr-connection': 'lidarr' @@ -56,6 +66,7 @@ { id: 'music-source', label: 'Music Source', group: 'Music Tracking', icon: BarChart3 }, { id: 'jellyfin', label: 'Jellyfin', group: 'Media Servers', icon: JellyfinIcon }, { id: 'navidrome', label: 'Navidrome', group: 'Media Servers', icon: NavidromeIcon }, + { id: 'plex', label: 'Plex', group: 'Media Servers', icon: PlexIcon }, { id: 'lidarr-connection', label: 'Lidarr Connection', @@ -91,7 +102,7 @@

    Settings

    -

    Manage your preferences and application settings

    +

    Manage your preferences and app settings.

    @@ -150,6 +161,8 @@ {:else if activeTab === 'navidrome'} + {:else if activeTab === 'plex'} + {:else if activeTab === 'listenbrainz'} {:else if activeTab === 'youtube'}