Compare commits

..

388 Commits

Author SHA1 Message Date
f741cc5297 fix: simplify ghost playback start to avoid Jellyfin validation errors
Some checks failed
CI / build-and-test (push) Has been cancelled
- Remove complex Item object from playback start (was causing 400 errors)
- Send minimal playback info with just ghost UUID and playback state
- Progress reports already working (204 responses)
- Jellyfin will track session without full item details
2026-02-07 13:30:47 -05:00
1a0e0216f5 feat: implement ghost item reporting for external track WebSocket sessions
- Generate deterministic UUIDs from external track IDs using MD5 hashing
- Create fake BaseItemDto objects with track metadata for external tracks
- Forward playback reports (start/progress/stop) to Jellyfin with ghost items
- Enables 'Now Playing' info in Jellyfin dashboard for external tracks
- Remove redundant JellyfinSessionManager WebSocket creation (client handles via proxy)
- Fix indentation issues in SquidWTF services (tabs to spaces)
- Add apis/*.md to .gitignore for temporary docs
- Fix null reference warning in provider switch expression
2026-02-07 13:27:25 -05:00
73bd3bf308 feat: add endpoint benchmarking on startup
- New EndpointBenchmarkService pings all endpoints on startup
- Measures average response time and success rate
- Reorders endpoints by performance (fastest first)
- RoundRobinFallbackHelper now uses benchmarked order
- Racing still happens, but starts with fastest endpoints
- Reduces latency by prioritizing known-fast servers
- Logs benchmark results for visibility
2026-02-07 12:51:48 -05:00
43bf71c390 fix: race endpoints for download metadata fetching
- GetTrackDownloadInfoAsync now uses RaceAllEndpointsAsync instead of TryWithFallbackAsync
- Prevents sequential fallback through all 10 endpoints on cancellation
- Eliminates cascade of 'task was canceled' warnings
- Consistent racing strategy across all download operations
2026-02-07 12:49:43 -05:00
2254616d32 feat: preserve source search ordering instead of re-scoring
- Respect SquidWTF/Tidal's native search ranking (better than fuzzy matching)
- Interleave local and external results based on average match quality
- Put better-matching source first, preserve original order within each source
- Remove unnecessary re-scoring that was disrupting optimal search results
- Simplifies search logic and improves result relevance
2026-02-07 12:43:15 -05:00
c0444becad feat: add endpoint racing for downloads and searches
- Race all proxy endpoints in parallel for downloads (SquidWTF)
- Use fastest responding server, cancel slower ones
- Apply same racing strategy to search operations
- Reduces download wait times from 5-10s to sub-second
- Reduces search latency from ~1s to ~300-500ms
- Add RaceAllEndpointsAsync method to RoundRobinFallbackHelper
2026-02-07 12:36:50 -05:00
b906a5fd6d Refactor: Extract duplicate code into reusable helpers
- Created RoundRobinFallbackHelper for SquidWTF services (eliminates 3 duplicates)
- Moved QueueRequestAsync to BaseDownloadService (eliminates 2 duplicates)
- Moved CalculateArtistMatchScore to FuzzyMatcher (eliminates 2 duplicates)
- Updated all SquidWTF services to use RoundRobinFallbackHelper
- Updated DeezerDownloadService and SquidWTFDownloadService to use base class rate limiting
- Updated SpotifyTrackMatchingService and JellyfinController to use FuzzyMatcher helper
- All 225 tests passing
2026-02-07 12:27:10 -05:00
e3bcc93597 Add Odesli service for Tidal to Spotify ID conversion
- Created OdesliService to convert Tidal track IDs to Spotify IDs
- Integrated Odesli API calls into SquidWTF download workflow
- Updated SquidWTFDownloadService to use OdesliService for track metadata enrichment
- Fixed dependency injection in Program.cs for OdesliService
- All 225 tests passing
2026-02-07 12:19:41 -05:00
7e6bed51e1 refactor: extract Spotify ID from URL instead of entityUniqueId
- Use linksByPlatform.spotify.url from Odesli response
- Extract track ID from Spotify URL using regex
- More reliable than parsing entityUniqueId format
- Matches the approach used in ConvertToSpotifyIdViaOdesliAsync
2026-02-07 12:06:48 -05:00
47b9427c20 fix: extract Spotify track ID from Odesli entityUniqueId format
- Odesli returns entityUniqueId as 'SPOTIFY_SONG::trackid'
- Now extracts just the track ID part after '::'
- Fixes Spotify lyrics not working due to invalid ID format
- Spotify lyrics service expects clean track IDs like '0PgYPBGqF6Wm5KFHQ81nq5'
2026-02-07 12:05:55 -05:00
bb46db43b1 fix: use persistent cache/Music folder instead of /tmp
- Cache mode now uses cache/Music/ (survives restarts, cleaned after 24h)
- Permanent mode uses downloads/ (keeps forever)
- Fixed all three download services: SquidWTF, Deezer, Qobuz
- Files no longer stored in /tmp/allstarr-cache/ which gets wiped on restart
- Both folders are in project root alongside cache/ and downloads/ directories
2026-02-07 12:02:48 -05:00
3937e637c6 feat: convert Tidal tracks to Spotify ID immediately for lyrics
- Added SpotifyId field to Song model
- SquidWTFMetadataService now calls Odesli API when fetching track metadata
- Spotify ID is populated immediately when track is loaded, not during lyrics fetch
- GetLyrics now checks song.SpotifyId first before falling back to cache/Odesli
- Enables Spotify lyrics for all SquidWTF (Tidal) tracks automatically
- Reduces latency - conversion happens once during track load, not every lyrics request
2026-02-07 11:57:24 -05:00
2272e8d363 fix: use stored session headers for WebSocket auth
- Changed MaintainWebSocketForSessionAsync to use session.Headers instead of parameter
- Parameter headers might be disposed after HTTP request completes
- Session headers are cloned and stored safely in memory
- WebSocket now properly authenticates as the client instead of falling back to server API key
- Sessions will now appear under correct user in Jellyfin dashboard
2026-02-07 11:52:17 -05:00
6169d7a4ac fix: session capabilities using disposed HTTP context
- Extract AccessToken from auth response before background task
- Create new HeaderDictionary with token instead of using Request.Headers
- Prevents ObjectDisposedException when HTTP context is disposed
- Session capabilities now work correctly for all clients

Note: WebSocket support for external tracks already implemented via
JellyfinSessionManager.EnsureSessionAsync and WebSocketProxyMiddleware
2026-02-07 11:49:43 -05:00
da8cb29e08 refactor: make authentication truly transparent proxy
- Pass through ALL Jellyfin responses (success and error) without modification
- Move session capabilities posting to background task (don't block auth response)
- Remove generic error fallbacks - always return Jellyfin's actual response
- Simplify logic: if Jellyfin returns a response, pass it through; if not, return status code only
2026-02-07 11:45:16 -05:00
d88ed64e37 fix: pass through Jellyfin error responses to client
- Modified PostJsonAsync to return error response body as JSON when available
- Updated AuthenticateByName to pass through Jellyfin's actual error response
- Clients now see Jellyfin's real error messages instead of generic ones
2026-02-07 11:42:11 -05:00
210d18220b fix: use case-insensitive provider key matching 2026-02-07 11:22:00 -05:00
c44e48a425 add debug logging to track provider identification 2026-02-07 11:21:03 -05:00
e44b46aee1 remove lyrics column from playlist table 2026-02-07 11:17:48 -05:00
a75df9328a fix: use playlist cache in view tracks endpoint 2026-02-07 11:14:36 -05:00
35c125d042 fix: skip expensive track stats query for non-Spotify playlists to prevent timeouts
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-02-07 03:48:00 -05:00
b12c971968 comment 2026-02-07 03:47:15 -05:00
8f051ad413 chore: remove noisy admin controller init log 2026-02-07 03:39:07 -05:00
6c1a578b35 fix: include manual external mappings in fallback playlist stats and add live UI refresh 2026-02-07 03:36:26 -05:00
8ab2923493 fix: increase delays in refresh & match all to ensure cache clears before matching 2026-02-07 03:23:13 -05:00
42b4e0e399 feat: add tooltips, refresh & match button, and matching warning banner 2026-02-07 02:36:48 -05:00
f03aa0be35 refactor: remove lyrics prefetching UI and optimize admin endpoints 2026-02-07 01:16:03 -05:00
440ef9850f Make kept path configurable via web UI
- Added Library:KeptPath to appsettings.json (default: /app/kept)
- Added Library Settings card to web UI with DownloadPath and KeptPath
- Updated GetDownloads and DeleteDownload endpoints to use configured path
- Updated JellyfinController to use configured kept path
- Injected IConfiguration into JellyfinController
- Users can now customize where favorited tracks are stored
2026-02-07 00:35:12 -05:00
c9b44dea43 Fix delete endpoint to work with kept folder and clean up empty directories
- Changed delete endpoint from Library:DownloadPath to /app/kept
- Now properly deletes empty Album and Artist folders after file deletion
- Added debug logging for deletion operations
- Structure: Artist/Album/Track.flac
2026-02-07 00:32:22 -05:00
3a9d00dcdb Fix downloads endpoint to only show kept files with debug logging
- Downloads endpoint now ONLY shows /app/kept (favorited tracks)
- Removed cache downloads from this endpoint (separate box needed)
- Added debug logging to troubleshoot why kept files weren't showing
- Logs directory existence and file count
2026-02-06 23:55:07 -05:00
2389b80733 Fix downloads endpoint to show kept files and remove lyrics cache endpoint
- Downloads endpoint now shows both /app/downloads (cache) and /app/kept (favorited)
- Added location field to distinguish between cache and kept files
- Added cacheCount and keptCount to response
- Removed lyrics cache clear endpoint (no longer needed)
2026-02-06 23:53:36 -05:00
b99a199ef3 Fix lyrics fetching and disable prefetching
- Fix LyricsPrefetchService to use server API key for Jellyfin lyrics checks
- Remove Spotify lyrics caching (local Docker container is fast)
- Disable lyrics prefetching service (not needed - Jellyfin/Spotify are fast)
- Add POST /api/admin/cache/clear-lyrics endpoint to clear LRCLIB cache
- Only LRCLIB lyrics are cached now (external API)
2026-02-06 23:48:18 -05:00
64e2004bdc Fix syntax error in AdminController.cs - move closing brace to correct location 2026-02-06 23:26:30 -05:00
7cee0911b6 fix: progress bar external detection and download row removal
Some checks failed
CI / build-and-test (push) Has been cancelled
- Handle JsonElement when deserializing ProviderIds from cache
- Check for external provider keys (SquidWTF, Deezer, Qobuz, Tidal)
- Fix row removal selector to properly escape path
- Progress bar now correctly shows local vs external split
2026-02-06 22:33:08 -05:00
a2b1eace5f feat: add kept downloads section to admin UI
- List all downloaded files with artist/album/file info
- Download button to save files locally
- Delete button with live row removal
- Shows total file count and size
- Auto-refreshes every 30 seconds
- Security: path validation to prevent directory traversal
2026-02-06 22:29:28 -05:00
ac1fbd4b34 fix: progress bar and add missing tracks section
- Fix external track detection in progress bar (check for external provider names in ProviderIds)
- Add missing tracks section at bottom of Active Playlists tab
- Shows all unmatched tracks across all playlists
- Includes Map to Local and Map to External buttons for each missing track
- Auto-refreshes with other playlist data
2026-02-06 22:12:15 -05:00
a6ac0dfbd2 feat: aggressive track matching with optimal order
- Strip decorators FIRST (feat, remaster, explicit, etc)
- Substring matching SECOND (cheap, high-precision)
- Levenshtein distance THIRD (expensive, fuzzy)
- Greedy assignment LAST (optimal global matching)
- Lower threshold to 40 (was 50-60) for max coverage
- Accept artist priority matches (artist 70+, title 30+)
- Handles cases like 'luther' → 'luther (feat. sza)'
- Handles cases like 'a' → 'a-blah' with same artist
- Prevents duplicate assignments across tracks
2026-02-06 21:22:42 -05:00
bb3140a247 feat: add Spotify ID to local tracks for lyrics support
- Inject Spotify ID into ProviderIds for manually mapped local tracks
- Also add Spotify ID to fuzzy-matched local tracks
- Enables Spotify Lyrics API to work for local Jellyfin tracks
- Fallback to Spotify lyrics when local track has no embedded lyrics
2026-02-06 20:32:32 -05:00
791e6a69d9 feat: re-add manual local Jellyfin track mapping support
- Allow mapping Spotify tracks to local Jellyfin tracks via JellyfinId
- Supports both local (Jellyfin) and external (provider) manual mappings
- Local mappings take priority over fuzzy matching
- Helps when automatic matching fails for tracks already in Jellyfin library
2026-02-06 20:29:29 -05:00
3ffa09dcfa fix: improve fuzzy matching for tracks with special formatting
- Lower matching threshold from 60 to 50 for more lenient matching
- Add fallback to trust provider's top result when artist matches well (>=70)
- Helps match tracks with parentheses, brackets, and stylized titles like 'PiLlOwT4lK'
- Provider search already does fuzzy matching, so trust it when artist is correct
2026-02-06 20:16:36 -05:00
b366a4b771 fix: add rate limiting for Odesli/song.link API
- Implemented semaphore-based rate limiter (10 requests per minute)
- Odesli API allows 10 requests per 60 seconds
- Rate limiter ensures 1 request per 6 seconds maximum
- Prevents API rate limit violations
- Cache still used first (30 day TTL) to minimize API calls
2026-02-06 19:55:16 -05:00
960d15175e fix: remove artist deduplication and add placeholder image support
- Removed ALL artist deduplication from search results
- Show both local and external artists with same name (e.g., Taylor Swift + Taylor Swift [S])
- Users can now browse external artist albums not in local library
- Added placeholder image support for missing cover art
- Placeholder served for both Jellyfin and external providers when image is null
- Added logging to SquidWTF artist search to show actual URLs
- Removed temporary documentation files from repo
- TIDAL image URLs already correctly implemented with UUID splitting

Changes:
- SearchItems: No deduplication, sort by relevance score
- SearchHints: No deduplication, show all matches
- GetArtists: No deduplication, show all matches
- GetImage: Returns placeholder.png when image unavailable
- Added GetPlaceholderImageAsync() helper method
2026-02-06 19:49:26 -05:00
1d774111e7 fix: show both local and external artists with same name
- Artists with same name now appear separately (local + external [S])
- Fixed deduplication to keep one local AND one external per name
- Added logging to SquidWTF artist search to show actual URLs
- External artists get [S] suffix to distinguish from local
- Allows users to browse external artist albums not in local library
- TIDAL image URLs already correctly implemented with UUID splitting
2026-02-06 19:41:06 -05:00
99d701a355 docs: add session management fix documentation and update TODO 2026-02-06 16:37:26 -05:00
73509eb80b feat: create sessions and WebSocket connections for external track playback
- External tracks now create Jellyfin sessions on playback start
- Sessions maintained via WebSocket connections to Jellyfin
- Session activity updated during progress reports
- Sessions auto-cleanup after 50s grace period when playback stops
- Clients playing external tracks now appear in Jellyfin dashboard
- Added comprehensive testing documentation
2026-02-06 16:36:23 -05:00
eb8e3196da feat: Odesli/song.link conversion for Spotify lyrics on external tracks 2026-02-06 16:30:13 -05:00
401d0b4008 feat: add Clear Cache & Rebuild button for playlists in Admin UI
- New endpoint: POST /api/admin/playlists/{name}/clear-cache
- Clears Redis cache keys (items, matched tracks, missing tracks)
- Deletes file caches
- Triggers automatic rebuild with latest code (includes Spotify IDs)
- Added prominent button in Admin UI playlist table
- Shows confirmation dialog with details of what will be cleared
2026-02-06 14:57:07 -05:00
6ccc6a4a0d debug: add logging to verify Spotify IDs in cached playlist items
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-02-06 14:15:49 -05:00
c54503f486 fix: Spotify lyrics validation and proactive prefetching
- Only attempt Spotify lyrics for tracks with valid Spotify IDs (22 chars, no 'local' or ':')
- Add Spotify IDs to external matched tracks in playlists for lyrics support
- Proactively fetch and cache lyrics when playback starts (background task)
- Fix pre-existing SubSonicController bug (missing _cache field)
- Lyrics now ready instantly when requested by client
2026-02-06 13:04:40 -05:00
fbac81df64 feat: add 1-hour cache for playlist cover images 2026-02-06 12:31:56 -05:00
3a433e276c refactor: reorganize apis folder into steering and api-calls 2026-02-06 12:20:54 -05:00
0c14f4a760 chore: explicitly ignore documentation files in apis folder 2026-02-06 12:16:39 -05:00
28c4f8f5df Remove local Jellyfin manual mapping, keep only external mappings 2026-02-06 12:05:26 -05:00
a3830c54c4 Use Jellyfin item IDs for lyrics check instead of searching
- Lyrics prefetch now uses playlist items cache which has Jellyfin item IDs
- Directly queries /Audio/{itemId}/Lyrics endpoint (no search needed)
- Eliminates all 401 errors and 'no client headers' warnings
- Priority order: 1) Local Jellyfin lyrics, 2) Spotify lyrics API, 3) LRCLib
- Much more efficient - no fuzzy searching required
- Only searches by artist/title as fallback if item ID not available
- All 225 tests passing
2026-02-06 11:53:35 -05:00
4226ead53a Add file-based caching for admin UI and fix Jellyfin API usage
- Added 5-minute file cache for playlist summary to speed up admin UI loads
- Added refresh parameter to force cache bypass
- Invalidate cache when playlists are refreshed or tracks are matched
- Fixed incorrect use of anyProviderIdEquals (Emby API) in Jellyfin
- Now searches Jellyfin by artist and title instead of provider ID
- Fixes 401 errors and 'no client headers' warnings in lyrics prefetch
- All 225 tests passing
2026-02-06 11:48:01 -05:00
2155c4a9d5 Fix delete button for manual track mappings
- Use data attributes instead of inline onclick to avoid quote escaping issues
- Add event listeners after rendering the table
- Fixes issue where Remove button didn't work due to escaped quotes in onclick attribute
2026-02-06 11:42:01 -05:00
a56b2c3ea3 Add delete button for manual track mappings
- Added DELETE /api/admin/mappings/tracks endpoint
- Removes mapping from JSON file and Redis cache
- Deletes file if it becomes empty after removal
- Added 'Remove' button to each mapping in web UI
- Enhanced confirm dialog explaining consequences for both local and external mappings
- Supports removing both Jellyfin (local) and external provider mappings
- Allows phasing out local mappings in favor of Spotify Import plugin
2026-02-06 11:36:51 -05:00
810247ba8c Add manual track mappings display to web UI
- Shows all manual mappings in Active Playlists tab
- Displays summary counts (total, jellyfin, external)
- Table shows playlist, Spotify ID, type, target, and creation date
- Color-coded badges for jellyfin vs external mappings
- Auto-refreshes every 30 seconds
- Helps review mappings before phasing out local ones
2026-02-06 11:18:48 -05:00
96814aa91b Add endpoint to view all manual track mappings
- GET /api/admin/mappings/tracks returns all manual mappings
- Shows both Jellyfin (local) and external provider mappings
- Groups by playlist and includes creation timestamps
- Returns counts for jellyfin vs external mappings
2026-02-06 11:16:09 -05:00
d52c0fc938 Add Spotify ID lookup for external tracks to enable Spotify lyrics
- External tracks from playlists now look up their Spotify ID from matched tracks cache
- Enables Spotify lyrics API to work for SquidWTF/Deezer/Qobuz tracks
- Searches through all playlist matched tracks to find the Spotify ID
- Falls back to LRCLIB if no Spotify ID found or lyrics unavailable
2026-02-06 11:14:55 -05:00
64eff088fa Remove incorrect healthcheck from spotify-lyrics service 2026-02-06 11:02:00 -05:00
ff6dfede87 Change Spotify lyrics API external port to 8365 2026-02-06 10:54:43 -05:00
d8696e254f Expose Spotify lyrics API on port 8080 for testing 2026-02-06 10:52:44 -05:00
261f20f378 Add Spotify lyrics test endpoint
- Add GET /api/admin/lyrics/spotify/test endpoint
- Accepts trackId query parameter (Spotify track ID)
- Returns lyrics in both JSON and LRC format
- Useful for testing Spotify lyrics API integration
2026-02-06 10:51:16 -05:00
ad5fea7d8e Fix LRCLib and SquidWTF error handling
Some checks failed
CI / build-and-test (push) Has been cancelled
- Handle nullable duration in LRCLib API responses
- Validate input parameters before making LRCLib requests
- Change SquidWTF artist warning to debug level (expected behavior)
- Prevent JSON deserialization errors when duration is null
- Prevent 400 Bad Request errors from empty track names
2026-02-06 02:07:45 -05:00
8a3abdcbf7 Fix LyricsStartupValidator build errors
- Remove duplicate _httpClient field (use inherited one)
- Replace ValidationResult.Warning with ValidationResult.Failure
- Use PARTIAL status for partial failures
2026-02-06 01:54:32 -05:00
f103dac6c8 Add comprehensive lyrics startup validation with '22' test
- Revert album endpoint back to ?id= (correct parameter)
- Update SquidWTF validator to test '22' by Taylor Swift
- Create LyricsStartupValidator testing all lyrics services:
  * LRCLib API
  * Spotify Lyrics Sidecar (docker container)
  * Spotify API configuration
- Test song: '22' by Taylor Swift (Spotify ID: 3yII7UwgLF6K5zW3xad3MP)
- Register lyrics validator in startup orchestrator
2026-02-06 01:48:12 -05:00
7abc26c069 Fix album detail endpoint to use correct parameter
- Change album endpoint from ?id= to ?f= to match API spec
- Album search parsing is correct (data.albums.items)
- Album detail parsing is correct (data with items array)
2026-02-06 01:45:10 -05:00
a2e9021100 Fix artist detail parsing to handle missing artist property
- Add TryGetProperty check for artist field in albums response
- Log response keys when artist data not found for debugging
- Improves error handling when API returns albums without artist field
2026-02-06 01:39:43 -05:00
5f22fb0a3b Integrate Spotify lyrics sidecar service
- Add spotify-lyrics-api sidecar container to docker-compose
- Replace direct Spotify API lyrics code with sidecar API calls
- Update SpotifyLyricsService to use sidecar exclusively
- Add LyricsApiUrl setting to SpotifyApiSettings
- Update prefetch to try Spotify lyrics first, then LRCLib
- Remove unused direct API authentication and parsing code
2026-02-06 01:24:49 -05:00
a3d1d81810 Add Spotify lyrics sidecar service and integrate with prefetch
- Add spotify-lyrics-api container to docker-compose
- Update SpotifyLyricsService to use sidecar API
- Prefetch now tries Spotify lyrics first (using track ID), then LRCLib
- Add SPOTIFY_LYRICS_API_URL setting
- Sidecar handles sp_dc cookie authentication automatically
2026-02-06 01:21:30 -05:00
2dd7020a61 Add logging to Spotify lyrics search for better debugging
Show when Spotify search is skipped, fails, or finds no matches
2026-02-06 00:59:23 -05:00
e36e685bee Fix lyrics cache key mismatch between prefetch and lookup
Use same artist format (comma-separated) in prefetch as LrclibService
to ensure cached lyrics are found during playback
2026-02-06 00:58:13 -05:00
7ff6dbbe7a Prioritize local Jellyfin lyrics over LRCLib in prefetch
Some checks failed
CI / build-and-test (push) Has been cancelled
- Check for embedded lyrics in local Jellyfin tracks before fetching from LRCLib
- Remove previously cached LRCLib lyrics when local lyrics are found
- Prevents unnecessary API calls and respects user's embedded lyrics
- Tracks with local lyrics are counted as 'cached' in prefetch stats
2026-02-05 15:09:59 -05:00
e0dbd1d4fd feat: Add lyrics ID mapping system, fix playlist display, enhance track view
- Add complete lyrics ID mapping system with Redis cache, file persistence, and cache warming
- Manual lyrics mappings checked FIRST before automatic search in LrclibService
- Add lyrics status badge to track view (blue badge shows when lyrics are cached)
- Enhance search links to show 'Search: Track Title - Artist Name'
- Fix Active Playlists tab to read from .env file directly (shows all 18 playlists now)
- Add Map Lyrics ID button to every track with modal for entering lrclib.net IDs
- Add POST /api/admin/lyrics/map and GET /api/admin/lyrics/mappings endpoints
- Lyrics mappings stored in /app/cache/lyrics_mappings.json with no expiration
- Cache warming loads lyrics mappings on startup
- All mappings follow same pattern as track mappings (Redis + file + warming)
2026-02-05 14:58:57 -05:00
328a6a0eea Add lyrics completion bar per playlist showing percentage of tracks with cached lyrics
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-02-05 12:44:11 -05:00
9abb53de1a Fix search to use SquidWTF HiFi API with round-robin base URLs, capitalize provider names in UI, and widen tracks modal to 90% 2026-02-05 12:35:33 -05:00
349fb740a2 Fix scrobbling: track playing item in session and send proper PlaybackStopped data on cleanup 2026-02-05 11:56:26 -05:00
b604d61039 Adjust modal size to 75% width and 65% height, call PlaybackStopped when cleaning up sessions 2026-02-05 11:53:35 -05:00
3b8d83b43e Add lyrics prefetch endpoint and UI button: prefetch lyrics for individual playlists with progress feedback 2026-02-05 11:45:36 -05:00
8555b67a38 Fix external track streaming: normalize provider names to lowercase (squidwtf, deezer, qobuz) 2026-02-05 11:40:45 -05:00
629e95ac30 Improve logging: clarify search vs manual mappings, show manual mapping counts in final log 2026-02-05 11:38:26 -05:00
2153a24c86 Make modal wider (800px) and taller (90vh) to fit buttons side by side 2026-02-05 11:35:09 -05:00
1ddb3954f3 Add missing using statement for IMusicMetadataService 2026-02-05 11:27:22 -05:00
3319c9b21b Fix external mapping: add Map to External button for external tracks, fetch metadata from provider, set searchQuery for missing tracks 2026-02-05 11:23:01 -05:00
8966fb1fa2 Add lyrics prefetching for injected playlists with file cache
New LyricsPrefetchService automatically fetches lyrics for all tracks in
Spotify injected playlists. Lyrics are cached in Redis and persisted to
disk for fast loading on startup.

Features:
- Prefetches lyrics for all playlist tracks on startup (after 3min delay)
- Daily refresh to catch new tracks
- File cache at /app/cache/lyrics for persistence
- Cache warming on startup loads lyrics from disk into Redis
- Rate limited to 2 requests/second to be respectful to LRCLIB API
- Logs fetched/cached/missing counts per playlist

Benefits:
- Instant lyrics availability for playlist tracks
- Survives container restarts
- Reduces API calls during playback
- Better user experience with pre-loaded lyrics
2026-02-05 11:15:42 -05:00
3b24ef3e78 Fix: Fetch full metadata for manual external mappings
Manual external mappings now fetch complete track metadata from the
provider (SquidWTF) instead of using minimal Spotify metadata. This
ensures proper IDs, artist IDs, album IDs, cover art, and all metadata
needed for playback.

Also fixed admin UI to properly detect manual external mappings so
tracks show as 'External' instead of 'Missing'.

Changes:
- Fetch full Song metadata using GetSongAsync when manual mapping exists
- Fallback to minimal metadata if fetch fails
- Admin controller now checks isManualMapping flag to set correct status
- Tracks with manual external mappings now show proper provider badge
2026-02-05 11:13:26 -05:00
dbeb060d52 Fix: Manual external mappings now work correctly for playback
Two critical fixes:
1. External songs from manual mappings now get proper IDs (ext-{provider}-song-{id})
   - Previously had no ID, causing 'dummy' errors in Jellyfin
   - Now follows same format as auto-matched external tracks

2. Admin UI now correctly shows manual external mappings as available
   - Previously showed as 'Missing' even after mapping
   - Now properly detects manual external mappings and shows provider badge

This fixes the 400 Bad Request errors when trying to play manually mapped tracks.
2026-02-05 11:12:15 -05:00
2155a287a5 Add manual mapping indicators and search button for missing tracks
- Manual mappings now show a blue 'Manual' badge next to the track status
- Added search button (🔍) for missing tracks to help find them
- Backend now returns isManualMapping, manualMappingType, and manualMappingId
- Frontend displays manual mapping indicators for both local and external tracks
- Missing tracks now show a search link to help locate them on SquidWTF
2026-02-05 10:20:31 -05:00
cb57b406c1 Fix: Manual external mappings now properly included in playlist cache
The bug was in PreBuildPlaylistItemsCacheAsync - when a manual external
mapping was found, it was added to matchedTracks but the code used
'continue' to skip to the next track WITHOUT adding it to finalItems.

This meant external manual mappings were never included in the playlist
cache that gets served to clients.

The fix converts the external song to Jellyfin item format and adds it
to finalItems before continuing, ensuring manual external mappings are
properly included in the pre-built playlist cache.
2026-02-05 10:07:57 -05:00
e91833ebbb Fix variable name conflict and change cache logs to DEBUG level
- Fixed CS0136 error: renamed 'doc' to 'extDoc' in AdminController to avoid variable name conflict
- Changed all Redis cache logs (HIT/MISS/SET) to DEBUG level instead of suppressing
- This allows cache logs to be visible in docker logs but not as noisy at INFO level
2026-02-05 09:59:28 -05:00
2e1577eb5a Fix external mapping deserialization and suppress cache MISS logs
- Fixed RuntimeBinderException when processing external mappings by replacing dynamic with JsonDocument parsing
- Suppressed cache MISS logs for manual/external mappings (they're expected to be missing most of the time)
- Only log manual/external mapping HITs at INFO level, other cache operations at DEBUG level
- Applied fix to SpotifyTrackMatchingService (2 locations) and AdminController (2 locations)
2026-02-05 09:57:07 -05:00
7cb722c396 Fix HasValue method to handle JsonElement properly
- Changed parameter type from dynamic? to object? to avoid runtime binding issues
- Added check for JsonValueKind.Undefined in addition to Null
- Fixes crash when checking external mappings that return JsonElement
- Applied fix to both AdminController and SpotifyTrackMatchingService
2026-02-05 09:40:39 -05:00
9dcaddb2db Make manual mappings permanent and persist to file
- Manual mappings now have NO expiration (permanent in Redis)
- Save manual mappings to /app/cache/mappings/*.json files
- Load manual mappings on startup via CacheWarmingService
- Manual mappings are first-order and survive restarts/cache clears
- User decisions are now truly permanent
2026-02-05 09:33:37 -05:00
5766cf9f62 Delete file caches when manual mappings are created
- When mapping a track to local or external, delete both Redis and file caches
- This forces the matched tracks cache to rebuild with the new mapping
- Ensures manual mappings are immediately reflected in playlists
2026-02-05 09:31:07 -05:00
a12d5ea3c9 Fix excessive track matching and reduce HTTP logging noise
- Added 5-minute cooldown between matching runs to prevent spam
- Improved cache checking to skip unnecessary matching
- Persist matched tracks cache to file for faster restarts
- Cache warming service now loads matched tracks on startup
- Suppress verbose HTTP client logs (LogicalHandler/ClientHandler)
- Only run matching when cache is missing or manual mappings added
2026-02-05 09:30:00 -05:00
25bbf45cbb Fix memory leak in ActiveDownloads dictionary
- Changed ActiveDownloads from Dictionary to ConcurrentDictionary for thread safety
- Added automatic cleanup of completed downloads after 5 minutes
- Added automatic cleanup of failed downloads after 2 minutes
- This fixes the 929MB -> 10MB memory issue where downloads were never removed from tracking
2026-02-05 09:19:32 -05:00
3fd13b855d Fix RuntimeBinderException, add session cleanup, memory stats endpoint, and fix all warnings
- Fixed RuntimeBinderException when comparing JsonElement with null
- Added HasValue() helper method for safe dynamic type checking
- Implemented intelligent session cleanup:
  * 50 seconds after playback stops (allows song changes)
  * 3 minutes of total inactivity (catches crashed clients)
- Added memory stats endpoint: GET /api/admin/memory-stats
- Added sessions monitoring endpoint: GET /api/admin/sessions
- Added GetSessionsInfo() to JellyfinSessionManager for debugging
- Fixed all nullable reference warnings
- Reduced warnings from 10 to 0
2026-02-05 09:17:40 -05:00
d9c0b8bb54 Add separate 'Map to External' button for missing tracks
Some checks failed
CI / build-and-test (push) Has been cancelled
- Missing tracks now show both 'Map to Local' and 'Map to External' buttons
- External tracks continue to show only 'Map to Local' button
- Added openExternalMap() function that opens modal in external mapping mode
- Added event listeners for .map-external-btn buttons
- External mapping button styled with warning color to distinguish from local mapping
- Users can now easily choose between mapping to Jellyfin tracks or external provider IDs
2026-02-05 00:26:02 -05:00
400ea31477 Fix missing track labeling and add external manual mapping support
- Fixed syntax errors in AdminController.cs (missing braces, duplicate code)
- Implemented proper track status logic to distinguish between:
  * Local tracks: isLocal=true, externalProvider=null
  * External matched tracks: isLocal=false, externalProvider='SquidWTF'
  * Missing tracks: isLocal=null, externalProvider=null
- Added external manual mapping support for SquidWTF/Deezer/Qobuz IDs
- Updated frontend UI with dual mapping modes (Jellyfin vs External)
- Extended ManualMappingRequest class with ExternalProvider + ExternalId fields
- Updated SpotifyTrackMatchingService to handle external manual mappings
- Fixed variable name conflicts and dynamic argument casting issues
- All tests passing (225/225)

Resolves issue where missing tracks incorrectly showed provider name instead of 'Missing' status.
2026-02-05 00:15:23 -05:00
b1cab0ddfc Fix missing track labeling and add external manual mapping support
FIXES:
- Fixed track display logic to properly distinguish between external matched and missing tracks
- Missing tracks now show 'Missing' instead of incorrectly showing provider name
- Added support for manual external provider mappings (e.g., SquidWTF IDs)

CHANGES:
- Extended ManualMappingRequest to support ExternalProvider + ExternalId
- Updated SaveManualMapping to handle both Jellyfin and external mappings
- Updated SpotifyTrackMatchingService to check for external manual mappings
- Updated AdminController track details to use proper missing/matched logic

NOTE: Build currently has syntax errors that need to be fixed, but core logic is implemented.
2026-02-04 23:56:21 -05:00
7cba915c5e Fix authentication issues in SpotifyTrackMatchingService
- Fixed SpotifyTrackMatchingService to use GetJsonAsyncInternal for authenticated requests
- This resolves 401 Unauthorized errors when fetching existing playlist tracks
- Should prevent unnecessary rematching when cache is warm
- Fixes 'No Items found in Jellyfin playlist response' warnings

The service was using GetJsonAsync with null headers, causing 401 errors.
Now uses server API key authentication for internal operations.
2026-02-04 23:44:45 -05:00
dfd7d678e7 Add internal API method and fix playlist count authentication
- Added GetJsonAsyncInternal method to JellyfinProxyService for server-side requests
- Uses server API key instead of client tokens for internal operations
- Updated UpdateSpotifyPlaylistCounts to use internal method with proper authentication
- This should resolve 401 Unauthorized errors when updating playlist counts

Now Spotify playlists should show correct track counts without authentication issues.
2026-02-04 23:42:16 -05:00
4071f6d650 Fix authentication issue in UpdateSpotifyPlaylistCounts
- Added UserId parameter to playlist items request to avoid 401 Unauthorized
- Fixed JsonElement casting issue that was causing InvalidCastException
- This should resolve both the authentication error and the track count update

Now Spotify playlists should show correct track counts without authentication errors.
2026-02-04 23:40:13 -05:00
d045b33afd Fix Spotify playlist track counts to include external tracks
- Changed totalAvailableCount calculation to include both local and external matched tracks
- Updated logging to show breakdown of local vs external tracks
- This fixes Discover Weekly and other external-only playlists showing 0 tracks in clients

Now playlists with all external tracks will show correct track counts in Feishin and other clients.
2026-02-04 23:35:10 -05:00
4f74b34b9a Fix Spotify playlist track counts in client responses
- Fixed UpdateSpotifyPlaylistCounts to use GetPlaylistByJellyfinId instead of GetPlaylistById
- Fixed GetSpotifyPlaylistTracksOrderedAsync to use correct playlist config lookup
- Added diagnostic logging for playlist config lookups
- Removed test-websocket.html file

This fixes the issue where Spotify playlists showed 0 tracks in playlist lists
but worked correctly when accessed directly.
2026-02-04 23:31:30 -05:00
b7417614b3 Remove memory optimization markdown file 2026-02-04 23:18:38 -05:00
72b1584d51 Fix admin dashboard to show total playable tracks (local + external matched) 2026-02-04 23:16:56 -05:00
4b289e4ddd Move admin endpoints to internal port 5275 for security 2026-02-04 22:55:21 -05:00
07844cc9c5 Add GC hints to prevent memory leaks from large byte arrays 2026-02-04 22:50:35 -05:00
1601b96800 Add memory monitoring endpoint 2026-02-04 22:45:11 -05:00
7db66067f4 Complete mark-for-deletion system and memory optimization 2026-02-04 22:41:08 -05:00
f44d8652b4 Improve favorite/unfavorite logic - copy from cache, avoid re-downloads 2026-02-04 22:34:11 -05:00
8fad6d8c4e Fix manual mapping detection in Active Playlists tab
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-02-04 19:35:34 -05:00
d11b656b23 Add loading state to save mapping button and timeout handling 2026-02-04 19:24:02 -05:00
cf1428d678 Fix manual mapping race condition and add log gitignore 2026-02-04 19:17:48 -05:00
030937b196 Add error handling and better logging for playlist cache deserialization 2026-02-04 19:10:04 -05:00
f77281fd3d Fix GetJellyfinTrack: add UserId and verify Audio type for URL-based mapping 2026-02-04 19:04:12 -05:00
791a8b3fdb Fix Jellyfin search: add UserId and verify Audio type 2026-02-04 19:03:21 -05:00
7311bbc04a Add debug logging to GetPlaylists cache reading 2026-02-04 18:59:00 -05:00
696a2d56f2 Fix manual mappings: preserve on rematch + fix local/external count detection 2026-02-04 18:53:09 -05:00
5680b9c7c9 Fix GetPlaylists to use pre-built cache with manual mappings for accurate counts 2026-02-04 18:49:12 -05:00
1d31784ff8 Fix manual mapping: add immediate playlist rebuild and manual mapping priority in cache builder 2026-02-04 18:38:25 -05:00
10e58eced9 fix: add authentication to playlist cache pre-building
- PreBuildPlaylistItemsCacheAsync was failing with HTTP 401
- Background services don't have client headers for authentication
- Now manually creates X-Emby-Authorization header with API key
- Fixes 'Failed to fetch Jellyfin playlist items: HTTP 401' warning
- Playlist items cache now builds successfully after track matching

All 225 tests pass.
2026-02-04 18:23:11 -05:00
0937fcf163 fix: accurate playlist counting and three-color progress bars
- Fix playlist counting logic to use fuzzy matching (same as track view)
- Count local tracks by matching Jellyfin tracks to Spotify tracks
- Count external matched tracks from cache
- Count missing tracks (not found locally or externally)
- Progress bars now show three colors:
  * Green: Local tracks in Jellyfin
  * Orange: External matched tracks (SquidWTF/Deezer/Qobuz)
  * Grey: Missing tracks (not found anywhere)
- Add 'Missing' badge to tracks that couldn't be found
- Missing tracks can still be manually mapped
- Fixes incorrect counts like '28 matched • 1 missing' showing 29 external tracks

All 225 tests pass.
2026-02-04 17:49:10 -05:00
506f39d606 feat: instant UI update after manual track mapping
- Backend now returns mapped track details after saving
- Frontend updates track in-place without requiring page refresh
- Track status changes from External to Local immediately
- Map button is removed after successful mapping
- Playlist counts refresh in background
- Improved UX: no more 'refresh the playlist' message

All 225 tests pass.
2026-02-04 17:44:57 -05:00
7bb7c6a40e fix: manual mapping UI and [S] tag consistency
- Fix manual mapping track selection visual feedback (use accent color + background)
- Clear all playlist caches after manual mapping (matched, ordered, items)
- Strip [S] suffix from titles/artists/albums when searching for lyrics
- Add [S] suffix to artist and album names when song has [S] for consistency
- Ensures external tracks are clearly marked across all metadata fields

All 225 tests pass.
2026-02-04 17:31:56 -05:00
3403f7a8c9 fix: remove orphaned code causing JavaScript syntax error
Removed duplicate/orphaned lines after searchProvider() function that were
causing 'expected expression, got }' syntax error in admin UI.
2026-02-04 17:06:24 -05:00
3e5c57766b feat: pre-build playlist cache and make matching interval configurable
- Pre-build playlist items cache during track matching for instant serving
- Add PreBuildPlaylistItemsCacheAsync() to SpotifyTrackMatchingService
- Combines local Jellyfin tracks + external matched tracks in correct Spotify order
- Saves to both Redis and file cache for persistence across restarts
- Change matching interval from hardcoded 30 minutes to configurable (default: 24 hours)
- Add SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS environment variable
- Set to 0 to only run once on startup (manual trigger still works)
- Add endpoint usage files to .gitignore
- Update documentation in README and .env.example

Rationale: Spotify playlists like Discover Weekly update once per week,
so running every 24 hours is more than sufficient. Pre-building the cache
eliminates slow 'on the fly' playlist building.

All 225 tests pass.
2026-02-04 17:03:50 -05:00
24c6219189 Fix external track counting by checking matched tracks cache
- External tracks are injected on-the-fly, not stored in Jellyfin DB
- Check spotify:matched:ordered cache to get accurate external count
- Calculate external tracks as: total matched - local tracks
- This will properly show the two-color progress bar (green local + orange external)
- All 225 tests passing
2026-02-04 16:54:56 -05:00
ea21d5aa77 Add clickable search links and enhanced debug logging
- Made search query clickable - opens provider-specific search
- Added searchProvider() function with URLs for Deezer, Qobuz, SquidWTF
- Enhanced console logging to debug progress bar data
- Logs raw playlist data and calculated percentages
- Search link opens in new tab
2026-02-04 16:51:46 -05:00
ee84770397 Improve progress bar visibility and add debug logging
- Increased progress bar height from 8px to 12px for better visibility
- Changed colors to more vibrant shades (green #10b981, orange #f59e0b)
- Added console debug logging for playlist stats
- Shows local (green) and external (orange) track percentages side-by-side
2026-02-04 16:50:20 -05:00
7ccb660299 Add startup cache warming service
- Proactively loads all file caches into Redis on container startup
- Warms genre cache (30-day expiration)
- Warms playlist items cache (24-hour expiration)
- Logs warming progress and duration
- Ensures fast access immediately after restart
- Cleans up expired genre cache files automatically
- All 225 tests passing
2026-02-04 16:46:27 -05:00
0793c4614b Add file-based caching for MusicBrainz genres
- Dual-layer caching: Redis (fast) + file system (persistent)
- File cache survives container restarts
- 30-day cache expiration for both layers
- Negative result caching to avoid repeated failed lookups
- Safe file names using base64 encoding
- Automatic cache restoration to Redis on startup
- Cache directory: /app/cache/genres
2026-02-04 16:44:35 -05:00
bf02dc5a57 Add MusicBrainz genre enrichment and improve track counting
- Fixed external track detection (check for provider prefix in ID)
- Added genre support to MusicBrainz service (inc=genres+tags)
- Created GenreEnrichmentService for async genre lookup with caching
- Show provider name and search query for external tracks in admin UI
- Display search query that will be used for external track streaming
- Aggregate playlist genres from track genres
- All 225 tests passing
2026-02-04 16:43:17 -05:00
7938871556 Release 1.0.0 - Production ready
- Fixed AdminController export/import .env endpoints (moved from ConfigUpdateRequest class)
- Added ArtistId and AlbumId to integration test fixtures
- All 225 tests passing
- Version set to 1.0.0 (semantic versioning)
- MusicBrainz service ready for future ISRC-based matching (1.1.0)
- Import/export handles full .env configuration with timestamped backups
2026-02-04 16:33:58 -05:00
39f6893741 Add MusicBrainz API integration for metadata enrichment
- Added MusicBrainzSettings model with username/password authentication
- Created MusicBrainzService with ISRC lookup and recording search
- Implements proper rate limiting (1 req/sec) per MusicBrainz rules
- Added meaningful User-Agent header as required
- Registered service in Program.cs with configuration
- Added MusicBrainz section to appsettings.json
- Credentials stored in .env (MUSICBRAINZ_USERNAME/PASSWORD)

Next: Add to admin UI and implement import/export for .env
2026-02-04 16:23:16 -05:00
cd4fd702fc Match Jellyfin response structure exactly based on real API responses
Verified against real Jellyfin responses for tracks, albums, artists, and playlists:
- Reordered fields to match Jellyfin's exact field order
- Added missing fields: PremiereDate, HasLyrics, Container, ETag, etc.
- Fixed MediaType to 'Unknown' for albums/artists (not null)
- Fixed UserData.Key format to match Jellyfin patterns
- Added ParentLogoItemId, ParentBackdropItemId for proper hierarchy
- Fixed Genres/GenreItems to always be arrays (never null)
- Added complete MediaStream structure with all Jellyfin fields
- Playlists now have MediaType='Audio' to match real playlists
- All responses now perfectly mimic real Jellyfin structure
2026-02-04 16:17:45 -05:00
038c3a9614 Fix playlist count caching and make external tracks perfectly mimic Jellyfin responses
- Fixed UpdateSpotifyPlaylistCounts to properly handle file cache without skipping items
- Added Genres and GenreItems fields to all tracks (empty array if no genre)
- Added complete MediaStreams with audio codec info for external tracks
- Added missing MediaSource fields: IgnoreDts, IgnoreIndex, GenPtsInput, HasSegments
- Ensured Artists array never contains null values
- All external tracks now have proper genre arrays to match Jellyfin structure
2026-02-04 16:12:41 -05:00
6e966f9e0d Fix nullability warnings in SpotifyTrackMatchingService 2026-02-04 16:10:16 -05:00
b778b3d31e Fix MediaSources null array fields and add logging for artist albums
- Added MediaStreams, MediaAttachments, Formats as empty arrays instead of null
- Added RunTimeTicks field to MediaSources
- Added detailed logging to GetExternalChildItems to debug artist album issues
- This should fix 'type Null is not a subtype of type List<dynamic>' error
2026-02-04 16:04:04 -05:00
526a079368 Fix compilation errors and nullability warnings
- Fixed LrclibService.GetLyricsAsync call to use empty string and 0 for duration
- Fixed nullability warnings in SpotifyTrackMatchingService by explicitly casting to nullable tuple
2026-02-04 15:40:52 -05:00
7a7b884af2 Update playlist progress bar to show stacked blue/yellow segments
- Blue segment shows local tracks percentage
- Yellow segment shows external matched tracks percentage
- Bar fills to 100% when all tracks are matched
- Added tooltips showing track counts on hover
2026-02-04 15:37:07 -05:00
6ab5e44112 Fix apostrophe normalization syntax error - use Unicode escape sequences 2026-02-04 15:33:59 -05:00
7c92515723 Fix null boolean error and playlist count showing 0 after restart
- Added all required boolean fields to MediaSources (IsRemote, IsInfiniteStream, RequiresOpening, etc)
- UpdateSpotifyPlaylistCounts now loads from file cache if Redis is empty
- This fixes 'type Null is not a subtype of type bool' error in Finamp
- Playlist counts now show correctly even after container restart
2026-02-04 15:32:18 -05:00
8091d30602 Add parallel provider racing for searches and lyrics pre-fetching
- Created ParallelMetadataService to race all providers and return fastest result
- Search now uses parallel service when available for lower latency
- Pre-fetch LRCLib lyrics for top 3 search results in background
- FuzzyMatcher already handles apostrophe normalization (applied everywhere)
2026-02-04 15:29:56 -05:00
e7ff330625 Add logging for server-to-client WebSocket messages to debug remote control
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-02-04 11:30:12 -05:00
aadda9b873 Fix apostrophe matching, add URL input for track mapping, improve search caching
Some checks failed
CI / build-and-test (push) Has been cancelled
- Enhanced FuzzyMatcher to normalize apostrophes (', ', ', etc) for better matching
- Added Redis-only caching for search results (15 min TTL)
- Added pattern-based cache deletion for search and image keys
- Added URL input field in Map to Local modal to paste Jellyfin track URLs
- Added /api/admin/jellyfin/track/{id} endpoint to fetch track details by ID
- Fixed duplicate cache key declaration in GetSpotifyPlaylistTracksOrderedAsync
- Updated cache clearing to include new spotify:playlist:items:* keys
2026-02-04 01:44:56 -05:00
8a84237f13 Fix MediaSources field appending to query string
- Properly parse and modify Fields parameter instead of appending to end
- Fixes 400 BadRequest errors from malformed URLs
- Now correctly adds MediaSources to Fields parameter value
2026-02-03 23:56:40 -05:00
e3a118e578 Fix bitrate for injected playlists by preserving raw Jellyfin items
CRITICAL FIX: Don't convert Jellyfin items to Song objects and back!

- Get raw Jellyfin playlist items with MediaSources field
- Reorder them according to Spotify positions
- Inject external tracks where needed
- Return raw items preserving ALL Jellyfin metadata (bitrate, etc)

This ensures local tracks in Spotify playlists show correct bitrate
just like regular Jellyfin playlists.
2026-02-03 23:41:29 -05:00
e17eee9bf3 Fix Map to Local button using data attributes instead of inline onclick
- Avoid JavaScript escaping issues with inline onclick handlers
- Use data attributes to store track info
- Add event listeners after DOM is created
- Prevents syntax errors with special characters in track names
2026-02-03 23:24:19 -05:00
4229924f61 Fix bitrate showing 0K for favorited songs and browse views
Some checks failed
CI / build-and-test (push) Has been cancelled
- Ensure MediaSources field is included when proxying browse requests
- Applies to /Users/{userId}/Items (favorites, recently played, etc)
- Jellyfin doesn't include MediaSources by default, must be requested
- Now bitrate info shows correctly in all browse contexts
2026-02-03 20:04:10 -05:00
a2a48f6ed9 Fix Map to Local button when artists array is empty
- Safely handle empty or undefined artists array
- Prevents 'expected expression, got }' JavaScript error
- Also fixed artists display to handle undefined arrays
2026-02-03 19:55:30 -05:00
c7785b6488 Fix playlist track count to show actual available tracks
- Changed ChildCount to reflect tracks actually in Jellyfin (local + external matched)
- Previously was incorrectly adding missing tracks to the count
- Now clients see the correct number of playable tracks
- Uses spotify:matched:ordered cache to count external matched tracks
2026-02-03 19:53:22 -05:00
af03a53af5 Enhanced playlist statistics in admin dashboard
Backend changes:
- Distinguish between local tracks (in Jellyfin library) and external tracks (downloaded)
- Track external matched vs external missing counts
- Calculate completion percentage for each playlist

Frontend changes:
- Show detailed breakdown: X local • Y matched • Z missing
- Display completion percentage with progress bar
- Color-coded stats (green=local, blue=matched, yellow=missing)
- Updated table headers for clarity
2026-02-03 19:49:32 -05:00
c1c2212b53 Fix undefined artists array causing syntax error 2026-02-03 19:46:48 -05:00
17560f0d34 Fix Map to Local button by properly escaping JavaScript parameters
- Use JSON.stringify instead of escapeHtml for onclick parameters
- Prevents JavaScript syntax errors when track names contain quotes
- Fixes button doing nothing when clicked
2026-02-03 19:46:31 -05:00
6ab314f603 Enhanced cache clearing to include all Spotify playlist Redis keys
- Added clearing of spotify:matched:* and spotify:matched:ordered:* keys
- This ensures bitrate metadata fix takes effect after cache clear
- Returns count of cleared Redis keys in response
2026-02-03 19:41:16 -05:00
64ac09becf Preserve MediaSources metadata for local tracks in playlists to show bitrate 2026-02-03 19:35:39 -05:00
a0bbb7cd4c Integrate WebSocket proxy with session manager to cleanup sessions on client disconnect 2026-02-03 19:10:02 -05:00
4bd478e85c Add HLS playlist (.m3u8, .ts) support to binary proxy handler 2026-02-03 19:04:24 -05:00
f7a88791e8 Fix GetImage endpoint to proxy images instead of redirecting 2026-02-03 19:02:42 -05:00
9f8b3d65fb Fix HttpClient.SendAsync call for image proxy 2026-02-03 18:59:58 -05:00
1a1f9e136f Fix image proxy to handle binary data instead of JSON parsing 2026-02-03 18:59:03 -05:00
48f69b766d Add manual track mapping feature
- Add 'Map to Local' button for external tracks in playlist viewer
- Search Jellyfin library to find local tracks
- Save manual mappings (Spotify ID → Jellyfin ID) in cache
- Manual mappings take priority over fuzzy matching
- Clear playlist cache when mapping is saved to force refresh
- UI shows which tracks are manually mapped in logs
2026-02-03 18:57:19 -05:00
d619881b8e Fix: Correct matching logic - Jellyfin tracks first, then fill gaps
CRITICAL FIX: Changed matching strategy completely
- Step 1: Match ALL Jellyfin tracks to Spotify positions (fuzzy 70%)
- Step 2: Build playlist in Spotify order using matched Jellyfin tracks
- Step 3: Fill remaining gaps with external tracks (cached or on-demand)
- Step 4: Add any unmatched Jellyfin tracks at the end

This ensures Jellyfin tracks are ALWAYS used when they match, preventing
external tracks from being used when local versions exist.
2026-02-03 18:45:07 -05:00
dccdb7b744 Fix: Add on-demand external track search when cache is empty
- Search external providers in real-time if no cached match exists
- Use fuzzy matching (60% threshold) for external tracks
- Ensures external tracks are always available even without pre-matching
- Local tracks still prioritized first (70% threshold)
2026-02-03 18:39:33 -05:00
f240423822 Fix: Prioritize LOCAL tracks in Spotify playlist injection - match by name only
- Remove Spotify ID/ISRC matching (Jellyfin plugin doesn't add these)
- Use ONLY fuzzy name matching (title + artist, 70% threshold)
- LOCAL tracks ALWAYS used first before external providers
- Include ALL tracks from Jellyfin playlist (even if not in Spotify)
- Prevent duplicate track usage with HashSet tracking
- AdminController also updated to match by name for Local/External badges
- Better logging with emojis for debugging
2026-02-03 18:36:33 -05:00
1492778b14 UI fixes: Match per playlist, Match All button, local/external labels, preserve tab on reload 2026-02-03 18:27:29 -05:00
08af650d6c Add fuzzy name matching as fallback for local tracks + better error logging
- Add fuzzy matching by title+artist as fallback (like Jellyfin Spotify Import plugin)
- Add clear error messages when JELLYFIN_USER_ID is not configured
- Add emoji logging for easier debugging (🔍 📌  )
- Check HTTP status code when fetching playlist items
- This should fix the issue where all tracks show [S] even when they exist locally
2026-02-03 18:23:39 -05:00
c44be48eb9 Fix: Add UserId parameter for Jellyfin playlist operations + UI improvements
- CRITICAL FIX: Add UserId parameter to all Jellyfin playlist item fetches (fixes 400 BadRequest errors)
- Fix GetPlaylists to correctly count local/missing tracks
- Fix GetSpotifyPlaylistTracksOrderedAsync to find local tracks (was serving external tracks for everything)
- Fix SpotifyTrackMatchingService to skip tracks already in Jellyfin
- Add detailed debug logging for track matching (LOCAL by ISRC/Spotify ID, EXTERNAL match, NO MATCH)
- Add 'Match Tracks' button for individual playlists (not all playlists)
- Add 'Match All Tracks' button for matching all playlists at once
- Add JELLYFIN_USER_ID to web UI configuration tab for easy setup
- Add /api/admin/playlists/match-all endpoint

This fixes the issue where local tracks weren't being used - the system was downloading
from SquidWTF even when files existed locally in Jellyfin.
2026-02-03 18:14:13 -05:00
b16d16c9c9 Fix: Register SpotifyTrackMatchingService as singleton for DI
- Register as singleton first, then as hosted service
- Allows AdminController to inject and trigger matching manually
- Added better logging for Jellyfin playlist fetch failures
- Logs when JellyfinId is missing or API calls fail
- Helps debug '0 local / 0 missing' issue
2026-02-03 17:51:55 -05:00
e51d569d79 Show actual local/missing track counts in Active Playlists
- Fetches current Jellyfin playlist track count
- Compares with Spotify playlist total to calculate missing tracks
- Shows: '25 local / 5 missing' instead of just track count
- Local = tracks currently in Jellyfin playlist
- Missing = Spotify tracks not yet in Jellyfin (need to be matched)
- Helps users see which playlists need track matching
2026-02-03 17:48:49 -05:00
363c9e6f1b Add Match Tracks button and Local/External column to Active Playlists
- Added 'Match Tracks' button to trigger matching for specific playlist
- Added Local/External column (shows '-' for now, will populate after matching)
- New endpoint: POST /api/admin/playlists/{name}/match
- Injects SpotifyTrackMatchingService into AdminController
- UI shows: Name | Spotify ID | Total | Local/External | Cache Age | Actions
- Allows users to manually trigger matching without waiting 30 minutes
2026-02-03 17:47:44 -05:00
f813fe9eeb Fix: Match local Jellyfin tracks by ISRC instead of Spotify ID
- Local Jellyfin tracks don't have Spotify IDs (only plugin-added tracks do)
- Now matches by ISRC first (most reliable), then falls back to Spotify ID
- Builds dictionaries for fast lookup: existingBySpotifyId and existingByIsrc
- Prioritizes local tracks over external matches
- Logs: 'X tracks (Y with Spotify IDs, Z with ISRCs)'
- This fixes all tracks showing [S] - now uses local files when available
2026-02-03 17:45:53 -05:00
ef0ee65160 Optimize UI: Update playlist status without refetching all playlists
- After linking/unlinking, update UI state directly instead of refetching
- Avoids 7+ second delay from fetching track stats for all playlists
- Added data-playlist-id attribute to table rows for easy lookup
- Only refreshes Active Playlists tab (fast operation)
- Much better UX when linking multiple playlists
2026-02-03 17:40:47 -05:00
b3bfa16b93 Fix RemovePlaylist to use new 4-field config format
- Include JellyfinId when serializing playlists after removal
- Ensures config format stays consistent: [Name,SpotifyId,JellyfinId,position]
- Fixes issue where removed playlists would stay in the list
2026-02-03 17:35:54 -05:00
aa9b5c874d Skip matching tracks already in Jellyfin playlist
- Fetch existing tracks from Jellyfin playlist before matching
- Extract Spotify IDs from ProviderIds to identify already-matched tracks
- Only search for tracks not already in Jellyfin
- Logs: 'Matching X/Y tracks (skipping Z already in Jellyfin)'
- Significantly reduces matching time for playlists with local content
- Example: If 20/50 tracks exist locally, only searches for 30 tracks
2026-02-03 17:29:28 -05:00
e3546425eb Optimize track matching with parallel batch processing
- Process tracks in batches of 11 (matches SquidWTF provider count)
- Each batch runs 11 parallel searches (one per provider)
- Wait 150ms between batches (not between individual tracks)
- This is ~11x faster: 50 tracks now takes ~1 second instead of ~7.5 seconds
- Round-robin in SquidWTF ensures each parallel request hits a different provider
- Maintains rate limiting while maximizing throughput
2026-02-03 17:20:05 -05:00
5646aa07ea Fix playlist injection: Store Jellyfin playlist ID in config
- Added JellyfinId field to SpotifyPlaylistConfig model
- Updated config format: [[Name,SpotifyId,JellyfinId,position],...]
- LinkPlaylist now stores both Jellyfin and Spotify playlist IDs
- IsSpotifyPlaylist() now checks by Jellyfin playlist ID (not Spotify ID)
- GetJellyfinPlaylists shows linked status by checking JellyfinId
- Updated Program.cs to parse new 4-field format
- Backward compatible: JellyfinId defaults to empty string for old configs

This fixes the issue where playlists weren't being recognized as configured
because the code was checking Jellyfin playlist IDs against Spotify IDs.
2026-02-03 17:18:08 -05:00
7cdf7e3806 Switch to Spotify GraphQL API to match Jellyfin plugin approach
- Replace REST API calls with GraphQL API (api-partner.spotify.com/pathfinder/v1/query)
- Use same persisted query approach as Jellyfin Spotify Import plugin
- GraphQL API is more reliable and has better rate limits
- Fetch playlists with operationName: fetchPlaylist
- Parse GraphQL response structure (playlistV2, itemV2, etc)
- This should eliminate TooManyRequests errors
2026-02-03 17:10:38 -05:00
fe9c1e17be Add rate limiting delays to prevent Spotify 429 errors
- Increase delay between playlist fetches from 1s to 3s
- Only delay between playlists, not after the last one
- Add debug logging for rate limit delays
- Spotify is very aggressive with rate limiting on their API
2026-02-03 17:05:34 -05:00
63324def62 Fix Jellyfin BadRequest errors when fetching playlist items
- Add UserId parameter to playlist items API call (required by Jellyfin)
- Auto-fetch first user if no user ID configured
- Simplify Fields parameter to only request Path
- This fixes the 400 BadRequest errors when loading Jellyfin playlists tab
2026-02-03 17:03:04 -05:00
ff72ae2395 Fix playlist linking to save all playlists and add restart banner
- Read playlists from .env file instead of stale in-memory config
- This fixes issue where linking multiple playlists only saved the last one
- Add prominent restart banner at top when config changes
- Update UI immediately after linking/unlinking playlists
- Banner shows 'Restart Now' button and dismissible option
- All config changes now show the restart banner instead of toast messages
- GetJellyfinPlaylists now reads from .env for accurate linked status
2026-02-03 17:00:23 -05:00
1a3134083b Fix remaining pragma warnings in debug console output 2026-02-03 16:55:32 -05:00
bd64f437cd Fix web UI infinite loop and improve cookie age tracking
- Add cookieDateInitialized flag to prevent infinite init-cookie-date calls
- Auto-initialize cookie date when cookie exists but date not tracked
- Improve local vs external track detection in Jellyfin playlists
- Support multiple Spotify playlist ID formats (ID, URI, URL)
- Fix pragma warnings in legacy playlist parsing code
- Simplify GetPlaylistTrackStats to check Path property for local tracks
2026-02-03 16:54:40 -05:00
5606706dc8 Fix web UI config persistence and cookie age tracking
- Add SPOTIFY_API_SESSION_COOKIE_SET_DATE to docker-compose.yml env mapping
- Mount .env file in container for web UI to update
- Add SessionCookieSetDate loading in Program.cs
- Improve .env update logic with better error handling and logging
- Auto-initialize cookie date when cookie exists but date not set
- Simplify local vs external track detection in Jellyfin playlists
- Enhanced Spotify playlist ID parsing (supports ID, URI, and URL formats)
- Better UI clarity: renamed tabs to 'Link Playlists' and 'Active Playlists'
2026-02-03 16:50:19 -05:00
79a9e4063d Remove unnecessary /me API call - not needed for sp_dc auth 2026-02-03 16:27:19 -05:00
c33c85455f Fix JS syntax error: escape quotes in onclick handlers for playlist names 2026-02-03 16:19:13 -05:00
5af2bb1113 Remove hardcoded playlists from appsettings - use web UI instead 2026-02-03 16:14:28 -05:00
2c1297ebec Fix playlist config: dedupe entries, use Spotify playlist ID for lookup 2026-02-03 16:12:25 -05:00
df7f11e769 Add local/external track columns to Jellyfin playlists, remove libraries filter 2026-02-03 16:08:49 -05:00
75c7acb745 Fix Jellyfin playlists tab: linked Spotify ID, track count, and user/library filters 2026-02-03 15:13:29 -05:00
c7f6783fa2 CRITICAL: Stop hammering Spotify API on status checks
- GetStatus() no longer calls Spotify API at all
- Status is now determined purely from configuration
- Fixes rate limiting issue (429 errors every 30 seconds)
- Spotify API should only be called when actually fetching playlists
2026-02-03 15:08:17 -05:00
4c6406ef8f Add full-screen restart overlay for better UX
- Shows overlay with spinner during container restart
- Displays status messages: stopping, waiting, reloading
- Shows elapsed time while waiting for server
- Extended timeout to 60 seconds
- Hides overlay and shows toast if timeout exceeded
2026-02-03 15:06:38 -05:00
3ddf51924b Add container restart capability from admin UI
- Added POST /api/admin/restart endpoint using Docker socket
- Mounts Docker socket read-only in docker-compose.yml
- Admin UI now has working 'Restart Container' button
- Auto-reloads page after container comes back up
- Falls back to manual restart instructions if socket unavailable
2026-02-03 15:05:43 -05:00
3826f29019 Add Jellyfin playlist discovery and linking feature
- Added GET /api/admin/jellyfin/playlists to fetch all playlists from Jellyfin
- Added POST /api/admin/jellyfin/playlists/{id}/link to link playlist to Spotify
- Added DELETE /api/admin/jellyfin/playlists/{name}/unlink to remove link
- Added new 'Jellyfin Playlists' tab in admin UI showing all playlists
- Shows link status for each playlist (Linked/Not Linked)
- Link modal accepts Spotify playlist ID or full URL
- Renamed 'Playlists' tab to 'Configured Playlists' for clarity
2026-02-03 15:03:31 -05:00
4036c739a3 Remove LocalTracksPosition UI, auto-init cookie date tracking
- Removed LocalTracksPosition from playlist table (uses Spotify order now)
- Removed LocalTracksPosition from add playlist modal and JS
- Added /api/admin/config/init-cookie-date endpoint to auto-set date
- Cookie date auto-initializes when cookie exists but date is unknown
- Improved user display message when profile scope unavailable
- TOTP tokens work for playlists but don't have user-read-private scope
2026-02-03 15:00:12 -05:00
b7379e2fd4 Change external track labeling from ' - S' to ' [S]' format
- Updated album suffix from ' - S' to ' [S]'
- Updated artist suffix from ' - S' to ' [S]'
- Added song title suffix ' [S]' for external/streaming tracks
- Cleaner bracket format is more visible and consistent
2026-02-03 14:56:01 -05:00
c9895f6d1a Expand admin UI with full config editing and sp_dc cookie age tracking
- Fix auth status detection to use token validity instead of /me endpoint
- Add SessionCookieSetDate to SpotifyApiSettings for tracking cookie age
- Auto-set cookie date when updating sp_dc via admin UI
- Add edit buttons for all config settings (Spotify, Deezer, Qobuz, SquidWTF, Jellyfin)
- Show cookie age with color-coded expiration warnings (green/yellow/red)
- Display cookie age on both Dashboard and Config tabs
- Add generic edit setting modal supporting text/password/number/toggle/select inputs
- Remove SquidWTF base URL (not configurable)
- Add restart container button with manual restart instructions
2026-02-03 14:52:40 -05:00
71c4241a8a Fix AdminStaticFilesMiddleware to use simple file serving instead of internal ASP.NET classes 2026-02-03 14:43:24 -05:00
ffed9a67f3 Expose admin UI port 5275 in docker-compose (for direct/Tailscale access) 2026-02-03 14:39:18 -05:00
a8d04b225b Move admin UI to separate internal port (5275) for security
- Admin API and static files only accessible on port 5275
- Main proxy port (8080) no longer serves admin endpoints
- AdminPortFilter rejects admin requests on wrong port
- AdminStaticFilesMiddleware only serves static files on admin port
- Port 5275 NOT exposed in Dockerfile or docker-compose by default
- Access admin UI via SSH tunnel or by uncommenting port mapping
2026-02-03 14:39:07 -05:00
6abf0e0717 Add web dashboard UI for configuration and playlist management
- Add AdminController with API endpoints for status, playlists, config
- Add web UI dashboard at /admin with dark theme
- Disable SpotifyMissingTracksFetcher when SpotifyApi is enabled with cookie
- Support viewing playlists, tracks, and managing configuration
- Add .env file modification from UI (requires restart to apply)
- Dashboard auto-refreshes status every 30 seconds
2026-02-03 14:37:26 -05:00
8e7fc8b4ef Add info-level logging for Spotify auth debugging 2026-02-03 14:31:28 -05:00
b2c28d10f1 Fix TOTP secrets URL - use correct xyloflake/spot-secrets-go repo 2026-02-03 14:29:39 -05:00
a335997196 Implement TOTP-based Spotify authentication (like Jellyfin plugin)
- Use pre-scraped TOTP secrets from Viperinius/spotify-totp-secrets
- Generate TOTP code using server time and cipher transformation
- Add OTP.NET package for TOTP generation
- Match the authentication flow used by jellyfin-plugin-spotify-import
2026-02-03 14:27:18 -05:00
590f8f76cb Fix Spotify token endpoint - add required query params and fix cookie handling 2026-02-03 14:22:51 -05:00
1532d74a20 Add SpotifyApi environment variables to docker-compose.yml 2026-02-03 14:18:25 -05:00
f5ce355747 Fix logging levels - use appropriate debug/info/warn/error levels 2026-02-03 14:12:39 -05:00
494b4bbbc2 Reduce WebSocket/session logging noise, add playback progress logging
- Changed routine WebSocket messages (Sessions, KeepAlive) to debug level
- Reduced keep-alive spam - 401 on expired tokens is expected, not an error
- Added periodic progress logging for local tracks (every 10 seconds)
- Log warnings only when progress reports fail
2026-02-03 14:09:40 -05:00
375e1894f3 Add Spotify direct API integration for lyrics, ISRC matching, and playlist ordering
Features:
- SpotifyApiClient: Direct Spotify API client using sp_dc session cookie
- SpotifyLyricsService: Fetch synced lyrics from Spotify's color-lyrics API
- SpotifyPlaylistFetcher: Get playlists with correct track ordering and ISRC codes
- SpotifyTrackMatchingService: ISRC-based exact track matching for external providers

Improvements:
- Lyrics endpoint now prioritizes: 1) Jellyfin embedded, 2) Spotify synced, 3) LRCLIB
- Fixed playback progress reporting - removed incorrect body wrapping for Jellyfin API
- Added SpotifyApiSettings configuration model

Security:
- Session cookie and client ID properly masked in startup logs
- All credentials read from environment variables only
2026-02-03 14:06:40 -05:00
bbb0d9bb73 track count fix
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-02-03 01:49:58 -05:00
0356f3c54d logic fix 2 2026-02-03 01:46:38 -05:00
0c25d16e42 logic fix 2026-02-03 01:44:02 -05:00
4c3709113f Add legacy Spotify playlist format support with position control 2026-02-03 01:39:33 -05:00
12db8370a3 format 2026-02-03 01:29:27 -05:00
64be6eddf4 .env format 2026-02-03 01:24:08 -05:00
0980547848 Combine Spotify playlist config into single JSON array with local track position option 2026-02-03 00:23:57 -05:00
2bb1ffa581 Fix Spotify playlist to return local tracks + external tracks
Local tracks in Jellyfin playlist are now returned first, with
matched external tracks (from squid.wtf) appended at the end.

Previously the code tried to match local tracks by exact title/artist
which often failed due to naming differences.
2026-02-03 00:06:08 -05:00
51702a544b fix spotify search: start 24h ahead, search backwards for 72h 2026-02-02 23:49:39 -05:00
d9375405a5 add debug logging for websocket auth headers
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-02-02 20:45:00 -05:00
83063f594a fix websocket session auth and header parsing for jellyfin dashboard 2026-02-02 20:41:25 -05:00
b40349206d websocket on behalf of client 2026-02-02 20:31:07 -05:00
8dbac23944 literally just logging 2026-02-02 20:24:43 -05:00
9fb86d3839 fixed universal 2026-02-02 20:16:02 -05:00
bb2bda1379 x-forwarded-for headers 2026-02-02 20:14:11 -05:00
e9f72efb01 websocket logging 2026-02-02 19:53:39 -05:00
ab36a43892 another 2026-02-02 19:06:33 -05:00
2ffb769a6f another 2026-02-02 18:48:19 -05:00
045c810abc lets try that again 2026-02-02 18:42:12 -05:00
4c55520ce0 entirely new session handling 2026-02-02 18:37:38 -05:00
04079223c2 sync window for spotify playlists 2026-02-02 18:24:49 -05:00
1bb902d96a better searching for the file 2026-02-02 18:22:16 -05:00
b5f3f54c8b I really hate time 2026-02-02 18:19:12 -05:00
3bcb60a09a logging 2026-02-02 18:16:18 -05:00
ba78ed0883 Artists, not artist but now for lyrics 2026-02-02 18:12:12 -05:00
d0f26c0182 Artists, not artist 2026-02-02 15:57:34 -05:00
91275a2835 timestamp logging 2026-02-02 14:40:40 -05:00
ccbc9cf859 multiple artists big fix! 2026-02-02 14:35:53 -05:00
97975f1e08 oh how i hate time 2026-02-02 13:31:10 -05:00
ff48891a5a oh how i hate time 2026-02-02 13:24:21 -05:00
273fac7a0a log time message 2026-02-02 13:19:05 -05:00
12436c2f9c Whoops! cache wasn't clearing for main provider! please remove the files in your downloads folder and pull this commit 2026-02-02 13:13:35 -05:00
2315d6ab9f reduce overlapping runs 2026-02-02 13:05:36 -05:00
6eaeee9a67 remove nonfunctional endpoint
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-02-02 13:00:04 -05:00
9dd49a2f43 add round robin load balancing so providers dont hate me 2026-02-02 12:57:04 -05:00
2bc2816191 stupid timezones 2026-02-02 12:43:23 -05:00
1a2e160279 Update README pertaining to spotify playlist import 2026-02-02 12:32:11 -05:00
4111b5228d prematch on startup 2026-02-02 12:31:09 -05:00
82b480c47e remove useless md 2026-02-02 12:26:31 -05:00
0d246a8e74 gitignore update 2026-02-02 12:25:21 -05:00
fc3a8134ca spotify track prematching 2026-02-02 12:24:30 -05:00
2f91457e52 fix playlist, fix session sending 2026-02-02 12:18:29 -05:00
77774120bf playback start websocket 2026-02-02 12:09:29 -05:00
936fa27aa7 websocket 5
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-02-02 03:01:58 -05:00
b90ce423d7 websocket 4.0 2026-02-02 02:53:14 -05:00
5f038965a2 websocket 3 2026-02-02 02:51:00 -05:00
229fa0bf65 websocket 2.0 2026-02-02 02:47:12 -05:00
1aec76c3dd websocket fix 2026-02-02 02:43:55 -05:00
96b06d5d4f region fix 2026-02-02 02:40:18 -05:00
747d310375 websocket stuff 2026-02-02 02:39:06 -05:00
fc78a095a9 Websocket Proxying 2026-02-02 02:19:43 -05:00
65ca80f9a0 Fix: Properly handle HTTP status codes in Jellyfin proxy
- Refactor proxy service methods to return (Body, StatusCode) tuples
- Add HandleProxyResponse helper for consistent status code handling
- Fix 401 authentication errors being returned as 204
- Add explicit session reporting endpoints for playback tracking
- Ensure local track playback sessions are forwarded to Jellyfin
- All 219 tests passing
2026-02-02 01:05:25 -05:00
8e6eb5cc4a Support both favorite endpoint formats
Some checks failed
CI / build-and-test (push) Has been cancelled
- Add /UserFavoriteItems/{itemId} route (official Jellyfin API)
- Keep /Users/{userId}/FavoriteItems/{itemId} for compatibility
- Support userId in query string or path
- Add route logging to debug which endpoint is being called
2026-02-01 18:29:31 -05:00
1326f1b3ab Fix unsynced lyrics and favorite endpoints
- Fix unsynced lyrics showing all at 0:00 by omitting Start field
- Add DeleteAsync to proxy service for proper auth forwarding
- Fix favorite/unfavorite endpoints to use proxy service
- Add comprehensive logging for debugging
2026-02-01 18:24:09 -05:00
0011538966 Use LRClib search API with fuzzy matching and prefer synced lyrics
Some checks failed
CI / build-and-test (push) Has been cancelled
- Search API is more forgiving than exact get endpoint
- Scores results by track/artist similarity and duration match
- +20 point bonus for results with synced lyrics
- Falls back to exact match if search fails
- Improves lyrics hit rate for metadata variations
2026-02-01 12:30:24 -05:00
5acdacf132 Fix unsynced lyrics displaying as one big line
When LRClib returns plain/unsynced lyrics, they contain newlines but
were being sent as a single text block. Jellyfin clients would display
them all on one line.

Now splits plain lyrics by newlines and sends each line separately,
so they display properly line-by-line in the client.

LRClib search URL format:
https://lrclib.net/api/get\?track_name\=\{track\}\&artist_name\=\{artist\}\&album_name\=\{album\}\&duration\=\{seconds\}
2026-02-01 12:23:08 -05:00
cef836da43 Fix Spotify playlist ChildCount in SearchItems endpoint
The playlist list was going through SearchItems (not ProxyRequest), so
UpdateSpotifyPlaylistCounts was never called. Now updates counts in both:
- SearchItems when browsing playlists (no search term)
- ProxyRequest for other playlist list requests

This fixes playlists showing 0 tracks when they should show the count
of missing tracks available.
2026-02-01 12:05:45 -05:00
26c9a72def Remove server API key fallback for client requests
SECURITY FIX: Stop using server API key when clients don't provide auth

Before: If client sent no auth → proxy used server API key → gave them access
After: If client sends no auth → proxy sends no auth → Jellyfin rejects (401)

This ensures:
- Unauthenticated users can't piggyback on server credentials
- All actions are properly attributed to the actual user
- Jellyfin's auth system works as intended
- Server API key only used for internal operations (images, library detection)

Updated test to reflect new behavior: GetJsonAsync without client headers
should NOT add any authentication.
2026-02-01 12:00:38 -05:00
f5124bdda2 Add debug logging for Spotify playlist ChildCount updates
- Log when checking items for Spotify playlists
- Log cache lookups and file cache fallbacks
- Log successful ChildCount updates
- Log when no Spotify playlists found
- Helps diagnose why playlist counts might not be updating correctly
2026-02-01 11:56:34 -05:00
f7f57e711c Add endpoint usage logging for analysis
- Log all proxied endpoints to /app/cache/endpoint-usage/endpoints.csv
- CSV format: timestamp, method, path, query string
- Add GET /debug/endpoint-usage?api_key=KEY to view statistics
  - Shows top N endpoints by usage count
  - Filter by date with since parameter
  - Returns total requests, unique endpoints, first/last seen
- Add DELETE /debug/endpoint-usage?api_key=KEY to clear logs
- Thread-safe file appending
- Helps identify which endpoints clients actually use
- Can inform future blocklist/allowlist decisions
2026-02-01 11:52:44 -05:00
76f633afce Add security blocklist for dangerous admin endpoints
- Block system restart/shutdown endpoints
- Block system configuration changes
- Block plugin management (install/uninstall/configure)
- Block scheduled task management
- Block server startup/setup endpoints
- Block user creation endpoint
- Block library management (refresh, virtual folders)
- Block server logs and activity log access
- Log blocked attempts with IP address for security monitoring
- Returns 403 Forbidden with descriptive error message

This maintains client compatibility via catch-all proxy while preventing
unauthorized access to administrative functions.
2026-02-01 11:48:45 -05:00
24df910ffa Update Spotify playlist ChildCount to show actual track count
- Intercept playlist list responses and update ChildCount for Spotify playlists
- Shows the number of missing tracks found (local + matched external)
- Fixes playlist showing 0 songs when Jellyfin has no local files
- Reads from cache (Redis or file) to get accurate count
2026-02-01 11:38:25 -05:00
eb46692b25 Extend backward search window to 24 hours for Spotify missing tracks
- Search forward 12 hours from sync time
- Search backward 24 hours from sync time (was 12 hours)
- Ensures yesterday's file is always found when running at 11 AM after 4 PM sync
- Sync runs daily at 4:15 PM, so 24h backward always catches previous day's file
2026-02-01 11:33:42 -05:00
c54a32ccfc Add extensive logging to debug startup cache loading
- List all files in cache directory on startup
- Show expected file paths and actual file existence
- Log each step of cache checking process
- Add phase indicators for forward/backward search
- Show when cache exists and fetch is skipped
- Help diagnose why yesterday's cache files aren't being loaded
2026-02-01 11:28:00 -05:00
c0c7668cc4 Bypass sync window check on startup to fetch missing tracks immediately
- On startup, if no cache exists, fetch immediately regardless of sync window
- Regular background checks still respect sync window timing
- Ensures playlists are populated even if app restarts before sync time
2026-02-01 11:22:12 -05:00
e860bbe0ee Fix nullable warnings in SpotifyMissingTracksFetcher 2026-02-01 11:18:58 -05:00
df3cc51e17 Fix DownloadSongAsync return type handling 2026-02-01 11:18:17 -05:00
027aeab969 Fix compilation errors in favorite-to-keep and file cache features 2026-02-01 11:14:18 -05:00
449bcc2561 Fix spotify/sync endpoint route priority 2026-02-01 11:11:11 -05:00
8da0bef481 Add favorite-to-keep feature for external tracks
- When favoriting an external track, automatically copy to /kept folder
- Organized as Artist/Album/Track structure
- Includes cover art if available
- Downloads track first if not already cached
- Add KEPT_PATH and CACHE_PATH volumes to docker-compose
- Update .env.example and README with new feature
2026-02-01 11:09:18 -05:00
ae8afa20f8 Add file-based persistence for Spotify missing tracks cache
- Save missing tracks to /app/cache/spotify/*.json files
- 24-hour TTL for file cache
- Fallback to file cache when Redis is empty/restarted
- Controller checks file cache before returning empty playlists
- Ensures playlist data persists across Redis restarts
2026-02-01 11:07:19 -05:00
da1d28d292 Change playlist debug logs from warning to info level
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-01-31 21:32:20 -05:00
7e0ea501fc Fix Spotify missing tracks search order: forward 12h then backward 12h 2026-01-31 21:19:39 -05:00
bb976fed4f add 1337 kbps bitrate to external track MediaSources 2026-01-31 21:08:51 -05:00
df77b16640 fix method name: MapToSong -> ParseSong 2026-01-31 20:57:19 -05:00
74ae85338c merge local Jellyfin tracks with matched external tracks in Spotify playlists 2026-01-31 20:56:03 -05:00
72b7198f1d add fuzzy matching for Spotify track matching with scoring 2026-01-31 20:52:25 -05:00
b24dfb5b6a simplify Spotify track matching to title + artist only 2026-01-31 20:51:55 -05:00
85f8e1cc5f fix SquidWTF fallback to detect API error responses 2026-01-31 20:43:40 -05:00
74bd64c949 add missing using statement for ApiKeyAuthFilter
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-01-31 20:03:26 -05:00
1afa68064e add API key authentication to Spotify admin endpoints 2026-01-31 20:01:59 -05:00
5251c7ef6d add spotify cache clear endpoint 2026-01-31 20:01:12 -05:00
63ab25ca91 parse SPOTIFY_IMPORT_PLAYLIST_NAMES env var 2026-01-31 19:53:03 -05:00
628f845e77 add missing SPOTIFY_IMPORT_PLAYLIST_NAMES env var 2026-01-31 19:49:57 -05:00
8ef5ee7d8f fix background service to use configured playlist names 2026-01-31 19:44:47 -05:00
fb3ea1b876 fix playlist names format in env example 2026-01-31 19:42:11 -05:00
3f3e1b708d add configurable playlist names env var 2026-01-31 19:37:10 -05:00
bc4faead74 fix playlist names to use underscores for filenames 2026-01-31 19:35:55 -05:00
6ffa2a3277 use hardcoded playlist names for Spotify sync 2026-01-31 19:34:45 -05:00
c3c01b5559 check every minute for missing tracks files 2026-01-31 19:33:02 -05:00
47d59ec0f5 search last 24 hours for missing tracks files in manual sync 2026-01-31 19:30:01 -05:00
e7f72cd87a add manual Spotify sync trigger endpoint 2026-01-31 19:12:34 -05:00
6d15d02f16 add Spotify Import environment variables to docker-compose.yml 2026-01-31 18:11:25 -05:00
3137cc4657 fix: remove duplicate Spotify playlist interception code with compilation error 2026-01-31 18:07:22 -05:00
18e700d6a4 Fix compilation errors and remove unused code 2026-01-31 18:03:55 -05:00
2420cd9a23 Fix SpotifyMissingTracksFetcher to work with ID-based configuration and add detailed logging 2026-01-31 18:00:24 -05:00
65d6eb041a Refactor Spotify playlist injection to use playlist IDs instead of names 2026-01-31 17:59:01 -05:00
103808f079 Add logging to every ProxyRequest call 2026-01-31 17:54:51 -05:00
cd29e0de6c Add debug logging to diagnose Spotify configuration issue 2026-01-31 17:21:14 -05:00
bd480be382 Fix Spotify playlist interception in ProxyRequest with detailed logging 2026-01-31 17:17:59 -05:00
293f6f5cc4 Add detailed logging to GetPlaylistItems interception 2026-01-31 17:16:10 -05:00
e9b893eb3e Add detailed Spotify Import configuration logging 2026-01-31 17:14:24 -05:00
51694a395d Add comprehensive debug logging for Spotify playlist interception 2026-01-31 17:09:28 -05:00
32166061ef Add Spotify playlist interception in ProxyRequest method 2026-01-31 17:07:31 -05:00
a8845a9ef3 Change GetPlaylistItems route order to 1 and add debug logging 2026-01-31 17:05:56 -05:00
e873cfe3bf Fix JsonDocument.RootElement access in GetPlaylistTracks 2026-01-31 17:03:36 -05:00
43718eaefc Remove duplicate GetPlaylistItems route and add constructor logging 2026-01-31 17:02:35 -05:00
5f9451f5b4 Add playlist parsing from env var and debug logging for Spotify feature 2026-01-31 17:00:16 -05:00
2c3ef5c360 Fix Spotify playlist injection: add dedicated route, startup fetch, and clarify config 2026-01-31 16:57:52 -05:00
4ba2245876 refactor: optimize playlist interception order
- Check external playlists first (fast check)
- Only check Spotify if enabled (avoid extra API call)
- Fall back to regular Jellyfin playlists
- Maintains correct behavior for all playlist types
2026-01-31 16:54:39 -05:00
c117fa41f6 fix: add route to intercept playlist items requests
- Add GetPlaylistItems route with Order=5 (before catch-all)
- Intercepts /playlists/{id}/items requests
- Routes to GetPlaylistTracks for Spotify playlist handling
2026-01-31 16:54:01 -05:00
2b078453b2 refactor: reuse Jellyfin settings for Spotify feature
- Remove duplicate JellyfinUrl and ApiKey from SpotifyImportSettings
- Use existing JELLYFIN_URL and JELLYFIN_API_KEY settings
- Simplify configuration - no duplicate settings needed
- Update documentation and .env.example
2026-01-31 16:52:26 -05:00
0ee1883ccb fix: intercept existing Jellyfin Spotify playlists
- Check playlist name instead of creating virtual playlists
- Intercept playlist items request for Spotify playlists
- Fill empty playlists with matched tracks from providers
- Works with existing Jellyfin Spotify Import plugin playlists
2026-01-31 16:50:16 -05:00
8912758b5e feat: spotify playlist injection
- Add SpotifyImportSettings configuration model
- Create SpotifyMissingTracksFetcher background service
- Inject virtual Spotify playlists into search results
- Auto-match tracks from external providers
- Update README with feature documentation
- Configure sync window and playlist settings
2026-01-31 16:43:49 -05:00
35d5249843 chore: add sampleMissingPlaylists to gitignore 2026-01-31 12:09:16 -05:00
62bfb367bc docs: update README
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-01-31 11:15:44 -05:00
6f91361966 refactor: use token-based fuzzy matching for flexible search
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-01-31 01:03:14 -05:00
d4036095f1 fix: check all permutations of title/artist/album in search scoring 2026-01-31 01:00:56 -05:00
6620b39357 fix: remove score filtering, add combined title+artist matching 2026-01-31 01:00:00 -05:00
dcaa89171a refactor: change external suffix from H to S (SquidWTF) 2026-01-31 00:50:55 -05:00
1889dc6e19 fix: gracefully skip malformed playlists instead of failing all endpoints 2026-01-31 00:47:59 -05:00
615ad58bc6 refactor: change external provider suffix from SW to H 2026-01-31 00:08:11 -05:00
6176777d0f fix: forward client auth headers for login 2026-01-30 22:09:09 -05:00
a339574f05 fix: forward caching headers for client-side caching
Jellyfin sends ETag, Last-Modified, and Cache-Control headers that allow clients like Feishin to cache songs locally. Proxy now forwards these headers so clients don't re-download songs unnecessarily.
2026-01-30 22:02:35 -05:00
67b4fac64c Merge branch 'main' into dev
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-01-30 14:12:05 -05:00
ada6653bd1 fix: enable deduplication for cache mode
- Cache mode now registers downloaded songs in mappings
- Prevents duplicate downloads in cache mode
- Only library scan/album download skip cache mode
- Fixes wasted API calls and duplicate cache files
2026-01-30 14:10:34 -05:00
df8dbfc5e1 fix: prevent duplicate downloads by registering before releasing lock
- Move RegisterDownloadedSongAsync call before lock release
- Prevents race condition where multiple threads download same song
- Second thread now finds registered mapping before starting download
- Fixes duplicate files like 'Song (1).flac', 'Song (2).flac'
- Library scan and album download still happen after lock release
2026-01-30 13:37:42 -05:00
e8d3fc4d17 refactor: simplify endpoint comments to just names 2026-01-30 13:21:44 -05:00
649351f68b feat: add automatic fallback support for SquidWTF endpoints
- Decode 6 base64 URLs at startup (1 primary + 5 backups)
- Automatic fallback when endpoint fails
- All services try next endpoint on failure
- Metadata, Download, and Validator all support fallback
- Endpoints: triton.squid.wtf, wolf/hund/maus/vogel/katze.qqdl.site
- Logs which endpoint is being used
- Cycles through all endpoints before giving up
2026-01-30 13:21:23 -05:00
3487f79b5e refactor: pass decoded SquidWTF URL from Program.cs to services
- Decode base64 URL once at startup in Program.cs
- Pass decoded URL as constructor parameter to all SquidWTF services
- Services receive plain URL, no decoding logic in service classes
- Cleaner separation: encoding/decoding only in Program.cs
- All service code remains unchanged, just receives decoded URL
2026-01-30 13:16:26 -05:00
3a3f572ead fix: decode base64 URLs once at startup in static fields
- Changed from decoding in constructor to static readonly fields
- Decode happens once per class initialization, stored in memory
- Cleaner implementation, no repeated decoding per instance
- All three SquidWTF services updated (Metadata, Download, Validator)
2026-01-30 13:13:16 -05:00
d7f15fc3ab Add base64 encoding for SquidWTF endpoint (without trailing slash) 2026-01-30 13:08:10 -05:00
e43f5cd427 REVERT: Remove base64 encoding and endpoint fallback, restore simple hardcoded URL 2026-01-30 12:57:42 -05:00
1b79138923 Fix HTTP 307 redirect handling: configure HttpClient to follow redirects 2026-01-30 12:49:58 -05:00
dda9736f8d Fix endpoint fallback: remove shared state, always try primary first 2026-01-30 12:46:40 -05:00
9493cb48a5 Merge branch 'main' into dev 2026-01-30 12:40:41 -05:00
6c2453896f Add automatic endpoint fallback to all SquidWTF metadata search methods 2026-01-30 12:40:34 -05:00
40594dea7e Merge branch 'main' into dev 2026-01-30 12:38:57 -05:00
a06bf42887 Standardize all SquidWTF API URL formats to prevent double slashes 2026-01-30 12:38:19 -05:00
6713007650 Merge branch 'main' into dev 2026-01-30 12:37:13 -05:00
e7724c2cc0 Fix double slash bug in SquidWTF API URLs 2026-01-30 12:36:16 -05:00
3358fe019d Merge branch 'main' into dev 2026-01-30 12:26:43 -05:00
9efc54857f Update startup validator to use base64 encoded endpoint 2026-01-30 12:26:14 -05:00
fcdf47984c Merge branch 'main' into dev 2026-01-30 12:25:28 -05:00
040a5451a1 Fix remaining BaseUrl references 2026-01-30 12:24:39 -05:00
8d76e97449 Merge branch 'main' into dev 2026-01-30 12:13:17 -05:00
a86a8013e6 Add backup API endpoints with automatic fallback 2026-01-30 12:11:41 -05:00
4c557a0325 Merge branch 'main' into dev 2026-01-30 12:01:12 -05:00
8540a22846 Add Contributing section with development setup 2026-01-30 11:59:58 -05:00
36a224bd45 Consolidate Quick Start sections and update to use docker pull 2026-01-30 11:57:05 -05:00
3af3ebb52b Merge branch 'main' into dev 2026-01-30 11:54:57 -05:00
614adb9892 Add build status badges for main and beta branches 2026-01-30 11:51:49 -05:00
f434f13a19 Merge branch 'main' into dev 2026-01-30 11:48:53 -05:00
625a75f8f9 Update docker-compose to use GHCR image by default 2026-01-30 11:46:56 -05:00
d600c5e456 Merge branch 'main' into dev 2026-01-30 11:39:14 -05:00
e23e22a736 Fix nullable reference warnings 2026-01-30 11:36:59 -05:00
ba0fe35e72 Merge branch 'main' into dev 2026-01-30 11:32:42 -05:00
6e9fe0e69e Upgrade to .NET 10.0 2026-01-30 11:31:45 -05:00
cba955c427 Fix JellyfinProxyServiceTests for RedisCacheService parameter 2026-01-30 11:27:14 -05:00
192173ea64 Merge branch 'main' into dev 2026-01-30 11:23:41 -05:00
c33180abd7 Fix: Parse LRC format into individual lines for Feishin compatibility
Feishin expects lyrics as an array of {Start, Text} objects with timestamps
in ticks, not as a single LRC-formatted text block
2026-01-30 03:00:55 -05:00
680454e76e Clarify lyrics preference: synced lyrics preferred over plain
Already working correctly, just made the code clearer with explicit variable
2026-01-30 02:28:05 -05:00
34bfc20d28 Change Redis cache logging from Debug to Info level
Makes cache hits/misses visible in production logs without needing Debug level
2026-01-30 02:20:11 -05:00
489159b424 Add debug logging for Redis cache hits/misses
Now logs when cache entries are retrieved (HIT/MISS) and when they're stored
2026-01-30 02:17:45 -05:00
2bb754b245 Fix: Pass through full query string for browse requests
Recently added, recently played, and frequently played sections use
different query parameters (SortBy, SortOrder, Filters, etc) that we
weren't forwarding. Now we pass the complete query string to Jellyfin.
2026-01-30 02:15:48 -05:00
8d8c0892a2 Fix: Handle decimal duration values from LRCLIB API
LRCLIB returns duration as a decimal/float, not int. Convert to int using Math.Round.
2026-01-30 02:13:10 -05:00
e12851e9ca Fix: Handle nullable Duration in lyrics endpoint 2026-01-30 02:10:25 -05:00
f8969bea8d Add LRCLIB lyrics integration for Jellyfin
- Create LrclibService to fetch lyrics from lrclib.net API
- Add LyricsInfo model for lyrics data
- Add /Audio/{itemId}/Lyrics and /Items/{itemId}/Lyrics endpoints
- Support both local and external songs
- Cache lyrics for 30 days in Redis
- Return lyrics in Jellyfin format with synced/plain lyrics
2026-01-30 02:09:27 -05:00
ceaa17f018 Fix: Register SquidWTFSettings configuration in Program.cs
The SquidWTF quality setting from .env was not being loaded because
SquidWTFSettings wasn't registered with the DI container.
2026-01-30 00:50:34 -05:00
9aa7ceb138 Remove unused _preferredQuality field from SquidWTFDownloadService
The quality setting is already being used correctly via _squidwtfSettings.Quality
2026-01-30 00:49:41 -05:00
72b1ebc2eb Fix: Wrap playback request bodies in required field names for Jellyfin
Jellyfin expects playback endpoints to have bodies wrapped:
- /Sessions/Playing -> {"playbackStartInfo": {...}}
- /Sessions/Playing/Progress -> {"playbackProgressInfo": {...}}
- /Sessions/Playing/Stopped -> {"playbackStopInfo": {...}}

This fixes the 'field is required' validation errors.
2026-01-30 00:48:38 -05:00
48a0351862 Improve POST body debugging for playback endpoints
- Better logging to show what client sends vs what we forward
- Log all headers when body is empty to help diagnose issues
2026-01-30 00:45:16 -05:00
4b95f9910c Fix: Ensure POST requests always send body content to Jellyfin
- Always send body content for POST requests, even if empty (send '{}')
- Update TODO.md to mark tasks 2 and 3 as done
- Improve logging for POST body debugging
2026-01-30 00:44:39 -05:00
80424a867d Fix: Update last access time for cached files to enable proper cleanup 2026-01-30 00:41:17 -05:00
4afd769602 Fix: Proxy Artists/{id}/Similar to correct Jellyfin endpoint 2026-01-30 00:29:56 -05:00
b47a5f9063 Add ' - SW' suffix to external albums and artists
Makes it easy to distinguish external content from local Jellyfin library
2026-01-30 00:21:37 -05:00
32 changed files with 765 additions and 12365 deletions

View File

@@ -18,41 +18,27 @@ SUBSONIC_URL=http://localhost:4533
# Server URL (required if using Jellyfin backend) # Server URL (required if using Jellyfin backend)
JELLYFIN_URL=http://localhost:8096 JELLYFIN_URL=http://localhost:8096
# API key for SERVER-SIDE operations only (get from Jellyfin Dashboard > API Keys) # API key for authentication (get from Jellyfin Dashboard > API Keys)
# This is used by Allstarr to query Jellyfin's library on behalf of the server
# CLIENT authentication is handled transparently - clients authenticate directly with Jellyfin
JELLYFIN_API_KEY= JELLYFIN_API_KEY=
# User ID for SERVER-SIDE library queries (get from Jellyfin Dashboard > Users > click user > check URL) # User ID (get from Jellyfin Dashboard > Users > click user > check URL)
# This determines which user's library Allstarr queries when searching/browsing
JELLYFIN_USER_ID= JELLYFIN_USER_ID=
# Music library ID (optional, auto-detected if not set) # Music library ID (optional, auto-detected if not set)
# If you have multiple libraries, set this to filter to music only
JELLYFIN_LIBRARY_ID= JELLYFIN_LIBRARY_ID=
# ===== MUSIC SOURCE SELECTION ===== # ===== MUSIC SOURCE SELECTION =====
# Music service to use: SquidWTF, Deezer, or Qobuz (default: SquidWTF) # Music service to use: SquidWTF, Deezer, or Qobuz (default: SquidWTF)
MUSIC_SERVICE=SquidWTF MUSIC_SERVICE=SquidWTF
<<<<<<< HEAD
# Base directory for all downloads (default: ./downloads)
# This creates three subdirectories:
# - downloads/permanent/ - Permanently downloaded tracks (STORAGE_MODE=Permanent)
# - downloads/cache/ - Temporarily cached tracks (STORAGE_MODE=Cache)
# - downloads/kept/ - Favorited external tracks (always permanent)
DOWNLOAD_PATH=./downloads
||||||| bc4e5d9
# Path where downloaded songs will be stored on the host (only applies if STORAGE_MODE=Permanent) # Path where downloaded songs will be stored on the host (only applies if STORAGE_MODE=Permanent)
DOWNLOAD_PATH=./downloads DOWNLOAD_PATH=./downloads
=======
# Base directory for all downloads (default: ./downloads) # Path where favorited external tracks are permanently kept
# This creates three subdirectories: KEPT_PATH=./kept
# - downloads/permanent/ - Permanently downloaded tracks (STORAGE_MODE=Permanent)
# - downloads/cache/ - Temporarily cached tracks (STORAGE_MODE=Cache) # Path for cache files (Spotify missing tracks, etc.)
# - downloads/kept/ - Favorited external tracks (always permanent) CACHE_PATH=./cache
Library__DownloadPath=./downloads
>>>>>>> dev
# ===== SQUIDWTF CONFIGURATION ===== # ===== SQUIDWTF CONFIGURATION =====
# Different quality options for SquidWTF. Only FLAC supported right now # Different quality options for SquidWTF. Only FLAC supported right now
@@ -122,14 +108,27 @@ CACHE_DURATION_HOURS=1
# ===== SPOTIFY PLAYLIST INJECTION (JELLYFIN ONLY) ===== # ===== SPOTIFY PLAYLIST INJECTION (JELLYFIN ONLY) =====
# REQUIRES: Jellyfin Spotify Import Plugin (https://github.com/Viperinius/jellyfin-plugin-spotify-import) # REQUIRES: Jellyfin Spotify Import Plugin (https://github.com/Viperinius/jellyfin-plugin-spotify-import)
# This feature intercepts Spotify Import plugin playlists and fills them with tracks from external providers # This feature intercepts Spotify Import plugin playlists (Release Radar, Discover Weekly) and fills them
# with tracks auto-matched from external providers (SquidWTF, Deezer, Qobuz)
# Uses JELLYFIN_URL and JELLYFIN_API_KEY configured above (no separate credentials needed) # Uses JELLYFIN_URL and JELLYFIN_API_KEY configured above (no separate credentials needed)
# Enable Spotify playlist injection (optional, default: false) # Enable Spotify playlist injection (optional, default: false)
SPOTIFY_IMPORT_ENABLED=false SPOTIFY_IMPORT_ENABLED=false
# Sync schedule: When does the Spotify Import plugin run?
# Set these to match your plugin's sync schedule in Jellyfin
# Example: If plugin runs daily at 4:15 PM, set HOUR=16 and MINUTE=15
SPOTIFY_IMPORT_SYNC_START_HOUR=16
SPOTIFY_IMPORT_SYNC_START_MINUTE=15
# Sync window: How long to search for missing tracks files (in hours)
# The fetcher will check every 5 minutes within this window
# Example: If plugin runs at 4:15 PM and window is 2 hours, checks from 4:00 PM to 6:00 PM
SPOTIFY_IMPORT_SYNC_WINDOW_HOURS=2
# Matching interval: How often to run track matching (in hours) # Matching interval: How often to run track matching (in hours)
# Spotify playlists like Discover Weekly update once per week, Release Radar updates weekly # Spotify playlists like Discover Weekly update once per week, Release Radar updates weekly
# Most playlists don't change frequently, so running once per day is reasonable
# Set to 0 to only run once on startup (manual trigger via admin UI still works) # Set to 0 to only run once on startup (manual trigger via admin UI still works)
# Default: 24 hours # Default: 24 hours
SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS=24 SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS=24

3
.gitignore vendored
View File

@@ -104,6 +104,3 @@ originals/
# Sample missing playlists for Spotify integration testing # Sample missing playlists for Spotify integration testing
sampleMissingPlaylists/ sampleMissingPlaylists/
# Migration guide (local only)
MIGRATION.md

526
README.md
View File

@@ -5,7 +5,11 @@
[![Docker Image](https://img.shields.io/badge/docker-ghcr.io%2Fsopat712%2Fallstarr-blue)](https://github.com/SoPat712/allstarr/pkgs/container/allstarr) [![Docker Image](https://img.shields.io/badge/docker-ghcr.io%2Fsopat712%2Fallstarr-blue)](https://github.com/SoPat712/allstarr/pkgs/container/allstarr)
[![License](https://img.shields.io/badge/license-GPL--3.0-green)](LICENSE) [![License](https://img.shields.io/badge/license-GPL--3.0-green)](LICENSE)
A media server proxy that integrates music streaming providers with your local library. Works with **Jellyfin** and **Subsonic-compatible** servers. When a song isn't in your local library, it gets fetched from your configured provider, downloaded, and served to your client. The downloaded song then lives in your library for next time. A media server proxy that integrates music streaming providers with your local library. Works with **Jellyfin** and **Subsonic-compatible** servers (Navidrome). When a song isn't in your local library, it gets fetched from your configured provider, downloaded, and served to your client. The downloaded song then lives in your library for next time.
**THIS IS UNDER ACTIVE DEVELOPMENT**
Please report all bugs as soon as possible, as the Jellyfin addition is entirely a test at this point
## Quick Start ## Quick Start
@@ -34,18 +38,17 @@ docker-compose logs -f
The proxy will be available at `http://localhost:5274`. The proxy will be available at `http://localhost:5274`.
<<<<<<< HEAD
## Web Dashboard ## Web Dashboard
Allstarr includes a web UI for easy configuration and playlist management, accessible at `http://localhost:5275` Allstarr includes a web-based dashboard for easy configuration and playlist management, accessible at `http://localhost:5275` (internal port, not exposed through reverse proxy).
### Features ### Features
- **Playlist Management**: Link Jellyfin playlists to Spotify playlists with just a few clicks - **Real-time Status**: Monitor Spotify authentication, cookie age, and playlist sync status
- **Provider Matching**: It should fill in the gaps of your Jellyfin library with tracks from your selected provider - **Playlist Management**: Link Jellyfin playlists to Spotify playlists with a few clicks
- **WebUI**: Update settings without manually editing .env files - **Configuration Editor**: Update settings without manually editing .env files
- **Music**: Using multiple sources for music (optimized for SquidWTF right now, though) - **Track Viewer**: Browse tracks in your configured playlists
- **Lyrics**: Using multiple sources for lyrics, first Jellyfin Lyrics, then Spotify Lyrics, then LrcLib as a last resort - **Cache Management**: Clear cached data and restart the container
### Quick Setup with Web UI ### Quick Setup with Web UI
@@ -62,66 +65,19 @@ Allstarr includes a web UI for easy configuration and playlist management, acces
- `37i9dQZF1DXcBWIGoYBM5M` (just the ID) - `37i9dQZF1DXcBWIGoYBM5M` (just the ID)
- `spotify:playlist:37i9dQZF1DXcBWIGoYBM5M` (Spotify URI) - `spotify:playlist:37i9dQZF1DXcBWIGoYBM5M` (Spotify URI)
- `https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M` (full URL) - `https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M` (full URL)
4. **Restart** to apply changes (should be a banner) 4. **Restart** to apply changes (button in Configuration tab)
Then, proceeed to **Active Playlists**, which shows you which Spotify playlists are currently being monitored and filled with tracks, and lets you do a bunch of useful operations on them. ### Why Two Playlist Tabs?
- **Link Playlists**: Shows all Jellyfin playlists and lets you connect them to Spotify
- **Active Playlists**: Shows which Spotify playlists are currently being monitored and filled with tracks
### Configuration Persistence ### Configuration Persistence
The web UI updates your `.env` file directly. Changes persist across container restarts, but require a restart to take effect. In development mode, the `.env` file is in your project root. In Docker, it's at `/app/.env`. The web UI updates your `.env` file directly. Changes persist across container restarts, but require a restart to take effect. In development mode, the `.env` file is in your project root. In Docker, it's at `/app/.env`.
There's an environment variable to modify this. **Recommended workflow**: Use the `sp_dc` cookie method (simpler and more reliable than the Jellyfin Spotify Import plugin).
**Recommended workflow**: Use the `sp_dc` cookie method alongside the [Spotify Import Plugin](https://github.com/Viperinius/jellyfin-plugin-spotify-import?tab=readme-ov-file).
||||||| bc4e5d9
=======
## Web Dashboard
Allstarr includes a web UI for easy configuration and playlist management, accessible at `http://localhost:5275`
<img width="1664" height="1101" alt="image" src="https://github.com/user-attachments/assets/9159100b-7e11-449e-8530-517d336d6bd2" />
### Features
- **Playlist Management**: Link Jellyfin playlists to Spotify playlists with just a few clicks
- **Provider Matching**: It should fill in the gaps of your Jellyfin library with tracks from your selected provider
- **WebUI**: Update settings without manually editing .env files
- **Music**: Using multiple sources for music (optimized for SquidWTF right now, though)
- **Lyrics**: Using multiple sources for lyrics, first Jellyfin Lyrics, then Spotify Lyrics, then LrcLib as a last resort
### Quick Setup with Web UI
1. **Access the dashboard** at `http://localhost:5275`
2. **Configure Spotify** (Configuration tab):
- Enable Spotify API
- Add your `sp_dc` cookie from Spotify (see instructions in UI)
- The cookie age is automatically tracked
3. **Link playlists** (Link Playlists tab):
- View all your Jellyfin playlists
- Click "Link to Spotify" on any playlist
- Paste the Spotify playlist ID, URL, or `spotify:playlist:` URI
- Accepts formats like:
- `37i9dQZF1DXcBWIGoYBM5M` (just the ID)
- `spotify:playlist:37i9dQZF1DXcBWIGoYBM5M` (Spotify URI)
- `https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M` (full URL)
4. **Restart** to apply changes (should be a banner)
Then, proceeed to **Active Playlists**, which shows you which Spotify playlists are currently being monitored and filled with tracks, and lets you do a bunch of useful operations on them.
### Configuration Persistence
The web UI updates your `.env` file directly. Changes persist across container restarts, but require a restart to take effect. In development mode, the `.env` file is in your project root. In Docker, it's at `/app/.env`.
There's an environment variable to modify this.
**Recommended workflow**: Use the `sp_dc` cookie method alongside the [Spotify Import Plugin](https://github.com/Viperinius/jellyfin-plugin-spotify-import?tab=readme-ov-file).
>>>>>>> dev
### Nginx Proxy Setup (Required) ### Nginx Proxy Setup (Required)
This service only exposes ports internally. You can use nginx to proxy to it, however PLEASE take significant precautions before exposing this! Everyone decides their own level of risk, but this is currently untested, potentially dangerous software, with almost unfettered access to your Jellyfin server. My recommendation is use Tailscale or something similar! This service only exposes ports internally. You can use nginx to proxy to it, however PLEASE take significant precautions before exposing this! Everyone decides their own level of risk, but this is currently untested, potentially dangerous software, with almost unfettered access to your Jellyfin server. My recommendation is use Tailscale or something similar!
@@ -185,21 +141,7 @@ This project brings together all the music streaming providers into one unified
**Compatible Jellyfin clients:** **Compatible Jellyfin clients:**
- [Feishin](https://github.com/jeffvli/feishin) (Mac/Windows/Linux) - [Feishin](https://github.com/jeffvli/feishin) (Mac/Windows/Linux)
<<<<<<< HEAD
- [Musiver](https://music.aqzscn.cn/en/) (Android/IOS/Windows/Android) - [Musiver](https://music.aqzscn.cn/en/) (Android/IOS/Windows/Android)
- [Finamp](https://github.com/jmshrv/finamp) ()
||||||| bc4e5d9
- [Musiver](https://music.aqzscn.cn/en/) (Android/IOS/Windows/Android)
=======
<img width="1691" height="1128" alt="image" src="https://github.com/user-attachments/assets/c602f71c-c4dd-49a9-b533-1558e24a9f45" />
- [Musiver](https://music.aqzscn.cn/en/) (Android/iOS/Windows/Android)
<img width="523" height="1025" alt="image" src="https://github.com/user-attachments/assets/135e2721-5fd7-482f-bb06-b0736003cfe7" />
- [Finamp](https://github.com/jmshrv/finamp) (Android/iOS)
>>>>>>> dev
_Working on getting more currently_ _Working on getting more currently_
@@ -391,10 +333,9 @@ Subsonic__EnableExternalPlaylists=false
> **Note**: Due to client-side filtering, playlists from streaming providers may not appear in the "Playlists" tab of some clients, but will show up in global search results. > **Note**: Due to client-side filtering, playlists from streaming providers may not appear in the "Playlists" tab of some clients, but will show up in global search results.
<<<<<<< HEAD
### Spotify Playlist Injection (Jellyfin Only) ### Spotify Playlist Injection (Jellyfin Only)
Allstarr automatically fills your Spotify playlists (like Release Radar and Discover Weekly) with tracks from your configured streaming provider (SquidWTF, Deezer, or Qobuz). This works by intercepting playlists created by the Jellyfin Spotify Import plugin and matching missing tracks with your streaming service. Allstarr can automatically fill your Spotify playlists (like Release Radar and Discover Weekly) with tracks from your configured streaming provider (SquidWTF, Deezer, or Qobuz). This feature works by intercepting playlists created by the Jellyfin Spotify Import plugin and matching missing tracks with your streaming service.
#### Prerequisites #### Prerequisites
@@ -408,244 +349,138 @@ Allstarr automatically fills your Spotify playlists (like Release Radar and Disc
- Go to Jellyfin Dashboard → Plugins → Spotify Import - Go to Jellyfin Dashboard → Plugins → Spotify Import
- Connect your Spotify account - Connect your Spotify account
- Select which playlists to sync (e.g., Release Radar, Discover Weekly) - Select which playlists to sync (e.g., Release Radar, Discover Weekly)
- Set a sync schedule (the plugin will create playlists in Jellyfin) - Set a daily sync schedule (e.g., 4:15 PM daily)
- The plugin will create playlists in Jellyfin and generate "missing tracks" files for songs not in your library
3. **Configure Allstarr** 3. **Configure Allstarr**
- Enable Spotify Import in Allstarr (see configuration below) - Allstarr needs to know when the plugin runs and which playlists to intercept
- Link your Jellyfin playlists to Spotify playlists via the Web UI - Uses your existing `JELLYFIN_URL` and `JELLYFIN_API_KEY` settings (no additional credentials needed)
- Uses your existing `JELLYFIN_URL` and `JELLYFIN_API_KEY` settings
#### Configuration #### Configuration
| Setting | Description | | Setting | Description |
|---------|-------------| |---------|-------------|
| `SpotifyImport:Enabled` | Enable Spotify playlist injection (default: `false`) | | `SpotifyImport:Enabled` | Enable Spotify playlist injection (default: `false`) |
| `SpotifyImport:MatchingIntervalHours` | How often to run track matching in hours (default: 24, set to 0 for startup only) | | `SpotifyImport:SyncStartHour` | Hour when the Spotify Import plugin runs (24-hour format, 0-23) |
| `SpotifyImport:Playlists` | JSON array of playlists (managed via Web UI) | | `SpotifyImport:SyncStartMinute` | Minute when the plugin runs (0-59) |
| `SpotifyImport:SyncWindowHours` | Hours to search for missing tracks files after sync time (default: 2) |
| `SpotifyImport:PlaylistIds` | Comma-separated Jellyfin playlist IDs to intercept |
| `SpotifyImport:PlaylistNames` | Comma-separated playlist names (must match order of IDs) |
**Environment variables example:** **Environment variables example:**
```bash ```bash
# Enable the feature # Enable the feature
SPOTIFY_IMPORT_ENABLED=true SPOTIFY_IMPORT_ENABLED=true
# Matching interval (24 hours = once per day) # Sync window settings (optional - used to prevent fetching too frequently)
SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS=24 # The fetcher searches backwards from current time for the last 48 hours
SPOTIFY_IMPORT_SYNC_START_HOUR=16
SPOTIFY_IMPORT_SYNC_START_MINUTE=15
SPOTIFY_IMPORT_SYNC_WINDOW_HOURS=2
# Playlists (use Web UI to manage instead of editing manually) # Get playlist IDs from Jellyfin URLs: https://jellyfin.example.com/web/#/details?id=PLAYLIST_ID
SPOTIFY_IMPORT_PLAYLISTS=[["Discover Weekly","37i9dQZEVXcV6s7Dm7RXsU","first"],["Release Radar","37i9dQZEVXbng2vDHnfQlC","first"]] SPOTIFY_IMPORT_PLAYLIST_IDS=ba50e26c867ec9d57ab2f7bf24cfd6b0,4383a46d8bcac3be2ef9385053ea18df
# Names must match exactly as they appear in Jellyfin (used to find missing tracks files)
SPOTIFY_IMPORT_PLAYLIST_NAMES=Release Radar,Discover Weekly
``` ```
#### How It Works #### How It Works
1. **Spotify Import Plugin Runs** 1. **Spotify Import Plugin Runs** (e.g., daily at 4:15 PM)
- Plugin fetches your Spotify playlists - Plugin fetches your Spotify playlists
- Creates/updates playlists in Jellyfin with tracks already in your library - Creates/updates playlists in Jellyfin with tracks already in your library
- Generates "missing tracks" JSON files for songs not found locally - Generates "missing tracks" JSON files for songs not found locally
- Files are named like: `Release Radar_missing_2026-02-01_16-15.json`
2. **Allstarr Matches Tracks** (on startup + every 24 hours by default) 2. **Allstarr Fetches Missing Tracks** (within sync window)
- Reads missing tracks files from the Jellyfin plugin - Searches for missing tracks files from the Jellyfin plugin
- Searches **+24 hours forward first** (newest files), then **-48 hours backward** if not found
- This efficiently finds the most recent file regardless of timezone differences
- Example: Server time 12 PM EST, file timestamped 9 PM UTC (same day) → Found in forward search
- Caches the list of missing tracks in Redis + file cache
- Runs automatically on startup (if needed) and every 5 minutes during the sync window
3. **Allstarr Matches Tracks** (2 minutes after startup, then configurable interval)
- For each missing track, searches your streaming provider (SquidWTF, Deezer, or Qobuz) - For each missing track, searches your streaming provider (SquidWTF, Deezer, or Qobuz)
- Uses fuzzy matching to find the best match (title + artist similarity) - Uses fuzzy matching to find the best match (title + artist similarity)
- Rate-limited to avoid overwhelming the service (150ms delay between searches) - Rate-limited to avoid overwhelming the service (150ms delay between searches)
- Pre-builds playlist cache for instant loading - Caches matched results for 1 hour
- **Pre-builds playlist items cache** for instant serving (no "on the fly" building)
- Default interval: 24 hours (configurable via `SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS`)
- Set to 0 to only run once on startup (manual trigger via admin UI still works)
3. **You Open the Playlist in Jellyfin** 4. **You Open the Playlist in Jellyfin**
- Allstarr intercepts the request - Allstarr intercepts the request
- Returns a merged list: local tracks + matched streaming tracks - Returns a merged list: local tracks + matched streaming tracks
- Loads instantly from cache! - Loads instantly from cache (no searching needed!)
4. **You Play a Track** 5. **You Play a Track**
- Local tracks stream from Jellyfin normally - If it's a local track, streams from Jellyfin normally
- Matched tracks download from streaming provider on-demand - If it's a matched track, downloads from streaming provider on-demand
- Downloaded tracks are saved to your library for future use - Downloaded tracks are saved to your library for future use
#### Manual API Triggers #### Manual Triggers
You can manually trigger operations via the admin API: You can manually trigger syncing and matching via API:
```bash ```bash
# Get API key from your .env file
API_KEY="your-api-key-here"
# Fetch missing tracks from Jellyfin plugin # Fetch missing tracks from Jellyfin plugin
curl "http://localhost:5274/spotify/sync?api_key=$API_KEY" curl "https://your-jellyfin-proxy.com/spotify/sync?api_key=YOUR_API_KEY"
# Trigger track matching (searches streaming provider) # Trigger track matching (searches streaming provider)
curl "http://localhost:5274/spotify/match?api_key=$API_KEY" curl "https://your-jellyfin-proxy.com/spotify/match?api_key=YOUR_API_KEY"
# Match all playlists (refresh all matches) # Clear cache to force re-matching
curl "http://localhost:5274/spotify/match-all?api_key=$API_KEY" curl "https://your-jellyfin-proxy.com/spotify/clear-cache?api_key=YOUR_API_KEY"
# Clear cache and rebuild
curl "http://localhost:5274/spotify/clear-cache?api_key=$API_KEY"
# Refresh specific playlist
curl "http://localhost:5274/spotify/refresh-playlist?playlistId=PLAYLIST_ID&api_key=$API_KEY"
``` ```
#### Web UI Management #### Startup Behavior
The easiest way to manage Spotify playlists is through the Web UI at `http://localhost:5275`: When Allstarr starts with Spotify Import enabled:
1. **Link Playlists Tab**: Link Jellyfin playlists to Spotify playlists **Smart Cache Check:**
2. **Active Playlists Tab**: View status, trigger matching, and manage playlists - Checks if today's sync window has passed (e.g., if sync is at 4 PM + 2 hour window = 6 PM)
3. **Configuration Tab**: Enable/disable Spotify Import and adjust settings - If before 6 PM and yesterday's cache exists → **Skips fetch** (cache is still current)
- If after 6 PM or no cache exists → **Fetches missing tracks** from Jellyfin plugin
**Track Matching:**
- **T+2min**: Matches tracks with streaming provider (with rate limiting)
- Only matches playlists that don't already have cached matches
- **Result**: Playlists load instantly when you open them!
**Example Timeline:**
- Plugin runs daily at 4:15 PM, creates files at ~4:16 PM
- You restart Allstarr at 12:00 PM (noon) the next day
- Startup check: "Today's sync window ends at 6 PM, and I have yesterday's 4:16 PM file"
- **Decision**: Skip fetch, use existing cache
- At 6:01 PM: Next scheduled check will search for new files
#### Troubleshooting #### Troubleshooting
**Playlists are empty:** **Playlists are empty:**
- Check that the Spotify Import plugin is running and creating playlists - Check that the Spotify Import plugin is running and creating playlists
- Verify playlists are linked in the Web UI - Verify `SPOTIFY_IMPORT_PLAYLIST_IDS` match your Jellyfin playlist IDs
- Check logs: `docker-compose logs -f allstarr | grep -i spotify` - Check logs: `docker-compose logs -f allstarr | grep -i spotify`
**Tracks aren't matching:** **Tracks aren't matching:**
- Ensure your streaming provider is configured (`MUSIC_SERVICE`, credentials) - Ensure your streaming provider is configured (`MUSIC_SERVICE`, credentials)
- Manually trigger matching via Web UI or API - Check that playlist names in `SPOTIFY_IMPORT_PLAYLIST_NAMES` match exactly
- Check that the Jellyfin plugin generated missing tracks files - Manually trigger matching: `curl "https://your-proxy.com/spotify/match?api_key=KEY"`
**Performance:** **Sync timing issues:**
- Matching runs in background with rate limiting (150ms between searches) - Set `SPOTIFY_IMPORT_SYNC_START_HOUR/MINUTE` to match your plugin schedule
- First match may take a few minutes for large playlists - Increase `SPOTIFY_IMPORT_SYNC_WINDOW_HOURS` if files aren't being found
- Subsequent loads are instant (served from cache) - Check Jellyfin plugin logs to confirm when it runs
#### Notes #### Notes
- Uses your existing `JELLYFIN_URL` and `JELLYFIN_API_KEY` settings - This feature uses your existing `JELLYFIN_URL` and `JELLYFIN_API_KEY` settings
- Matched tracks cached for fast loading - Matched tracks are cached for 1 hour to avoid repeated searches
- Missing tracks cache persists across restarts (Redis + file cache) - Missing tracks cache persists across restarts (stored in Redis + file cache)
- Rate limiting prevents overwhelming your streaming provider - Rate limiting prevents overwhelming your streaming provider (150ms between searches)
- Only works with Jellyfin backend (not Subsonic/Navidrome) - Only works with Jellyfin backend (not Subsonic/Navidrome)
||||||| bc4e5d9
=======
### Spotify Playlist Injection (Jellyfin Only)
Allstarr automatically fills your Spotify playlists (like Release Radar and Discover Weekly) with tracks from your configured streaming provider (SquidWTF, Deezer, or Qobuz). This works by intercepting playlists created by the Jellyfin Spotify Import plugin and matching missing tracks with your streaming service.
<img width="1649" height="3764" alt="image" src="https://github.com/user-attachments/assets/a4d3d79c-7741-427f-8c01-ffc90f3a579b" />
#### Prerequisites
1. **Install the Jellyfin Spotify Import Plugin**
- Navigate to Jellyfin Dashboard → Plugins → Catalog
- Search for "Spotify Import" by Viperinius
- Install and restart Jellyfin
- Plugin repository: [Viperinius/jellyfin-plugin-spotify-import](https://github.com/Viperinius/jellyfin-plugin-spotify-import)
2. **Configure the Spotify Import Plugin**
- Go to Jellyfin Dashboard → Plugins → Spotify Import
- Connect your Spotify account
- Select which playlists to sync (e.g., Release Radar, Discover Weekly)
- Set a sync schedule (the plugin will create playlists in Jellyfin)
3. **Configure Allstarr**
- Enable Spotify Import in Allstarr (see configuration below)
- Link your Jellyfin playlists to Spotify playlists via the Web UI
- Uses your existing `JELLYFIN_URL` and `JELLYFIN_API_KEY` settings
#### Configuration
| Setting | Description |
|---------|-------------|
| `SpotifyImport:Enabled` | Enable Spotify playlist injection (default: `false`) |
| `SpotifyImport:MatchingIntervalHours` | How often to run track matching in hours (default: 24, set to 0 for startup only) |
| `SpotifyImport:Playlists` | JSON array of playlists (managed via Web UI) |
**Environment variables example:**
```bash
# Enable the feature
SPOTIFY_IMPORT_ENABLED=true
# Matching interval (24 hours = once per day)
SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS=24
# Playlists (use Web UI to manage instead of editing manually)
SPOTIFY_IMPORT_PLAYLISTS=[["Discover Weekly","37i9dQZEVXcV6s7Dm7RXsU","first"],["Release Radar","37i9dQZEVXbng2vDHnfQlC","first"]]
```
#### How It Works
1. **Spotify Import Plugin Runs**
- Plugin fetches your Spotify playlists
- Creates/updates playlists in Jellyfin with tracks already in your library
- Generates "missing tracks" JSON files for songs not found locally
2. **Allstarr Matches Tracks** (on startup + every 24 hours by default)
- Reads missing tracks files from the Jellyfin plugin
- For each missing track, searches your streaming provider (SquidWTF, Deezer, or Qobuz)
- Uses fuzzy matching to find the best match (title + artist similarity)
- Rate-limited to avoid overwhelming the service (150ms delay between searches)
- Pre-builds playlist cache for instant loading
3. **You Open the Playlist in Jellyfin**
- Allstarr intercepts the request
- Returns a merged list: local tracks + matched streaming tracks
- Loads instantly from cache!
4. **You Play a Track**
- Local tracks stream from Jellyfin normally
- Matched tracks download from streaming provider on-demand
- Downloaded tracks are saved to your library for future use
#### Manual API Triggers
You can manually trigger operations via the admin API:
```bash
# Get API key from your .env file
API_KEY="your-api-key-here"
# Fetch missing tracks from Jellyfin plugin
curl "http://localhost:5274/spotify/sync?api_key=$API_KEY"
# Trigger track matching (searches streaming provider)
curl "http://localhost:5274/spotify/match?api_key=$API_KEY"
# Match all playlists (refresh all matches)
curl "http://localhost:5274/spotify/match-all?api_key=$API_KEY"
# Clear cache and rebuild
curl "http://localhost:5274/spotify/clear-cache?api_key=$API_KEY"
# Refresh specific playlist
curl "http://localhost:5274/spotify/refresh-playlist?playlistId=PLAYLIST_ID&api_key=$API_KEY"
```
#### Web UI Management
The easiest way to manage Spotify playlists is through the Web UI at `http://localhost:5275`:
1. **Link Playlists Tab**: Link Jellyfin playlists to Spotify playlists
2. **Active Playlists Tab**: View status, trigger matching, and manage playlists
3. **Configuration Tab**: Enable/disable Spotify Import and adjust settings
#### Troubleshooting
**Playlists are empty:**
- Check that the Spotify Import plugin is running and creating playlists
- Verify playlists are linked in the Web UI
- Check logs: `docker-compose logs -f allstarr | grep -i spotify`
**Tracks aren't matching:**
- Ensure your streaming provider is configured (`MUSIC_SERVICE`, credentials)
- Manually trigger matching via Web UI or API
- Check that the Jellyfin plugin generated missing tracks files
**Performance:**
- Matching runs in background with rate limiting (150ms between searches)
- First match may take a few minutes for large playlists
- Subsequent loads are instant (served from cache)
#### Notes
- Uses your existing `JELLYFIN_URL` and `JELLYFIN_API_KEY` settings
- Matched tracks cached for fast loading
- Missing tracks cache persists across restarts (Redis + file cache)
- Rate limiting prevents overwhelming your streaming provider
- Only works with Jellyfin backend (not Subsonic/Navidrome)
>>>>>>> dev
### Getting Credentials ### Getting Credentials
#### Deezer ARL Token #### Deezer ARL Token
@@ -757,46 +592,9 @@ If you prefer to run Allstarr without Docker:
## API Endpoints ## API Endpoints
### Jellyfin Backend (Primary Focus)
The proxy provides comprehensive Jellyfin API support with streaming provider integration:
| Endpoint | Description |
|----------|-------------|
| `GET /Items` | Search and browse library items (local + streaming providers) |
| `GET /Artists` | Browse artists with merged results from local + streaming |
| `GET /Artists/AlbumArtists` | Album artists with streaming provider results |
| `GET /Users/{userId}/Items` | User library items with external content |
| `GET /Audio/{id}/stream` | Stream audio, downloading from provider on-demand |
| `GET /Audio/{id}/Lyrics` | Lyrics from Jellyfin, Spotify, or LRCLib |
| `GET /Items/{id}/Images/{type}` | Proxy cover art for external content |
| `GET /Playlists/{id}/Items` | Playlist items (Spotify Import integration) |
| `POST /UserFavoriteItems/{id}` | Favorite items; copies external tracks to kept folder |
| `DELETE /UserFavoriteItems/{id}` | Unfavorite items |
| `POST /Sessions/Playing` | Playback reporting for external tracks |
| `POST /Sessions/Playing/Progress` | Playback progress tracking |
| `POST /Sessions/Playing/Stopped` | Playback stopped reporting |
| `WebSocket /socket` | Real-time session management and remote control |
**Admin API (Port 5275):**
| Endpoint | Description |
|----------|-------------|
| `GET /api/config` | Get current configuration |
| `POST /api/config` | Update configuration |
| `GET /api/playlists` | List Spotify Import playlists |
| `POST /api/playlists/link` | Link Jellyfin playlist to Spotify |
| `DELETE /api/playlists/{id}` | Unlink playlist |
| `POST /spotify/sync` | Fetch missing tracks from Jellyfin plugin |
| `POST /spotify/match` | Trigger track matching |
| `POST /spotify/match-all` | Match all playlists |
| `POST /spotify/clear-cache` | Clear playlist cache |
| `POST /spotify/refresh-playlist` | Refresh specific playlist |
All other Jellyfin API endpoints are passed through unchanged.
### Subsonic Backend ### Subsonic Backend
The proxy implements the Subsonic API with streaming provider integration: The proxy implements the Subsonic API and adds transparent streaming provider integration:
| Endpoint | Description | | Endpoint | Description |
|----------|-------------| |----------|-------------|
@@ -810,6 +608,20 @@ The proxy implements the Subsonic API with streaming provider integration:
All other Subsonic API endpoints are passed through to Navidrome unchanged. All other Subsonic API endpoints are passed through to Navidrome unchanged.
### Jellyfin Backend
The proxy implements a subset of the Jellyfin API:
| Endpoint | Description |
|----------|-------------|
| `GET /Items` | Search and browse library items |
| `GET /Artists` | Browse artists with streaming provider results |
| `GET /Audio/{id}/stream` | Stream audio, downloading from provider if needed |
| `GET /Items/{id}/Images/{type}` | Proxy cover art for external content |
| `POST /UserFavoriteItems/{id}` | Favorite items; triggers playlist download |
All other Jellyfin API endpoints are passed through unchanged.
## External ID Format ## External ID Format
External (streaming provider) content uses typed IDs: External (streaming provider) content uses typed IDs:
@@ -824,37 +636,25 @@ Legacy format `ext-deezer-{id}` is also supported (assumes song type).
## Download Folder Structure ## Download Folder Structure
All downloads are organized under a single base directory (default: `./downloads`): Downloaded music is organized as:
``` ```
downloads/ downloads/
├── permanent/ # Permanent downloads (STORAGE_MODE=Permanent) ├── Artist Name/
│ ├── Artist Name/ │ ├── Album Title/
│ │ ├── Album Title/ │ │ ├── 01 - Track One.mp3
│ │ ├── 01 - Track One.flac │ │ ├── 02 - Track Two.mp3
│ │ │ ├── 02 - Track Two.flac
│ │ │ └── ...
│ │ └── Another Album/
│ │ └── ... │ │ └── ...
│ └── playlists/ │ └── Another Album/
── My Favorite Songs.m3u ── ...
│ └── Chill Vibes.m3u ├── Another Artist/
├── cache/ # Temporary cache (STORAGE_MODE=Cache) │ └── ...
│ └── Artist Name/ └── playlists/
── Album Title/ ── My Favorite Songs.m3u
│ └── Track.flac ├── Chill Vibes.m3u
└── kept/ # Favorited external tracks (always permanent) └── ...
└── Artist Name/
└── Album Title/
└── Track.flac
``` ```
**Storage modes:** Playlists are stored as M3U files with relative paths to downloaded tracks, making them portable and compatible with most music players.
- **Permanent** (`downloads/permanent/`): Files saved permanently and registered in your media server
- **Cache** (`downloads/cache/`): Temporary files, auto-cleaned after `CACHE_DURATION_HOURS`
- **Kept** (`downloads/kept/`): External tracks you've favorited - always permanent, separate from cache
Playlists are stored as M3U files with relative paths, making them portable and compatible with most music players.
## Metadata Embedding ## Metadata Embedding
@@ -885,17 +685,10 @@ dotnet test
``` ```
allstarr/ allstarr/
├── Controllers/ ├── Controllers/
│ ├── AdminController.cs # Admin dashboard API │ ├── JellyfinController.cs # Jellyfin API controller (registered when Backend:Type=Jellyfin)
── JellyfinController.cs # Jellyfin API controller ── SubsonicController.cs # Subsonic API controller (registered when Backend:Type=Subsonic)
│ └── SubsonicController.cs # Subsonic API controller
├── Filters/
│ ├── AdminPortFilter.cs # Admin port access control
│ ├── ApiKeyAuthFilter.cs # API key authentication
│ └── JellyfinAuthFilter.cs # Jellyfin authentication
├── Middleware/ ├── Middleware/
── AdminStaticFilesMiddleware.cs # Admin UI static file serving ── GlobalExceptionHandler.cs # Global error handling
│ ├── GlobalExceptionHandler.cs # Global error handling
│ └── WebSocketProxyMiddleware.cs # WebSocket proxying for Jellyfin
├── Models/ ├── Models/
│ ├── Domain/ # Domain entities │ ├── Domain/ # Domain entities
│ │ ├── Song.cs │ │ ├── Song.cs
@@ -904,39 +697,18 @@ allstarr/
│ ├── Settings/ # Configuration models │ ├── Settings/ # Configuration models
│ │ ├── SubsonicSettings.cs │ │ ├── SubsonicSettings.cs
│ │ ├── DeezerSettings.cs │ │ ├── DeezerSettings.cs
│ │ ── QobuzSettings.cs │ │ ── QobuzSettings.cs
│ │ ├── SquidWTFSettings.cs
│ │ ├── SpotifyApiSettings.cs
│ │ ├── SpotifyImportSettings.cs
│ │ ├── MusicBrainzSettings.cs
│ │ └── RedisSettings.cs
│ ├── Download/ # Download-related models │ ├── Download/ # Download-related models
│ │ ├── DownloadInfo.cs │ │ ├── DownloadInfo.cs
│ │ └── DownloadStatus.cs │ │ └── DownloadStatus.cs
│ ├── Lyrics/
│ │ └── LyricsInfo.cs
│ ├── Search/ │ ├── Search/
│ │ └── SearchResult.cs │ │ └── SearchResult.cs
│ ├── Spotify/
│ │ ├── MissingTrack.cs
│ │ └── SpotifyPlaylistTrack.cs
│ └── Subsonic/ │ └── Subsonic/
│ ├── ExternalPlaylist.cs
│ └── ScanStatus.cs │ └── ScanStatus.cs
├── Services/ ├── Services/
│ ├── Common/ # Shared services │ ├── Common/ # Shared services
│ │ ├── BaseDownloadService.cs # Template method base class │ │ ├── BaseDownloadService.cs # Template method base class
│ │ ├── CacheCleanupService.cs # Cache cleanup background service
│ │ ├── CacheWarmingService.cs # Startup cache warming
│ │ ├── EndpointBenchmarkService.cs # Endpoint performance benchmarking
│ │ ├── FuzzyMatcher.cs # Fuzzy string matching
│ │ ├── GenreEnrichmentService.cs # MusicBrainz genre enrichment
│ │ ├── OdesliService.cs # Odesli/song.link conversion
│ │ ├── ParallelMetadataService.cs # Parallel metadata fetching
│ │ ├── PathHelper.cs # Path utilities │ │ ├── PathHelper.cs # Path utilities
│ │ ├── PlaylistIdHelper.cs # Playlist ID helpers
│ │ ├── RedisCacheService.cs # Redis caching
│ │ ├── RoundRobinFallbackHelper.cs # Load balancing and failover
│ │ ├── Result.cs # Result<T> pattern │ │ ├── Result.cs # Result<T> pattern
│ │ └── Error.cs # Error types │ │ └── Error.cs # Error types
│ ├── Deezer/ # Deezer provider │ ├── Deezer/ # Deezer provider
@@ -948,35 +720,12 @@ allstarr/
│ │ ├── QobuzMetadataService.cs │ │ ├── QobuzMetadataService.cs
│ │ ├── QobuzBundleService.cs │ │ ├── QobuzBundleService.cs
│ │ └── QobuzStartupValidator.cs │ │ └── QobuzStartupValidator.cs
│ ├── SquidWTF/ # SquidWTF provider
│ │ ├── SquidWTFDownloadService.cs
│ │ ├── SquidWTFMetadataService.cs
│ │ └── SquidWTFStartupValidator.cs
│ ├── Jellyfin/ # Jellyfin integration
│ │ ├── JellyfinModelMapper.cs # Model mapping
│ │ ├── JellyfinProxyService.cs # Request proxying
│ │ ├── JellyfinResponseBuilder.cs # Response building
│ │ ├── JellyfinSessionManager.cs # Session management
│ │ └── JellyfinStartupValidator.cs # Startup validation
│ ├── Lyrics/ # Lyrics services
│ │ ├── LrclibService.cs # LRCLIB lyrics
│ │ ├── LyricsPrefetchService.cs # Background lyrics prefetching
│ │ ├── LyricsStartupValidator.cs # Lyrics validation
│ │ └── SpotifyLyricsService.cs # Spotify lyrics
│ ├── MusicBrainz/
│ │ └── MusicBrainzService.cs # MusicBrainz metadata
│ ├── Spotify/ # Spotify integration
│ │ ├── SpotifyApiClient.cs # Spotify API client
│ │ ├── SpotifyMissingTracksFetcher.cs # Missing tracks fetcher
│ │ ├── SpotifyPlaylistFetcher.cs # Playlist fetcher
│ │ └── SpotifyTrackMatchingService.cs # Track matching
│ ├── Local/ # Local library │ ├── Local/ # Local library
│ │ ├── ILocalLibraryService.cs │ │ ├── ILocalLibraryService.cs
│ │ └── LocalLibraryService.cs │ │ └── LocalLibraryService.cs
│ ├── Subsonic/ # Subsonic API logic │ ├── Subsonic/ # Subsonic API logic
│ │ ├── PlaylistSyncService.cs # Playlist synchronization
│ │ ├── SubsonicModelMapper.cs # Model mapping
│ │ ├── SubsonicProxyService.cs # Request proxying │ │ ├── SubsonicProxyService.cs # Request proxying
│ │ ├── SubsonicModelMapper.cs # Model mapping
│ │ ├── SubsonicRequestParser.cs # Request parsing │ │ ├── SubsonicRequestParser.cs # Request parsing
│ │ └── SubsonicResponseBuilder.cs # Response building │ │ └── SubsonicResponseBuilder.cs # Response building
│ ├── Validation/ # Startup validation │ ├── Validation/ # Startup validation
@@ -988,17 +737,13 @@ allstarr/
│ ├── IDownloadService.cs # Download interface │ ├── IDownloadService.cs # Download interface
│ ├── IMusicMetadataService.cs # Metadata interface │ ├── IMusicMetadataService.cs # Metadata interface
│ └── StartupValidationService.cs │ └── StartupValidationService.cs
├── wwwroot/ # Admin UI static files
│ ├── index.html # Admin dashboard
│ └── placeholder.png # Placeholder image
├── Program.cs # Application entry point ├── Program.cs # Application entry point
└── appsettings.json # Configuration └── appsettings.json # Configuration
allstarr.Tests/ allstarr.Tests/
├── DeezerDownloadServiceTests.cs # Deezer download tests ├── DeezerDownloadServiceTests.cs # Deezer download tests
├── DeezerMetadataServiceTests.cs # Deezer metadata tests ├── DeezerMetadataServiceTests.cs # Deezer metadata tests
├── JellyfinResponseStructureTests.cs # Jellyfin response tests ├── QobuzDownloadServiceTests.cs # Qobuz download tests (127 tests)
├── QobuzDownloadServiceTests.cs # Qobuz download tests
├── LocalLibraryServiceTests.cs # Local library tests ├── LocalLibraryServiceTests.cs # Local library tests
├── SubsonicModelMapperTests.cs # Model mapping tests ├── SubsonicModelMapperTests.cs # Model mapping tests
├── SubsonicProxyServiceTests.cs # Proxy service tests ├── SubsonicProxyServiceTests.cs # Proxy service tests
@@ -1072,7 +817,7 @@ We welcome contributions! Here's how to get started:
- Follow existing code patterns and conventions - Follow existing code patterns and conventions
- Add tests for new features - Add tests for new features
- Update documentation as needed - Update documentation as needed
- Keep commits feature focused - Keep commits focused and atomic
### Testing ### Testing
@@ -1094,21 +839,8 @@ GPL-3.0
## Acknowledgments ## Acknowledgments
- [octo-fiesta](https://github.com/V1ck3s/octo-fiesta) - The original
- [octo-fiestarr](https://github.com/bransoned/octo-fiestarr) - The fork that introduced me to this idea based on the above
- [Jellyfin Spotify Import Plugin](https://github.com/Viperinius/jellyfin-plugin-spotify-import?tab=readme-ov-file) - The plugin that I **strongly** recommend using alongside this repo
- [Jellyfin](https://jellyfin.org/) - The free and open-source media server
- [Navidrome](https://www.navidrome.org/) - The excellent self-hosted music server - [Navidrome](https://www.navidrome.org/) - The excellent self-hosted music server
- [Subsonic API](http://www.subsonic.org/pages/api.jsp) - The API specification - [Jellyfin](https://jellyfin.org/) - The free and open-source media server
- [Hi-Fi API](https://github.com/binimum/hifi-api) - These people do some great work, and you should thank them for this even existing!
- [Deezer](https://www.deezer.com/) - Music streaming service - [Deezer](https://www.deezer.com/) - Music streaming service
- [Qobuz](https://www.qobuz.com/) - Hi-Res music streaming service - [Qobuz](https://www.qobuz.com/) - Hi-Res music streaming service
<<<<<<< HEAD
- [spotify-lyrics-api](https://github.com/akashrchandran/spotify-lyrics-api) - Thank them for the fact that we have access to Spotify's lyrics!
- [LRCLIB](https://github.com/tranxuanthang/lrclib) - The GOATS for giving us a free api for lyrics! They power LRCGET, which I'm sure some of you have heard of
||||||| bc4e5d9
- [Subsonic API](http://www.subsonic.org/pages/api.jsp) - The API specification - [Subsonic API](http://www.subsonic.org/pages/api.jsp) - The API specification
=======
- [spotify-lyrics-api](https://github.com/akashrchandran/spotify-lyrics-api) - Thank them for the fact that we have access to Spotify's lyrics!
- [LRCLIB](https://github.com/tranxuanthang/lrclib) - The GOATS for giving us a free api for lyrics! They power LRCGET, which I'm sure some of you have heard of
>>>>>>> dev

View File

@@ -1,4 +1,3 @@
<<<<<<< HEAD
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using allstarr.Models.Settings; using allstarr.Models.Settings;
@@ -29,7 +28,6 @@ public class AdminController : ControllerBase
private readonly SpotifyApiSettings _spotifyApiSettings; private readonly SpotifyApiSettings _spotifyApiSettings;
private readonly SpotifyImportSettings _spotifyImportSettings; private readonly SpotifyImportSettings _spotifyImportSettings;
private readonly JellyfinSettings _jellyfinSettings; private readonly JellyfinSettings _jellyfinSettings;
private readonly SubsonicSettings _subsonicSettings;
private readonly DeezerSettings _deezerSettings; private readonly DeezerSettings _deezerSettings;
private readonly QobuzSettings _qobuzSettings; private readonly QobuzSettings _qobuzSettings;
private readonly SquidWTFSettings _squidWtfSettings; private readonly SquidWTFSettings _squidWtfSettings;
@@ -54,7 +52,6 @@ public class AdminController : ControllerBase
IOptions<SpotifyApiSettings> spotifyApiSettings, IOptions<SpotifyApiSettings> spotifyApiSettings,
IOptions<SpotifyImportSettings> spotifyImportSettings, IOptions<SpotifyImportSettings> spotifyImportSettings,
IOptions<JellyfinSettings> jellyfinSettings, IOptions<JellyfinSettings> jellyfinSettings,
IOptions<SubsonicSettings> subsonicSettings,
IOptions<DeezerSettings> deezerSettings, IOptions<DeezerSettings> deezerSettings,
IOptions<QobuzSettings> qobuzSettings, IOptions<QobuzSettings> qobuzSettings,
IOptions<SquidWTFSettings> squidWtfSettings, IOptions<SquidWTFSettings> squidWtfSettings,
@@ -72,7 +69,6 @@ public class AdminController : ControllerBase
_spotifyApiSettings = spotifyApiSettings.Value; _spotifyApiSettings = spotifyApiSettings.Value;
_spotifyImportSettings = spotifyImportSettings.Value; _spotifyImportSettings = spotifyImportSettings.Value;
_jellyfinSettings = jellyfinSettings.Value; _jellyfinSettings = jellyfinSettings.Value;
_subsonicSettings = subsonicSettings.Value;
_deezerSettings = deezerSettings.Value; _deezerSettings = deezerSettings.Value;
_qobuzSettings = qobuzSettings.Value; _qobuzSettings = qobuzSettings.Value;
_squidWtfSettings = squidWtfSettings.Value; _squidWtfSettings = squidWtfSettings.Value;
@@ -167,7 +163,8 @@ public class AdminController : ControllerBase
spotifyImport = new spotifyImport = new
{ {
enabled = _spotifyImportSettings.Enabled, enabled = _spotifyImportSettings.Enabled,
matchingIntervalHours = _spotifyImportSettings.MatchingIntervalHours, syncTime = $"{_spotifyImportSettings.SyncStartHour:D2}:{_spotifyImportSettings.SyncStartMinute:D2}",
syncWindowHours = _spotifyImportSettings.SyncWindowHours,
playlistCount = _spotifyImportSettings.Playlists.Count playlistCount = _spotifyImportSettings.Playlists.Count
}, },
deezer = new deezer = new
@@ -1392,7 +1389,9 @@ public class AdminController : ControllerBase
spotifyImport = new spotifyImport = new
{ {
enabled = _spotifyImportSettings.Enabled, enabled = _spotifyImportSettings.Enabled,
matchingIntervalHours = _spotifyImportSettings.MatchingIntervalHours, syncStartHour = _spotifyImportSettings.SyncStartHour,
syncStartMinute = _spotifyImportSettings.SyncStartMinute,
syncWindowHours = _spotifyImportSettings.SyncWindowHours,
playlists = _spotifyImportSettings.Playlists.Select(p => new playlists = _spotifyImportSettings.Playlists.Select(p => new
{ {
name = p.Name, name = p.Name,
@@ -1409,13 +1408,8 @@ public class AdminController : ControllerBase
}, },
library = new library = new
{ {
downloadPath = _subsonicSettings.StorageMode == StorageMode.Cache downloadPath = _configuration["Library:DownloadPath"] ?? "./downloads",
? Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "cache") keptPath = _configuration["Library:KeptPath"] ?? "/app/kept"
: Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "permanent"),
keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept"),
storageMode = _subsonicSettings.StorageMode.ToString(),
cacheDurationHours = _subsonicSettings.CacheDurationHours,
downloadMode = _subsonicSettings.DownloadMode.ToString()
}, },
deezer = new deezer = new
{ {
@@ -2698,11 +2692,7 @@ public class AdminController : ControllerBase
if (parts.Length >= 3) if (parts.Length >= 3)
{ {
var timestamp = parts[0]; var timestamp = parts[0];
var method = parts[1]; var endpoint = parts[1];
var endpoint = parts[2];
// Combine method and endpoint for better clarity
var fullEndpoint = $"{method} {endpoint}";
// Filter by date if specified // Filter by date if specified
if (sinceDate.HasValue && DateTime.TryParse(timestamp, out var logDate)) if (sinceDate.HasValue && DateTime.TryParse(timestamp, out var logDate))
@@ -2711,7 +2701,7 @@ public class AdminController : ControllerBase
continue; continue;
} }
usage[fullEndpoint] = usage.GetValueOrDefault(fullEndpoint, 0) + 1; usage[endpoint] = usage.GetValueOrDefault(endpoint, 0) + 1;
} }
} }
@@ -3307,7 +3297,7 @@ public class LinkPlaylistRequest
{ {
try try
{ {
var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept"); var keptPath = _configuration["Library:KeptPath"] ?? "/app/kept";
_logger.LogInformation("📂 Checking kept folder: {Path}", keptPath); _logger.LogInformation("📂 Checking kept folder: {Path}", keptPath);
_logger.LogInformation("📂 Directory exists: {Exists}", Directory.Exists(keptPath)); _logger.LogInformation("📂 Directory exists: {Exists}", Directory.Exists(keptPath));
@@ -3390,7 +3380,7 @@ public class LinkPlaylistRequest
return BadRequest(new { error = "Path is required" }); return BadRequest(new { error = "Path is required" });
} }
var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept"); var keptPath = _configuration["Library:KeptPath"] ?? "/app/kept";
var fullPath = Path.Combine(keptPath, path); var fullPath = Path.Combine(keptPath, path);
_logger.LogInformation("🗑️ Delete request for: {Path}", fullPath); _logger.LogInformation("🗑️ Delete request for: {Path}", fullPath);
@@ -3454,7 +3444,7 @@ public class LinkPlaylistRequest
return BadRequest(new { error = "Path is required" }); return BadRequest(new { error = "Path is required" });
} }
var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept"); var keptPath = _configuration["Library:KeptPath"] ?? "/app/kept";
var fullPath = Path.Combine(keptPath, path); var fullPath = Path.Combine(keptPath, path);
// Security: Ensure the path is within the kept directory // Security: Ensure the path is within the kept directory
@@ -3496,3630 +3486,3 @@ public class LinkPlaylistRequest
return $"{len:0.##} {sizes[order]}"; return $"{len:0.##} {sizes[order]}";
} }
} }
||||||| bc4e5d9
=======
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using allstarr.Models.Settings;
using allstarr.Models.Spotify;
using allstarr.Services.Spotify;
using allstarr.Services.Jellyfin;
using allstarr.Services.Common;
using allstarr.Services;
using allstarr.Filters;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Runtime;
namespace allstarr.Controllers;
/// <summary>
/// Admin API controller for the web dashboard.
/// Provides endpoints for viewing status, playlists, and modifying configuration.
/// Only accessible on internal admin port (5275) - not exposed through reverse proxy.
/// </summary>
[ApiController]
[Route("api/admin")]
[ServiceFilter(typeof(AdminPortFilter))]
public class AdminController : ControllerBase
{
private readonly ILogger<AdminController> _logger;
private readonly IConfiguration _configuration;
private readonly SpotifyApiSettings _spotifyApiSettings;
private readonly SpotifyImportSettings _spotifyImportSettings;
private readonly JellyfinSettings _jellyfinSettings;
private readonly SubsonicSettings _subsonicSettings;
private readonly DeezerSettings _deezerSettings;
private readonly QobuzSettings _qobuzSettings;
private readonly SquidWTFSettings _squidWtfSettings;
private readonly MusicBrainzSettings _musicBrainzSettings;
private readonly SpotifyApiClient _spotifyClient;
private readonly SpotifyPlaylistFetcher _playlistFetcher;
private readonly SpotifyTrackMatchingService? _matchingService;
private readonly RedisCacheService _cache;
private readonly HttpClient _jellyfinHttpClient;
private readonly IWebHostEnvironment _environment;
private readonly IServiceProvider _serviceProvider;
private readonly string _envFilePath;
private readonly List<string> _squidWtfApiUrls;
private static int _urlIndex = 0;
private static readonly object _urlIndexLock = new();
private const string CacheDirectory = "/app/cache/spotify";
public AdminController(
ILogger<AdminController> logger,
IConfiguration configuration,
IWebHostEnvironment environment,
IOptions<SpotifyApiSettings> spotifyApiSettings,
IOptions<SpotifyImportSettings> spotifyImportSettings,
IOptions<JellyfinSettings> jellyfinSettings,
IOptions<SubsonicSettings> subsonicSettings,
IOptions<DeezerSettings> deezerSettings,
IOptions<QobuzSettings> qobuzSettings,
IOptions<SquidWTFSettings> squidWtfSettings,
IOptions<MusicBrainzSettings> musicBrainzSettings,
SpotifyApiClient spotifyClient,
SpotifyPlaylistFetcher playlistFetcher,
RedisCacheService cache,
IHttpClientFactory httpClientFactory,
IServiceProvider serviceProvider,
SpotifyTrackMatchingService? matchingService = null)
{
_logger = logger;
_configuration = configuration;
_environment = environment;
_spotifyApiSettings = spotifyApiSettings.Value;
_spotifyImportSettings = spotifyImportSettings.Value;
_jellyfinSettings = jellyfinSettings.Value;
_subsonicSettings = subsonicSettings.Value;
_deezerSettings = deezerSettings.Value;
_qobuzSettings = qobuzSettings.Value;
_squidWtfSettings = squidWtfSettings.Value;
_musicBrainzSettings = musicBrainzSettings.Value;
_spotifyClient = spotifyClient;
_playlistFetcher = playlistFetcher;
_matchingService = matchingService;
_cache = cache;
_jellyfinHttpClient = httpClientFactory.CreateClient();
_serviceProvider = serviceProvider;
// Decode SquidWTF base URLs
_squidWtfApiUrls = DecodeSquidWtfUrls();
// .env file path is always /app/.env in Docker (mounted from host)
// In development, it's in the parent directory of ContentRootPath
_envFilePath = _environment.IsDevelopment()
? Path.Combine(_environment.ContentRootPath, "..", ".env")
: "/app/.env";
}
private static List<string> DecodeSquidWtfUrls()
{
var encodedUrls = new[]
{
"aHR0cHM6Ly90cml0b24uc3F1aWQud3Rm", // triton
"aHR0cHM6Ly90aWRhbC1hcGkuYmluaW11bS5vcmc=", // binimum
"aHR0cHM6Ly90aWRhbC5raW5vcGx1cy5vbmxpbmU=", // kinoplus
"aHR0cHM6Ly9oaWZpLXR3by5zcG90aXNhdmVyLm5ldA==", // spoti-2
"aHR0cHM6Ly9oaWZpLW9uZS5zcG90aXNhdmVyLm5ldA==", // spoti-1
"aHR0cHM6Ly93b2xmLnFxZGwuc2l0ZQ==", // wolf
"aHR0cDovL2h1bmQucXFkbC5zaXRl", // hund
"aHR0cHM6Ly9rYXR6ZS5xcWRsLnNpdGU=", // katze
"aHR0cHM6Ly92b2dlbC5xcWRsLnNpdGU=", // vogel
"aHR0cHM6Ly9tYXVzLnFxZGwuc2l0ZQ==" // maus
};
return encodedUrls
.Select(encoded => System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(encoded)))
.ToList();
}
/// <summary>
/// Helper method to safely check if a dynamic cache result has a value
/// Handles the case where JsonElement cannot be compared to null directly
/// </summary>
private static bool HasValue(object? obj)
{
if (obj == null) return false;
if (obj is JsonElement jsonEl) return jsonEl.ValueKind != JsonValueKind.Null && jsonEl.ValueKind != JsonValueKind.Undefined;
return true;
}
/// <summary>
/// Get current system status and configuration
/// </summary>
[HttpGet("status")]
public IActionResult GetStatus()
{
// Determine Spotify auth status based on configuration only
// DO NOT call Spotify API here - this endpoint is polled frequently
var spotifyAuthStatus = "not_configured";
string? spotifyUser = null;
if (_spotifyApiSettings.Enabled && !string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie))
{
// If cookie is set, assume it's working until proven otherwise
// Actual validation happens when playlists are fetched
spotifyAuthStatus = "configured";
spotifyUser = "(cookie set)";
}
else if (_spotifyApiSettings.Enabled)
{
spotifyAuthStatus = "missing_cookie";
}
return Ok(new
{
version = "1.0.0",
backendType = _configuration.GetValue<string>("Backend:Type") ?? "Jellyfin",
jellyfinUrl = _jellyfinSettings.Url,
spotify = new
{
apiEnabled = _spotifyApiSettings.Enabled,
authStatus = spotifyAuthStatus,
user = spotifyUser,
hasCookie = !string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie),
cookieSetDate = _spotifyApiSettings.SessionCookieSetDate,
cacheDurationMinutes = _spotifyApiSettings.CacheDurationMinutes,
preferIsrcMatching = _spotifyApiSettings.PreferIsrcMatching
},
spotifyImport = new
{
enabled = _spotifyImportSettings.Enabled,
matchingIntervalHours = _spotifyImportSettings.MatchingIntervalHours,
playlistCount = _spotifyImportSettings.Playlists.Count
},
deezer = new
{
hasArl = !string.IsNullOrEmpty(_deezerSettings.Arl),
quality = _deezerSettings.Quality ?? "FLAC"
},
qobuz = new
{
hasToken = !string.IsNullOrEmpty(_qobuzSettings.UserAuthToken),
quality = _qobuzSettings.Quality ?? "FLAC"
},
squidWtf = new
{
quality = _squidWtfSettings.Quality ?? "LOSSLESS"
}
});
}
/// <summary>
/// Get a random SquidWTF base URL for searching (round-robin)
/// </summary>
[HttpGet("squidwtf-base-url")]
public IActionResult GetSquidWtfBaseUrl()
{
if (_squidWtfApiUrls.Count == 0)
{
return NotFound(new { error = "No SquidWTF base URLs configured" });
}
string baseUrl;
lock (_urlIndexLock)
{
baseUrl = _squidWtfApiUrls[_urlIndex];
_urlIndex = (_urlIndex + 1) % _squidWtfApiUrls.Count;
}
return Ok(new { baseUrl });
}
/// <summary>
/// Get list of configured playlists with their current data
/// </summary>
[HttpGet("playlists")]
public async Task<IActionResult> GetPlaylists([FromQuery] bool refresh = false)
{
var playlistCacheFile = "/app/cache/admin_playlists_summary.json";
// Check file cache first (5 minute TTL) unless refresh is requested
if (!refresh && System.IO.File.Exists(playlistCacheFile))
{
try
{
var fileInfo = new FileInfo(playlistCacheFile);
var age = DateTime.UtcNow - fileInfo.LastWriteTimeUtc;
if (age.TotalMinutes < 5)
{
var cachedJson = await System.IO.File.ReadAllTextAsync(playlistCacheFile);
var cachedData = JsonSerializer.Deserialize<Dictionary<string, object>>(cachedJson);
_logger.LogDebug("📦 Returning cached playlist summary (age: {Age:F1}m)", age.TotalMinutes);
return Ok(cachedData);
}
else
{
_logger.LogDebug("🔄 Cache expired (age: {Age:F1}m), refreshing...", age.TotalMinutes);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read cached playlist summary");
}
}
else if (refresh)
{
_logger.LogInformation("🔄 Force refresh requested for playlist summary");
}
var playlists = new List<object>();
// Read playlists directly from .env file to get the latest configuration
// (IOptions is cached and doesn't reload after .env changes)
var configuredPlaylists = await ReadPlaylistsFromEnvFile();
foreach (var config in configuredPlaylists)
{
var playlistInfo = new Dictionary<string, object?>
{
["name"] = config.Name,
["id"] = config.Id,
["jellyfinId"] = config.JellyfinId,
["localTracksPosition"] = config.LocalTracksPosition.ToString(),
["syncSchedule"] = config.SyncSchedule ?? "0 8 * * 1",
["trackCount"] = 0,
["localTracks"] = 0,
["externalTracks"] = 0,
["lastFetched"] = null as DateTime?,
["cacheAge"] = null as string
};
// Get Spotify playlist track count from cache
var cacheFilePath = Path.Combine(CacheDirectory, $"{SanitizeFileName(config.Name)}_spotify.json");
int spotifyTrackCount = 0;
if (System.IO.File.Exists(cacheFilePath))
{
try
{
var json = await System.IO.File.ReadAllTextAsync(cacheFilePath);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (root.TryGetProperty("tracks", out var tracks))
{
spotifyTrackCount = tracks.GetArrayLength();
playlistInfo["trackCount"] = spotifyTrackCount;
}
if (root.TryGetProperty("fetchedAt", out var fetchedAt))
{
var fetchedTime = fetchedAt.GetDateTime();
playlistInfo["lastFetched"] = fetchedTime;
var age = DateTime.UtcNow - fetchedTime;
playlistInfo["cacheAge"] = age.TotalHours < 1
? $"{age.TotalMinutes:F0}m"
: $"{age.TotalHours:F1}h";
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read cache for playlist {Name}", config.Name);
}
}
// Get current Jellyfin playlist track count
if (!string.IsNullOrEmpty(config.JellyfinId))
{
try
{
// Jellyfin requires UserId parameter to fetch playlist items
var userId = _jellyfinSettings.UserId;
// If no user configured, try to get the first user
if (string.IsNullOrEmpty(userId))
{
var usersResponse = await _jellyfinHttpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get, $"{_jellyfinSettings.Url}/Users")
{
Headers = { { "X-Emby-Authorization", GetJellyfinAuthHeader() } }
});
if (usersResponse.IsSuccessStatusCode)
{
var usersJson = await usersResponse.Content.ReadAsStringAsync();
using var usersDoc = JsonDocument.Parse(usersJson);
if (usersDoc.RootElement.GetArrayLength() > 0)
{
userId = usersDoc.RootElement[0].GetProperty("Id").GetString();
}
}
}
if (string.IsNullOrEmpty(userId))
{
_logger.LogWarning("No user ID available to fetch playlist items for {Name}", config.Name);
}
else
{
var url = $"{_jellyfinSettings.Url}/Playlists/{config.JellyfinId}/Items?UserId={userId}&Fields=Path";
var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
_logger.LogDebug("Fetching Jellyfin playlist items for {Name} from {Url}", config.Name, url);
var response = await _jellyfinHttpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
var jellyfinJson = await response.Content.ReadAsStringAsync();
using var jellyfinDoc = JsonDocument.Parse(jellyfinJson);
if (jellyfinDoc.RootElement.TryGetProperty("Items", out var items))
{
// Get Spotify tracks to match against
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name);
// Try to use the pre-built playlist cache first (includes manual mappings!)
var playlistItemsCacheKey = $"spotify:playlist:items:{config.Name}";
List<Dictionary<string, object?>>? cachedPlaylistItems = null;
try
{
cachedPlaylistItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(playlistItemsCacheKey);
}
catch (Exception cacheEx)
{
_logger.LogWarning(cacheEx, "Failed to deserialize playlist cache for {Playlist}", config.Name);
}
_logger.LogInformation("Checking cache for {Playlist}: {CacheKey}, Found: {Found}, Count: {Count}",
config.Name, playlistItemsCacheKey, cachedPlaylistItems != null, cachedPlaylistItems?.Count ?? 0);
if (cachedPlaylistItems != null && cachedPlaylistItems.Count > 0)
{
// Use the pre-built cache which respects manual mappings
var localCount = 0;
var externalCount = 0;
foreach (var item in cachedPlaylistItems)
{
// Check if it's external by looking for external provider in ProviderIds
// External providers: SquidWTF, Deezer, Qobuz, Tidal
var isExternal = false;
if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null)
{
// Handle both Dictionary<string, string> and JsonElement
Dictionary<string, string>? providerIds = null;
if (providerIdsObj is Dictionary<string, string> dict)
{
providerIds = dict;
}
else if (providerIdsObj is JsonElement jsonEl && jsonEl.ValueKind == JsonValueKind.Object)
{
providerIds = new Dictionary<string, string>();
foreach (var prop in jsonEl.EnumerateObject())
{
providerIds[prop.Name] = prop.Value.GetString() ?? "";
}
}
if (providerIds != null)
{
// Check for external provider keys (not MusicBrainz, ISRC, Spotify, etc)
isExternal = providerIds.Keys.Any(k =>
k.Equals("SquidWTF", StringComparison.OrdinalIgnoreCase) ||
k.Equals("Deezer", StringComparison.OrdinalIgnoreCase) ||
k.Equals("Qobuz", StringComparison.OrdinalIgnoreCase) ||
k.Equals("Tidal", StringComparison.OrdinalIgnoreCase));
}
}
if (isExternal)
{
externalCount++;
}
else
{
localCount++;
}
}
var externalMissingCount = spotifyTracks.Count - cachedPlaylistItems.Count;
if (externalMissingCount < 0) externalMissingCount = 0;
playlistInfo["localTracks"] = localCount;
playlistInfo["externalMatched"] = externalCount;
playlistInfo["externalMissing"] = externalMissingCount;
playlistInfo["externalTotal"] = externalCount + externalMissingCount;
playlistInfo["totalInJellyfin"] = cachedPlaylistItems.Count;
playlistInfo["totalPlayable"] = localCount + externalCount; // Total tracks that will be served
_logger.LogInformation("Playlist {Name} (from cache): {Total} Spotify tracks, {Local} local, {ExtMatched} external matched, {ExtMissing} external missing, {Playable} total playable",
config.Name, spotifyTracks.Count, localCount, externalCount, externalMissingCount, localCount + externalCount);
}
else
{
// Fallback: Build list of local tracks from Jellyfin (match by name only)
var localTracks = new List<(string Title, string Artist)>();
foreach (var item in items.EnumerateArray())
{
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
var artist = "";
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
{
artist = artistsEl[0].GetString() ?? "";
}
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
{
artist = albumArtistEl.GetString() ?? "";
}
if (!string.IsNullOrEmpty(title))
{
localTracks.Add((title, artist));
}
}
// Get matched external tracks cache once
var matchedTracksKey = $"spotify:matched:ordered:{config.Name}";
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
var matchedSpotifyIds = new HashSet<string>(
matchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>()
);
var localCount = 0;
var externalMatchedCount = 0;
var externalMissingCount = 0;
// Match each Spotify track to determine if it's local, external, or missing
foreach (var track in spotifyTracks)
{
var isLocal = false;
var hasExternalMapping = false;
// FIRST: Check for manual Jellyfin mapping
var manualMappingKey = $"spotify:manual-map:{config.Name}:{track.SpotifyId}";
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
if (!string.IsNullOrEmpty(manualJellyfinId))
{
// Manual Jellyfin mapping exists - this track is definitely local
isLocal = true;
}
else
{
// Check for external manual mapping
var externalMappingKey = $"spotify:external-map:{config.Name}:{track.SpotifyId}";
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
if (!string.IsNullOrEmpty(externalMappingJson))
{
// External manual mapping exists
hasExternalMapping = true;
}
else if (localTracks.Count > 0)
{
// SECOND: No manual mapping, try fuzzy matching with local tracks
var bestMatch = localTracks
.Select(local => new
{
Local = local,
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, local.Title),
ArtistScore = FuzzyMatcher.CalculateSimilarity(track.PrimaryArtist, local.Artist)
})
.Select(x => new
{
x.Local,
x.TitleScore,
x.ArtistScore,
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
})
.OrderByDescending(x => x.TotalScore)
.FirstOrDefault();
// Use 70% threshold (same as playback matching)
if (bestMatch != null && bestMatch.TotalScore >= 70)
{
isLocal = true;
}
}
}
if (isLocal)
{
localCount++;
}
else
{
// Check if external track is matched (either manual mapping or auto-matched)
if (hasExternalMapping || matchedSpotifyIds.Contains(track.SpotifyId))
{
externalMatchedCount++;
}
else
{
externalMissingCount++;
}
}
}
playlistInfo["localTracks"] = localCount;
playlistInfo["externalMatched"] = externalMatchedCount;
playlistInfo["externalMissing"] = externalMissingCount;
playlistInfo["externalTotal"] = externalMatchedCount + externalMissingCount;
playlistInfo["totalInJellyfin"] = localCount + externalMatchedCount;
playlistInfo["totalPlayable"] = localCount + externalMatchedCount; // Total tracks that will be served
_logger.LogDebug("Playlist {Name} (fallback): {Total} Spotify tracks, {Local} local, {ExtMatched} external matched, {ExtMissing} external missing, {Playable} total playable",
config.Name, spotifyTracks.Count, localCount, externalMatchedCount, externalMissingCount, localCount + externalMatchedCount);
}
}
else
{
_logger.LogWarning("No Items property in Jellyfin response for {Name}", config.Name);
}
}
else
{
_logger.LogWarning("Failed to get Jellyfin playlist {Name}: {StatusCode}",
config.Name, response.StatusCode);
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get Jellyfin playlist tracks for {Name}", config.Name);
}
}
else
{
_logger.LogWarning("Playlist {Name} has no JellyfinId configured", config.Name);
}
playlists.Add(playlistInfo);
}
// Save to file cache
try
{
var cacheDir = "/app/cache";
Directory.CreateDirectory(cacheDir);
var cacheFile = Path.Combine(cacheDir, "admin_playlists_summary.json");
var response = new { playlists };
var json = JsonSerializer.Serialize(response, new JsonSerializerOptions { WriteIndented = false });
await System.IO.File.WriteAllTextAsync(cacheFile, json);
_logger.LogDebug("💾 Saved playlist summary to cache");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to save playlist summary cache");
}
return Ok(new { playlists });
}
/// <summary>
/// Get tracks for a specific playlist with local/external status
/// </summary>
[HttpGet("playlists/{name}/tracks")]
public async Task<IActionResult> GetPlaylistTracks(string name)
{
var decodedName = Uri.UnescapeDataString(name);
// Get Spotify tracks
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(decodedName);
var tracksWithStatus = new List<object>();
// Use the pre-built playlist cache (same as GetPlaylists endpoint)
// This cache includes all matched tracks with proper provider IDs
var playlistItemsCacheKey = $"spotify:playlist:items:{decodedName}";
List<Dictionary<string, object?>>? cachedPlaylistItems = null;
try
{
cachedPlaylistItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(playlistItemsCacheKey);
}
catch (Exception cacheEx)
{
_logger.LogWarning(cacheEx, "Failed to deserialize playlist cache for {Playlist}", decodedName);
}
_logger.LogInformation("GetPlaylistTracks for {Playlist}: Cache found: {Found}, Count: {Count}",
decodedName, cachedPlaylistItems != null, cachedPlaylistItems?.Count ?? 0);
if (cachedPlaylistItems != null && cachedPlaylistItems.Count > 0)
{
// Build a map of Spotify ID -> cached item for quick lookup
var spotifyIdToItem = new Dictionary<string, Dictionary<string, object?>>();
foreach (var item in cachedPlaylistItems)
{
if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null)
{
Dictionary<string, string>? providerIds = null;
if (providerIdsObj is Dictionary<string, string> dict)
{
providerIds = dict;
}
else if (providerIdsObj is JsonElement jsonEl && jsonEl.ValueKind == JsonValueKind.Object)
{
providerIds = new Dictionary<string, string>();
foreach (var prop in jsonEl.EnumerateObject())
{
providerIds[prop.Name] = prop.Value.GetString() ?? "";
}
}
if (providerIds != null && providerIds.TryGetValue("Spotify", out var spotifyId) && !string.IsNullOrEmpty(spotifyId))
{
spotifyIdToItem[spotifyId] = item;
}
}
}
// Match each Spotify track to its cached item
foreach (var track in spotifyTracks)
{
bool? isLocal = null;
string? externalProvider = null;
bool isManualMapping = false;
string? manualMappingType = null;
string? manualMappingId = null;
if (spotifyIdToItem.TryGetValue(track.SpotifyId, out var cachedItem))
{
// Track is in the cache - determine if it's local or external
if (cachedItem.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null)
{
Dictionary<string, string>? providerIds = null;
if (providerIdsObj is Dictionary<string, string> dict)
{
providerIds = dict;
}
else if (providerIdsObj is JsonElement jsonEl && jsonEl.ValueKind == JsonValueKind.Object)
{
providerIds = new Dictionary<string, string>();
foreach (var prop in jsonEl.EnumerateObject())
{
providerIds[prop.Name] = prop.Value.GetString() ?? "";
}
}
if (providerIds != null)
{
_logger.LogDebug("Track {Title} has ProviderIds: {Keys}", track.Title, string.Join(", ", providerIds.Keys));
// Check for external provider keys (case-insensitive)
// External providers: squidwtf, deezer, qobuz, tidal (lowercase)
var providerKey = providerIds.Keys.FirstOrDefault(k =>
k.Equals("squidwtf", StringComparison.OrdinalIgnoreCase) ||
k.Equals("SquidWTF", StringComparison.OrdinalIgnoreCase));
if (providerKey != null)
{
isLocal = false;
externalProvider = "SquidWTF";
_logger.LogDebug("✓ Track {Title} identified as SquidWTF", track.Title);
}
else if ((providerKey = providerIds.Keys.FirstOrDefault(k => k.Equals("deezer", StringComparison.OrdinalIgnoreCase))) != null)
{
isLocal = false;
externalProvider = "Deezer";
_logger.LogDebug("✓ Track {Title} identified as Deezer", track.Title);
}
else if ((providerKey = providerIds.Keys.FirstOrDefault(k => k.Equals("qobuz", StringComparison.OrdinalIgnoreCase))) != null)
{
isLocal = false;
externalProvider = "Qobuz";
_logger.LogDebug("✓ Track {Title} identified as Qobuz", track.Title);
}
else if ((providerKey = providerIds.Keys.FirstOrDefault(k => k.Equals("tidal", StringComparison.OrdinalIgnoreCase))) != null)
{
isLocal = false;
externalProvider = "Tidal";
_logger.LogDebug("✓ Track {Title} identified as Tidal", track.Title);
}
else
{
// No external provider key found - it's a local track
// Local tracks have MusicBrainz, ISRC, Spotify IDs but no external provider
isLocal = true;
_logger.LogDebug("✓ Track {Title} identified as LOCAL (has ProviderIds but no external provider)", track.Title);
}
}
else
{
_logger.LogWarning("Track {Title} has ProviderIds object but it's null after parsing", track.Title);
}
}
else
{
_logger.LogWarning("Track {Title} in cache but has NO ProviderIds - treating as missing", track.Title);
isLocal = null;
externalProvider = null;
}
// Check if this is a manual mapping
var manualJellyfinKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}";
var manualJellyfinId = await _cache.GetAsync<string>(manualJellyfinKey);
if (!string.IsNullOrEmpty(manualJellyfinId))
{
isManualMapping = true;
manualMappingType = "jellyfin";
manualMappingId = manualJellyfinId;
}
else
{
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
if (!string.IsNullOrEmpty(externalMappingJson))
{
try
{
using var extDoc = JsonDocument.Parse(externalMappingJson);
var extRoot = extDoc.RootElement;
if (extRoot.TryGetProperty("id", out var idEl))
{
isManualMapping = true;
manualMappingType = "external";
manualMappingId = idEl.GetString();
}
}
catch { }
}
}
}
else
{
// Track not in cache - it's missing
isLocal = null;
externalProvider = null;
}
// Check lyrics status
var cacheKey = $"lyrics:{track.PrimaryArtist}:{track.Title}:{track.Album}:{track.DurationMs / 1000}";
var existingLyrics = await _cache.GetStringAsync(cacheKey);
var hasLyrics = !string.IsNullOrEmpty(existingLyrics);
tracksWithStatus.Add(new
{
position = track.Position,
title = track.Title,
artists = track.Artists,
album = track.Album,
isrc = track.Isrc,
spotifyId = track.SpotifyId,
durationMs = track.DurationMs,
albumArtUrl = track.AlbumArtUrl,
isLocal = isLocal,
externalProvider = externalProvider,
searchQuery = isLocal != true ? $"{track.Title} {track.PrimaryArtist}" : null,
isManualMapping = isManualMapping,
manualMappingType = manualMappingType,
manualMappingId = manualMappingId,
hasLyrics = hasLyrics
});
}
return Ok(new
{
name = decodedName,
trackCount = spotifyTracks.Count,
tracks = tracksWithStatus
});
}
// Fallback: Cache not available, use matched tracks cache
_logger.LogWarning("Playlist cache not available for {Playlist}, using fallback", decodedName);
var fallbackMatchedTracksKey = $"spotify:matched:ordered:{decodedName}";
var fallbackMatchedTracks = await _cache.GetAsync<List<MatchedTrack>>(fallbackMatchedTracksKey);
var fallbackMatchedSpotifyIds = new HashSet<string>(
fallbackMatchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>()
);
foreach (var track in spotifyTracks)
{
bool? isLocal = null;
string? externalProvider = null;
// Check for manual Jellyfin mapping
var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}";
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
if (!string.IsNullOrEmpty(manualJellyfinId))
{
isLocal = true;
}
else
{
// Check for external manual mapping
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
if (!string.IsNullOrEmpty(externalMappingJson))
{
try
{
using var extDoc = JsonDocument.Parse(externalMappingJson);
var extRoot = extDoc.RootElement;
string? provider = null;
if (extRoot.TryGetProperty("provider", out var providerEl))
{
provider = providerEl.GetString();
}
if (!string.IsNullOrEmpty(provider))
{
isLocal = false;
externalProvider = provider;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", track.Title);
}
}
else if (fallbackMatchedSpotifyIds.Contains(track.SpotifyId))
{
isLocal = false;
externalProvider = "SquidWTF";
}
else
{
isLocal = null;
externalProvider = null;
}
}
tracksWithStatus.Add(new
{
position = track.Position,
title = track.Title,
artists = track.Artists,
album = track.Album,
isrc = track.Isrc,
spotifyId = track.SpotifyId,
durationMs = track.DurationMs,
albumArtUrl = track.AlbumArtUrl,
isLocal = isLocal,
externalProvider = externalProvider,
searchQuery = isLocal != true ? $"{track.Title} {track.PrimaryArtist}" : null
});
}
return Ok(new
{
name = decodedName,
trackCount = spotifyTracks.Count,
tracks = tracksWithStatus
});
}
/// <summary>
/// Trigger a manual refresh of all playlists
/// </summary>
[HttpPost("playlists/refresh")]
public async Task<IActionResult> RefreshPlaylists()
{
_logger.LogInformation("Manual playlist refresh triggered from admin UI");
await _playlistFetcher.TriggerFetchAsync();
// Invalidate playlist summary cache
InvalidatePlaylistSummaryCache();
return Ok(new { message = "Playlist refresh triggered", timestamp = DateTime.UtcNow });
}
/// <summary>
/// Trigger track matching for a specific playlist
/// </summary>
[HttpPost("playlists/{name}/match")]
public async Task<IActionResult> MatchPlaylistTracks(string name)
{
var decodedName = Uri.UnescapeDataString(name);
_logger.LogInformation("Manual track matching triggered for playlist: {Name}", decodedName);
if (_matchingService == null)
{
return BadRequest(new { error = "Track matching service is not available" });
}
try
{
await _matchingService.TriggerMatchingForPlaylistAsync(decodedName);
// Invalidate playlist summary cache
InvalidatePlaylistSummaryCache();
return Ok(new { message = $"Track matching triggered for {decodedName}", timestamp = DateTime.UtcNow });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to trigger track matching for {Name}", decodedName);
return StatusCode(500, new { error = "Failed to trigger track matching", details = ex.Message });
}
}
/// <summary>
/// Clear cache and rebuild for a specific playlist
/// </summary>
[HttpPost("playlists/{name}/clear-cache")]
public async Task<IActionResult> ClearPlaylistCache(string name)
{
var decodedName = Uri.UnescapeDataString(name);
_logger.LogInformation("Clear cache & rebuild triggered for playlist: {Name}", decodedName);
if (_matchingService == null)
{
return BadRequest(new { error = "Track matching service is not available" });
}
try
{
// Clear all cache keys for this playlist
var cacheKeys = new[]
{
$"spotify:playlist:items:{decodedName}", // Pre-built items cache
$"spotify:matched:ordered:{decodedName}", // Ordered matched tracks
$"spotify:matched:{decodedName}", // Legacy matched tracks
$"spotify:missing:{decodedName}" // Missing tracks
};
foreach (var key in cacheKeys)
{
await _cache.DeleteAsync(key);
_logger.LogDebug("Cleared cache key: {Key}", key);
}
// Delete file caches
var safeName = string.Join("_", decodedName.Split(Path.GetInvalidFileNameChars()));
var filesToDelete = new[]
{
Path.Combine(CacheDirectory, $"{safeName}_items.json"),
Path.Combine(CacheDirectory, $"{safeName}_matched.json")
};
foreach (var file in filesToDelete)
{
if (System.IO.File.Exists(file))
{
System.IO.File.Delete(file);
_logger.LogDebug("Deleted cache file: {File}", file);
}
}
_logger.LogInformation("✓ Cleared all caches for playlist: {Name}", decodedName);
// Trigger rebuild
await _matchingService.TriggerMatchingForPlaylistAsync(decodedName);
// Invalidate playlist summary cache
InvalidatePlaylistSummaryCache();
return Ok(new
{
message = $"Cache cleared and rebuild triggered for {decodedName}",
timestamp = DateTime.UtcNow,
clearedKeys = cacheKeys.Length,
clearedFiles = filesToDelete.Count(System.IO.File.Exists)
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to clear cache for {Name}", decodedName);
return StatusCode(500, new { error = "Failed to clear cache", details = ex.Message });
}
}
/// <summary>
/// Search Jellyfin library for tracks (for manual mapping)
/// </summary>
[HttpGet("jellyfin/search")]
public async Task<IActionResult> SearchJellyfinTracks([FromQuery] string query)
{
if (string.IsNullOrWhiteSpace(query))
{
return BadRequest(new { error = "Query is required" });
}
try
{
var userId = _jellyfinSettings.UserId;
// Build URL with UserId if available
var url = $"{_jellyfinSettings.Url}/Items?searchTerm={Uri.EscapeDataString(query)}&includeItemTypes=Audio&recursive=true&limit=20";
if (!string.IsNullOrEmpty(userId))
{
url += $"&UserId={userId}";
}
var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
_logger.LogDebug("Searching Jellyfin: {Url}", url);
var response = await _jellyfinHttpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync();
_logger.LogWarning("Jellyfin search failed: {StatusCode} - {Error}", response.StatusCode, errorBody);
return StatusCode((int)response.StatusCode, new { error = "Failed to search Jellyfin" });
}
var json = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
var tracks = new List<object>();
if (doc.RootElement.TryGetProperty("Items", out var items))
{
foreach (var item in items.EnumerateArray())
{
// Verify it's actually an Audio item
var type = item.TryGetProperty("Type", out var typeEl) ? typeEl.GetString() : "";
if (type != "Audio")
{
_logger.LogDebug("Skipping non-audio item: {Type}", type);
continue;
}
var id = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() : "";
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : "";
var album = item.TryGetProperty("Album", out var albumEl) ? albumEl.GetString() : "";
var artist = "";
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
{
artist = artistsEl[0].GetString() ?? "";
}
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
{
artist = albumArtistEl.GetString() ?? "";
}
tracks.Add(new { id, title, artist, album });
}
}
return Ok(new { tracks });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to search Jellyfin tracks");
return StatusCode(500, new { error = "Search failed" });
}
}
/// <summary>
/// Get track details by Jellyfin ID (for URL-based mapping)
/// </summary>
[HttpGet("jellyfin/track/{id}")]
public async Task<IActionResult> GetJellyfinTrack(string id)
{
if (string.IsNullOrWhiteSpace(id))
{
return BadRequest(new { error = "Track ID is required" });
}
try
{
var userId = _jellyfinSettings.UserId;
var url = $"{_jellyfinSettings.Url}/Items/{id}";
if (!string.IsNullOrEmpty(userId))
{
url += $"?UserId={userId}";
}
var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
_logger.LogDebug("Fetching Jellyfin track {Id} from {Url}", id, url);
var response = await _jellyfinHttpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync();
_logger.LogWarning("Failed to fetch Jellyfin track {Id}: {StatusCode} - {Error}",
id, response.StatusCode, errorBody);
return StatusCode((int)response.StatusCode, new { error = "Track not found in Jellyfin" });
}
var json = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
var item = doc.RootElement;
// Verify it's an Audio item
var type = item.TryGetProperty("Type", out var typeEl) ? typeEl.GetString() : "";
if (type != "Audio")
{
_logger.LogWarning("Item {Id} is not an Audio track, it's a {Type}", id, type);
return BadRequest(new { error = $"Item is not an audio track (it's a {type})" });
}
var trackId = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() : "";
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : "";
var album = item.TryGetProperty("Album", out var albumEl) ? albumEl.GetString() : "";
var artist = "";
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
{
artist = artistsEl[0].GetString() ?? "";
}
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
{
artist = albumArtistEl.GetString() ?? "";
}
_logger.LogInformation("Found Jellyfin track: {Title} by {Artist}", title, artist);
return Ok(new { id = trackId, title, artist, album });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get Jellyfin track {Id}", id);
return StatusCode(500, new { error = "Failed to get track details" });
}
}
/// <summary>
/// Save manual track mapping (local Jellyfin or external provider)
/// </summary>
[HttpPost("playlists/{name}/map")]
public async Task<IActionResult> SaveManualMapping(string name, [FromBody] ManualMappingRequest request)
{
var decodedName = Uri.UnescapeDataString(name);
if (string.IsNullOrWhiteSpace(request.SpotifyId))
{
return BadRequest(new { error = "SpotifyId is required" });
}
// Validate that either Jellyfin mapping or external mapping is provided
var hasJellyfinMapping = !string.IsNullOrWhiteSpace(request.JellyfinId);
var hasExternalMapping = !string.IsNullOrWhiteSpace(request.ExternalProvider) && !string.IsNullOrWhiteSpace(request.ExternalId);
if (!hasJellyfinMapping && !hasExternalMapping)
{
return BadRequest(new { error = "Either JellyfinId or (ExternalProvider + ExternalId) is required" });
}
if (hasJellyfinMapping && hasExternalMapping)
{
return BadRequest(new { error = "Cannot specify both Jellyfin and external mapping for the same track" });
}
try
{
string? normalizedProvider = null;
if (hasJellyfinMapping)
{
// Store Jellyfin mapping in cache (NO EXPIRATION - manual mappings are permanent)
var mappingKey = $"spotify:manual-map:{decodedName}:{request.SpotifyId}";
await _cache.SetAsync(mappingKey, request.JellyfinId!);
// Also save to file for persistence across restarts
await SaveManualMappingToFileAsync(decodedName, request.SpotifyId, request.JellyfinId!, null, null);
_logger.LogInformation("Manual Jellyfin mapping saved: {Playlist} - Spotify {SpotifyId} → Jellyfin {JellyfinId}",
decodedName, request.SpotifyId, request.JellyfinId);
}
else
{
// Store external mapping in cache (NO EXPIRATION - manual mappings are permanent)
var externalMappingKey = $"spotify:external-map:{decodedName}:{request.SpotifyId}";
normalizedProvider = request.ExternalProvider!.ToLowerInvariant(); // Normalize to lowercase
var externalMapping = new { provider = normalizedProvider, id = request.ExternalId };
await _cache.SetAsync(externalMappingKey, externalMapping);
// Also save to file for persistence across restarts
await SaveManualMappingToFileAsync(decodedName, request.SpotifyId, null, normalizedProvider, request.ExternalId!);
_logger.LogInformation("Manual external mapping saved: {Playlist} - Spotify {SpotifyId} → {Provider} {ExternalId}",
decodedName, request.SpotifyId, normalizedProvider, request.ExternalId);
}
// Clear all related caches to force rebuild
var matchedCacheKey = $"spotify:matched:{decodedName}";
var orderedCacheKey = $"spotify:matched:ordered:{decodedName}";
var playlistItemsKey = $"spotify:playlist:items:{decodedName}";
await _cache.DeleteAsync(matchedCacheKey);
await _cache.DeleteAsync(orderedCacheKey);
await _cache.DeleteAsync(playlistItemsKey);
// Also delete file caches to force rebuild
try
{
var cacheDir = "/app/cache/spotify";
var safeName = string.Join("_", decodedName.Split(Path.GetInvalidFileNameChars()));
var matchedFile = Path.Combine(cacheDir, $"{safeName}_matched.json");
var itemsFile = Path.Combine(cacheDir, $"{safeName}_items.json");
if (System.IO.File.Exists(matchedFile))
{
System.IO.File.Delete(matchedFile);
_logger.LogDebug("Deleted matched tracks file cache for {Playlist}", decodedName);
}
if (System.IO.File.Exists(itemsFile))
{
System.IO.File.Delete(itemsFile);
_logger.LogDebug("Deleted playlist items file cache for {Playlist}", decodedName);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete file caches for {Playlist}", decodedName);
}
_logger.LogInformation("Cleared playlist caches for {Playlist} to force rebuild", decodedName);
// Fetch external provider track details to return to the UI (only for external mappings)
string? trackTitle = null;
string? trackArtist = null;
string? trackAlbum = null;
if (hasExternalMapping && normalizedProvider != null)
{
try
{
var metadataService = HttpContext.RequestServices.GetRequiredService<IMusicMetadataService>();
var externalSong = await metadataService.GetSongAsync(normalizedProvider, request.ExternalId!);
if (externalSong != null)
{
trackTitle = externalSong.Title;
trackArtist = externalSong.Artist;
trackAlbum = externalSong.Album;
_logger.LogInformation("✓ Fetched external track metadata: {Title} by {Artist}", trackTitle, trackArtist);
}
else
{
_logger.LogWarning("Failed to fetch external track metadata for {Provider} ID {Id}",
normalizedProvider, request.ExternalId);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch external track metadata, but mapping was saved");
}
}
// Trigger immediate playlist rebuild with the new mapping
if (_matchingService != null)
{
_logger.LogInformation("Triggering immediate playlist rebuild for {Playlist} with new manual mapping", decodedName);
// Run rebuild in background with timeout to avoid blocking the response
_ = Task.Run(async () =>
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2)); // 2 minute timeout
await _matchingService.TriggerMatchingForPlaylistAsync(decodedName);
_logger.LogInformation("✓ Playlist {Playlist} rebuilt successfully with manual mapping", decodedName);
}
catch (OperationCanceledException)
{
_logger.LogWarning("Playlist rebuild for {Playlist} timed out after 2 minutes", decodedName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to rebuild playlist {Playlist} after manual mapping", decodedName);
}
});
}
else
{
_logger.LogWarning("Matching service not available - playlist will rebuild on next scheduled run");
}
// Return success with track details if available
var mappedTrack = new
{
id = request.ExternalId,
title = trackTitle ?? "Unknown",
artist = trackArtist ?? "Unknown",
album = trackAlbum ?? "Unknown",
isLocal = false,
externalProvider = request.ExternalProvider!.ToLowerInvariant()
};
return Ok(new
{
message = "Mapping saved and playlist rebuild triggered",
track = mappedTrack,
rebuildTriggered = _matchingService != null
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save manual mapping");
return StatusCode(500, new { error = "Failed to save mapping" });
}
}
/// <summary>
/// Trigger track matching for all playlists
/// </summary>
[HttpPost("playlists/match-all")]
public async Task<IActionResult> MatchAllPlaylistTracks()
{
_logger.LogInformation("Manual track matching triggered for all playlists");
if (_matchingService == null)
{
return BadRequest(new { error = "Track matching service is not available" });
}
try
{
await _matchingService.TriggerMatchingAsync();
return Ok(new { message = "Track matching triggered for all playlists", timestamp = DateTime.UtcNow });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to trigger track matching for all playlists");
return StatusCode(500, new { error = "Failed to trigger track matching", details = ex.Message });
}
}
/// <summary>
/// Get current configuration (safe values only)
/// </summary>
[HttpGet("config")]
public IActionResult GetConfig()
{
return Ok(new
{
backendType = _configuration.GetValue<string>("Backend:Type") ?? "Jellyfin",
musicService = _configuration.GetValue<string>("MusicService") ?? "SquidWTF",
explicitFilter = _configuration.GetValue<string>("ExplicitFilter") ?? "All",
enableExternalPlaylists = _configuration.GetValue<bool>("EnableExternalPlaylists", false),
playlistsDirectory = _configuration.GetValue<string>("PlaylistsDirectory") ?? "(not set)",
redisEnabled = _configuration.GetValue<bool>("Redis:Enabled", false),
spotifyApi = new
{
enabled = _spotifyApiSettings.Enabled,
sessionCookie = MaskValue(_spotifyApiSettings.SessionCookie, showLast: 8),
sessionCookieSetDate = _spotifyApiSettings.SessionCookieSetDate,
cacheDurationMinutes = _spotifyApiSettings.CacheDurationMinutes,
rateLimitDelayMs = _spotifyApiSettings.RateLimitDelayMs,
preferIsrcMatching = _spotifyApiSettings.PreferIsrcMatching
},
spotifyImport = new
{
enabled = _spotifyImportSettings.Enabled,
matchingIntervalHours = _spotifyImportSettings.MatchingIntervalHours,
playlists = _spotifyImportSettings.Playlists.Select(p => new
{
name = p.Name,
id = p.Id,
localTracksPosition = p.LocalTracksPosition.ToString()
})
},
jellyfin = new
{
url = _jellyfinSettings.Url,
apiKey = MaskValue(_jellyfinSettings.ApiKey),
userId = _jellyfinSettings.UserId ?? "(not set)",
libraryId = _jellyfinSettings.LibraryId
},
library = new
{
downloadPath = _subsonicSettings.StorageMode == StorageMode.Cache
? Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "cache")
: Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "permanent"),
keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept"),
storageMode = _subsonicSettings.StorageMode.ToString(),
cacheDurationHours = _subsonicSettings.CacheDurationHours,
downloadMode = _subsonicSettings.DownloadMode.ToString()
},
deezer = new
{
arl = MaskValue(_deezerSettings.Arl, showLast: 8),
arlFallback = MaskValue(_deezerSettings.ArlFallback, showLast: 8),
quality = _deezerSettings.Quality ?? "FLAC"
},
qobuz = new
{
userAuthToken = MaskValue(_qobuzSettings.UserAuthToken, showLast: 8),
userId = _qobuzSettings.UserId,
quality = _qobuzSettings.Quality ?? "FLAC"
},
squidWtf = new
{
quality = _squidWtfSettings.Quality ?? "LOSSLESS"
},
musicBrainz = new
{
enabled = _musicBrainzSettings.Enabled,
username = _musicBrainzSettings.Username ?? "(not set)",
password = MaskValue(_musicBrainzSettings.Password),
baseUrl = _musicBrainzSettings.BaseUrl,
rateLimitMs = _musicBrainzSettings.RateLimitMs
}
});
}
/// <summary>
/// Update configuration by modifying .env file
/// </summary>
[HttpPost("config")]
public async Task<IActionResult> UpdateConfig([FromBody] ConfigUpdateRequest request)
{
if (request == null || request.Updates == null || request.Updates.Count == 0)
{
return BadRequest(new { error = "No updates provided" });
}
_logger.LogInformation("Config update requested: {Count} changes", request.Updates.Count);
try
{
// Check if .env file exists
if (!System.IO.File.Exists(_envFilePath))
{
_logger.LogWarning(".env file not found at {Path}, creating new file", _envFilePath);
}
// Read current .env file or create new one
var envContent = new Dictionary<string, string>();
if (System.IO.File.Exists(_envFilePath))
{
var lines = await System.IO.File.ReadAllLinesAsync(_envFilePath);
foreach (var line in lines)
{
if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith('#'))
continue;
var eqIndex = line.IndexOf('=');
if (eqIndex > 0)
{
var key = line[..eqIndex].Trim();
var value = line[(eqIndex + 1)..].Trim();
envContent[key] = value;
}
}
_logger.LogInformation("Loaded {Count} existing env vars from {Path}", envContent.Count, _envFilePath);
}
// Apply updates with validation
var appliedUpdates = new List<string>();
foreach (var (key, value) in request.Updates)
{
// Validate key format
if (!IsValidEnvKey(key))
{
_logger.LogWarning("Invalid env key rejected: {Key}", key);
return BadRequest(new { error = $"Invalid environment variable key: {key}" });
}
envContent[key] = value;
appliedUpdates.Add(key);
_logger.LogInformation(" Setting {Key} = {Value}", key,
key.Contains("COOKIE") || key.Contains("TOKEN") || key.Contains("KEY") || key.Contains("ARL")
? "***" + (value.Length > 8 ? value[^8..] : "")
: value);
// Auto-set cookie date when Spotify session cookie is updated
if (key == "SPOTIFY_API_SESSION_COOKIE" && !string.IsNullOrEmpty(value))
{
var dateKey = "SPOTIFY_API_SESSION_COOKIE_SET_DATE";
var dateValue = DateTime.UtcNow.ToString("o"); // ISO 8601 format
envContent[dateKey] = dateValue;
appliedUpdates.Add(dateKey);
_logger.LogInformation(" Auto-setting {Key} to {Value}", dateKey, dateValue);
}
}
// Write back to .env file
var newContent = string.Join("\n", envContent.Select(kv => $"{kv.Key}={kv.Value}"));
await System.IO.File.WriteAllTextAsync(_envFilePath, newContent + "\n");
_logger.LogInformation("Config file updated successfully at {Path}", _envFilePath);
return Ok(new
{
message = "Configuration updated. Restart container to apply changes.",
updatedKeys = appliedUpdates,
requiresRestart = true,
envFilePath = _envFilePath
});
}
catch (UnauthorizedAccessException ex)
{
_logger.LogError(ex, "Permission denied writing to .env file at {Path}", _envFilePath);
return StatusCode(500, new {
error = "Permission denied",
details = "Cannot write to .env file. Check file permissions and volume mount.",
path = _envFilePath
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update configuration at {Path}", _envFilePath);
return StatusCode(500, new {
error = "Failed to update configuration",
details = ex.Message,
path = _envFilePath
});
}
}
/// <summary>
/// Add a new playlist to the configuration
/// </summary>
[HttpPost("playlists")]
public async Task<IActionResult> AddPlaylist([FromBody] AddPlaylistRequest request)
{
if (string.IsNullOrEmpty(request.Name) || string.IsNullOrEmpty(request.SpotifyId))
{
return BadRequest(new { error = "Name and SpotifyId are required" });
}
_logger.LogInformation("Adding playlist: {Name} ({SpotifyId})", request.Name, request.SpotifyId);
// Get current playlists
var currentPlaylists = _spotifyImportSettings.Playlists.ToList();
// Check for duplicates
if (currentPlaylists.Any(p => p.Id == request.SpotifyId || p.Name == request.Name))
{
return BadRequest(new { error = "Playlist with this name or ID already exists" });
}
// Add new playlist
currentPlaylists.Add(new SpotifyPlaylistConfig
{
Name = request.Name,
Id = request.SpotifyId,
LocalTracksPosition = request.LocalTracksPosition == "last"
? LocalTracksPosition.Last
: LocalTracksPosition.First
});
// Convert to JSON format for env var
var playlistsJson = JsonSerializer.Serialize(
currentPlaylists.Select(p => new[] { p.Name, p.Id, p.LocalTracksPosition.ToString().ToLower() }).ToArray()
);
// Update .env file
var updateRequest = new ConfigUpdateRequest
{
Updates = new Dictionary<string, string>
{
["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson
}
};
return await UpdateConfig(updateRequest);
}
/// <summary>
/// Remove a playlist from the configuration
/// </summary>
[HttpDelete("playlists/{name}")]
public async Task<IActionResult> RemovePlaylist(string name)
{
var decodedName = Uri.UnescapeDataString(name);
_logger.LogInformation("Removing playlist: {Name}", decodedName);
// Read current playlists from .env file (not stale in-memory config)
var currentPlaylists = await ReadPlaylistsFromEnvFile();
var playlist = currentPlaylists.FirstOrDefault(p => p.Name == decodedName);
if (playlist == null)
{
return NotFound(new { error = "Playlist not found" });
}
currentPlaylists.Remove(playlist);
// Convert to JSON format for env var: [["Name","SpotifyId","JellyfinId","first|last"],...]
var playlistsJson = JsonSerializer.Serialize(
currentPlaylists.Select(p => new[] { p.Name, p.Id, p.JellyfinId, p.LocalTracksPosition.ToString().ToLower() }).ToArray()
);
// Update .env file
var updateRequest = new ConfigUpdateRequest
{
Updates = new Dictionary<string, string>
{
["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson
}
};
return await UpdateConfig(updateRequest);
}
/// <summary>
/// Clear all cached data
/// </summary>
[HttpPost("cache/clear")]
public async Task<IActionResult> ClearCache()
{
_logger.LogInformation("Cache clear requested from admin UI");
var clearedFiles = 0;
var clearedRedisKeys = 0;
// Clear file cache
if (Directory.Exists(CacheDirectory))
{
foreach (var file in Directory.GetFiles(CacheDirectory, "*.json"))
{
try
{
System.IO.File.Delete(file);
clearedFiles++;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete cache file {File}", file);
}
}
}
// Clear ALL Redis cache keys for Spotify playlists
// This includes matched tracks, ordered tracks, missing tracks, playlist items, etc.
foreach (var playlist in _spotifyImportSettings.Playlists)
{
var keysToDelete = new[]
{
$"spotify:playlist:{playlist.Name}",
$"spotify:missing:{playlist.Name}",
$"spotify:matched:{playlist.Name}",
$"spotify:matched:ordered:{playlist.Name}",
$"spotify:playlist:items:{playlist.Name}" // NEW: Clear file-backed playlist items cache
};
foreach (var key in keysToDelete)
{
if (await _cache.DeleteAsync(key))
{
clearedRedisKeys++;
_logger.LogInformation("Cleared Redis cache key: {Key}", key);
}
}
}
// Clear all search cache keys (pattern-based deletion)
var searchKeysDeleted = await _cache.DeleteByPatternAsync("search:*");
clearedRedisKeys += searchKeysDeleted;
// Clear all image cache keys (pattern-based deletion)
var imageKeysDeleted = await _cache.DeleteByPatternAsync("image:*");
clearedRedisKeys += imageKeysDeleted;
_logger.LogInformation("Cache cleared: {Files} files, {RedisKeys} Redis keys (including {SearchKeys} search keys, {ImageKeys} image keys)",
clearedFiles, clearedRedisKeys, searchKeysDeleted, imageKeysDeleted);
return Ok(new {
message = "Cache cleared successfully",
filesDeleted = clearedFiles,
redisKeysDeleted = clearedRedisKeys
});
}
/// <summary>
/// Restart the allstarr container to apply configuration changes
/// </summary>
[HttpPost("restart")]
public async Task<IActionResult> RestartContainer()
{
_logger.LogInformation("Container restart requested from admin UI");
try
{
// Use Docker socket to restart the container
var socketPath = "/var/run/docker.sock";
if (!System.IO.File.Exists(socketPath))
{
_logger.LogWarning("Docker socket not available at {Path}", socketPath);
return StatusCode(503, new {
error = "Docker socket not available",
message = "Please restart manually: docker-compose restart allstarr"
});
}
// Get container ID from hostname (Docker sets hostname to container ID by default)
// Or use the well-known container name
var containerId = Environment.MachineName;
var containerName = "allstarr";
_logger.LogInformation("Attempting to restart container {ContainerId} / {ContainerName}", containerId, containerName);
// Create Unix socket HTTP client
var handler = new SocketsHttpHandler
{
ConnectCallback = async (context, cancellationToken) =>
{
var socket = new System.Net.Sockets.Socket(
System.Net.Sockets.AddressFamily.Unix,
System.Net.Sockets.SocketType.Stream,
System.Net.Sockets.ProtocolType.Unspecified);
var endpoint = new System.Net.Sockets.UnixDomainSocketEndPoint(socketPath);
await socket.ConnectAsync(endpoint, cancellationToken);
return new System.Net.Sockets.NetworkStream(socket, ownsSocket: true);
}
};
using var dockerClient = new HttpClient(handler)
{
BaseAddress = new Uri("http://localhost")
};
// Try to restart by container name first, then by ID
var response = await dockerClient.PostAsync($"/containers/{containerName}/restart?t=5", null);
if (!response.IsSuccessStatusCode)
{
// Try by container ID
response = await dockerClient.PostAsync($"/containers/{containerId}/restart?t=5", null);
}
if (response.IsSuccessStatusCode)
{
_logger.LogInformation("Container restart initiated successfully");
return Ok(new { message = "Restarting container...", success = true });
}
else
{
var errorBody = await response.Content.ReadAsStringAsync();
_logger.LogError("Failed to restart container: {StatusCode} - {Body}", response.StatusCode, errorBody);
return StatusCode((int)response.StatusCode, new {
error = "Failed to restart container",
message = "Please restart manually: docker-compose restart allstarr"
});
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error restarting container");
return StatusCode(500, new {
error = "Failed to restart container",
details = ex.Message,
message = "Please restart manually: docker-compose restart allstarr"
});
}
}
/// <summary>
/// Initialize cookie date to current date if cookie exists but date is not set
/// </summary>
[HttpPost("config/init-cookie-date")]
public async Task<IActionResult> InitCookieDate()
{
// Only init if cookie exists but date is not set
if (string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie))
{
return BadRequest(new { error = "No cookie set" });
}
if (!string.IsNullOrEmpty(_spotifyApiSettings.SessionCookieSetDate))
{
return Ok(new { message = "Cookie date already set", date = _spotifyApiSettings.SessionCookieSetDate });
}
_logger.LogInformation("Initializing cookie date to current date (cookie existed without date tracking)");
var updateRequest = new ConfigUpdateRequest
{
Updates = new Dictionary<string, string>
{
["SPOTIFY_API_SESSION_COOKIE_SET_DATE"] = DateTime.UtcNow.ToString("o")
}
};
return await UpdateConfig(updateRequest);
}
/// <summary>
/// Get all Jellyfin users
/// </summary>
[HttpGet("jellyfin/users")]
public async Task<IActionResult> GetJellyfinUsers()
{
if (string.IsNullOrEmpty(_jellyfinSettings.Url) || string.IsNullOrEmpty(_jellyfinSettings.ApiKey))
{
return BadRequest(new { error = "Jellyfin URL or API key not configured" });
}
try
{
var url = $"{_jellyfinSettings.Url}/Users";
var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
var response = await _jellyfinHttpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync();
_logger.LogError("Failed to fetch Jellyfin users: {StatusCode} - {Body}", response.StatusCode, errorBody);
return StatusCode((int)response.StatusCode, new { error = "Failed to fetch users from Jellyfin" });
}
var json = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
var users = new List<object>();
foreach (var user in doc.RootElement.EnumerateArray())
{
var id = user.GetProperty("Id").GetString();
var name = user.GetProperty("Name").GetString();
users.Add(new { id, name });
}
return Ok(new { users });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching Jellyfin users");
return StatusCode(500, new { error = "Failed to fetch users", details = ex.Message });
}
}
/// <summary>
/// Get all Jellyfin libraries (virtual folders)
/// </summary>
[HttpGet("jellyfin/libraries")]
public async Task<IActionResult> GetJellyfinLibraries()
{
if (string.IsNullOrEmpty(_jellyfinSettings.Url) || string.IsNullOrEmpty(_jellyfinSettings.ApiKey))
{
return BadRequest(new { error = "Jellyfin URL or API key not configured" });
}
try
{
var url = $"{_jellyfinSettings.Url}/Library/VirtualFolders";
var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
var response = await _jellyfinHttpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync();
_logger.LogError("Failed to fetch Jellyfin libraries: {StatusCode} - {Body}", response.StatusCode, errorBody);
return StatusCode((int)response.StatusCode, new { error = "Failed to fetch libraries from Jellyfin" });
}
var json = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
var libraries = new List<object>();
foreach (var lib in doc.RootElement.EnumerateArray())
{
var name = lib.GetProperty("Name").GetString();
var itemId = lib.TryGetProperty("ItemId", out var id) ? id.GetString() : null;
var collectionType = lib.TryGetProperty("CollectionType", out var ct) ? ct.GetString() : null;
libraries.Add(new { id = itemId, name, collectionType });
}
return Ok(new { libraries });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching Jellyfin libraries");
return StatusCode(500, new { error = "Failed to fetch libraries", details = ex.Message });
}
}
/// <summary>
/// Get all playlists from the user's Spotify account
/// </summary>
[HttpGet("spotify/user-playlists")]
public async Task<IActionResult> GetSpotifyUserPlaylists()
{
if (!_spotifyApiSettings.Enabled || string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie))
{
return BadRequest(new { error = "Spotify API not configured. Please set sp_dc session cookie." });
}
try
{
// Get list of already-configured Spotify playlist IDs
var configuredPlaylists = await ReadPlaylistsFromEnvFile();
var linkedSpotifyIds = new HashSet<string>(
configuredPlaylists.Select(p => p.Id),
StringComparer.OrdinalIgnoreCase
);
// Use SpotifyApiClient's GraphQL method - much less rate-limited than REST API
var spotifyPlaylists = await _spotifyClient.GetUserPlaylistsAsync(searchName: null);
if (spotifyPlaylists == null || spotifyPlaylists.Count == 0)
{
return Ok(new { playlists = new List<object>() });
}
var playlists = spotifyPlaylists.Select(p => new
{
id = p.SpotifyId,
name = p.Name,
trackCount = p.TotalTracks,
owner = p.OwnerName ?? "",
isPublic = p.Public,
isLinked = linkedSpotifyIds.Contains(p.SpotifyId)
}).ToList();
return Ok(new { playlists });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching Spotify user playlists");
return StatusCode(500, new { error = "Failed to fetch Spotify playlists", details = ex.Message });
}
}
/// <summary>
/// Get all playlists from Jellyfin
/// </summary>
[HttpGet("jellyfin/playlists")]
public async Task<IActionResult> GetJellyfinPlaylists([FromQuery] string? userId = null)
{
if (string.IsNullOrEmpty(_jellyfinSettings.Url) || string.IsNullOrEmpty(_jellyfinSettings.ApiKey))
{
return BadRequest(new { error = "Jellyfin URL or API key not configured" });
}
try
{
// Build URL with optional userId filter
var url = $"{_jellyfinSettings.Url}/Items?IncludeItemTypes=Playlist&Recursive=true&Fields=ProviderIds,ChildCount,RecursiveItemCount,SongCount";
if (!string.IsNullOrEmpty(userId))
{
url += $"&UserId={userId}";
}
var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
var response = await _jellyfinHttpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync();
_logger.LogError("Failed to fetch Jellyfin playlists: {StatusCode} - {Body}", response.StatusCode, errorBody);
return StatusCode((int)response.StatusCode, new { error = "Failed to fetch playlists from Jellyfin" });
}
var json = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
var playlists = new List<object>();
// Read current playlists from .env file for accurate linked status
var configuredPlaylists = await ReadPlaylistsFromEnvFile();
if (doc.RootElement.TryGetProperty("Items", out var items))
{
foreach (var item in items.EnumerateArray())
{
var id = item.GetProperty("Id").GetString();
var name = item.GetProperty("Name").GetString();
// Try multiple fields for track count - Jellyfin may use different fields
var childCount = 0;
if (item.TryGetProperty("ChildCount", out var cc) && cc.ValueKind == JsonValueKind.Number)
childCount = cc.GetInt32();
else if (item.TryGetProperty("SongCount", out var sc) && sc.ValueKind == JsonValueKind.Number)
childCount = sc.GetInt32();
else if (item.TryGetProperty("RecursiveItemCount", out var ric) && ric.ValueKind == JsonValueKind.Number)
childCount = ric.GetInt32();
// Check if this playlist is configured in allstarr by Jellyfin ID
var configuredPlaylist = configuredPlaylists
.FirstOrDefault(p => p.JellyfinId.Equals(id, StringComparison.OrdinalIgnoreCase));
var isConfigured = configuredPlaylist != null;
var linkedSpotifyId = configuredPlaylist?.Id;
// Only fetch detailed track stats for configured Spotify playlists
// This avoids expensive queries for large non-Spotify playlists
var trackStats = (LocalTracks: 0, ExternalTracks: 0, ExternalAvailable: 0);
if (isConfigured)
{
trackStats = await GetPlaylistTrackStats(id!);
}
// Use actual track stats for configured playlists, otherwise use Jellyfin's count
var actualTrackCount = isConfigured
? trackStats.LocalTracks + trackStats.ExternalTracks
: childCount;
playlists.Add(new
{
id,
name,
trackCount = actualTrackCount,
linkedSpotifyId,
isConfigured,
localTracks = trackStats.LocalTracks,
externalTracks = trackStats.ExternalTracks,
externalAvailable = trackStats.ExternalAvailable
});
}
}
return Ok(new { playlists });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching Jellyfin playlists");
return StatusCode(500, new { error = "Failed to fetch playlists", details = ex.Message });
}
}
/// <summary>
/// Get track statistics for a playlist (local vs external)
/// </summary>
private async Task<(int LocalTracks, int ExternalTracks, int ExternalAvailable)> GetPlaylistTrackStats(string playlistId)
{
try
{
// Jellyfin requires a UserId to fetch playlist items
// We'll use the first available user if not specified
var userId = _jellyfinSettings.UserId;
// If no user configured, try to get the first user
if (string.IsNullOrEmpty(userId))
{
var usersResponse = await _jellyfinHttpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get, $"{_jellyfinSettings.Url}/Users")
{
Headers = { { "X-Emby-Authorization", GetJellyfinAuthHeader() } }
});
if (usersResponse.IsSuccessStatusCode)
{
var usersJson = await usersResponse.Content.ReadAsStringAsync();
using var usersDoc = JsonDocument.Parse(usersJson);
if (usersDoc.RootElement.GetArrayLength() > 0)
{
userId = usersDoc.RootElement[0].GetProperty("Id").GetString();
}
}
}
if (string.IsNullOrEmpty(userId))
{
_logger.LogWarning("No user ID available to fetch playlist items for {PlaylistId}", playlistId);
return (0, 0, 0);
}
var url = $"{_jellyfinSettings.Url}/Playlists/{playlistId}/Items?UserId={userId}&Fields=Path";
var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
var response = await _jellyfinHttpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("Failed to fetch playlist items for {PlaylistId}: {StatusCode}", playlistId, response.StatusCode);
return (0, 0, 0);
}
var json = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
var localTracks = 0;
var externalTracks = 0;
var externalAvailable = 0;
if (doc.RootElement.TryGetProperty("Items", out var items))
{
foreach (var item in items.EnumerateArray())
{
// Simpler detection: Check if Path exists and is not empty
// External tracks from allstarr won't have a Path property
var hasPath = item.TryGetProperty("Path", out var pathProp) &&
pathProp.ValueKind == JsonValueKind.String &&
!string.IsNullOrEmpty(pathProp.GetString());
if (hasPath)
{
var pathStr = pathProp.GetString()!;
// Check if it's a real file path (not a URL)
if (pathStr.StartsWith("/") || pathStr.Contains(":\\"))
{
localTracks++;
}
else
{
// It's a URL or external source
externalTracks++;
externalAvailable++;
}
}
else
{
// No path means it's external
externalTracks++;
externalAvailable++;
}
}
_logger.LogDebug("Playlist {PlaylistId} stats: {Local} local, {External} external",
playlistId, localTracks, externalTracks);
}
else
{
_logger.LogWarning("No Items property in playlist response for {PlaylistId}", playlistId);
}
return (localTracks, externalTracks, externalAvailable);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get track stats for playlist {PlaylistId}", playlistId);
return (0, 0, 0);
}
}
/// <summary>
/// Link a Jellyfin playlist to a Spotify playlist
/// </summary>
[HttpPost("jellyfin/playlists/{jellyfinPlaylistId}/link")]
public async Task<IActionResult> LinkPlaylist(string jellyfinPlaylistId, [FromBody] LinkPlaylistRequest request)
{
if (string.IsNullOrEmpty(request.SpotifyPlaylistId))
{
return BadRequest(new { error = "SpotifyPlaylistId is required" });
}
if (string.IsNullOrEmpty(request.Name))
{
return BadRequest(new { error = "Name is required" });
}
_logger.LogInformation("Linking Jellyfin playlist {JellyfinId} to Spotify playlist {SpotifyId} with name {Name}",
jellyfinPlaylistId, request.SpotifyPlaylistId, request.Name);
// Read current playlists from .env file (not in-memory config which is stale)
var currentPlaylists = await ReadPlaylistsFromEnvFile();
// Check if already configured by Jellyfin ID
var existingByJellyfinId = currentPlaylists
.FirstOrDefault(p => p.JellyfinId.Equals(jellyfinPlaylistId, StringComparison.OrdinalIgnoreCase));
if (existingByJellyfinId != null)
{
return BadRequest(new { error = $"This Jellyfin playlist is already linked to '{existingByJellyfinId.Name}'" });
}
// Check if already configured by name
var existingByName = currentPlaylists
.FirstOrDefault(p => p.Name.Equals(request.Name, StringComparison.OrdinalIgnoreCase));
if (existingByName != null)
{
return BadRequest(new { error = $"Playlist name '{request.Name}' is already configured" });
}
// Add the playlist to configuration
currentPlaylists.Add(new SpotifyPlaylistConfig
{
Name = request.Name,
Id = request.SpotifyPlaylistId,
JellyfinId = jellyfinPlaylistId,
LocalTracksPosition = LocalTracksPosition.First, // Use Spotify order
SyncSchedule = request.SyncSchedule ?? "0 8 * * 1" // Default to Monday 8 AM
});
// Convert to JSON format for env var: [["Name","SpotifyId","JellyfinId","first|last","cronSchedule"],...]
var playlistsJson = JsonSerializer.Serialize(
currentPlaylists.Select(p => new[] {
p.Name,
p.Id,
p.JellyfinId,
p.LocalTracksPosition.ToString().ToLower(),
p.SyncSchedule ?? "0 8 * * 1"
}).ToArray()
);
// Update .env file
var updateRequest = new ConfigUpdateRequest
{
Updates = new Dictionary<string, string>
{
["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson
}
};
return await UpdateConfig(updateRequest);
}
/// <summary>
/// Unlink a playlist (remove from configuration)
/// </summary>
[HttpDelete("jellyfin/playlists/{name}/unlink")]
public async Task<IActionResult> UnlinkPlaylist(string name)
{
var decodedName = Uri.UnescapeDataString(name);
return await RemovePlaylist(decodedName);
}
/// <summary>
/// Update playlist sync schedule
/// </summary>
[HttpPut("playlists/{name}/schedule")]
public async Task<IActionResult> UpdatePlaylistSchedule(string name, [FromBody] UpdateScheduleRequest request)
{
var decodedName = Uri.UnescapeDataString(name);
if (string.IsNullOrWhiteSpace(request.SyncSchedule))
{
return BadRequest(new { error = "SyncSchedule is required" });
}
// Basic cron validation
var cronParts = request.SyncSchedule.Trim().Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
if (cronParts.Length != 5)
{
return BadRequest(new { error = "Invalid cron format. Expected: minute hour day month dayofweek" });
}
// Read current playlists
var currentPlaylists = await ReadPlaylistsFromEnvFile();
var playlist = currentPlaylists.FirstOrDefault(p => p.Name.Equals(decodedName, StringComparison.OrdinalIgnoreCase));
if (playlist == null)
{
return NotFound(new { error = $"Playlist '{decodedName}' not found" });
}
// Update the schedule
playlist.SyncSchedule = request.SyncSchedule.Trim();
// Save back to .env
var playlistsJson = JsonSerializer.Serialize(
currentPlaylists.Select(p => new[] {
p.Name,
p.Id,
p.JellyfinId,
p.LocalTracksPosition.ToString().ToLower(),
p.SyncSchedule ?? "0 8 * * 1"
}).ToArray()
);
var updateRequest = new ConfigUpdateRequest
{
Updates = new Dictionary<string, string>
{
["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson
}
};
return await UpdateConfig(updateRequest);
}
private string GetJellyfinAuthHeader()
{
return $"MediaBrowser Client=\"Allstarr\", Device=\"Server\", DeviceId=\"allstarr-admin\", Version=\"1.0.0\", Token=\"{_jellyfinSettings.ApiKey}\"";
}
/// <summary>
/// Read current playlists from .env file (not stale in-memory config)
/// </summary>
private async Task<List<SpotifyPlaylistConfig>> ReadPlaylistsFromEnvFile()
{
var playlists = new List<SpotifyPlaylistConfig>();
if (!System.IO.File.Exists(_envFilePath))
{
return playlists;
}
try
{
var lines = await System.IO.File.ReadAllLinesAsync(_envFilePath);
foreach (var line in lines)
{
if (line.TrimStart().StartsWith("SPOTIFY_IMPORT_PLAYLISTS="))
{
var value = line.Substring(line.IndexOf('=') + 1).Trim();
if (string.IsNullOrWhiteSpace(value) || value == "[]")
{
return playlists;
}
// Parse JSON array format: [["Name","SpotifyId","JellyfinId","first|last","cronSchedule"],...]
var playlistArrays = JsonSerializer.Deserialize<string[][]>(value);
if (playlistArrays != null)
{
foreach (var arr in playlistArrays)
{
if (arr.Length >= 2)
{
playlists.Add(new SpotifyPlaylistConfig
{
Name = arr[0].Trim(),
Id = arr[1].Trim(),
JellyfinId = arr.Length >= 3 ? arr[2].Trim() : "",
LocalTracksPosition = arr.Length >= 4 &&
arr[3].Trim().Equals("last", StringComparison.OrdinalIgnoreCase)
? LocalTracksPosition.Last
: LocalTracksPosition.First,
SyncSchedule = arr.Length >= 5 ? arr[4].Trim() : "0 8 * * 1"
});
}
}
}
break;
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to read playlists from .env file");
}
return playlists;
}
private static string MaskValue(string? value, int showLast = 0)
{
if (string.IsNullOrEmpty(value)) return "(not set)";
if (value.Length <= showLast) return "***";
return showLast > 0 ? "***" + value[^showLast..] : value[..8] + "...";
}
private static string SanitizeFileName(string name)
{
return string.Join("_", name.Split(Path.GetInvalidFileNameChars()));
}
private static bool IsValidEnvKey(string key)
{
// Only allow alphanumeric, underscore, and must start with letter/underscore
return Regex.IsMatch(key, @"^[A-Z_][A-Z0-9_]*$", RegexOptions.IgnoreCase);
}
/// <summary>
/// Export .env file for backup/transfer
/// </summary>
[HttpGet("export-env")]
public IActionResult ExportEnv()
{
try
{
if (!System.IO.File.Exists(_envFilePath))
{
return NotFound(new { error = ".env file not found" });
}
var envContent = System.IO.File.ReadAllText(_envFilePath);
var bytes = System.Text.Encoding.UTF8.GetBytes(envContent);
return File(bytes, "text/plain", ".env");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to export .env file");
return StatusCode(500, new { error = "Failed to export .env file", details = ex.Message });
}
}
/// <summary>
/// Import .env file from upload
/// </summary>
[HttpPost("import-env")]
public async Task<IActionResult> ImportEnv([FromForm] IFormFile file)
{
if (file == null || file.Length == 0)
{
return BadRequest(new { error = "No file provided" });
}
if (!file.FileName.EndsWith(".env"))
{
return BadRequest(new { error = "File must be a .env file" });
}
try
{
// Read uploaded file
using var reader = new StreamReader(file.OpenReadStream());
var content = await reader.ReadToEndAsync();
// Validate it's a valid .env file (basic check)
if (string.IsNullOrWhiteSpace(content))
{
return BadRequest(new { error = ".env file is empty" });
}
// Backup existing .env
if (System.IO.File.Exists(_envFilePath))
{
var backupPath = $"{_envFilePath}.backup.{DateTime.UtcNow:yyyyMMddHHmmss}";
System.IO.File.Copy(_envFilePath, backupPath, true);
_logger.LogInformation("Backed up existing .env to {BackupPath}", backupPath);
}
// Write new .env file
await System.IO.File.WriteAllTextAsync(_envFilePath, content);
_logger.LogInformation(".env file imported successfully");
return Ok(new
{
success = true,
message = ".env file imported successfully. Restart the application for changes to take effect."
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to import .env file");
return StatusCode(500, new { error = "Failed to import .env file", details = ex.Message });
}
}
/// <summary>
/// Gets detailed memory usage statistics for debugging.
/// </summary>
[HttpGet("memory-stats")]
public IActionResult GetMemoryStats()
{
try
{
// Get memory stats BEFORE GC
var memoryBeforeGC = GC.GetTotalMemory(false);
var gen0Before = GC.CollectionCount(0);
var gen1Before = GC.CollectionCount(1);
var gen2Before = GC.CollectionCount(2);
// Force garbage collection to get accurate numbers
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
var memoryAfterGC = GC.GetTotalMemory(false);
var gen0After = GC.CollectionCount(0);
var gen1After = GC.CollectionCount(1);
var gen2After = GC.CollectionCount(2);
// Get process memory info
var process = System.Diagnostics.Process.GetCurrentProcess();
return Ok(new {
Timestamp = DateTime.UtcNow,
BeforeGC = new {
GCMemoryBytes = memoryBeforeGC,
GCMemoryMB = Math.Round(memoryBeforeGC / (1024.0 * 1024.0), 2)
},
AfterGC = new {
GCMemoryBytes = memoryAfterGC,
GCMemoryMB = Math.Round(memoryAfterGC / (1024.0 * 1024.0), 2)
},
MemoryFreedMB = Math.Round((memoryBeforeGC - memoryAfterGC) / (1024.0 * 1024.0), 2),
ProcessWorkingSetBytes = process.WorkingSet64,
ProcessWorkingSetMB = Math.Round(process.WorkingSet64 / (1024.0 * 1024.0), 2),
ProcessPrivateMemoryBytes = process.PrivateMemorySize64,
ProcessPrivateMemoryMB = Math.Round(process.PrivateMemorySize64 / (1024.0 * 1024.0), 2),
ProcessVirtualMemoryBytes = process.VirtualMemorySize64,
ProcessVirtualMemoryMB = Math.Round(process.VirtualMemorySize64 / (1024.0 * 1024.0), 2),
GCCollections = new {
Gen0Before = gen0Before,
Gen0After = gen0After,
Gen0Triggered = gen0After - gen0Before,
Gen1Before = gen1Before,
Gen1After = gen1After,
Gen1Triggered = gen1After - gen1Before,
Gen2Before = gen2Before,
Gen2After = gen2After,
Gen2Triggered = gen2After - gen2Before
},
GCMode = GCSettings.IsServerGC ? "Server" : "Workstation",
GCLatencyMode = GCSettings.LatencyMode.ToString()
});
}
catch (Exception ex)
{
return BadRequest(new { error = ex.Message });
}
}
/// <summary>
/// Forces garbage collection to free up memory (emergency use only).
/// </summary>
[HttpPost("force-gc")]
public IActionResult ForceGarbageCollection()
{
try
{
var memoryBefore = GC.GetTotalMemory(false);
var processBefore = System.Diagnostics.Process.GetCurrentProcess().WorkingSet64;
// Force full garbage collection
GC.Collect(2, GCCollectionMode.Forced);
GC.WaitForPendingFinalizers();
GC.Collect(2, GCCollectionMode.Forced);
var memoryAfter = GC.GetTotalMemory(false);
var processAfter = System.Diagnostics.Process.GetCurrentProcess().WorkingSet64;
return Ok(new {
Timestamp = DateTime.UtcNow,
MemoryFreedMB = Math.Round((memoryBefore - memoryAfter) / (1024.0 * 1024.0), 2),
ProcessMemoryFreedMB = Math.Round((processBefore - processAfter) / (1024.0 * 1024.0), 2),
BeforeGCMB = Math.Round(memoryBefore / (1024.0 * 1024.0), 2),
AfterGCMB = Math.Round(memoryAfter / (1024.0 * 1024.0), 2),
BeforeProcessMB = Math.Round(processBefore / (1024.0 * 1024.0), 2),
AfterProcessMB = Math.Round(processAfter / (1024.0 * 1024.0), 2)
});
}
catch (Exception ex)
{
return BadRequest(new { error = ex.Message });
}
}
/// <summary>
/// Gets current active sessions for debugging.
/// </summary>
[HttpGet("sessions")]
public IActionResult GetActiveSessions()
{
try
{
var sessionManager = HttpContext.RequestServices.GetService<JellyfinSessionManager>();
if (sessionManager == null)
{
return BadRequest(new { error = "Session manager not available" });
}
var sessionInfo = sessionManager.GetSessionsInfo();
return Ok(sessionInfo);
}
catch (Exception ex)
{
return BadRequest(new { error = ex.Message });
}
}
/// <summary>
/// Helper method to trigger GC after large file operations to prevent memory leaks.
/// </summary>
private static void TriggerGCAfterLargeOperation(int sizeInBytes)
{
// Only trigger GC for files larger than 1MB to avoid performance impact
if (sizeInBytes > 1024 * 1024)
{
// Suggest GC collection for large objects (they go to LOH and aren't collected as frequently)
GC.Collect(2, GCCollectionMode.Optimized, blocking: false);
}
}
#region Spotify Admin Endpoints
/// <summary>
/// Manual trigger endpoint to force fetch Spotify missing tracks.
/// </summary>
[HttpGet("spotify/sync")]
public async Task<IActionResult> TriggerSpotifySync([FromServices] IEnumerable<IHostedService> hostedServices)
{
try
{
if (!_spotifyImportSettings.Enabled)
{
return BadRequest(new { error = "Spotify Import is not enabled" });
}
_logger.LogInformation("Manual Spotify sync triggered via admin endpoint");
// Find the SpotifyMissingTracksFetcher service
var fetcherService = hostedServices
.OfType<allstarr.Services.Spotify.SpotifyMissingTracksFetcher>()
.FirstOrDefault();
if (fetcherService == null)
{
return BadRequest(new { error = "SpotifyMissingTracksFetcher service not found" });
}
// Trigger the sync in background
_ = Task.Run(async () =>
{
try
{
// Use reflection to call the private ExecuteOnceAsync method
var method = fetcherService.GetType().GetMethod("ExecuteOnceAsync",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
if (method != null)
{
await (Task)method.Invoke(fetcherService, new object[] { CancellationToken.None })!;
_logger.LogInformation("Manual Spotify sync completed successfully");
}
else
{
_logger.LogError("Could not find ExecuteOnceAsync method on SpotifyMissingTracksFetcher");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during manual Spotify sync");
}
});
return Ok(new {
message = "Spotify sync started in background",
timestamp = DateTime.UtcNow
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error triggering Spotify sync");
return StatusCode(500, new { error = "Internal server error" });
}
}
/// <summary>
/// Manual trigger endpoint to force Spotify track matching.
/// </summary>
[HttpGet("spotify/match")]
public async Task<IActionResult> TriggerSpotifyMatch([FromServices] IEnumerable<IHostedService> hostedServices)
{
try
{
if (!_spotifyApiSettings.Enabled)
{
return BadRequest(new { error = "Spotify API is not enabled" });
}
_logger.LogInformation("Manual Spotify track matching triggered via admin endpoint");
// Find the SpotifyTrackMatchingService
var matchingService = hostedServices
.OfType<allstarr.Services.Spotify.SpotifyTrackMatchingService>()
.FirstOrDefault();
if (matchingService == null)
{
return BadRequest(new { error = "SpotifyTrackMatchingService not found" });
}
// Trigger matching in background
_ = Task.Run(async () =>
{
try
{
// Use reflection to call the private ExecuteOnceAsync method
var method = matchingService.GetType().GetMethod("ExecuteOnceAsync",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
if (method != null)
{
await (Task)method.Invoke(matchingService, new object[] { CancellationToken.None })!;
_logger.LogInformation("Manual Spotify track matching completed successfully");
}
else
{
_logger.LogError("Could not find ExecuteOnceAsync method on SpotifyTrackMatchingService");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during manual Spotify track matching");
}
});
return Ok(new {
message = "Spotify track matching started in background",
timestamp = DateTime.UtcNow
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error triggering Spotify track matching");
return StatusCode(500, new { error = "Internal server error" });
}
}
/// <summary>
/// Clear Spotify playlist cache to force re-matching.
/// </summary>
[HttpPost("spotify/clear-cache")]
public async Task<IActionResult> ClearSpotifyCache()
{
try
{
var clearedKeys = new List<string>();
// Clear Redis cache for all configured playlists
foreach (var playlist in _spotifyImportSettings.Playlists)
{
var keys = new[]
{
$"spotify:playlist:{playlist.Name}",
$"spotify:playlist:items:{playlist.Name}",
$"spotify:matched:{playlist.Name}"
};
foreach (var key in keys)
{
await _cache.DeleteAsync(key);
clearedKeys.Add(key);
}
}
_logger.LogInformation("Cleared Spotify cache for {Count} keys via admin endpoint", clearedKeys.Count);
return Ok(new {
message = "Spotify cache cleared successfully",
clearedKeys = clearedKeys,
timestamp = DateTime.UtcNow
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error clearing Spotify cache");
return StatusCode(500, new { error = "Internal server error" });
}
}
#endregion
#region Debug Endpoints
/// <summary>
/// Gets endpoint usage statistics from the log file.
/// </summary>
[HttpGet("debug/endpoint-usage")]
public async Task<IActionResult> GetEndpointUsage(
[FromQuery] int top = 100,
[FromQuery] string? since = null)
{
try
{
var logFile = "/app/cache/endpoint-usage/endpoints.csv";
if (!System.IO.File.Exists(logFile))
{
return Ok(new {
message = "No endpoint usage data available",
endpoints = new object[0]
});
}
var lines = await System.IO.File.ReadAllLinesAsync(logFile);
var usage = new Dictionary<string, int>();
DateTime? sinceDate = null;
if (!string.IsNullOrEmpty(since) && DateTime.TryParse(since, out var parsedDate))
{
sinceDate = parsedDate;
}
foreach (var line in lines.Skip(1)) // Skip header
{
var parts = line.Split(',');
if (parts.Length >= 3)
{
var timestamp = parts[0];
var method = parts[1];
var endpoint = parts[2];
// Combine method and endpoint for better clarity
var fullEndpoint = $"{method} {endpoint}";
// Filter by date if specified
if (sinceDate.HasValue && DateTime.TryParse(timestamp, out var logDate))
{
if (logDate < sinceDate.Value)
continue;
}
usage[fullEndpoint] = usage.GetValueOrDefault(fullEndpoint, 0) + 1;
}
}
var topEndpoints = usage
.OrderByDescending(kv => kv.Value)
.Take(top)
.Select(kv => new { endpoint = kv.Key, count = kv.Value })
.ToArray();
return Ok(new {
totalEndpoints = usage.Count,
totalRequests = usage.Values.Sum(),
since = since,
top = top,
endpoints = topEndpoints
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting endpoint usage");
return StatusCode(500, new { error = "Internal server error" });
}
}
/// <summary>
/// Clears the endpoint usage log file.
/// </summary>
[HttpDelete("debug/endpoint-usage")]
public IActionResult ClearEndpointUsage()
{
try
{
var logFile = "/app/cache/endpoint-usage/endpoints.csv";
if (System.IO.File.Exists(logFile))
{
System.IO.File.Delete(logFile);
_logger.LogInformation("Cleared endpoint usage log via admin endpoint");
return Ok(new {
message = "Endpoint usage log cleared successfully",
timestamp = DateTime.UtcNow
});
}
else
{
return Ok(new {
message = "No endpoint usage log file found",
timestamp = DateTime.UtcNow
});
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error clearing endpoint usage log");
return StatusCode(500, new { error = "Internal server error" });
}
}
#endregion
#region Private Helper Methods
/// <summary>
/// Saves a manual mapping to file for persistence across restarts.
/// Manual mappings NEVER expire - they are permanent user decisions.
/// </summary>
private async Task SaveManualMappingToFileAsync(
string playlistName,
string spotifyId,
string? jellyfinId,
string? externalProvider,
string? externalId)
{
try
{
var mappingsDir = "/app/cache/mappings";
Directory.CreateDirectory(mappingsDir);
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
var filePath = Path.Combine(mappingsDir, $"{safeName}_mappings.json");
// Load existing mappings
var mappings = new Dictionary<string, ManualMappingEntry>();
if (System.IO.File.Exists(filePath))
{
var json = await System.IO.File.ReadAllTextAsync(filePath);
mappings = JsonSerializer.Deserialize<Dictionary<string, ManualMappingEntry>>(json)
?? new Dictionary<string, ManualMappingEntry>();
}
// Add or update mapping
mappings[spotifyId] = new ManualMappingEntry
{
SpotifyId = spotifyId,
JellyfinId = jellyfinId,
ExternalProvider = externalProvider,
ExternalId = externalId,
CreatedAt = DateTime.UtcNow
};
// Save back to file
var updatedJson = JsonSerializer.Serialize(mappings, new JsonSerializerOptions { WriteIndented = true });
await System.IO.File.WriteAllTextAsync(filePath, updatedJson);
_logger.LogDebug("💾 Saved manual mapping to file: {Playlist} - {SpotifyId}", playlistName, spotifyId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save manual mapping to file for {Playlist}", playlistName);
}
}
/// <summary>
/// Save lyrics mapping to file for persistence across restarts.
/// Lyrics mappings NEVER expire - they are permanent user decisions.
/// </summary>
private async Task SaveLyricsMappingToFileAsync(
string artist,
string title,
string album,
int durationSeconds,
int lyricsId)
{
try
{
var mappingsFile = "/app/cache/lyrics_mappings.json";
// Load existing mappings
var mappings = new List<LyricsMappingEntry>();
if (System.IO.File.Exists(mappingsFile))
{
var json = await System.IO.File.ReadAllTextAsync(mappingsFile);
mappings = JsonSerializer.Deserialize<List<LyricsMappingEntry>>(json)
?? new List<LyricsMappingEntry>();
}
// Remove any existing mapping for this track
mappings.RemoveAll(m =>
m.Artist.Equals(artist, StringComparison.OrdinalIgnoreCase) &&
m.Title.Equals(title, StringComparison.OrdinalIgnoreCase));
// Add new mapping
mappings.Add(new LyricsMappingEntry
{
Artist = artist,
Title = title,
Album = album,
DurationSeconds = durationSeconds,
LyricsId = lyricsId,
CreatedAt = DateTime.UtcNow
});
// Save back to file
var updatedJson = JsonSerializer.Serialize(mappings, new JsonSerializerOptions { WriteIndented = true });
await System.IO.File.WriteAllTextAsync(mappingsFile, updatedJson);
_logger.LogDebug("💾 Saved lyrics mapping to file: {Artist} - {Title} → Lyrics ID {LyricsId}",
artist, title, lyricsId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save lyrics mapping to file for {Artist} - {Title}", artist, title);
}
}
/// <summary>
/// Save manual lyrics ID mapping for a track
/// </summary>
[HttpPost("lyrics/map")]
public async Task<IActionResult> SaveLyricsMapping([FromBody] LyricsMappingRequest request)
{
if (string.IsNullOrWhiteSpace(request.Artist) || string.IsNullOrWhiteSpace(request.Title))
{
return BadRequest(new { error = "Artist and Title are required" });
}
if (request.LyricsId <= 0)
{
return BadRequest(new { error = "Valid LyricsId is required" });
}
try
{
// Store lyrics mapping in cache (NO EXPIRATION - manual mappings are permanent)
var mappingKey = $"lyrics:manual-map:{request.Artist}:{request.Title}";
await _cache.SetStringAsync(mappingKey, request.LyricsId.ToString());
// Also save to file for persistence across restarts
await SaveLyricsMappingToFileAsync(request.Artist, request.Title, request.Album ?? "", request.DurationSeconds, request.LyricsId);
_logger.LogInformation("Manual lyrics mapping saved: {Artist} - {Title} → Lyrics ID {LyricsId}",
request.Artist, request.Title, request.LyricsId);
// Optionally fetch and cache the lyrics immediately
try
{
var lyricsService = _serviceProvider.GetService<allstarr.Services.Lyrics.LrclibService>();
if (lyricsService != null)
{
var lyricsInfo = await lyricsService.GetLyricsByIdAsync(request.LyricsId);
if (lyricsInfo != null && !string.IsNullOrEmpty(lyricsInfo.PlainLyrics))
{
// Cache the lyrics using the standard cache key
var lyricsCacheKey = $"lyrics:{request.Artist}:{request.Title}:{request.Album ?? ""}:{request.DurationSeconds}";
await _cache.SetAsync(lyricsCacheKey, lyricsInfo.PlainLyrics);
_logger.LogInformation("✓ Fetched and cached lyrics for {Artist} - {Title}", request.Artist, request.Title);
return Ok(new
{
message = "Lyrics mapping saved and lyrics cached successfully",
lyricsId = request.LyricsId,
cached = true,
lyrics = new
{
id = lyricsInfo.Id,
trackName = lyricsInfo.TrackName,
artistName = lyricsInfo.ArtistName,
albumName = lyricsInfo.AlbumName,
duration = lyricsInfo.Duration,
instrumental = lyricsInfo.Instrumental
}
});
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch lyrics after mapping, but mapping was saved");
}
return Ok(new
{
message = "Lyrics mapping saved successfully",
lyricsId = request.LyricsId,
cached = false
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save lyrics mapping");
return StatusCode(500, new { error = "Failed to save lyrics mapping" });
}
}
/// <summary>
/// Get manual lyrics mappings
/// </summary>
[HttpGet("lyrics/mappings")]
public async Task<IActionResult> GetLyricsMappings()
{
try
{
var mappingsFile = "/app/cache/lyrics_mappings.json";
if (!System.IO.File.Exists(mappingsFile))
{
return Ok(new { mappings = new List<object>() });
}
var json = await System.IO.File.ReadAllTextAsync(mappingsFile);
var mappings = JsonSerializer.Deserialize<List<LyricsMappingEntry>>(json) ?? new List<LyricsMappingEntry>();
return Ok(new { mappings });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get lyrics mappings");
return StatusCode(500, new { error = "Failed to get lyrics mappings" });
}
}
/// <summary>
/// Get all manual track mappings (both Jellyfin and external) for all playlists
/// </summary>
[HttpGet("mappings/tracks")]
public async Task<IActionResult> GetAllTrackMappings()
{
try
{
var mappingsDir = "/app/cache/mappings";
var allMappings = new List<object>();
if (!Directory.Exists(mappingsDir))
{
return Ok(new { mappings = allMappings, totalCount = 0 });
}
var files = Directory.GetFiles(mappingsDir, "*_mappings.json");
foreach (var file in files)
{
try
{
var json = await System.IO.File.ReadAllTextAsync(file);
var playlistMappings = JsonSerializer.Deserialize<Dictionary<string, ManualMappingEntry>>(json);
if (playlistMappings != null)
{
var fileName = Path.GetFileNameWithoutExtension(file);
var playlistName = fileName.Replace("_mappings", "").Replace("_", " ");
foreach (var mapping in playlistMappings.Values)
{
allMappings.Add(new
{
playlist = playlistName,
spotifyId = mapping.SpotifyId,
type = !string.IsNullOrEmpty(mapping.JellyfinId) ? "jellyfin" : "external",
jellyfinId = mapping.JellyfinId,
externalProvider = mapping.ExternalProvider,
externalId = mapping.ExternalId,
createdAt = mapping.CreatedAt
});
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read mapping file {File}", file);
}
}
return Ok(new
{
mappings = allMappings.OrderBy(m => ((dynamic)m).playlist).ThenBy(m => ((dynamic)m).createdAt),
totalCount = allMappings.Count,
jellyfinCount = allMappings.Count(m => ((dynamic)m).type == "jellyfin"),
externalCount = allMappings.Count(m => ((dynamic)m).type == "external")
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get track mappings");
return StatusCode(500, new { error = "Failed to get track mappings" });
}
}
/// <summary>
/// Delete a manual track mapping
/// </summary>
[HttpDelete("mappings/tracks")]
public async Task<IActionResult> DeleteTrackMapping([FromQuery] string playlist, [FromQuery] string spotifyId)
{
if (string.IsNullOrEmpty(playlist) || string.IsNullOrEmpty(spotifyId))
{
return BadRequest(new { error = "playlist and spotifyId parameters are required" });
}
try
{
var mappingsDir = "/app/cache/mappings";
var safeName = string.Join("_", playlist.Split(Path.GetInvalidFileNameChars()));
var filePath = Path.Combine(mappingsDir, $"{safeName}_mappings.json");
if (!System.IO.File.Exists(filePath))
{
return NotFound(new { error = "Mapping file not found for playlist" });
}
// Load existing mappings
var json = await System.IO.File.ReadAllTextAsync(filePath);
var mappings = JsonSerializer.Deserialize<Dictionary<string, ManualMappingEntry>>(json);
if (mappings == null || !mappings.ContainsKey(spotifyId))
{
return NotFound(new { error = "Mapping not found" });
}
// Remove the mapping
mappings.Remove(spotifyId);
// Save back to file (or delete file if empty)
if (mappings.Count == 0)
{
System.IO.File.Delete(filePath);
_logger.LogInformation("🗑️ Deleted empty mapping file for playlist {Playlist}", playlist);
}
else
{
var updatedJson = JsonSerializer.Serialize(mappings, new JsonSerializerOptions { WriteIndented = true });
await System.IO.File.WriteAllTextAsync(filePath, updatedJson);
_logger.LogInformation("🗑️ Deleted mapping: {Playlist} - {SpotifyId}", playlist, spotifyId);
}
// Also remove from Redis cache
var cacheKey = $"manual:mapping:{playlist}:{spotifyId}";
await _cache.DeleteAsync(cacheKey);
return Ok(new { success = true, message = "Mapping deleted successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete track mapping for {Playlist} - {SpotifyId}", playlist, spotifyId);
return StatusCode(500, new { error = "Failed to delete track mapping" });
}
}
/// <summary>
/// Test Spotify lyrics API by fetching lyrics for a specific Spotify track ID
/// Example: GET /api/admin/lyrics/spotify/test?trackId=3yII7UwgLF6K5zW3xad3MP
/// </summary>
[HttpGet("lyrics/spotify/test")]
public async Task<IActionResult> TestSpotifyLyrics([FromQuery] string trackId)
{
if (string.IsNullOrEmpty(trackId))
{
return BadRequest(new { error = "trackId parameter is required" });
}
try
{
var spotifyLyricsService = _serviceProvider.GetService<allstarr.Services.Lyrics.SpotifyLyricsService>();
if (spotifyLyricsService == null)
{
return StatusCode(500, new { error = "Spotify lyrics service not available" });
}
_logger.LogInformation("Testing Spotify lyrics for track ID: {TrackId}", trackId);
var result = await spotifyLyricsService.GetLyricsByTrackIdAsync(trackId);
if (result == null)
{
return NotFound(new
{
error = "No lyrics found",
trackId,
message = "Lyrics may not be available for this track, or the Spotify API is not configured correctly"
});
}
return Ok(new
{
success = true,
trackId = result.SpotifyTrackId,
syncType = result.SyncType,
lineCount = result.Lines.Count,
language = result.Language,
provider = result.Provider,
providerDisplayName = result.ProviderDisplayName,
lines = result.Lines.Select(l => new
{
startTimeMs = l.StartTimeMs,
endTimeMs = l.EndTimeMs,
words = l.Words
}).ToList(),
// Also show LRC format
lrcFormat = string.Join("\n", result.Lines.Select(l =>
{
var timestamp = TimeSpan.FromMilliseconds(l.StartTimeMs);
var mm = (int)timestamp.TotalMinutes;
var ss = timestamp.Seconds;
var ms = timestamp.Milliseconds / 10;
return $"[{mm:D2}:{ss:D2}.{ms:D2}]{l.Words}";
}))
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Spotify lyrics for track {TrackId}", trackId);
return StatusCode(500, new { error = $"Failed to fetch lyrics: {ex.Message}" });
}
}
/// <summary>
/// Prefetch lyrics for a specific playlist
/// </summary>
[HttpPost("playlists/{name}/prefetch-lyrics")]
public async Task<IActionResult> PrefetchPlaylistLyrics(string name)
{
var decodedName = Uri.UnescapeDataString(name);
try
{
var lyricsPrefetchService = _serviceProvider.GetService<allstarr.Services.Lyrics.LyricsPrefetchService>();
if (lyricsPrefetchService == null)
{
return StatusCode(500, new { error = "Lyrics prefetch service not available" });
}
_logger.LogInformation("Starting lyrics prefetch for playlist: {Playlist}", decodedName);
var (fetched, cached, missing) = await lyricsPrefetchService.PrefetchPlaylistLyricsAsync(
decodedName,
HttpContext.RequestAborted);
return Ok(new
{
message = "Lyrics prefetch complete",
playlist = decodedName,
fetched,
cached,
missing,
total = fetched + cached + missing
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to prefetch lyrics for playlist {Playlist}", decodedName);
return StatusCode(500, new { error = $"Failed to prefetch lyrics: {ex.Message}" });
}
}
#endregion
#region Helper Methods
/// <summary>
/// Invalidates the cached playlist summary so it will be regenerated on next request
/// </summary>
private void InvalidatePlaylistSummaryCache()
{
try
{
var cacheFile = "/app/cache/admin_playlists_summary.json";
if (System.IO.File.Exists(cacheFile))
{
System.IO.File.Delete(cacheFile);
_logger.LogDebug("🗑️ Invalidated playlist summary cache");
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to invalidate playlist summary cache");
}
}
#endregion
public class ManualMappingRequest
{
public string SpotifyId { get; set; } = "";
public string? JellyfinId { get; set; }
public string? ExternalProvider { get; set; }
public string? ExternalId { get; set; }
}
public class LyricsMappingRequest
{
public string Artist { get; set; } = "";
public string Title { get; set; } = "";
public string? Album { get; set; }
public int DurationSeconds { get; set; }
public int LyricsId { get; set; }
}
public class ManualMappingEntry
{
public string SpotifyId { get; set; } = "";
public string? JellyfinId { get; set; }
public string? ExternalProvider { get; set; }
public string? ExternalId { get; set; }
public DateTime CreatedAt { get; set; }
}
public class LyricsMappingEntry
{
public string Artist { get; set; } = "";
public string Title { get; set; } = "";
public string? Album { get; set; }
public int DurationSeconds { get; set; }
public int LyricsId { get; set; }
public DateTime CreatedAt { get; set; }
}
public class ConfigUpdateRequest
{
public Dictionary<string, string> Updates { get; set; } = new();
}
public class AddPlaylistRequest
{
public string Name { get; set; } = string.Empty;
public string SpotifyId { get; set; } = string.Empty;
public string LocalTracksPosition { get; set; } = "first";
}
public class LinkPlaylistRequest
{
public string Name { get; set; } = string.Empty;
public string SpotifyPlaylistId { get; set; } = string.Empty;
public string SyncSchedule { get; set; } = "0 8 * * 1"; // Default: 8 AM every Monday
}
public class UpdateScheduleRequest
{
public string SyncSchedule { get; set; } = string.Empty;
}
/// <summary>
/// GET /api/admin/downloads
/// Lists all downloaded files in the KEPT folder only (favorited tracks)
/// </summary>
[HttpGet("downloads")]
public IActionResult GetDownloads()
{
try
{
var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
_logger.LogInformation("📂 Checking kept folder: {Path}", keptPath);
_logger.LogInformation("📂 Directory exists: {Exists}", Directory.Exists(keptPath));
if (!Directory.Exists(keptPath))
{
_logger.LogWarning("Kept folder does not exist: {Path}", keptPath);
return Ok(new { files = new List<object>(), totalSize = 0, count = 0 });
}
var files = new List<object>();
long totalSize = 0;
// Recursively get all audio files from kept folder
var audioExtensions = new[] { ".flac", ".mp3", ".m4a", ".opus" };
var allFiles = Directory.GetFiles(keptPath, "*.*", SearchOption.AllDirectories)
.Where(f => audioExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
.ToList();
_logger.LogInformation("📂 Found {Count} audio files in kept folder", allFiles.Count);
foreach (var filePath in allFiles)
{
_logger.LogDebug("📂 Processing file: {Path}", filePath);
var fileInfo = new FileInfo(filePath);
var relativePath = Path.GetRelativePath(keptPath, filePath);
// Parse artist/album/track from path structure
var parts = relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var artist = parts.Length > 0 ? parts[0] : "";
var album = parts.Length > 1 ? parts[1] : "";
var fileName = parts.Length > 2 ? parts[^1] : Path.GetFileName(filePath);
files.Add(new
{
path = relativePath,
fullPath = filePath,
artist,
album,
fileName,
size = fileInfo.Length,
sizeFormatted = FormatFileSize(fileInfo.Length),
lastModified = fileInfo.LastWriteTimeUtc,
extension = fileInfo.Extension
});
totalSize += fileInfo.Length;
}
_logger.LogInformation("📂 Returning {Count} kept files, total size: {Size}", files.Count, FormatFileSize(totalSize));
return Ok(new
{
files = files.OrderBy(f => ((dynamic)f).artist).ThenBy(f => ((dynamic)f).album).ThenBy(f => ((dynamic)f).fileName),
totalSize,
totalSizeFormatted = FormatFileSize(totalSize),
count = files.Count
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to list kept downloads");
return StatusCode(500, new { error = "Failed to list kept downloads" });
}
}
/// <summary>
/// DELETE /api/admin/downloads
/// Deletes a specific kept file and cleans up empty folders
/// </summary>
[HttpDelete("downloads")]
public IActionResult DeleteDownload([FromQuery] string path)
{
try
{
if (string.IsNullOrEmpty(path))
{
return BadRequest(new { error = "Path is required" });
}
var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
var fullPath = Path.Combine(keptPath, path);
_logger.LogInformation("🗑️ Delete request for: {Path}", fullPath);
// Security: Ensure the path is within the kept directory
var normalizedFullPath = Path.GetFullPath(fullPath);
var normalizedKeptPath = Path.GetFullPath(keptPath);
if (!normalizedFullPath.StartsWith(normalizedKeptPath))
{
_logger.LogWarning("🗑️ Invalid path (outside kept folder): {Path}", normalizedFullPath);
return BadRequest(new { error = "Invalid path" });
}
if (!System.IO.File.Exists(fullPath))
{
_logger.LogWarning("🗑️ File not found: {Path}", fullPath);
return NotFound(new { error = "File not found" });
}
System.IO.File.Delete(fullPath);
_logger.LogInformation("🗑️ Deleted file: {Path}", fullPath);
// Clean up empty directories (Album folder, then Artist folder if empty)
var directory = Path.GetDirectoryName(fullPath);
while (directory != null && directory != keptPath && directory.StartsWith(keptPath))
{
if (Directory.Exists(directory) && !Directory.EnumerateFileSystemEntries(directory).Any())
{
Directory.Delete(directory);
_logger.LogInformation("🗑️ Deleted empty directory: {Dir}", directory);
directory = Path.GetDirectoryName(directory);
}
else
{
_logger.LogDebug("🗑️ Directory not empty or doesn't exist, stopping cleanup: {Dir}", directory);
break;
}
}
return Ok(new { success = true, message = "File deleted successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete file: {Path}", path);
return StatusCode(500, new { error = "Failed to delete file" });
}
}
/// <summary>
/// GET /api/admin/downloads/file
/// Downloads a specific file from the kept folder
/// </summary>
[HttpGet("downloads/file")]
public IActionResult DownloadFile([FromQuery] string path)
{
try
{
if (string.IsNullOrEmpty(path))
{
return BadRequest(new { error = "Path is required" });
}
var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
var fullPath = Path.Combine(keptPath, path);
// Security: Ensure the path is within the kept directory
var normalizedFullPath = Path.GetFullPath(fullPath);
var normalizedKeptPath = Path.GetFullPath(keptPath);
if (!normalizedFullPath.StartsWith(normalizedKeptPath))
{
return BadRequest(new { error = "Invalid path" });
}
if (!System.IO.File.Exists(fullPath))
{
return NotFound(new { error = "File not found" });
}
var fileName = Path.GetFileName(fullPath);
var fileStream = System.IO.File.OpenRead(fullPath);
return File(fileStream, "application/octet-stream", fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to download file: {Path}", path);
return StatusCode(500, new { error = "Failed to download file" });
}
}
private static string FormatFileSize(long bytes)
{
string[] sizes = { "B", "KB", "MB", "GB", "TB" };
double len = bytes;
int order = 0;
while (len >= 1024 && order < sizes.Length - 1)
{
order++;
len = len / 1024;
}
return $"{len:0.##} {sizes[order]}";
}
}
>>>>>>> dev

View File

@@ -1144,8 +1144,6 @@ public class JellyfinController : ControllerBase
[HttpGet("Items/{itemId}/Lyrics")] [HttpGet("Items/{itemId}/Lyrics")]
public async Task<IActionResult> GetLyrics(string itemId) public async Task<IActionResult> GetLyrics(string itemId)
{ {
_logger.LogInformation("🎵 GetLyrics called for itemId: {ItemId}", itemId);
if (string.IsNullOrWhiteSpace(itemId)) if (string.IsNullOrWhiteSpace(itemId))
{ {
return NotFound(); return NotFound();
@@ -1153,9 +1151,6 @@ public class JellyfinController : ControllerBase
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId); var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
_logger.LogInformation("🎵 Lyrics request: itemId={ItemId}, isExternal={IsExternal}, provider={Provider}, externalId={ExternalId}",
itemId, isExternal, provider, externalId);
// For local tracks, check if Jellyfin already has embedded lyrics // For local tracks, check if Jellyfin already has embedded lyrics
if (!isExternal) if (!isExternal)
{ {
@@ -1164,16 +1159,13 @@ public class JellyfinController : ControllerBase
// Try to get lyrics from Jellyfin first (it reads embedded lyrics from files) // Try to get lyrics from Jellyfin first (it reads embedded lyrics from files)
var (jellyfinLyrics, statusCode) = await _proxyService.GetJsonAsync($"Audio/{itemId}/Lyrics", null, Request.Headers); var (jellyfinLyrics, statusCode) = await _proxyService.GetJsonAsync($"Audio/{itemId}/Lyrics", null, Request.Headers);
_logger.LogInformation("Jellyfin lyrics check result: statusCode={StatusCode}, hasLyrics={HasLyrics}",
statusCode, jellyfinLyrics != null);
if (jellyfinLyrics != null && statusCode == 200) if (jellyfinLyrics != null && statusCode == 200)
{ {
_logger.LogInformation("Found embedded lyrics in Jellyfin for track {ItemId}", itemId); _logger.LogInformation("Found embedded lyrics in Jellyfin for track {ItemId}", itemId);
return new JsonResult(JsonSerializer.Deserialize<object>(jellyfinLyrics.RootElement.GetRawText())); return new JsonResult(JsonSerializer.Deserialize<object>(jellyfinLyrics.RootElement.GetRawText()));
} }
_logger.LogInformation("No embedded lyrics found in Jellyfin (status: {StatusCode}), trying Spotify/LRCLIB", statusCode); _logger.LogInformation("No embedded lyrics found in Jellyfin, trying Spotify/LRCLIB");
} }
// Get song metadata for lyrics search // Get song metadata for lyrics search
@@ -2183,7 +2175,7 @@ public class JellyfinController : ControllerBase
var method = Request.Method; var method = Request.Method;
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : ""; var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : "";
_logger.LogDebug("📡 Session capabilities reported - Method: {Method}, Query: {Query}", method, queryString); _logger.LogInformation("📡 Session capabilities reported - Method: {Method}, Query: {Query}", method, queryString);
_logger.LogInformation("Headers: {Headers}", _logger.LogInformation("Headers: {Headers}",
string.Join(", ", Request.Headers.Where(h => h.Key.Contains("Auth", StringComparison.OrdinalIgnoreCase) || h.Key.Contains("Device", StringComparison.OrdinalIgnoreCase) || h.Key.Contains("Client", StringComparison.OrdinalIgnoreCase)) string.Join(", ", Request.Headers.Where(h => h.Key.Contains("Auth", StringComparison.OrdinalIgnoreCase) || h.Key.Contains("Device", StringComparison.OrdinalIgnoreCase) || h.Key.Contains("Client", StringComparison.OrdinalIgnoreCase))
.Select(h => $"{h.Key}={h.Value}"))); .Select(h => $"{h.Key}={h.Value}")));
@@ -2208,11 +2200,7 @@ public class JellyfinController : ControllerBase
if (statusCode == 204 || statusCode == 200) if (statusCode == 204 || statusCode == 200)
{ {
_logger.LogDebug("✓ Session capabilities forwarded to Jellyfin ({StatusCode})", statusCode); _logger.LogInformation("✓ Session capabilities forwarded to Jellyfin ({StatusCode})", statusCode);
}
else if (statusCode == 401)
{
_logger.LogDebug("⚠ Jellyfin returned 401 for capabilities (token expired)");
} }
else else
{ {
@@ -2246,7 +2234,7 @@ public class JellyfinController : ControllerBase
} }
Request.Body.Position = 0; Request.Body.Position = 0;
_logger.LogDebug("📻 Playback START reported"); _logger.LogInformation("📻 Playback START reported");
// Parse the body to check if it's an external track // Parse the body to check if it's an external track
var doc = JsonDocument.Parse(body); var doc = JsonDocument.Parse(body);
@@ -2322,7 +2310,7 @@ public class JellyfinController : ControllerBase
if (ghostStatusCode == 204 || ghostStatusCode == 200) if (ghostStatusCode == 204 || ghostStatusCode == 200)
{ {
_logger.LogDebug("✓ Ghost playback start forwarded to Jellyfin for external track ({StatusCode})", ghostStatusCode); _logger.LogInformation("✓ Ghost playback start forwarded to Jellyfin for external track ({StatusCode})", ghostStatusCode);
} }
else else
{ {
@@ -2350,7 +2338,7 @@ public class JellyfinController : ControllerBase
} }
// For local tracks, forward playback start to Jellyfin FIRST // For local tracks, forward playback start to Jellyfin FIRST
_logger.LogDebug("Forwarding playback start to Jellyfin..."); _logger.LogInformation("Forwarding playback start to Jellyfin...");
// Fetch full item details to include in playback report // Fetch full item details to include in playback report
try try
@@ -2376,7 +2364,7 @@ public class JellyfinController : ControllerBase
if (statusCode == 204 || statusCode == 200) if (statusCode == 204 || statusCode == 200)
{ {
_logger.LogDebug("✓ Playback start forwarded to Jellyfin ({StatusCode})", statusCode); _logger.LogInformation("✓ Playback start forwarded to Jellyfin ({StatusCode})", statusCode);
// NOW ensure session exists with capabilities (after playback is reported) // NOW ensure session exists with capabilities (after playback is reported)
if (!string.IsNullOrEmpty(deviceId)) if (!string.IsNullOrEmpty(deviceId))
@@ -2384,7 +2372,7 @@ public class JellyfinController : ControllerBase
var sessionCreated = await _sessionManager.EnsureSessionAsync(deviceId, client ?? "Unknown", device ?? "Unknown", version ?? "1.0", Request.Headers); var sessionCreated = await _sessionManager.EnsureSessionAsync(deviceId, client ?? "Unknown", device ?? "Unknown", version ?? "1.0", Request.Headers);
if (sessionCreated) if (sessionCreated)
{ {
_logger.LogDebug("✓ SESSION: Session ensured for device {DeviceId} after playback start", deviceId); _logger.LogWarning("✓ SESSION: Session ensured for device {DeviceId} after playback start", deviceId);
} }
else else
{ {
@@ -2408,7 +2396,7 @@ public class JellyfinController : ControllerBase
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", body, Request.Headers); var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", body, Request.Headers);
if (statusCode == 204 || statusCode == 200) if (statusCode == 204 || statusCode == 200)
{ {
_logger.LogDebug("✓ Basic playback start forwarded to Jellyfin ({StatusCode})", statusCode); _logger.LogInformation("✓ Basic playback start forwarded to Jellyfin ({StatusCode})", statusCode);
} }
} }
} }
@@ -2521,7 +2509,7 @@ public class JellyfinController : ControllerBase
// Only log at 10-second intervals // Only log at 10-second intervals
if (position.Seconds % 10 == 0 && position.Milliseconds < 500) if (position.Seconds % 10 == 0 && position.Milliseconds < 500)
{ {
_logger.LogDebug("▶️ Progress: {Position:mm\\:ss} for item {ItemId}", position, itemId); _logger.LogInformation("▶️ Progress: {Position:mm\\:ss} for item {ItemId}", position, itemId);
} }
} }
} }
@@ -2561,7 +2549,7 @@ public class JellyfinController : ControllerBase
} }
Request.Body.Position = 0; Request.Body.Position = 0;
_logger.LogDebug("⏹️ Playback STOPPED reported"); _logger.LogInformation("⏹️ Playback STOPPED reported");
// Parse the body to check if it's an external track // Parse the body to check if it's an external track
var doc = JsonDocument.Parse(body); var doc = JsonDocument.Parse(body);
@@ -2619,7 +2607,7 @@ public class JellyfinController : ControllerBase
if (stopStatusCode == 204 || stopStatusCode == 200) if (stopStatusCode == 204 || stopStatusCode == 200)
{ {
_logger.LogDebug("✓ Ghost playback stop forwarded to Jellyfin ({StatusCode})", stopStatusCode); _logger.LogInformation("✓ Ghost playback stop forwarded to Jellyfin ({StatusCode})", stopStatusCode);
} }
return NoContent(); return NoContent();
@@ -2630,7 +2618,7 @@ public class JellyfinController : ControllerBase
} }
// For local tracks, forward to Jellyfin // For local tracks, forward to Jellyfin
_logger.LogDebug("Forwarding playback stop to Jellyfin..."); _logger.LogInformation("Forwarding playback stop to Jellyfin...");
// Log the body being sent for debugging // Log the body being sent for debugging
_logger.LogInformation("📤 Sending playback stop body: {Body}", body); _logger.LogInformation("📤 Sending playback stop body: {Body}", body);
@@ -2653,11 +2641,7 @@ public class JellyfinController : ControllerBase
if (statusCode == 204 || statusCode == 200) if (statusCode == 204 || statusCode == 200)
{ {
_logger.LogDebug("✓ Playback stop forwarded to Jellyfin ({StatusCode})", statusCode); _logger.LogInformation("✓ Playback stop forwarded to Jellyfin ({StatusCode})", statusCode);
}
else if (statusCode == 401)
{
_logger.LogDebug("Playback stop returned 401 (token expired)");
} }
else else
{ {
@@ -2716,7 +2700,7 @@ public class JellyfinController : ControllerBase
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : ""; var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : "";
var endpoint = string.IsNullOrEmpty(path) ? $"Sessions{queryString}" : $"Sessions/{path}{queryString}"; var endpoint = string.IsNullOrEmpty(path) ? $"Sessions{queryString}" : $"Sessions/{path}{queryString}";
_logger.LogDebug("🔄 Proxying session request: {Method} {Endpoint}", method, endpoint); _logger.LogInformation("🔄 Proxying session request: {Method} {Endpoint}", method, endpoint);
_logger.LogDebug("Session proxy headers: {Headers}", _logger.LogDebug("Session proxy headers: {Headers}",
string.Join(", ", Request.Headers.Where(h => h.Key.Contains("Auth", StringComparison.OrdinalIgnoreCase)) string.Join(", ", Request.Headers.Where(h => h.Key.Contains("Auth", StringComparison.OrdinalIgnoreCase))
.Select(h => $"{h.Key}={h.Value}"))); .Select(h => $"{h.Key}={h.Value}")));
@@ -2746,11 +2730,11 @@ public class JellyfinController : ControllerBase
if (result != null) if (result != null)
{ {
_logger.LogDebug("✓ Session request proxied successfully ({StatusCode})", statusCode); _logger.LogInformation("✓ Session request proxied successfully ({StatusCode})", statusCode);
return new JsonResult(result.RootElement.Clone()); return new JsonResult(result.RootElement.Clone());
} }
_logger.LogDebug("✓ Session request proxied ({StatusCode}, no body)", statusCode); _logger.LogInformation("✓ Session request proxied ({StatusCode}, no body)", statusCode);
return StatusCode(statusCode); return StatusCode(statusCode);
} }
catch (Exception ex) catch (Exception ex)
@@ -2808,7 +2792,7 @@ public class JellyfinController : ControllerBase
if (path.Contains("session", StringComparison.OrdinalIgnoreCase) || if (path.Contains("session", StringComparison.OrdinalIgnoreCase) ||
path.Contains("capabilit", StringComparison.OrdinalIgnoreCase)) path.Contains("capabilit", StringComparison.OrdinalIgnoreCase))
{ {
_logger.LogDebug("🔍 SESSION/CAPABILITY REQUEST: {Method} /{Path}{Query}", Request.Method, path, Request.QueryString); _logger.LogWarning("🔍 SESSION/CAPABILITY REQUEST: {Method} /{Path}{Query}", Request.Method, path, Request.QueryString);
} }
else else
{ {
@@ -3916,7 +3900,7 @@ public class JellyfinController : ControllerBase
} }
// Build kept folder path: Artist/Album/ // Build kept folder path: Artist/Album/
var keptBasePath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept"); var keptBasePath = _configuration["Library:KeptPath"] ?? "/app/kept";
var keptArtistPath = Path.Combine(keptBasePath, PathHelper.SanitizeFileName(song.Artist)); var keptArtistPath = Path.Combine(keptBasePath, PathHelper.SanitizeFileName(song.Artist));
var keptAlbumPath = Path.Combine(keptArtistPath, PathHelper.SanitizeFileName(song.Album)); var keptAlbumPath = Path.Combine(keptArtistPath, PathHelper.SanitizeFileName(song.Album));
@@ -4212,7 +4196,7 @@ public class JellyfinController : ControllerBase
var song = await _metadataService.GetSongAsync(provider!, externalId!); var song = await _metadataService.GetSongAsync(provider!, externalId!);
if (song == null) return; if (song == null) return;
var keptBasePath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept"); var keptBasePath = _configuration["Library:KeptPath"] ?? "/app/kept";
var keptArtistPath = Path.Combine(keptBasePath, PathHelper.SanitizeFileName(song.Artist)); var keptArtistPath = Path.Combine(keptBasePath, PathHelper.SanitizeFileName(song.Artist));
var keptAlbumPath = Path.Combine(keptArtistPath, PathHelper.SanitizeFileName(song.Album)); var keptAlbumPath = Path.Combine(keptArtistPath, PathHelper.SanitizeFileName(song.Album));

View File

@@ -2,44 +2,239 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using allstarr.Models.Settings; using allstarr.Models.Settings;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace allstarr.Filters; namespace allstarr.Filters;
/// <summary> /// <summary>
/// REMOVED: Authentication filter for Jellyfin API endpoints. /// Authentication filter for Jellyfin API endpoints.
/// /// Validates client credentials against configured username and API key.
/// This filter has been removed because Allstarr acts as a TRANSPARENT PROXY. /// Clients can authenticate via:
/// Clients authenticate directly with Jellyfin through the proxy, not with the proxy itself. /// - Authorization header: MediaBrowser Token="apikey"
/// /// - X-Emby-Token header
/// Authentication flow: /// - Query parameter: api_key
/// 1. Client sends credentials to /Users/AuthenticateByName /// - JSON body (for login endpoints): Username/Pw fields
/// 2. Proxy forwards request to Jellyfin (no validation)
/// 3. Jellyfin validates credentials and returns AccessToken
/// 4. Client uses AccessToken in subsequent requests
/// 5. Proxy forwards token to Jellyfin for validation
///
/// The proxy NEVER validates credentials or tokens - that's Jellyfin's job.
/// The proxy only forwards authentication headers transparently.
///
/// If you need to restrict access to the proxy itself, use network-level controls
/// (firewall, VPN, reverse proxy with auth) instead of application-level auth.
/// </summary> /// </summary>
public class JellyfinAuthFilter : IAsyncActionFilter public partial class JellyfinAuthFilter : IAsyncActionFilter
{ {
private readonly JellyfinSettings _settings;
private readonly ILogger<JellyfinAuthFilter> _logger; private readonly ILogger<JellyfinAuthFilter> _logger;
public JellyfinAuthFilter(ILogger<JellyfinAuthFilter> logger) public JellyfinAuthFilter(
IOptions<JellyfinSettings> settings,
ILogger<JellyfinAuthFilter> logger)
{ {
_settings = settings.Value;
_logger = logger; _logger = logger;
} }
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{ {
// This filter is now a no-op - all authentication is handled by Jellyfin // Skip auth if no credentials configured (open mode)
// Keeping the class for backwards compatibility but it does nothing if (string.IsNullOrEmpty(_settings.ClientUsername) || string.IsNullOrEmpty(_settings.ApiKey))
{
_logger.LogDebug("Auth skipped - no client credentials configured");
await next();
return;
}
_logger.LogTrace("JellyfinAuthFilter: Transparent proxy mode - no authentication check"); var request = context.HttpContext.Request;
// Try to extract credentials from various sources
var (username, token) = await ExtractCredentialsAsync(request);
// Validate credentials
if (!ValidateCredentials(username, token))
{
_logger.LogWarning("Authentication failed for user '{Username}' from {IP}",
username ?? "unknown",
context.HttpContext.Connection.RemoteIpAddress);
context.Result = new UnauthorizedObjectResult(new
{
error = "Invalid credentials",
message = "Authentication required. Provide valid username and API key."
});
return;
}
_logger.LogDebug("Authentication successful for user '{Username}'", username);
await next(); await next();
} }
private async Task<(string? username, string? token)> ExtractCredentialsAsync(HttpRequest request)
{
string? username = null;
string? token = null;
// 1. Check Authorization header (MediaBrowser format)
if (request.Headers.TryGetValue("Authorization", out var authHeader))
{
var authValue = authHeader.ToString();
// Parse MediaBrowser auth header: MediaBrowser Client="...", Token="..."
if (authValue.StartsWith("MediaBrowser", StringComparison.OrdinalIgnoreCase))
{
token = ExtractTokenFromMediaBrowser(authValue);
username = ExtractUserIdFromMediaBrowser(authValue);
}
// Basic auth: Basic base64(username:password)
else if (authValue.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))
{
(username, token) = ParseBasicAuth(authValue);
}
}
// 2. Check X-Emby-Token header
if (string.IsNullOrEmpty(token) && request.Headers.TryGetValue("X-Emby-Token", out var embyToken))
{
token = embyToken.ToString();
}
// 3. Check X-MediaBrowser-Token header
if (string.IsNullOrEmpty(token) && request.Headers.TryGetValue("X-MediaBrowser-Token", out var mbToken))
{
token = mbToken.ToString();
}
// 4. Check X-Emby-Authorization header (alternative format)
if (string.IsNullOrEmpty(token) && request.Headers.TryGetValue("X-Emby-Authorization", out var embyAuth))
{
token = ExtractTokenFromMediaBrowser(embyAuth.ToString());
if (string.IsNullOrEmpty(username))
{
username = ExtractUserIdFromMediaBrowser(embyAuth.ToString());
}
}
// 5. Check query parameters
if (string.IsNullOrEmpty(token))
{
token = request.Query["api_key"].FirstOrDefault()
?? request.Query["ApiKey"].FirstOrDefault()
?? request.Query["X-Emby-Token"].FirstOrDefault();
}
if (string.IsNullOrEmpty(username))
{
username = request.Query["userId"].FirstOrDefault()
?? request.Query["UserId"].FirstOrDefault()
?? request.Query["u"].FirstOrDefault();
}
// 6. Check JSON body for login endpoints (Jellyfin: Username/Pw, Navidrome: username/password)
if ((string.IsNullOrEmpty(username) || string.IsNullOrEmpty(token)) &&
request.ContentType?.Contains("application/json", StringComparison.OrdinalIgnoreCase) == true &&
request.ContentLength > 0)
{
var (bodyUsername, bodyPassword) = await ExtractCredentialsFromBodyAsync(request);
if (string.IsNullOrEmpty(username)) username = bodyUsername;
if (string.IsNullOrEmpty(token)) token = bodyPassword;
}
return (username, token);
}
private async Task<(string? username, string? password)> ExtractCredentialsFromBodyAsync(HttpRequest request)
{
try
{
request.EnableBuffering();
request.Body.Position = 0;
using var reader = new StreamReader(request.Body, leaveOpen: true);
var body = await reader.ReadToEndAsync();
request.Body.Position = 0;
if (string.IsNullOrEmpty(body)) return (null, null);
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
// Try Jellyfin format: Username, Pw
string? username = null;
string? password = null;
if (root.TryGetProperty("Username", out var usernameProp))
username = usernameProp.GetString();
else if (root.TryGetProperty("username", out var usernameLowerProp))
username = usernameLowerProp.GetString();
if (root.TryGetProperty("Pw", out var pwProp))
password = pwProp.GetString();
else if (root.TryGetProperty("pw", out var pwLowerProp))
password = pwLowerProp.GetString();
else if (root.TryGetProperty("Password", out var passwordProp))
password = passwordProp.GetString();
else if (root.TryGetProperty("password", out var passwordLowerProp))
password = passwordLowerProp.GetString();
return (username, password);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to parse credentials from request body");
return (null, null);
}
}
private string? ExtractTokenFromMediaBrowser(string header)
{
var match = TokenRegex().Match(header);
return match.Success ? match.Groups[1].Value : null;
}
private string? ExtractUserIdFromMediaBrowser(string header)
{
var match = UserIdRegex().Match(header);
return match.Success ? match.Groups[1].Value : null;
}
private static (string? username, string? password) ParseBasicAuth(string authHeader)
{
try
{
var base64 = authHeader["Basic ".Length..].Trim();
var bytes = Convert.FromBase64String(base64);
var credentials = System.Text.Encoding.UTF8.GetString(bytes);
var parts = credentials.Split(':', 2);
return parts.Length == 2 ? (parts[0], parts[1]) : (null, null);
}
catch
{
return (null, null);
}
}
private bool ValidateCredentials(string? username, string? token)
{
// Must have token (API key used as password)
if (string.IsNullOrEmpty(token))
{
return false;
}
// Token must match API key
if (!string.Equals(token, _settings.ApiKey, StringComparison.Ordinal))
{
return false;
}
// If username provided, it must match configured client username
if (!string.IsNullOrEmpty(username) &&
!string.Equals(username, _settings.ClientUsername, StringComparison.OrdinalIgnoreCase))
{
return false;
}
return true;
}
[GeneratedRegex(@"Token=""([^""]+)""", RegexOptions.IgnoreCase)]
private static partial Regex TokenRegex();
[GeneratedRegex(@"UserId=""([^""]+)""", RegexOptions.IgnoreCase)]
private static partial Regex UserIdRegex();
} }

View File

@@ -54,7 +54,7 @@ public class WebSocketProxyMiddleware
if (context.Request.Path.StartsWithSegments("/socket", StringComparison.OrdinalIgnoreCase) && if (context.Request.Path.StartsWithSegments("/socket", StringComparison.OrdinalIgnoreCase) &&
context.WebSockets.IsWebSocketRequest) context.WebSockets.IsWebSocketRequest)
{ {
_logger.LogDebug("🔌 WEBSOCKET: WebSocket connection request received from {RemoteIp}", _logger.LogInformation("🔌 WEBSOCKET: WebSocket connection request received from {RemoteIp}",
context.Connection.RemoteIpAddress); context.Connection.RemoteIpAddress);
await HandleWebSocketProxyAsync(context); await HandleWebSocketProxyAsync(context);
@@ -142,7 +142,7 @@ public class WebSocketProxyMiddleware
serverWebSocket.Options.SetRequestHeader("User-Agent", "Allstarr/1.0"); serverWebSocket.Options.SetRequestHeader("User-Agent", "Allstarr/1.0");
await serverWebSocket.ConnectAsync(new Uri(jellyfinWsUrl), context.RequestAborted); await serverWebSocket.ConnectAsync(new Uri(jellyfinWsUrl), context.RequestAborted);
_logger.LogDebug("✓ WEBSOCKET: Connected to Jellyfin WebSocket"); _logger.LogInformation("✓ WEBSOCKET: Connected to Jellyfin WebSocket");
// Start bidirectional proxying // Start bidirectional proxying
var clientToServer = ProxyMessagesAsync(clientWebSocket, serverWebSocket, "Client→Server", context.RequestAborted); var clientToServer = ProxyMessagesAsync(clientWebSocket, serverWebSocket, "Client→Server", context.RequestAborted);
@@ -154,17 +154,9 @@ public class WebSocketProxyMiddleware
_logger.LogDebug("🔌 WEBSOCKET: WebSocket proxy connection closed"); _logger.LogDebug("🔌 WEBSOCKET: WebSocket proxy connection closed");
} }
catch (WebSocketException wsEx) catch (WebSocketException wsEx)
{
// 403 is expected when tokens expire or session ends - don't spam logs
if (wsEx.Message.Contains("403"))
{
_logger.LogDebug("WEBSOCKET: Connection rejected with 403 (token expired or session ended)");
}
else
{ {
_logger.LogWarning(wsEx, "⚠️ WEBSOCKET: WebSocket error: {Message}", wsEx.Message); _logger.LogWarning(wsEx, "⚠️ WEBSOCKET: WebSocket error: {Message}", wsEx.Message);
} }
}
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "❌ WEBSOCKET: Error in WebSocket proxy"); _logger.LogError(ex, "❌ WEBSOCKET: Error in WebSocket proxy");
@@ -202,7 +194,7 @@ public class WebSocketProxyMiddleware
// CRITICAL: Notify session manager that client disconnected // CRITICAL: Notify session manager that client disconnected
if (!string.IsNullOrEmpty(deviceId)) if (!string.IsNullOrEmpty(deviceId))
{ {
_logger.LogDebug("🧹 WEBSOCKET: Client disconnected, removing session for device {DeviceId}", deviceId); _logger.LogInformation("🧹 WEBSOCKET: Client disconnected, removing session for device {DeviceId}", deviceId);
await _sessionManager.RemoveSessionAsync(deviceId); await _sessionManager.RemoveSessionAsync(deviceId);
} }
@@ -247,7 +239,7 @@ public class WebSocketProxyMiddleware
if (direction == "Server→Client") if (direction == "Server→Client")
{ {
var messageText = System.Text.Encoding.UTF8.GetString(messageBytes); var messageText = System.Text.Encoding.UTF8.GetString(messageBytes);
_logger.LogTrace("📥 WEBSOCKET {Direction}: {Preview}", _logger.LogInformation("📥 WEBSOCKET {Direction}: {Preview}",
direction, direction,
messageText.Length > 500 ? messageText[..500] + "..." : messageText); messageText.Length > 500 ? messageText[..500] + "..." : messageText);
} }
@@ -282,7 +274,7 @@ public class WebSocketProxyMiddleware
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogDebug(ex, "WEBSOCKET {Direction}: Error proxying messages (connection closed)", direction); _logger.LogWarning(ex, "⚠️ WEBSOCKET {Direction}: Error proxying messages", direction);
} }
} }
} }

View File

@@ -14,26 +14,11 @@ public class Song
public string Title { get; set; } = string.Empty; public string Title { get; set; } = string.Empty;
public string Artist { get; set; } = string.Empty; public string Artist { get; set; } = string.Empty;
public string? ArtistId { get; set; } public string? ArtistId { get; set; }
<<<<<<< HEAD
/// <summary> /// <summary>
/// All artists for this track (main + featured). For display in Jellyfin clients. /// All artists for this track (main + featured). For display in Jellyfin clients.
/// </summary> /// </summary>
public List<string> Artists { get; set; } = new(); public List<string> Artists { get; set; } = new();
||||||| bc4e5d9
=======
/// <summary>
/// All artists for this track (main + featured). For display in Jellyfin clients.
/// </summary>
public List<string> Artists { get; set; } = new();
/// <summary>
/// All artist IDs corresponding to the Artists list. Index-matched with Artists.
/// </summary>
public List<string> ArtistIds { get; set; } = new();
>>>>>>> dev
public string Album { get; set; } = string.Empty; public string Album { get; set; } = string.Empty;
public string? AlbumId { get; set; } public string? AlbumId { get; set; }
public int? Duration { get; set; } // In seconds public int? Duration { get; set; } // In seconds

View File

@@ -1,4 +1,3 @@
<<<<<<< HEAD
namespace allstarr.Models.Settings; namespace allstarr.Models.Settings;
/// <summary> /// <summary>
@@ -60,6 +59,27 @@ public class SpotifyImportSettings
/// </summary> /// </summary>
public bool Enabled { get; set; } public bool Enabled { get; set; }
/// <summary>
/// Hour when Spotify Import plugin runs (24-hour format, 0-23)
/// NOTE: This setting is now optional and only used for the sync window check.
/// The fetcher will search backwards from current time for the last 48 hours,
/// so timezone confusion is avoided.
/// </summary>
public int SyncStartHour { get; set; } = 16;
/// <summary>
/// Minute when Spotify Import plugin runs (0-59)
/// NOTE: This setting is now optional and only used for the sync window check.
/// </summary>
public int SyncStartMinute { get; set; } = 15;
/// <summary>
/// How many hours to search for missing tracks files after sync start time
/// This prevents the fetcher from running too frequently.
/// Set to 0 to disable the sync window check and always search on startup.
/// </summary>
public int SyncWindowHours { get; set; } = 2;
/// <summary> /// <summary>
/// How often to run track matching in hours. /// How often to run track matching in hours.
/// Spotify playlists like Discover Weekly update once per week, Release Radar updates weekly. /// Spotify playlists like Discover Weekly update once per week, Release Radar updates weekly.
@@ -122,137 +142,3 @@ public class SpotifyImportSettings
public bool IsSpotifyPlaylist(string jellyfinPlaylistId) => public bool IsSpotifyPlaylist(string jellyfinPlaylistId) =>
Playlists.Any(p => p.JellyfinId.Equals(jellyfinPlaylistId, StringComparison.OrdinalIgnoreCase)); Playlists.Any(p => p.JellyfinId.Equals(jellyfinPlaylistId, StringComparison.OrdinalIgnoreCase));
} }
||||||| bc4e5d9
=======
namespace allstarr.Models.Settings;
/// <summary>
/// Where to position local tracks relative to external matched tracks in Spotify playlists.
/// </summary>
public enum LocalTracksPosition
{
/// <summary>
/// Local tracks appear first, external tracks appended at the end (default)
/// </summary>
First,
/// <summary>
/// External tracks appear first, local tracks appended at the end
/// </summary>
Last
}
/// <summary>
/// Configuration for a single Spotify Import playlist.
/// </summary>
public class SpotifyPlaylistConfig
{
/// <summary>
/// Playlist name as it appears in Jellyfin/Spotify Import plugin
/// Example: "Discover Weekly", "Release Radar"
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Spotify playlist ID (get from Spotify playlist URL)
/// Example: "37i9dQZF1DXcBWIGoYBM5M" (from open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M)
/// Required for personalized playlists like Discover Weekly, Release Radar, etc.
/// </summary>
public string Id { get; set; } = string.Empty;
/// <summary>
/// Jellyfin playlist ID (internal Jellyfin GUID)
/// Example: "4383a46d8bcac3be2ef9385053ea18df"
/// This is the ID Jellyfin uses when requesting playlist tracks
/// </summary>
public string JellyfinId { get; set; } = string.Empty;
/// <summary>
/// Where to position local tracks: "first" or "last"
/// </summary>
public LocalTracksPosition LocalTracksPosition { get; set; } = LocalTracksPosition.First;
/// <summary>
/// Cron schedule for syncing this playlist with Spotify
/// Format: minute hour day month dayofweek
/// Example: "0 8 * * 1" = 8 AM every Monday
/// Default: "0 8 * * 1" (weekly on Monday at 8 AM)
/// </summary>
public string SyncSchedule { get; set; } = "0 8 * * 1";
}
/// <summary>
/// Configuration for Spotify playlist injection feature.
/// Requires Jellyfin Spotify Import Plugin: https://github.com/Viperinius/jellyfin-plugin-spotify-import
/// Uses JellyfinSettings.Url and JellyfinSettings.ApiKey for API access.
/// </summary>
public class SpotifyImportSettings
{
/// <summary>
/// Enable Spotify playlist injection feature
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// How often to run track matching in hours.
/// Spotify playlists like Discover Weekly update once per week, Release Radar updates weekly.
/// Most playlists don't change frequently, so running every 24 hours is reasonable.
/// Set to 0 to only run once on startup (manual trigger via admin UI still works).
/// Default: 24 hours
/// </summary>
public int MatchingIntervalHours { get; set; } = 24;
/// <summary>
/// Combined playlist configuration as JSON array.
/// Format: [["Name","Id","first|last"],...]
/// Example: [["Discover Weekly","abc123","first"],["Release Radar","def456","last"]]
/// </summary>
public List<SpotifyPlaylistConfig> Playlists { get; set; } = new();
/// <summary>
/// Legacy: Comma-separated list of Jellyfin playlist IDs to inject
/// Deprecated: Use Playlists instead
/// </summary>
[Obsolete("Use Playlists instead")]
public List<string> PlaylistIds { get; set; } = new();
/// <summary>
/// Legacy: Comma-separated list of playlist names
/// Deprecated: Use Playlists instead
/// </summary>
[Obsolete("Use Playlists instead")]
public List<string> PlaylistNames { get; set; } = new();
/// <summary>
/// Legacy: Comma-separated list of local track positions ("first" or "last")
/// Deprecated: Use Playlists instead
/// Example: "first,last,first,first" (one per playlist)
/// </summary>
[Obsolete("Use Playlists instead")]
public List<string> PlaylistLocalTracksPositions { get; set; } = new();
/// <summary>
/// Gets the playlist configuration by Jellyfin playlist ID.
/// </summary>
public SpotifyPlaylistConfig? GetPlaylistById(string playlistId) =>
Playlists.FirstOrDefault(p => p.Id.Equals(playlistId, StringComparison.OrdinalIgnoreCase));
/// <summary>
/// Gets the playlist configuration by Jellyfin playlist ID.
/// </summary>
public SpotifyPlaylistConfig? GetPlaylistByJellyfinId(string jellyfinPlaylistId) =>
Playlists.FirstOrDefault(p => p.JellyfinId.Equals(jellyfinPlaylistId, StringComparison.OrdinalIgnoreCase));
/// <summary>
/// Gets the playlist configuration by name.
/// </summary>
public SpotifyPlaylistConfig? GetPlaylistByName(string name) =>
Playlists.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
/// <summary>
/// Checks if a Jellyfin playlist ID is configured for Spotify import.
/// </summary>
public bool IsSpotifyPlaylist(string jellyfinPlaylistId) =>
Playlists.Any(p => p.JellyfinId.Equals(jellyfinPlaylistId, StringComparison.OrdinalIgnoreCase));
}
>>>>>>> dev

View File

@@ -13,49 +13,25 @@ using allstarr.Middleware;
using allstarr.Filters; using allstarr.Filters;
using Microsoft.Extensions.Http; using Microsoft.Extensions.Http;
using System.Text; using System.Text;
using System.Net;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// Configure forwarded headers for reverse proxy support (nginx, etc.)
// This allows ASP.NET Core to read X-Forwarded-For, X-Real-IP, etc.
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedFor
| Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedProto
| Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedHost;
// Clear known networks and proxies to accept headers from any proxy
// This is safe when running behind a trusted reverse proxy (nginx)
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
// Trust X-Forwarded-* headers from any source
// Only do this if your reverse proxy is properly configured and trusted
options.ForwardLimit = null;
});
// Decode SquidWTF API base URLs once at startup // Decode SquidWTF API base URLs once at startup
var squidWtfApiUrls = DecodeSquidWtfUrls(); var squidWtfApiUrls = DecodeSquidWtfUrls();
static List<string> DecodeSquidWtfUrls() static List<string> DecodeSquidWtfUrls()
{ {
var encodedUrls = new[] var encodedUrls = new[]
{ {
"aHR0cHM6Ly90cml0b24uc3F1aWQud3Rm", // triton.squid.wtf "aHR0cHM6Ly90cml0b24uc3F1aWQud3Rm", // triton
"aHR0cHM6Ly90aWRhbC1hcGkuYmluaW11bS5vcmc=", // tidal-api.binimum.org "aHR0cHM6Ly90aWRhbC1hcGkuYmluaW11bS5vcmc=", // binimum
"aHR0cHM6Ly90aWRhbC5raW5vcGx1cy5vbmxpbmU=", // tidal.kinoplus.online "aHR0cHM6Ly90aWRhbC5raW5vcGx1cy5vbmxpbmU=", // kinoplus
"aHR0cHM6Ly9oaWZpLXR3by5zcG90aXNhdmVyLm5ldA==", // hifi-two.spotisaver.net "aHR0cHM6Ly9oaWZpLXR3by5zcG90aXNhdmVyLm5ldA==", // spoti-2
"aHR0cHM6Ly9oaWZpLW9uZS5zcG90aXNhdmVyLm5ldA==", // hifi-one.spotisaver.net "aHR0cHM6Ly9oaWZpLW9uZS5zcG90aXNhdmVyLm5ldA==", // spoti-1
"aHR0cHM6Ly93b2xmLnFxZGwuc2l0ZQ==", // wolf.qqdl.site "aHR0cHM6Ly93b2xmLnFxZGwuc2l0ZQ==", // wolf
"aHR0cDovL2h1bmQucXFkbC5zaXRl", // hund.qqdl.site (http) "aHR0cDovL2h1bmQucXFkbC5zaXRl", // hund
"aHR0cHM6Ly9rYXR6ZS5xcWRsLnNpdGU=", // katze.qqdl.site "aHR0cHM6Ly9rYXR6ZS5xcWRsLnNpdGU=", // katze
"aHR0cHM6Ly92b2dlbC5xcWRsLnNpdGU=", // vogel.qqdl.site "aHR0cHM6Ly92b2dlbC5xcWRsLnNpdGU=", // vogel
"aHR0cHM6Ly9tYXVzLnFxZGwuc2l0ZQ==", // maus.qqdl.site "aHR0cHM6Ly9tYXVzLnFxZGwuc2l0ZQ==" // maus
"aHR0cHM6Ly9ldS1jZW50cmFsLm1vbm9jaHJvbWUudGY=", // eu-central.monochrome.tf
"aHR0cHM6Ly91cy13ZXN0Lm1vbm9jaHJvbWUudGY=", // us-west.monochrome.tf
"aHR0cHM6Ly9hcnJhbi5tb25vY2hyb21lLnRm", // arran.monochrome.tf
"aHR0cHM6Ly9hcGkubW9ub2Nocm9tZS50Zg==", // api.monochrome.tf
"aHR0cHM6Ly9odW5kLnFxZGwuc2l0ZQ==" // hund.qqdl.site (https)
}; };
return encodedUrls return encodedUrls
@@ -377,7 +353,7 @@ builder.Services.Configure<SpotifyImportSettings>(options =>
} }
// Log configuration at startup // Log configuration at startup
Console.WriteLine($"Spotify Import: Enabled={options.Enabled}, MatchingInterval={options.MatchingIntervalHours}h"); Console.WriteLine($"Spotify Import: Enabled={options.Enabled}, SyncHour={options.SyncStartHour}:{options.SyncStartMinute:D2}, WindowHours={options.SyncWindowHours}");
Console.WriteLine($"Spotify Import Playlists: {options.Playlists.Count} configured"); Console.WriteLine($"Spotify Import Playlists: {options.Playlists.Count} configured");
foreach (var playlist in options.Playlists) foreach (var playlist in options.Playlists)
{ {
@@ -645,23 +621,7 @@ builder.Services.AddCors(options =>
var app = builder.Build(); var app = builder.Build();
// Migrate old .env file format on startup
try
{
var migrationService = new EnvMigrationService(app.Services.GetRequiredService<ILogger<EnvMigrationService>>());
migrationService.MigrateEnvFile();
}
catch (Exception ex)
{
app.Logger.LogWarning(ex, "Failed to run .env migration");
}
// Configure the HTTP request pipeline. // Configure the HTTP request pipeline.
// IMPORTANT: UseForwardedHeaders must be called BEFORE other middleware
// This processes X-Forwarded-For, X-Real-IP, etc. from nginx
app.UseForwardedHeaders();
app.UseExceptionHandler(_ => { }); // Global exception handler app.UseExceptionHandler(_ => { }); // Global exception handler
// Enable response compression EARLY in the pipeline // Enable response compression EARLY in the pipeline

View File

@@ -95,89 +95,23 @@ public abstract class BaseDownloadService : IDownloadService
public async Task<Stream> DownloadAndStreamAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) public async Task<Stream> DownloadAndStreamAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
{ {
var startTime = DateTime.UtcNow;
// Check if already downloaded locally // Check if already downloaded locally
var localPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId); var localPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId);
if (localPath != null && IOFile.Exists(localPath)) if (localPath != null && IOFile.Exists(localPath))
{ {
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds; Logger.LogInformation("Streaming from local cache: {Path}", localPath);
Logger.LogInformation("Streaming from local cache ({ElapsedMs}ms): {Path}", elapsed, localPath);
// Update access time for cache cleanup
if (SubsonicSettings.StorageMode == StorageMode.Cache)
{
IOFile.SetLastAccessTime(localPath, DateTime.UtcNow);
}
// Start background Odesli conversion for lyrics (if not already cached)
StartBackgroundOdesliConversion(externalProvider, externalId);
return IOFile.OpenRead(localPath); return IOFile.OpenRead(localPath);
} }
// Download to disk first to ensure complete file with metadata // For on-demand streaming, download to disk first to ensure complete file
// This is necessary because: // This is necessary because:
// 1. Clients may seek to arbitrary positions (requires full file) // 1. Clients may seek to arbitrary positions (requires full file)
// 2. Metadata embedding requires complete file // 2. Metadata embedding requires complete file
// 3. Caching for future plays // 3. Caching for future plays
Logger.LogInformation("Downloading song for streaming: {Provider}:{ExternalId}", externalProvider, externalId); Logger.LogInformation("Downloading song for streaming: {Provider}:{ExternalId}", externalProvider, externalId);
try
{
localPath = await DownloadSongInternalAsync(externalProvider, externalId, triggerAlbumDownload: true, cancellationToken); localPath = await DownloadSongInternalAsync(externalProvider, externalId, triggerAlbumDownload: true, cancellationToken);
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
Logger.LogInformation("Download completed, starting stream ({ElapsedMs}ms total): {Path}", elapsed, localPath);
// Start background Odesli conversion for lyrics (after stream starts)
StartBackgroundOdesliConversion(externalProvider, externalId);
return IOFile.OpenRead(localPath); return IOFile.OpenRead(localPath);
} }
catch (OperationCanceledException)
{
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
Logger.LogWarning("Download cancelled by client after {ElapsedMs}ms for {Provider}:{ExternalId}", elapsed, externalProvider, externalId);
throw;
}
catch (Exception ex)
{
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
Logger.LogError(ex, "Download failed after {ElapsedMs}ms for {Provider}:{ExternalId}", elapsed, externalProvider, externalId);
throw;
}
}
/// <summary>
/// Starts background Odesli conversion for lyrics support.
/// This is called AFTER streaming starts so it doesn't block the client.
/// </summary>
private void StartBackgroundOdesliConversion(string externalProvider, string externalId)
{
_ = Task.Run(async () =>
{
try
{
// Provider-specific conversion (override in subclasses if needed)
await ConvertToSpotifyIdAsync(externalProvider, externalId);
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Background Spotify ID conversion failed for {Provider}:{ExternalId}", externalProvider, externalId);
}
});
}
/// <summary>
/// Converts external track ID to Spotify ID for lyrics support.
/// Override in provider-specific services if needed.
/// </summary>
protected virtual Task ConvertToSpotifyIdAsync(string externalProvider, string externalId)
{
// Default implementation does nothing
// Provider-specific services can override this
return Task.CompletedTask;
}
public DownloadInfo? GetDownloadStatus(string songId) public DownloadInfo? GetDownloadStatus(string songId)
{ {
@@ -264,7 +198,6 @@ public abstract class BaseDownloadService : IDownloadService
// Acquire lock BEFORE checking existence to prevent race conditions with concurrent requests // Acquire lock BEFORE checking existence to prevent race conditions with concurrent requests
await DownloadLock.WaitAsync(cancellationToken); await DownloadLock.WaitAsync(cancellationToken);
var lockHeld = true;
try try
{ {
@@ -286,27 +219,21 @@ public abstract class BaseDownloadService : IDownloadService
// Check if download in progress // Check if download in progress
if (ActiveDownloads.TryGetValue(songId, out var activeDownload) && activeDownload.Status == DownloadStatus.InProgress) if (ActiveDownloads.TryGetValue(songId, out var activeDownload) && activeDownload.Status == DownloadStatus.InProgress)
{ {
Logger.LogDebug("Download already in progress for {SongId}, waiting for completion...", songId); Logger.LogInformation("Download already in progress for {SongId}, waiting...", songId);
// Release lock while waiting // Release lock while waiting
DownloadLock.Release(); DownloadLock.Release();
lockHeld = false;
// Wait for download to complete, checking every 100ms (faster than 500ms)
// Also respect cancellation token so client timeouts are handled immediately
while (ActiveDownloads.TryGetValue(songId, out activeDownload) && activeDownload.Status == DownloadStatus.InProgress) while (ActiveDownloads.TryGetValue(songId, out activeDownload) && activeDownload.Status == DownloadStatus.InProgress)
{ {
cancellationToken.ThrowIfCancellationRequested(); await Task.Delay(500, cancellationToken);
await Task.Delay(100, cancellationToken);
} }
if (activeDownload?.Status == DownloadStatus.Completed && activeDownload.LocalPath != null) if (activeDownload?.Status == DownloadStatus.Completed && activeDownload.LocalPath != null)
{ {
Logger.LogDebug("Download completed while waiting, returning path: {Path}", activeDownload.LocalPath);
return activeDownload.LocalPath; return activeDownload.LocalPath;
} }
// Download failed or was cancelled throw new Exception(activeDownload?.ErrorMessage ?? "Download failed");
throw new Exception(activeDownload?.ErrorMessage ?? "Download failed while waiting");
} }
// Get metadata // Get metadata
@@ -445,13 +372,10 @@ public abstract class BaseDownloadService : IDownloadService
throw; throw;
} }
finally finally
{
if (lockHeld)
{ {
DownloadLock.Release(); DownloadLock.Release();
} }
} }
}
protected async Task DownloadRemainingAlbumTracksAsync(string albumExternalId, string excludeTrackExternalId) protected async Task DownloadRemainingAlbumTracksAsync(string albumExternalId, string excludeTrackExternalId)
{ {

View File

@@ -66,9 +66,7 @@ public class CacheCleanupService : BackgroundService
private async Task CleanupOldCachedFilesAsync(CancellationToken cancellationToken) private async Task CleanupOldCachedFilesAsync(CancellationToken cancellationToken)
{ {
// Get the actual cache path used by download services var cachePath = PathHelper.GetCachePath();
var downloadPath = _configuration["Library:DownloadPath"] ?? "downloads";
var cachePath = Path.Combine(downloadPath, "cache");
if (!Directory.Exists(cachePath)) if (!Directory.Exists(cachePath))
{ {
@@ -80,7 +78,7 @@ public class CacheCleanupService : BackgroundService
var deletedCount = 0; var deletedCount = 0;
var totalSize = 0L; var totalSize = 0L;
_logger.LogInformation("Starting cache cleanup: deleting files older than {CutoffTime} from {Path}", cutoffTime, cachePath); _logger.LogInformation("Starting cache cleanup: deleting files older than {CutoffTime}", cutoffTime);
try try
{ {
@@ -96,16 +94,16 @@ public class CacheCleanupService : BackgroundService
{ {
var fileInfo = new FileInfo(filePath); var fileInfo = new FileInfo(filePath);
// Use last write time (when file was created/downloaded) to determine if file should be deleted // Use last access time to determine if file should be deleted
// LastAccessTime is unreliable on many filesystems (noatime mount option) // This gets updated when a cached file is streamed
if (fileInfo.LastWriteTimeUtc < cutoffTime) if (fileInfo.LastAccessTimeUtc < cutoffTime)
{ {
var size = fileInfo.Length; var size = fileInfo.Length;
File.Delete(filePath); File.Delete(filePath);
deletedCount++; deletedCount++;
totalSize += size; totalSize += size;
_logger.LogDebug("Deleted cached file: {Path} (age: {Age:F1} hours)", _logger.LogDebug("Deleted cached file: {Path} (last accessed: {LastAccess})",
filePath, (DateTime.UtcNow - fileInfo.LastWriteTimeUtc).TotalHours); filePath, fileInfo.LastAccessTimeUtc);
} }
} }
catch (Exception ex) catch (Exception ex)

View File

@@ -1,4 +1,3 @@
<<<<<<< HEAD
using System.Diagnostics; using System.Diagnostics;
namespace allstarr.Services.Common; namespace allstarr.Services.Common;
@@ -134,144 +133,3 @@ public class EndpointMetrics
public double SuccessRate { get; set; } public double SuccessRate { get; set; }
public DateTime LastBenchmark { get; set; } public DateTime LastBenchmark { get; set; }
} }
||||||| bc4e5d9
=======
using System.Diagnostics;
namespace allstarr.Services.Common;
/// <summary>
/// Benchmarks API endpoints on startup and maintains performance metrics.
/// Used to prioritize faster endpoints in racing scenarios.
/// </summary>
public class EndpointBenchmarkService
{
private readonly ILogger<EndpointBenchmarkService> _logger;
private readonly Dictionary<string, EndpointMetrics> _metrics = new();
private readonly SemaphoreSlim _lock = new(1, 1);
public EndpointBenchmarkService(ILogger<EndpointBenchmarkService> logger)
{
_logger = logger;
}
/// <summary>
/// Benchmarks a list of endpoints by making test requests.
/// Returns endpoints sorted by average response time (fastest first).
///
/// IMPORTANT: The testFunc should implement its own timeout to prevent slow endpoints
/// from blocking startup. Recommended: 5-10 second timeout per ping.
/// </summary>
public async Task<List<string>> BenchmarkEndpointsAsync(
List<string> endpoints,
Func<string, CancellationToken, Task<bool>> testFunc,
int pingCount = 3,
CancellationToken cancellationToken = default)
{
_logger.LogInformation("🏁 Benchmarking {Count} endpoints with {Pings} pings each...", endpoints.Count, pingCount);
var tasks = endpoints.Select(async endpoint =>
{
var sw = Stopwatch.StartNew();
var successCount = 0;
var totalMs = 0L;
for (int i = 0; i < pingCount; i++)
{
try
{
var pingStart = Stopwatch.GetTimestamp();
var success = await testFunc(endpoint, cancellationToken);
var pingMs = Stopwatch.GetElapsedTime(pingStart).TotalMilliseconds;
if (success)
{
successCount++;
totalMs += (long)pingMs;
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Benchmark ping failed for {Endpoint}", endpoint);
}
// Small delay between pings
if (i < pingCount - 1)
{
await Task.Delay(100, cancellationToken);
}
}
sw.Stop();
var avgMs = successCount > 0 ? totalMs / successCount : long.MaxValue;
var metrics = new EndpointMetrics
{
Endpoint = endpoint,
AverageResponseMs = avgMs,
SuccessRate = (double)successCount / pingCount,
LastBenchmark = DateTime.UtcNow
};
await _lock.WaitAsync(cancellationToken);
try
{
_metrics[endpoint] = metrics;
}
finally
{
_lock.Release();
}
_logger.LogInformation(" {Endpoint}: {AvgMs}ms avg, {SuccessRate:P0} success rate",
endpoint, avgMs, metrics.SuccessRate);
return metrics;
}).ToList();
var results = await Task.WhenAll(tasks);
// Sort by: success rate first (must be > 0), then by average response time
var sorted = results
.Where(m => m.SuccessRate > 0)
.OrderByDescending(m => m.SuccessRate)
.ThenBy(m => m.AverageResponseMs)
.Select(m => m.Endpoint)
.ToList();
_logger.LogInformation("✅ Benchmark complete. Fastest: {Fastest} ({Ms}ms)",
sorted.FirstOrDefault() ?? "none",
results.Where(m => m.SuccessRate > 0).MinBy(m => m.AverageResponseMs)?.AverageResponseMs ?? 0);
return sorted;
}
/// <summary>
/// Gets the metrics for a specific endpoint.
/// </summary>
public EndpointMetrics? GetMetrics(string endpoint)
{
_metrics.TryGetValue(endpoint, out var metrics);
return metrics;
}
/// <summary>
/// Gets all endpoint metrics sorted by performance.
/// </summary>
public List<EndpointMetrics> GetAllMetrics()
{
return _metrics.Values
.OrderByDescending(m => m.SuccessRate)
.ThenBy(m => m.AverageResponseMs)
.ToList();
}
}
public class EndpointMetrics
{
public string Endpoint { get; set; } = string.Empty;
public long AverageResponseMs { get; set; }
public double SuccessRate { get; set; }
public DateTime LastBenchmark { get; set; }
}
>>>>>>> dev

View File

@@ -1,59 +0,0 @@
namespace allstarr.Services.Common;
/// <summary>
/// Service that runs on startup to migrate old .env file format to new format
/// </summary>
public class EnvMigrationService
{
private readonly ILogger<EnvMigrationService> _logger;
private readonly string _envFilePath;
public EnvMigrationService(ILogger<EnvMigrationService> logger)
{
_logger = logger;
_envFilePath = Path.Combine(Directory.GetCurrentDirectory(), ".env");
}
public void MigrateEnvFile()
{
if (!File.Exists(_envFilePath))
{
_logger.LogDebug("No .env file found, skipping migration");
return;
}
try
{
var lines = File.ReadAllLines(_envFilePath);
var modified = false;
for (int i = 0; i < lines.Length; i++)
{
var line = lines[i].Trim();
// Skip comments and empty lines
if (string.IsNullOrWhiteSpace(line) || line.StartsWith("#"))
continue;
// Migrate DOWNLOAD_PATH to Library__DownloadPath
if (line.StartsWith("DOWNLOAD_PATH="))
{
var value = line.Substring("DOWNLOAD_PATH=".Length);
lines[i] = $"Library__DownloadPath={value}";
modified = true;
_logger.LogInformation("Migrated DOWNLOAD_PATH to Library__DownloadPath in .env file");
}
}
if (modified)
{
File.WriteAllLines(_envFilePath, lines);
_logger.LogInformation("✅ .env file migration completed successfully");
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to migrate .env file - please manually update DOWNLOAD_PATH to Library__DownloadPath");
}
}
}

View File

@@ -11,12 +11,6 @@ public class RoundRobinFallbackHelper
private readonly object _urlIndexLock = new object(); private readonly object _urlIndexLock = new object();
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly string _serviceName; private readonly string _serviceName;
private readonly HttpClient _healthCheckClient;
// Cache health check results for 30 seconds to avoid excessive checks
private readonly Dictionary<string, (bool isHealthy, DateTime checkedAt)> _healthCache = new();
private readonly object _healthCacheLock = new object();
private readonly TimeSpan _healthCacheExpiry = TimeSpan.FromSeconds(30);
public int EndpointCount => _apiUrls.Count; public int EndpointCount => _apiUrls.Count;
@@ -30,91 +24,6 @@ public class RoundRobinFallbackHelper
{ {
throw new ArgumentException("API URLs list cannot be empty", nameof(apiUrls)); throw new ArgumentException("API URLs list cannot be empty", nameof(apiUrls));
} }
// Create a dedicated HttpClient for health checks with short timeout
_healthCheckClient = new HttpClient
{
Timeout = TimeSpan.FromSeconds(3) // Quick health check timeout
};
}
/// <summary>
/// Quickly checks if an endpoint is healthy (responds within 3 seconds).
/// Results are cached for 30 seconds to avoid excessive health checks.
/// </summary>
private async Task<bool> IsEndpointHealthyAsync(string baseUrl)
{
// Check cache first
lock (_healthCacheLock)
{
if (_healthCache.TryGetValue(baseUrl, out var cached))
{
if (DateTime.UtcNow - cached.checkedAt < _healthCacheExpiry)
{
return cached.isHealthy;
}
}
}
// Perform health check
try
{
var response = await _healthCheckClient.GetAsync(baseUrl, HttpCompletionOption.ResponseHeadersRead);
var isHealthy = response.IsSuccessStatusCode;
// Cache result
lock (_healthCacheLock)
{
_healthCache[baseUrl] = (isHealthy, DateTime.UtcNow);
}
if (!isHealthy)
{
_logger.LogDebug("{Service} endpoint {Endpoint} health check failed: {StatusCode}",
_serviceName, baseUrl, response.StatusCode);
}
return isHealthy;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "{Service} endpoint {Endpoint} health check failed", _serviceName, baseUrl);
// Cache as unhealthy
lock (_healthCacheLock)
{
_healthCache[baseUrl] = (false, DateTime.UtcNow);
}
return false;
}
}
/// <summary>
/// Gets a list of healthy endpoints, checking them in parallel.
/// Falls back to all endpoints if none are healthy.
/// </summary>
private async Task<List<string>> GetHealthyEndpointsAsync()
{
var healthCheckTasks = _apiUrls.Select(async url => new
{
Url = url,
IsHealthy = await IsEndpointHealthyAsync(url)
}).ToList();
var results = await Task.WhenAll(healthCheckTasks);
var healthyEndpoints = results.Where(r => r.IsHealthy).Select(r => r.Url).ToList();
if (healthyEndpoints.Count == 0)
{
_logger.LogWarning("{Service} health check: no healthy endpoints found, will try all", _serviceName);
return _apiUrls;
}
_logger.LogDebug("{Service} health check: {Healthy}/{Total} endpoints healthy",
_serviceName, healthyEndpoints.Count, _apiUrls.Count);
return healthyEndpoints;
} }
/// <summary> /// <summary>
@@ -145,14 +54,10 @@ public class RoundRobinFallbackHelper
/// <summary> /// <summary>
/// Tries the request with the next provider in round-robin, then falls back to others on failure. /// Tries the request with the next provider in round-robin, then falls back to others on failure.
/// This distributes load evenly across all providers while maintaining reliability. /// This distributes load evenly across all providers while maintaining reliability.
/// Performs quick health checks first to avoid wasting time on dead endpoints.
/// Throws exception if all endpoints fail. /// Throws exception if all endpoints fail.
/// </summary> /// </summary>
public async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action) public async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action)
{ {
// Get healthy endpoints first (with caching to avoid excessive checks)
var healthyEndpoints = await GetHealthyEndpointsAsync();
// Start with the next URL in round-robin to distribute load // Start with the next URL in round-robin to distribute load
var startIndex = 0; var startIndex = 0;
lock (_urlIndexLock) lock (_urlIndexLock)
@@ -161,21 +66,16 @@ public class RoundRobinFallbackHelper
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count; _currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
} }
// Try healthy endpoints first, then fall back to all if needed
var endpointsToTry = healthyEndpoints.Count < _apiUrls.Count
? healthyEndpoints.Concat(_apiUrls.Except(healthyEndpoints)).ToList()
: healthyEndpoints;
// Try all URLs starting from the round-robin selected one // Try all URLs starting from the round-robin selected one
for (int attempt = 0; attempt < endpointsToTry.Count; attempt++) for (int attempt = 0; attempt < _apiUrls.Count; attempt++)
{ {
var urlIndex = (startIndex + attempt) % endpointsToTry.Count; var urlIndex = (startIndex + attempt) % _apiUrls.Count;
var baseUrl = endpointsToTry[urlIndex]; var baseUrl = _apiUrls[urlIndex];
try try
{ {
_logger.LogDebug("Trying {Service} endpoint {Endpoint} (attempt {Attempt}/{Total})", _logger.LogDebug("Trying {Service} endpoint {Endpoint} (attempt {Attempt}/{Total})",
_serviceName, baseUrl, attempt + 1, endpointsToTry.Count); _serviceName, baseUrl, attempt + 1, _apiUrls.Count);
return await action(baseUrl); return await action(baseUrl);
} }
catch (Exception ex) catch (Exception ex)
@@ -183,15 +83,9 @@ public class RoundRobinFallbackHelper
_logger.LogWarning(ex, "{Service} request failed with endpoint {Endpoint}, trying next...", _logger.LogWarning(ex, "{Service} request failed with endpoint {Endpoint}, trying next...",
_serviceName, baseUrl); _serviceName, baseUrl);
// Mark as unhealthy in cache if (attempt == _apiUrls.Count - 1)
lock (_healthCacheLock)
{ {
_healthCache[baseUrl] = (false, DateTime.UtcNow); _logger.LogError("All {Count} {Service} endpoints failed", _apiUrls.Count, _serviceName);
}
if (attempt == endpointsToTry.Count - 1)
{
_logger.LogError("All {Count} {Service} endpoints failed", endpointsToTry.Count, _serviceName);
throw; throw;
} }
} }
@@ -256,14 +150,10 @@ public class RoundRobinFallbackHelper
/// <summary> /// <summary>
/// Tries the request with the next provider in round-robin, then falls back to others on failure. /// Tries the request with the next provider in round-robin, then falls back to others on failure.
/// Performs quick health checks first to avoid wasting time on dead endpoints.
/// Returns default value if all endpoints fail (does not throw). /// Returns default value if all endpoints fail (does not throw).
/// </summary> /// </summary>
public async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action, T defaultValue) public async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action, T defaultValue)
{ {
// Get healthy endpoints first (with caching to avoid excessive checks)
var healthyEndpoints = await GetHealthyEndpointsAsync();
// Start with the next URL in round-robin to distribute load // Start with the next URL in round-robin to distribute load
var startIndex = 0; var startIndex = 0;
lock (_urlIndexLock) lock (_urlIndexLock)
@@ -272,21 +162,16 @@ public class RoundRobinFallbackHelper
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count; _currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
} }
// Try healthy endpoints first, then fall back to all if needed
var endpointsToTry = healthyEndpoints.Count < _apiUrls.Count
? healthyEndpoints.Concat(_apiUrls.Except(healthyEndpoints)).ToList()
: healthyEndpoints;
// Try all URLs starting from the round-robin selected one // Try all URLs starting from the round-robin selected one
for (int attempt = 0; attempt < endpointsToTry.Count; attempt++) for (int attempt = 0; attempt < _apiUrls.Count; attempt++)
{ {
var urlIndex = (startIndex + attempt) % endpointsToTry.Count; var urlIndex = (startIndex + attempt) % _apiUrls.Count;
var baseUrl = endpointsToTry[urlIndex]; var baseUrl = _apiUrls[urlIndex];
try try
{ {
_logger.LogDebug("Trying {Service} endpoint {Endpoint} (attempt {Attempt}/{Total})", _logger.LogDebug("Trying {Service} endpoint {Endpoint} (attempt {Attempt}/{Total})",
_serviceName, baseUrl, attempt + 1, endpointsToTry.Count); _serviceName, baseUrl, attempt + 1, _apiUrls.Count);
return await action(baseUrl); return await action(baseUrl);
} }
catch (Exception ex) catch (Exception ex)
@@ -294,16 +179,10 @@ public class RoundRobinFallbackHelper
_logger.LogWarning(ex, "{Service} request failed with endpoint {Endpoint}, trying next...", _logger.LogWarning(ex, "{Service} request failed with endpoint {Endpoint}, trying next...",
_serviceName, baseUrl); _serviceName, baseUrl);
// Mark as unhealthy in cache if (attempt == _apiUrls.Count - 1)
lock (_healthCacheLock)
{
_healthCache[baseUrl] = (false, DateTime.UtcNow);
}
if (attempt == endpointsToTry.Count - 1)
{ {
_logger.LogError("All {Count} {Service} endpoints failed, returning default value", _logger.LogError("All {Count} {Service} endpoints failed, returning default value",
endpointsToTry.Count, _serviceName); _apiUrls.Count, _serviceName);
return defaultValue; return defaultValue;
} }
} }

View File

@@ -107,10 +107,10 @@ public class DeezerDownloadService : BaseDownloadService
// Build organized folder structure: Artist/Album/Track using AlbumArtist (fallback to Artist for singles) // Build organized folder structure: Artist/Album/Track using AlbumArtist (fallback to Artist for singles)
var artistForPath = song.AlbumArtist ?? song.Artist; var artistForPath = song.AlbumArtist ?? song.Artist;
// Cache mode uses downloads/cache/ folder, Permanent mode uses downloads/permanent/ // Cache mode uses cache/Music folder (cleaned up after 24h), Permanent mode uses downloads folder
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache var basePath = SubsonicSettings.StorageMode == StorageMode.Cache
? Path.Combine("downloads", "cache") ? Path.Combine("cache", "Music")
: Path.Combine("downloads", "permanent"); : "downloads";
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension); var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension);
// Create directories if they don't exist // Create directories if they don't exist

View File

@@ -384,23 +384,17 @@ public class DeezerMetadataService : IMusicMetadataService
} }
} }
// Contributors (all artists including features) // Contributors
var contributors = new List<string>(); var contributors = new List<string>();
var contributorIds = new List<string>();
if (track.TryGetProperty("contributors", out var contribs)) if (track.TryGetProperty("contributors", out var contribs))
{ {
foreach (var contrib in contribs.EnumerateArray()) foreach (var contrib in contribs.EnumerateArray())
{ {
if (contrib.TryGetProperty("name", out var contribName) && if (contrib.TryGetProperty("name", out var contribName))
contrib.TryGetProperty("id", out var contribId))
{ {
var name = contribName.GetString(); var name = contribName.GetString();
var id = contribId.GetInt64();
if (!string.IsNullOrEmpty(name)) if (!string.IsNullOrEmpty(name))
{
contributors.Add(name); contributors.Add(name);
contributorIds.Add($"ext-deezer-artist-{id}");
}
} }
} }
} }
@@ -443,8 +437,6 @@ public class DeezerMetadataService : IMusicMetadataService
ArtistId = track.TryGetProperty("artist", out var artistForId) ArtistId = track.TryGetProperty("artist", out var artistForId)
? $"ext-deezer-artist-{artistForId.GetProperty("id").GetInt64()}" ? $"ext-deezer-artist-{artistForId.GetProperty("id").GetInt64()}"
: null, : null,
Artists = contributors.Count > 0 ? contributors : new List<string>(),
ArtistIds = contributorIds.Count > 0 ? contributorIds : new List<string>(),
Album = track.TryGetProperty("album", out var album) Album = track.TryGetProperty("album", out var album)
? album.GetProperty("title").GetString() ?? "" ? album.GetProperty("title").GetString() ?? ""
: "", : "",

View File

@@ -168,11 +168,6 @@ public class JellyfinProxyService
(h.Value.ToString().Contains("image", StringComparison.OrdinalIgnoreCase) || (h.Value.ToString().Contains("image", StringComparison.OrdinalIgnoreCase) ||
h.Value.ToString().Contains("document", StringComparison.OrdinalIgnoreCase))) == true); h.Value.ToString().Contains("document", StringComparison.OrdinalIgnoreCase))) == true);
// Check if this is a public endpoint that doesn't require authentication
bool isPublicEndpoint = url.Contains("/System/Info/Public", StringComparison.OrdinalIgnoreCase) ||
url.Contains("/Branding/", StringComparison.OrdinalIgnoreCase) ||
url.Contains("/Startup/", StringComparison.OrdinalIgnoreCase);
// Forward authentication headers from client if provided // Forward authentication headers from client if provided
if (clientHeaders != null && clientHeaders.Count > 0) if (clientHeaders != null && clientHeaders.Count > 0)
{ {
@@ -184,27 +179,11 @@ public class JellyfinProxyService
var headerValue = header.Value.ToString(); var headerValue = header.Value.ToString();
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue); request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
authHeaderAdded = true; authHeaderAdded = true;
_logger.LogTrace("Forwarded X-Emby-Authorization header"); _logger.LogInformation("Forwarded X-Emby-Authorization: {Value}", headerValue);
break; break;
} }
} }
// Try X-Emby-Token (simpler format used by some clients)
if (!authHeaderAdded)
{
foreach (var header in clientHeaders)
{
if (header.Key.Equals("X-Emby-Token", StringComparison.OrdinalIgnoreCase))
{
var headerValue = header.Value.ToString();
request.Headers.TryAddWithoutValidation("X-Emby-Token", headerValue);
authHeaderAdded = true;
_logger.LogTrace("Forwarded X-Emby-Token header");
break;
}
}
}
// If no X-Emby-Authorization, check if Authorization header contains MediaBrowser format // If no X-Emby-Authorization, check if Authorization header contains MediaBrowser format
// Some clients send it as "Authorization" instead of "X-Emby-Authorization" // Some clients send it as "Authorization" instead of "X-Emby-Authorization"
if (!authHeaderAdded) if (!authHeaderAdded)
@@ -222,32 +201,37 @@ public class JellyfinProxyService
// Forward as X-Emby-Authorization (Jellyfin's expected header) // Forward as X-Emby-Authorization (Jellyfin's expected header)
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue); request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
authHeaderAdded = true; authHeaderAdded = true;
_logger.LogTrace("Converted Authorization to X-Emby-Authorization"); _logger.LogInformation("Converted Authorization to X-Emby-Authorization: {Value}", headerValue);
} }
else else
{ {
// Standard Bearer token - forward as-is // Standard Bearer token - forward as-is
request.Headers.TryAddWithoutValidation("Authorization", headerValue); request.Headers.TryAddWithoutValidation("Authorization", headerValue);
authHeaderAdded = true; authHeaderAdded = true;
_logger.LogTrace("Forwarded Authorization header"); _logger.LogInformation("Forwarded Authorization (Bearer): {Value}", headerValue);
} }
break; break;
} }
} }
} }
// Check for api_key query parameter (some clients use this) // Only log warnings for non-browser static requests
if (!authHeaderAdded && url.Contains("api_key=", StringComparison.OrdinalIgnoreCase)) if (!authHeaderAdded && !isBrowserStaticRequest)
{ {
authHeaderAdded = true; // It's in the URL, no need to add header _logger.LogWarning("✗ No auth header found. Available headers: {Headers}",
_logger.LogTrace("Using api_key from query string"); string.Join(", ", clientHeaders.Select(h => $"{h.Key}={h.Value}")));
} }
} }
else if (!isBrowserStaticRequest)
{
_logger.LogWarning("✗ No client headers provided for {Url}", url);
}
// Only log warnings for non-public, non-browser requests without auth // DO NOT use server API key as fallback - let Jellyfin handle unauthenticated requests
if (!authHeaderAdded && !isBrowserStaticRequest && !isPublicEndpoint) // If client doesn't provide auth, they get what they deserve (401 from Jellyfin)
if (!authHeaderAdded && !isBrowserStaticRequest)
{ {
_logger.LogDebug("No client auth provided for {Url} - Jellyfin will handle authentication", url); _logger.LogInformation("No client auth provided for {Url} - forwarding without auth", url);
} }
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
@@ -264,28 +248,14 @@ public class JellyfinProxyService
{ {
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{ {
// 401 means token expired or invalid - client needs to re-authenticate _logger.LogWarning("Jellyfin returned 401 Unauthorized for {Url} - passing through to client", url);
_logger.LogInformation("Jellyfin returned 401 Unauthorized for {Url} - client should re-authenticate", url);
} }
else if (!isBrowserStaticRequest && !isPublicEndpoint) else if (!isBrowserStaticRequest) // Don't log 404s for browser static requests
{ {
_logger.LogWarning("Jellyfin request failed: {StatusCode} for {Url}", response.StatusCode, url); _logger.LogWarning("Jellyfin request failed: {StatusCode} for {Url}", response.StatusCode, url);
} }
// Try to parse error response to pass through to client // Return null body with the actual status code
if (!string.IsNullOrWhiteSpace(content))
{
try
{
var errorDoc = JsonDocument.Parse(content);
return (errorDoc, statusCode);
}
catch
{
// Not valid JSON, return null
}
}
return (null, statusCode); return (null, statusCode);
} }
@@ -327,10 +297,8 @@ public class JellyfinProxyService
request.Content = new StringContent(bodyToSend, System.Text.Encoding.UTF8, "application/json"); request.Content = new StringContent(bodyToSend, System.Text.Encoding.UTF8, "application/json");
bool authHeaderAdded = false; bool authHeaderAdded = false;
bool isAuthEndpoint = endpoint.Contains("Authenticate", StringComparison.OrdinalIgnoreCase);
// Forward authentication headers from client (case-insensitive) // Forward authentication headers from client (case-insensitive)
// Try X-Emby-Authorization first
foreach (var header in clientHeaders) foreach (var header in clientHeaders)
{ {
if (header.Key.Equals("X-Emby-Authorization", StringComparison.OrdinalIgnoreCase)) if (header.Key.Equals("X-Emby-Authorization", StringComparison.OrdinalIgnoreCase))
@@ -338,28 +306,11 @@ public class JellyfinProxyService
var headerValue = header.Value.ToString(); var headerValue = header.Value.ToString();
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue); request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
authHeaderAdded = true; authHeaderAdded = true;
_logger.LogTrace("Forwarded X-Emby-Authorization header"); _logger.LogDebug("Forwarded X-Emby-Authorization from client");
break; break;
} }
} }
// Try X-Emby-Token
if (!authHeaderAdded)
{
foreach (var header in clientHeaders)
{
if (header.Key.Equals("X-Emby-Token", StringComparison.OrdinalIgnoreCase))
{
var headerValue = header.Value.ToString();
request.Headers.TryAddWithoutValidation("X-Emby-Token", headerValue);
authHeaderAdded = true;
_logger.LogTrace("Forwarded X-Emby-Token header");
break;
}
}
}
// Try Authorization header
if (!authHeaderAdded) if (!authHeaderAdded)
{ {
foreach (var header in clientHeaders) foreach (var header in clientHeaders)
@@ -374,13 +325,13 @@ public class JellyfinProxyService
{ {
// Forward as X-Emby-Authorization // Forward as X-Emby-Authorization
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue); request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
_logger.LogTrace("Converted Authorization to X-Emby-Authorization"); _logger.LogDebug("Converted Authorization to X-Emby-Authorization");
} }
else else
{ {
// Standard Bearer token // Standard Bearer token
request.Headers.TryAddWithoutValidation("Authorization", headerValue); request.Headers.TryAddWithoutValidation("Authorization", headerValue);
_logger.LogTrace("Forwarded Authorization header"); _logger.LogDebug("Forwarded Authorization header");
} }
authHeaderAdded = true; authHeaderAdded = true;
break; break;
@@ -388,23 +339,30 @@ public class JellyfinProxyService
} }
} }
// For authentication endpoints, credentials are in the body, not headers // DO NOT use server credentials as fallback
// For other endpoints without auth, let Jellyfin reject the request // Exception: For auth endpoints, client provides their own credentials in the body
if (!authHeaderAdded && !isAuthEndpoint) // For all other endpoints, if client doesn't provide auth, let Jellyfin reject it
if (!authHeaderAdded)
{ {
_logger.LogDebug("No client auth provided for POST {Url} - Jellyfin will handle authentication", url); _logger.LogInformation("No client auth provided for POST {Url} - forwarding without auth", url);
} }
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
// DO NOT log the body for auth endpoints - it contains passwords! // DO NOT log the body for auth endpoints - it contains passwords!
if (isAuthEndpoint) if (endpoint.Contains("Authenticate", StringComparison.OrdinalIgnoreCase))
{ {
_logger.LogDebug("POST to Jellyfin: {Url} (auth request - body not logged)", url); _logger.LogDebug("POST to Jellyfin: {Url} (auth request - body not logged)", url);
} }
else else
{ {
_logger.LogTrace("POST to Jellyfin: {Url}, body length: {Length} bytes", url, bodyToSend.Length); _logger.LogInformation("POST to Jellyfin: {Url}, body length: {Length} bytes", url, bodyToSend.Length);
// Log body content for playback endpoints to debug
if (endpoint.Contains("Playing", StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation("Sending body to Jellyfin: {Body}", bodyToSend);
}
} }
var response = await _httpClient.SendAsync(request); var response = await _httpClient.SendAsync(request);
@@ -414,17 +372,8 @@ public class JellyfinProxyService
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
var errorContent = await response.Content.ReadAsStringAsync(); var errorContent = await response.Content.ReadAsStringAsync();
_logger.LogWarning("❌ SESSION: Jellyfin POST request failed: {StatusCode} for {Url}. Response: {Response}",
// 401 is expected when tokens expire - don't spam logs response.StatusCode, url, errorContent);
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
_logger.LogInformation("Jellyfin POST returned 401 for {Url} - client should re-authenticate", url);
}
else
{
_logger.LogWarning("Jellyfin POST request failed: {StatusCode} for {Url}. Response: {Response}",
response.StatusCode, url, errorContent.Length > 200 ? errorContent[..200] + "..." : errorContent);
}
// Try to parse error response as JSON to pass through to client // Try to parse error response as JSON to pass through to client
if (!string.IsNullOrWhiteSpace(errorContent)) if (!string.IsNullOrWhiteSpace(errorContent))
@@ -446,7 +395,7 @@ public class JellyfinProxyService
// Log successful session-related responses // Log successful session-related responses
if (endpoint.Contains("Sessions", StringComparison.OrdinalIgnoreCase)) if (endpoint.Contains("Sessions", StringComparison.OrdinalIgnoreCase))
{ {
_logger.LogTrace("Jellyfin responded {StatusCode} for {Endpoint}", statusCode, endpoint); _logger.LogWarning("✓ SESSION: Jellyfin responded {StatusCode} for {Endpoint}", statusCode, endpoint);
} }
// Handle 204 No Content responses (e.g., /sessions/playing, /sessions/playing/progress) // Handle 204 No Content responses (e.g., /sessions/playing, /sessions/playing/progress)
@@ -463,6 +412,13 @@ public class JellyfinProxyService
return (null, statusCode); return (null, statusCode);
} }
// Log response content for session endpoints
if (endpoint.Contains("Sessions", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(responseContent))
{
var preview = responseContent.Length > 200 ? responseContent[..200] + "..." : responseContent;
_logger.LogWarning("📥 SESSION: Jellyfin response body: {Body}", preview);
}
return (JsonDocument.Parse(responseContent), statusCode); return (JsonDocument.Parse(responseContent), statusCode);
} }

View File

@@ -298,7 +298,6 @@ public class JellyfinResponseBuilder
["Key"] = $"Audio-{song.Id}", ["Key"] = $"Audio-{song.Id}",
["ItemId"] = song.Id ["ItemId"] = song.Id
}, },
<<<<<<< HEAD
["Artists"] = artistNames.Count > 0 ? artistNames.ToArray() : new[] { artistName ?? "" }, ["Artists"] = artistNames.Count > 0 ? artistNames.ToArray() : new[] { artistName ?? "" },
["ArtistItems"] = artistNames.Count > 0 ["ArtistItems"] = artistNames.Count > 0
? artistNames.Select((name, index) => new Dictionary<string, object?> ? artistNames.Select((name, index) => new Dictionary<string, object?>
@@ -339,47 +338,6 @@ public class JellyfinResponseBuilder
["MediaType"] = "Audio", ["MediaType"] = "Audio",
["NormalizationGain"] = 0.0, ["NormalizationGain"] = 0.0,
["Path"] = $"/music/{song.Artist}/{song.Album}/{song.Title}.flac", ["Path"] = $"/music/{song.Artist}/{song.Album}/{song.Title}.flac",
||||||| bc4e5d9
=======
["Artists"] = artistNames.Count > 0 ? artistNames.ToArray() : new[] { artistName ?? "" },
["ArtistItems"] = artistNames.Count > 0 && song.ArtistIds.Count == artistNames.Count
? artistNames.Select((name, index) => new Dictionary<string, object?>
{
["Name"] = name,
["Id"] = song.ArtistIds[index]
}).ToArray()
: new[]
{
new Dictionary<string, object?>
{
["Id"] = song.ArtistId ?? song.Id,
["Name"] = artistName ?? ""
}
},
["Album"] = albumName,
["AlbumId"] = song.AlbumId ?? song.Id,
["AlbumPrimaryImageTag"] = song.AlbumId ?? song.Id,
["AlbumArtist"] = song.AlbumArtist ?? artistName,
["AlbumArtists"] = new[]
{
new Dictionary<string, object?>
{
["Name"] = song.AlbumArtist ?? artistName ?? "",
["Id"] = song.ArtistId ?? song.Id
}
},
["ImageTags"] = new Dictionary<string, string>
{
["Primary"] = song.Id
},
["BackdropImageTags"] = new string[0],
["ParentLogoImageTag"] = song.AlbumId ?? song.Id,
["ImageBlurHashes"] = new Dictionary<string, object>(),
["LocationType"] = "FileSystem",
["MediaType"] = "Audio",
["NormalizationGain"] = 0.0,
["Path"] = $"/music/{song.Artist}/{song.Album}/{song.Title}.flac",
>>>>>>> dev
["CanDownload"] = true, ["CanDownload"] = true,
["SupportsSync"] = true ["SupportsSync"] = true
}; };

View File

@@ -1,4 +1,3 @@
<<<<<<< HEAD
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Net.WebSockets; using System.Net.WebSockets;
using System.Text; using System.Text;
@@ -39,13 +38,12 @@ public class JellyfinSessionManager : IDisposable
/// <summary> /// <summary>
/// Ensures a session exists for the given device. Creates one if needed. /// Ensures a session exists for the given device. Creates one if needed.
/// Returns false if token is expired (401), indicating client needs to re-authenticate.
/// </summary> /// </summary>
public async Task<bool> EnsureSessionAsync(string deviceId, string client, string device, string version, IHeaderDictionary headers) public async Task<bool> EnsureSessionAsync(string deviceId, string client, string device, string version, IHeaderDictionary headers)
{ {
if (string.IsNullOrEmpty(deviceId)) if (string.IsNullOrEmpty(deviceId))
{ {
_logger.LogWarning("Cannot create session - no device ID"); _logger.LogWarning("⚠️ SESSION: Cannot create session - no device ID");
return false; return false;
} }
@@ -53,37 +51,25 @@ public class JellyfinSessionManager : IDisposable
if (_sessions.TryGetValue(deviceId, out var existingSession)) if (_sessions.TryGetValue(deviceId, out var existingSession))
{ {
existingSession.LastActivity = DateTime.UtcNow; existingSession.LastActivity = DateTime.UtcNow;
_logger.LogTrace("Session already exists for device {DeviceId}", deviceId); _logger.LogDebug("✓ SESSION: Session already exists for device {DeviceId}", deviceId);
// Refresh capabilities to keep session alive // Refresh capabilities to keep session alive
// If this returns false (401), the token expired and client needs to re-auth await PostCapabilitiesAsync(headers);
var success = await PostCapabilitiesAsync(headers);
if (!success)
{
// Token expired - remove the stale session
_logger.LogInformation("Token expired for device {DeviceId} - removing session", deviceId);
await RemoveSessionAsync(deviceId);
return false;
}
return true; return true;
} }
_logger.LogDebug("Creating new session for device: {DeviceId} ({Client} on {Device})", deviceId, client, device); _logger.LogInformation("🔧 SESSION: Creating new session for device: {DeviceId} ({Client} on {Device})", deviceId, client, device);
// Log the headers we received for debugging
_logger.LogDebug("🔍 SESSION: Headers received for session creation: {Headers}",
string.Join(", ", headers.Select(h => $"{h.Key}={h.Value.ToString().Substring(0, Math.Min(30, h.Value.ToString().Length))}...")));
try try
{ {
// Post session capabilities to Jellyfin - this creates the session // Post session capabilities to Jellyfin - this creates the session
var success = await PostCapabilitiesAsync(headers); await PostCapabilitiesAsync(headers);
if (!success) _logger.LogInformation("✓ SESSION: Session created for {DeviceId}", deviceId);
{
// Token expired or invalid - client needs to re-authenticate
_logger.LogInformation("Failed to create session for {DeviceId} - token may be expired", deviceId);
return false;
}
_logger.LogDebug("Session created for {DeviceId}", deviceId);
// Track this session // Track this session
_sessions[deviceId] = new SessionInfo _sessions[deviceId] = new SessionInfo
@@ -103,16 +89,15 @@ public class JellyfinSessionManager : IDisposable
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error creating session for {DeviceId}", deviceId); _logger.LogError(ex, "❌ SESSION: Error creating session for {DeviceId}", deviceId);
return false; return false;
} }
} }
/// <summary> /// <summary>
/// Posts session capabilities to Jellyfin. /// Posts session capabilities to Jellyfin.
/// Returns true if successful, false if token expired (401).
/// </summary> /// </summary>
private async Task<bool> PostCapabilitiesAsync(IHeaderDictionary headers) private async Task PostCapabilitiesAsync(IHeaderDictionary headers)
{ {
var capabilities = new var capabilities = new
{ {
@@ -133,19 +118,12 @@ public class JellyfinSessionManager : IDisposable
if (statusCode == 204 || statusCode == 200) if (statusCode == 204 || statusCode == 200)
{ {
_logger.LogTrace("Posted capabilities successfully ({StatusCode})", statusCode); _logger.LogDebug("✓ SESSION: Posted capabilities successfully ({StatusCode})", statusCode);
return true;
}
else if (statusCode == 401)
{
// Token expired - this is expected, client needs to re-authenticate
_logger.LogDebug("Capabilities returned 401 (token expired) - client should re-authenticate");
return false;
} }
else else
{ {
_logger.LogDebug("Capabilities post returned {StatusCode}", statusCode); // 401 is common when cached headers have expired - not a critical error
return false; _logger.LogDebug("SESSION: Capabilities post returned {StatusCode} (may be expected if token expired)", statusCode);
} }
} }
@@ -200,7 +178,7 @@ public class JellyfinSessionManager : IDisposable
if (_sessions.TryGetValue(deviceId, out var currentSession) && if (_sessions.TryGetValue(deviceId, out var currentSession) &&
currentSession.LastActivity <= markedTime) currentSession.LastActivity <= markedTime)
{ {
_logger.LogDebug("🧹 SESSION: Auto-removing inactive session {DeviceId} after playback stop", deviceId); _logger.LogInformation("🧹 SESSION: Auto-removing inactive session {DeviceId} after playback stop", deviceId);
await RemoveSessionAsync(deviceId); await RemoveSessionAsync(deviceId);
} }
else else
@@ -245,7 +223,7 @@ public class JellyfinSessionManager : IDisposable
{ {
if (_sessions.TryRemove(deviceId, out var session)) if (_sessions.TryRemove(deviceId, out var session))
{ {
_logger.LogDebug("🗑️ SESSION: Removing session for device {DeviceId}", deviceId); _logger.LogInformation("🗑️ SESSION: Removing session for device {DeviceId}", deviceId);
// Close WebSocket if it exists // Close WebSocket if it exists
if (session.WebSocket != null && session.WebSocket.State == WebSocketState.Open) if (session.WebSocket != null && session.WebSocket.State == WebSocketState.Open)
@@ -257,7 +235,7 @@ public class JellyfinSessionManager : IDisposable
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogDebug(ex, "WEBSOCKET: Error closing WebSocket for {DeviceId}", deviceId); _logger.LogWarning(ex, "⚠️ WEBSOCKET: Error closing WebSocket for {DeviceId}", deviceId);
} }
finally finally
{ {
@@ -277,7 +255,7 @@ public class JellyfinSessionManager : IDisposable
}; };
var stopJson = JsonSerializer.Serialize(stopPayload); var stopJson = JsonSerializer.Serialize(stopPayload);
await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", stopJson, session.Headers); await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", stopJson, session.Headers);
_logger.LogDebug("🛑 SESSION: Reported playback stopped for {DeviceId} (ItemId: {ItemId}, Position: {Position})", _logger.LogInformation("🛑 SESSION: Reported playback stopped for {DeviceId} (ItemId: {ItemId}, Position: {Position})",
deviceId, session.LastPlayingItemId, session.LastPlayingPositionTicks); deviceId, session.LastPlayingItemId, session.LastPlayingPositionTicks);
} }
@@ -361,11 +339,11 @@ public class JellyfinSessionManager : IDisposable
if (!string.IsNullOrEmpty(_settings.ApiKey)) if (!string.IsNullOrEmpty(_settings.ApiKey))
{ {
jellyfinWsUrl += $"?api_key={_settings.ApiKey}"; jellyfinWsUrl += $"?api_key={_settings.ApiKey}";
_logger.LogDebug("WEBSOCKET: No client auth found in headers, falling back to server API key for {DeviceId}", deviceId); _logger.LogWarning("⚠️ WEBSOCKET: No client auth found in headers, falling back to server API key for {DeviceId}", deviceId);
} }
else else
{ {
_logger.LogWarning("❌ WEBSOCKET: No authentication available for {DeviceId} - WebSocket will fail", deviceId); _logger.LogWarning("❌ WEBSOCKET: No authentication available for {DeviceId}!", deviceId);
} }
} }
@@ -376,7 +354,7 @@ public class JellyfinSessionManager : IDisposable
// Connect to Jellyfin // Connect to Jellyfin
await webSocket.ConnectAsync(new Uri(jellyfinWsUrl), CancellationToken.None); await webSocket.ConnectAsync(new Uri(jellyfinWsUrl), CancellationToken.None);
_logger.LogDebug("✓ WEBSOCKET: Connected to Jellyfin for device {DeviceId}", deviceId); _logger.LogInformation("✓ WEBSOCKET: Connected to Jellyfin for device {DeviceId}", deviceId);
// CRITICAL: Send ForceKeepAlive message to initialize session in Jellyfin // CRITICAL: Send ForceKeepAlive message to initialize session in Jellyfin
// This tells Jellyfin to create/show the session in the dashboard // This tells Jellyfin to create/show the session in the dashboard
@@ -432,8 +410,8 @@ public class JellyfinSessionManager : IDisposable
} }
else else
{ {
// Log other message types at trace level // Log other message types at info level
_logger.LogTrace("📥 WEBSOCKET: {DeviceId}: {Message}", _logger.LogInformation("📥 WEBSOCKET: {DeviceId}: {Message}",
deviceId, message.Length > 100 ? message[..100] + "..." : message); deviceId, message.Length > 100 ? message[..100] + "..." : message);
} }
} }
@@ -455,7 +433,7 @@ public class JellyfinSessionManager : IDisposable
} }
catch (WebSocketException wsEx) catch (WebSocketException wsEx)
{ {
_logger.LogDebug(wsEx, "WEBSOCKET: Connection closed for device {DeviceId}", deviceId); _logger.LogWarning(wsEx, "⚠️ WEBSOCKET: WebSocket error for device {DeviceId}", deviceId);
break; break;
} }
} }
@@ -491,7 +469,6 @@ public class JellyfinSessionManager : IDisposable
/// <summary> /// <summary>
/// Periodically pings Jellyfin to keep sessions alive. /// Periodically pings Jellyfin to keep sessions alive.
/// Note: This is a backup mechanism. The WebSocket connection is the primary keep-alive. /// Note: This is a backup mechanism. The WebSocket connection is the primary keep-alive.
/// Removes sessions with expired tokens (401 responses).
/// </summary> /// </summary>
private async void KeepSessionsAlive(object? state) private async void KeepSessionsAlive(object? state)
{ {
@@ -503,43 +480,29 @@ public class JellyfinSessionManager : IDisposable
return; return;
} }
_logger.LogTrace("Keeping {Count} sessions alive", activeSessions.Count); _logger.LogDebug("💓 SESSION: Keeping {Count} sessions alive", activeSessions.Count);
var expiredSessions = new List<string>();
foreach (var session in activeSessions) foreach (var session in activeSessions)
{ {
try try
{ {
// Post capabilities again to keep session alive // Post capabilities again to keep session alive
// If this returns false (401), the token has expired // Note: This may fail with 401 if the client's token has expired
var success = await PostCapabilitiesAsync(session.Headers); // That's okay - the WebSocket connection keeps the session alive anyway
await PostCapabilitiesAsync(session.Headers);
if (!success)
{
_logger.LogInformation("Token expired for device {DeviceId} during keep-alive - marking for removal", session.DeviceId);
expiredSessions.Add(session.DeviceId);
}
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogDebug(ex, "Error keeping session alive for {DeviceId}", session.DeviceId); _logger.LogDebug(ex, "SESSION: Error keeping session alive for {DeviceId} (WebSocket still active)", session.DeviceId);
} }
} }
// Remove sessions with expired tokens
foreach (var deviceId in expiredSessions)
{
_logger.LogInformation("Removing session with expired token: {DeviceId}", deviceId);
await RemoveSessionAsync(deviceId);
}
// Clean up stale sessions after 3 minutes of inactivity // Clean up stale sessions after 3 minutes of inactivity
// This balances cleaning up finished sessions with allowing brief pauses/network issues // This balances cleaning up finished sessions with allowing brief pauses/network issues
var staleSessions = _sessions.Where(kvp => now - kvp.Value.LastActivity > TimeSpan.FromMinutes(3)).ToList(); var staleSessions = _sessions.Where(kvp => now - kvp.Value.LastActivity > TimeSpan.FromMinutes(3)).ToList();
foreach (var stale in staleSessions) foreach (var stale in staleSessions)
{ {
_logger.LogDebug("Removing stale session for {DeviceId} (inactive for {Minutes:F1} minutes)", _logger.LogInformation("🧹 SESSION: Removing stale session for {DeviceId} (inactive for {Minutes:F1} minutes)",
stale.Key, (now - stale.Value.LastActivity).TotalMinutes); stale.Key, (now - stale.Value.LastActivity).TotalMinutes);
await RemoveSessionAsync(stale.Key); await RemoveSessionAsync(stale.Key);
} }
@@ -590,604 +553,3 @@ public class JellyfinSessionManager : IDisposable
} }
} }
} }
||||||| bc4e5d9
=======
using System.Collections.Concurrent;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using allstarr.Models.Settings;
namespace allstarr.Services.Jellyfin;
/// <summary>
/// Manages Jellyfin sessions for connected clients.
/// Creates sessions on first playback and keeps them alive with periodic pings.
/// Also maintains server-side WebSocket connections to Jellyfin on behalf of clients.
/// </summary>
public class JellyfinSessionManager : IDisposable
{
private readonly JellyfinProxyService _proxyService;
private readonly JellyfinSettings _settings;
private readonly ILogger<JellyfinSessionManager> _logger;
private readonly ConcurrentDictionary<string, SessionInfo> _sessions = new();
private readonly Timer _keepAliveTimer;
public JellyfinSessionManager(
JellyfinProxyService proxyService,
IOptions<JellyfinSettings> settings,
ILogger<JellyfinSessionManager> logger)
{
_proxyService = proxyService;
_settings = settings.Value;
_logger = logger;
// Keep sessions alive every 10 seconds (Jellyfin considers sessions stale after ~15 seconds of inactivity)
_keepAliveTimer = new Timer(KeepSessionsAlive, null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10));
_logger.LogDebug("🔧 SESSION: JellyfinSessionManager initialized with 10-second keep-alive and WebSocket support");
}
/// <summary>
/// Ensures a session exists for the given device. Creates one if needed.
/// Returns false if token is expired (401), indicating client needs to re-authenticate.
/// </summary>
public async Task<bool> EnsureSessionAsync(string deviceId, string client, string device, string version, IHeaderDictionary headers)
{
if (string.IsNullOrEmpty(deviceId))
{
_logger.LogWarning("Cannot create session - no device ID");
return false;
}
// Check if we already have this session tracked
if (_sessions.TryGetValue(deviceId, out var existingSession))
{
existingSession.LastActivity = DateTime.UtcNow;
_logger.LogTrace("Session already exists for device {DeviceId}", deviceId);
// Refresh capabilities to keep session alive
// If this returns false (401), the token expired and client needs to re-auth
var success = await PostCapabilitiesAsync(headers);
if (!success)
{
// Token expired - remove the stale session
_logger.LogInformation("Token expired for device {DeviceId} - removing session", deviceId);
await RemoveSessionAsync(deviceId);
return false;
}
return true;
}
_logger.LogDebug("Creating new session for device: {DeviceId} ({Client} on {Device})", deviceId, client, device);
try
{
// Post session capabilities to Jellyfin - this creates the session
var success = await PostCapabilitiesAsync(headers);
if (!success)
{
// Token expired or invalid - client needs to re-authenticate
_logger.LogInformation("Failed to create session for {DeviceId} - token may be expired", deviceId);
return false;
}
_logger.LogDebug("Session created for {DeviceId}", deviceId);
// Track this session
var clientIp = headers["X-Forwarded-For"].FirstOrDefault()?.Split(',')[0].Trim()
?? headers["X-Real-IP"].FirstOrDefault()
?? "Unknown";
_sessions[deviceId] = new SessionInfo
{
DeviceId = deviceId,
Client = client,
Device = device,
Version = version,
LastActivity = DateTime.UtcNow,
Headers = CloneHeaders(headers),
ClientIp = clientIp
};
// Start a WebSocket connection to Jellyfin on behalf of this client
_ = Task.Run(() => MaintainWebSocketForSessionAsync(deviceId, headers));
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating session for {DeviceId}", deviceId);
return false;
}
}
/// <summary>
/// Posts session capabilities to Jellyfin.
/// Returns true if successful, false if token expired (401).
/// </summary>
private async Task<bool> PostCapabilitiesAsync(IHeaderDictionary headers)
{
var capabilities = new
{
PlayableMediaTypes = new[] { "Audio" },
SupportedCommands = new[]
{
"Play",
"Playstate",
"PlayNext"
},
SupportsMediaControl = true,
SupportsPersistentIdentifier = true,
SupportsSync = false
};
var json = JsonSerializer.Serialize(capabilities);
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Capabilities/Full", json, headers);
if (statusCode == 204 || statusCode == 200)
{
_logger.LogTrace("Posted capabilities successfully ({StatusCode})", statusCode);
return true;
}
else if (statusCode == 401)
{
// Token expired - this is expected, client needs to re-authenticate
_logger.LogDebug("Capabilities returned 401 (token expired) - client should re-authenticate");
return false;
}
else
{
_logger.LogDebug("Capabilities post returned {StatusCode}", statusCode);
return false;
}
}
/// <summary>
/// Updates session activity timestamp.
/// </summary>
public void UpdateActivity(string deviceId)
{
if (_sessions.TryGetValue(deviceId, out var session))
{
session.LastActivity = DateTime.UtcNow;
_logger.LogDebug("🔄 SESSION: Updated activity for {DeviceId}", deviceId);
}
else
{
_logger.LogDebug("⚠️ SESSION: Cannot update activity - device {DeviceId} not found", deviceId);
}
}
/// <summary>
/// Updates the currently playing item for a session (for scrobbling on cleanup).
/// </summary>
public void UpdatePlayingItem(string deviceId, string? itemId, long? positionTicks)
{
if (_sessions.TryGetValue(deviceId, out var session))
{
session.LastPlayingItemId = itemId;
session.LastPlayingPositionTicks = positionTicks;
session.LastActivity = DateTime.UtcNow;
_logger.LogDebug("🎵 SESSION: Updated playing item for {DeviceId}: {ItemId} at {Position}",
deviceId, itemId, positionTicks);
}
}
/// <summary>
/// Marks a session as potentially ended (e.g., after playback stops).
/// The session will be cleaned up if no new activity occurs within the timeout.
/// </summary>
public void MarkSessionPotentiallyEnded(string deviceId, TimeSpan timeout)
{
if (_sessions.TryGetValue(deviceId, out var session))
{
_logger.LogDebug("⏰ SESSION: Marking session {DeviceId} as potentially ended, will cleanup in {Seconds}s if no activity",
deviceId, timeout.TotalSeconds);
_ = Task.Run(async () =>
{
var markedTime = DateTime.UtcNow;
await Task.Delay(timeout);
// Check if there's been activity since we marked it
if (_sessions.TryGetValue(deviceId, out var currentSession) &&
currentSession.LastActivity <= markedTime)
{
_logger.LogDebug("🧹 SESSION: Auto-removing inactive session {DeviceId} after playback stop", deviceId);
await RemoveSessionAsync(deviceId);
}
else
{
_logger.LogDebug("✓ SESSION: Session {DeviceId} had activity, keeping alive", deviceId);
}
});
}
}
/// <summary>
/// Gets information about current active sessions for debugging.
/// </summary>
public object GetSessionsInfo()
{
var now = DateTime.UtcNow;
var sessions = _sessions.Values.Select(s => new
{
DeviceId = s.DeviceId,
Client = s.Client,
Device = s.Device,
Version = s.Version,
ClientIp = s.ClientIp,
LastActivity = s.LastActivity,
InactiveMinutes = Math.Round((now - s.LastActivity).TotalMinutes, 1),
HasWebSocket = s.WebSocket != null,
WebSocketState = s.WebSocket?.State.ToString() ?? "None"
}).ToList();
return new
{
TotalSessions = sessions.Count,
ActiveSessions = sessions.Count(s => s.InactiveMinutes < 2),
StaleSessions = sessions.Count(s => s.InactiveMinutes >= 2),
Sessions = sessions.OrderBy(s => s.InactiveMinutes)
};
}
/// <summary>
/// Removes a session when the client disconnects.
/// </summary>
public async Task RemoveSessionAsync(string deviceId)
{
if (_sessions.TryRemove(deviceId, out var session))
{
_logger.LogDebug("🗑️ SESSION: Removing session for device {DeviceId}", deviceId);
// Close WebSocket if it exists
if (session.WebSocket != null && session.WebSocket.State == WebSocketState.Open)
{
try
{
await session.WebSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Session ended", CancellationToken.None);
_logger.LogDebug("🔌 WEBSOCKET: Closed WebSocket for device {DeviceId}", deviceId);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "WEBSOCKET: Error closing WebSocket for {DeviceId}", deviceId);
}
finally
{
session.WebSocket?.Dispose();
}
}
try
{
// Report playback stopped to Jellyfin if we have a playing item (for scrobbling)
if (!string.IsNullOrEmpty(session.LastPlayingItemId))
{
var stopPayload = new
{
ItemId = session.LastPlayingItemId,
PositionTicks = session.LastPlayingPositionTicks ?? 0
};
var stopJson = JsonSerializer.Serialize(stopPayload);
await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", stopJson, session.Headers);
_logger.LogDebug("🛑 SESSION: Reported playback stopped for {DeviceId} (ItemId: {ItemId}, Position: {Position})",
deviceId, session.LastPlayingItemId, session.LastPlayingPositionTicks);
}
// Notify Jellyfin that the session is ending
await _proxyService.PostJsonAsync("Sessions/Logout", "{}", session.Headers);
}
catch (Exception ex)
{
_logger.LogWarning("⚠️ SESSION: Error removing session for {DeviceId}: {Message}", deviceId, ex.Message);
}
}
}
/// <summary>
/// Maintains a WebSocket connection to Jellyfin on behalf of a client session.
/// This allows the session to appear in Jellyfin's dashboard.
/// </summary>
private async Task MaintainWebSocketForSessionAsync(string deviceId, IHeaderDictionary headers)
{
if (!_sessions.TryGetValue(deviceId, out var session))
{
_logger.LogDebug("⚠️ WEBSOCKET: Cannot create WebSocket - session {DeviceId} not found", deviceId);
return;
}
ClientWebSocket? webSocket = null;
try
{
// Build Jellyfin WebSocket URL
var jellyfinUrl = _settings.Url?.TrimEnd('/') ?? "";
var wsScheme = jellyfinUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ? "wss://" : "ws://";
var jellyfinHost = jellyfinUrl.Replace("https://", "").Replace("http://", "");
var jellyfinWsUrl = $"{wsScheme}{jellyfinHost}/socket";
// IMPORTANT: Do NOT add api_key to URL - we want to authenticate as the CLIENT, not the server
// The client's token is passed via X-Emby-Authorization header
// Using api_key would create a session for the server/admin, not the actual user's client
webSocket = new ClientWebSocket();
session.WebSocket = webSocket;
// Use stored session headers instead of parameter (parameter might be disposed)
var sessionHeaders = session.Headers;
// Log available headers for debugging
_logger.LogDebug("🔍 WEBSOCKET: Available headers for {DeviceId}: {Headers}",
deviceId, string.Join(", ", sessionHeaders.Keys));
// Forward authentication headers from the CLIENT - this is critical for session to appear under the right user
bool authFound = false;
if (sessionHeaders.TryGetValue("X-Emby-Authorization", out var embyAuth))
{
webSocket.Options.SetRequestHeader("X-Emby-Authorization", embyAuth.ToString());
_logger.LogDebug("🔑 WEBSOCKET: Using X-Emby-Authorization for {DeviceId}: {Auth}",
deviceId, embyAuth.ToString().Length > 50 ? embyAuth.ToString()[..50] + "..." : embyAuth.ToString());
authFound = true;
}
else if (sessionHeaders.TryGetValue("Authorization", out var auth))
{
var authValue = auth.ToString();
if (authValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase))
{
webSocket.Options.SetRequestHeader("X-Emby-Authorization", authValue);
_logger.LogDebug("🔑 WEBSOCKET: Converted Authorization to X-Emby-Authorization for {DeviceId}: {Auth}",
deviceId, authValue.Length > 50 ? authValue[..50] + "..." : authValue);
authFound = true;
}
else
{
webSocket.Options.SetRequestHeader("Authorization", authValue);
_logger.LogDebug("🔑 WEBSOCKET: Using Authorization for {DeviceId}: {Auth}",
deviceId, authValue.Length > 50 ? authValue[..50] + "..." : authValue);
authFound = true;
}
}
if (!authFound)
{
// No client auth found - fall back to server API key as last resort
if (!string.IsNullOrEmpty(_settings.ApiKey))
{
jellyfinWsUrl += $"?api_key={_settings.ApiKey}";
_logger.LogDebug("WEBSOCKET: No client auth found in headers, falling back to server API key for {DeviceId}", deviceId);
}
else
{
_logger.LogWarning("❌ WEBSOCKET: No authentication available for {DeviceId} - WebSocket will fail", deviceId);
}
}
_logger.LogDebug("🔗 WEBSOCKET: Connecting to Jellyfin for device {DeviceId}: {Url}", deviceId, jellyfinWsUrl);
// Set user agent
webSocket.Options.SetRequestHeader("User-Agent", $"Allstarr-Proxy/{session.Client}");
// Connect to Jellyfin
await webSocket.ConnectAsync(new Uri(jellyfinWsUrl), CancellationToken.None);
_logger.LogDebug("✓ WEBSOCKET: Connected to Jellyfin for device {DeviceId}", deviceId);
// CRITICAL: Send ForceKeepAlive message to initialize session in Jellyfin
// This tells Jellyfin to create/show the session in the dashboard
// Without this message, the WebSocket is connected but no session appears
var forceKeepAliveMessage = "{\"MessageType\":\"ForceKeepAlive\",\"Data\":100}";
var messageBytes = Encoding.UTF8.GetBytes(forceKeepAliveMessage);
await webSocket.SendAsync(new ArraySegment<byte>(messageBytes), WebSocketMessageType.Text, true, CancellationToken.None);
_logger.LogDebug("📤 WEBSOCKET: Sent ForceKeepAlive to initialize session for {DeviceId}", deviceId);
// Also send SessionsStart to subscribe to session updates
var sessionsStartMessage = "{\"MessageType\":\"SessionsStart\",\"Data\":\"0,1500\"}";
messageBytes = Encoding.UTF8.GetBytes(sessionsStartMessage);
await webSocket.SendAsync(new ArraySegment<byte>(messageBytes), WebSocketMessageType.Text, true, CancellationToken.None);
_logger.LogDebug("📤 WEBSOCKET: Sent SessionsStart for {DeviceId}", deviceId);
// Keep the WebSocket alive by reading messages and sending periodic keep-alive
var buffer = new byte[1024 * 4];
var lastKeepAlive = DateTime.UtcNow;
using var cts = new CancellationTokenSource();
while (webSocket.State == WebSocketState.Open && _sessions.ContainsKey(deviceId))
{
try
{
// Use a timeout so we can send keep-alive messages periodically
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(30));
try
{
var result = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), timeoutCts.Token);
if (result.MessageType == WebSocketMessageType.Close)
{
_logger.LogDebug("🔌 WEBSOCKET: Jellyfin closed WebSocket for device {DeviceId}", deviceId);
break;
}
// Log received messages for debugging (only non-routine messages)
if (result.MessageType == WebSocketMessageType.Text)
{
var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
// Respond to KeepAlive requests from Jellyfin
if (message.Contains("\"MessageType\":\"KeepAlive\""))
{
_logger.LogDebug("💓 WEBSOCKET: Received KeepAlive from Jellyfin for {DeviceId}", deviceId);
}
else if (message.Contains("\"MessageType\":\"Sessions\""))
{
// Session updates are routine, log at debug level
_logger.LogDebug("📥 WEBSOCKET: Session update for {DeviceId}", deviceId);
}
else
{
// Log other message types at trace level
_logger.LogTrace("📥 WEBSOCKET: {DeviceId}: {Message}",
deviceId, message.Length > 100 ? message[..100] + "..." : message);
}
}
}
catch (OperationCanceledException) when (!cts.IsCancellationRequested)
{
// Timeout - this is expected, send keep-alive if needed
}
// Send periodic keep-alive every 30 seconds
if (DateTime.UtcNow - lastKeepAlive > TimeSpan.FromSeconds(30))
{
var keepAliveMsg = "{\"MessageType\":\"KeepAlive\"}";
var keepAliveBytes = Encoding.UTF8.GetBytes(keepAliveMsg);
await webSocket.SendAsync(new ArraySegment<byte>(keepAliveBytes), WebSocketMessageType.Text, true, CancellationToken.None);
_logger.LogDebug("💓 WEBSOCKET: Sent KeepAlive for {DeviceId}", deviceId);
lastKeepAlive = DateTime.UtcNow;
}
}
catch (WebSocketException wsEx)
{
_logger.LogDebug(wsEx, "WEBSOCKET: Connection closed for device {DeviceId}", deviceId);
break;
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "❌ WEBSOCKET: Failed to maintain WebSocket for device {DeviceId}", deviceId);
}
finally
{
if (webSocket != null)
{
if (webSocket.State == WebSocketState.Open)
{
try
{
await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Session ended", CancellationToken.None);
}
catch { }
}
webSocket.Dispose();
_logger.LogDebug("🧹 WEBSOCKET: Cleaned up WebSocket for device {DeviceId}", deviceId);
}
// Clear WebSocket reference from session
if (_sessions.TryGetValue(deviceId, out var sess))
{
sess.WebSocket = null;
}
}
}
/// <summary>
/// Periodically pings Jellyfin to keep sessions alive.
/// Note: This is a backup mechanism. The WebSocket connection is the primary keep-alive.
/// Removes sessions with expired tokens (401 responses).
/// </summary>
private async void KeepSessionsAlive(object? state)
{
var now = DateTime.UtcNow;
var activeSessions = _sessions.Values.Where(s => now - s.LastActivity < TimeSpan.FromMinutes(5)).ToList();
if (activeSessions.Count == 0)
{
return;
}
_logger.LogTrace("Keeping {Count} sessions alive", activeSessions.Count);
var expiredSessions = new List<string>();
foreach (var session in activeSessions)
{
try
{
// Post capabilities again to keep session alive
// If this returns false (401), the token has expired
var success = await PostCapabilitiesAsync(session.Headers);
if (!success)
{
_logger.LogInformation("Token expired for device {DeviceId} during keep-alive - marking for removal", session.DeviceId);
expiredSessions.Add(session.DeviceId);
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error keeping session alive for {DeviceId}", session.DeviceId);
}
}
// Remove sessions with expired tokens
foreach (var deviceId in expiredSessions)
{
_logger.LogInformation("Removing session with expired token: {DeviceId}", deviceId);
await RemoveSessionAsync(deviceId);
}
// Clean up stale sessions after 3 minutes of inactivity
// This balances cleaning up finished sessions with allowing brief pauses/network issues
var staleSessions = _sessions.Where(kvp => now - kvp.Value.LastActivity > TimeSpan.FromMinutes(3)).ToList();
foreach (var stale in staleSessions)
{
_logger.LogDebug("Removing stale session for {DeviceId} (inactive for {Minutes:F1} minutes)",
stale.Key, (now - stale.Value.LastActivity).TotalMinutes);
await RemoveSessionAsync(stale.Key);
}
}
private static IHeaderDictionary CloneHeaders(IHeaderDictionary headers)
{
var cloned = new HeaderDictionary();
foreach (var header in headers)
{
cloned[header.Key] = header.Value;
}
return cloned;
}
private class SessionInfo
{
public required string DeviceId { get; init; }
public required string Client { get; init; }
public required string Device { get; init; }
public required string Version { get; init; }
public DateTime LastActivity { get; set; }
public required IHeaderDictionary Headers { get; init; }
public ClientWebSocket? WebSocket { get; set; }
public string? LastPlayingItemId { get; set; }
public long? LastPlayingPositionTicks { get; set; }
public string? ClientIp { get; set; }
}
public void Dispose()
{
_keepAliveTimer?.Dispose();
// Close all WebSocket connections
foreach (var session in _sessions.Values)
{
if (session.WebSocket != null && session.WebSocket.State == WebSocketState.Open)
{
try
{
session.WebSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Service stopping", CancellationToken.None).Wait(TimeSpan.FromSeconds(5));
}
catch { }
finally
{
session.WebSocket?.Dispose();
}
}
}
}
}
>>>>>>> dev

View File

@@ -110,10 +110,10 @@ public class QobuzDownloadService : BaseDownloadService
// Build organized folder structure using AlbumArtist (fallback to Artist for singles) // Build organized folder structure using AlbumArtist (fallback to Artist for singles)
var artistForPath = song.AlbumArtist ?? song.Artist; var artistForPath = song.AlbumArtist ?? song.Artist;
// Cache mode uses downloads/cache/ folder, Permanent mode uses downloads/permanent/ // Cache mode uses cache/Music folder (cleaned up after 24h), Permanent mode uses downloads folder
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache var basePath = SubsonicSettings.StorageMode == StorageMode.Cache
? Path.Combine(DownloadPath, "cache") ? Path.Combine("cache", "Music")
: Path.Combine(DownloadPath, "permanent"); : "downloads";
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension); var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension);
var albumFolder = Path.GetDirectoryName(outputPath)!; var albumFolder = Path.GetDirectoryName(outputPath)!;

View File

@@ -1,4 +1,3 @@
<<<<<<< HEAD
using System.Net; using System.Net;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Text; using System.Text;
@@ -899,1072 +898,3 @@ public class SpotifyApiClient : IDisposable
public List<byte> Secret { get; set; } = new(); public List<byte> Secret { get; set; } = new();
} }
} }
||||||| bc4e5d9
=======
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using allstarr.Models.Settings;
using allstarr.Models.Spotify;
using Microsoft.Extensions.Options;
using OtpNet;
namespace allstarr.Services.Spotify;
/// <summary>
/// Client for accessing Spotify's APIs directly.
///
/// Supports two modes:
/// 1. Official API - For public playlists and standard operations
/// 2. Web API (with session cookie) - For editorial/personalized playlists like Release Radar, Discover Weekly
///
/// The session cookie (sp_dc) is required because Spotify's official API doesn't expose
/// algorithmically generated "Made For You" playlists.
///
/// Uses TOTP-based authentication similar to the Jellyfin Spotify Import plugin.
/// </summary>
public class SpotifyApiClient : IDisposable
{
private readonly ILogger<SpotifyApiClient> _logger;
private readonly SpotifyApiSettings _settings;
private readonly HttpClient _httpClient;
private readonly HttpClient _webApiClient;
private readonly CookieContainer _cookieContainer;
// Spotify API endpoints
private const string OfficialApiBase = "https://api.spotify.com/v1";
private const string WebApiBase = "https://api-partner.spotify.com/pathfinder/v1";
private const string SpotifyBaseUrl = "https://open.spotify.com";
private const string TokenEndpoint = "https://open.spotify.com/api/token";
// URL for pre-scraped TOTP secrets (same as Jellyfin plugin uses)
private const string TotpSecretsUrl = "https://raw.githubusercontent.com/xyloflake/spot-secrets-go/refs/heads/main/secrets/secretBytes.json";
// Web API access token (obtained via session cookie)
private string? _webAccessToken;
private DateTime _webTokenExpiry = DateTime.MinValue;
private readonly SemaphoreSlim _tokenLock = new(1, 1);
// Cached TOTP secrets
private TotpSecret? _cachedTotpSecret;
private DateTime _totpSecretFetchedAt = DateTime.MinValue;
public SpotifyApiClient(
ILogger<SpotifyApiClient> logger,
IOptions<SpotifyApiSettings> settings)
{
_logger = logger;
_settings = settings.Value;
// Client for official API
_httpClient = new HttpClient
{
BaseAddress = new Uri(OfficialApiBase),
Timeout = TimeSpan.FromSeconds(30)
};
// Client for web API (requires session cookie)
_cookieContainer = new CookieContainer();
var handler = new HttpClientHandler
{
UseCookies = true,
CookieContainer = _cookieContainer
};
if (!string.IsNullOrEmpty(_settings.SessionCookie))
{
_cookieContainer.SetCookies(
new Uri(SpotifyBaseUrl),
$"sp_dc={_settings.SessionCookie}");
}
_webApiClient = new HttpClient(handler)
{
Timeout = TimeSpan.FromSeconds(30)
};
// Common headers for web API
_webApiClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36");
_webApiClient.DefaultRequestHeaders.Add("Accept", "application/json");
_webApiClient.DefaultRequestHeaders.Add("Accept-Language", "en-US");
_webApiClient.DefaultRequestHeaders.Add("app-platform", "WebPlayer");
_webApiClient.DefaultRequestHeaders.Add("spotify-app-version", "1.2.46.25.g7f189073");
}
/// <summary>
/// Gets an access token using the session cookie and TOTP authentication.
/// This token can be used for both the official API and web API.
/// </summary>
public async Task<string?> GetWebAccessTokenAsync(CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(_settings.SessionCookie))
{
_logger.LogWarning("No Spotify session cookie configured");
return null;
}
await _tokenLock.WaitAsync(cancellationToken);
try
{
// Return cached token if still valid
if (!string.IsNullOrEmpty(_webAccessToken) && DateTime.UtcNow < _webTokenExpiry)
{
return _webAccessToken;
}
_logger.LogInformation("Fetching new Spotify web access token using TOTP authentication");
// Fetch TOTP secrets if needed
var totpSecret = await GetTotpSecretAsync(cancellationToken);
if (totpSecret == null)
{
_logger.LogError("Failed to get TOTP secrets");
return null;
}
// Generate TOTP
var totpResult = await GenerateTotpAsync(totpSecret, cancellationToken);
if (totpResult == null)
{
_logger.LogError("Failed to generate TOTP");
return null;
}
var (otp, serverTime) = totpResult.Value;
var clientTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// Build token URL with TOTP parameters
var tokenUrl = $"{TokenEndpoint}?reason=init&productType=web-player&totp={otp}&totpServer={otp}&totpVer={totpSecret.Version}&sTime={serverTime}&cTime={clientTime}";
_logger.LogDebug("Requesting token from: {Url}", tokenUrl.Replace(otp, "***"));
var response = await _webApiClient.GetAsync(tokenUrl, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogError("Failed to get Spotify access token: {StatusCode} - {Body}", response.StatusCode, errorBody);
return null;
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var tokenResponse = JsonSerializer.Deserialize<SpotifyTokenResponse>(json);
if (tokenResponse == null || string.IsNullOrEmpty(tokenResponse.AccessToken))
{
_logger.LogError("No access token in Spotify response: {Json}", json);
return null;
}
if (tokenResponse.IsAnonymous)
{
_logger.LogWarning("Spotify returned anonymous token - session cookie may be invalid");
}
_webAccessToken = tokenResponse.AccessToken;
// Token typically expires in 1 hour, but we'll refresh early
if (tokenResponse.ExpirationTimestampMs > 0)
{
_webTokenExpiry = DateTimeOffset.FromUnixTimeMilliseconds(tokenResponse.ExpirationTimestampMs).UtcDateTime;
// Refresh 5 minutes early
_webTokenExpiry = _webTokenExpiry.AddMinutes(-5);
}
else
{
_webTokenExpiry = DateTime.UtcNow.AddMinutes(55);
}
_logger.LogInformation("Obtained Spotify web access token, expires at {Expiry}, anonymous: {IsAnonymous}",
_webTokenExpiry, tokenResponse.IsAnonymous);
return _webAccessToken;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting Spotify web access token");
return null;
}
finally
{
_tokenLock.Release();
}
}
/// <summary>
/// Fetches TOTP secrets from the pre-scraped secrets repository.
/// </summary>
private async Task<TotpSecret?> GetTotpSecretAsync(CancellationToken cancellationToken)
{
// Return cached secret if fresh (cache for 1 hour)
if (_cachedTotpSecret != null && DateTime.UtcNow - _totpSecretFetchedAt < TimeSpan.FromHours(1))
{
return _cachedTotpSecret;
}
try
{
_logger.LogDebug("Fetching TOTP secrets from {Url}", TotpSecretsUrl);
var response = await _webApiClient.GetAsync(TotpSecretsUrl, cancellationToken);
if (!response.IsSuccessStatusCode)
{
_logger.LogError("Failed to fetch TOTP secrets: {StatusCode}", response.StatusCode);
return null;
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var secrets = JsonSerializer.Deserialize<TotpSecret[]>(json);
if (secrets == null || secrets.Length == 0)
{
_logger.LogError("No TOTP secrets found in response");
return null;
}
// Use the newest version
_cachedTotpSecret = secrets.OrderByDescending(s => s.Version).First();
_totpSecretFetchedAt = DateTime.UtcNow;
_logger.LogDebug("Got TOTP secret version {Version}", _cachedTotpSecret.Version);
return _cachedTotpSecret;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching TOTP secrets");
return null;
}
}
/// <summary>
/// Generates a TOTP code using the secret and server time.
/// Based on the Jellyfin plugin implementation.
/// </summary>
private async Task<(string Otp, long ServerTime)?> GenerateTotpAsync(TotpSecret secret, CancellationToken cancellationToken)
{
try
{
// Get server time from Spotify via HEAD request
var headRequest = new HttpRequestMessage(HttpMethod.Head, SpotifyBaseUrl);
var response = await _webApiClient.SendAsync(headRequest, cancellationToken);
if (!response.IsSuccessStatusCode)
{
_logger.LogError("Failed to get Spotify server time: {StatusCode}", response.StatusCode);
return null;
}
var serverTime = response.Headers.Date?.ToUnixTimeSeconds();
if (serverTime == null)
{
_logger.LogError("No Date header in Spotify response");
return null;
}
// Compute secret from cipher bytes
// The secret bytes need to be transformed: XOR each byte with ((index % 33) + 9)
var cipherBytes = secret.Secret.ToArray();
var transformedBytes = cipherBytes.Select((b, i) => (byte)(b ^ ((i % 33) + 9))).ToArray();
// Convert to UTF-8 string representation then back to bytes for TOTP
var transformedString = string.Join("", transformedBytes.Select(b => b.ToString()));
var utf8Bytes = Encoding.UTF8.GetBytes(transformedString);
// Generate TOTP
var totp = new Totp(utf8Bytes, step: 30, totpSize: 6);
var otp = totp.ComputeTotp(DateTime.UnixEpoch.AddSeconds(serverTime.Value));
_logger.LogDebug("Generated TOTP for server time {ServerTime}", serverTime.Value);
return (otp, serverTime.Value);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generating TOTP");
return null;
}
}
/// <summary>
/// Fetches a playlist with all its tracks from Spotify using the GraphQL API.
/// This matches the approach used by the Jellyfin Spotify Import plugin.
/// </summary>
/// <param name="playlistId">Spotify playlist ID or URI</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Playlist with tracks in correct order, or null if not found</returns>
public async Task<SpotifyPlaylist?> GetPlaylistAsync(string playlistId, CancellationToken cancellationToken = default)
{
// Extract ID from URI if needed (spotify:playlist:xxxxx or https://open.spotify.com/playlist/xxxxx)
playlistId = ExtractPlaylistId(playlistId);
var token = await GetWebAccessTokenAsync(cancellationToken);
if (string.IsNullOrEmpty(token))
{
_logger.LogError("Cannot fetch playlist without access token");
return null;
}
try
{
// Use GraphQL API (same as Jellyfin plugin) - more reliable and less rate-limited
return await FetchPlaylistViaGraphQLAsync(playlistId, token, cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching playlist {PlaylistId}", playlistId);
return null;
}
}
/// <summary>
/// Fetch playlist using Spotify's GraphQL API (api-partner.spotify.com/pathfinder/v1/query)
/// This is the same approach used by the Jellyfin Spotify Import plugin
/// </summary>
private async Task<SpotifyPlaylist?> FetchPlaylistViaGraphQLAsync(
string playlistId,
string token,
CancellationToken cancellationToken)
{
const int pageLimit = 50;
var offset = 0;
var totalTrackCount = pageLimit;
var tracks = new List<SpotifyPlaylistTrack>();
SpotifyPlaylist? playlist = null;
while (tracks.Count < totalTrackCount && offset < totalTrackCount)
{
if (cancellationToken.IsCancellationRequested) break;
// Build GraphQL query URL (same as Jellyfin plugin)
var queryParams = new Dictionary<string, string>
{
{ "operationName", "fetchPlaylist" },
{ "variables", $"{{\"uri\":\"spotify:playlist:{playlistId}\",\"offset\":{offset},\"limit\":{pageLimit}}}" },
{ "extensions", "{\"persistedQuery\":{\"version\":1,\"sha256Hash\":\"19ff1327c29e99c208c86d7a9d8f1929cfdf3d3202a0ff4253c821f1901aa94d\"}}" }
};
var queryString = string.Join("&", queryParams.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"));
var url = $"{WebApiBase}/query?{queryString}";
var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await _webApiClient.SendAsync(request, cancellationToken);
// Handle 429 rate limiting with exponential backoff
if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
{
var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(5);
_logger.LogWarning("Spotify rate limit hit (429) when fetching playlist {PlaylistId}. Waiting {Seconds}s before retry...", playlistId, retryAfter.TotalSeconds);
await Task.Delay(retryAfter, cancellationToken);
// Retry the request
response = await _webApiClient.SendAsync(request, cancellationToken);
}
if (!response.IsSuccessStatusCode)
{
_logger.LogError("Failed to fetch playlist via GraphQL: {StatusCode}", response.StatusCode);
return null;
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
using var doc = JsonDocument.Parse(json);
if (!doc.RootElement.TryGetProperty("data", out var data) ||
!data.TryGetProperty("playlistV2", out var playlistV2))
{
_logger.LogError("Invalid GraphQL response structure");
return null;
}
// Parse playlist metadata on first iteration
if (playlist == null)
{
playlist = ParseGraphQLPlaylist(playlistV2, playlistId);
if (playlist == null) return null;
}
// Parse tracks from this page
if (playlistV2.TryGetProperty("content", out var content))
{
if (content.TryGetProperty("totalCount", out var totalCount))
{
totalTrackCount = totalCount.GetInt32();
}
if (content.TryGetProperty("items", out var items))
{
foreach (var item in items.EnumerateArray())
{
var track = ParseGraphQLTrack(item, offset + tracks.Count);
if (track != null)
{
tracks.Add(track);
}
}
}
}
offset += pageLimit;
}
if (playlist != null)
{
playlist.Tracks = tracks;
playlist.TotalTracks = tracks.Count;
_logger.LogInformation("Fetched playlist '{Name}' with {Count} tracks via GraphQL", playlist.Name, tracks.Count);
}
return playlist;
}
private SpotifyPlaylist? ParseGraphQLPlaylist(JsonElement playlistV2, string playlistId)
{
try
{
var name = playlistV2.TryGetProperty("name", out var n) ? n.GetString() : "Unknown Playlist";
var description = playlistV2.TryGetProperty("description", out var d) ? d.GetString() : null;
string? ownerName = null;
if (playlistV2.TryGetProperty("ownerV2", out var owner) &&
owner.TryGetProperty("data", out var ownerData) &&
ownerData.TryGetProperty("name", out var ownerNameProp))
{
ownerName = ownerNameProp.GetString();
}
return new SpotifyPlaylist
{
SpotifyId = playlistId,
Name = name ?? "Unknown Playlist",
Description = description,
OwnerName = ownerName,
FetchedAt = DateTime.UtcNow,
Tracks = new List<SpotifyPlaylistTrack>()
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to parse GraphQL playlist metadata");
return null;
}
}
private SpotifyPlaylistTrack? ParseGraphQLTrack(JsonElement item, int position)
{
try
{
if (!item.TryGetProperty("itemV2", out var itemV2) ||
!itemV2.TryGetProperty("data", out var data))
{
return null;
}
var trackId = data.TryGetProperty("uri", out var uri) ? uri.GetString()?.Replace("spotify:track:", "") : null;
var name = data.TryGetProperty("name", out var n) ? n.GetString() : null;
if (string.IsNullOrEmpty(trackId) || string.IsNullOrEmpty(name))
{
return null;
}
// Parse artists
var artists = new List<string>();
if (data.TryGetProperty("artists", out var artistsObj) &&
artistsObj.TryGetProperty("items", out var artistItems))
{
foreach (var artist in artistItems.EnumerateArray())
{
if (artist.TryGetProperty("profile", out var profile) &&
profile.TryGetProperty("name", out var artistName))
{
var artistNameStr = artistName.GetString();
if (!string.IsNullOrEmpty(artistNameStr))
{
artists.Add(artistNameStr);
}
}
}
}
// Parse album
string? albumName = null;
if (data.TryGetProperty("albumOfTrack", out var album) &&
album.TryGetProperty("name", out var albumNameProp))
{
albumName = albumNameProp.GetString();
}
// Parse duration
int durationMs = 0;
if (data.TryGetProperty("trackDuration", out var duration) &&
duration.TryGetProperty("totalMilliseconds", out var durationMsProp))
{
durationMs = durationMsProp.GetInt32();
}
// Parse album art
string? albumArtUrl = null;
if (data.TryGetProperty("albumOfTrack", out var albumOfTrack) &&
albumOfTrack.TryGetProperty("coverArt", out var coverArt) &&
coverArt.TryGetProperty("sources", out var sources) &&
sources.GetArrayLength() > 0)
{
var firstSource = sources[0];
if (firstSource.TryGetProperty("url", out var urlProp))
{
albumArtUrl = urlProp.GetString();
}
}
return new SpotifyPlaylistTrack
{
SpotifyId = trackId,
Title = name,
Artists = artists,
Album = albumName ?? string.Empty,
DurationMs = durationMs,
Position = position,
AlbumArtUrl = albumArtUrl,
Isrc = null // GraphQL doesn't return ISRC, we'll fetch it separately if needed
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse GraphQL track");
return null;
}
}
private async Task<SpotifyPlaylist?> FetchPlaylistMetadataAsync(
string playlistId,
string token,
CancellationToken cancellationToken)
{
var url = $"{OfficialApiBase}/playlists/{playlistId}?fields=id,name,description,owner(display_name,id),images,collaborative,public,snapshot_id,tracks.total";
var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await _httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
_logger.LogError("Failed to fetch playlist metadata: {StatusCode}", response.StatusCode);
return null;
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var playlist = new SpotifyPlaylist
{
SpotifyId = root.GetProperty("id").GetString() ?? playlistId,
Name = root.GetProperty("name").GetString() ?? "Unknown Playlist",
Description = root.TryGetProperty("description", out var desc) ? desc.GetString() : null,
SnapshotId = root.TryGetProperty("snapshot_id", out var snap) ? snap.GetString() : null,
Collaborative = root.TryGetProperty("collaborative", out var collab) && collab.GetBoolean(),
Public = root.TryGetProperty("public", out var pub) && pub.ValueKind != JsonValueKind.Null && pub.GetBoolean(),
FetchedAt = DateTime.UtcNow
};
if (root.TryGetProperty("owner", out var owner))
{
playlist.OwnerName = owner.TryGetProperty("display_name", out var dn) ? dn.GetString() : null;
playlist.OwnerId = owner.TryGetProperty("id", out var oid) ? oid.GetString() : null;
}
if (root.TryGetProperty("images", out var images) && images.GetArrayLength() > 0)
{
playlist.ImageUrl = images[0].GetProperty("url").GetString();
}
if (root.TryGetProperty("tracks", out var tracks) && tracks.TryGetProperty("total", out var total))
{
playlist.TotalTracks = total.GetInt32();
}
return playlist;
}
private async Task<List<SpotifyPlaylistTrack>> FetchAllPlaylistTracksAsync(
string playlistId,
string token,
CancellationToken cancellationToken)
{
var allTracks = new List<SpotifyPlaylistTrack>();
var offset = 0;
const int limit = 100; // Spotify's max
while (true)
{
var tracks = await FetchPlaylistTracksPageAsync(playlistId, token, offset, limit, cancellationToken);
if (tracks == null || tracks.Count == 0) break;
allTracks.AddRange(tracks);
if (tracks.Count < limit) break;
offset += limit;
// Rate limiting
if (_settings.RateLimitDelayMs > 0)
{
await Task.Delay(_settings.RateLimitDelayMs, cancellationToken);
}
}
return allTracks;
}
private async Task<List<SpotifyPlaylistTrack>?> FetchPlaylistTracksPageAsync(
string playlistId,
string token,
int offset,
int limit,
CancellationToken cancellationToken)
{
// Request fields needed for matching and ordering
var fields = "items(added_at,track(id,name,album(id,name,images,release_date),artists(id,name),duration_ms,explicit,popularity,preview_url,disc_number,track_number,external_ids))";
var url = $"{OfficialApiBase}/playlists/{playlistId}/tracks?offset={offset}&limit={limit}&fields={fields}";
var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await _httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
_logger.LogError("Failed to fetch playlist tracks: {StatusCode}", response.StatusCode);
return null;
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (!root.TryGetProperty("items", out var items))
{
return new List<SpotifyPlaylistTrack>();
}
var tracks = new List<SpotifyPlaylistTrack>();
var position = offset;
foreach (var item in items.EnumerateArray())
{
// Skip null tracks (can happen with deleted/unavailable tracks)
if (!item.TryGetProperty("track", out var trackElement) ||
trackElement.ValueKind == JsonValueKind.Null)
{
position++;
continue;
}
var track = ParseTrack(trackElement, position);
// Parse added_at timestamp
if (item.TryGetProperty("added_at", out var addedAt) &&
addedAt.ValueKind != JsonValueKind.Null)
{
var addedAtStr = addedAt.GetString();
if (DateTime.TryParse(addedAtStr, out var addedAtDate))
{
track.AddedAt = addedAtDate;
}
}
tracks.Add(track);
position++;
}
return tracks;
}
private SpotifyPlaylistTrack ParseTrack(JsonElement track, int position)
{
var result = new SpotifyPlaylistTrack
{
Position = position,
SpotifyId = track.TryGetProperty("id", out var id) ? id.GetString() ?? "" : "",
Title = track.TryGetProperty("name", out var name) ? name.GetString() ?? "" : "",
DurationMs = track.TryGetProperty("duration_ms", out var dur) ? dur.GetInt32() : 0,
Explicit = track.TryGetProperty("explicit", out var exp) && exp.GetBoolean(),
Popularity = track.TryGetProperty("popularity", out var pop) ? pop.GetInt32() : 0,
PreviewUrl = track.TryGetProperty("preview_url", out var prev) && prev.ValueKind != JsonValueKind.Null
? prev.GetString() : null,
DiscNumber = track.TryGetProperty("disc_number", out var disc) ? disc.GetInt32() : 1,
TrackNumber = track.TryGetProperty("track_number", out var tn) ? tn.GetInt32() : 1
};
// Parse album
if (track.TryGetProperty("album", out var album))
{
result.Album = album.TryGetProperty("name", out var albumName)
? albumName.GetString() ?? "" : "";
result.AlbumId = album.TryGetProperty("id", out var albumId)
? albumId.GetString() ?? "" : "";
result.ReleaseDate = album.TryGetProperty("release_date", out var rd)
? rd.GetString() : null;
if (album.TryGetProperty("images", out var images) && images.GetArrayLength() > 0)
{
result.AlbumArtUrl = images[0].GetProperty("url").GetString();
}
}
// Parse artists
if (track.TryGetProperty("artists", out var artists))
{
foreach (var artist in artists.EnumerateArray())
{
if (artist.TryGetProperty("name", out var artistName))
{
result.Artists.Add(artistName.GetString() ?? "");
}
if (artist.TryGetProperty("id", out var artistId))
{
result.ArtistIds.Add(artistId.GetString() ?? "");
}
}
}
// Parse ISRC from external_ids
if (track.TryGetProperty("external_ids", out var externalIds) &&
externalIds.TryGetProperty("isrc", out var isrc))
{
result.Isrc = isrc.GetString();
}
return result;
}
/// <summary>
/// Searches for a user's playlists by name.
/// Useful for finding playlists like "Release Radar" or "Discover Weekly" by their names.
/// </summary>
public async Task<List<SpotifyPlaylist>> SearchUserPlaylistsAsync(
string searchName,
CancellationToken cancellationToken = default)
{
return await GetUserPlaylistsAsync(searchName, cancellationToken);
}
/// <summary>
/// Gets all playlists from the user's library, optionally filtered by name.
/// Uses GraphQL API which is less rate-limited than REST API.
/// </summary>
/// <param name="searchName">Optional name filter (case-insensitive). If null, returns all playlists.</param>
public async Task<List<SpotifyPlaylist>> GetUserPlaylistsAsync(
string? searchName = null,
CancellationToken cancellationToken = default)
{
var token = await GetWebAccessTokenAsync(cancellationToken);
if (string.IsNullOrEmpty(token))
{
return new List<SpotifyPlaylist>();
}
try
{
// Use GraphQL endpoint instead of REST API to avoid rate limiting
// GraphQL is less aggressive with rate limits
var playlists = new List<SpotifyPlaylist>();
var offset = 0;
const int limit = 50;
while (true)
{
// GraphQL query to fetch user playlists - using libraryV3 operation
var queryParams = new Dictionary<string, string>
{
{ "operationName", "libraryV3" },
{ "variables", $"{{\"filters\":[\"Playlists\",\"By Spotify\"],\"order\":null,\"textFilter\":\"\",\"features\":[\"LIKED_SONGS\",\"YOUR_EPISODES\"],\"offset\":{offset},\"limit\":{limit}}}" },
{ "extensions", "{\"persistedQuery\":{\"version\":1,\"sha256Hash\":\"50650f72ea32a99b5b46240bee22fea83024eec302478a9a75cfd05a0814ba99\"}}" }
};
var queryString = string.Join("&", queryParams.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"));
var url = $"{WebApiBase}/query?{queryString}";
var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await _webApiClient.SendAsync(request, cancellationToken);
// Handle 429 rate limiting with exponential backoff
if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
{
var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(5);
_logger.LogWarning("Spotify rate limit hit (429) when fetching library playlists. Waiting {Seconds}s before retry...", retryAfter.TotalSeconds);
await Task.Delay(retryAfter, cancellationToken);
// Retry the request
response = await _httpClient.SendAsync(request, cancellationToken);
}
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("GraphQL user playlists request failed: {StatusCode}", response.StatusCode);
break;
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (!root.TryGetProperty("data", out var data) ||
!data.TryGetProperty("me", out var me) ||
!me.TryGetProperty("libraryV3", out var library) ||
!library.TryGetProperty("items", out var items))
{
break;
}
// Get total count
if (library.TryGetProperty("totalCount", out var totalCount))
{
var total = totalCount.GetInt32();
if (total == 0) break;
}
var itemCount = 0;
foreach (var item in items.EnumerateArray())
{
itemCount++;
if (!item.TryGetProperty("item", out var playlistItem) ||
!playlistItem.TryGetProperty("data", out var playlist))
{
continue;
}
// Check __typename to filter out folders and only include playlists
if (playlistItem.TryGetProperty("__typename", out var typename))
{
var typeStr = typename.GetString();
// Skip folders - only process Playlist types
if (typeStr != null && typeStr.Contains("Folder", StringComparison.OrdinalIgnoreCase))
{
continue;
}
}
// Get playlist URI/ID
string? uri = null;
if (playlistItem.TryGetProperty("uri", out var uriProp))
{
uri = uriProp.GetString();
}
else if (playlistItem.TryGetProperty("_uri", out var uriProp2))
{
uri = uriProp2.GetString();
}
if (string.IsNullOrEmpty(uri)) continue;
// Skip if not a playlist URI (e.g., folders have different URI format)
if (!uri.StartsWith("spotify:playlist:", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var spotifyId = uri.Replace("spotify:playlist:", "", StringComparison.OrdinalIgnoreCase);
var itemName = playlist.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
// Check if name matches (case-insensitive) - if searchName is provided
if (!string.IsNullOrEmpty(searchName) &&
!itemName.Contains(searchName, StringComparison.OrdinalIgnoreCase))
{
continue;
}
// Get track count if available - try multiple possible paths
var trackCount = 0;
if (playlist.TryGetProperty("content", out var content))
{
if (content.TryGetProperty("totalCount", out var totalTrackCount))
{
trackCount = totalTrackCount.GetInt32();
}
}
// Fallback: try attributes.itemCount
else if (playlist.TryGetProperty("attributes", out var attributes) &&
attributes.TryGetProperty("itemCount", out var itemCountProp))
{
trackCount = itemCountProp.GetInt32();
}
// Fallback: try totalCount directly
else if (playlist.TryGetProperty("totalCount", out var directTotalCount))
{
trackCount = directTotalCount.GetInt32();
}
// Log if we couldn't find track count for debugging
if (trackCount == 0)
{
_logger.LogDebug("Could not find track count for playlist {Name} (ID: {Id}). Response structure: {Json}",
itemName, spotifyId, playlist.GetRawText());
}
// Get owner name
string? ownerName = null;
if (playlist.TryGetProperty("ownerV2", out var ownerV2) &&
ownerV2.TryGetProperty("data", out var ownerData) &&
ownerData.TryGetProperty("username", out var ownerNameProp))
{
ownerName = ownerNameProp.GetString();
}
// Get image URL
string? imageUrl = null;
if (playlist.TryGetProperty("images", out var images) &&
images.TryGetProperty("items", out var imageItems) &&
imageItems.GetArrayLength() > 0)
{
var firstImage = imageItems[0];
if (firstImage.TryGetProperty("sources", out var sources) &&
sources.GetArrayLength() > 0)
{
var firstSource = sources[0];
if (firstSource.TryGetProperty("url", out var urlProp))
{
imageUrl = urlProp.GetString();
}
}
}
playlists.Add(new SpotifyPlaylist
{
SpotifyId = spotifyId,
Name = itemName,
Description = playlist.TryGetProperty("description", out var desc) ? desc.GetString() : null,
TotalTracks = trackCount,
OwnerName = ownerName,
ImageUrl = imageUrl,
SnapshotId = null
});
}
if (itemCount < limit) break;
offset += limit;
// Add delay between pages to avoid rate limiting
// Library fetching can be aggressive, so use a longer delay
var delayMs = Math.Max(_settings.RateLimitDelayMs, 500); // Minimum 500ms between pages
_logger.LogDebug("Waiting {DelayMs}ms before fetching next page of library playlists...", delayMs);
await Task.Delay(delayMs, cancellationToken);
}
_logger.LogInformation("Found {Count} playlists{Filter} via GraphQL",
playlists.Count,
string.IsNullOrEmpty(searchName) ? "" : $" matching '{searchName}'");
return playlists;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching user playlists{Filter} via GraphQL",
string.IsNullOrEmpty(searchName) ? "" : $" matching '{searchName}'");
return new List<SpotifyPlaylist>();
}
}
/// <summary>
/// Gets the current user's profile to verify authentication is working.
/// </summary>
public async Task<(bool Success, string? UserId, string? DisplayName)> GetCurrentUserAsync(
CancellationToken cancellationToken = default)
{
var token = await GetWebAccessTokenAsync(cancellationToken);
if (string.IsNullOrEmpty(token))
{
return (false, null, null);
}
try
{
var request = new HttpRequestMessage(HttpMethod.Get, $"{OfficialApiBase}/me");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await _httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogWarning("Spotify /me endpoint returned {StatusCode}: {Body}", response.StatusCode, errorBody);
return (false, null, null);
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var userId = root.TryGetProperty("id", out var id) ? id.GetString() : null;
var displayName = root.TryGetProperty("display_name", out var dn) ? dn.GetString() : null;
return (true, userId, displayName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting current Spotify user");
return (false, null, null);
}
}
private static string ExtractPlaylistId(string input)
{
if (string.IsNullOrEmpty(input)) return input;
// Handle spotify:playlist:xxxxx format
if (input.StartsWith("spotify:playlist:"))
{
return input.Substring("spotify:playlist:".Length);
}
// Handle https://open.spotify.com/playlist/xxxxx format
if (input.Contains("open.spotify.com/playlist/"))
{
var start = input.IndexOf("/playlist/") + "/playlist/".Length;
var end = input.IndexOf('?', start);
return end > 0 ? input.Substring(start, end - start) : input.Substring(start);
}
return input;
}
public void Dispose()
{
_httpClient.Dispose();
_webApiClient.Dispose();
_tokenLock.Dispose();
}
// Internal classes for JSON deserialization
private class SpotifyTokenResponse
{
[JsonPropertyName("accessToken")]
public string AccessToken { get; set; } = string.Empty;
[JsonPropertyName("accessTokenExpirationTimestampMs")]
public long ExpirationTimestampMs { get; set; }
[JsonPropertyName("isAnonymous")]
public bool IsAnonymous { get; set; }
[JsonPropertyName("clientId")]
public string ClientId { get; set; } = string.Empty;
}
private class TotpSecret
{
[JsonPropertyName("version")]
public int Version { get; set; }
[JsonPropertyName("secret")]
public List<byte> Secret { get; set; } = new();
}
}
>>>>>>> dev

View File

@@ -44,7 +44,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
public async Task TriggerFetchAsync() public async Task TriggerFetchAsync()
{ {
_logger.LogInformation("Manual fetch triggered"); _logger.LogInformation("Manual fetch triggered");
await FetchMissingTracksAsync(CancellationToken.None); await FetchMissingTracksAsync(CancellationToken.None, bypassSyncWindowCheck: true);
} }
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -84,6 +84,19 @@ public class SpotifyMissingTracksFetcher : BackgroundService
_logger.LogInformation("Spotify Import ENABLED"); _logger.LogInformation("Spotify Import ENABLED");
_logger.LogInformation("Configured Playlists: {Count}", _spotifySettings.Value.Playlists.Count); _logger.LogInformation("Configured Playlists: {Count}", _spotifySettings.Value.Playlists.Count);
// Log the search schedule
var settings = _spotifySettings.Value;
var syncTime = DateTime.Today
.AddHours(settings.SyncStartHour)
.AddMinutes(settings.SyncStartMinute);
var syncEndTime = syncTime.AddHours(settings.SyncWindowHours);
_logger.LogInformation("Search Schedule:");
_logger.LogInformation(" Plugin sync time: {Time:HH:mm} UTC (configured)", syncTime);
_logger.LogInformation(" Search window: {Start:HH:mm} - {End:HH:mm} UTC ({Hours}h window)",
syncTime, syncEndTime, settings.SyncWindowHours);
_logger.LogInformation(" Will search for new files once per day after sync window ends");
_logger.LogInformation(" Background check interval: 5 minutes"); _logger.LogInformation(" Background check interval: 5 minutes");
// Fetch playlist names from Jellyfin // Fetch playlist names from Jellyfin
@@ -96,7 +109,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
} }
_logger.LogInformation("========================================"); _logger.LogInformation("========================================");
// Run on startup if we don't have cache // Check if we should run on startup
if (!_hasRunOnce) if (!_hasRunOnce)
{ {
var shouldRun = await ShouldRunOnStartupAsync(); var shouldRun = await ShouldRunOnStartupAsync();
@@ -105,7 +118,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
_logger.LogInformation("Running initial fetch on startup"); _logger.LogInformation("Running initial fetch on startup");
try try
{ {
await FetchMissingTracksAsync(stoppingToken); await FetchMissingTracksAsync(stoppingToken, bypassSyncWindowCheck: true);
_hasRunOnce = true; _hasRunOnce = true;
} }
catch (Exception ex) catch (Exception ex)
@@ -115,20 +128,21 @@ public class SpotifyMissingTracksFetcher : BackgroundService
} }
else else
{ {
_logger.LogInformation("Skipping startup fetch - already have cached files"); _logger.LogInformation("Skipping startup fetch - already have current files");
_hasRunOnce = true; _hasRunOnce = true;
} }
} }
// Background loop - check for new files every 5 minutes
while (!stoppingToken.IsCancellationRequested) while (!stoppingToken.IsCancellationRequested)
{ {
try try
{ {
// Only fetch if we're past today's sync window AND we haven't fetched today yet
var shouldFetch = await ShouldFetchNowAsync(); var shouldFetch = await ShouldFetchNowAsync();
if (shouldFetch) if (shouldFetch)
{ {
await FetchMissingTracksAsync(stoppingToken); await FetchMissingTracksAsync(stoppingToken);
_hasRunOnce = true;
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -142,29 +156,42 @@ public class SpotifyMissingTracksFetcher : BackgroundService
private async Task<bool> ShouldFetchNowAsync() private async Task<bool> ShouldFetchNowAsync()
{ {
// Check if we have recent cache files (within last 24 hours) var settings = _spotifySettings.Value;
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var cacheThreshold = now.AddHours(-24);
// Calculate today's sync window
var todaySync = now.Date
.AddHours(settings.SyncStartHour)
.AddMinutes(settings.SyncStartMinute);
var todaySyncEnd = todaySync.AddHours(settings.SyncWindowHours);
// Only fetch if we're past today's sync window
if (now < todaySyncEnd)
{
return false;
}
// Check if we already have today's files
foreach (var playlistName in _playlistIdToName.Values) foreach (var playlistName in _playlistIdToName.Values)
{ {
var filePath = GetCacheFilePath(playlistName); var filePath = GetCacheFilePath(playlistName);
if (!File.Exists(filePath)) if (File.Exists(filePath))
{ {
// Missing cache file for this playlist
return true;
}
var fileTime = File.GetLastWriteTimeUtc(filePath); var fileTime = File.GetLastWriteTimeUtc(filePath);
if (fileTime < cacheThreshold)
// If file is from today's sync or later, we already have it
if (fileTime >= todaySync)
{ {
// Cache file is older than 24 hours continue;
return true;
} }
} }
// All playlists have recent cache files // Missing today's file for this playlist
return true;
}
// All playlists have today's files
return false; return false;
} }
@@ -183,6 +210,25 @@ public class SpotifyMissingTracksFetcher : BackgroundService
{ {
_logger.LogInformation("=== STARTUP CACHE CHECK ==="); _logger.LogInformation("=== STARTUP CACHE CHECK ===");
var settings = _spotifySettings.Value;
var now = DateTime.UtcNow;
// Calculate today's sync window
var todaySync = now.Date
.AddHours(settings.SyncStartHour)
.AddMinutes(settings.SyncStartMinute);
var todaySyncEnd = todaySync.AddHours(settings.SyncWindowHours);
_logger.LogInformation("Today's sync window: {Start:yyyy-MM-dd HH:mm} - {End:yyyy-MM-dd HH:mm} UTC",
todaySync, todaySyncEnd);
_logger.LogInformation("Current time: {Now:yyyy-MM-dd HH:mm} UTC", now);
// If we're still before today's sync window end, we should have yesterday's or today's file
// Don't search again until after today's sync window ends
if (now < todaySyncEnd)
{
_logger.LogInformation("We're before today's sync window end - checking if we have recent cache...");
var allPlaylistsHaveCache = true; var allPlaylistsHaveCache = true;
foreach (var playlistName in _playlistIdToName.Values) foreach (var playlistName in _playlistIdToName.Values)
@@ -219,8 +265,66 @@ public class SpotifyMissingTracksFetcher : BackgroundService
if (allPlaylistsHaveCache) if (allPlaylistsHaveCache)
{ {
_logger.LogInformation("=== ALL PLAYLISTS HAVE CACHE - SKIPPING STARTUP FETCH ==="); _logger.LogInformation("=== ALL PLAYLISTS HAVE CACHE - SKIPPING STARTUP FETCH ===");
_logger.LogInformation("Will search again after {Time:yyyy-MM-dd HH:mm} UTC", todaySyncEnd);
return false; return false;
} }
}
// If we're after today's sync window end, check if we already have today's file
if (now >= todaySyncEnd)
{
_logger.LogInformation("We're after today's sync window end - checking if we already fetched today's files...");
var allPlaylistsHaveTodaysFile = true;
foreach (var playlistName in _playlistIdToName.Values)
{
var filePath = GetCacheFilePath(playlistName);
var cacheKey = $"spotify:missing:{playlistName}";
// Check if file exists and was created today (after sync start)
if (File.Exists(filePath))
{
var fileTime = File.GetLastWriteTimeUtc(filePath);
// File should be from today's sync window or later
if (fileTime >= todaySync)
{
var fileAge = DateTime.UtcNow - fileTime;
_logger.LogInformation(" {Playlist}: Have today's file (created {Time:yyyy-MM-dd HH:mm}, age: {Age:F1}h)",
playlistName, fileTime, fileAge.TotalHours);
// Load into Redis if not already there
if (!await _cache.ExistsAsync(cacheKey))
{
await LoadFromFileCache(playlistName);
}
continue;
}
else
{
_logger.LogInformation(" {Playlist}: File is old (from {Time:yyyy-MM-dd HH:mm}, before today's sync)",
playlistName, fileTime);
}
}
else
{
_logger.LogInformation(" {Playlist}: No file found", playlistName);
}
allPlaylistsHaveTodaysFile = false;
}
if (allPlaylistsHaveTodaysFile)
{
_logger.LogInformation("=== ALL PLAYLISTS HAVE TODAY'S FILES - SKIPPING STARTUP FETCH ===");
// Calculate when to search next (tomorrow after sync window)
var tomorrowSyncEnd = todaySyncEnd.AddDays(1);
_logger.LogInformation("Will search again after {Time:yyyy-MM-dd HH:mm} UTC", tomorrowSyncEnd);
return false;
}
}
_logger.LogInformation("=== WILL FETCH ON STARTUP ==="); _logger.LogInformation("=== WILL FETCH ON STARTUP ===");
return true; return true;
@@ -276,9 +380,32 @@ public class SpotifyMissingTracksFetcher : BackgroundService
} }
} }
private async Task FetchMissingTracksAsync(CancellationToken cancellationToken) private async Task FetchMissingTracksAsync(CancellationToken cancellationToken, bool bypassSyncWindowCheck = false)
{ {
_logger.LogInformation("=== FETCHING MISSING TRACKS ==="); var settings = _spotifySettings.Value;
var now = DateTime.UtcNow;
var syncStart = now.Date
.AddHours(settings.SyncStartHour)
.AddMinutes(settings.SyncStartMinute);
var syncEnd = syncStart.AddHours(settings.SyncWindowHours);
// Only run after the sync window has passed (unless bypassing for startup)
if (!bypassSyncWindowCheck && now < syncEnd)
{
_logger.LogInformation("Skipping fetch - sync window not passed yet (now: {Now}, window ends: {End})",
now, syncEnd);
return;
}
if (bypassSyncWindowCheck)
{
_logger.LogInformation("=== FETCHING MISSING TRACKS (STARTUP MODE) ===");
}
else
{
_logger.LogInformation("=== FETCHING MISSING TRACKS (SYNC WINDOW PASSED) ===");
}
_logger.LogInformation("Processing {Count} playlists", _playlistIdToName.Count); _logger.LogInformation("Processing {Count} playlists", _playlistIdToName.Count);
// Track when we find files to optimize search for other playlists // Track when we find files to optimize search for other playlists

View File

@@ -1,4 +1,3 @@
<<<<<<< HEAD
using allstarr.Models.Settings; using allstarr.Models.Settings;
using allstarr.Models.Spotify; using allstarr.Models.Spotify;
using allstarr.Services.Common; using allstarr.Services.Common;
@@ -335,475 +334,3 @@ public class SpotifyPlaylistFetcher : BackgroundService
} }
} }
} }
||||||| bc4e5d9
=======
using allstarr.Models.Settings;
using allstarr.Models.Spotify;
using allstarr.Services.Common;
using Microsoft.Extensions.Options;
using System.Text.Json;
using Cronos;
namespace allstarr.Services.Spotify;
/// <summary>
/// Background service that fetches playlist tracks directly from Spotify's API.
///
/// This replaces the Jellyfin Spotify Import plugin dependency with key advantages:
/// - Track ordering is preserved (critical for playlists like Release Radar)
/// - ISRC codes available for exact matching
/// - Real-time data without waiting for plugin sync schedules
/// - Full track metadata (duration, release date, etc.)
///
/// CRON SCHEDULING: Playlists are fetched based on their cron schedules, not a global interval.
/// Cache persists until next cron run to prevent excess Spotify API calls.
/// </summary>
public class SpotifyPlaylistFetcher : BackgroundService
{
private readonly ILogger<SpotifyPlaylistFetcher> _logger;
private readonly SpotifyApiSettings _spotifyApiSettings;
private readonly SpotifyImportSettings _spotifyImportSettings;
private readonly SpotifyApiClient _spotifyClient;
private readonly RedisCacheService _cache;
private const string CacheDirectory = "/app/cache/spotify";
private const string CacheKeyPrefix = "spotify:playlist:";
// Track Spotify playlist IDs after discovery
private readonly Dictionary<string, string> _playlistNameToSpotifyId = new();
public SpotifyPlaylistFetcher(
ILogger<SpotifyPlaylistFetcher> logger,
IOptions<SpotifyApiSettings> spotifyApiSettings,
IOptions<SpotifyImportSettings> spotifyImportSettings,
SpotifyApiClient spotifyClient,
RedisCacheService cache)
{
_logger = logger;
_spotifyApiSettings = spotifyApiSettings.Value;
_spotifyImportSettings = spotifyImportSettings.Value;
_spotifyClient = spotifyClient;
_cache = cache;
}
/// <summary>
/// Gets the Spotify playlist tracks in order, using cache if available.
/// Cache persists until next cron run to prevent excess API calls.
/// </summary>
/// <param name="playlistName">Playlist name (e.g., "Release Radar", "Discover Weekly")</param>
/// <returns>List of tracks in playlist order, or empty list if not found</returns>
public async Task<List<SpotifyPlaylistTrack>> GetPlaylistTracksAsync(string playlistName)
{
var cacheKey = $"{CacheKeyPrefix}{playlistName}";
// Try Redis cache first
var cached = await _cache.GetAsync<SpotifyPlaylist>(cacheKey);
if (cached != null && cached.Tracks.Count > 0)
{
var age = DateTime.UtcNow - cached.FetchedAt;
// Calculate if cache should still be valid based on cron schedule
var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName);
var shouldRefresh = false;
if (playlistConfig != null && !string.IsNullOrEmpty(playlistConfig.SyncSchedule))
{
try
{
var cron = CronExpression.Parse(playlistConfig.SyncSchedule);
var nextRun = cron.GetNextOccurrence(cached.FetchedAt, TimeZoneInfo.Utc);
if (nextRun.HasValue && DateTime.UtcNow >= nextRun.Value)
{
shouldRefresh = true;
_logger.LogInformation("Cache expired for '{Name}' - next cron run was at {NextRun} UTC",
playlistName, nextRun.Value);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not parse cron schedule for '{Name}', falling back to cache duration", playlistName);
shouldRefresh = age.TotalMinutes >= _spotifyApiSettings.CacheDurationMinutes;
}
}
else
{
// No cron schedule, use cache duration from settings
shouldRefresh = age.TotalMinutes >= _spotifyApiSettings.CacheDurationMinutes;
}
if (!shouldRefresh)
{
_logger.LogDebug("Using cached playlist '{Name}' ({Count} tracks, age: {Age:F1}m)",
playlistName, cached.Tracks.Count, age.TotalMinutes);
return cached.Tracks;
}
}
// Try file cache
var filePath = GetCacheFilePath(playlistName);
if (File.Exists(filePath))
{
try
{
var json = await File.ReadAllTextAsync(filePath);
var filePlaylist = JsonSerializer.Deserialize<SpotifyPlaylist>(json);
if (filePlaylist != null && filePlaylist.Tracks.Count > 0)
{
var age = DateTime.UtcNow - filePlaylist.FetchedAt;
if (age.TotalMinutes < _spotifyApiSettings.CacheDurationMinutes)
{
_logger.LogDebug("Using file-cached playlist '{Name}' ({Count} tracks)",
playlistName, filePlaylist.Tracks.Count);
return filePlaylist.Tracks;
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read file cache for '{Name}'", playlistName);
}
}
// Need to fetch fresh - try to use cached or configured Spotify playlist ID
if (!_playlistNameToSpotifyId.TryGetValue(playlistName, out var spotifyId))
{
// Check if we have a configured Spotify ID for this playlist
var config = _spotifyImportSettings.GetPlaylistByName(playlistName);
if (config != null && !string.IsNullOrEmpty(config.Id))
{
// Use the configured Spotify playlist ID directly
spotifyId = config.Id;
_playlistNameToSpotifyId[playlistName] = spotifyId;
_logger.LogInformation("Using configured Spotify playlist ID for '{Name}': {Id}", playlistName, spotifyId);
}
else
{
// No configured ID, try searching by name (works for public/followed playlists)
_logger.LogDebug("No configured Spotify ID for '{Name}', searching...", playlistName);
var playlists = await _spotifyClient.SearchUserPlaylistsAsync(playlistName);
var exactMatch = playlists.FirstOrDefault(p =>
p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
if (exactMatch == null)
{
_logger.LogWarning("Could not find Spotify playlist named '{Name}' - try configuring the Spotify playlist ID", playlistName);
// Return file cache even if expired, as a fallback
if (File.Exists(filePath))
{
var json = await File.ReadAllTextAsync(filePath);
var fallback = JsonSerializer.Deserialize<SpotifyPlaylist>(json);
if (fallback != null)
{
_logger.LogWarning("Using expired file cache as fallback for '{Name}'", playlistName);
return fallback.Tracks;
}
}
return new List<SpotifyPlaylistTrack>();
}
spotifyId = exactMatch.SpotifyId;
_playlistNameToSpotifyId[playlistName] = spotifyId;
_logger.LogInformation("Found Spotify playlist '{Name}' with ID: {Id}", playlistName, spotifyId);
}
}
// Fetch the full playlist
var playlist = await _spotifyClient.GetPlaylistAsync(spotifyId);
if (playlist == null || playlist.Tracks.Count == 0)
{
_logger.LogWarning("Failed to fetch playlist '{Name}' from Spotify", playlistName);
return cached?.Tracks ?? new List<SpotifyPlaylistTrack>();
}
// Calculate cache expiration based on cron schedule
var playlistCfg = _spotifyImportSettings.GetPlaylistByName(playlistName);
var cacheExpiration = TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes * 2); // Default
if (playlistCfg != null && !string.IsNullOrEmpty(playlistCfg.SyncSchedule))
{
try
{
var cron = CronExpression.Parse(playlistCfg.SyncSchedule);
var nextRun = cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc);
if (nextRun.HasValue)
{
var timeUntilNextRun = nextRun.Value - DateTime.UtcNow;
// Add 5 minutes buffer
cacheExpiration = timeUntilNextRun + TimeSpan.FromMinutes(5);
_logger.LogInformation("Playlist '{Name}' cache will persist until next cron run: {NextRun} UTC (in {Hours:F1}h)",
playlistName, nextRun.Value, timeUntilNextRun.TotalHours);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not calculate next cron run for '{Name}', using default cache duration", playlistName);
}
}
// Update cache with cron-based expiration
await _cache.SetAsync(cacheKey, playlist, cacheExpiration);
await SaveToFileCacheAsync(playlistName, playlist);
_logger.LogInformation("Fetched and cached playlist '{Name}' with {Count} tracks (expires in {Hours:F1}h)",
playlistName, playlist.Tracks.Count, cacheExpiration.TotalHours);
return playlist.Tracks;
}
/// <summary>
/// Gets missing tracks for a playlist (tracks not found in Jellyfin library).
/// This provides compatibility with the existing SpotifyMissingTracksFetcher interface.
/// </summary>
/// <param name="playlistName">Playlist name</param>
/// <param name="jellyfinTrackIds">Set of Spotify IDs that exist in Jellyfin library</param>
/// <returns>List of missing tracks with position preserved</returns>
public async Task<List<SpotifyPlaylistTrack>> GetMissingTracksAsync(
string playlistName,
HashSet<string> jellyfinTrackIds)
{
var allTracks = await GetPlaylistTracksAsync(playlistName);
// Filter to only tracks not in Jellyfin, preserving order
return allTracks
.Where(t => !jellyfinTrackIds.Contains(t.SpotifyId))
.ToList();
}
/// <summary>
/// Manual trigger to refresh a specific playlist.
/// </summary>
public async Task RefreshPlaylistAsync(string playlistName)
{
_logger.LogInformation("Manual refresh triggered for playlist '{Name}'", playlistName);
// Clear cache to force refresh
var cacheKey = $"{CacheKeyPrefix}{playlistName}";
await _cache.DeleteAsync(cacheKey);
// Re-fetch
await GetPlaylistTracksAsync(playlistName);
}
/// <summary>
/// Manual trigger to refresh all configured playlists.
/// </summary>
public async Task TriggerFetchAsync()
{
_logger.LogInformation("Manual fetch triggered for all playlists");
foreach (var config in _spotifyImportSettings.Playlists)
{
await RefreshPlaylistAsync(config.Name);
}
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("========================================");
_logger.LogInformation("SpotifyPlaylistFetcher: Starting up...");
// Ensure cache directory exists
Directory.CreateDirectory(CacheDirectory);
if (!_spotifyApiSettings.Enabled)
{
_logger.LogInformation("Spotify API integration is DISABLED");
_logger.LogInformation("========================================");
return;
}
if (string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie))
{
_logger.LogWarning("Spotify session cookie not configured - cannot access editorial playlists");
_logger.LogInformation("========================================");
return;
}
// Verify we can get an access token (the most reliable auth check)
_logger.LogInformation("Attempting Spotify authentication...");
var token = await _spotifyClient.GetWebAccessTokenAsync(stoppingToken);
if (string.IsNullOrEmpty(token))
{
_logger.LogError("Failed to get Spotify access token - check session cookie");
_logger.LogInformation("========================================");
return;
}
_logger.LogInformation("Spotify API ENABLED");
_logger.LogInformation("Authenticated via sp_dc session cookie");
_logger.LogInformation("ISRC matching: {Enabled}", _spotifyApiSettings.PreferIsrcMatching ? "enabled" : "disabled");
_logger.LogInformation("Configured Playlists: {Count}", _spotifyImportSettings.Playlists.Count);
foreach (var playlist in _spotifyImportSettings.Playlists)
{
var schedule = string.IsNullOrEmpty(playlist.SyncSchedule) ? "0 8 * * 1" : playlist.SyncSchedule;
_logger.LogInformation(" - {Name}: {Schedule}", playlist.Name, schedule);
}
_logger.LogInformation("========================================");
// Initial fetch of all playlists on startup
await FetchAllPlaylistsAsync(stoppingToken);
// Cron-based refresh loop - only fetch when cron schedule triggers
// This prevents excess Spotify API calls
while (!stoppingToken.IsCancellationRequested)
{
try
{
// Check each playlist to see if it needs refreshing based on cron schedule
var now = DateTime.UtcNow;
var needsRefresh = new List<string>();
foreach (var config in _spotifyImportSettings.Playlists)
{
var schedule = string.IsNullOrEmpty(config.SyncSchedule) ? "0 8 * * 1" : config.SyncSchedule;
try
{
var cron = CronExpression.Parse(schedule);
// Check if we have cached data
var cacheKey = $"{CacheKeyPrefix}{config.Name}";
var cached = await _cache.GetAsync<SpotifyPlaylist>(cacheKey);
if (cached != null)
{
// Calculate when the next run should be after the last fetch
var nextRun = cron.GetNextOccurrence(cached.FetchedAt, TimeZoneInfo.Utc);
if (nextRun.HasValue && now >= nextRun.Value)
{
needsRefresh.Add(config.Name);
_logger.LogInformation("Playlist '{Name}' needs refresh - last fetched {Age:F1}h ago, next run was {NextRun}",
config.Name, (now - cached.FetchedAt).TotalHours, nextRun.Value);
}
}
else
{
// No cache, fetch it
needsRefresh.Add(config.Name);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Invalid cron schedule for playlist {Name}: {Schedule}", config.Name, schedule);
}
}
// Fetch playlists that need refreshing
if (needsRefresh.Count > 0)
{
_logger.LogInformation("=== CRON TRIGGER: Fetching {Count} playlists ===", needsRefresh.Count);
foreach (var playlistName in needsRefresh)
{
if (stoppingToken.IsCancellationRequested) break;
try
{
await GetPlaylistTracksAsync(playlistName);
// Rate limiting between playlists
if (playlistName != needsRefresh.Last())
{
_logger.LogDebug("Waiting 3 seconds before next playlist to avoid rate limits...");
await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching playlist '{Name}'", playlistName);
}
}
_logger.LogInformation("=== FINISHED FETCHING PLAYLISTS ===");
}
// Sleep for 1 hour before checking again
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in playlist fetcher loop");
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
}
}
}
private async Task FetchAllPlaylistsAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("=== FETCHING SPOTIFY PLAYLISTS ===");
foreach (var config in _spotifyImportSettings.Playlists)
{
if (cancellationToken.IsCancellationRequested) break;
try
{
var tracks = await GetPlaylistTracksAsync(config.Name);
_logger.LogInformation(" {Name}: {Count} tracks", config.Name, tracks.Count);
// Log sample of track order for debugging
if (tracks.Count > 0)
{
_logger.LogDebug(" First track: #{Position} {Title} - {Artist}",
tracks[0].Position, tracks[0].Title, tracks[0].PrimaryArtist);
if (tracks.Count > 1)
{
var last = tracks[^1];
_logger.LogDebug(" Last track: #{Position} {Title} - {Artist}",
last.Position, last.Title, last.PrimaryArtist);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching playlist '{Name}'", config.Name);
}
// Rate limiting between playlists - Spotify is VERY aggressive with rate limiting
// Wait 3 seconds between each playlist to avoid 429 TooManyRequests errors
if (config != _spotifyImportSettings.Playlists.Last())
{
_logger.LogDebug("Waiting 3 seconds before next playlist to avoid rate limits...");
await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken);
}
}
_logger.LogInformation("=== FINISHED FETCHING SPOTIFY PLAYLISTS ===");
}
private string GetCacheFilePath(string playlistName)
{
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
return Path.Combine(CacheDirectory, $"{safeName}_spotify.json");
}
private async Task SaveToFileCacheAsync(string playlistName, SpotifyPlaylist playlist)
{
try
{
var filePath = GetCacheFilePath(playlistName);
var json = JsonSerializer.Serialize(playlist, new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
await File.WriteAllTextAsync(filePath, json);
_logger.LogDebug("Saved playlist '{Name}' to file cache", playlistName);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to save file cache for '{Name}'", playlistName);
}
}
}
>>>>>>> dev

View File

@@ -1,4 +1,3 @@
<<<<<<< HEAD
using allstarr.Models.Domain; using allstarr.Models.Domain;
using allstarr.Models.Settings; using allstarr.Models.Settings;
using allstarr.Models.Spotify; using allstarr.Models.Spotify;
@@ -1274,1386 +1273,3 @@ public class SpotifyTrackMatchingService : BackgroundService
} }
} }
||||||| bc4e5d9
=======
using allstarr.Models.Domain;
using allstarr.Models.Settings;
using allstarr.Models.Spotify;
using allstarr.Services.Common;
using allstarr.Services.Jellyfin;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using System.Text.Json;
using Cronos;
namespace allstarr.Services.Spotify;
/// <summary>
/// Background service that pre-matches Spotify tracks with external providers.
///
/// Supports two modes:
/// 1. Legacy mode: Uses MissingTrack from Jellyfin plugin (no ISRC, no ordering)
/// 2. Direct API mode: Uses SpotifyPlaylistTrack (with ISRC and ordering)
///
/// When ISRC is available, exact matching is preferred. Falls back to fuzzy matching.
///
/// CRON SCHEDULING: Each playlist has its own cron schedule. Matching only runs when the schedule triggers.
/// Manual refresh is always allowed. Cache persists until next cron run.
/// </summary>
public class SpotifyTrackMatchingService : BackgroundService
{
private readonly SpotifyImportSettings _spotifySettings;
private readonly SpotifyApiSettings _spotifyApiSettings;
private readonly RedisCacheService _cache;
private readonly ILogger<SpotifyTrackMatchingService> _logger;
private readonly IServiceProvider _serviceProvider;
private const int DelayBetweenSearchesMs = 150; // 150ms = ~6.6 searches/second to avoid rate limiting
private const int BatchSize = 11; // Number of parallel searches (matches SquidWTF provider count)
// Track last run time per playlist to prevent duplicate runs
private readonly Dictionary<string, DateTime> _lastRunTimes = new();
private readonly TimeSpan _minimumRunInterval = TimeSpan.FromMinutes(5); // Cooldown between runs
public SpotifyTrackMatchingService(
IOptions<SpotifyImportSettings> spotifySettings,
IOptions<SpotifyApiSettings> spotifyApiSettings,
RedisCacheService cache,
IServiceProvider serviceProvider,
ILogger<SpotifyTrackMatchingService> logger)
{
_spotifySettings = spotifySettings.Value;
_spotifyApiSettings = spotifyApiSettings.Value;
_cache = cache;
_serviceProvider = serviceProvider;
_logger = logger;
}
/// <summary>
/// Helper method to safely check if a dynamic cache result has a value
/// Handles the case where JsonElement cannot be compared to null directly
/// </summary>
private static bool HasValue(object? obj)
{
if (obj == null) return false;
if (obj is JsonElement jsonEl) return jsonEl.ValueKind != JsonValueKind.Null && jsonEl.ValueKind != JsonValueKind.Undefined;
return true;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("========================================");
_logger.LogInformation("SpotifyTrackMatchingService: Starting up...");
if (!_spotifySettings.Enabled)
{
_logger.LogInformation("Spotify playlist injection is DISABLED, matching service will not run");
_logger.LogInformation("========================================");
return;
}
var matchMode = _spotifyApiSettings.Enabled && _spotifyApiSettings.PreferIsrcMatching
? "ISRC-preferred" : "fuzzy";
_logger.LogInformation("Matching mode: {Mode}", matchMode);
_logger.LogInformation("Cron-based scheduling: Each playlist has independent schedule");
// Log all playlist schedules
foreach (var playlist in _spotifySettings.Playlists)
{
var schedule = string.IsNullOrEmpty(playlist.SyncSchedule) ? "0 8 * * 1" : playlist.SyncSchedule;
_logger.LogInformation(" - {Name}: {Schedule}", playlist.Name, schedule);
}
_logger.LogInformation("========================================");
// Wait a bit for the fetcher to run first
await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
// Run once on startup to match any existing missing tracks
try
{
_logger.LogInformation("Running initial track matching on startup (one-time)");
await MatchAllPlaylistsAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during startup track matching");
}
// Now start the cron-based scheduling loop
while (!stoppingToken.IsCancellationRequested)
{
try
{
// Calculate next run time for each playlist
var now = DateTime.UtcNow;
var nextRuns = new List<(string PlaylistName, DateTime NextRun, CronExpression Cron)>();
foreach (var playlist in _spotifySettings.Playlists)
{
var schedule = string.IsNullOrEmpty(playlist.SyncSchedule) ? "0 8 * * 1" : playlist.SyncSchedule;
try
{
var cron = CronExpression.Parse(schedule);
var nextRun = cron.GetNextOccurrence(now, TimeZoneInfo.Utc);
if (nextRun.HasValue)
{
nextRuns.Add((playlist.Name, nextRun.Value, cron));
}
else
{
_logger.LogWarning("Could not calculate next run for playlist {Name} with schedule {Schedule}",
playlist.Name, schedule);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Invalid cron schedule for playlist {Name}: {Schedule}",
playlist.Name, schedule);
}
}
if (nextRuns.Count == 0)
{
_logger.LogWarning("No valid cron schedules found, sleeping for 1 hour");
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
continue;
}
// Find the next playlist that needs to run
var nextPlaylist = nextRuns.OrderBy(x => x.NextRun).First();
var waitTime = nextPlaylist.NextRun - now;
if (waitTime.TotalSeconds > 0)
{
_logger.LogInformation("Next scheduled run: {Playlist} at {Time} UTC (in {Minutes:F1} minutes)",
nextPlaylist.PlaylistName, nextPlaylist.NextRun, waitTime.TotalMinutes);
// Wait until next run (or max 1 hour to re-check schedules)
var maxWait = TimeSpan.FromHours(1);
var actualWait = waitTime > maxWait ? maxWait : waitTime;
await Task.Delay(actualWait, stoppingToken);
continue;
}
// Time to run this playlist
_logger.LogInformation("=== CRON TRIGGER: Running scheduled match for {Playlist} ===", nextPlaylist.PlaylistName);
// Check cooldown to prevent duplicate runs
if (_lastRunTimes.TryGetValue(nextPlaylist.PlaylistName, out var lastRun))
{
var timeSinceLastRun = now - lastRun;
if (timeSinceLastRun < _minimumRunInterval)
{
_logger.LogInformation("Skipping {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)",
nextPlaylist.PlaylistName, (int)timeSinceLastRun.TotalSeconds, (int)_minimumRunInterval.TotalSeconds);
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
continue;
}
}
// Run matching for this playlist
await MatchSinglePlaylistAsync(nextPlaylist.PlaylistName, stoppingToken);
_lastRunTimes[nextPlaylist.PlaylistName] = DateTime.UtcNow;
_logger.LogInformation("=== FINISHED: {Playlist} - Next run at {NextRun} UTC ===",
nextPlaylist.PlaylistName, nextPlaylist.Cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in cron scheduling loop");
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
}
}
}
/// <summary>
/// Matches tracks for a single playlist (called by cron scheduler or manual trigger).
/// </summary>
private async Task MatchSinglePlaylistAsync(string playlistName, CancellationToken cancellationToken)
{
var playlist = _spotifySettings.Playlists
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
if (playlist == null)
{
_logger.LogWarning("Playlist {Playlist} not found in configuration", playlistName);
return;
}
using var scope = _serviceProvider.CreateScope();
var metadataService = scope.ServiceProvider.GetRequiredService<IMusicMetadataService>();
// Check if we should use the new SpotifyPlaylistFetcher
SpotifyPlaylistFetcher? playlistFetcher = null;
if (_spotifyApiSettings.Enabled)
{
playlistFetcher = scope.ServiceProvider.GetService<SpotifyPlaylistFetcher>();
}
try
{
if (playlistFetcher != null)
{
// Use new direct API mode with ISRC support
await MatchPlaylistTracksWithIsrcAsync(
playlist.Name, playlistFetcher, metadataService, cancellationToken);
}
else
{
// Fall back to legacy mode
await MatchPlaylistTracksLegacyAsync(
playlist.Name, metadataService, cancellationToken);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error matching tracks for playlist {Playlist}", playlist.Name);
throw;
}
}
/// <summary>
/// Public method to trigger matching manually for all playlists (called from controller).
/// This bypasses cron schedules and runs immediately.
/// </summary>
public async Task TriggerMatchingAsync()
{
_logger.LogInformation("Manual track matching triggered for all playlists (bypassing cron schedules)");
await MatchAllPlaylistsAsync(CancellationToken.None);
}
/// <summary>
/// Public method to trigger matching for a specific playlist (called from controller).
/// This bypasses cron schedules and runs immediately.
/// </summary>
public async Task TriggerMatchingForPlaylistAsync(string playlistName)
{
_logger.LogInformation("Manual track matching triggered for playlist: {Playlist} (bypassing cron schedule)", playlistName);
// Check cooldown to prevent abuse
if (_lastRunTimes.TryGetValue(playlistName, out var lastRun))
{
var timeSinceLastRun = DateTime.UtcNow - lastRun;
if (timeSinceLastRun < _minimumRunInterval)
{
_logger.LogWarning("Skipping manual refresh for {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)",
playlistName, (int)timeSinceLastRun.TotalSeconds, (int)_minimumRunInterval.TotalSeconds);
throw new InvalidOperationException($"Please wait {(int)(_minimumRunInterval - timeSinceLastRun).TotalSeconds} more seconds before refreshing again");
}
}
await MatchSinglePlaylistAsync(playlistName, CancellationToken.None);
_lastRunTimes[playlistName] = DateTime.UtcNow;
}
private async Task MatchAllPlaylistsAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("=== STARTING TRACK MATCHING FOR ALL PLAYLISTS ===");
var playlists = _spotifySettings.Playlists;
if (playlists.Count == 0)
{
_logger.LogInformation("No playlists configured for matching");
return;
}
foreach (var playlist in playlists)
{
if (cancellationToken.IsCancellationRequested) break;
try
{
await MatchSinglePlaylistAsync(playlist.Name, cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error matching tracks for playlist {Playlist}", playlist.Name);
}
}
_logger.LogInformation("=== FINISHED TRACK MATCHING FOR ALL PLAYLISTS ===");
}
/// <summary>
/// New matching mode that uses ISRC when available for exact matches.
/// Preserves track position for correct playlist ordering.
/// Only matches tracks that aren't already in the Jellyfin playlist.
/// Uses GREEDY ASSIGNMENT to maximize total matches.
/// </summary>
private async Task MatchPlaylistTracksWithIsrcAsync(
string playlistName,
SpotifyPlaylistFetcher playlistFetcher,
IMusicMetadataService metadataService,
CancellationToken cancellationToken)
{
var matchedTracksKey = $"spotify:matched:ordered:{playlistName}";
// Get playlist tracks with full metadata including ISRC and position
var spotifyTracks = await playlistFetcher.GetPlaylistTracksAsync(playlistName);
if (spotifyTracks.Count == 0)
{
_logger.LogInformation("No tracks found for {Playlist}, skipping matching", playlistName);
return;
}
// Get the Jellyfin playlist ID to check which tracks already exist
var playlistConfig = _spotifySettings.Playlists
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
HashSet<string> existingSpotifyIds = new();
if (!string.IsNullOrEmpty(playlistConfig?.JellyfinId))
{
// Get existing tracks from Jellyfin playlist to avoid re-matching
using var scope = _serviceProvider.CreateScope();
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
var jellyfinSettings = scope.ServiceProvider.GetService<IOptions<JellyfinSettings>>()?.Value;
if (proxyService != null && jellyfinSettings != null)
{
try
{
// CRITICAL: Must include UserId parameter or Jellyfin returns empty results
var userId = jellyfinSettings.UserId;
var playlistItemsUrl = $"Playlists/{playlistConfig.JellyfinId}/Items";
var queryParams = new Dictionary<string, string>();
if (!string.IsNullOrEmpty(userId))
{
queryParams["UserId"] = userId;
}
else
{
_logger.LogWarning("No UserId configured - may not be able to fetch existing playlist tracks for {Playlist}", playlistName);
}
var (existingTracksResponse, _) = await proxyService.GetJsonAsyncInternal(
playlistItemsUrl,
queryParams);
if (existingTracksResponse != null &&
existingTracksResponse.RootElement.TryGetProperty("Items", out var items))
{
foreach (var item in items.EnumerateArray())
{
if (item.TryGetProperty("ProviderIds", out var providerIds) &&
providerIds.TryGetProperty("Spotify", out var spotifyId))
{
var id = spotifyId.GetString();
if (!string.IsNullOrEmpty(id))
{
existingSpotifyIds.Add(id);
}
}
}
_logger.LogInformation("Found {Count} tracks already in Jellyfin playlist {Playlist}, will skip matching these",
existingSpotifyIds.Count, playlistName);
}
else
{
_logger.LogWarning("No Items found in Jellyfin playlist response for {Playlist} - may need UserId parameter", playlistName);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not fetch existing Jellyfin tracks for {Playlist}, will match all tracks", playlistName);
}
}
}
// Filter to only tracks not already in Jellyfin
var tracksToMatch = spotifyTracks
.Where(t => !existingSpotifyIds.Contains(t.SpotifyId))
.ToList();
if (tracksToMatch.Count == 0)
{
_logger.LogInformation("All {Count} tracks for {Playlist} already exist in Jellyfin, skipping matching",
spotifyTracks.Count, playlistName);
return;
}
_logger.LogInformation("Matching {ToMatch}/{Total} tracks for {Playlist} (skipping {Existing} already in Jellyfin, ISRC: {IsrcEnabled}, AGGRESSIVE MODE)",
tracksToMatch.Count, spotifyTracks.Count, playlistName, existingSpotifyIds.Count, _spotifyApiSettings.PreferIsrcMatching);
// Check cache - use snapshot/timestamp to detect changes
var existingMatched = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
// CRITICAL: Skip matching if cache exists and is valid
// Only re-match if cache is missing OR if we detect manual mappings that need to be applied
if (existingMatched != null && existingMatched.Count > 0)
{
// Check if we have NEW manual mappings that aren't in the cache
var hasNewManualMappings = false;
foreach (var track in tracksToMatch)
{
// Check if this track has a manual mapping but isn't in the cached results
var manualMappingKey = $"spotify:manual-map:{playlistName}:{track.SpotifyId}";
var manualMapping = await _cache.GetAsync<string>(manualMappingKey);
var externalMappingKey = $"spotify:external-map:{playlistName}:{track.SpotifyId}";
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
var hasManualMapping = !string.IsNullOrEmpty(manualMapping) || !string.IsNullOrEmpty(externalMappingJson);
var isInCache = existingMatched.Any(m => m.SpotifyId == track.SpotifyId);
// If track has manual mapping but isn't in cache, we need to rebuild
if (hasManualMapping && !isInCache)
{
hasNewManualMappings = true;
break;
}
}
if (!hasNewManualMappings)
{
_logger.LogInformation("✓ Playlist {Playlist} already has {Count} matched tracks cached (skipping {ToMatch} new tracks), no re-matching needed",
playlistName, existingMatched.Count, tracksToMatch.Count);
return;
}
_logger.LogInformation("New manual mappings detected for {Playlist}, rebuilding cache to apply them", playlistName);
}
var matchedTracks = new List<MatchedTrack>();
var isrcMatches = 0;
var fuzzyMatches = 0;
var noMatch = 0;
// GREEDY ASSIGNMENT: Collect all possible matches first, then assign optimally
var allCandidates = new List<(SpotifyPlaylistTrack SpotifyTrack, Song MatchedSong, double Score, string MatchType)>();
// Process tracks in batches for parallel searching
var orderedTracks = tracksToMatch.OrderBy(t => t.Position).ToList();
for (int i = 0; i < orderedTracks.Count; i += BatchSize)
{
if (cancellationToken.IsCancellationRequested) break;
var batch = orderedTracks.Skip(i).Take(BatchSize).ToList();
_logger.LogDebug("Processing batch {Start}-{End} of {Total}",
i + 1, Math.Min(i + BatchSize, orderedTracks.Count), orderedTracks.Count);
// Process all tracks in this batch in parallel
var batchTasks = batch.Select(async spotifyTrack =>
{
try
{
var candidates = new List<(Song Song, double Score, string MatchType)>();
// Try ISRC match first if available and enabled
if (_spotifyApiSettings.PreferIsrcMatching && !string.IsNullOrEmpty(spotifyTrack.Isrc))
{
var isrcSong = await TryMatchByIsrcAsync(spotifyTrack.Isrc, metadataService);
if (isrcSong != null)
{
candidates.Add((isrcSong, 100.0, "isrc"));
}
}
// Always try fuzzy matching to get more candidates
var fuzzySongs = await TryMatchByFuzzyMultipleAsync(
spotifyTrack.Title,
spotifyTrack.Artists,
metadataService);
foreach (var (song, score) in fuzzySongs)
{
candidates.Add((song, score, "fuzzy"));
}
return (spotifyTrack, candidates);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
spotifyTrack.Title, spotifyTrack.PrimaryArtist);
return (spotifyTrack, new List<(Song, double, string)>());
}
}).ToList();
// Wait for all tracks in this batch to complete
var batchResults = await Task.WhenAll(batchTasks);
// Collect all candidates
foreach (var (spotifyTrack, candidates) in batchResults)
{
foreach (var (song, score, matchType) in candidates)
{
allCandidates.Add((spotifyTrack, song, score, matchType));
}
}
// Rate limiting between batches
if (i + BatchSize < orderedTracks.Count)
{
await Task.Delay(DelayBetweenSearchesMs, cancellationToken);
}
}
// GREEDY ASSIGNMENT: Assign each Spotify track to its best unique match
var usedSongIds = new HashSet<string>();
var assignments = new Dictionary<string, (Song Song, double Score, string MatchType)>();
// Sort candidates by score (highest first)
var sortedCandidates = allCandidates
.OrderByDescending(c => c.Score)
.ToList();
foreach (var (spotifyTrack, song, score, matchType) in sortedCandidates)
{
// Skip if this Spotify track already has a match
if (assignments.ContainsKey(spotifyTrack.SpotifyId))
continue;
// Skip if this song is already used
if (usedSongIds.Contains(song.Id))
continue;
// Assign this match
assignments[spotifyTrack.SpotifyId] = (song, score, matchType);
usedSongIds.Add(song.Id);
}
// Build final matched tracks list
foreach (var spotifyTrack in orderedTracks)
{
if (assignments.TryGetValue(spotifyTrack.SpotifyId, out var match))
{
var matched = new MatchedTrack
{
Position = spotifyTrack.Position,
SpotifyId = spotifyTrack.SpotifyId,
SpotifyTitle = spotifyTrack.Title,
SpotifyArtist = spotifyTrack.PrimaryArtist,
Isrc = spotifyTrack.Isrc,
MatchType = match.MatchType,
MatchedSong = match.Song
};
matchedTracks.Add(matched);
if (match.MatchType == "isrc") isrcMatches++;
else if (match.MatchType == "fuzzy") fuzzyMatches++;
_logger.LogDebug(" #{Position} {Title} - {Artist} → {MatchType} match (score: {Score:F1}): {MatchedTitle}",
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist,
match.MatchType, match.Score, match.Song.Title);
}
else
{
noMatch++;
_logger.LogDebug(" #{Position} {Title} - {Artist} → no match",
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist);
}
}
if (matchedTracks.Count > 0)
{
// Calculate cache expiration: until next cron run (not just cache duration from settings)
var playlist = _spotifySettings.Playlists
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
var cacheExpiration = TimeSpan.FromHours(24); // Default 24 hours
if (playlist != null && !string.IsNullOrEmpty(playlist.SyncSchedule))
{
try
{
var cron = CronExpression.Parse(playlist.SyncSchedule);
var nextRun = cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc);
if (nextRun.HasValue)
{
var timeUntilNextRun = nextRun.Value - DateTime.UtcNow;
// Add 5 minutes buffer to ensure cache doesn't expire before next run
cacheExpiration = timeUntilNextRun + TimeSpan.FromMinutes(5);
_logger.LogInformation("Cache will persist until next cron run: {NextRun} UTC (in {Hours:F1} hours)",
nextRun.Value, timeUntilNextRun.TotalHours);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not calculate next cron run for {Playlist}, using default cache duration", playlistName);
}
}
// Cache matched tracks with position data until next cron run
await _cache.SetAsync(matchedTracksKey, matchedTracks, cacheExpiration);
// Save matched tracks to file for persistence across restarts
await SaveMatchedTracksToFileAsync(playlistName, matchedTracks);
// Also update legacy cache for backward compatibility
var legacyKey = $"spotify:matched:{playlistName}";
var legacySongs = matchedTracks.OrderBy(t => t.Position).Select(t => t.MatchedSong).ToList();
await _cache.SetAsync(legacyKey, legacySongs, cacheExpiration);
_logger.LogInformation(
"✓ Cached {Matched}/{Total} tracks for {Playlist} via GREEDY ASSIGNMENT (ISRC: {Isrc}, Fuzzy: {Fuzzy}, No match: {NoMatch}) - cache expires in {Hours:F1}h",
matchedTracks.Count, tracksToMatch.Count, playlistName, isrcMatches, fuzzyMatches, noMatch, cacheExpiration.TotalHours);
// Pre-build playlist items cache for instant serving
// This is what makes the UI show all matched tracks at once
await PreBuildPlaylistItemsCacheAsync(playlistName, playlistConfig?.JellyfinId, spotifyTracks, matchedTracks, cacheExpiration, cancellationToken);
}
else
{
_logger.LogInformation("No tracks matched for {Playlist}", playlistName);
}
}
/// <summary>
/// Returns multiple candidate matches with scores for greedy assignment.
/// FOLLOWS OPTIMAL ORDER:
/// 1. Strip decorators (done in FuzzyMatcher)
/// 2. Substring matching (done in FuzzyMatcher)
/// 3. Levenshtein distance (done in FuzzyMatcher)
/// This method just collects candidates; greedy assignment happens later.
/// </summary>
private async Task<List<(Song Song, double Score)>> TryMatchByFuzzyMultipleAsync(
string title,
List<string> artists,
IMusicMetadataService metadataService)
{
try
{
var primaryArtist = artists.FirstOrDefault() ?? "";
// STEP 1: Strip decorators FIRST (before searching)
var titleStripped = FuzzyMatcher.StripDecorators(title);
var query = $"{titleStripped} {primaryArtist}";
var results = await metadataService.SearchSongsAsync(query, limit: 10);
if (results.Count == 0) return new List<(Song, double)>();
// STEP 2-3: Score all results (substring + Levenshtein already in CalculateSimilarityAggressive)
var scoredResults = results
.Select(song => new
{
Song = song,
// Use aggressive matching which follows optimal order internally
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
})
.Select(x => new
{
x.Song,
x.TitleScore,
x.ArtistScore,
// Weight: 70% title, 30% artist (prioritize title matching)
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
})
.Where(x =>
x.TotalScore >= 40 ||
(x.ArtistScore >= 70 && x.TitleScore >= 30) ||
x.TitleScore >= 85)
.OrderByDescending(x => x.TotalScore)
.Select(x => (x.Song, x.TotalScore))
.ToList();
return scoredResults;
}
catch
{
return new List<(Song, double)>();
}
}
/// <summary>
/// Attempts to match a track by ISRC using provider search.
/// </summary>
private async Task<Song?> TryMatchByIsrcAsync(string isrc, IMusicMetadataService metadataService)
{
try
{
// Search by ISRC directly - most providers support this
var results = await metadataService.SearchSongsAsync($"isrc:{isrc}", limit: 1);
if (results.Count > 0 && results[0].Isrc == isrc)
{
return results[0];
}
// Some providers may not support isrc: prefix, try without
results = await metadataService.SearchSongsAsync(isrc, limit: 5);
var exactMatch = results.FirstOrDefault(r =>
!string.IsNullOrEmpty(r.Isrc) &&
r.Isrc.Equals(isrc, StringComparison.OrdinalIgnoreCase));
return exactMatch;
}
catch
{
return null;
}
}
/// <summary>
/// Attempts to match a track by title and artist using AGGRESSIVE fuzzy matching.
/// FOLLOWS OPTIMAL ORDER:
/// 1. Strip decorators FIRST (before searching)
/// 2. Substring matching (in FuzzyMatcher)
/// 3. Levenshtein distance (in FuzzyMatcher)
/// PRIORITY: Match as many tracks as possible, even with lower confidence.
/// </summary>
private async Task<Song?> TryMatchByFuzzyAsync(
string title,
List<string> artists,
IMusicMetadataService metadataService)
{
try
{
var primaryArtist = artists.FirstOrDefault() ?? "";
// STEP 1: Strip decorators FIRST (before searching)
var titleStripped = FuzzyMatcher.StripDecorators(title);
var query = $"{titleStripped} {primaryArtist}";
var results = await metadataService.SearchSongsAsync(query, limit: 10);
if (results.Count == 0) return null;
// STEP 2-3: Score all results (substring + Levenshtein in CalculateSimilarityAggressive)
var scoredResults = results
.Select(song => new
{
Song = song,
// Use aggressive matching which follows optimal order internally
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
})
.Select(x => new
{
x.Song,
x.TitleScore,
x.ArtistScore,
// Weight: 70% title, 30% artist (prioritize title matching)
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
})
.OrderByDescending(x => x.TotalScore)
.ToList();
var bestMatch = scoredResults.FirstOrDefault();
if (bestMatch == null) return null;
// AGGRESSIVE: Accept matches with score >= 40 (was 50)
if (bestMatch.TotalScore >= 40)
{
_logger.LogDebug("✓ Matched (score: {Score:F1}, title: {TitleScore}, artist: {ArtistScore}): {SpotifyTitle} → {MatchedTitle}",
bestMatch.TotalScore, bestMatch.TitleScore, bestMatch.ArtistScore, title, bestMatch.Song.Title);
return bestMatch.Song;
}
// SUPER AGGRESSIVE: If artist matches well (70+), accept even lower title scores
// This handles cases like "a" → "a-blah" where artist is the same
if (bestMatch.ArtistScore >= 70 && bestMatch.TitleScore >= 30)
{
_logger.LogDebug("✓ Matched via artist priority (artist: {ArtistScore}, title: {TitleScore}): {SpotifyTitle} → {MatchedTitle}",
bestMatch.ArtistScore, bestMatch.TitleScore, title, bestMatch.Song.Title);
return bestMatch.Song;
}
// ULTRA AGGRESSIVE: If title has high substring match (85+), accept it
// This handles "luther" → "luther (feat. sza)"
if (bestMatch.TitleScore >= 85)
{
_logger.LogDebug("✓ Matched via substring (title: {TitleScore}): {SpotifyTitle} → {MatchedTitle}",
bestMatch.TitleScore, title, bestMatch.Song.Title);
return bestMatch.Song;
}
return null;
}
catch
{
return null;
}
}
/// <summary>
/// Legacy matching mode using MissingTrack from Jellyfin plugin.
/// </summary>
private async Task MatchPlaylistTracksLegacyAsync(
string playlistName,
IMusicMetadataService metadataService,
CancellationToken cancellationToken)
{
var missingTracksKey = $"spotify:missing:{playlistName}";
var matchedTracksKey = $"spotify:matched:{playlistName}";
// Check if we already have matched tracks cached
var existingMatched = await _cache.GetAsync<List<Song>>(matchedTracksKey);
if (existingMatched != null && existingMatched.Count > 0)
{
_logger.LogInformation("Playlist {Playlist} already has {Count} matched tracks cached, skipping",
playlistName, existingMatched.Count);
return;
}
// Get missing tracks
var missingTracks = await _cache.GetAsync<List<MissingTrack>>(missingTracksKey);
if (missingTracks == null || missingTracks.Count == 0)
{
_logger.LogInformation("No missing tracks found for {Playlist}, skipping matching", playlistName);
return;
}
_logger.LogInformation("Matching {Count} tracks for {Playlist} (with rate limiting)",
missingTracks.Count, playlistName);
var matchedSongs = new List<Song>();
var matchCount = 0;
foreach (var track in missingTracks)
{
if (cancellationToken.IsCancellationRequested) break;
try
{
var query = $"{track.Title} {track.PrimaryArtist}";
var results = await metadataService.SearchSongsAsync(query, limit: 5);
if (results.Count > 0)
{
// Fuzzy match to find best result
// Check that ALL artists match (not just some)
var bestMatch = results
.Select(song => new
{
Song = song,
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, song.Title),
// Calculate artist score by checking ALL artists match
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors)
})
.Select(x => new
{
x.Song,
x.TitleScore,
x.ArtistScore,
TotalScore = (x.TitleScore * 0.6) + (x.ArtistScore * 0.4)
})
.OrderByDescending(x => x.TotalScore)
.FirstOrDefault();
if (bestMatch != null && bestMatch.TotalScore >= 60)
{
matchedSongs.Add(bestMatch.Song);
matchCount++;
if (matchCount % 10 == 0)
{
_logger.LogInformation("Matched {Count}/{Total} tracks for {Playlist}",
matchCount, missingTracks.Count, playlistName);
}
}
}
// Rate limiting: delay between searches
await Task.Delay(DelayBetweenSearchesMs, cancellationToken);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
track.Title, track.PrimaryArtist);
}
}
if (matchedSongs.Count > 0)
{
// Cache matched tracks for 1 hour
await _cache.SetAsync(matchedTracksKey, matchedSongs, TimeSpan.FromHours(1));
_logger.LogInformation("✓ Cached {Matched}/{Total} matched tracks for {Playlist}",
matchedSongs.Count, missingTracks.Count, playlistName);
}
else
{
_logger.LogInformation("No tracks matched for {Playlist}", playlistName);
}
}
/// <summary>
/// Calculates artist match score ensuring ALL artists are present.
/// Penalizes if artist counts don't match or if any artist is missing.
/// </summary>
private static double CalculateArtistMatchScore(List<string> spotifyArtists, string songMainArtist, List<string> songContributors)
{
if (spotifyArtists.Count == 0 || string.IsNullOrEmpty(songMainArtist))
return 0;
// Build list of all song artists (main + contributors)
var allSongArtists = new List<string> { songMainArtist };
allSongArtists.AddRange(songContributors);
// If artist counts differ significantly, penalize
var countDiff = Math.Abs(spotifyArtists.Count - allSongArtists.Count);
if (countDiff > 1) // Allow 1 artist difference (sometimes features are listed differently)
return 0;
// Check that each Spotify artist has a good match in song artists
var spotifyScores = new List<double>();
foreach (var spotifyArtist in spotifyArtists)
{
var bestMatch = allSongArtists.Max(songArtist =>
FuzzyMatcher.CalculateSimilarity(spotifyArtist, songArtist));
spotifyScores.Add(bestMatch);
}
// Check that each song artist has a good match in Spotify artists
var songScores = new List<double>();
foreach (var songArtist in allSongArtists)
{
var bestMatch = spotifyArtists.Max(spotifyArtist =>
FuzzyMatcher.CalculateSimilarity(songArtist, spotifyArtist));
songScores.Add(bestMatch);
}
// Average all scores - this ensures ALL artists must match well
var allScores = spotifyScores.Concat(songScores);
var avgScore = allScores.Average();
// Penalize if any individual artist match is poor (< 70)
var minScore = allScores.Min();
if (minScore < 70)
avgScore *= 0.7; // 30% penalty for poor individual match
return avgScore;
}
/// <summary>
/// Pre-builds the playlist items cache for instant serving.
/// This combines local Jellyfin tracks with external matched tracks in the correct Spotify order.
/// </summary>
private async Task PreBuildPlaylistItemsCacheAsync(
string playlistName,
string? jellyfinPlaylistId,
List<SpotifyPlaylistTrack> spotifyTracks,
List<MatchedTrack> matchedTracks,
TimeSpan cacheExpiration,
CancellationToken cancellationToken)
{
try
{
_logger.LogInformation("🔨 Pre-building playlist items cache for {Playlist}...", playlistName);
if (string.IsNullOrEmpty(jellyfinPlaylistId))
{
_logger.LogWarning("No Jellyfin playlist ID configured for {Playlist}, cannot pre-build cache", playlistName);
return;
}
// Get existing tracks from Jellyfin playlist
using var scope = _serviceProvider.CreateScope();
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
var responseBuilder = scope.ServiceProvider.GetService<JellyfinResponseBuilder>();
var jellyfinSettings = scope.ServiceProvider.GetService<IOptions<JellyfinSettings>>()?.Value;
if (proxyService == null || responseBuilder == null || jellyfinSettings == null)
{
_logger.LogWarning("Required services not available for pre-building cache");
return;
}
var userId = jellyfinSettings.UserId;
if (string.IsNullOrEmpty(userId))
{
_logger.LogWarning("No UserId configured, cannot pre-build playlist cache for {Playlist}", playlistName);
return;
}
// Create authentication headers for background service call
var headers = new HeaderDictionary();
if (!string.IsNullOrEmpty(jellyfinSettings.ApiKey))
{
headers["X-Emby-Authorization"] = $"MediaBrowser Token=\"{jellyfinSettings.ApiKey}\"";
}
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items?UserId={userId}&Fields=MediaSources";
var (existingTracksResponse, statusCode) = await proxyService.GetJsonAsync(playlistItemsUrl, null, headers);
if (statusCode != 200 || existingTracksResponse == null)
{
_logger.LogWarning("Failed to fetch Jellyfin playlist items for {Playlist}: HTTP {StatusCode}", playlistName, statusCode);
return;
}
// Index Jellyfin items by title+artist for matching
var jellyfinItemsByName = new Dictionary<string, JsonElement>();
if (existingTracksResponse.RootElement.TryGetProperty("Items", out var items))
{
foreach (var item in items.EnumerateArray())
{
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
var artist = "";
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
{
artist = artistsEl[0].GetString() ?? "";
}
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
{
artist = albumArtistEl.GetString() ?? "";
}
var key = $"{title}|{artist}".ToLowerInvariant();
if (!jellyfinItemsByName.ContainsKey(key))
{
jellyfinItemsByName[key] = item;
}
}
}
// Build the final track list in correct Spotify order
var finalItems = new List<Dictionary<string, object?>>();
var usedJellyfinItems = new HashSet<string>();
var localUsedCount = 0;
var externalUsedCount = 0;
var manualExternalCount = 0;
foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position))
{
if (cancellationToken.IsCancellationRequested) break;
JsonElement? matchedJellyfinItem = null;
string? matchedKey = null;
// FIRST: Check for manual Jellyfin mapping
var manualMappingKey = $"spotify:manual-map:{playlistName}:{spotifyTrack.SpotifyId}";
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
if (!string.IsNullOrEmpty(manualJellyfinId))
{
// Find the Jellyfin item by ID
foreach (var kvp in jellyfinItemsByName)
{
var item = kvp.Value;
if (item.TryGetProperty("Id", out var idEl) && idEl.GetString() == manualJellyfinId)
{
matchedJellyfinItem = item;
matchedKey = kvp.Key;
_logger.LogInformation("✓ Using manual Jellyfin mapping for {Title}: Jellyfin ID {Id}",
spotifyTrack.Title, manualJellyfinId);
break;
}
}
if (matchedJellyfinItem.HasValue)
{
// Use the raw Jellyfin item (preserves ALL metadata)
var itemDict = JsonSerializer.Deserialize<Dictionary<string, object?>>(matchedJellyfinItem.Value.GetRawText());
if (itemDict != null)
{
// Add Spotify ID to ProviderIds so lyrics can work for local tracks too
if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId))
{
if (!itemDict.ContainsKey("ProviderIds"))
{
itemDict["ProviderIds"] = new Dictionary<string, string>();
}
var providerIds = itemDict["ProviderIds"] as Dictionary<string, string>;
if (providerIds != null && !providerIds.ContainsKey("Spotify"))
{
providerIds["Spotify"] = spotifyTrack.SpotifyId;
_logger.LogDebug("Added Spotify ID {SpotifyId} to local track for lyrics support", spotifyTrack.SpotifyId);
}
}
finalItems.Add(itemDict);
if (matchedKey != null)
{
usedJellyfinItems.Add(matchedKey);
}
localUsedCount++;
}
continue; // Skip to next track
}
}
// SECOND: Check for external manual mapping
var externalMappingKey = $"spotify:external-map:{playlistName}:{spotifyTrack.SpotifyId}";
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
if (!string.IsNullOrEmpty(externalMappingJson))
{
try
{
using var doc = JsonDocument.Parse(externalMappingJson);
var root = doc.RootElement;
string? provider = null;
string? externalId = null;
if (root.TryGetProperty("provider", out var providerEl))
{
provider = providerEl.GetString();
}
if (root.TryGetProperty("id", out var idEl))
{
externalId = idEl.GetString();
}
if (!string.IsNullOrEmpty(provider) && !string.IsNullOrEmpty(externalId))
{
// Fetch full metadata from the provider instead of using minimal Spotify data
Song? externalSong = null;
try
{
using var metadataScope = _serviceProvider.CreateScope();
var metadataServiceForFetch = metadataScope.ServiceProvider.GetRequiredService<IMusicMetadataService>();
externalSong = await metadataServiceForFetch.GetSongAsync(provider, externalId);
if (externalSong != null)
{
_logger.LogInformation("✓ Fetched full metadata for manual external mapping: {Title} by {Artist}",
externalSong.Title, externalSong.Artist);
}
else
{
_logger.LogWarning("Failed to fetch metadata for {Provider} ID {ExternalId}, using fallback",
provider, externalId);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error fetching metadata for {Provider} ID {ExternalId}, using fallback",
provider, externalId);
}
// Fallback to minimal metadata if fetch failed
if (externalSong == null)
{
externalSong = new Song
{
Id = $"ext-{provider}-song-{externalId}",
Title = spotifyTrack.Title,
Artist = spotifyTrack.PrimaryArtist,
Album = spotifyTrack.Album,
Duration = spotifyTrack.DurationMs / 1000,
Isrc = spotifyTrack.Isrc,
IsLocal = false,
ExternalProvider = provider,
ExternalId = externalId
};
}
var matchedTrack = new MatchedTrack
{
Position = spotifyTrack.Position,
SpotifyId = spotifyTrack.SpotifyId,
MatchedSong = externalSong
};
matchedTracks.Add(matchedTrack);
// Convert external song to Jellyfin item format and add to finalItems
var externalItem = responseBuilder.ConvertSongToJellyfinItem(externalSong);
// Add Spotify ID to ProviderIds so lyrics can work
if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId))
{
if (!externalItem.ContainsKey("ProviderIds"))
{
externalItem["ProviderIds"] = new Dictionary<string, string>();
}
var providerIds = externalItem["ProviderIds"] as Dictionary<string, string>;
if (providerIds != null && !providerIds.ContainsKey("Spotify"))
{
providerIds["Spotify"] = spotifyTrack.SpotifyId;
}
}
finalItems.Add(externalItem);
externalUsedCount++;
manualExternalCount++;
_logger.LogInformation("✓ Using manual external mapping for {Title}: {Provider} {ExternalId}",
spotifyTrack.Title, provider, externalId);
continue; // Skip to next track
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", spotifyTrack.Title);
}
}
// If no manual external mapping, try AGGRESSIVE fuzzy matching with local Jellyfin tracks
double bestScore = 0;
foreach (var kvp in jellyfinItemsByName)
{
if (usedJellyfinItems.Contains(kvp.Key)) continue;
var item = kvp.Value;
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
var artist = "";
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
{
artist = artistsEl[0].GetString() ?? "";
}
// Use AGGRESSIVE matching with decorator stripping
var titleScore = FuzzyMatcher.CalculateSimilarityAggressive(spotifyTrack.Title, title);
var artistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, artist);
// Weight: 70% title, 30% artist (prioritize title matching)
var totalScore = (titleScore * 0.7) + (artistScore * 0.3);
// AGGRESSIVE: Accept score >= 40 (was 70)
// Also accept if artist matches well (70+) and title is decent (30+)
var isGoodMatch = totalScore >= 40 || (artistScore >= 70 && titleScore >= 30);
if (totalScore > bestScore && isGoodMatch)
{
bestScore = totalScore;
matchedJellyfinItem = item;
matchedKey = kvp.Key;
}
}
if (matchedJellyfinItem.HasValue)
{
// Use the raw Jellyfin item (preserves ALL metadata)
var itemDict = JsonSerializer.Deserialize<Dictionary<string, object?>>(matchedJellyfinItem.Value.GetRawText());
if (itemDict != null)
{
// Add Spotify ID to ProviderIds so lyrics can work for fuzzy-matched local tracks too
if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId))
{
if (!itemDict.ContainsKey("ProviderIds"))
{
itemDict["ProviderIds"] = new Dictionary<string, string>();
}
var providerIds = itemDict["ProviderIds"] as Dictionary<string, string>;
if (providerIds != null && !providerIds.ContainsKey("Spotify"))
{
providerIds["Spotify"] = spotifyTrack.SpotifyId;
_logger.LogDebug("Added Spotify ID {SpotifyId} to fuzzy-matched local track for lyrics support", spotifyTrack.SpotifyId);
}
}
finalItems.Add(itemDict);
if (matchedKey != null)
{
usedJellyfinItems.Add(matchedKey);
}
localUsedCount++;
}
}
else
{
// No local match - try to find external track
var matched = matchedTracks.FirstOrDefault(t => t.SpotifyId == spotifyTrack.SpotifyId);
if (matched != null && matched.MatchedSong != null)
{
// Convert external song to Jellyfin item format
var externalItem = responseBuilder.ConvertSongToJellyfinItem(matched.MatchedSong);
// Add Spotify ID to ProviderIds so lyrics can work
if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId))
{
if (!externalItem.ContainsKey("ProviderIds"))
{
externalItem["ProviderIds"] = new Dictionary<string, string>();
}
var providerIds = externalItem["ProviderIds"] as Dictionary<string, string>;
if (providerIds != null && !providerIds.ContainsKey("Spotify"))
{
providerIds["Spotify"] = spotifyTrack.SpotifyId;
}
}
finalItems.Add(externalItem);
externalUsedCount++;
}
}
}
if (finalItems.Count > 0)
{
// Save to Redis cache with same expiration as matched tracks (until next cron run)
var cacheKey = $"spotify:playlist:items:{playlistName}";
await _cache.SetAsync(cacheKey, finalItems, cacheExpiration);
// Save to file cache for persistence
await SavePlaylistItemsToFileAsync(playlistName, finalItems);
var manualMappingInfo = "";
if (manualExternalCount > 0)
{
manualMappingInfo = $" [Manual external: {manualExternalCount}]";
}
_logger.LogInformation(
"✅ Pre-built playlist cache for {Playlist}: {Total} tracks ({Local} LOCAL + {External} EXTERNAL){ManualInfo} - expires in {Hours:F1}h",
playlistName, finalItems.Count, localUsedCount, externalUsedCount, manualMappingInfo, cacheExpiration.TotalHours);
}
else
{
_logger.LogWarning("No items to cache for {Playlist}", playlistName);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to pre-build playlist items cache for {Playlist}", playlistName);
}
}
/// <summary>
/// Saves playlist items to file cache for persistence across restarts.
/// </summary>
private async Task SavePlaylistItemsToFileAsync(string playlistName, List<Dictionary<string, object?>> items)
{
try
{
var cacheDir = "/app/cache/spotify";
Directory.CreateDirectory(cacheDir);
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
var filePath = Path.Combine(cacheDir, $"{safeName}_items.json");
var json = JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true });
await System.IO.File.WriteAllTextAsync(filePath, json);
_logger.LogDebug("💾 Saved {Count} playlist items to file cache: {Path}", items.Count, filePath);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save playlist items to file for {Playlist}", playlistName);
}
}
/// <summary>
/// Saves matched tracks to file cache for persistence across restarts.
/// </summary>
private async Task SaveMatchedTracksToFileAsync(string playlistName, List<MatchedTrack> matchedTracks)
{
try
{
var cacheDir = "/app/cache/spotify";
Directory.CreateDirectory(cacheDir);
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
var filePath = Path.Combine(cacheDir, $"{safeName}_matched.json");
var json = JsonSerializer.Serialize(matchedTracks, new JsonSerializerOptions { WriteIndented = true });
await System.IO.File.WriteAllTextAsync(filePath, json);
_logger.LogDebug("💾 Saved {Count} matched tracks to file cache: {Path}", matchedTracks.Count, filePath);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save matched tracks to file for {Playlist}", playlistName);
}
}
}
>>>>>>> dev

View File

@@ -7,7 +7,6 @@ using allstarr.Models.Search;
using allstarr.Models.Subsonic; using allstarr.Models.Subsonic;
using allstarr.Services.Local; using allstarr.Services.Local;
using allstarr.Services.Common; using allstarr.Services.Common;
using allstarr.Services.Lyrics;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using IOFile = System.IO.File; using IOFile = System.IO.File;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -56,7 +55,6 @@ public class SquidWTFDownloadService : BaseDownloadService
private readonly SquidWTFSettings _squidwtfSettings; private readonly SquidWTFSettings _squidwtfSettings;
private readonly OdesliService _odesliService; private readonly OdesliService _odesliService;
private readonly RoundRobinFallbackHelper _fallbackHelper; private readonly RoundRobinFallbackHelper _fallbackHelper;
private readonly IServiceProvider _serviceProvider;
protected override string ProviderName => "squidwtf"; protected override string ProviderName => "squidwtf";
@@ -77,10 +75,6 @@ public class SquidWTFDownloadService : BaseDownloadService
_squidwtfSettings = SquidWTFSettings.Value; _squidwtfSettings = SquidWTFSettings.Value;
_odesliService = odesliService; _odesliService = odesliService;
_fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF"); _fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF");
_serviceProvider = serviceProvider;
// Increase timeout for large downloads and slow endpoints
_httpClient.Timeout = TimeSpan.FromMinutes(5);
} }
@@ -114,6 +108,9 @@ public class SquidWTFDownloadService : BaseDownloadService
Logger.LogInformation("Track download URL obtained from hifi-api: {Url}", downloadInfo.DownloadUrl); Logger.LogInformation("Track download URL obtained from hifi-api: {Url}", downloadInfo.DownloadUrl);
Logger.LogInformation("Using format: {Format} (Quality: {Quality})", downloadInfo.MimeType, downloadInfo.AudioQuality); Logger.LogInformation("Using format: {Format} (Quality: {Quality})", downloadInfo.MimeType, downloadInfo.AudioQuality);
// Start Spotify ID conversion in parallel with download (don't await yet)
var spotifyIdTask = _odesliService.ConvertTidalToSpotifyIdAsync(trackId, cancellationToken);
// Determine extension from MIME type // Determine extension from MIME type
var extension = downloadInfo.MimeType?.ToLower() switch var extension = downloadInfo.MimeType?.ToLower() switch
{ {
@@ -125,10 +122,10 @@ public class SquidWTFDownloadService : BaseDownloadService
// Build organized folder structure: Artist/Album/Track using AlbumArtist (fallback to Artist for singles) // Build organized folder structure: Artist/Album/Track using AlbumArtist (fallback to Artist for singles)
var artistForPath = song.AlbumArtist ?? song.Artist; var artistForPath = song.AlbumArtist ?? song.Artist;
// Cache mode uses downloads/cache/ folder, Permanent mode uses downloads/permanent/ // Cache mode uses cache/Music folder (cleaned up after 24h), Permanent mode uses downloads folder
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache var basePath = SubsonicSettings.StorageMode == StorageMode.Cache
? Path.Combine("downloads", "cache") ? Path.Combine("cache", "Music")
: Path.Combine("downloads", "permanent"); : "downloads";
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension); var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension);
// Create directories if they don't exist // Create directories if they don't exist
@@ -138,10 +135,10 @@ public class SquidWTFDownloadService : BaseDownloadService
// Resolve unique path if file already exists // Resolve unique path if file already exists
outputPath = PathHelper.ResolveUniquePath(outputPath); outputPath = PathHelper.ResolveUniquePath(outputPath);
// Use round-robin with fallback for downloads to reduce CPU usage // Race all endpoints to download from the fastest one
Logger.LogDebug("Using round-robin endpoint selection for download"); Logger.LogInformation("🏁 Racing {Count} endpoints for fastest download", _fallbackHelper.EndpointCount);
var response = await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) => var response = await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
{ {
// Map quality settings to Tidal's quality levels per hifi-api spec // Map quality settings to Tidal's quality levels per hifi-api spec
var quality = _squidwtfSettings.Quality?.ToUpperInvariant() switch var quality = _squidwtfSettings.Quality?.ToUpperInvariant() switch
@@ -157,10 +154,10 @@ public class SquidWTFDownloadService : BaseDownloadService
var url = $"{baseUrl}/track/?id={trackId}&quality={quality}"; var url = $"{baseUrl}/track/?id={trackId}&quality={quality}";
// Get download info from this endpoint // Get download info from this endpoint
var infoResponse = await _httpClient.GetAsync(url, cancellationToken); var infoResponse = await _httpClient.GetAsync(url, ct);
infoResponse.EnsureSuccessStatusCode(); infoResponse.EnsureSuccessStatusCode();
var json = await infoResponse.Content.ReadAsStringAsync(cancellationToken); var json = await infoResponse.Content.ReadAsStringAsync(ct);
var doc = JsonDocument.Parse(json); var doc = JsonDocument.Parse(json);
if (!doc.RootElement.TryGetProperty("data", out var data)) if (!doc.RootElement.TryGetProperty("data", out var data))
@@ -188,8 +185,8 @@ public class SquidWTFDownloadService : BaseDownloadService
request.Headers.Add("User-Agent", "Mozilla/5.0"); request.Headers.Add("User-Agent", "Mozilla/5.0");
request.Headers.Add("Accept", "*/*"); request.Headers.Add("Accept", "*/*");
return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
}); }, cancellationToken);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
@@ -202,26 +199,14 @@ public class SquidWTFDownloadService : BaseDownloadService
// Close file before writing metadata // Close file before writing metadata
await outputFile.DisposeAsync(); await outputFile.DisposeAsync();
// Start Spotify ID conversion in background (for lyrics support) // Wait for Spotify ID conversion to complete and update song metadata
// This doesn't block streaming - lyrics endpoint will fetch it on-demand if needed var spotifyId = await spotifyIdTask;
_ = Task.Run(async () =>
{
try
{
var spotifyId = await _odesliService.ConvertTidalToSpotifyIdAsync(trackId, CancellationToken.None);
if (!string.IsNullOrEmpty(spotifyId)) if (!string.IsNullOrEmpty(spotifyId))
{ {
Logger.LogDebug("Background Spotify ID obtained for Tidal/{TrackId}: {SpotifyId}", trackId, spotifyId); song.SpotifyId = spotifyId;
// Spotify ID is cached by Odesli service for future lyrics requests
} }
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Background Spotify ID conversion failed for Tidal/{TrackId}", trackId);
}
});
// Write metadata and cover art (without Spotify ID - it's only needed for lyrics) // Write metadata and cover art
await WriteMetadataAsync(outputPath, song, cancellationToken); await WriteMetadataAsync(outputPath, song, cancellationToken);
return outputPath; return outputPath;
@@ -243,8 +228,8 @@ public class SquidWTFDownloadService : BaseDownloadService
{ {
return await QueueRequestAsync(async () => return await QueueRequestAsync(async () =>
{ {
// Use round-robin with fallback instead of racing to reduce CPU usage // Race all endpoints for fastest download info retrieval
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) => return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
{ {
// Map quality settings to Tidal's quality levels per hifi-api spec // Map quality settings to Tidal's quality levels per hifi-api spec
var quality = _squidwtfSettings.Quality?.ToUpperInvariant() switch var quality = _squidwtfSettings.Quality?.ToUpperInvariant() switch
@@ -261,10 +246,10 @@ public class SquidWTFDownloadService : BaseDownloadService
Logger.LogDebug("Fetching track download info from: {Url}", url); Logger.LogDebug("Fetching track download info from: {Url}", url);
var response = await _httpClient.GetAsync(url, cancellationToken); var response = await _httpClient.GetAsync(url, ct);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(cancellationToken); var json = await response.Content.ReadAsStringAsync(ct);
var doc = JsonDocument.Parse(json); var doc = JsonDocument.Parse(json);
if (!doc.RootElement.TryGetProperty("data", out var data)) if (!doc.RootElement.TryGetProperty("data", out var data))
@@ -297,7 +282,8 @@ public class SquidWTFDownloadService : BaseDownloadService
? audioQualityEl.GetString() ? audioQualityEl.GetString()
: "LOSSLESS"; : "LOSSLESS";
Logger.LogInformation("Track download URL obtained from hifi-api: {Url}", downloadUrl); Logger.LogDebug("Decoded manifest - URL: {Url}, MIME: {MimeType}, Quality: {Quality}",
downloadUrl, mimeType, audioQuality);
return new DownloadResult return new DownloadResult
{ {
@@ -305,7 +291,7 @@ public class SquidWTFDownloadService : BaseDownloadService
MimeType = mimeType ?? "audio/flac", MimeType = mimeType ?? "audio/flac",
AudioQuality = audioQuality ?? "LOSSLESS" AudioQuality = audioQuality ?? "LOSSLESS"
}; };
}); }, cancellationToken);
}); });
} }
@@ -314,53 +300,6 @@ public class SquidWTFDownloadService : BaseDownloadService
#region Utility Methods #region Utility Methods
/// <summary>
/// Converts Tidal track ID to Spotify ID for lyrics support.
/// Called in background after streaming starts.
/// Also prefetches lyrics immediately after conversion.
/// </summary>
protected override async Task ConvertToSpotifyIdAsync(string externalProvider, string externalId)
{
if (externalProvider != "squidwtf")
{
return;
}
var spotifyId = await _odesliService.ConvertTidalToSpotifyIdAsync(externalId, CancellationToken.None);
if (!string.IsNullOrEmpty(spotifyId))
{
Logger.LogDebug("Background Spotify ID obtained for Tidal/{TrackId}: {SpotifyId}", externalId, spotifyId);
// Immediately prefetch lyrics now that we have the Spotify ID
// This ensures lyrics are cached and ready when the client requests them
_ = Task.Run(async () =>
{
try
{
using var scope = _serviceProvider.CreateScope();
var spotifyLyricsService = scope.ServiceProvider.GetService<SpotifyLyricsService>();
if (spotifyLyricsService != null)
{
var lyrics = await spotifyLyricsService.GetLyricsByTrackIdAsync(spotifyId);
if (lyrics != null && lyrics.Lines.Count > 0)
{
Logger.LogDebug("Background lyrics prefetched for Spotify/{SpotifyId}: {LineCount} lines",
spotifyId, lyrics.Lines.Count);
}
else
{
Logger.LogDebug("No lyrics available for Spotify/{SpotifyId}", spotifyId);
}
}
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Background lyrics prefetch failed for Spotify/{SpotifyId}", spotifyId);
}
});
}
}
#endregion #endregion

View File

@@ -74,9 +74,6 @@ public class SquidWTFMetadataService : IMusicMetadataService
// Set up default headers // Set up default headers
_httpClient.DefaultRequestHeaders.Add("User-Agent", _httpClient.DefaultRequestHeaders.Add("User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0"); "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0");
// Increase timeout for large artist/album responses (some artists have 100+ albums)
_httpClient.Timeout = TimeSpan.FromMinutes(5);
} }
@@ -593,18 +590,9 @@ public class SquidWTFMetadataService : IMusicMetadataService
? volNum.GetInt32() ? volNum.GetInt32()
: null; : null;
<<<<<<< HEAD
// Get all artists - Tidal provides both "artist" (singular) and "artists" (plural array) // Get all artists - Tidal provides both "artist" (singular) and "artists" (plural array)
var allArtists = new List<string>(); var allArtists = new List<string>();
||||||| bc4e5d9
// Get artist name - handle both single artist and artists array
=======
// Get all artists - Tidal provides both "artist" (singular) and "artists" (plural array)
var allArtists = new List<string>();
var allArtistIds = new List<string>();
>>>>>>> dev
string artistName = ""; string artistName = "";
<<<<<<< HEAD
string? artistId = null; string? artistId = null;
// Prefer the "artists" array as it includes all collaborators // Prefer the "artists" array as it includes all collaborators
@@ -628,61 +616,10 @@ public class SquidWTFMetadataService : IMusicMetadataService
} }
// Fallback to singular "artist" field // Fallback to singular "artist" field
else if (track.TryGetProperty("artist", out var artist)) else if (track.TryGetProperty("artist", out var artist))
||||||| bc4e5d9
if (track.TryGetProperty("artist", out var artist))
=======
string? artistId = null;
// Prefer the "artists" array as it includes all collaborators
if (track.TryGetProperty("artists", out var artists) && artists.GetArrayLength() > 0)
{
foreach (var artistEl in artists.EnumerateArray())
{
var name = artistEl.GetProperty("name").GetString();
var id = artistEl.GetProperty("id").GetInt64();
if (!string.IsNullOrEmpty(name))
{
allArtists.Add(name);
allArtistIds.Add($"ext-squidwtf-artist-{id}");
}
}
// First artist is the main artist
if (allArtists.Count > 0)
{
artistName = allArtists[0];
artistId = allArtistIds[0];
}
}
// Fallback to singular "artist" field
else if (track.TryGetProperty("artist", out var artist))
>>>>>>> dev
{ {
artistName = artist.GetProperty("name").GetString() ?? ""; artistName = artist.GetProperty("name").GetString() ?? "";
<<<<<<< HEAD
artistId = $"ext-squidwtf-artist-{artist.GetProperty("id").GetInt64()}"; artistId = $"ext-squidwtf-artist-{artist.GetProperty("id").GetInt64()}";
allArtists.Add(artistName); allArtists.Add(artistName);
||||||| bc4e5d9
}
else if (track.TryGetProperty("artists", out var artists) && artists.GetArrayLength() > 0)
{
artistName = artists[0].GetProperty("name").GetString() ?? "";
}
// Get artist ID
string? artistId = null;
if (track.TryGetProperty("artist", out var artistForId))
{
artistId = $"ext-squidwtf-artist-{artistForId.GetProperty("id").GetInt64()}";
}
else if (track.TryGetProperty("artists", out var artistsForId) && artistsForId.GetArrayLength() > 0)
{
artistId = $"ext-squidwtf-artist-{artistsForId[0].GetProperty("id").GetInt64()}";
=======
artistId = $"ext-squidwtf-artist-{artist.GetProperty("id").GetInt64()}";
allArtists.Add(artistName);
allArtistIds.Add(artistId);
>>>>>>> dev
} }
// Get album info // Get album info
@@ -708,13 +645,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
Title = track.GetProperty("title").GetString() ?? "", Title = track.GetProperty("title").GetString() ?? "",
Artist = artistName, Artist = artistName,
ArtistId = artistId, ArtistId = artistId,
<<<<<<< HEAD
Artists = allArtists, Artists = allArtists,
||||||| bc4e5d9
=======
Artists = allArtists,
ArtistIds = allArtistIds,
>>>>>>> dev
Album = albumTitle, Album = albumTitle,
AlbumId = albumId, AlbumId = albumId,
Duration = track.TryGetProperty("duration", out var duration) Duration = track.TryGetProperty("duration", out var duration)
@@ -775,7 +706,6 @@ public class SquidWTFMetadataService : IMusicMetadataService
} }
} }
<<<<<<< HEAD
// Get all artists - prefer "artists" array for collaborations // Get all artists - prefer "artists" array for collaborations
var allArtists = new List<string>(); var allArtists = new List<string>();
string artistName = ""; string artistName = "";
@@ -804,44 +734,6 @@ public class SquidWTFMetadataService : IMusicMetadataService
artistIdNum = artist.GetProperty("id").GetInt64(); artistIdNum = artist.GetProperty("id").GetInt64();
allArtists.Add(artistName); allArtists.Add(artistName);
} }
||||||| bc4e5d9
// Get artist info
string artistName = track.GetProperty("artist").GetProperty("name").GetString() ?? "";
long artistIdNum = track.GetProperty("artist").GetProperty("id").GetInt64();
=======
// Get all artists - prefer "artists" array for collaborations
var allArtists = new List<string>();
var allArtistIds = new List<string>();
string artistName = "";
long artistIdNum = 0;
if (track.TryGetProperty("artists", out var artists) && artists.GetArrayLength() > 0)
{
foreach (var artistEl in artists.EnumerateArray())
{
var name = artistEl.GetProperty("name").GetString();
var id = artistEl.GetProperty("id").GetInt64();
if (!string.IsNullOrEmpty(name))
{
allArtists.Add(name);
allArtistIds.Add($"ext-squidwtf-artist-{id}");
}
}
if (allArtists.Count > 0)
{
artistName = allArtists[0];
artistIdNum = artists[0].GetProperty("id").GetInt64();
}
}
else if (track.TryGetProperty("artist", out var artist))
{
artistName = artist.GetProperty("name").GetString() ?? "";
artistIdNum = artist.GetProperty("id").GetInt64();
allArtists.Add(artistName);
allArtistIds.Add($"ext-squidwtf-artist-{artistIdNum}");
}
>>>>>>> dev
// Album artist - same as main artist for Tidal tracks // Album artist - same as main artist for Tidal tracks
string? albumArtist = artistName; string? albumArtist = artistName;
@@ -875,13 +767,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
Title = track.GetProperty("title").GetString() ?? "", Title = track.GetProperty("title").GetString() ?? "",
Artist = artistName, Artist = artistName,
ArtistId = $"ext-squidwtf-artist-{artistIdNum}", ArtistId = $"ext-squidwtf-artist-{artistIdNum}",
<<<<<<< HEAD
Artists = allArtists, Artists = allArtists,
||||||| bc4e5d9
=======
Artists = allArtists,
ArtistIds = allArtistIds,
>>>>>>> dev
Album = albumTitle, Album = albumTitle,
AlbumId = $"ext-squidwtf-album-{albumIdNum}", AlbumId = $"ext-squidwtf-album-{albumIdNum}",
AlbumArtist = albumArtist, AlbumArtist = albumArtist,

View File

@@ -50,7 +50,6 @@ public class SquidWTFStartupValidator : BaseStartupValidator
WriteStatus("SquidWTF Quality", quality, ConsoleColor.Cyan); WriteStatus("SquidWTF Quality", quality, ConsoleColor.Cyan);
<<<<<<< HEAD
// Benchmark all endpoints to determine fastest // Benchmark all endpoints to determine fastest
var apiUrls = _fallbackHelper.EndpointCount > 0 var apiUrls = _fallbackHelper.EndpointCount > 0
? Enumerable.Range(0, _fallbackHelper.EndpointCount).Select(_ => "").ToList() // Placeholder, we'll get actual URLs from fallback helper ? Enumerable.Range(0, _fallbackHelper.EndpointCount).Select(_ => "").ToList() // Placeholder, we'll get actual URLs from fallback helper
@@ -92,54 +91,6 @@ public class SquidWTFStartupValidator : BaseStartupValidator
} }
} }
||||||| bc4e5d9
=======
// Benchmark all endpoints to determine fastest
var apiUrls = _fallbackHelper.EndpointCount > 0
? Enumerable.Range(0, _fallbackHelper.EndpointCount).Select(_ => "").ToList() // Placeholder, we'll get actual URLs from fallback helper
: new List<string>();
// Get the actual API URLs by reflection (not ideal, but works for now)
var fallbackHelperType = _fallbackHelper.GetType();
var apiUrlsField = fallbackHelperType.GetField("_apiUrls", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
if (apiUrlsField != null)
{
apiUrls = (List<string>)apiUrlsField.GetValue(_fallbackHelper)!;
}
if (apiUrls.Count > 1)
{
WriteStatus("Benchmarking Endpoints", $"{apiUrls.Count} endpoints", ConsoleColor.Cyan);
var orderedEndpoints = await _benchmarkService.BenchmarkEndpointsAsync(
apiUrls,
async (endpoint, ct) =>
{
try
{
// 5 second timeout per ping - mark slow endpoints as failed
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(5));
var response = await _httpClient.GetAsync(endpoint, timeoutCts.Token);
return response.IsSuccessStatusCode;
}
catch
{
return false;
}
},
pingCount: 2,
cancellationToken);
if (orderedEndpoints.Count > 0)
{
_fallbackHelper.SetEndpointOrder(orderedEndpoints);
WriteDetail($"Fastest endpoint: {orderedEndpoints.First()}");
}
}
>>>>>>> dev
// Test connectivity with fallback // Test connectivity with fallback
var result = await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) => var result = await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{ {

View File

@@ -12,7 +12,6 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" /> <PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
<PackageReference Include="Cronos" Version="0.11.1" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.4" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.4" />
<PackageReference Include="Otp.NET" Version="1.4.1" /> <PackageReference Include="Otp.NET" Version="1.4.1" />
<PackageReference Include="StackExchange.Redis" Version="2.8.16" /> <PackageReference Include="StackExchange.Redis" Version="2.8.16" />

View File

@@ -1,4 +1,3 @@
<<<<<<< HEAD
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -540,7 +539,6 @@
<div class="tab" data-tab="jellyfin-playlists">Link Playlists</div> <div class="tab" data-tab="jellyfin-playlists">Link Playlists</div>
<div class="tab" data-tab="playlists">Active Playlists</div> <div class="tab" data-tab="playlists">Active Playlists</div>
<div class="tab" data-tab="config">Configuration</div> <div class="tab" data-tab="config">Configuration</div>
<div class="tab" data-tab="endpoints">API Analytics</div>
</div> </div>
<!-- Dashboard Tab --> <!-- Dashboard Tab -->
@@ -982,85 +980,6 @@
</div> </div>
</div> </div>
</div> </div>
<!-- API Analytics Tab -->
<div class="tab-content" id="tab-endpoints">
<div class="card">
<h2>
API Endpoint Usage
<div class="actions">
<button onclick="fetchEndpointUsage()">Refresh</button>
<button class="danger" onclick="clearEndpointUsage()">Clear Data</button>
</div>
</h2>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
Track which Jellyfin API endpoints are being called most frequently. Useful for debugging and understanding client behavior.
</p>
<div id="endpoints-summary" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 20px;">
<div style="background: var(--bg-tertiary); padding: 16px; border-radius: 8px;">
<div style="color: var(--text-secondary); font-size: 0.85rem; margin-bottom: 4px;">Total Requests</div>
<div style="font-size: 1.8rem; font-weight: 600; color: var(--accent);" id="endpoints-total-requests">0</div>
</div>
<div style="background: var(--bg-tertiary); padding: 16px; border-radius: 8px;">
<div style="color: var(--text-secondary); font-size: 0.85rem; margin-bottom: 4px;">Unique Endpoints</div>
<div style="font-size: 1.8rem; font-weight: 600; color: var(--success);" id="endpoints-unique-count">0</div>
</div>
<div style="background: var(--bg-tertiary); padding: 16px; border-radius: 8px;">
<div style="color: var(--text-secondary); font-size: 0.85rem; margin-bottom: 4px;">Most Called</div>
<div style="font-size: 1.1rem; font-weight: 600; color: var(--text-primary); word-break: break-all;" id="endpoints-most-called">-</div>
</div>
</div>
<div style="margin-bottom: 16px;">
<label style="display: block; margin-bottom: 8px; color: var(--text-secondary); font-size: 0.9rem;">Show Top</label>
<select id="endpoints-top-select" onchange="fetchEndpointUsage()" style="padding: 8px; background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary);">
<option value="25">Top 25</option>
<option value="50" selected>Top 50</option>
<option value="100">Top 100</option>
<option value="500">Top 500</option>
</select>
</div>
<div style="max-height: 600px; overflow-y: auto;">
<table class="playlist-table">
<thead>
<tr>
<th style="width: 60px;">#</th>
<th>Endpoint</th>
<th style="width: 120px; text-align: right;">Requests</th>
<th style="width: 120px; text-align: right;">% of Total</th>
</tr>
</thead>
<tbody id="endpoints-table-body">
<tr>
<td colspan="4" class="loading">
<span class="spinner"></span> Loading endpoint usage data...
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="card">
<h2>About Endpoint Tracking</h2>
<p style="color: var(--text-secondary); line-height: 1.6;">
Allstarr logs every Jellyfin API endpoint call to help you understand how clients interact with your server.
This data is stored in <code style="background: var(--bg-tertiary); padding: 2px 6px; border-radius: 4px;">/app/cache/endpoint-usage/endpoints.csv</code>
and persists across restarts.
<br><br>
<strong>Common Endpoints:</strong>
<ul style="margin-top: 8px; margin-left: 20px;">
<li><code>/Users/{userId}/Items</code> - Browse library items</li>
<li><code>/Items/{itemId}</code> - Get item details</li>
<li><code>/Audio/{itemId}/stream</code> - Stream audio</li>
<li><code>/Sessions/Playing</code> - Report playback status</li>
<li><code>/Search/Hints</code> - Search functionality</li>
</ul>
</p>
</div>
</div>
</div> </div>
<!-- Add Playlist Modal --> <!-- Add Playlist Modal -->
@@ -2945,7 +2864,6 @@
fetchJellyfinUsers(); fetchJellyfinUsers();
fetchJellyfinPlaylists(); fetchJellyfinPlaylists();
fetchConfig(); fetchConfig();
fetchEndpointUsage();
// Auto-refresh every 30 seconds // Auto-refresh every 30 seconds
setInterval(() => { setInterval(() => {
@@ -2954,3441 +2872,7 @@
fetchTrackMappings(); fetchTrackMappings();
fetchMissingTracks(); fetchMissingTracks();
fetchDownloads(); fetchDownloads();
// Refresh endpoint usage if on that tab
const endpointsTab = document.getElementById('tab-endpoints');
if (endpointsTab && endpointsTab.classList.contains('active')) {
fetchEndpointUsage();
}
}, 30000); }, 30000);
// Endpoint Usage Functions
async function fetchEndpointUsage() {
try {
const topSelect = document.getElementById('endpoints-top-select');
const top = topSelect ? topSelect.value : 50;
const res = await fetch(`/api/admin/debug/endpoint-usage?top=${top}`);
const data = await res.json();
// Update summary stats
document.getElementById('endpoints-total-requests').textContent = data.totalRequests?.toLocaleString() || '0';
document.getElementById('endpoints-unique-count').textContent = data.totalEndpoints?.toLocaleString() || '0';
const mostCalled = data.endpoints && data.endpoints.length > 0
? data.endpoints[0].endpoint
: '-';
document.getElementById('endpoints-most-called').textContent = mostCalled;
// Update table
const tbody = document.getElementById('endpoints-table-body');
if (!data.endpoints || data.endpoints.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:var(--text-secondary);padding:40px;">No endpoint usage data available yet. Data will appear as clients make requests.</td></tr>';
return;
}
tbody.innerHTML = data.endpoints.map((ep, index) => {
const percentage = data.totalRequests > 0
? ((ep.count / data.totalRequests) * 100).toFixed(1)
: '0.0';
// Color code based on usage
let countColor = 'var(--text-primary)';
if (ep.count > 1000) countColor = 'var(--error)';
else if (ep.count > 100) countColor = 'var(--warning)';
else if (ep.count > 10) countColor = 'var(--accent)';
// Highlight common patterns
let endpointDisplay = ep.endpoint;
if (ep.endpoint.includes('/stream')) {
endpointDisplay = `<span style="color:var(--success)">${escapeHtml(ep.endpoint)}</span>`;
} else if (ep.endpoint.includes('/Playing')) {
endpointDisplay = `<span style="color:var(--accent)">${escapeHtml(ep.endpoint)}</span>`;
} else if (ep.endpoint.includes('/Search')) {
endpointDisplay = `<span style="color:var(--warning)">${escapeHtml(ep.endpoint)}</span>`;
} else {
endpointDisplay = escapeHtml(ep.endpoint);
}
return `
<tr>
<td style="color:var(--text-secondary);text-align:center;">${index + 1}</td>
<td style="font-family:monospace;font-size:0.85rem;">${endpointDisplay}</td>
<td style="text-align:right;font-weight:600;color:${countColor}">${ep.count.toLocaleString()}</td>
<td style="text-align:right;color:var(--text-secondary)">${percentage}%</td>
</tr>
`;
}).join('');
} catch (error) {
console.error('Failed to fetch endpoint usage:', error);
const tbody = document.getElementById('endpoints-table-body');
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:var(--error);padding:40px;">Failed to load endpoint usage data</td></tr>';
}
}
async function clearEndpointUsage() {
if (!confirm('Are you sure you want to clear all endpoint usage data? This cannot be undone.')) {
return;
}
try {
const res = await fetch('/api/admin/debug/endpoint-usage', { method: 'DELETE' });
const data = await res.json();
showToast(data.message || 'Endpoint usage data cleared', 'success');
fetchEndpointUsage();
} catch (error) {
console.error('Failed to clear endpoint usage:', error);
showToast('Failed to clear endpoint usage data', 'error');
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
</script> </script>
</body> </body>
</html> </html>
||||||| bc4e5d9
=======
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Allstarr Dashboard</title>
<style>
:root {
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--text-primary: #f0f6fc;
--text-secondary: #8b949e;
--accent: #58a6ff;
--accent-hover: #79c0ff;
--success: #3fb950;
--warning: #d29922;
--error: #f85149;
--border: #30363d;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.5;
min-height: 100vh;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 0;
border-bottom: 1px solid var(--border);
margin-bottom: 30px;
}
h1 {
font-size: 1.8rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 10px;
}
h1 .version {
font-size: 0.8rem;
color: var(--text-secondary);
font-weight: normal;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
}
.status-badge.success { background: rgba(63, 185, 80, 0.2); color: var(--success); }
.status-badge.warning { background: rgba(210, 153, 34, 0.2); color: var(--warning); }
.status-badge.error { background: rgba(248, 81, 73, 0.2); color: var(--error); }
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
}
.card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.card h2 {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 16px;
display: flex;
align-items: center;
justify-content: space-between;
}
.card h2 .actions {
display: flex;
gap: 8px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
}
.stat-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid var(--border);
}
.stat-row:last-child {
border-bottom: none;
}
.stat-label {
color: var(--text-secondary);
}
.stat-value {
font-weight: 500;
}
.stat-value.success { color: var(--success); }
.stat-value.warning { color: var(--warning); }
.stat-value.error { color: var(--error); }
button {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border);
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}
button:hover {
background: var(--border);
}
button.primary {
background: var(--accent);
border-color: var(--accent);
color: white;
}
button.primary:hover {
background: var(--accent-hover);
}
button.danger {
background: rgba(248, 81, 73, 0.15);
border-color: var(--error);
color: var(--error);
}
button.danger:hover {
background: rgba(248, 81, 73, 0.3);
}
.playlist-table {
width: 100%;
border-collapse: collapse;
}
.playlist-table th,
.playlist-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid var(--border);
}
.playlist-table th {
color: var(--text-secondary);
font-weight: 500;
font-size: 0.85rem;
}
.playlist-table tr:hover td {
background: var(--bg-tertiary);
}
.playlist-table .track-count {
font-family: monospace;
color: var(--accent);
}
.playlist-table .cache-age {
color: var(--text-secondary);
font-size: 0.85rem;
}
.input-group {
display: flex;
gap: 8px;
margin-top: 16px;
}
input, select {
background: var(--bg-tertiary);
border: 1px solid var(--border);
color: var(--text-primary);
padding: 8px 12px;
border-radius: 6px;
font-size: 0.9rem;
}
input:focus, select:focus {
outline: none;
border-color: var(--accent);
}
input::placeholder {
color: var(--text-secondary);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr 120px auto;
gap: 8px;
align-items: end;
}
.form-row label {
display: block;
font-size: 0.8rem;
color: var(--text-secondary);
margin-bottom: 4px;
}
.config-section {
margin-bottom: 24px;
}
.config-section h3 {
font-size: 0.95rem;
color: var(--text-secondary);
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.config-item {
display: grid;
grid-template-columns: 200px 1fr auto;
gap: 16px;
align-items: center;
padding: 12px;
background: var(--bg-tertiary);
border-radius: 6px;
margin-bottom: 8px;
}
.config-item .label {
font-weight: 500;
}
.config-item .value {
font-family: monospace;
color: var(--text-secondary);
}
.toast {
position: fixed;
bottom: 20px;
right: 20px;
padding: 12px 20px;
border-radius: 8px;
background: var(--bg-secondary);
border: 1px solid var(--border);
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
z-index: 1000;
animation: slideIn 0.3s ease;
}
.toast.success { border-color: var(--success); }
.toast.error { border-color: var(--error); }
.toast.warning { border-color: var(--warning); }
.toast.info { border-color: var(--accent); }
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.restart-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--bg-primary);
z-index: 9999;
justify-content: center;
align-items: center;
flex-direction: column;
gap: 20px;
}
.restart-overlay.active {
display: flex;
}
.restart-overlay .spinner-large {
width: 48px;
height: 48px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
}
.restart-overlay h2 {
color: var(--text-primary);
font-size: 1.5rem;
}
.restart-overlay p {
color: var(--text-secondary);
}
.restart-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
background: var(--warning);
color: var(--bg-primary);
padding: 12px 20px;
text-align: center;
font-weight: 500;
z-index: 9998;
display: none;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
.restart-banner.active {
display: block;
}
.restart-banner button {
margin-left: 16px;
background: var(--bg-primary);
color: var(--text-primary);
border: none;
padding: 6px 16px;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
}
.restart-banner button:hover {
background: var(--bg-secondary);
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.7);
z-index: 1000;
justify-content: center;
align-items: center;
}
.modal.active {
display: flex;
}
.modal-content {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px;
max-width: 75%;
width: 75%;
max-height: 65vh;
overflow-y: auto;
}
.modal-content h3 {
margin-bottom: 20px;
}
.modal-content .form-group {
margin-bottom: 16px;
}
.modal-content .form-group label {
display: block;
margin-bottom: 6px;
color: var(--text-secondary);
}
.modal-content .form-group input,
.modal-content .form-group select {
width: 100%;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 24px;
}
.tabs {
display: flex;
border-bottom: 1px solid var(--border);
margin-bottom: 20px;
}
.tab {
padding: 12px 20px;
cursor: pointer;
color: var(--text-secondary);
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.tab:hover {
color: var(--text-primary);
}
.tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.tracks-list {
max-height: 400px;
overflow-y: auto;
}
.track-item {
display: grid;
grid-template-columns: 40px 1fr auto;
gap: 12px;
align-items: center;
padding: 8px;
border-bottom: 1px solid var(--border);
}
.track-item:hover {
background: var(--bg-tertiary);
}
.track-position {
color: var(--text-secondary);
font-size: 0.85rem;
text-align: center;
}
.track-info h4 {
font-weight: 500;
font-size: 0.95rem;
}
.track-info .artists {
color: var(--text-secondary);
font-size: 0.85rem;
}
.track-meta {
text-align: right;
color: var(--text-secondary);
font-size: 0.8rem;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
color: var(--text-secondary);
}
.spinner {
width: 24px;
height: 24px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-right: 12px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
</head>
<body>
<!-- Restart Required Banner -->
<div class="restart-banner" id="restart-banner">
⚠️ Configuration changed. Restart required to apply changes.
<button onclick="restartContainer()">Restart Now</button>
<button onclick="dismissRestartBanner()" style="background: transparent; border: 1px solid var(--bg-primary);">Dismiss</button>
</div>
<div class="container">
<header>
<h1>
Allstarr <span class="version" id="version">v1.0.0</span>
</h1>
<div id="status-indicator">
<span class="status-badge" id="spotify-status">
<span class="status-dot"></span>
<span>Loading...</span>
</span>
</div>
</header>
<div class="tabs">
<div class="tab active" data-tab="dashboard">Dashboard</div>
<div class="tab" data-tab="jellyfin-playlists">Link Playlists</div>
<div class="tab" data-tab="playlists">Injected Playlists</div>
<div class="tab" data-tab="config">Configuration</div>
<div class="tab" data-tab="endpoints">API Analytics</div>
</div>
<!-- Dashboard Tab -->
<div class="tab-content active" id="tab-dashboard">
<div class="grid">
<div class="card">
<h2>Spotify API</h2>
<div class="stat-row">
<span class="stat-label">Status</span>
<span class="stat-value" id="spotify-auth-status">Loading...</span>
</div>
<div class="stat-row">
<span class="stat-label">User</span>
<span class="stat-value" id="spotify-user">-</span>
</div>
<div class="stat-row">
<span class="stat-label">Cookie Age</span>
<span class="stat-value" id="spotify-cookie-age">-</span>
</div>
<div class="stat-row">
<span class="stat-label">Cache Duration</span>
<span class="stat-value" id="cache-duration">-</span>
</div>
<div class="stat-row">
<span class="stat-label">ISRC Matching</span>
<span class="stat-value" id="isrc-matching">-</span>
</div>
</div>
<div class="card">
<h2>Jellyfin</h2>
<div class="stat-row">
<span class="stat-label">Backend</span>
<span class="stat-value" id="backend-type">-</span>
</div>
<div class="stat-row">
<span class="stat-label">URL</span>
<span class="stat-value" id="jellyfin-url">-</span>
</div>
<div class="stat-row">
<span class="stat-label">Playlists</span>
<span class="stat-value" id="playlist-count">-</span>
</div>
</div>
</div>
<div class="card">
<h2>
Quick Actions
</h2>
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
<button class="primary" onclick="refreshPlaylists()">Refresh All Playlists</button>
<button onclick="clearCache()">Clear Cache</button>
<button onclick="openAddPlaylist()">Add Playlist</button>
</div>
</div>
</div>
<!-- Link Playlists Tab -->
<div class="tab-content" id="tab-jellyfin-playlists">
<div class="card">
<h2>
Link Jellyfin Playlists to Spotify
<div class="actions">
<button onclick="fetchJellyfinPlaylists()">Refresh</button>
</div>
</h2>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
Connect your Jellyfin playlists to Spotify playlists. Allstarr will automatically fill in missing tracks from Spotify using your preferred music service (SquidWTF/Deezer/Qobuz).
<br><strong>Tip:</strong> Use the sp_dc cookie method for best results - it's simpler and more reliable.
</p>
<div style="display: flex; gap: 16px; margin-bottom: 16px; flex-wrap: wrap;">
<div class="form-group" style="margin: 0; flex: 1; min-width: 200px;">
<label style="display: block; margin-bottom: 4px; color: var(--text-secondary); font-size: 0.85rem;">User</label>
<select id="jellyfin-user-select" onchange="fetchJellyfinPlaylists()" style="width: 100%; padding: 8px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary);">
<option value="">All Users</option>
</select>
</div>
</div>
<table class="playlist-table">
<thead>
<tr>
<th>Name</th>
<th>Local</th>
<th>External</th>
<th>Linked Spotify ID</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="jellyfin-playlist-table-body">
<tr>
<td colspan="6" class="loading">
<span class="spinner"></span> Loading Jellyfin playlists...
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Active Playlists Tab -->
<div class="tab-content" id="tab-playlists">
<!-- Warning Banner (hidden by default) -->
<div id="matching-warning-banner" style="display:none;background:#f59e0b;color:#000;padding:16px;border-radius:8px;margin-bottom:16px;font-weight:600;text-align:center;box-shadow:0 4px 6px rgba(0,0,0,0.1);">
⚠️ TRACK MATCHING IN PROGRESS - Please wait for matching to complete before making changes to playlists or mappings!
</div>
<div class="card">
<h2>
Injected Spotify Playlists
<div class="actions">
<button onclick="matchAllPlaylists()" title="Match tracks for all playlists against your local library and external providers. This may take several minutes.">Match All Tracks</button>
<button onclick="refreshPlaylists()" title="Fetch the latest playlist data from Spotify without re-matching tracks.">Refresh All</button>
<button onclick="refreshAndMatchAll()" title="Clear caches, fetch fresh data from Spotify, and match all tracks. This is a full rebuild and may take several minutes." style="background:var(--accent);border-color:var(--accent);">Refresh & Match All</button>
</div>
</h2>
<p style="color: var(--text-secondary); margin-bottom: 12px;">
These are the Spotify playlists currently being injected into Jellyfin with tracks from your music service.
</p>
<table class="playlist-table">
<thead>
<tr>
<th>Name</th>
<th>Spotify ID</th>
<th>Sync Schedule</th>
<th>Tracks</th>
<th>Completion</th>
<th>Cache Age</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="playlist-table-body">
<tr>
<td colspan="7" class="loading">
<span class="spinner"></span> Loading playlists...
</td>
</tr>
</tbody>
</table>
</div>
<!-- Manual Track Mappings Section -->
<div class="card">
<h2>
Manual Track Mappings
<div class="actions">
<button onclick="fetchTrackMappings()">Refresh</button>
</div>
</h2>
<p style="color: var(--text-secondary); margin-bottom: 12px;">
Manual mappings override automatic matching for external providers (SquidWTF, Deezer, Qobuz). For local Jellyfin tracks, use the Spotify Import plugin instead.
</p>
<div id="mappings-summary" style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
<div>
<span style="color: var(--text-secondary);">Total:</span>
<span style="font-weight: 600; margin-left: 8px;" id="mappings-total">0</span>
</div>
<div>
<span style="color: var(--text-secondary);">External:</span>
<span style="font-weight: 600; margin-left: 8px; color: var(--success);" id="mappings-external">0</span>
</div>
</div>
<table class="playlist-table">
<thead>
<tr>
<th>Playlist</th>
<th>Spotify ID</th>
<th>Type</th>
<th>Target</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="mappings-table-body">
<tr>
<td colspan="6" class="loading">
<span class="spinner"></span> Loading mappings...
</td>
</tr>
</tbody>
</table>
</div>
<!-- Missing Tracks Section -->
<div class="card">
<h2>
Missing Tracks (All Playlists)
<div class="actions">
<button onclick="fetchMissingTracks()">Refresh</button>
</div>
</h2>
<p style="color: var(--text-secondary); margin-bottom: 12px;">
Tracks that couldn't be matched locally or externally. Map them manually to add them to your playlists.
</p>
<div id="missing-summary" style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
<div>
<span style="color: var(--text-secondary);">Total Missing:</span>
<span style="font-weight: 600; margin-left: 8px; color: var(--warning);" id="missing-total">0</span>
</div>
</div>
<table class="playlist-table">
<thead>
<tr>
<th>Playlist</th>
<th>Track</th>
<th>Artist</th>
<th>Album</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="missing-tracks-table-body">
<tr>
<td colspan="5" class="loading">
<span class="spinner"></span> Loading missing tracks...
</td>
</tr>
</tbody>
</table>
</div>
<!-- Kept Downloads Section -->
<div class="card">
<h2>
Kept Downloads
<div class="actions">
<button onclick="fetchDownloads()">Refresh</button>
</div>
</h2>
<p style="color: var(--text-secondary); margin-bottom: 12px;">
Downloaded files stored permanently. Download or delete individual tracks.
</p>
<div id="downloads-summary" style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
<div>
<span style="color: var(--text-secondary);">Total Files:</span>
<span style="font-weight: 600; margin-left: 8px; color: var(--accent);" id="downloads-count">0</span>
</div>
<div>
<span style="color: var(--text-secondary);">Total Size:</span>
<span style="font-weight: 600; margin-left: 8px; color: var(--accent);" id="downloads-size">0 B</span>
</div>
</div>
<table class="playlist-table">
<thead>
<tr>
<th>Artist</th>
<th>Album</th>
<th>File</th>
<th>Size</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="downloads-table-body">
<tr>
<td colspan="5" class="loading">
<span class="spinner"></span> Loading downloads...
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Configuration Tab -->
<div class="tab-content" id="tab-config">
<div class="card">
<h2>Core Settings</h2>
<div class="config-section">
<div class="config-item">
<span class="label">Backend Type <span style="color: var(--error);">*</span></span>
<span class="value" id="config-backend-type">-</span>
<button onclick="openEditSetting('BACKEND_TYPE', 'Backend Type', 'select', 'Choose your media server backend', ['Jellyfin', 'Subsonic'])">Edit</button>
</div>
<div class="config-item">
<span class="label">Music Service <span style="color: var(--error);">*</span></span>
<span class="value" id="config-music-service">-</span>
<button onclick="openEditSetting('MUSIC_SERVICE', 'Music Service', 'select', 'Choose your music download provider', ['SquidWTF', 'Deezer', 'Qobuz'])">Edit</button>
</div>
<div class="config-item">
<span class="label">Storage Mode</span>
<span class="value" id="config-storage-mode">-</span>
<button onclick="openEditSetting('STORAGE_MODE', 'Storage Mode', 'select', 'Permanent keeps files forever, Cache auto-deletes after duration', ['Permanent', 'Cache'])">Edit</button>
</div>
<div class="config-item" id="cache-duration-row" style="display: none;">
<span class="label">Cache Duration (hours)</span>
<span class="value" id="config-cache-duration-hours">-</span>
<button onclick="openEditSetting('CACHE_DURATION_HOURS', 'Cache Duration (hours)', 'number', 'How long to keep cached files before deletion')">Edit</button>
</div>
<div class="config-item">
<span class="label">Download Mode</span>
<span class="value" id="config-download-mode">-</span>
<button onclick="openEditSetting('DOWNLOAD_MODE', 'Download Mode', 'select', 'Download individual tracks or full albums', ['Track', 'Album'])">Edit</button>
</div>
<div class="config-item">
<span class="label">Explicit Filter</span>
<span class="value" id="config-explicit-filter">-</span>
<button onclick="openEditSetting('EXPLICIT_FILTER', 'Explicit Filter', 'select', 'Filter explicit content', ['All', 'Explicit', 'Clean'])">Edit</button>
</div>
<div class="config-item">
<span class="label">Enable External Playlists</span>
<span class="value" id="config-enable-external-playlists">-</span>
<button onclick="openEditSetting('ENABLE_EXTERNAL_PLAYLISTS', 'Enable External Playlists', 'toggle')">Edit</button>
</div>
<div class="config-item">
<span class="label">Playlists Directory</span>
<span class="value" id="config-playlists-directory">-</span>
<button onclick="openEditSetting('PLAYLISTS_DIRECTORY', 'Playlists Directory', 'text', 'Directory path for external playlists')">Edit</button>
</div>
<div class="config-item">
<span class="label">Redis Enabled</span>
<span class="value" id="config-redis-enabled">-</span>
<button onclick="openEditSetting('REDIS_ENABLED', 'Redis Enabled', 'toggle')">Edit</button>
</div>
</div>
</div>
<div class="card">
<h2>Spotify API Settings</h2>
<div style="background: rgba(248, 81, 73, 0.15); border: 1px solid var(--error); border-radius: 6px; padding: 12px; margin-bottom: 16px; color: var(--text-primary);">
⚠️ For active playlists and link functionality to work, sp_dc session cookie must be set!
</div>
<div class="config-section">
<div class="config-item">
<span class="label">API Enabled</span>
<span class="value" id="config-spotify-enabled">-</span>
<button onclick="openEditSetting('SPOTIFY_API_ENABLED', 'Spotify API Enabled', 'toggle')">Edit</button>
</div>
<div class="config-item">
<span class="label">Session Cookie (sp_dc) <span style="color: var(--error);">*</span></span>
<span class="value" id="config-spotify-cookie">-</span>
<button onclick="openEditSetting('SPOTIFY_API_SESSION_COOKIE', 'Spotify Session Cookie', 'password', 'Get from browser dev tools while logged into Spotify. Cookie typically lasts ~1 year.')">Update</button>
</div>
<div class="config-item" style="grid-template-columns: 200px 1fr;">
<span class="label">Cookie Age</span>
<span class="value" id="config-cookie-age">-</span>
</div>
<div class="config-item">
<span class="label">Cache Duration</span>
<span class="value" id="config-cache-duration">-</span>
<button onclick="openEditSetting('SPOTIFY_API_CACHE_DURATION_MINUTES', 'Cache Duration (minutes)', 'number')">Edit</button>
</div>
<div class="config-item">
<span class="label">ISRC Matching</span>
<span class="value" id="config-isrc-matching">-</span>
<button onclick="openEditSetting('SPOTIFY_API_PREFER_ISRC_MATCHING', 'Prefer ISRC Matching', 'toggle')">Edit</button>
</div>
</div>
</div>
<div class="card">
<h2>Deezer Settings</h2>
<div class="config-section">
<div class="config-item">
<span class="label">ARL Token</span>
<span class="value" id="config-deezer-arl">-</span>
<button onclick="openEditSetting('DEEZER_ARL', 'Deezer ARL Token', 'password', 'Get from browser cookies while logged into Deezer')">Update</button>
</div>
<div class="config-item">
<span class="label">Quality</span>
<span class="value" id="config-deezer-quality">-</span>
<button onclick="openEditSetting('DEEZER_QUALITY', 'Deezer Quality', 'select', '', ['FLAC', 'MP3_320', 'MP3_128'])">Edit</button>
</div>
</div>
</div>
<div class="card">
<h2>SquidWTF / Tidal Settings</h2>
<div class="config-section">
<div class="config-item">
<span class="label">Quality</span>
<span class="value" id="config-squid-quality">-</span>
<button onclick="openEditSetting('SQUIDWTF_QUALITY', 'SquidWTF Quality', 'select', '', ['LOSSLESS', 'HIGH', 'LOW'])">Edit</button>
</div>
</div>
</div>
<div class="card">
<h2>MusicBrainz Settings</h2>
<div class="config-section">
<div class="config-item">
<span class="label">Enabled</span>
<span class="value" id="config-musicbrainz-enabled">-</span>
<button onclick="openEditSetting('MUSICBRAINZ_ENABLED', 'MusicBrainz Enabled', 'select', '', ['true', 'false'])">Edit</button>
</div>
<div class="config-item">
<span class="label">Username</span>
<span class="value" id="config-musicbrainz-username">-</span>
<button onclick="openEditSetting('MUSICBRAINZ_USERNAME', 'MusicBrainz Username', 'text', 'Your MusicBrainz username')">Update</button>
</div>
<div class="config-item">
<span class="label">Password</span>
<span class="value" id="config-musicbrainz-password">-</span>
<button onclick="openEditSetting('MUSICBRAINZ_PASSWORD', 'MusicBrainz Password', 'password', 'Your MusicBrainz password')">Update</button>
</div>
</div>
</div>
<div class="card">
<h2>Qobuz Settings</h2>
<div class="config-section">
<div class="config-item">
<span class="label">User Auth Token</span>
<span class="value" id="config-qobuz-token">-</span>
<button onclick="openEditSetting('QOBUZ_USER_AUTH_TOKEN', 'Qobuz User Auth Token', 'password', 'Get from browser while logged into Qobuz')">Update</button>
</div>
<div class="config-item">
<span class="label">Quality</span>
<span class="value" id="config-qobuz-quality">-</span>
<button onclick="openEditSetting('QOBUZ_QUALITY', 'Qobuz Quality', 'select', '', ['FLAC_24_192', 'FLAC_24_96', 'FLAC_16_44', 'MP3_320'])">Edit</button>
</div>
</div>
</div>
<div class="card">
<h2>Jellyfin Settings</h2>
<div class="config-section">
<div class="config-item">
<span class="label">URL <span style="color: var(--error);">*</span></span>
<span class="value" id="config-jellyfin-url">-</span>
<button onclick="openEditSetting('JELLYFIN_URL', 'Jellyfin URL', 'text')">Edit</button>
</div>
<div class="config-item">
<span class="label">API Key <span style="color: var(--error);">*</span></span>
<span class="value" id="config-jellyfin-api-key">-</span>
<button onclick="openEditSetting('JELLYFIN_API_KEY', 'Jellyfin API Key', 'password')">Update</button>
</div>
<div class="config-item">
<span class="label">User ID <span style="color: var(--error);">*</span></span>
<span class="value" id="config-jellyfin-user-id">-</span>
<button onclick="openEditSetting('JELLYFIN_USER_ID', 'Jellyfin User ID', 'text', 'Required for playlist operations. Get from Jellyfin user profile URL: userId=...')">Edit</button>
</div>
<div class="config-item">
<span class="label">Library ID</span>
<span class="value" id="config-jellyfin-library-id">-</span>
<button onclick="openEditSetting('JELLYFIN_LIBRARY_ID', 'Jellyfin Library ID', 'text')">Edit</button>
</div>
</div>
</div>
<div class="card">
<h2>Library Settings</h2>
<div class="config-section">
<div class="config-item">
<span class="label">Download Path (Cache)</span>
<span class="value" id="config-download-path">-</span>
<button onclick="openEditSetting('LIBRARY_DOWNLOAD_PATH', 'Download Path', 'text')">Edit</button>
</div>
<div class="config-item">
<span class="label">Kept Path (Favorited)</span>
<span class="value" id="config-kept-path">-</span>
<button onclick="openEditSetting('LIBRARY_KEPT_PATH', 'Kept Path', 'text')">Edit</button>
</div>
</div>
</div>
<div class="card">
<h2>Spotify Import Settings</h2>
<div class="config-section">
<div class="config-item">
<span class="label">Spotify Import Enabled</span>
<span class="value" id="config-spotify-import-enabled">-</span>
<button onclick="openEditSetting('SPOTIFY_IMPORT_ENABLED', 'Spotify Import Enabled', 'toggle')">Edit</button>
</div>
<div class="config-item">
<span class="label">Matching Interval (hours)</span>
<span class="value" id="config-matching-interval">-</span>
<button onclick="openEditSetting('SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS', 'Matching Interval (hours)', 'number', 'How often to check for playlist updates')">Edit</button>
</div>
</div>
</div>
<div class="card">
<h2>Configuration Backup</h2>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
Export your .env configuration for backup or import a previously saved configuration.
</p>
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
<button onclick="exportEnv()">📥 Export .env</button>
<button onclick="document.getElementById('import-env-input').click()">📤 Import .env</button>
<input type="file" id="import-env-input" accept=".env" style="display:none" onchange="importEnv(event)">
</div>
</div>
<div class="card" style="background: rgba(248, 81, 73, 0.1); border-color: var(--error);">
<h2 style="color: var(--error);">Danger Zone</h2>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
These actions can affect your data. Use with caution.
</p>
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
<button class="danger" onclick="clearCache()">Clear All Cache</button>
<button class="danger" onclick="restartContainer()">Restart Container</button>
</div>
</div>
</div>
<!-- API Analytics Tab -->
<div class="tab-content" id="tab-endpoints">
<div class="card">
<h2>
API Endpoint Usage
<div class="actions">
<button onclick="fetchEndpointUsage()">Refresh</button>
<button class="danger" onclick="clearEndpointUsage()">Clear Data</button>
</div>
</h2>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
Track which Jellyfin API endpoints are being called most frequently. Useful for debugging and understanding client behavior.
</p>
<div id="endpoints-summary" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 20px;">
<div style="background: var(--bg-tertiary); padding: 16px; border-radius: 8px;">
<div style="color: var(--text-secondary); font-size: 0.85rem; margin-bottom: 4px;">Total Requests</div>
<div style="font-size: 1.8rem; font-weight: 600; color: var(--accent);" id="endpoints-total-requests">0</div>
</div>
<div style="background: var(--bg-tertiary); padding: 16px; border-radius: 8px;">
<div style="color: var(--text-secondary); font-size: 0.85rem; margin-bottom: 4px;">Unique Endpoints</div>
<div style="font-size: 1.8rem; font-weight: 600; color: var(--success);" id="endpoints-unique-count">0</div>
</div>
<div style="background: var(--bg-tertiary); padding: 16px; border-radius: 8px;">
<div style="color: var(--text-secondary); font-size: 0.85rem; margin-bottom: 4px;">Most Called</div>
<div style="font-size: 1.1rem; font-weight: 600; color: var(--text-primary); word-break: break-all;" id="endpoints-most-called">-</div>
</div>
</div>
<div style="margin-bottom: 16px;">
<label style="display: block; margin-bottom: 8px; color: var(--text-secondary); font-size: 0.9rem;">Show Top</label>
<select id="endpoints-top-select" onchange="fetchEndpointUsage()" style="padding: 8px; background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary);">
<option value="25">Top 25</option>
<option value="50" selected>Top 50</option>
<option value="100">Top 100</option>
<option value="500">Top 500</option>
</select>
</div>
<div style="max-height: 600px; overflow-y: auto;">
<table class="playlist-table">
<thead>
<tr>
<th style="width: 60px;">#</th>
<th>Endpoint</th>
<th style="width: 120px; text-align: right;">Requests</th>
<th style="width: 120px; text-align: right;">% of Total</th>
</tr>
</thead>
<tbody id="endpoints-table-body">
<tr>
<td colspan="4" class="loading">
<span class="spinner"></span> Loading endpoint usage data...
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="card">
<h2>About Endpoint Tracking</h2>
<p style="color: var(--text-secondary); line-height: 1.6;">
Allstarr logs every Jellyfin API endpoint call to help you understand how clients interact with your server.
This data is stored in <code style="background: var(--bg-tertiary); padding: 2px 6px; border-radius: 4px;">/app/cache/endpoint-usage/endpoints.csv</code>
and persists across restarts.
<br><br>
<strong>Common Endpoints:</strong>
<ul style="margin-top: 8px; margin-left: 20px;">
<li><code>/Users/{userId}/Items</code> - Browse library items</li>
<li><code>/Items/{itemId}</code> - Get item details</li>
<li><code>/Audio/{itemId}/stream</code> - Stream audio</li>
<li><code>/Sessions/Playing</code> - Report playback status</li>
<li><code>/Search/Hints</code> - Search functionality</li>
</ul>
</p>
</div>
</div>
</div>
<!-- Add Playlist Modal -->
<div class="modal" id="add-playlist-modal">
<div class="modal-content">
<h3>Add Playlist</h3>
<div class="form-group">
<label>Playlist Name</label>
<input type="text" id="new-playlist-name" placeholder="e.g., Release Radar">
</div>
<div class="form-group">
<label>Spotify Playlist ID</label>
<input type="text" id="new-playlist-id" placeholder="Get from Spotify Import plugin">
</div>
<div class="modal-actions">
<button onclick="closeModal('add-playlist-modal')">Cancel</button>
<button class="primary" onclick="addPlaylist()">Add Playlist</button>
</div>
</div>
</div>
<!-- Edit Setting Modal -->
<div class="modal" id="edit-setting-modal">
<div class="modal-content">
<h3 id="edit-setting-title">Edit Setting</h3>
<p id="edit-setting-help" style="color: var(--text-secondary); margin-bottom: 16px; display: none;"></p>
<div class="form-group">
<label id="edit-setting-label">Value</label>
<div id="edit-setting-input-container">
<input type="text" id="edit-setting-value" placeholder="Enter value">
</div>
</div>
<div class="modal-actions">
<button onclick="closeModal('edit-setting-modal')">Cancel</button>
<button class="primary" onclick="saveEditSetting()">Save</button>
</div>
</div>
</div>
<!-- Track List Modal -->
<div class="modal" id="tracks-modal">
<div class="modal-content" style="max-width: 90%; width: 90%;">
<h3 id="tracks-modal-title">Playlist Tracks</h3>
<div class="tracks-list" id="tracks-list">
<div class="loading">
<span class="spinner"></span> Loading tracks...
</div>
</div>
<div class="modal-actions">
<button onclick="closeModal('tracks-modal')">Close</button>
</div>
</div>
</div>
<!-- Manual Track Mapping Modal -->
<div class="modal" id="manual-map-modal">
<div class="modal-content" style="max-width: 600px;">
<h3>Map Track to External Provider</h3>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
Map this track to an external provider (SquidWTF, Deezer, or Qobuz). For local Jellyfin tracks, use the Spotify Import plugin instead.
</p>
<!-- Track Info -->
<div class="form-group">
<label>Spotify Track (Position <span id="map-position"></span>)</label>
<div style="background: var(--bg-primary); padding: 12px; border-radius: 8px; margin-bottom: 16px;">
<strong id="map-spotify-title"></strong><br>
<span style="color: var(--text-secondary);" id="map-spotify-artist"></span>
</div>
</div>
<!-- External Mapping Section -->
<div id="external-mapping-section">
<div class="form-group">
<label>External Provider</label>
<select id="map-external-provider" style="width: 100%;">
<option value="SquidWTF">SquidWTF</option>
<option value="Deezer">Deezer</option>
<option value="Qobuz">Qobuz</option>
</select>
</div>
<div class="form-group">
<label>External Provider ID</label>
<input type="text" id="map-external-id" placeholder="Enter the provider-specific track ID..." oninput="validateExternalMapping()">
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
For SquidWTF: Use the track ID from the search results or URL<br>
For Deezer: Use the track ID from Deezer URLs<br>
For Qobuz: Use the track ID from Qobuz URLs
</small>
</div>
</div>
<input type="hidden" id="map-playlist-name">
<input type="hidden" id="map-spotify-id">
<div class="modal-actions">
<button onclick="closeModal('manual-map-modal')">Cancel</button>
<button class="primary" onclick="saveManualMapping()" id="map-save-btn" disabled>Save Mapping</button>
</div>
</div>
</div>
<!-- Link Playlist Modal -->
<div class="modal" id="link-playlist-modal">
<div class="modal-content">
<h3>Link to Spotify Playlist</h3>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
Select a playlist from your Spotify library or enter a playlist ID/URL manually. Allstarr will automatically download missing tracks from your configured music service.
</p>
<div class="form-group">
<label>Jellyfin Playlist</label>
<input type="text" id="link-jellyfin-name" readonly style="background: var(--bg-primary);">
<input type="hidden" id="link-jellyfin-id">
</div>
<!-- Toggle between select and manual input -->
<div style="display: flex; gap: 8px; margin-bottom: 16px;">
<button type="button" id="select-mode-btn" class="primary" onclick="switchLinkMode('select')" style="flex: 1;">Select from My Playlists</button>
<button type="button" id="manual-mode-btn" onclick="switchLinkMode('manual')" style="flex: 1;">Enter Manually</button>
</div>
<!-- Select from user playlists -->
<div class="form-group" id="link-select-group">
<label>Your Spotify Playlists</label>
<select id="link-spotify-select" style="width: 100%;">
<option value="">Loading playlists...</option>
</select>
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
Select a playlist from your Spotify library
</small>
</div>
<!-- Manual input -->
<div class="form-group" id="link-manual-group" style="display: none;">
<label>Spotify Playlist ID or URL</label>
<input type="text" id="link-spotify-id" placeholder="37i9dQZF1DXcBWIGoYBM5M or spotify:playlist:... or full URL">
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
Accepts: <code>37i9dQZF1DXcBWIGoYBM5M</code>, <code>spotify:playlist:37i9dQZF1DXcBWIGoYBM5M</code>, or full Spotify URL
</small>
</div>
<!-- Sync Schedule -->
<div class="form-group">
<label>Sync Schedule (Cron)</label>
<input type="text" id="link-sync-schedule" placeholder="0 8 * * 1" value="0 8 * * 1" style="font-family: monospace;">
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
Cron format: <code>minute hour day month dayofweek</code><br>
Default: <code>0 8 * * 1</code> = 8 AM every Monday<br>
Examples: <code>0 6 * * *</code> = daily at 6 AM, <code>0 20 * * 5</code> = Fridays at 8 PM<br>
<a href="https://crontab.guru/" target="_blank" style="color: var(--primary);">Use crontab.guru to build your schedule</a>
</small>
</div>
<div class="modal-actions">
<button onclick="closeModal('link-playlist-modal')">Cancel</button>
<button class="primary" onclick="linkPlaylist()">Link Playlist</button>
</div>
</div>
</div>
<!-- Lyrics ID Mapping Modal -->
<div class="modal" id="lyrics-map-modal">
<div class="modal-content" style="max-width: 600px;">
<h3>Map Lyrics ID</h3>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
Manually map a track to a specific lyrics ID from lrclib.net. You can find lyrics IDs by searching on <a href="https://lrclib.net" target="_blank" style="color: var(--accent);">lrclib.net</a>.
</p>
<!-- Track Info -->
<div class="form-group">
<label>Track</label>
<div style="background: var(--bg-primary); padding: 12px; border-radius: 8px; margin-bottom: 16px;">
<strong id="lyrics-map-title"></strong><br>
<span style="color: var(--text-secondary);" id="lyrics-map-artist"></span><br>
<small style="color: var(--text-secondary);" id="lyrics-map-album"></small>
</div>
</div>
<!-- Lyrics ID Input -->
<div class="form-group">
<label>Lyrics ID from lrclib.net</label>
<input type="number" id="lyrics-map-id" placeholder="Enter lyrics ID (e.g., 5929990)" min="1">
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
Search for the track on <a href="https://lrclib.net" target="_blank" style="color: var(--accent);">lrclib.net</a> and copy the ID from the URL or API response
</small>
</div>
<input type="hidden" id="lyrics-map-artist-value">
<input type="hidden" id="lyrics-map-title-value">
<input type="hidden" id="lyrics-map-album-value">
<input type="hidden" id="lyrics-map-duration">
<div class="modal-actions">
<button onclick="closeModal('lyrics-map-modal')">Cancel</button>
<button class="primary" onclick="saveLyricsMapping()" id="lyrics-map-save-btn">Save Mapping</button>
</div>
</div>
</div>
<!-- Restart Overlay -->
<div class="restart-overlay" id="restart-overlay">
<div class="spinner-large"></div>
<h2>Restarting Container</h2>
<p id="restart-status">Applying configuration changes...</p>
</div>
<script>
// Current edit setting state
let currentEditKey = null;
let currentEditType = null;
let currentEditOptions = null;
// Track if we've already initialized the cookie date to prevent infinite loop
let cookieDateInitialized = false;
// Track if restart is required
let restartRequired = false;
function showRestartBanner() {
restartRequired = true;
document.getElementById('restart-banner').classList.add('active');
}
function dismissRestartBanner() {
document.getElementById('restart-banner').classList.remove('active');
}
// Tab switching with URL hash support
function switchTab(tabName) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
const tab = document.querySelector(`.tab[data-tab="${tabName}"]`);
const content = document.getElementById('tab-' + tabName);
if (tab && content) {
tab.classList.add('active');
content.classList.add('active');
window.location.hash = tabName;
}
}
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
switchTab(tab.dataset.tab);
});
});
// Restore tab from URL hash on page load
window.addEventListener('load', () => {
const hash = window.location.hash.substring(1);
if (hash) {
switchTab(hash);
}
// Start auto-refresh for playlists tab (every 5 seconds)
startPlaylistAutoRefresh();
});
// Auto-refresh functionality for playlists
let playlistAutoRefreshInterval = null;
function startPlaylistAutoRefresh() {
// Clear any existing interval
if (playlistAutoRefreshInterval) {
clearInterval(playlistAutoRefreshInterval);
}
// Refresh every 5 seconds when on playlists tab
playlistAutoRefreshInterval = setInterval(() => {
const playlistsTab = document.getElementById('tab-playlists');
if (playlistsTab && playlistsTab.classList.contains('active')) {
// Silently refresh without showing loading state
fetchPlaylists(true);
}
}, 5000);
}
function stopPlaylistAutoRefresh() {
if (playlistAutoRefreshInterval) {
clearInterval(playlistAutoRefreshInterval);
playlistAutoRefreshInterval = null;
}
}
// Toast notification
function showToast(message, type = 'success', duration = 3000) {
const toast = document.createElement('div');
toast.className = 'toast ' + type;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), duration);
}
// Modal helpers
function openModal(id) {
document.getElementById(id).classList.add('active');
}
function closeModal(id) {
document.getElementById(id).classList.remove('active');
}
// Close modals on backdrop click
document.querySelectorAll('.modal').forEach(modal => {
modal.addEventListener('click', e => {
if (e.target === modal) closeModal(modal.id);
});
});
// Format cookie age with color coding
function formatCookieAge(setDateStr, hasCookie = false) {
if (!setDateStr) {
if (hasCookie) {
return { text: 'Unknown age', class: 'warning', detail: 'Cookie date not tracked', needsInit: true };
}
return { text: 'No cookie', class: '', detail: '', needsInit: false };
}
const setDate = new Date(setDateStr);
const now = new Date();
const diffMs = now - setDate;
const daysAgo = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const monthsAgo = daysAgo / 30;
let status = 'success'; // green: < 6 months
if (monthsAgo >= 10) status = 'error'; // red: > 10 months
else if (monthsAgo >= 6) status = 'warning'; // yellow: 6-10 months
let text;
if (daysAgo === 0) text = 'Set today';
else if (daysAgo === 1) text = 'Set yesterday';
else if (daysAgo < 30) text = `Set ${daysAgo} days ago`;
else if (daysAgo < 60) text = 'Set ~1 month ago';
else text = `Set ~${Math.floor(monthsAgo)} months ago`;
const remaining = 12 - monthsAgo;
let detail;
if (remaining > 6) detail = 'Cookie typically lasts ~1 year';
else if (remaining > 2) detail = `~${Math.floor(remaining)} months until expiration`;
else if (remaining > 0) detail = 'Cookie may expire soon!';
else detail = 'Cookie may have expired - update if having issues';
return { text, class: status, detail, needsInit: false };
}
// Initialize cookie date if cookie exists but date is not set
async function initCookieDate() {
if (cookieDateInitialized) {
console.log('Cookie date already initialized, skipping');
return;
}
cookieDateInitialized = true;
try {
const res = await fetch('/api/admin/config/init-cookie-date', { method: 'POST' });
if (res.ok) {
console.log('Cookie date initialized successfully - restart container to apply');
showToast('Cookie date set. Restart container to apply changes.', 'success');
} else {
const data = await res.json();
console.log('Cookie date init response:', data);
}
} catch (error) {
console.error('Failed to init cookie date:', error);
cookieDateInitialized = false; // Allow retry on error
}
}
// API calls
async function fetchStatus() {
try {
const res = await fetch('/api/admin/status');
const data = await res.json();
document.getElementById('version').textContent = 'v' + data.version;
document.getElementById('backend-type').textContent = data.backendType;
document.getElementById('jellyfin-url').textContent = data.jellyfinUrl || '-';
document.getElementById('playlist-count').textContent = data.spotifyImport.playlistCount;
document.getElementById('cache-duration').textContent = data.spotify.cacheDurationMinutes + ' min';
document.getElementById('isrc-matching').textContent = data.spotify.preferIsrcMatching ? 'Enabled' : 'Disabled';
document.getElementById('spotify-user').textContent = data.spotify.user || '-';
// Update status badge and cookie age
const statusBadge = document.getElementById('spotify-status');
const authStatus = document.getElementById('spotify-auth-status');
const cookieAgeEl = document.getElementById('spotify-cookie-age');
if (data.spotify.authStatus === 'configured') {
statusBadge.className = 'status-badge success';
statusBadge.innerHTML = '<span class="status-dot"></span>Spotify Ready';
authStatus.textContent = 'Cookie Set';
authStatus.className = 'stat-value success';
} else if (data.spotify.authStatus === 'missing_cookie') {
statusBadge.className = 'status-badge warning';
statusBadge.innerHTML = '<span class="status-dot"></span>Cookie Missing';
authStatus.textContent = 'No Cookie';
authStatus.className = 'stat-value warning';
} else {
statusBadge.className = 'status-badge';
statusBadge.innerHTML = '<span class="status-dot"></span>Not Configured';
authStatus.textContent = 'Not Configured';
authStatus.className = 'stat-value';
}
// Update cookie age display
if (cookieAgeEl) {
const hasCookie = data.spotify.hasCookie;
const age = formatCookieAge(data.spotify.cookieSetDate, hasCookie);
cookieAgeEl.innerHTML = `<span class="${age.class}">${age.text}</span><br><small style="color:var(--text-secondary)">${age.detail}</small>`;
// Auto-init cookie date if cookie exists but date is not set
if (age.needsInit) {
console.log('Cookie exists but date not set, initializing...');
initCookieDate();
}
}
} catch (error) {
console.error('Failed to fetch status:', error);
showToast('Failed to fetch status', 'error');
}
}
async function fetchPlaylists(silent = false) {
try {
const res = await fetch('/api/admin/playlists');
const data = await res.json();
const tbody = document.getElementById('playlist-table-body');
if (data.playlists.length === 0) {
if (!silent) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists configured. Link playlists from the Jellyfin Playlists tab.</td></tr>';
}
return;
}
tbody.innerHTML = data.playlists.map(p => {
// Enhanced statistics display
const spotifyTotal = p.trackCount || 0;
const localCount = p.localTracks || 0;
const externalMatched = p.externalMatched || 0;
const externalMissing = p.externalMissing || 0;
const totalInJellyfin = p.totalInJellyfin || 0;
const totalPlayable = p.totalPlayable || (localCount + externalMatched); // Total tracks that will be served
// Debug: Log the raw data
console.log(`Playlist ${p.name}:`, {
spotifyTotal,
localCount,
externalMatched,
externalMissing,
totalInJellyfin,
totalPlayable,
rawData: p
});
// Build detailed stats string - show total playable tracks prominently
let statsHtml = `<span class="track-count">${totalPlayable}/${spotifyTotal}</span>`;
// Show breakdown with color coding
let breakdownParts = [];
if (localCount > 0) {
breakdownParts.push(`<span style="color:var(--success)">${localCount} local</span>`);
}
if (externalMatched > 0) {
breakdownParts.push(`<span style="color:var(--accent)">${externalMatched} matched</span>`);
}
if (externalMissing > 0) {
breakdownParts.push(`<span style="color:var(--warning)">${externalMissing} missing</span>`);
}
const breakdown = breakdownParts.length > 0
? `<br><small style="color:var(--text-secondary)">${breakdownParts.join(' • ')}</small>`
: '';
// Calculate completion percentage based on playable tracks
const completionPct = spotifyTotal > 0 ? Math.round((totalPlayable / spotifyTotal) * 100) : 0;
const localPct = spotifyTotal > 0 ? Math.round((localCount / spotifyTotal) * 100) : 0;
const externalPct = spotifyTotal > 0 ? Math.round((externalMatched / spotifyTotal) * 100) : 0;
const missingPct = spotifyTotal > 0 ? Math.round((externalMissing / spotifyTotal) * 100) : 0;
const completionColor = completionPct === 100 ? 'var(--success)' : completionPct >= 80 ? 'var(--accent)' : 'var(--warning)';
// Debug logging
console.log(`Progress bar for ${p.name}: local=${localPct}%, external=${externalPct}%, missing=${missingPct}%, total=${completionPct}%`);
const syncSchedule = p.syncSchedule || '0 8 * * 1';
return `
<tr>
<td><strong>${escapeHtml(p.name)}</strong></td>
<td style="font-family:monospace;font-size:0.85rem;color:var(--text-secondary);">${p.id || '-'}</td>
<td style="font-family:monospace;font-size:0.85rem;">
${escapeHtml(syncSchedule)}
<button onclick="editPlaylistSchedule('${escapeJs(p.name)}', '${escapeJs(syncSchedule)}')" style="margin-left:4px;font-size:0.75rem;padding:2px 6px;">Edit</button>
</td>
<td>${statsHtml}${breakdown}</td>
<td>
<div style="display:flex;align-items:center;gap:8px;">
<div style="flex:1;background:var(--bg-tertiary);height:12px;border-radius:6px;overflow:hidden;display:flex;">
<div style="width:${localPct}%;height:100%;background:#10b981;transition:width 0.3s;" title="${localCount} local tracks"></div>
<div style="width:${externalPct}%;height:100%;background:#f59e0b;transition:width 0.3s;" title="${externalMatched} external matched tracks"></div>
<div style="width:${missingPct}%;height:100%;background:#6b7280;transition:width 0.3s;" title="${externalMissing} missing tracks"></div>
</div>
<span style="font-size:0.85rem;color:${completionColor};font-weight:500;min-width:40px;">${completionPct}%</span>
</div>
</td>
<td class="cache-age">${p.cacheAge || '-'}</td>
<td>
<button onclick="clearPlaylistCache('${escapeJs(p.name)}')">Clear Cache & Rebuild</button>
<button onclick="matchPlaylistTracks('${escapeJs(p.name)}')">Match Tracks</button>
<button onclick="viewTracks('${escapeJs(p.name)}')">View</button>
<button class="danger" onclick="removePlaylist('${escapeJs(p.name)}')">Remove</button>
</td>
</tr>
`;
}).join('');
} catch (error) {
console.error('Failed to fetch playlists:', error);
showToast('Failed to fetch playlists', 'error');
}
}
async function fetchTrackMappings() {
try {
const res = await fetch('/api/admin/mappings/tracks');
const data = await res.json();
// Update summary (only external now)
document.getElementById('mappings-total').textContent = data.externalCount || 0;
document.getElementById('mappings-external').textContent = data.externalCount || 0;
const tbody = document.getElementById('mappings-table-body');
if (data.mappings.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:40px;">No manual mappings found.</td></tr>';
return;
}
// Filter to only show external mappings
const externalMappings = data.mappings.filter(m => m.type === 'external');
if (externalMappings.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:40px;">No external mappings found. Local Jellyfin mappings should be managed via Spotify Import plugin.</td></tr>';
return;
}
tbody.innerHTML = externalMappings.map((m, index) => {
const typeColor = 'var(--success)';
const typeBadge = `<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:0.8rem;background:${typeColor}20;color:${typeColor};font-weight:500;">external</span>`;
const targetDisplay = `<span style="font-family:monospace;font-size:0.85rem;color:var(--success);">${m.externalProvider}/${m.externalId}</span>`;
const createdDate = m.createdAt ? new Date(m.createdAt).toLocaleString() : '-';
return `
<tr>
<td><strong>${escapeHtml(m.playlist)}</strong></td>
<td style="font-family:monospace;font-size:0.85rem;color:var(--text-secondary);">${m.spotifyId}</td>
<td>${typeBadge}</td>
<td>${targetDisplay}</td>
<td style="color:var(--text-secondary);font-size:0.85rem;">${createdDate}</td>
<td>
<button class="danger delete-mapping-btn" style="padding:4px 12px;font-size:0.8rem;" data-playlist="${escapeHtml(m.playlist)}" data-spotify-id="${m.spotifyId}">Remove</button>
</td>
</tr>
`;
}).join('');
// Add event listeners to all delete buttons
document.querySelectorAll('.delete-mapping-btn').forEach(btn => {
btn.addEventListener('click', function() {
const playlist = this.getAttribute('data-playlist');
const spotifyId = this.getAttribute('data-spotify-id');
deleteTrackMapping(playlist, spotifyId);
});
});
} catch (error) {
console.error('Failed to fetch track mappings:', error);
showToast('Failed to fetch track mappings', 'error');
}
}
async function deleteTrackMapping(playlist, spotifyId) {
if (!confirm(`Remove manual external mapping for ${spotifyId} in playlist "${playlist}"?\n\nThis will:\n• Delete the manual mapping from the cache\n• Allow the track to be matched automatically again\n• The track may be re-matched with potentially better results\n\nThis action cannot be undone.`)) {
return;
}
try {
const res = await fetch(`/api/admin/mappings/tracks?playlist=${encodeURIComponent(playlist)}&spotifyId=${encodeURIComponent(spotifyId)}`, {
method: 'DELETE'
});
if (res.ok) {
showToast('Mapping removed successfully', 'success');
await fetchTrackMappings();
} else {
const error = await res.json();
showToast(error.error || 'Failed to remove mapping', 'error');
}
} catch (error) {
console.error('Failed to delete mapping:', error);
showToast('Failed to remove mapping', 'error');
}
}
async function fetchMissingTracks() {
try {
const res = await fetch('/api/admin/playlists');
const data = await res.json();
const tbody = document.getElementById('missing-tracks-table-body');
const missingTracks = [];
// Collect all missing tracks from all playlists
for (const playlist of data.playlists) {
if (playlist.externalMissing > 0) {
// Fetch tracks for this playlist
try {
const tracksRes = await fetch(`/api/admin/playlists/${encodeURIComponent(playlist.name)}/tracks`);
const tracksData = await tracksRes.json();
// Filter to only missing tracks (isLocal === null)
const missing = tracksData.tracks.filter(t => t.isLocal === null);
missing.forEach(t => {
missingTracks.push({
playlist: playlist.name,
...t
});
});
} catch (err) {
console.error(`Failed to fetch tracks for ${playlist.name}:`, err);
}
}
}
// Update summary
document.getElementById('missing-total').textContent = missingTracks.length;
if (missingTracks.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text-secondary);padding:40px;">🎉 No missing tracks! All tracks are matched.</td></tr>';
return;
}
tbody.innerHTML = missingTracks.map(t => {
const artist = (t.artists && t.artists.length > 0) ? t.artists.join(', ') : '';
const searchQuery = `${t.title} ${artist}`;
return `
<tr>
<td><strong>${escapeHtml(t.playlist)}</strong></td>
<td>${escapeHtml(t.title)}</td>
<td>${escapeHtml(artist)}</td>
<td style="color:var(--text-secondary);">${t.album ? escapeHtml(t.album) : '-'}</td>
<td>
<button onclick="searchProvider('${escapeJs(searchQuery)}', 'squidwtf')"
style="margin-right:4px;font-size:0.75rem;padding:4px 8px;background:#3b82f6;border-color:#3b82f6;color:white;">🔍 Search</button>
<button onclick="openMapToLocal('${escapeJs(t.playlist)}', '${escapeJs(t.spotifyId)}', '${escapeJs(t.title)}', '${escapeJs(artist)}')"
style="margin-right:4px;font-size:0.75rem;padding:4px 8px;background:var(--success);border-color:var(--success);">Map to Local</button>
<button onclick="openMapToExternal('${escapeJs(t.playlist)}', '${escapeJs(t.spotifyId)}', '${escapeJs(t.title)}', '${escapeJs(artist)}')"
style="font-size:0.75rem;padding:4px 8px;background:var(--warning);border-color:var(--warning);">Map to External</button>
</td>
</tr>
`;
}).join('');
} catch (error) {
console.error('Failed to fetch missing tracks:', error);
showToast('Failed to fetch missing tracks', 'error');
}
}
async function fetchDownloads() {
try {
const res = await fetch('/api/admin/downloads');
const data = await res.json();
const tbody = document.getElementById('downloads-table-body');
// Update summary
document.getElementById('downloads-count').textContent = data.count;
document.getElementById('downloads-size').textContent = data.totalSizeFormatted;
if (data.count === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text-secondary);padding:40px;">No downloaded files found.</td></tr>';
return;
}
tbody.innerHTML = data.files.map(f => {
return `
<tr data-path="${escapeHtml(f.path)}">
<td><strong>${escapeHtml(f.artist)}</strong></td>
<td>${escapeHtml(f.album)}</td>
<td style="font-family:monospace;font-size:0.85rem;">${escapeHtml(f.fileName)}</td>
<td style="color:var(--text-secondary);">${f.sizeFormatted}</td>
<td>
<button onclick="downloadFile('${escapeJs(f.path)}')"
style="margin-right:4px;font-size:0.75rem;padding:4px 8px;background:var(--accent);border-color:var(--accent);">Download</button>
<button onclick="deleteDownload('${escapeJs(f.path)}')"
class="danger" style="font-size:0.75rem;padding:4px 8px;">Delete</button>
</td>
</tr>
`;
}).join('');
} catch (error) {
console.error('Failed to fetch downloads:', error);
showToast('Failed to fetch downloads', 'error');
}
}
async function downloadFile(path) {
try {
window.open(`/api/admin/downloads/file?path=${encodeURIComponent(path)}`, '_blank');
} catch (error) {
console.error('Failed to download file:', error);
showToast('Failed to download file', 'error');
}
}
async function deleteDownload(path) {
if (!confirm(`Delete this file?\n\n${path}\n\nThis action cannot be undone.`)) {
return;
}
try {
const res = await fetch(`/api/admin/downloads?path=${encodeURIComponent(path)}`, {
method: 'DELETE'
});
if (res.ok) {
showToast('File deleted successfully', 'success');
// Remove the row immediately for live update
const escapedPath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const row = document.querySelector(`tr[data-path="${escapedPath}"]`);
if (row) {
row.remove();
}
// Refresh to update counts
await fetchDownloads();
} else {
const error = await res.json();
showToast(error.error || 'Failed to delete file', 'error');
}
} catch (error) {
console.error('Failed to delete file:', error);
showToast('Failed to delete file', 'error');
}
}
async function fetchConfig() {
try {
const res = await fetch('/api/admin/config');
const data = await res.json();
// Core settings
document.getElementById('config-backend-type').textContent = data.backendType || 'Jellyfin';
document.getElementById('config-music-service').textContent = data.musicService || 'SquidWTF';
document.getElementById('config-storage-mode').textContent = data.library?.storageMode || 'Cache';
document.getElementById('config-cache-duration-hours').textContent = data.library?.cacheDurationHours || '24';
document.getElementById('config-download-mode').textContent = data.library?.downloadMode || 'Track';
document.getElementById('config-explicit-filter').textContent = data.explicitFilter || 'All';
document.getElementById('config-enable-external-playlists').textContent = data.enableExternalPlaylists ? 'Yes' : 'No';
document.getElementById('config-playlists-directory').textContent = data.playlistsDirectory || '(not set)';
document.getElementById('config-redis-enabled').textContent = data.redisEnabled ? 'Yes' : 'No';
// Show/hide cache duration based on storage mode
const cacheDurationRow = document.getElementById('cache-duration-row');
if (cacheDurationRow) {
cacheDurationRow.style.display = data.library?.storageMode === 'Cache' ? 'grid' : 'none';
}
// Spotify API settings
document.getElementById('config-spotify-enabled').textContent = data.spotifyApi.enabled ? 'Yes' : 'No';
document.getElementById('config-spotify-cookie').textContent = data.spotifyApi.sessionCookie;
document.getElementById('config-cache-duration').textContent = data.spotifyApi.cacheDurationMinutes + ' minutes';
document.getElementById('config-isrc-matching').textContent = data.spotifyApi.preferIsrcMatching ? 'Enabled' : 'Disabled';
// Cookie age in config tab
const configCookieAge = document.getElementById('config-cookie-age');
if (configCookieAge) {
const hasCookie = data.spotifyApi.sessionCookie && data.spotifyApi.sessionCookie !== '(not set)';
const age = formatCookieAge(data.spotifyApi.sessionCookieSetDate, hasCookie);
configCookieAge.innerHTML = `<span class="${age.class}">${age.text}</span> - ${age.detail}`;
}
// Deezer settings
document.getElementById('config-deezer-arl').textContent = data.deezer.arl || '(not set)';
document.getElementById('config-deezer-quality').textContent = data.deezer.quality;
// SquidWTF settings
document.getElementById('config-squid-quality').textContent = data.squidWtf.quality;
// MusicBrainz settings
document.getElementById('config-musicbrainz-enabled').textContent = data.musicBrainz.enabled ? 'Yes' : 'No';
document.getElementById('config-musicbrainz-username').textContent = data.musicBrainz.username || '(not set)';
document.getElementById('config-musicbrainz-password').textContent = data.musicBrainz.password || '(not set)';
// Qobuz settings
document.getElementById('config-qobuz-token').textContent = data.qobuz.userAuthToken || '(not set)';
document.getElementById('config-qobuz-quality').textContent = data.qobuz.quality || 'FLAC';
// Jellyfin settings
document.getElementById('config-jellyfin-url').textContent = data.jellyfin.url || '-';
document.getElementById('config-jellyfin-api-key').textContent = data.jellyfin.apiKey;
document.getElementById('config-jellyfin-user-id').textContent = data.jellyfin.userId || '(not set)';
document.getElementById('config-jellyfin-library-id').textContent = data.jellyfin.libraryId || '-';
// Library settings
document.getElementById('config-download-path').textContent = data.library?.downloadPath || './downloads';
document.getElementById('config-kept-path').textContent = data.library?.keptPath || '/app/kept';
// Sync settings
document.getElementById('config-spotify-import-enabled').textContent = data.spotifyImport?.enabled ? 'Yes' : 'No';
document.getElementById('config-matching-interval').textContent = (data.spotifyImport?.matchingIntervalHours || 24) + ' hours';
} catch (error) {
console.error('Failed to fetch config:', error);
}
}
async function fetchJellyfinUsers() {
try {
const res = await fetch('/api/admin/jellyfin/users');
if (!res.ok) return;
const data = await res.json();
const select = document.getElementById('jellyfin-user-select');
select.innerHTML = '<option value="">All Users</option>' +
data.users.map(u => `<option value="${u.id}">${escapeHtml(u.name)}</option>`).join('');
} catch (error) {
console.error('Failed to fetch users:', error);
}
}
async function fetchJellyfinPlaylists() {
const tbody = document.getElementById('jellyfin-playlist-table-body');
tbody.innerHTML = '<tr><td colspan="6" class="loading"><span class="spinner"></span> Loading Jellyfin playlists...</td></tr>';
try {
// Build URL with optional user filter
const userId = document.getElementById('jellyfin-user-select').value;
let url = '/api/admin/jellyfin/playlists';
if (userId) url += '?userId=' + encodeURIComponent(userId);
const res = await fetch(url);
if (!res.ok) {
const errorData = await res.json();
tbody.innerHTML = `<tr><td colspan="6" style="text-align:center;color:var(--error);padding:40px;">${errorData.error || 'Failed to fetch playlists'}</td></tr>`;
return;
}
const data = await res.json();
if (data.playlists.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists found in Jellyfin</td></tr>';
return;
}
tbody.innerHTML = data.playlists.map(p => {
const statusBadge = p.isConfigured
? '<span class="status-badge success"><span class="status-dot"></span>Linked</span>'
: '<span class="status-badge"><span class="status-dot"></span>Not Linked</span>';
const actionButton = p.isConfigured
? `<button class="danger" onclick="unlinkPlaylist('${escapeJs(p.name)}')">Unlink</button>`
: `<button class="primary" onclick="openLinkPlaylist('${escapeJs(p.id)}', '${escapeJs(p.name)}')">Link to Spotify</button>`;
const localCount = p.localTracks || 0;
const externalCount = p.externalTracks || 0;
const externalAvail = p.externalAvailable || 0;
return `
<tr data-playlist-id="${escapeHtml(p.id)}">
<td><strong>${escapeHtml(p.name)}</strong></td>
<td class="track-count">${localCount}</td>
<td class="track-count">${externalCount > 0 ? `${externalAvail}/${externalCount}` : '-'}</td>
<td style="font-family:monospace;font-size:0.85rem;color:var(--text-secondary);">${p.linkedSpotifyId || '-'}</td>
<td>${statusBadge}</td>
<td>${actionButton}</td>
</tr>
`;
}).join('');
} catch (error) {
console.error('Failed to fetch Jellyfin playlists:', error);
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--error);padding:40px;">Failed to fetch playlists</td></tr>';
}
}
let currentLinkMode = 'select'; // 'select' or 'manual'
let spotifyUserPlaylists = []; // Cache of user playlists
function switchLinkMode(mode) {
currentLinkMode = mode;
const selectGroup = document.getElementById('link-select-group');
const manualGroup = document.getElementById('link-manual-group');
const selectBtn = document.getElementById('select-mode-btn');
const manualBtn = document.getElementById('manual-mode-btn');
if (mode === 'select') {
selectGroup.style.display = 'block';
manualGroup.style.display = 'none';
selectBtn.classList.add('primary');
manualBtn.classList.remove('primary');
} else {
selectGroup.style.display = 'none';
manualGroup.style.display = 'block';
selectBtn.classList.remove('primary');
manualBtn.classList.add('primary');
}
}
async function fetchSpotifyUserPlaylists() {
try {
const res = await fetch('/api/admin/spotify/user-playlists');
if (!res.ok) {
const error = await res.json();
console.error('Failed to fetch Spotify playlists:', res.status, error);
// Show user-friendly error message
if (res.status === 429) {
showToast('Spotify rate limit reached. Please wait a moment and try again.', 'warning', 5000);
} else if (res.status === 401) {
showToast('Spotify authentication failed. Check your sp_dc cookie.', 'error', 5000);
}
return [];
}
const data = await res.json();
return data.playlists || [];
} catch (error) {
console.error('Failed to fetch Spotify playlists:', error);
return [];
}
}
async function openLinkPlaylist(jellyfinId, name) {
document.getElementById('link-jellyfin-id').value = jellyfinId;
document.getElementById('link-jellyfin-name').value = name;
document.getElementById('link-spotify-id').value = '';
// Reset to select mode
switchLinkMode('select');
// Fetch user playlists if not already cached
if (spotifyUserPlaylists.length === 0) {
const select = document.getElementById('link-spotify-select');
select.innerHTML = '<option value="">Loading playlists...</option>';
spotifyUserPlaylists = await fetchSpotifyUserPlaylists();
// Filter out already-linked playlists
const availablePlaylists = spotifyUserPlaylists.filter(p => !p.isLinked);
if (availablePlaylists.length === 0) {
if (spotifyUserPlaylists.length > 0) {
select.innerHTML = '<option value="">All your playlists are already linked</option>';
} else {
select.innerHTML = '<option value="">No playlists found or Spotify not configured</option>';
}
// Switch to manual mode if no available playlists
switchLinkMode('manual');
} else {
// Populate dropdown with only unlinked playlists
select.innerHTML = '<option value="">-- Select a playlist --</option>' +
availablePlaylists.map(p =>
`<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)} (${p.trackCount} tracks)</option>`
).join('');
}
} else {
// Re-filter in case playlists were linked since last fetch
const select = document.getElementById('link-spotify-select');
const availablePlaylists = spotifyUserPlaylists.filter(p => !p.isLinked);
if (availablePlaylists.length === 0) {
select.innerHTML = '<option value="">All your playlists are already linked</option>';
switchLinkMode('manual');
} else {
select.innerHTML = '<option value="">-- Select a playlist --</option>' +
availablePlaylists.map(p =>
`<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)} (${p.trackCount} tracks)</option>`
).join('');
}
}
openModal('link-playlist-modal');
}
async function linkPlaylist() {
const jellyfinId = document.getElementById('link-jellyfin-id').value;
const name = document.getElementById('link-jellyfin-name').value;
const syncSchedule = document.getElementById('link-sync-schedule').value.trim();
// Validate sync schedule (basic cron format check)
if (!syncSchedule) {
showToast('Sync schedule is required', 'error');
return;
}
const cronParts = syncSchedule.split(/\s+/);
if (cronParts.length !== 5) {
showToast('Invalid cron format. Expected: minute hour day month dayofweek', 'error');
return;
}
// Get Spotify ID based on current mode
let spotifyId = '';
if (currentLinkMode === 'select') {
spotifyId = document.getElementById('link-spotify-select').value;
if (!spotifyId) {
showToast('Please select a Spotify playlist', 'error');
return;
}
} else {
spotifyId = document.getElementById('link-spotify-id').value.trim();
if (!spotifyId) {
showToast('Spotify Playlist ID is required', 'error');
return;
}
}
// Extract ID from various Spotify formats:
// - spotify:playlist:37i9dQZF1DXcBWIGoYBM5M
// - https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M
// - 37i9dQZF1DXcBWIGoYBM5M
let cleanSpotifyId = spotifyId;
// Handle spotify: URI format
if (spotifyId.startsWith('spotify:playlist:')) {
cleanSpotifyId = spotifyId.replace('spotify:playlist:', '');
}
// Handle URL format
else if (spotifyId.includes('spotify.com/playlist/')) {
const match = spotifyId.match(/playlist\/([a-zA-Z0-9]+)/);
if (match) cleanSpotifyId = match[1];
}
// Remove any query parameters or trailing slashes
cleanSpotifyId = cleanSpotifyId.split('?')[0].split('#')[0].replace(/\/$/, '');
try {
const res = await fetch(`/api/admin/jellyfin/playlists/${encodeURIComponent(jellyfinId)}/link`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
spotifyPlaylistId: cleanSpotifyId,
syncSchedule: syncSchedule
})
});
const data = await res.json();
if (res.ok) {
showToast('Playlist linked!', 'success');
showRestartBanner();
closeModal('link-playlist-modal');
// Clear the Spotify playlists cache so it refreshes next time
spotifyUserPlaylists = [];
// Update UI state without refetching all playlists
const playlistsTable = document.getElementById('jellyfinPlaylistsTable');
if (playlistsTable) {
const rows = playlistsTable.querySelectorAll('tr');
rows.forEach(row => {
if (row.dataset.playlistId === jellyfinId) {
const actionCell = row.querySelector('td:last-child');
if (actionCell) {
actionCell.innerHTML = `<button class="danger" onclick="unlinkPlaylist('${escapeJs(name)}')">Unlink</button>`;
}
}
});
}
fetchPlaylists(); // Only refresh the Active Playlists tab
} else {
showToast(data.error || 'Failed to link playlist', 'error');
}
} catch (error) {
showToast('Failed to link playlist', 'error');
}
}
async function unlinkPlaylist(name) {
if (!confirm(`Unlink playlist "${name}"? This will stop filling in missing tracks.`)) return;
try {
const res = await fetch(`/api/admin/jellyfin/playlists/${encodeURIComponent(name)}/unlink`, {
method: 'DELETE'
});
const data = await res.json();
if (res.ok) {
showToast('Playlist unlinked.', 'success');
showRestartBanner();
// Clear the Spotify playlists cache so it refreshes next time
spotifyUserPlaylists = [];
// Update UI state without refetching all playlists
const playlistsTable = document.getElementById('jellyfinPlaylistsTable');
if (playlistsTable) {
const rows = playlistsTable.querySelectorAll('tr');
rows.forEach(row => {
const nameCell = row.querySelector('td:first-child');
if (nameCell && nameCell.textContent === name) {
const actionCell = row.querySelector('td:last-child');
if (actionCell) {
const playlistId = row.dataset.playlistId;
actionCell.innerHTML = `<button class="primary" onclick="openLinkPlaylist('${escapeJs(playlistId)}', '${escapeJs(name)}')">Link to Spotify</button>`;
}
}
});
}
fetchPlaylists(); // Only refresh the Active Playlists tab
} else {
showToast(data.error || 'Failed to unlink playlist', 'error');
}
} catch (error) {
showToast('Failed to unlink playlist', 'error');
}
}
async function refreshPlaylists() {
try {
showToast('Refreshing playlists...', 'success');
const res = await fetch('/api/admin/playlists/refresh', { method: 'POST' });
const data = await res.json();
showToast(data.message, 'success');
setTimeout(fetchPlaylists, 2000);
} catch (error) {
showToast('Failed to refresh playlists', 'error');
}
}
async function clearPlaylistCache(name) {
if (!confirm(`Clear cache and rebuild for "${name}"?\n\nThis will:\n• Clear Redis cache\n• Delete file caches\n• Rebuild with latest Spotify IDs\n\nThis may take a minute.`)) return;
try {
// Show warning banner
document.getElementById('matching-warning-banner').style.display = 'block';
showToast(`Clearing cache for ${name}...`, 'info');
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(name)}/clear-cache`, { method: 'POST' });
const data = await res.json();
if (res.ok) {
showToast(`${data.message} (Cleared ${data.clearedKeys} cache keys, ${data.clearedFiles} files)`, 'success', 5000);
// Refresh the playlists table after a delay to show updated counts
setTimeout(() => {
fetchPlaylists();
// Hide warning banner after refresh
document.getElementById('matching-warning-banner').style.display = 'none';
}, 3000);
} else {
showToast(data.error || 'Failed to clear cache', 'error');
document.getElementById('matching-warning-banner').style.display = 'none';
}
} catch (error) {
showToast('Failed to clear cache', 'error');
document.getElementById('matching-warning-banner').style.display = 'none';
}
}
async function matchPlaylistTracks(name) {
try {
// Show warning banner
document.getElementById('matching-warning-banner').style.display = 'block';
showToast(`Matching tracks for ${name}...`, 'success');
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(name)}/match`, { method: 'POST' });
const data = await res.json();
if (res.ok) {
showToast(`${data.message}`, 'success');
// Refresh the playlists table after a delay to show updated counts
setTimeout(() => {
fetchPlaylists();
// Hide warning banner after refresh
document.getElementById('matching-warning-banner').style.display = 'none';
}, 2000);
} else {
showToast(data.error || 'Failed to match tracks', 'error');
document.getElementById('matching-warning-banner').style.display = 'none';
}
} catch (error) {
showToast('Failed to match tracks', 'error');
document.getElementById('matching-warning-banner').style.display = 'none';
}
}
async function matchAllPlaylists() {
if (!confirm('Match tracks for ALL playlists? This may take a few minutes.')) return;
try {
// Show warning banner
document.getElementById('matching-warning-banner').style.display = 'block';
showToast('Matching tracks for all playlists...', 'success');
const res = await fetch('/api/admin/playlists/match-all', { method: 'POST' });
const data = await res.json();
if (res.ok) {
showToast(`${data.message}`, 'success');
// Refresh the playlists table after a delay to show updated counts
setTimeout(() => {
fetchPlaylists();
// Hide warning banner after refresh
document.getElementById('matching-warning-banner').style.display = 'none';
}, 2000);
} else {
showToast(data.error || 'Failed to match tracks', 'error');
document.getElementById('matching-warning-banner').style.display = 'none';
}
} catch (error) {
showToast('Failed to match tracks', 'error');
document.getElementById('matching-warning-banner').style.display = 'none';
}
}
async function refreshAndMatchAll() {
if (!confirm('Clear caches, refresh from Spotify, and match all tracks?\n\nThis will:\n• Clear all playlist caches\n• Fetch fresh data from Spotify\n• Match all tracks against local library and external providers\n\nThis may take several minutes.')) return;
try {
// Show warning banner
document.getElementById('matching-warning-banner').style.display = 'block';
showToast('Starting full refresh and match...', 'info', 3000);
// Step 1: Clear all caches
showToast('Step 1/3: Clearing caches...', 'info', 2000);
await fetch('/api/admin/cache/clear', { method: 'POST' });
// Wait for cache to be fully cleared
await new Promise(resolve => setTimeout(resolve, 2000));
// Step 2: Refresh playlists from Spotify
showToast('Step 2/3: Fetching from Spotify...', 'info', 2000);
await fetch('/api/admin/playlists/refresh', { method: 'POST' });
// Wait for Spotify fetch to complete
await new Promise(resolve => setTimeout(resolve, 5000));
// Step 3: Match all tracks
showToast('Step 3/3: Matching all tracks (this may take several minutes)...', 'info', 3000);
const res = await fetch('/api/admin/playlists/match-all', { method: 'POST' });
const data = await res.json();
if (res.ok) {
showToast(`✓ Full refresh and match complete!`, 'success', 5000);
// Refresh the playlists table after a delay
setTimeout(() => {
fetchPlaylists();
// Hide warning banner after refresh
document.getElementById('matching-warning-banner').style.display = 'none';
}, 3000);
} else {
showToast(data.error || 'Failed to match tracks', 'error');
document.getElementById('matching-warning-banner').style.display = 'none';
}
} catch (error) {
showToast('Failed to complete refresh and match', 'error');
document.getElementById('matching-warning-banner').style.display = 'none';
}
}
async function searchProvider(query, provider) {
// Use SquidWTF HiFi API with round-robin base URLs for all searches
// Get a random base URL from the backend
try {
const response = await fetch('/api/admin/squidwtf-base-url');
const data = await response.json();
if (data.baseUrl) {
// Use the HiFi API search endpoint: /search/?s=query
const searchUrl = `${data.baseUrl}/search/?s=${encodeURIComponent(query)}`;
window.open(searchUrl, '_blank');
} else {
showToast('Failed to get search URL', 'error');
}
} catch (error) {
showToast('Failed to get search URL', 'error');
}
}
function capitalizeProvider(provider) {
// Capitalize provider names for display
const providerMap = {
'squidwtf': 'SquidWTF',
'deezer': 'Deezer',
'qobuz': 'Qobuz'
};
return providerMap[provider?.toLowerCase()] || provider;
}
async function clearCache() {
if (!confirm('Clear all cached playlist data?')) return;
try {
const res = await fetch('/api/admin/cache/clear', { method: 'POST' });
const data = await res.json();
showToast(data.message, 'success');
fetchPlaylists();
} catch (error) {
showToast('Failed to clear cache', 'error');
}
}
async function exportEnv() {
try {
const res = await fetch('/api/admin/export-env');
if (!res.ok) {
throw new Error('Export failed');
}
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `.env.backup.${new Date().toISOString().split('T')[0]}`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
showToast('.env file exported successfully', 'success');
} catch (error) {
showToast('Failed to export .env file', 'error');
}
}
async function importEnv(event) {
const file = event.target.files[0];
if (!file) return;
if (!confirm('Import this .env file? This will replace your current configuration.\n\nA backup will be created automatically.\n\nYou will need to restart the container for changes to take effect.')) {
event.target.value = '';
return;
}
try {
const formData = new FormData();
formData.append('file', file);
const res = await fetch('/api/admin/import-env', {
method: 'POST',
body: formData
});
const data = await res.json();
if (res.ok) {
showToast(data.message, 'success');
} else {
showToast(data.error || 'Failed to import .env file', 'error');
}
} catch (error) {
showToast('Failed to import .env file', 'error');
}
event.target.value = '';
}
async function restartContainer() {
if (!confirm('Restart the container to apply configuration changes?\n\nThe dashboard will be temporarily unavailable.')) {
return;
}
try {
const res = await fetch('/api/admin/restart', { method: 'POST' });
const data = await res.json();
if (res.ok) {
// Show the restart overlay
document.getElementById('restart-overlay').classList.add('active');
document.getElementById('restart-status').textContent = 'Stopping container...';
// Wait a bit then start checking if the server is back
setTimeout(() => {
document.getElementById('restart-status').textContent = 'Waiting for server to come back...';
checkServerAndReload();
}, 3000);
} else {
showToast(data.message || data.error || 'Failed to restart', 'error');
}
} catch (error) {
showToast('Failed to restart container', 'error');
}
}
async function checkServerAndReload() {
let attempts = 0;
const maxAttempts = 60; // Try for 60 seconds
const checkHealth = async () => {
try {
const res = await fetch('/api/admin/status', {
method: 'GET',
cache: 'no-store'
});
if (res.ok) {
document.getElementById('restart-status').textContent = 'Server is back! Reloading...';
dismissRestartBanner();
setTimeout(() => window.location.reload(), 500);
return;
}
} catch (e) {
// Server still restarting
}
attempts++;
document.getElementById('restart-status').textContent = `Waiting for server to come back... (${attempts}s)`;
if (attempts < maxAttempts) {
setTimeout(checkHealth, 1000);
} else {
document.getElementById('restart-overlay').classList.remove('active');
showToast('Server may still be restarting. Please refresh manually.', 'warning');
}
};
checkHealth();
}
function openAddPlaylist() {
document.getElementById('new-playlist-name').value = '';
document.getElementById('new-playlist-id').value = '';
openModal('add-playlist-modal');
}
async function addPlaylist() {
const name = document.getElementById('new-playlist-name').value.trim();
const id = document.getElementById('new-playlist-id').value.trim();
if (!name || !id) {
showToast('Name and ID are required', 'error');
return;
}
try {
const res = await fetch('/api/admin/playlists', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, spotifyId: id })
});
const data = await res.json();
if (res.ok) {
showToast('Playlist added.', 'success');
showRestartBanner();
closeModal('add-playlist-modal');
} else {
showToast(data.error || 'Failed to add playlist', 'error');
}
} catch (error) {
showToast('Failed to add playlist', 'error');
}
}
async function editPlaylistSchedule(playlistName, currentSchedule) {
const newSchedule = prompt(`Edit sync schedule for "${playlistName}"\n\nCron format: minute hour day month dayofweek\nExamples:\n• 0 8 * * 1 = Monday 8 AM\n• 0 6 * * * = Daily 6 AM\n• 0 20 * * 5 = Friday 8 PM\n\nUse https://crontab.guru/ to build your schedule`, currentSchedule);
if (!newSchedule || newSchedule === currentSchedule) return;
// Validate cron format
const cronParts = newSchedule.trim().split(/\s+/);
if (cronParts.length !== 5) {
showToast('Invalid cron format. Expected: minute hour day month dayofweek', 'error');
return;
}
try {
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(playlistName)}/schedule`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ syncSchedule: newSchedule.trim() })
});
if (res.ok) {
showToast('Sync schedule updated!', 'success');
showRestartBanner();
fetchPlaylists();
} else {
const error = await res.json();
showToast(error.error || 'Failed to update schedule', 'error');
}
} catch (error) {
console.error('Failed to update schedule:', error);
showToast('Failed to update schedule', 'error');
}
}
async function removePlaylist(name) {
if (!confirm(`Remove playlist "${name}"?`)) return;
try {
const res = await fetch('/api/admin/playlists/' + encodeURIComponent(name), {
method: 'DELETE'
});
const data = await res.json();
if (res.ok) {
showToast('Playlist removed.', 'success');
showRestartBanner();
fetchPlaylists();
} else {
showToast(data.error || 'Failed to remove playlist', 'error');
}
} catch (error) {
showToast('Failed to remove playlist', 'error');
}
}
async function viewTracks(name) {
document.getElementById('tracks-modal-title').textContent = name + ' - Tracks';
document.getElementById('tracks-list').innerHTML = '<div class="loading"><span class="spinner"></span> Loading tracks...</div>';
openModal('tracks-modal');
try {
const res = await fetch('/api/admin/playlists/' + encodeURIComponent(name) + '/tracks');
if (!res.ok) {
console.error('Failed to fetch tracks:', res.status, res.statusText);
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Failed to load tracks: ' + res.status + ' ' + res.statusText + '</p>';
return;
}
const data = await res.json();
console.log('Tracks data received:', data);
if (!data || !data.tracks) {
console.error('Invalid data structure:', data);
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Invalid data received from server</p>';
return;
}
if (data.tracks.length === 0) {
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--text-secondary);padding:40px;">No tracks found</p>';
return;
}
document.getElementById('tracks-list').innerHTML = data.tracks.map(t => {
let statusBadge = '';
let mapButton = '';
let lyricsBadge = '';
// Add lyrics status badge
if (t.hasLyrics) {
lyricsBadge = '<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:4px;background:#3b82f6;color:white;"><span class="status-dot" style="background:white;"></span>Lyrics</span>';
}
if (t.isLocal === true) {
statusBadge = '<span class="status-badge success" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>Local</span>';
// Add manual mapping indicator for local tracks
if (t.isManualMapping && t.manualMappingType === 'jellyfin') {
statusBadge += '<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:4px;background:var(--info);color:white;"><span class="status-dot" style="background:white;"></span>Manual</span>';
}
} else if (t.isLocal === false) {
const provider = capitalizeProvider(t.externalProvider) || 'External';
statusBadge = `<span class="status-badge warning" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>${escapeHtml(provider)}</span>`;
// Add manual mapping indicator for external tracks
if (t.isManualMapping && t.manualMappingType === 'external') {
statusBadge += '<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:4px;background:var(--info);color:white;"><span class="status-dot" style="background:white;"></span>Manual</span>';
}
// Add both mapping buttons for external tracks using data attributes
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
mapButton = `<button class="small map-track-btn"
data-playlist-name="${escapeHtml(name)}"
data-position="${t.position}"
data-title="${escapeHtml(t.title || '')}"
data-artist="${escapeHtml(firstArtist)}"
data-spotify-id="${escapeHtml(t.spotifyId || '')}"
style="margin-left:8px;font-size:0.75rem;padding:4px 8px;">Map to Local</button>
<button class="small map-external-btn"
data-playlist-name="${escapeHtml(name)}"
data-position="${t.position}"
data-title="${escapeHtml(t.title || '')}"
data-artist="${escapeHtml(firstArtist)}"
data-spotify-id="${escapeHtml(t.spotifyId || '')}"
style="margin-left:4px;font-size:0.75rem;padding:4px 8px;background:var(--warning);border-color:var(--warning);">Map to External</button>`;
} else {
// isLocal is null/undefined - track is missing (not found locally or externally)
statusBadge = '<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;background:var(--bg-tertiary);color:var(--text-secondary);"><span class="status-dot" style="background:var(--text-secondary);"></span>Missing</span>';
// Add both mapping buttons for missing tracks
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
mapButton = `<button class="small map-track-btn"
data-playlist-name="${escapeHtml(name)}"
data-position="${t.position}"
data-title="${escapeHtml(t.title || '')}"
data-artist="${escapeHtml(firstArtist)}"
data-spotify-id="${escapeHtml(t.spotifyId || '')}"
style="margin-left:8px;font-size:0.75rem;padding:4px 8px;">Map to Local</button>
<button class="small map-external-btn"
data-playlist-name="${escapeHtml(name)}"
data-position="${t.position}"
data-title="${escapeHtml(t.title || '')}"
data-artist="${escapeHtml(firstArtist)}"
data-spotify-id="${escapeHtml(t.spotifyId || '')}"
style="margin-left:4px;font-size:0.75rem;padding:4px 8px;background:var(--warning);border-color:var(--warning);">Map to External</button>`;
}
// Build search link with track name and artist
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
const searchLinkText = `${t.title} - ${firstArtist}`;
const durationSeconds = Math.floor((t.durationMs || 0) / 1000);
// Add lyrics mapping button
const lyricsMapButton = `<button class="small" onclick="openLyricsMap('${escapeJs(firstArtist)}', '${escapeJs(t.title)}', '${escapeJs(t.album || '')}', ${durationSeconds})" style="margin-left:4px;font-size:0.75rem;padding:4px 8px;background:#3b82f6;border-color:#3b82f6;color:white;">Map Lyrics ID</button>`;
return `
<div class="track-item" data-position="${t.position}">
<span class="track-position">${t.position + 1}</span>
<div class="track-info">
<h4>${escapeHtml(t.title)}${statusBadge}${lyricsBadge}${mapButton}${lyricsMapButton}</h4>
<span class="artists">${escapeHtml((t.artists || []).join(', '))}</span>
</div>
<div class="track-meta">
${t.album ? escapeHtml(t.album) : ''}
${t.isrc ? '<br><small>ISRC: ' + t.isrc + '</small>' : ''}
${t.isLocal === false && t.searchQuery && t.externalProvider ? '<br><small style="color:var(--accent)"><a href="#" onclick="searchProvider(\'' + escapeJs(t.searchQuery) + '\', \'' + escapeJs(t.externalProvider) + '\'); return false;" style="color:var(--accent);text-decoration:underline;">🔍 Search: ' + escapeHtml(searchLinkText) + '</a></small>' : ''}
${t.isLocal === null && t.searchQuery ? '<br><small style="color:var(--text-secondary)"><a href="#" onclick="searchProvider(\'' + escapeJs(t.searchQuery) + '\', \'squidwtf\'); return false;" style="color:var(--text-secondary);text-decoration:underline;">🔍 Search: ' + escapeHtml(searchLinkText) + '</a></small>' : ''}
</div>
</div>
`;
}).join('');
// Add event listeners to map buttons
document.querySelectorAll('.map-track-btn').forEach(btn => {
btn.addEventListener('click', function() {
const playlistName = this.getAttribute('data-playlist-name');
const position = parseInt(this.getAttribute('data-position'));
const title = this.getAttribute('data-title');
const artist = this.getAttribute('data-artist');
const spotifyId = this.getAttribute('data-spotify-id');
openManualMap(playlistName, position, title, artist, spotifyId);
});
});
// Add event listeners to external map buttons
document.querySelectorAll('.map-external-btn').forEach(btn => {
btn.addEventListener('click', function() {
const playlistName = this.getAttribute('data-playlist-name');
const position = parseInt(this.getAttribute('data-position'));
const title = this.getAttribute('data-title');
const artist = this.getAttribute('data-artist');
const spotifyId = this.getAttribute('data-spotify-id');
openExternalMap(playlistName, position, title, artist, spotifyId);
});
});
} catch (error) {
console.error('Error in viewTracks:', error);
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Failed to load tracks: ' + error.message + '</p>';
}
}
// Generic edit setting modal
function openEditSetting(envKey, label, inputType, helpText = '', options = []) {
currentEditKey = envKey;
currentEditType = inputType;
currentEditOptions = options;
document.getElementById('edit-setting-title').textContent = 'Edit ' + label;
document.getElementById('edit-setting-label').textContent = label;
const helpEl = document.getElementById('edit-setting-help');
if (helpText) {
helpEl.textContent = helpText;
helpEl.style.display = 'block';
} else {
helpEl.style.display = 'none';
}
const container = document.getElementById('edit-setting-input-container');
if (inputType === 'toggle') {
container.innerHTML = `
<select id="edit-setting-value">
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</select>
`;
} else if (inputType === 'select') {
container.innerHTML = `
<select id="edit-setting-value">
${options.map(opt => `<option value="${opt}">${opt}</option>`).join('')}
</select>
`;
} else if (inputType === 'password') {
container.innerHTML = `<input type="password" id="edit-setting-value" placeholder="Enter new value" autocomplete="off">`;
} else if (inputType === 'number') {
container.innerHTML = `<input type="number" id="edit-setting-value" placeholder="Enter value">`;
} else {
container.innerHTML = `<input type="text" id="edit-setting-value" placeholder="Enter value">`;
}
openModal('edit-setting-modal');
}
async function saveEditSetting() {
const value = document.getElementById('edit-setting-value').value.trim();
if (!value && currentEditType !== 'toggle') {
showToast('Value is required', 'error');
return;
}
try {
const res = await fetch('/api/admin/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ updates: { [currentEditKey]: value } })
});
const data = await res.json();
if (res.ok) {
showToast('Setting updated.', 'success');
showRestartBanner();
closeModal('edit-setting-modal');
fetchConfig();
fetchStatus();
} else {
showToast(data.error || 'Failed to update setting', 'error');
}
} catch (error) {
showToast('Failed to update setting', 'error');
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Manual track mapping
let searchTimeout = null;
async function searchJellyfinTracks() {
const query = document.getElementById('map-search-query').value.trim();
if (!query) {
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">Type to search for local tracks or paste a Jellyfin URL...</p>';
return;
}
// Clear URL input when searching
document.getElementById('map-jellyfin-url').value = '';
// Debounce search
clearTimeout(searchTimeout);
searchTimeout = setTimeout(async () => {
document.getElementById('map-search-results').innerHTML = '<div class="loading"><span class="spinner"></span> Searching...</div>';
try {
const res = await fetch('/api/admin/jellyfin/search?query=' + encodeURIComponent(query));
const data = await res.json();
if (data.tracks.length === 0) {
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">No tracks found</p>';
return;
}
document.getElementById('map-search-results').innerHTML = data.tracks.map(t => `
<div class="track-item" style="cursor: pointer; border: 2px solid transparent;" onclick="selectJellyfinTrack('${t.id}', this)">
<div class="track-info">
<h4>${escapeHtml(t.title)}</h4>
<span class="artists">${escapeHtml(t.artist)}</span>
</div>
<div class="track-meta">
${t.album ? escapeHtml(t.album) : ''}
</div>
</div>
`).join('');
} catch (error) {
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--error); padding: 20px;">Search failed</p>';
}
}, 300);
}
async function extractJellyfinId() {
const url = document.getElementById('map-jellyfin-url').value.trim();
if (!url) {
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">Type to search for local tracks or paste a Jellyfin URL...</p>';
document.getElementById('map-selected-jellyfin-id').value = '';
document.getElementById('map-save-btn').disabled = true;
return;
}
// Clear search input when using URL
document.getElementById('map-search-query').value = '';
// Extract ID from URL patterns:
// https://jellyfin.example.com/web/#/details?id=XXXXX&serverId=...
// https://jellyfin.example.com/web/index.html#!/details?id=XXXXX
let jellyfinId = null;
try {
const idMatch = url.match(/[?&]id=([a-f0-9]+)/i);
if (idMatch) {
jellyfinId = idMatch[1];
}
} catch (e) {
// Invalid URL format
}
if (!jellyfinId) {
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--error); padding: 20px;">Could not extract track ID from URL. Make sure it contains "?id=..."</p>';
document.getElementById('map-selected-jellyfin-id').value = '';
document.getElementById('map-save-btn').disabled = true;
return;
}
// Fetch track details to show preview
document.getElementById('map-search-results').innerHTML = '<div class="loading"><span class="spinner"></span> Loading track details...</div>';
try {
const res = await fetch('/api/admin/jellyfin/track/' + jellyfinId);
const track = await res.json();
if (res.ok && track.id) {
document.getElementById('map-selected-jellyfin-id').value = track.id;
document.getElementById('map-save-btn').disabled = false;
document.getElementById('map-search-results').innerHTML = `
<div class="track-item" style="border: 2px solid var(--accent); background: var(--bg-tertiary);">
<div class="track-info">
<h4>${escapeHtml(track.title)}</h4>
<span class="artists">${escapeHtml(track.artist)}</span>
</div>
<div class="track-meta">
${track.album ? escapeHtml(track.album) : ''}
</div>
</div>
<p style="text-align: center; color: var(--success); padding: 12px; margin-top: 8px;">
✓ Track loaded from URL. Click "Save Mapping" to confirm.
</p>
`;
} else {
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--error); padding: 20px;">Track not found in Jellyfin</p>';
document.getElementById('map-selected-jellyfin-id').value = '';
document.getElementById('map-save-btn').disabled = true;
}
} catch (error) {
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--error); padding: 20px;">Failed to load track details</p>';
document.getElementById('map-selected-jellyfin-id').value = '';
document.getElementById('map-save-btn').disabled = true;
}
}
function selectJellyfinTrack(jellyfinId, element) {
// Remove selection from all tracks
document.querySelectorAll('#map-search-results .track-item').forEach(el => {
el.style.border = '2px solid transparent';
el.style.background = '';
});
// Highlight selected track
element.style.border = '2px solid var(--accent)';
element.style.background = 'var(--bg-tertiary)';
// Store selected ID and enable save button
document.getElementById('map-selected-jellyfin-id').value = jellyfinId;
document.getElementById('map-save-btn').disabled = false;
}
// Validate external mapping input
function validateExternalMapping() {
const externalId = document.getElementById('map-external-id').value.trim();
const saveBtn = document.getElementById('map-save-btn');
// Enable save button if external ID is provided
saveBtn.disabled = !externalId;
}
// Open manual mapping modal (external only)
function openManualMap(playlistName, position, title, artist, spotifyId) {
document.getElementById('map-playlist-name').value = playlistName;
document.getElementById('map-position').textContent = position + 1;
document.getElementById('map-spotify-title').textContent = title;
document.getElementById('map-spotify-artist').textContent = artist;
document.getElementById('map-spotify-id').value = spotifyId;
// Reset fields
document.getElementById('map-external-id').value = '';
document.getElementById('map-external-provider').value = 'SquidWTF';
document.getElementById('map-save-btn').disabled = true;
openModal('manual-map-modal');
}
// Alias for backward compatibility
function openExternalMap(playlistName, position, title, artist, spotifyId) {
openManualMap(playlistName, position, title, artist, spotifyId);
}
// Save manual mapping (external only)
async function saveManualMapping() {
const playlistName = document.getElementById('map-playlist-name').value;
const spotifyId = document.getElementById('map-spotify-id').value;
const position = parseInt(document.getElementById('map-position').textContent) - 1; // Convert back to 0-indexed
const externalProvider = document.getElementById('map-external-provider').value;
const externalId = document.getElementById('map-external-id').value.trim();
if (!externalId) {
showToast('Please enter an external provider ID', 'error');
return;
}
const requestBody = {
spotifyId,
externalProvider,
externalId
};
// Show loading state
const saveBtn = document.getElementById('map-save-btn');
const originalText = saveBtn.textContent;
saveBtn.textContent = 'Saving...';
saveBtn.disabled = true;
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout
const res = await fetch('/api/admin/playlists/' + encodeURIComponent(playlistName) + '/map', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
signal: controller.signal
});
clearTimeout(timeoutId);
const data = await res.json();
if (res.ok) {
showToast(`✓ Track mapped to ${requestBody.externalProvider} - rebuilding playlist...`, 'success');
closeModal('manual-map-modal');
// Show rebuilding indicator
showPlaylistRebuildingIndicator(playlistName);
// Show detailed info toast after a moment
setTimeout(() => {
showToast(`🔄 Rebuilding playlist with your ${requestBody.externalProvider} mapping...`, 'info', 8000);
}, 1000);
// Update the track in the UI without refreshing
const trackItem = document.querySelector(`.track-item[data-position="${position}"]`);
if (trackItem) {
const titleEl = trackItem.querySelector('.track-info h4');
if (titleEl) {
// Update status badge to show provider
const currentTitle = titleEl.textContent.split(' - ')[0]; // Remove old status
const capitalizedProvider = capitalizeProvider(requestBody.externalProvider);
const newStatusBadge = `<span class="status-badge warning" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>${escapeHtml(capitalizedProvider)}</span>`;
titleEl.innerHTML = escapeHtml(currentTitle) + newStatusBadge;
}
// Remove search link since it's now mapped
const searchLink = trackItem.querySelector('.track-meta a');
if (searchLink) {
searchLink.remove();
}
}
// Also refresh the playlist counts in the background
fetchPlaylists();
} else {
showToast(data.error || 'Failed to save mapping', 'error');
}
} catch (error) {
if (error.name === 'AbortError') {
showToast('Request timed out - mapping may still be processing', 'warning');
} else {
showToast('Failed to save mapping', 'error');
}
} finally {
// Reset button state
saveBtn.textContent = originalText;
saveBtn.disabled = false;
}
}
function showPlaylistRebuildingIndicator(playlistName) {
// Find the playlist in the UI and show rebuilding state
const playlistCards = document.querySelectorAll('.playlist-card');
for (const card of playlistCards) {
const nameEl = card.querySelector('h3');
if (nameEl && nameEl.textContent.trim() === playlistName) {
// Add rebuilding indicator
const existingIndicator = card.querySelector('.rebuilding-indicator');
if (!existingIndicator) {
const indicator = document.createElement('div');
indicator.className = 'rebuilding-indicator';
indicator.style.cssText = `
position: absolute;
top: 8px;
right: 8px;
background: var(--warning);
color: white;
padding: 4px 8px;
border-radius: 12px;
font-size: 0.7rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 4px;
z-index: 10;
`;
indicator.innerHTML = '<span class="spinner" style="width: 10px; height: 10px;"></span>Rebuilding...';
card.style.position = 'relative';
card.appendChild(indicator);
// Auto-remove after 30 seconds and refresh
setTimeout(() => {
indicator.remove();
fetchPlaylists(); // Refresh to get updated counts
}, 30000);
}
break;
}
}
}
function escapeJs(text) {
if (!text) return '';
return text.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"');
}
// Lyrics ID mapping functions
function openLyricsMap(artist, title, album, durationSeconds) {
document.getElementById('lyrics-map-artist').textContent = artist;
document.getElementById('lyrics-map-title').textContent = title;
document.getElementById('lyrics-map-album').textContent = album || '(No album)';
document.getElementById('lyrics-map-artist-value').value = artist;
document.getElementById('lyrics-map-title-value').value = title;
document.getElementById('lyrics-map-album-value').value = album || '';
document.getElementById('lyrics-map-duration').value = durationSeconds;
document.getElementById('lyrics-map-id').value = '';
openModal('lyrics-map-modal');
}
async function saveLyricsMapping() {
const artist = document.getElementById('lyrics-map-artist-value').value;
const title = document.getElementById('lyrics-map-title-value').value;
const album = document.getElementById('lyrics-map-album-value').value;
const durationSeconds = parseInt(document.getElementById('lyrics-map-duration').value);
const lyricsId = parseInt(document.getElementById('lyrics-map-id').value);
if (!lyricsId || lyricsId <= 0) {
showToast('Please enter a valid lyrics ID', 'error');
return;
}
const saveBtn = document.getElementById('lyrics-map-save-btn');
const originalText = saveBtn.textContent;
saveBtn.textContent = 'Saving...';
saveBtn.disabled = true;
try {
const res = await fetch('/api/admin/lyrics/map', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
artist,
title,
album,
durationSeconds,
lyricsId
})
});
const data = await res.json();
if (res.ok) {
if (data.cached && data.lyrics) {
showToast(`✓ Lyrics mapped and cached: ${data.lyrics.trackName} by ${data.lyrics.artistName}`, 'success', 5000);
} else {
showToast('✓ Lyrics mapping saved successfully', 'success');
}
closeModal('lyrics-map-modal');
} else {
showToast(data.error || 'Failed to save lyrics mapping', 'error');
}
} catch (error) {
showToast('Failed to save lyrics mapping', 'error');
} finally {
saveBtn.textContent = originalText;
saveBtn.disabled = false;
}
}
// Initial load
fetchStatus();
fetchPlaylists();
fetchTrackMappings();
fetchMissingTracks();
fetchDownloads();
fetchJellyfinUsers();
fetchJellyfinPlaylists();
fetchConfig();
fetchEndpointUsage();
// Auto-refresh every 30 seconds
setInterval(() => {
fetchStatus();
fetchPlaylists();
fetchTrackMappings();
fetchMissingTracks();
fetchDownloads();
// Refresh endpoint usage if on that tab
const endpointsTab = document.getElementById('tab-endpoints');
if (endpointsTab && endpointsTab.classList.contains('active')) {
fetchEndpointUsage();
}
}, 30000);
// Endpoint Usage Functions
async function fetchEndpointUsage() {
try {
const topSelect = document.getElementById('endpoints-top-select');
const top = topSelect ? topSelect.value : 50;
const res = await fetch(`/api/admin/debug/endpoint-usage?top=${top}`);
const data = await res.json();
// Update summary stats
document.getElementById('endpoints-total-requests').textContent = data.totalRequests?.toLocaleString() || '0';
document.getElementById('endpoints-unique-count').textContent = data.totalEndpoints?.toLocaleString() || '0';
const mostCalled = data.endpoints && data.endpoints.length > 0
? data.endpoints[0].endpoint
: '-';
document.getElementById('endpoints-most-called').textContent = mostCalled;
// Update table
const tbody = document.getElementById('endpoints-table-body');
if (!data.endpoints || data.endpoints.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:var(--text-secondary);padding:40px;">No endpoint usage data available yet. Data will appear as clients make requests.</td></tr>';
return;
}
tbody.innerHTML = data.endpoints.map((ep, index) => {
const percentage = data.totalRequests > 0
? ((ep.count / data.totalRequests) * 100).toFixed(1)
: '0.0';
// Color code based on usage
let countColor = 'var(--text-primary)';
if (ep.count > 1000) countColor = 'var(--error)';
else if (ep.count > 100) countColor = 'var(--warning)';
else if (ep.count > 10) countColor = 'var(--accent)';
// Highlight common patterns
let endpointDisplay = ep.endpoint;
if (ep.endpoint.includes('/stream')) {
endpointDisplay = `<span style="color:var(--success)">${escapeHtml(ep.endpoint)}</span>`;
} else if (ep.endpoint.includes('/Playing')) {
endpointDisplay = `<span style="color:var(--accent)">${escapeHtml(ep.endpoint)}</span>`;
} else if (ep.endpoint.includes('/Search')) {
endpointDisplay = `<span style="color:var(--warning)">${escapeHtml(ep.endpoint)}</span>`;
} else {
endpointDisplay = escapeHtml(ep.endpoint);
}
return `
<tr>
<td style="color:var(--text-secondary);text-align:center;">${index + 1}</td>
<td style="font-family:monospace;font-size:0.85rem;">${endpointDisplay}</td>
<td style="text-align:right;font-weight:600;color:${countColor}">${ep.count.toLocaleString()}</td>
<td style="text-align:right;color:var(--text-secondary)">${percentage}%</td>
</tr>
`;
}).join('');
} catch (error) {
console.error('Failed to fetch endpoint usage:', error);
const tbody = document.getElementById('endpoints-table-body');
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:var(--error);padding:40px;">Failed to load endpoint usage data</td></tr>';
}
}
async function clearEndpointUsage() {
if (!confirm('Are you sure you want to clear all endpoint usage data? This cannot be undone.')) {
return;
}
try {
const res = await fetch('/api/admin/debug/endpoint-usage', { method: 'DELETE' });
const data = await res.json();
showToast(data.message || 'Endpoint usage data cleared', 'success');
fetchEndpointUsage();
} catch (error) {
console.error('Failed to clear endpoint usage:', error);
showToast('Failed to clear endpoint usage data', 'error');
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
</script>
</body>
</html>
>>>>>>> dev

View File

@@ -12,7 +12,6 @@ services:
interval: 10s interval: 10s
timeout: 3s timeout: 3s
retries: 3 retries: 3
<<<<<<< HEAD
volumes: volumes:
- ${REDIS_DATA_PATH:-./redis-data}:/data - ${REDIS_DATA_PATH:-./redis-data}:/data
networks: networks:
@@ -26,25 +25,6 @@ services:
- "8365:8080" - "8365:8080"
environment: environment:
- SP_DC=${SPOTIFY_API_SESSION_COOKIE:-} - SP_DC=${SPOTIFY_API_SESSION_COOKIE:-}
||||||| bc4e5d9
=======
volumes:
- ${REDIS_DATA_PATH:-./redis-data}:/data
networks:
- allstarr-network
# Spotify Lyrics API sidecar service
# Note: This image only supports AMD64. On ARM64 systems, Docker will use emulation.
spotify-lyrics:
image: akashrchandran/spotify-lyrics-api:latest
platform: linux/amd64
container_name: allstarr-spotify-lyrics
restart: unless-stopped
ports:
- "8365:8080"
environment:
- SP_DC=${SPOTIFY_API_SESSION_COOKIE:-}
>>>>>>> dev
networks: networks:
- allstarr-network - allstarr-network