Compare commits

...

237 Commits

Author SHA1 Message Date
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
37 changed files with 11477 additions and 366 deletions

View File

@@ -6,6 +6,10 @@ BACKEND_TYPE=Subsonic
# Enable Redis caching for metadata and images (default: true) # Enable Redis caching for metadata and images (default: true)
REDIS_ENABLED=true REDIS_ENABLED=true
# Redis data persistence directory (default: ./redis-data)
# Redis will save snapshots and append-only logs here to persist cache across restarts
REDIS_DATA_PATH=./redis-data
# ===== SUBSONIC/NAVIDROME CONFIGURATION ===== # ===== SUBSONIC/NAVIDROME CONFIGURATION =====
# Server URL (required if using Subsonic backend) # Server URL (required if using Subsonic backend)
SUBSONIC_URL=http://localhost:4533 SUBSONIC_URL=http://localhost:4533
@@ -30,6 +34,12 @@ MUSIC_SERVICE=SquidWTF
# 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
# Path where favorited external tracks are permanently kept
KEPT_PATH=./kept
# Path for cache files (Spotify missing tracks, etc.)
CACHE_PATH=./cache
# ===== SQUIDWTF CONFIGURATION ===== # ===== SQUIDWTF CONFIGURATION =====
# Different quality options for SquidWTF. Only FLAC supported right now # Different quality options for SquidWTF. Only FLAC supported right now
SQUIDWTF_QUALITY=FLAC SQUIDWTF_QUALITY=FLAC
@@ -95,3 +105,78 @@ STORAGE_MODE=Permanent
# Based on last access time (updated each time the file is streamed) # Based on last access time (updated each time the file is streamed)
# Cache location: /tmp/allstarr-cache (or $TMPDIR/allstarr-cache if TMPDIR is set) # Cache location: /tmp/allstarr-cache (or $TMPDIR/allstarr-cache if TMPDIR is set)
CACHE_DURATION_HOURS=1 CACHE_DURATION_HOURS=1
# ===== SPOTIFY PLAYLIST INJECTION (JELLYFIN ONLY) =====
# REQUIRES: Jellyfin Spotify Import Plugin (https://github.com/Viperinius/jellyfin-plugin-spotify-import)
# 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)
# Enable Spotify playlist injection (optional, default: 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
# Playlists configuration (JSON ARRAY FORMAT - managed by web UI)
# Format: [["PlaylistName","SpotifyPlaylistId","first|last"],...]
# - PlaylistName: Name as it appears in Jellyfin
# - SpotifyPlaylistId: Get from Spotify URL (e.g., 37i9dQZF1DXcBWIGoYBM5M)
# Accepts: spotify:playlist:ID, full URL, or just the ID
# - first|last: Where to position local tracks (first=local tracks first, last=external tracks first)
#
# Example:
# SPOTIFY_IMPORT_PLAYLISTS=[["Discover Weekly","37i9dQZEVXcV6s7Dm7RXsU","first"],["Release Radar","37i9dQZEVXbng2vDHnfQlC","first"]]
#
# RECOMMENDED: Use the web UI (Link Playlists tab) to manage playlists instead of editing this manually
SPOTIFY_IMPORT_PLAYLISTS=[]
# ===== SPOTIFY DIRECT API (RECOMMENDED - ENABLES TRACK ORDERING & LYRICS) =====
# This is the preferred method for Spotify playlist integration.
# Provides: Correct track ordering, ISRC-based exact matching, synchronized lyrics
# Does NOT require the Jellyfin Spotify Import plugin (can work standalone)
# Enable direct Spotify API access (default: false)
SPOTIFY_API_ENABLED=false
# Spotify Client ID from https://developer.spotify.com/dashboard
# Create an app in the Spotify Developer Dashboard to get this
SPOTIFY_API_CLIENT_ID=
# Spotify Client Secret (optional - only needed for certain OAuth flows)
SPOTIFY_API_CLIENT_SECRET=
# Spotify session cookie (sp_dc) - REQUIRED for editorial playlists
# Editorial playlists (Release Radar, Discover Weekly, etc.) require authentication
# via session cookie because they're not accessible through the official API.
#
# To get your sp_dc cookie:
# 1. Open https://open.spotify.com in your browser and log in
# 2. Open DevTools (F12) → Application → Cookies → https://open.spotify.com
# 3. Find the cookie named "sp_dc" and copy its value
# 4. Note: This cookie expires periodically (typically every few months)
SPOTIFY_API_SESSION_COOKIE=
# Date when the session cookie was set (ISO 8601 format)
# Automatically set by the web UI when you update the cookie
# Used to track cookie age and warn when approaching expiration (~1 year)
SPOTIFY_API_SESSION_COOKIE_SET_DATE=
# Cache duration for playlist data in minutes (default: 60)
# Release Radar updates weekly, Discover Weekly updates Mondays
SPOTIFY_API_CACHE_DURATION_MINUTES=60
# Rate limit delay between API requests in milliseconds (default: 100)
SPOTIFY_API_RATE_LIMIT_DELAY_MS=100
# Prefer ISRC matching over fuzzy title/artist matching (default: true)
# ISRC provides exact track identification across different streaming services
SPOTIFY_API_PREFER_ISRC_MATCHING=true

11
.gitignore vendored
View File

@@ -74,6 +74,12 @@ obj/
downloads/ downloads/
!downloads/.gitkeep !downloads/.gitkeep
# Kept music files (favorited external tracks)
kept/
# Cache files (Spotify missing tracks, etc.)
cache/
# Docker volumes # Docker volumes
redis-data/ redis-data/
@@ -83,4 +89,7 @@ apis/*.json
!apis/jellyfin-openapi-stable.json !apis/jellyfin-openapi-stable.json
# Original source code for reference # Original source code for reference
originals/ originals/
# Sample missing playlists for Spotify integration testing
sampleMissingPlaylists/

View File

@@ -24,7 +24,8 @@ RUN mkdir -p /app/downloads
COPY --from=build /app/publish . COPY --from=build /app/publish .
# Only expose the main proxy port (8080)
# Admin UI runs on 5275 but is NOT exposed - access via docker exec or SSH tunnel
EXPOSE 8080 EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080
ENTRYPOINT ["dotnet", "allstarr.dll"] ENTRYPOINT ["dotnet", "allstarr.dll"]

202
README.md
View File

@@ -16,13 +16,18 @@ Please report all bugs as soon as possible, as the Jellyfin addition is entirely
Using Docker (recommended): Using Docker (recommended):
```bash ```bash
# 1. Pull the latest image # 1. Download the docker-compose.yml file and the .env.example file to a folder on the machine you have Docker
docker-compose pull
curl -O https://raw.githubusercontent.com/SoPat712/allstarr/refs/heads/main/docker-compose.yml \
-O https://raw.githubusercontent.com/SoPat712/allstarr/refs/heads/main/.env.example
# 2. Configure environment # 2. Configure environment
cp .env.example .env cp .env.example .env
vi .env # Edit with your settings vi .env # Edit with your settings
# 3. Pull the latest image
docker-compose pull
# 3. Start services # 3. Start services
docker-compose up -d docker-compose up -d
@@ -33,9 +38,49 @@ docker-compose logs -f
The proxy will be available at `http://localhost:5274`. The proxy will be available at `http://localhost:5274`.
## Web Dashboard
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
- **Real-time Status**: Monitor Spotify authentication, cookie age, and playlist sync status
- **Playlist Management**: Link Jellyfin playlists to Spotify playlists with a few clicks
- **Configuration Editor**: Update settings without manually editing .env files
- **Track Viewer**: Browse tracks in your configured playlists
- **Cache Management**: Clear cached data and restart the container
### 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 (button in Configuration tab)
### 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
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`.
**Recommended workflow**: Use the `sp_dc` cookie method (simpler and more reliable than the Jellyfin Spotify Import plugin).
### Nginx Proxy Setup (Required) ### Nginx Proxy Setup (Required)
This service only exposes ports internally. You **must** use nginx to proxy to it: 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!
```nginx ```nginx
server { server {
@@ -78,6 +123,7 @@ This project brings together all the music streaming providers into one unified
- **Transparent Proxy**: Sits between your music clients and media server - **Transparent Proxy**: Sits between your music clients and media server
- **Automatic Search**: Searches streaming providers when songs aren't local - **Automatic Search**: Searches streaming providers when songs aren't local
- **On-the-Fly Downloads**: Songs download and cache for future use - **On-the-Fly Downloads**: Songs download and cache for future use
- **Favorite to Keep**: When you favorite an external track, it's automatically copied to a permanent `/kept` folder separate from the cache
- **External Playlist Support**: Search and download playlists from Deezer, Qobuz, and SquidWTF with M3U generation - **External Playlist Support**: Search and download playlists from Deezer, Qobuz, and SquidWTF with M3U generation
- **Hi-Res Audio**: SquidWTF supports up to 24-bit/192kHz FLAC - **Hi-Res Audio**: SquidWTF supports up to 24-bit/192kHz FLAC
- **Full Metadata**: Downloaded files include complete ID3 tags (title, artist, album, track number, year, genre, BPM, ISRC, etc.) and cover art - **Full Metadata**: Downloaded files include complete ID3 tags (title, artist, album, track number, year, genre, BPM, ISRC, etc.) and cover art
@@ -85,6 +131,7 @@ This project brings together all the music streaming providers into one unified
- **Artist Deduplication**: Merges local and streaming artists to avoid duplicates - **Artist Deduplication**: Merges local and streaming artists to avoid duplicates
- **Album Enrichment**: Adds missing tracks to local albums from streaming providers - **Album Enrichment**: Adds missing tracks to local albums from streaming providers
- **Cover Art Proxy**: Serves cover art for external content - **Cover Art Proxy**: Serves cover art for external content
- **Spotify Playlist Injection** (Jellyfin only): Intercepts Spotify Import plugin playlists (Release Radar, Discover Weekly) and fills them with tracks auto-matched from streaming providers
## Supported Backends ## Supported Backends
@@ -243,6 +290,10 @@ Choose your preferred provider via the `MUSIC_SERVICE` environment variable. Add
|---------|-------------| |---------|-------------|
| `SquidWTF:Quality` | Preferred audio quality: `FLAC`, `MP3_320`, `MP3_128`. If not specified, the highest available quality for your account will be used | | `SquidWTF:Quality` | Preferred audio quality: `FLAC`, `MP3_320`, `MP3_128`. If not specified, the highest available quality for your account will be used |
**Load Balancing & Reliability:**
SquidWTF uses a round-robin load balancing strategy across multiple backup API endpoints to distribute requests evenly and prevent overwhelming any single provider. Each request automatically rotates to the next endpoint in the pool, with automatic fallback to other endpoints if one fails. This ensures high availability and prevents rate limiting by distributing load across multiple providers.
### Deezer Settings ### Deezer Settings
| Setting | Description | | Setting | Description |
@@ -282,6 +333,151 @@ 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.
### Spotify Playlist Injection (Jellyfin Only)
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
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 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**
- Allstarr needs to know when the plugin runs and which playlists to intercept
- Uses your existing `JELLYFIN_URL` and `JELLYFIN_API_KEY` settings (no additional credentials needed)
#### Configuration
| Setting | Description |
|---------|-------------|
| `SpotifyImport:Enabled` | Enable Spotify playlist injection (default: `false`) |
| `SpotifyImport:SyncStartHour` | Hour when the Spotify Import plugin runs (24-hour format, 0-23) |
| `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:**
```bash
# Enable the feature
SPOTIFY_IMPORT_ENABLED=true
# Sync window settings (optional - used to prevent fetching too frequently)
# 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
# Get playlist IDs from Jellyfin URLs: https://jellyfin.example.com/web/#/details?id=PLAYLIST_ID
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
1. **Spotify Import Plugin Runs** (e.g., daily at 4:15 PM)
- 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
- Files are named like: `Release Radar_missing_2026-02-01_16-15.json`
2. **Allstarr Fetches Missing Tracks** (within sync window)
- 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 every 30 minutes)
- 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)
- Caches matched results for 1 hour
4. **You Open the Playlist in Jellyfin**
- Allstarr intercepts the request
- Returns a merged list: local tracks + matched streaming tracks
- Loads instantly from cache (no searching needed!)
5. **You Play a Track**
- If it's a local track, streams from Jellyfin normally
- If it's a matched track, downloads from streaming provider on-demand
- Downloaded tracks are saved to your library for future use
#### Manual Triggers
You can manually trigger syncing and matching via API:
```bash
# Fetch missing tracks from Jellyfin plugin
curl "https://your-jellyfin-proxy.com/spotify/sync?api_key=YOUR_API_KEY"
# Trigger track matching (searches streaming provider)
curl "https://your-jellyfin-proxy.com/spotify/match?api_key=YOUR_API_KEY"
# Clear cache to force re-matching
curl "https://your-jellyfin-proxy.com/spotify/clear-cache?api_key=YOUR_API_KEY"
```
#### Startup Behavior
When Allstarr starts with Spotify Import enabled:
**Smart Cache Check:**
- Checks if today's sync window has passed (e.g., if sync is at 4 PM + 2 hour window = 6 PM)
- 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
**Playlists are empty:**
- Check that the Spotify Import plugin is running and creating playlists
- Verify `SPOTIFY_IMPORT_PLAYLIST_IDS` match your Jellyfin playlist IDs
- Check logs: `docker-compose logs -f allstarr | grep -i spotify`
**Tracks aren't matching:**
- Ensure your streaming provider is configured (`MUSIC_SERVICE`, credentials)
- Check that playlist names in `SPOTIFY_IMPORT_PLAYLIST_NAMES` match exactly
- Manually trigger matching: `curl "https://your-proxy.com/spotify/match?api_key=KEY"`
**Sync timing issues:**
- Set `SPOTIFY_IMPORT_SYNC_START_HOUR/MINUTE` to match your plugin schedule
- Increase `SPOTIFY_IMPORT_SYNC_WINDOW_HOURS` if files aren't being found
- Check Jellyfin plugin logs to confirm when it runs
#### Notes
- This feature uses your existing `JELLYFIN_URL` and `JELLYFIN_API_KEY` settings
- Matched tracks are cached for 1 hour to avoid repeated searches
- Missing tracks cache persists across restarts (stored in Redis + file cache)
- Rate limiting prevents overwhelming your streaming provider (150ms between searches)
- Only works with Jellyfin backend (not Subsonic/Navidrome)
### Getting Credentials ### Getting Credentials
#### Deezer ARL Token #### Deezer ARL Token

View File

@@ -63,11 +63,12 @@ public class JellyfinProxyServiceTests
SetupMockResponse(HttpStatusCode.OK, jsonResponse, "application/json"); SetupMockResponse(HttpStatusCode.OK, jsonResponse, "application/json");
// Act // Act
var result = await _service.GetJsonAsync("Items"); var (body, statusCode) = await _service.GetJsonAsync("Items");
// Assert // Assert
Assert.NotNull(result); Assert.NotNull(body);
Assert.True(result.RootElement.TryGetProperty("Items", out var items)); Assert.Equal(200, statusCode);
Assert.True(body.RootElement.TryGetProperty("Items", out var items));
Assert.Equal(1, items.GetArrayLength()); Assert.Equal(1, items.GetArrayLength());
} }
@@ -78,14 +79,15 @@ public class JellyfinProxyServiceTests
SetupMockResponse(HttpStatusCode.InternalServerError, "", "text/plain"); SetupMockResponse(HttpStatusCode.InternalServerError, "", "text/plain");
// Act // Act
var result = await _service.GetJsonAsync("Items"); var (body, statusCode) = await _service.GetJsonAsync("Items");
// Assert // Assert
Assert.Null(result); Assert.Null(body);
Assert.Equal(500, statusCode);
} }
[Fact] [Fact]
public async Task GetJsonAsync_IncludesAuthHeader() public async Task GetJsonAsync_WithoutClientHeaders_SendsNoAuth()
{ {
// Arrange // Arrange
HttpRequestMessage? captured = null; HttpRequestMessage? captured = null;
@@ -102,13 +104,10 @@ public class JellyfinProxyServiceTests
// Act // Act
await _service.GetJsonAsync("Items"); await _service.GetJsonAsync("Items");
// Assert // Assert - Should NOT include auth when no client headers provided
Assert.NotNull(captured); Assert.NotNull(captured);
Assert.True(captured!.Headers.Contains("Authorization")); Assert.False(captured!.Headers.Contains("Authorization"));
var authHeader = captured.Headers.GetValues("Authorization").First(); Assert.False(captured.Headers.Contains("X-Emby-Authorization"));
Assert.Contains("MediaBrowser", authHeader);
Assert.Contains(_settings.ApiKey!, authHeader);
Assert.Contains(_settings.ClientName!, authHeader);
} }
[Fact] [Fact]
@@ -210,12 +209,13 @@ public class JellyfinProxyServiceTests
}); });
// Act // Act
var result = await _service.GetItemAsync("abc-123"); var (body, statusCode) = await _service.GetItemAsync("abc-123");
// Assert // Assert
Assert.NotNull(captured); Assert.NotNull(captured);
Assert.Contains("/Items/abc-123", captured!.RequestUri!.ToString()); Assert.Contains("/Items/abc-123", captured!.RequestUri!.ToString());
Assert.NotNull(result); Assert.NotNull(body);
Assert.Equal(200, statusCode);
} }
[Fact] [Fact]

View File

@@ -0,0 +1,1512 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using allstarr.Models.Settings;
using allstarr.Services.Spotify;
using allstarr.Services.Jellyfin;
using allstarr.Services.Common;
using allstarr.Filters;
using System.Text.Json;
using System.Text.RegularExpressions;
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 DeezerSettings _deezerSettings;
private readonly QobuzSettings _qobuzSettings;
private readonly SquidWTFSettings _squidWtfSettings;
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 string _envFilePath;
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<DeezerSettings> deezerSettings,
IOptions<QobuzSettings> qobuzSettings,
IOptions<SquidWTFSettings> squidWtfSettings,
SpotifyApiClient spotifyClient,
SpotifyPlaylistFetcher playlistFetcher,
RedisCacheService cache,
IHttpClientFactory httpClientFactory,
SpotifyTrackMatchingService? matchingService = null)
{
_logger = logger;
_configuration = configuration;
_environment = environment;
_spotifyApiSettings = spotifyApiSettings.Value;
_spotifyImportSettings = spotifyImportSettings.Value;
_jellyfinSettings = jellyfinSettings.Value;
_deezerSettings = deezerSettings.Value;
_qobuzSettings = qobuzSettings.Value;
_squidWtfSettings = squidWtfSettings.Value;
_spotifyClient = spotifyClient;
_playlistFetcher = playlistFetcher;
_matchingService = matchingService;
_cache = cache;
_jellyfinHttpClient = httpClientFactory.CreateClient();
// .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";
_logger.LogInformation("Admin controller initialized. .env path: {EnvFilePath}", _envFilePath);
}
/// <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,
syncTime = $"{_spotifyImportSettings.SyncStartHour:D2}:{_spotifyImportSettings.SyncStartMinute:D2}",
syncWindowHours = _spotifyImportSettings.SyncWindowHours,
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 list of configured playlists with their current data
/// </summary>
[HttpGet("playlists")]
public async Task<IActionResult> GetPlaylists()
{
var playlists = new List<object>();
foreach (var config in _spotifyImportSettings.Playlists)
{
var playlistInfo = new Dictionary<string, object?>
{
["name"] = config.Name,
["id"] = config.Id,
["jellyfinId"] = config.JellyfinId,
["localTracksPosition"] = config.LocalTracksPosition.ToString(),
["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))
{
var localCount = 0;
var externalMatchedCount = 0;
// Count local vs external tracks
foreach (var item in items.EnumerateArray())
{
// Check if track has a real file path (local) or is external
var hasPath = item.TryGetProperty("Path", out var pathProp) &&
pathProp.ValueKind == JsonValueKind.String &&
!string.IsNullOrEmpty(pathProp.GetString());
if (hasPath)
{
var pathStr = pathProp.GetString()!;
// Local tracks have filesystem paths starting with / or containing :\
if (pathStr.StartsWith("/") || pathStr.Contains(":\\"))
{
localCount++;
}
else
{
// External track (downloaded from Deezer/Qobuz/etc)
externalMatchedCount++;
}
}
else
{
// No path means external
externalMatchedCount++;
}
}
var totalInJellyfin = localCount + externalMatchedCount;
var externalMissingCount = Math.Max(0, spotifyTrackCount - totalInJellyfin);
playlistInfo["localTracks"] = localCount;
playlistInfo["externalMatched"] = externalMatchedCount;
playlistInfo["externalMissing"] = externalMissingCount;
playlistInfo["externalTotal"] = externalMatchedCount + externalMissingCount;
playlistInfo["totalInJellyfin"] = totalInJellyfin;
_logger.LogDebug("Playlist {Name}: {Total} Spotify tracks, {Local} local, {ExtMatched} external matched, {ExtMissing} external missing",
config.Name, spotifyTrackCount, localCount, externalMatchedCount, externalMissingCount);
}
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);
}
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);
// Get the playlist config to find Jellyfin ID
var playlistConfig = _spotifyImportSettings.Playlists
.FirstOrDefault(p => p.Name.Equals(decodedName, StringComparison.OrdinalIgnoreCase));
var tracksWithStatus = new List<object>();
if (!string.IsNullOrEmpty(playlistConfig?.JellyfinId))
{
// Get existing tracks from Jellyfin to determine local/external status
var userId = _jellyfinSettings.UserId;
if (!string.IsNullOrEmpty(userId))
{
try
{
var url = $"{_jellyfinSettings.Url}/Playlists/{playlistConfig.JellyfinId}/Items?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 json = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
// Build list of local tracks (match by name only - no Spotify IDs!)
var localTracks = new List<(string Title, string Artist)>();
if (doc.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() ?? "";
}
if (!string.IsNullOrEmpty(title))
{
localTracks.Add((title, artist));
}
}
}
_logger.LogInformation("Found {Count} local tracks in Jellyfin playlist {Playlist}",
localTracks.Count, decodedName);
// Match Spotify tracks to local tracks by name (fuzzy matching)
foreach (var track in spotifyTracks)
{
var isLocal = false;
if (localTracks.Count > 0)
{
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;
}
}
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
});
}
return Ok(new
{
name = decodedName,
trackCount = spotifyTracks.Count,
tracks = tracksWithStatus
});
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get local track status for {Playlist}", decodedName);
}
}
}
// Fallback: return tracks without local/external status
return Ok(new
{
name = decodedName,
trackCount = spotifyTracks.Count,
tracks = spotifyTracks.Select(t => new
{
position = t.Position,
title = t.Title,
artists = t.Artists,
album = t.Album,
isrc = t.Isrc,
spotifyId = t.SpotifyId,
durationMs = t.DurationMs,
albumArtUrl = t.AlbumArtUrl,
isLocal = (bool?)null // Unknown
})
});
}
/// <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();
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);
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>
/// 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 url = $"{_jellyfinSettings.Url}/Items?searchTerm={Uri.EscapeDataString(query)}&includeItemTypes=Audio&recursive=true&limit=20";
var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
var response = await _jellyfinHttpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
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())
{
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>
/// Save manual track mapping
/// </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) || string.IsNullOrWhiteSpace(request.JellyfinId))
{
return BadRequest(new { error = "SpotifyId and JellyfinId are required" });
}
try
{
// Store mapping in cache (you could also persist to a file)
var mappingKey = $"spotify:manual-map:{decodedName}:{request.SpotifyId}";
await _cache.SetAsync(mappingKey, request.JellyfinId, TimeSpan.FromDays(365)); // Long TTL
_logger.LogInformation("Manual mapping saved: {Playlist} - Spotify {SpotifyId} → Jellyfin {JellyfinId}",
decodedName, request.SpotifyId, request.JellyfinId);
// Clear the matched tracks cache to force re-matching
var cacheKey = $"spotify:matched:{decodedName}";
await _cache.DeleteAsync(cacheKey);
return Ok(new { message = "Mapping saved successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save manual mapping");
return StatusCode(500, new { error = "Failed to save mapping" });
}
}
public class ManualMappingRequest
{
public string SpotifyId { get; set; } = "";
public string JellyfinId { get; set; } = "";
}
/// <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
{
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,
syncStartHour = _spotifyImportSettings.SyncStartHour,
syncStartMinute = _spotifyImportSettings.SyncStartMinute,
syncWindowHours = _spotifyImportSettings.SyncWindowHours,
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
},
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"
}
});
}
/// <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, 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}"
};
foreach (var key in keysToDelete)
{
if (await _cache.DeleteAsync(key))
{
clearedRedisKeys++;
_logger.LogInformation("Cleared Redis cache key: {Key}", key);
}
}
}
_logger.LogInformation("Cache cleared: {Files} files, {RedisKeys} Redis keys", clearedFiles, clearedRedisKeys);
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 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;
// Fetch track details to categorize local vs external
var trackStats = await GetPlaylistTrackStats(id!);
playlists.Add(new
{
id,
name,
trackCount = childCount,
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
});
// 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>
/// 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);
}
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"],...]
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
});
}
}
}
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);
}
}
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;
}

View File

@@ -2,14 +2,18 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using System.Text.Json; using System.Text.Json;
using allstarr.Models.Domain; using allstarr.Models.Domain;
using allstarr.Models.Lyrics;
using allstarr.Models.Settings; using allstarr.Models.Settings;
using allstarr.Models.Subsonic; using allstarr.Models.Subsonic;
using allstarr.Models.Spotify;
using allstarr.Services; using allstarr.Services;
using allstarr.Services.Common; using allstarr.Services.Common;
using allstarr.Services.Local; using allstarr.Services.Local;
using allstarr.Services.Jellyfin; using allstarr.Services.Jellyfin;
using allstarr.Services.Subsonic; using allstarr.Services.Subsonic;
using allstarr.Services.Lyrics; using allstarr.Services.Lyrics;
using allstarr.Services.Spotify;
using allstarr.Filters;
namespace allstarr.Controllers; namespace allstarr.Controllers;
@@ -22,34 +26,52 @@ namespace allstarr.Controllers;
public class JellyfinController : ControllerBase public class JellyfinController : ControllerBase
{ {
private readonly JellyfinSettings _settings; private readonly JellyfinSettings _settings;
private readonly SpotifyImportSettings _spotifySettings;
private readonly SpotifyApiSettings _spotifyApiSettings;
private readonly IMusicMetadataService _metadataService; private readonly IMusicMetadataService _metadataService;
private readonly ILocalLibraryService _localLibraryService; private readonly ILocalLibraryService _localLibraryService;
private readonly IDownloadService _downloadService; private readonly IDownloadService _downloadService;
private readonly JellyfinResponseBuilder _responseBuilder; private readonly JellyfinResponseBuilder _responseBuilder;
private readonly JellyfinModelMapper _modelMapper; private readonly JellyfinModelMapper _modelMapper;
private readonly JellyfinProxyService _proxyService; private readonly JellyfinProxyService _proxyService;
private readonly JellyfinSessionManager _sessionManager;
private readonly PlaylistSyncService? _playlistSyncService; private readonly PlaylistSyncService? _playlistSyncService;
private readonly SpotifyPlaylistFetcher? _spotifyPlaylistFetcher;
private readonly SpotifyLyricsService? _spotifyLyricsService;
private readonly RedisCacheService _cache;
private readonly ILogger<JellyfinController> _logger; private readonly ILogger<JellyfinController> _logger;
public JellyfinController( public JellyfinController(
IOptions<JellyfinSettings> settings, IOptions<JellyfinSettings> settings,
IOptions<SpotifyImportSettings> spotifySettings,
IOptions<SpotifyApiSettings> spotifyApiSettings,
IMusicMetadataService metadataService, IMusicMetadataService metadataService,
ILocalLibraryService localLibraryService, ILocalLibraryService localLibraryService,
IDownloadService downloadService, IDownloadService downloadService,
JellyfinResponseBuilder responseBuilder, JellyfinResponseBuilder responseBuilder,
JellyfinModelMapper modelMapper, JellyfinModelMapper modelMapper,
JellyfinProxyService proxyService, JellyfinProxyService proxyService,
JellyfinSessionManager sessionManager,
RedisCacheService cache,
ILogger<JellyfinController> logger, ILogger<JellyfinController> logger,
PlaylistSyncService? playlistSyncService = null) PlaylistSyncService? playlistSyncService = null,
SpotifyPlaylistFetcher? spotifyPlaylistFetcher = null,
SpotifyLyricsService? spotifyLyricsService = null)
{ {
_settings = settings.Value; _settings = settings.Value;
_spotifySettings = spotifySettings.Value;
_spotifyApiSettings = spotifyApiSettings.Value;
_metadataService = metadataService; _metadataService = metadataService;
_localLibraryService = localLibraryService; _localLibraryService = localLibraryService;
_downloadService = downloadService; _downloadService = downloadService;
_responseBuilder = responseBuilder; _responseBuilder = responseBuilder;
_modelMapper = modelMapper; _modelMapper = modelMapper;
_proxyService = proxyService; _proxyService = proxyService;
_sessionManager = sessionManager;
_playlistSyncService = playlistSyncService; _playlistSyncService = playlistSyncService;
_spotifyPlaylistFetcher = spotifyPlaylistFetcher;
_spotifyLyricsService = spotifyLyricsService;
_cache = cache;
_logger = logger; _logger = logger;
if (string.IsNullOrWhiteSpace(_settings.Url)) if (string.IsNullOrWhiteSpace(_settings.Url))
@@ -101,17 +123,43 @@ public class JellyfinController : ControllerBase
// Build the full endpoint path with query string // Build the full endpoint path with query string
var endpoint = userId != null ? $"Users/{userId}/Items" : "Items"; var endpoint = userId != null ? $"Users/{userId}/Items" : "Items";
if (Request.QueryString.HasValue)
// Ensure MediaSources is included in Fields parameter for bitrate info
var queryString = Request.QueryString.Value ?? "";
if (!queryString.Contains("Fields=", StringComparison.OrdinalIgnoreCase))
{ {
endpoint = $"{endpoint}{Request.QueryString.Value}"; // No Fields parameter, add MediaSources
queryString = string.IsNullOrEmpty(queryString)
? "?Fields=MediaSources"
: $"{queryString}&Fields=MediaSources";
}
else if (!queryString.Contains("MediaSources", StringComparison.OrdinalIgnoreCase))
{
// Fields parameter exists but doesn't include MediaSources, append it
queryString = $"{queryString},MediaSources";
} }
var browseResult = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers); endpoint = $"{endpoint}{queryString}";
var (browseResult, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
if (browseResult == null) if (browseResult == null)
{ {
_logger.LogInformation("Jellyfin returned null - likely 401 Unauthorized, returning 401 to client"); if (statusCode == 401)
return Unauthorized(new { error = "Authentication required" }); {
_logger.LogInformation("Jellyfin returned 401 Unauthorized, returning 401 to client");
return Unauthorized(new { error = "Authentication required" });
}
_logger.LogInformation("Jellyfin returned {StatusCode}, returning empty result", statusCode);
return new JsonResult(new { Items = Array.Empty<object>(), TotalRecordCount = 0, StartIndex = startIndex });
}
// Update Spotify playlist counts if enabled and response contains playlists
if (_spotifySettings.Enabled && browseResult.RootElement.TryGetProperty("Items", out var _))
{
_logger.LogInformation("Browse result has Items, checking for Spotify playlists to update counts");
browseResult = await UpdateSpotifyPlaylistCounts(browseResult);
} }
var result = JsonSerializer.Deserialize<object>(browseResult.RootElement.GetRawText()); var result = JsonSerializer.Deserialize<object>(browseResult.RootElement.GetRawText());
@@ -154,7 +202,7 @@ public class JellyfinController : ControllerBase
await Task.WhenAll(jellyfinTask, externalTask, playlistTask); await Task.WhenAll(jellyfinTask, externalTask, playlistTask);
var jellyfinResult = await jellyfinTask; var (jellyfinResult, _) = await jellyfinTask;
var externalResult = await externalTask; var externalResult = await externalTask;
var playlistResult = await playlistTask; var playlistResult = await playlistTask;
@@ -169,31 +217,28 @@ public class JellyfinController : ControllerBase
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult); var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult);
// Score and filter Jellyfin results by relevance // Score and filter Jellyfin results by relevance
var scoredLocalSongs = ScoreSearchResults(cleanQuery, localSongs, s => s.Title, s => s.Artist, isExternal: false); var scoredLocalSongs = ScoreSearchResults(cleanQuery, localSongs, s => s.Title, s => s.Artist, s => s.Album, isExternal: false);
var scoredLocalAlbums = ScoreSearchResults(cleanQuery, localAlbums, a => a.Title, a => a.Artist, isExternal: false); var scoredLocalAlbums = ScoreSearchResults(cleanQuery, localAlbums, a => a.Title, a => a.Artist, _ => null, isExternal: false);
var scoredLocalArtists = ScoreSearchResults(cleanQuery, localArtists, a => a.Name, _ => null, isExternal: false); var scoredLocalArtists = ScoreSearchResults(cleanQuery, localArtists, a => a.Name, _ => null, _ => null, isExternal: false);
// Score external results with a small boost // Score external results with a small boost
var scoredExternalSongs = ScoreSearchResults(cleanQuery, externalResult.Songs, s => s.Title, s => s.Artist, isExternal: true); var scoredExternalSongs = ScoreSearchResults(cleanQuery, externalResult.Songs, s => s.Title, s => s.Artist, s => s.Album, isExternal: true);
var scoredExternalAlbums = ScoreSearchResults(cleanQuery, externalResult.Albums, a => a.Title, a => a.Artist, isExternal: true); var scoredExternalAlbums = ScoreSearchResults(cleanQuery, externalResult.Albums, a => a.Title, a => a.Artist, _ => null, isExternal: true);
var scoredExternalArtists = ScoreSearchResults(cleanQuery, externalResult.Artists, a => a.Name, _ => null, isExternal: true); var scoredExternalArtists = ScoreSearchResults(cleanQuery, externalResult.Artists, a => a.Name, _ => null, _ => null, isExternal: true);
// Merge and sort by score (only include items with score >= 40) // Merge and sort by score (no filtering - just reorder by relevance)
var allSongs = scoredLocalSongs.Concat(scoredExternalSongs) var allSongs = scoredLocalSongs.Concat(scoredExternalSongs)
.Where(x => x.Score >= 40)
.OrderByDescending(x => x.Score) .OrderByDescending(x => x.Score)
.Select(x => x.Item) .Select(x => x.Item)
.ToList(); .ToList();
var allAlbums = scoredLocalAlbums.Concat(scoredExternalAlbums) var allAlbums = scoredLocalAlbums.Concat(scoredExternalAlbums)
.Where(x => x.Score >= 40)
.OrderByDescending(x => x.Score) .OrderByDescending(x => x.Score)
.Select(x => x.Item) .Select(x => x.Item)
.ToList(); .ToList();
// Dedupe artists by name, keeping highest scored version // Dedupe artists by name, keeping highest scored version
var artistScores = scoredLocalArtists.Concat(scoredExternalArtists) var artistScores = scoredLocalArtists.Concat(scoredExternalArtists)
.Where(x => x.Score >= 40)
.GroupBy(x => x.Item.Name, StringComparer.OrdinalIgnoreCase) .GroupBy(x => x.Item.Name, StringComparer.OrdinalIgnoreCase)
.Select(g => g.OrderByDescending(x => x.Score).First()) .Select(g => g.OrderByDescending(x => x.Score).First())
.OrderByDescending(x => x.Score) .OrderByDescending(x => x.Score)
@@ -210,7 +255,6 @@ public class JellyfinController : ControllerBase
{ {
var scoredPlaylists = playlistResult var scoredPlaylists = playlistResult
.Select(p => new { Playlist = p, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, p.Name) }) .Select(p => new { Playlist = p, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, p.Name) })
.Where(x => x.Score >= 40)
.OrderByDescending(x => x.Score) .OrderByDescending(x => x.Score)
.Select(x => _responseBuilder.ConvertPlaylistToJellyfinItem(x.Playlist)) .Select(x => _responseBuilder.ConvertPlaylistToJellyfinItem(x.Playlist))
.ToList(); .ToList();
@@ -305,7 +349,7 @@ public class JellyfinController : ControllerBase
} }
// Proxy to Jellyfin for local content // Proxy to Jellyfin for local content
var result = await _proxyService.GetItemsAsync( var (result, statusCode) = await _proxyService.GetItemsAsync(
parentId: parentId, parentId: parentId,
includeItemTypes: ParseItemTypes(includeItemTypes), includeItemTypes: ParseItemTypes(includeItemTypes),
sortBy: sortBy, sortBy: sortBy,
@@ -313,12 +357,7 @@ public class JellyfinController : ControllerBase
startIndex: startIndex, startIndex: startIndex,
clientHeaders: Request.Headers); clientHeaders: Request.Headers);
if (result == null) return HandleProxyResponse(result, statusCode);
{
return _responseBuilder.CreateError(404, "Parent not found");
}
return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText()));
} }
/// <summary> /// <summary>
@@ -350,7 +389,7 @@ public class JellyfinController : ControllerBase
await Task.WhenAll(jellyfinTask, externalTask); await Task.WhenAll(jellyfinTask, externalTask);
var jellyfinResult = await jellyfinTask; var (jellyfinResult, _) = await jellyfinTask;
var externalResult = await externalTask; var externalResult = await externalTask;
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult); var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult);
@@ -406,13 +445,9 @@ public class JellyfinController : ControllerBase
} }
// Proxy to Jellyfin // Proxy to Jellyfin
var result = await _proxyService.GetItemAsync(itemId, Request.Headers); var (result, statusCode) = await _proxyService.GetItemAsync(itemId, Request.Headers);
if (result == null)
{ return HandleProxyResponse(result, statusCode);
return _responseBuilder.CreateError(404, "Item not found");
}
return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText()));
} }
/// <summary> /// <summary>
@@ -524,7 +559,7 @@ public class JellyfinController : ControllerBase
await Task.WhenAll(jellyfinTask, externalTask); await Task.WhenAll(jellyfinTask, externalTask);
var jellyfinResult = await jellyfinTask; var (jellyfinResult, _) = await jellyfinTask;
var externalArtists = await externalTask; var externalArtists = await externalTask;
_logger.LogInformation("Artist search results: Jellyfin={JellyfinCount}, External={ExternalCount}", _logger.LogInformation("Artist search results: Jellyfin={JellyfinCount}, External={ExternalCount}",
@@ -574,19 +609,14 @@ public class JellyfinController : ControllerBase
} }
// No search term - just proxy to Jellyfin // No search term - just proxy to Jellyfin
var result = await _proxyService.GetArtistsAsync(searchTerm, limit, startIndex, Request.Headers); var (result, statusCode) = await _proxyService.GetArtistsAsync(searchTerm, limit, startIndex, Request.Headers);
if (result == null) return HandleProxyResponse(result, statusCode, new
{ {
return new JsonResult(new Dictionary<string, object> Items = Array.Empty<object>(),
{ TotalRecordCount = 0,
["Items"] = Array.Empty<object>(), StartIndex = startIndex
["TotalRecordCount"] = 0, });
["StartIndex"] = startIndex
});
}
return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText()));
} }
/// <summary> /// <summary>
@@ -617,10 +647,10 @@ public class JellyfinController : ControllerBase
} }
// Get local artist from Jellyfin // Get local artist from Jellyfin
var jellyfinArtist = await _proxyService.GetArtistAsync(artistIdOrName, Request.Headers); var (jellyfinArtist, statusCode) = await _proxyService.GetArtistAsync(artistIdOrName, Request.Headers);
if (jellyfinArtist == null) if (jellyfinArtist == null)
{ {
return _responseBuilder.CreateError(404, "Artist not found"); return HandleProxyResponse(null, statusCode);
} }
var artistData = _modelMapper.ParseArtist(jellyfinArtist.RootElement); var artistData = _modelMapper.ParseArtist(jellyfinArtist.RootElement);
@@ -628,7 +658,7 @@ public class JellyfinController : ControllerBase
var localArtistId = artistData.Id; var localArtistId = artistData.Id;
// Get local albums // Get local albums
var localAlbumsResult = await _proxyService.GetItemsAsync( var (localAlbumsResult, _) = await _proxyService.GetItemsAsync(
parentId: null, parentId: null,
includeItemTypes: new[] { "MusicAlbum" }, includeItemTypes: new[] { "MusicAlbum" },
sortBy: "SortName", sortBy: "SortName",
@@ -778,6 +808,23 @@ public class JellyfinController : ControllerBase
var contentType = response.Content.Headers.ContentType?.ToString() ?? "audio/mpeg"; var contentType = response.Content.Headers.ContentType?.ToString() ?? "audio/mpeg";
// Forward caching headers for client-side caching
if (response.Headers.ETag != null)
{
Response.Headers["ETag"] = response.Headers.ETag.ToString();
}
if (response.Content.Headers.LastModified.HasValue)
{
Response.Headers["Last-Modified"] = response.Content.Headers.LastModified.Value.ToString("R");
}
if (response.Headers.CacheControl != null)
{
Response.Headers["Cache-Control"] = response.Headers.CacheControl.ToString();
}
// Forward range headers for seeking
if (response.Content.Headers.ContentRange != null) if (response.Content.Headers.ContentRange != null)
{ {
Response.Headers["Content-Range"] = response.Content.Headers.ContentRange.ToString(); Response.Headers["Content-Range"] = response.Content.Headers.ContentRange.ToString();
@@ -845,12 +892,34 @@ public class JellyfinController : ControllerBase
} }
/// <summary> /// <summary>
/// Universal audio endpoint that redirects to the stream endpoint. /// Universal audio endpoint - handles transcoding, format negotiation, and adaptive streaming.
/// This is the primary endpoint used by Jellyfin Web and most clients.
/// </summary> /// </summary>
[HttpGet("Audio/{itemId}/universal")] [HttpGet("Audio/{itemId}/universal")]
public Task<IActionResult> UniversalAudio(string itemId) [HttpHead("Audio/{itemId}/universal")]
public async Task<IActionResult> UniversalAudio(string itemId)
{ {
return StreamAudio(itemId); if (string.IsNullOrWhiteSpace(itemId))
{
return BadRequest(new { error = "Missing item ID" });
}
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
if (!isExternal)
{
// For local content, proxy the universal endpoint with all query parameters
var fullPath = $"Audio/{itemId}/universal";
if (Request.QueryString.HasValue)
{
fullPath = $"{fullPath}{Request.QueryString.Value}";
}
return await ProxyJellyfinStream(fullPath, itemId);
}
// For external content, use simple streaming (no transcoding support yet)
return await StreamExternalContent(provider!, externalId!);
} }
#endregion #endregion
@@ -884,24 +953,19 @@ public class JellyfinController : ControllerBase
if (!isExternal) if (!isExternal)
{ {
// Redirect to Jellyfin directly for local content images // Proxy image from Jellyfin for local content
var queryString = new List<string>(); var (imageBytes, contentType) = await _proxyService.GetImageAsync(
if (maxWidth.HasValue) queryString.Add($"maxWidth={maxWidth.Value}"); itemId,
if (maxHeight.HasValue) queryString.Add($"maxHeight={maxHeight.Value}"); imageType,
maxWidth,
maxHeight);
var path = $"Items/{itemId}/Images/{imageType}"; if (imageBytes == null || contentType == null)
if (imageIndex > 0)
{ {
path = $"Items/{itemId}/Images/{imageType}/{imageIndex}"; return NotFound();
} }
if (queryString.Any()) return File(imageBytes, contentType);
{
path = $"{path}?{string.Join("&", queryString)}";
}
var jellyfinUrl = $"{_settings.Url?.TrimEnd('/')}/{path}";
return Redirect(jellyfinUrl);
} }
// Get external cover art URL // Get external cover art URL
@@ -944,6 +1008,7 @@ public class JellyfinController : ControllerBase
/// <summary> /// <summary>
/// Gets lyrics for an item. /// Gets lyrics for an item.
/// Priority: 1. Jellyfin embedded lyrics, 2. Spotify synced lyrics, 3. LRCLIB
/// </summary> /// </summary>
[HttpGet("Audio/{itemId}/Lyrics")] [HttpGet("Audio/{itemId}/Lyrics")]
[HttpGet("Items/{itemId}/Lyrics")] [HttpGet("Items/{itemId}/Lyrics")]
@@ -956,16 +1021,36 @@ public class JellyfinController : ControllerBase
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId); var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
// For local tracks, check if Jellyfin already has embedded lyrics
if (!isExternal)
{
_logger.LogInformation("Checking Jellyfin for embedded lyrics for local track: {ItemId}", itemId);
// 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);
if (jellyfinLyrics != null && statusCode == 200)
{
_logger.LogInformation("Found embedded lyrics in Jellyfin for track {ItemId}", itemId);
return new JsonResult(JsonSerializer.Deserialize<object>(jellyfinLyrics.RootElement.GetRawText()));
}
_logger.LogInformation("No embedded lyrics found in Jellyfin, trying Spotify/LRCLIB");
}
// Get song metadata for lyrics search
Song? song = null; Song? song = null;
string? spotifyTrackId = null;
if (isExternal) if (isExternal)
{ {
song = await _metadataService.GetSongAsync(provider!, externalId!); song = await _metadataService.GetSongAsync(provider!, externalId!);
// For Deezer tracks, we'll search Spotify by metadata
} }
else else
{ {
// For local songs, get metadata from Jellyfin // For local songs, get metadata from Jellyfin
var item = await _proxyService.GetItemAsync(itemId, Request.Headers); var (item, _) = await _proxyService.GetItemAsync(itemId, Request.Headers);
if (item != null && item.RootElement.TryGetProperty("Type", out var typeEl) && if (item != null && item.RootElement.TryGetProperty("Type", out var typeEl) &&
typeEl.GetString() == "Audio") typeEl.GetString() == "Audio")
{ {
@@ -976,6 +1061,15 @@ public class JellyfinController : ControllerBase
Album = item.RootElement.TryGetProperty("Album", out var album) ? album.GetString() ?? "" : "", Album = item.RootElement.TryGetProperty("Album", out var album) ? album.GetString() ?? "" : "",
Duration = item.RootElement.TryGetProperty("RunTimeTicks", out var ticks) ? (int)(ticks.GetInt64() / 10000000) : 0 Duration = item.RootElement.TryGetProperty("RunTimeTicks", out var ticks) ? (int)(ticks.GetInt64() / 10000000) : 0
}; };
// Check for Spotify ID in provider IDs
if (item.RootElement.TryGetProperty("ProviderIds", out var providerIds))
{
if (providerIds.TryGetProperty("Spotify", out var spotifyId))
{
spotifyTrackId = spotifyId.GetString();
}
}
} }
} }
@@ -984,18 +1078,54 @@ public class JellyfinController : ControllerBase
return NotFound(new { error = "Song not found" }); return NotFound(new { error = "Song not found" });
} }
// Try to get lyrics from LRCLIB LyricsInfo? lyrics = null;
var lyricsService = HttpContext.RequestServices.GetService<LrclibService>();
if (lyricsService == null) // Try Spotify lyrics first (better synced lyrics quality)
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled)
{ {
return NotFound(new { error = "Lyrics service not available" }); _logger.LogInformation("Trying Spotify lyrics for: {Artist} - {Title}", song.Artist, song.Title);
SpotifyLyricsResult? spotifyLyrics = null;
// If we have a Spotify track ID, use it directly
if (!string.IsNullOrEmpty(spotifyTrackId))
{
spotifyLyrics = await _spotifyLyricsService.GetLyricsByTrackIdAsync(spotifyTrackId);
}
else
{
// Search by metadata
spotifyLyrics = await _spotifyLyricsService.SearchAndGetLyricsAsync(
song.Title,
song.Artists.Count > 0 ? song.Artists[0] : song.Artist ?? "",
song.Album,
song.Duration.HasValue ? song.Duration.Value * 1000 : null);
}
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
{
_logger.LogInformation("Found Spotify lyrics for {Artist} - {Title} ({LineCount} lines, type: {SyncType})",
song.Artist, song.Title, spotifyLyrics.Lines.Count, spotifyLyrics.SyncType);
lyrics = _spotifyLyricsService.ToLyricsInfo(spotifyLyrics);
}
}
// Fall back to LRCLIB if no Spotify lyrics
if (lyrics == null)
{
_logger.LogInformation("Searching LRCLIB for lyrics: {Artists} - {Title}",
song.Artists.Count > 0 ? string.Join(", ", song.Artists) : song.Artist,
song.Title);
var lrclibService = HttpContext.RequestServices.GetService<LrclibService>();
if (lrclibService != null)
{
lyrics = await lrclibService.GetLyricsAsync(
song.Title,
song.Artists.Count > 0 ? song.Artists.ToArray() : new[] { song.Artist ?? "" },
song.Album ?? "",
song.Duration ?? 0);
}
} }
var lyrics = await lyricsService.GetLyricsAsync(
song.Title,
song.Artist ?? "",
song.Album ?? "",
song.Duration ?? 0);
if (lyrics == null) if (lyrics == null)
{ {
@@ -1006,15 +1136,21 @@ public class JellyfinController : ControllerBase
var lyricsText = lyrics.SyncedLyrics ?? lyrics.PlainLyrics ?? ""; var lyricsText = lyrics.SyncedLyrics ?? lyrics.PlainLyrics ?? "";
var isSynced = !string.IsNullOrEmpty(lyrics.SyncedLyrics); var isSynced = !string.IsNullOrEmpty(lyrics.SyncedLyrics);
_logger.LogInformation("Lyrics for {Artist} - {Track}: synced={HasSynced}, plainLength={PlainLen}, syncedLength={SyncLen}",
song.Artist, song.Title, isSynced, lyrics.PlainLyrics?.Length ?? 0, lyrics.SyncedLyrics?.Length ?? 0);
// Parse LRC format into individual lines for Jellyfin // Parse LRC format into individual lines for Jellyfin
var lyricLines = new List<object>(); var lyricLines = new List<Dictionary<string, object>>();
if (isSynced && !string.IsNullOrEmpty(lyrics.SyncedLyrics)) if (isSynced && !string.IsNullOrEmpty(lyrics.SyncedLyrics))
{ {
_logger.LogInformation("Parsing synced lyrics (LRC format)");
// Parse LRC format: [mm:ss.xx] text // Parse LRC format: [mm:ss.xx] text
// Skip ID tags like [ar:Artist], [ti:Title], etc.
var lines = lyrics.SyncedLyrics.Split('\n', StringSplitOptions.RemoveEmptyEntries); var lines = lyrics.SyncedLyrics.Split('\n', StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines) foreach (var line in lines)
{ {
// Match timestamp format [mm:ss.xx] or [mm:ss.xxx]
var match = System.Text.RegularExpressions.Regex.Match(line, @"^\[(\d+):(\d+)\.(\d+)\]\s*(.*)$"); var match = System.Text.RegularExpressions.Regex.Match(line, @"^\[(\d+):(\d+)\.(\d+)\]\s*(.*)$");
if (match.Success) if (match.Success)
{ {
@@ -1027,21 +1163,40 @@ public class JellyfinController : ControllerBase
var totalMilliseconds = (minutes * 60 + seconds) * 1000 + centiseconds * 10; var totalMilliseconds = (minutes * 60 + seconds) * 1000 + centiseconds * 10;
var ticks = totalMilliseconds * 10000L; var ticks = totalMilliseconds * 10000L;
lyricLines.Add(new // For synced lyrics, include Start timestamp
lyricLines.Add(new Dictionary<string, object>
{ {
Start = ticks, ["Text"] = text,
Text = text ["Start"] = ticks
}); });
} }
// Skip ID tags like [ar:Artist], [ti:Title], [length:2:23], etc.
} }
_logger.LogInformation("Parsed {Count} synced lyric lines (skipped ID tags)", lyricLines.Count);
}
else if (!string.IsNullOrEmpty(lyricsText))
{
_logger.LogInformation("Splitting plain lyrics into lines (no timestamps)");
// Plain lyrics - split by newlines and return each line separately
// IMPORTANT: Do NOT include "Start" field at all for unsynced lyrics
// Including it (even as null) causes clients to treat it as synced with timestamp 0:00
var lines = lyricsText.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines)
{
lyricLines.Add(new Dictionary<string, object>
{
["Text"] = line.Trim()
});
}
_logger.LogInformation("Split into {Count} plain lyric lines", lyricLines.Count);
} }
else else
{ {
// Plain lyrics - return as single block _logger.LogWarning("No lyrics text available");
lyricLines.Add(new // No lyrics at all
lyricLines.Add(new Dictionary<string, object>
{ {
Start = (long?)null, ["Text"] = ""
Text = lyricsText
}); });
} }
@@ -1058,6 +1213,17 @@ public class JellyfinController : ControllerBase
Lyrics = lyricLines Lyrics = lyricLines
}; };
_logger.LogInformation("Returning lyrics response: {LineCount} lines, synced={IsSynced}", lyricLines.Count, isSynced);
// Log a sample of the response for debugging
if (lyricLines.Count > 0)
{
var sampleLine = lyricLines[0];
var hasStart = sampleLine.ContainsKey("Start");
_logger.LogInformation("Sample line: Text='{Text}', HasStart={HasStart}",
sampleLine.GetValueOrDefault("Text"), hasStart);
}
return Ok(response); return Ok(response);
} }
@@ -1067,10 +1233,21 @@ public class JellyfinController : ControllerBase
/// <summary> /// <summary>
/// Marks an item as favorite. For playlists, triggers a full download. /// Marks an item as favorite. For playlists, triggers a full download.
/// Supports both /Users/{userId}/FavoriteItems/{itemId} and /UserFavoriteItems/{itemId}?userId=xxx
/// </summary> /// </summary>
[HttpPost("Users/{userId}/FavoriteItems/{itemId}")] [HttpPost("Users/{userId}/FavoriteItems/{itemId}")]
public async Task<IActionResult> MarkFavorite(string userId, string itemId) [HttpPost("UserFavoriteItems/{itemId}")]
public async Task<IActionResult> MarkFavorite(string itemId, string? userId = null)
{ {
// Get userId from query string if not in path
if (string.IsNullOrEmpty(userId))
{
userId = Request.Query["userId"].ToString();
}
_logger.LogInformation("MarkFavorite called: userId={UserId}, itemId={ItemId}, route={Route}",
userId, itemId, Request.Path);
// Check if this is an external playlist - trigger download // Check if this is an external playlist - trigger download
if (PlaylistIdHelper.IsExternalPlaylist(itemId)) if (PlaylistIdHelper.IsExternalPlaylist(itemId))
{ {
@@ -1094,97 +1271,102 @@ public class JellyfinController : ControllerBase
} }
}); });
return Ok(new { IsFavorite = true }); // Return a minimal UserItemDataDto response
return Ok(new
{
IsFavorite = true,
ItemId = itemId
});
} }
// Check if this is an external song/album // Check if this is an external song/album
var (isExternal, _, _) = _localLibraryService.ParseSongId(itemId); var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
if (isExternal) if (isExternal)
{ {
// External items don't exist in Jellyfin, so we can't favorite them there _logger.LogInformation("Favoriting external item {ItemId}, copying to kept folder", itemId);
// Just return success - the client will show it as favorited
_logger.LogDebug("Favoriting external item {ItemId} (not synced to Jellyfin)", itemId); // Copy the track to kept folder in background
return Ok(new { IsFavorite = true }); _ = Task.Run(async () =>
{
try
{
await CopyExternalTrackToKeptAsync(itemId, provider!, externalId!);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to copy external track {ItemId} to kept folder", itemId);
}
});
// Return a minimal UserItemDataDto response
return Ok(new
{
IsFavorite = true,
ItemId = itemId
});
} }
// For local Jellyfin items, proxy the request through // For local Jellyfin items, proxy the request through
var endpoint = $"Users/{userId}/FavoriteItems/{itemId}"; // Use the official Jellyfin endpoint format
var endpoint = $"UserFavoriteItems/{itemId}";
if (!string.IsNullOrEmpty(userId))
{
endpoint = $"{endpoint}?userId={userId}";
}
try _logger.LogInformation("Proxying favorite request to Jellyfin: {Endpoint}", endpoint);
{
using var request = new HttpRequestMessage(HttpMethod.Post, $"{_settings.Url?.TrimEnd('/')}/{endpoint}"); var (result, statusCode) = await _proxyService.PostJsonAsync(endpoint, "{}", Request.Headers);
// Forward client authentication return HandleProxyResponse(result, statusCode);
if (Request.Headers.TryGetValue("X-Emby-Authorization", out var embyAuth))
{
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", embyAuth.ToString());
}
else if (Request.Headers.TryGetValue("Authorization", out var auth))
{
request.Headers.TryAddWithoutValidation("Authorization", auth.ToString());
}
var response = await _proxyService.HttpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
return Ok(new { IsFavorite = true });
}
_logger.LogWarning("Failed to favorite item in Jellyfin: {StatusCode}", response.StatusCode);
return _responseBuilder.CreateError((int)response.StatusCode, "Failed to mark favorite");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error favoriting item {ItemId}", itemId);
return _responseBuilder.CreateError(500, "Failed to mark favorite");
}
} }
/// <summary> /// <summary>
/// Removes an item from favorites. /// Removes an item from favorites.
/// Supports both /Users/{userId}/FavoriteItems/{itemId} and /UserFavoriteItems/{itemId}?userId=xxx
/// </summary> /// </summary>
[HttpDelete("Users/{userId}/FavoriteItems/{itemId}")] [HttpDelete("Users/{userId}/FavoriteItems/{itemId}")]
public async Task<IActionResult> UnmarkFavorite(string userId, string itemId) [HttpDelete("UserFavoriteItems/{itemId}")]
public async Task<IActionResult> UnmarkFavorite(string itemId, string? userId = null)
{ {
// External items can't be unfavorited // Get userId from query string if not in path
if (string.IsNullOrEmpty(userId))
{
userId = Request.Query["userId"].ToString();
}
_logger.LogInformation("UnmarkFavorite called: userId={UserId}, itemId={ItemId}, route={Route}",
userId, itemId, Request.Path);
// External items can't be unfavorited (they're not really favorited in Jellyfin)
var (isExternal, _, _) = _localLibraryService.ParseSongId(itemId); var (isExternal, _, _) = _localLibraryService.ParseSongId(itemId);
if (isExternal || PlaylistIdHelper.IsExternalPlaylist(itemId)) if (isExternal || PlaylistIdHelper.IsExternalPlaylist(itemId))
{ {
return Ok(new { IsFavorite = false }); _logger.LogInformation("Unfavoriting external item {ItemId} - returning success", itemId);
return Ok(new
{
IsFavorite = false,
ItemId = itemId
});
} }
// Proxy to Jellyfin to unfavorite // Proxy to Jellyfin to unfavorite
var url = $"Users/{userId}/FavoriteItems/{itemId}"; // Use the official Jellyfin endpoint format
var endpoint = $"UserFavoriteItems/{itemId}";
if (!string.IsNullOrEmpty(userId))
{
endpoint = $"{endpoint}?userId={userId}";
}
try _logger.LogInformation("Proxying unfavorite request to Jellyfin: {Endpoint}", endpoint);
{
using var request = new HttpRequestMessage(HttpMethod.Delete, $"{_settings.Url?.TrimEnd('/')}/{url}"); var (result, statusCode) = await _proxyService.DeleteAsync(endpoint, Request.Headers);
// Forward client authentication return HandleProxyResponse(result, statusCode, new
if (Request.Headers.TryGetValue("X-Emby-Authorization", out var embyAuth)) {
{ IsFavorite = false,
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", embyAuth.ToString()); ItemId = itemId
} });
else if (Request.Headers.TryGetValue("Authorization", out var auth))
{
request.Headers.TryAddWithoutValidation("Authorization", auth.ToString());
}
var response = await _proxyService.HttpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
return Ok(new { IsFavorite = false });
}
return _responseBuilder.CreateError(500, "Failed to unfavorite item");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error unfavoriting item {ItemId}", itemId);
return _responseBuilder.CreateError(500, "Failed to unfavorite item");
}
} }
#endregion #endregion
@@ -1238,10 +1420,50 @@ public class JellyfinController : ControllerBase
{ {
try try
{ {
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(playlistId); _logger.LogInformation("=== GetPlaylistTracks called === PlaylistId: {PlaylistId}", playlistId);
var tracks = await _metadataService.GetPlaylistTracksAsync(provider, externalId);
// Check if this is an external playlist (Deezer/Qobuz) first
if (PlaylistIdHelper.IsExternalPlaylist(playlistId))
{
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(playlistId);
var tracks = await _metadataService.GetPlaylistTracksAsync(provider, externalId);
return _responseBuilder.CreateItemsResponse(tracks);
}
return _responseBuilder.CreateItemsResponse(tracks); // Check if this is a Spotify playlist (by ID)
_logger.LogInformation("Spotify Import Enabled: {Enabled}, Configured Playlists: {Count}",
_spotifySettings.Enabled, _spotifySettings.Playlists.Count);
if (_spotifySettings.Enabled && _spotifySettings.IsSpotifyPlaylist(playlistId))
{
// Get playlist info from Jellyfin to get the name for matching missing tracks
_logger.LogInformation("Fetching playlist info from Jellyfin for ID: {PlaylistId}", playlistId);
var (playlistInfo, _) = await _proxyService.GetJsonAsync($"Items/{playlistId}", null, Request.Headers);
if (playlistInfo != null && playlistInfo.RootElement.TryGetProperty("Name", out var nameElement))
{
var playlistName = nameElement.GetString() ?? "";
_logger.LogInformation("✓ MATCHED! Intercepting Spotify playlist: {PlaylistName} (ID: {PlaylistId})",
playlistName, playlistId);
return await GetSpotifyPlaylistTracksAsync(playlistName, playlistId);
}
else
{
_logger.LogWarning("Could not get playlist name from Jellyfin for ID: {PlaylistId}", playlistId);
}
}
// Regular Jellyfin playlist - proxy through
var endpoint = $"Playlists/{playlistId}/Items";
if (Request.QueryString.HasValue)
{
endpoint = $"{endpoint}{Request.QueryString.Value}";
}
_logger.LogInformation("Proxying to Jellyfin: {Endpoint}", endpoint);
var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
return HandleProxyResponse(result, statusCode);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -1309,15 +1531,51 @@ public class JellyfinController : ControllerBase
// DO NOT log request body or detailed headers - contains password // DO NOT log request body or detailed headers - contains password
// Forward to Jellyfin server with client headers // Forward to Jellyfin server with client headers
var result = await _proxyService.PostJsonAsync("Users/AuthenticateByName", body, Request.Headers); var (result, statusCode) = await _proxyService.PostJsonAsync("Users/AuthenticateByName", body, Request.Headers);
if (result == null) if (result == null)
{ {
_logger.LogWarning("Authentication failed - no response from Jellyfin"); _logger.LogWarning("Authentication failed - status {StatusCode}", statusCode);
return Unauthorized(new { error = "Authentication failed" }); if (statusCode == 401)
{
return Unauthorized(new { error = "Invalid username or password" });
}
return StatusCode(statusCode, new { error = "Authentication failed" });
} }
_logger.LogInformation("Authentication successful"); _logger.LogInformation("Authentication successful");
// Post session capabilities immediately after authentication
// This ensures Jellyfin creates a session that will show up in the dashboard
try
{
_logger.LogInformation("🔧 Posting session capabilities after authentication");
var capabilities = new
{
PlayableMediaTypes = new[] { "Audio" },
SupportedCommands = Array.Empty<string>(),
SupportsMediaControl = false,
SupportsPersistentIdentifier = true,
SupportsSync = false
};
var capabilitiesJson = JsonSerializer.Serialize(capabilities);
var (capResult, capStatus) = await _proxyService.PostJsonAsync("Sessions/Capabilities/Full", capabilitiesJson, Request.Headers);
if (capStatus == 204 || capStatus == 200)
{
_logger.LogInformation("✓ Session capabilities posted after auth ({StatusCode})", capStatus);
}
else
{
_logger.LogWarning("⚠ Session capabilities returned {StatusCode} after auth", capStatus);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to post session capabilities after auth, continuing anyway");
}
return Content(result.RootElement.GetRawText(), "application/json"); return Content(result.RootElement.GetRawText(), "application/json");
} }
catch (Exception ex) catch (Exception ex)
@@ -1421,18 +1679,9 @@ public class JellyfinController : ControllerBase
queryParams["userId"] = userId; queryParams["userId"] = userId;
} }
var result = await _proxyService.GetJsonAsync(endpoint, queryParams, Request.Headers); var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, queryParams, Request.Headers);
if (result == null) return HandleProxyResponse(result, statusCode, new { Items = Array.Empty<object>(), TotalRecordCount = 0 });
{
return _responseBuilder.CreateJsonResponse(new
{
Items = Array.Empty<object>(),
TotalRecordCount = 0
});
}
return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText()));
} }
/// <summary> /// <summary>
@@ -1535,22 +1784,455 @@ public class JellyfinController : ControllerBase
queryParams["userId"] = userId; queryParams["userId"] = userId;
} }
var result = await _proxyService.GetJsonAsync($"Songs/{itemId}/InstantMix", queryParams, Request.Headers); var (result, statusCode) = await _proxyService.GetJsonAsync($"Songs/{itemId}/InstantMix", queryParams, Request.Headers);
if (result == null) return HandleProxyResponse(result, statusCode, new { Items = Array.Empty<object>(), TotalRecordCount = 0 });
{
return _responseBuilder.CreateJsonResponse(new
{
Items = Array.Empty<object>(),
TotalRecordCount = 0
});
}
return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText()));
} }
#endregion #endregion
#region Playback Session Reporting
#region Session Management
/// <summary>
/// Reports session capabilities. Required for Jellyfin to track active sessions.
/// Handles both POST (with body) and GET (query params only) methods.
/// </summary>
[HttpPost("Sessions/Capabilities")]
[HttpPost("Sessions/Capabilities/Full")]
[HttpGet("Sessions/Capabilities")]
[HttpGet("Sessions/Capabilities/Full")]
public async Task<IActionResult> ReportCapabilities()
{
try
{
var method = Request.Method;
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : "";
_logger.LogInformation("📡 Session capabilities reported - Method: {Method}, Query: {Query}", method, queryString);
_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))
.Select(h => $"{h.Key}={h.Value}")));
// Forward to Jellyfin with query string and headers
var endpoint = $"Sessions/Capabilities{queryString}";
// Read body if present (POST requests)
string body = "{}";
if (method == "POST" && Request.ContentLength > 0)
{
Request.EnableBuffering();
using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true))
{
body = await reader.ReadToEndAsync();
}
Request.Body.Position = 0;
_logger.LogInformation("Capabilities body: {Body}", body);
}
var (result, statusCode) = await _proxyService.PostJsonAsync(endpoint, body, Request.Headers);
if (statusCode == 204 || statusCode == 200)
{
_logger.LogInformation("✓ Session capabilities forwarded to Jellyfin ({StatusCode})", statusCode);
}
else
{
_logger.LogWarning("⚠ Jellyfin returned {StatusCode} for capabilities", statusCode);
}
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to report session capabilities");
return StatusCode(500);
}
}
/// <summary>
/// Reports playback start. Handles both local and external tracks.
/// For local tracks, forwards to Jellyfin. For external tracks, logs locally.
/// Also ensures session is initialized if this is the first report from a device.
/// </summary>
[HttpPost("Sessions/Playing")]
public async Task<IActionResult> ReportPlaybackStart()
{
try
{
Request.EnableBuffering();
string body;
using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true))
{
body = await reader.ReadToEndAsync();
}
Request.Body.Position = 0;
_logger.LogInformation("📻 Playback START reported");
// Parse the body to check if it's an external track
var doc = JsonDocument.Parse(body);
string? itemId = null;
string? itemName = null;
if (doc.RootElement.TryGetProperty("ItemId", out var itemIdProp))
{
itemId = itemIdProp.GetString();
}
if (doc.RootElement.TryGetProperty("ItemName", out var itemNameProp))
{
itemName = itemNameProp.GetString();
}
if (!string.IsNullOrEmpty(itemId))
{
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
if (isExternal)
{
_logger.LogInformation("🎵 External track playback started: {Name} ({Provider}/{ExternalId})",
itemName ?? "Unknown", provider, externalId);
// For external tracks, we can't report to Jellyfin since it doesn't know about them
// Just return success so the client is happy
return NoContent();
}
_logger.LogInformation("🎵 Local track playback started: {Name} (ID: {ItemId})",
itemName ?? "Unknown", itemId);
}
// For local tracks, forward playback start to Jellyfin FIRST
_logger.LogInformation("Forwarding playback start to Jellyfin...");
// Fetch full item details to include in playback report
try
{
var (itemResult, itemStatus) = await _proxyService.GetJsonAsync($"Items/{itemId}", null, Request.Headers);
if (itemResult != null && itemStatus == 200)
{
var item = itemResult.RootElement;
_logger.LogInformation("📦 Fetched item details for playback report");
// Build playback start info - Jellyfin will fetch item details itself
var playbackStart = new
{
ItemId = itemId,
PositionTicks = doc.RootElement.TryGetProperty("PositionTicks", out var posProp) ? posProp.GetInt64() : 0,
// Let Jellyfin fetch the item details - don't include NowPlayingItem
};
var playbackJson = JsonSerializer.Serialize(playbackStart);
_logger.LogInformation("📤 Sending playback start: {Json}", playbackJson);
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", playbackJson, Request.Headers);
if (statusCode == 204 || statusCode == 200)
{
_logger.LogInformation("✓ Playback start forwarded to Jellyfin ({StatusCode})", statusCode);
// NOW ensure session exists with capabilities (after playback is reported)
var (deviceId, client, device, version) = ExtractDeviceInfo(Request.Headers);
if (!string.IsNullOrEmpty(deviceId))
{
var sessionCreated = await _sessionManager.EnsureSessionAsync(deviceId, client ?? "Unknown", device ?? "Unknown", version ?? "1.0", Request.Headers);
if (sessionCreated)
{
_logger.LogWarning("✓ SESSION: Session ensured for device {DeviceId} after playback start", deviceId);
}
else
{
_logger.LogWarning("⚠️ SESSION: Failed to ensure session for device {DeviceId}", deviceId);
}
}
else
{
_logger.LogWarning("⚠️ SESSION: No device ID found in headers for playback start");
}
}
else
{
_logger.LogWarning("⚠️ Playback start returned status {StatusCode}", statusCode);
}
}
else
{
_logger.LogWarning("⚠️ Could not fetch item details ({StatusCode}), sending basic playback start", itemStatus);
// Fall back to basic playback start
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", body, Request.Headers);
if (statusCode == 204 || statusCode == 200)
{
_logger.LogInformation("✓ Basic playback start forwarded to Jellyfin ({StatusCode})", statusCode);
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send playback start, trying basic");
// Fall back to basic playback start
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", body, Request.Headers);
if (statusCode == 204 || statusCode == 200)
{
_logger.LogInformation("✓ Basic playback start forwarded to Jellyfin ({StatusCode})", statusCode);
}
}
return NoContent();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to report playback start");
return NoContent(); // Return success anyway to not break playback
}
}
/// <summary>
/// Reports playback progress. Handles both local and external tracks.
/// </summary>
[HttpPost("Sessions/Playing/Progress")]
public async Task<IActionResult> ReportPlaybackProgress()
{
try
{
Request.EnableBuffering();
string body;
using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true))
{
body = await reader.ReadToEndAsync();
}
Request.Body.Position = 0;
// Update session activity
var (deviceId, _, _, _) = ExtractDeviceInfo(Request.Headers);
if (!string.IsNullOrEmpty(deviceId))
{
_sessionManager.UpdateActivity(deviceId);
}
// Parse the body to check if it's an external track
var doc = JsonDocument.Parse(body);
string? itemId = null;
long? positionTicks = null;
if (doc.RootElement.TryGetProperty("ItemId", out var itemIdProp))
{
itemId = itemIdProp.GetString();
}
if (doc.RootElement.TryGetProperty("PositionTicks", out var posProp))
{
positionTicks = posProp.GetInt64();
}
if (!string.IsNullOrEmpty(itemId))
{
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
if (isExternal)
{
// For external tracks, just acknowledge (no logging to avoid spam)
return NoContent();
}
// Log progress for local tracks (only every ~10 seconds to avoid spam)
if (positionTicks.HasValue)
{
var position = TimeSpan.FromTicks(positionTicks.Value);
// Only log at 10-second intervals
if (position.Seconds % 10 == 0 && position.Milliseconds < 500)
{
_logger.LogInformation("▶️ Progress: {Position:mm\\:ss} for item {ItemId}", position, itemId);
}
}
}
// For local tracks, forward to Jellyfin
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing/Progress", body, Request.Headers);
if (statusCode != 204 && statusCode != 200)
{
_logger.LogWarning("⚠️ Progress report returned {StatusCode} for item {ItemId}", statusCode, itemId);
}
return NoContent();
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to report playback progress");
return NoContent();
}
}
/// <summary>
/// Reports playback stopped. Handles both local and external tracks.
/// </summary>
[HttpPost("Sessions/Playing/Stopped")]
public async Task<IActionResult> ReportPlaybackStopped()
{
try
{
Request.EnableBuffering();
string body;
using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true))
{
body = await reader.ReadToEndAsync();
}
Request.Body.Position = 0;
_logger.LogInformation("⏹️ Playback STOPPED reported");
// Parse the body to check if it's an external track
var doc = JsonDocument.Parse(body);
string? itemId = null;
string? itemName = null;
long? positionTicks = null;
if (doc.RootElement.TryGetProperty("ItemId", out var itemIdProp))
{
itemId = itemIdProp.GetString();
}
if (doc.RootElement.TryGetProperty("ItemName", out var itemNameProp))
{
itemName = itemNameProp.GetString();
}
if (doc.RootElement.TryGetProperty("PositionTicks", out var posProp))
{
positionTicks = posProp.GetInt64();
}
if (!string.IsNullOrEmpty(itemId))
{
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
if (isExternal)
{
var position = positionTicks.HasValue
? TimeSpan.FromTicks(positionTicks.Value).ToString(@"mm\:ss")
: "unknown";
_logger.LogInformation("🎵 External track playback stopped: {Name} at {Position} ({Provider}/{ExternalId})",
itemName ?? "Unknown", position, provider, externalId);
return NoContent();
}
_logger.LogInformation("🎵 Local track playback stopped: {Name} (ID: {ItemId})",
itemName ?? "Unknown", itemId);
}
// For local tracks, forward to Jellyfin
_logger.LogInformation("Forwarding playback stop to Jellyfin...");
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", body, Request.Headers);
if (statusCode == 204 || statusCode == 200)
{
_logger.LogInformation("✓ Playback stop forwarded to Jellyfin ({StatusCode})", statusCode);
}
else
{
_logger.LogWarning("Playback stop forward failed with status {StatusCode}", statusCode);
}
return NoContent();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to report playback stopped");
return NoContent();
}
}
/// <summary>
/// Pings a playback session to keep it alive.
/// </summary>
[HttpPost("Sessions/Playing/Ping")]
public async Task<IActionResult> PingPlaybackSession([FromQuery] string playSessionId)
{
try
{
_logger.LogDebug("Playback session ping: {SessionId}", playSessionId);
// Forward to Jellyfin
var endpoint = $"Sessions/Playing/Ping?playSessionId={Uri.EscapeDataString(playSessionId)}";
var (result, statusCode) = await _proxyService.PostJsonAsync(endpoint, "{}", Request.Headers);
return NoContent();
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to ping playback session");
return NoContent();
}
}
/// <summary>
/// Catch-all for any other session-related requests.
/// <summary>
/// Catch-all proxy for any other session-related endpoints we haven't explicitly implemented.
/// This ensures all session management calls get proxied to Jellyfin.
/// Examples: GET /Sessions, POST /Sessions/Logout, etc.
/// </summary>
[HttpGet("Sessions")]
[HttpPost("Sessions")]
[HttpGet("Sessions/{**path}")]
[HttpPost("Sessions/{**path}")]
[HttpPut("Sessions/{**path}")]
[HttpDelete("Sessions/{**path}")]
public async Task<IActionResult> ProxySessionRequest(string? path = null)
{
try
{
var method = Request.Method;
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : "";
var endpoint = string.IsNullOrEmpty(path) ? $"Sessions{queryString}" : $"Sessions/{path}{queryString}";
_logger.LogInformation("🔄 Proxying session request: {Method} {Endpoint}", method, endpoint);
_logger.LogDebug("Session proxy headers: {Headers}",
string.Join(", ", Request.Headers.Where(h => h.Key.Contains("Auth", StringComparison.OrdinalIgnoreCase))
.Select(h => $"{h.Key}={h.Value}")));
// Read body if present
string body = "{}";
if ((method == "POST" || method == "PUT") && Request.ContentLength > 0)
{
Request.EnableBuffering();
using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true))
{
body = await reader.ReadToEndAsync();
}
Request.Body.Position = 0;
_logger.LogDebug("Session proxy body: {Body}", body);
}
// Forward to Jellyfin
var (result, statusCode) = method switch
{
"GET" => await _proxyService.GetJsonAsync(endpoint, null, Request.Headers),
"POST" => await _proxyService.PostJsonAsync(endpoint, body, Request.Headers),
"PUT" => await _proxyService.PostJsonAsync(endpoint, body, Request.Headers), // Use POST for PUT
"DELETE" => await _proxyService.PostJsonAsync(endpoint, body, Request.Headers), // Use POST for DELETE
_ => (null, 405)
};
if (result != null)
{
_logger.LogInformation("✓ Session request proxied successfully ({StatusCode})", statusCode);
return new JsonResult(result.RootElement.Clone());
}
_logger.LogInformation("✓ Session request proxied ({StatusCode}, no body)", statusCode);
return StatusCode(statusCode);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to proxy session request: {Path}", path);
return StatusCode(500);
}
}
#endregion // Session Management
#endregion // Playback Session Reporting
#region System & Proxy #region System & Proxy
/// <summary> /// <summary>
@@ -1585,14 +2267,100 @@ public class JellyfinController : ControllerBase
/// <summary> /// <summary>
/// Catch-all endpoint that proxies unhandled requests to Jellyfin transparently. /// Catch-all endpoint that proxies unhandled requests to Jellyfin transparently.
/// This route has the lowest priority and should only match requests that don't have SearchTerm. /// This route has the lowest priority and should only match requests that don't have SearchTerm.
/// Blocks dangerous admin endpoints for security.
/// </summary> /// </summary>
[HttpGet("{**path}", Order = 100)] [HttpGet("{**path}", Order = 100)]
[HttpPost("{**path}", Order = 100)] [HttpPost("{**path}", Order = 100)]
public async Task<IActionResult> ProxyRequest(string path) public async Task<IActionResult> ProxyRequest(string path)
{ {
// Handle non-JSON responses (robots.txt, etc.) // Log session-related requests prominently to debug missing capabilities call
if (path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase) || if (path.Contains("session", StringComparison.OrdinalIgnoreCase) ||
path.EndsWith(".xml", StringComparison.OrdinalIgnoreCase)) path.Contains("capabilit", StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("🔍 SESSION/CAPABILITY REQUEST: {Method} /{Path}{Query}", Request.Method, path, Request.QueryString);
}
else
{
_logger.LogDebug("ProxyRequest: {Method} /{Path}", Request.Method, path);
}
// Log endpoint usage to file for analysis
await LogEndpointUsageAsync(path, Request.Method);
// Block dangerous admin endpoints
var blockedPrefixes = new[]
{
"system/restart", // Server restart
"system/shutdown", // Server shutdown
"system/configuration", // System configuration changes
"system/logs", // Server logs access
"system/activitylog", // Activity log access
"plugins/", // Plugin management (install/uninstall/configure)
"scheduledtasks/", // Scheduled task management
"startup/", // Initial server setup
"users/new", // User creation
"library/refresh", // Library scan (expensive operation)
"library/virtualfolders", // Library folder management
"branding/", // Branding configuration
"displaypreferences/", // Display preferences (if not user-specific)
"notifications/admin" // Admin notifications
};
// Check if path matches any blocked prefix
if (blockedPrefixes.Any(prefix =>
path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)))
{
_logger.LogWarning("BLOCKED: Access denied to admin endpoint: {Path} from {IP}",
path,
HttpContext.Connection.RemoteIpAddress);
return StatusCode(403, new
{
error = "Access to administrative endpoints is not allowed through this proxy",
path = path
});
}
// Intercept Spotify playlist requests by ID
if (_spotifySettings.Enabled &&
path.StartsWith("playlists/", StringComparison.OrdinalIgnoreCase) &&
path.Contains("/items", StringComparison.OrdinalIgnoreCase))
{
// Extract playlist ID from path: playlists/{id}/items
var parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 2 && parts[0].Equals("playlists", StringComparison.OrdinalIgnoreCase))
{
var playlistId = parts[1];
_logger.LogInformation("=== PLAYLIST REQUEST ===");
_logger.LogInformation("Playlist ID: {PlaylistId}", playlistId);
_logger.LogInformation("Spotify Enabled: {Enabled}", _spotifySettings.Enabled);
_logger.LogInformation("Configured Playlists: {Playlists}", string.Join(", ", _spotifySettings.Playlists.Select(p => $"{p.Name}:{p.Id}")));
_logger.LogInformation("Is configured: {IsConfigured}", _spotifySettings.IsSpotifyPlaylist(playlistId));
// Check if this playlist ID is configured for Spotify injection
if (_spotifySettings.IsSpotifyPlaylist(playlistId))
{
_logger.LogInformation("========================================");
_logger.LogInformation("=== INTERCEPTING SPOTIFY PLAYLIST ===");
_logger.LogInformation("Playlist ID: {PlaylistId}", playlistId);
_logger.LogInformation("========================================");
return await GetPlaylistTracks(playlistId);
}
}
}
// Handle non-JSON responses (images, robots.txt, etc.)
if (path.Contains("/Images/", StringComparison.OrdinalIgnoreCase) ||
path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase) ||
path.EndsWith(".xml", StringComparison.OrdinalIgnoreCase) ||
path.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) ||
path.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase) ||
path.EndsWith(".png", StringComparison.OrdinalIgnoreCase) ||
path.EndsWith(".gif", StringComparison.OrdinalIgnoreCase) ||
path.EndsWith(".webp", StringComparison.OrdinalIgnoreCase) ||
path.EndsWith(".m3u8", StringComparison.OrdinalIgnoreCase) ||
path.EndsWith(".m3u", StringComparison.OrdinalIgnoreCase) ||
path.EndsWith(".ts", StringComparison.OrdinalIgnoreCase))
{ {
var fullPath = path; var fullPath = path;
if (Request.QueryString.HasValue) if (Request.QueryString.HasValue)
@@ -1604,14 +2372,42 @@ public class JellyfinController : ControllerBase
try try
{ {
var response = await _proxyService.HttpClient.GetAsync(url); // Forward authentication headers for image requests
var content = await response.Content.ReadAsStringAsync(); using var request = new HttpRequestMessage(HttpMethod.Get, url);
var contentType = response.Content.Headers.ContentType?.ToString() ?? "text/plain";
return Content(content, contentType); // Forward auth headers from client
if (Request.Headers.TryGetValue("X-Emby-Authorization", out var embyAuth))
{
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", embyAuth.ToString());
}
else if (Request.Headers.TryGetValue("Authorization", out var auth))
{
var authValue = auth.ToString();
if (authValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase) ||
authValue.Contains("Token=", StringComparison.OrdinalIgnoreCase))
{
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", authValue);
}
else
{
request.Headers.TryAddWithoutValidation("Authorization", authValue);
}
}
var response = await _proxyService.HttpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
return StatusCode((int)response.StatusCode);
}
var contentBytes = await response.Content.ReadAsByteArrayAsync();
var contentType = response.Content.Headers.ContentType?.ToString() ?? "application/octet-stream";
return File(contentBytes, contentType);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "Failed to proxy non-JSON request for {Path}", path); _logger.LogWarning(ex, "Failed to proxy binary request for {Path}", path);
return NotFound(); return NotFound();
} }
} }
@@ -1659,6 +2455,7 @@ public class JellyfinController : ControllerBase
} }
JsonDocument? result; JsonDocument? result;
int statusCode;
if (HttpContext.Request.Method == HttpMethod.Post.Method) if (HttpContext.Request.Method == HttpMethod.Post.Method)
{ {
@@ -1700,22 +2497,59 @@ public class JellyfinController : ControllerBase
} }
} }
result = await _proxyService.PostJsonAsync(fullPath, body, Request.Headers); (result, statusCode) = await _proxyService.PostJsonAsync(fullPath, body, Request.Headers);
} }
else else
{ {
// Forward GET requests transparently with authentication headers and query string // Forward GET requests transparently with authentication headers and query string
result = await _proxyService.GetJsonAsync(fullPath, null, Request.Headers); (result, statusCode) = await _proxyService.GetJsonAsync(fullPath, null, Request.Headers);
} }
// Handle different status codes
if (result == null) if (result == null)
{ {
// Return 204 No Content for successful requests with no body // No body - return the status code from Jellyfin
// (e.g., /sessions/playing, /sessions/playing/progress) if (statusCode == 204)
{
return NoContent();
}
else if (statusCode == 401)
{
return Unauthorized();
}
else if (statusCode == 403)
{
return Forbid();
}
else if (statusCode == 404)
{
return NotFound();
}
else if (statusCode >= 400 && statusCode < 500)
{
return StatusCode(statusCode);
}
else if (statusCode >= 500)
{
return StatusCode(statusCode);
}
// Default to 204 for 2xx responses with no body
return NoContent(); return NoContent();
} }
return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText())); // Modify response if it contains Spotify playlists to update ChildCount
// Only check for Items if the response is an object (not a string or array)
if (_spotifySettings.Enabled &&
result.RootElement.ValueKind == JsonValueKind.Object &&
result.RootElement.TryGetProperty("Items", out var items))
{
_logger.LogInformation("Response has Items property, checking for Spotify playlists to update counts");
result = await UpdateSpotifyPlaylistCounts(result);
}
// Return the raw JSON element directly to avoid deserialization issues with simple types
return new JsonResult(result.RootElement.Clone());
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -1728,6 +2562,219 @@ public class JellyfinController : ControllerBase
#region Helpers #region Helpers
/// <summary>
/// Helper to handle proxy responses with proper status code handling.
/// </summary>
private IActionResult HandleProxyResponse(JsonDocument? result, int statusCode, object? fallbackValue = null)
{
if (result != null)
{
return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText()));
}
// Handle error status codes
if (statusCode == 401)
{
return Unauthorized();
}
else if (statusCode == 403)
{
return Forbid();
}
else if (statusCode == 404)
{
return NotFound();
}
else if (statusCode >= 400)
{
return StatusCode(statusCode);
}
// Success with no body - return fallback or empty
if (fallbackValue != null)
{
return new JsonResult(fallbackValue);
}
return NoContent();
}
/// <summary>
/// Updates ChildCount for Spotify playlists in the response to show total tracks (local + matched).
/// </summary>
private async Task<JsonDocument> UpdateSpotifyPlaylistCounts(JsonDocument response)
{
try
{
if (!response.RootElement.TryGetProperty("Items", out var items))
{
return response;
}
var itemsArray = items.EnumerateArray().ToList();
var modified = false;
var updatedItems = new List<Dictionary<string, object>>();
_logger.LogInformation("Checking {Count} items for Spotify playlists", itemsArray.Count);
foreach (var item in itemsArray)
{
var itemDict = JsonSerializer.Deserialize<Dictionary<string, object>>(item.GetRawText());
if (itemDict == null)
{
continue;
}
// Check if this is a Spotify playlist
if (item.TryGetProperty("Id", out var idProp))
{
var playlistId = idProp.GetString();
_logger.LogDebug("Checking item with ID: {Id}", playlistId);
if (!string.IsNullOrEmpty(playlistId) && _spotifySettings.IsSpotifyPlaylist(playlistId))
{
_logger.LogInformation("Found Spotify playlist: {Id}", playlistId);
// This is a Spotify playlist - get the actual track count
var playlistConfig = _spotifySettings.GetPlaylistById(playlistId);
if (playlistConfig != null)
{
var playlistName = playlistConfig.Name;
// Get matched external tracks (tracks that were successfully downloaded/matched)
var matchedTracksKey = $"spotify:matched:ordered:{playlistName}";
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
_logger.LogInformation("Cache lookup for {Key}: {Count} matched tracks",
matchedTracksKey, matchedTracks?.Count ?? 0);
// Fallback to legacy cache format
if (matchedTracks == null || matchedTracks.Count == 0)
{
var legacyKey = $"spotify:matched:{playlistName}";
var legacySongs = await _cache.GetAsync<List<Song>>(legacyKey);
if (legacySongs != null && legacySongs.Count > 0)
{
matchedTracks = legacySongs.Select((s, i) => new MatchedTrack
{
Position = i,
MatchedSong = s
}).ToList();
_logger.LogInformation("Loaded {Count} tracks from legacy cache", matchedTracks.Count);
}
}
// Get local tracks count from Jellyfin
var localTracksCount = 0;
try
{
var (localTracksResponse, _) = await _proxyService.GetJsonAsync(
$"Playlists/{playlistId}/Items",
null,
Request.Headers);
if (localTracksResponse != null &&
localTracksResponse.RootElement.TryGetProperty("Items", out var localItems))
{
localTracksCount = localItems.GetArrayLength();
_logger.LogInformation("Found {Count} total items in Jellyfin playlist {Name}",
localTracksCount, playlistName);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get local tracks count for {Name}", playlistName);
}
// Count external matched tracks (not local)
var externalMatchedCount = 0;
if (matchedTracks != null)
{
externalMatchedCount = matchedTracks.Count(t => t.MatchedSong != null && !t.MatchedSong.IsLocal);
}
// Total available tracks = what's actually in Jellyfin (local + external matched)
// This is what clients should see as the track count
var totalAvailableCount = localTracksCount;
if (totalAvailableCount > 0)
{
// Update ChildCount to show actual available tracks
itemDict["ChildCount"] = totalAvailableCount;
modified = true;
_logger.LogInformation("✓ Updated ChildCount for Spotify playlist {Name} to {Total} (actual tracks in Jellyfin)",
playlistName, totalAvailableCount);
}
else
{
_logger.LogWarning("No tracks found in Jellyfin for {Name}", playlistName);
}
}
}
}
updatedItems.Add(itemDict);
}
if (!modified)
{
_logger.LogInformation("No Spotify playlists found to update");
return response;
}
_logger.LogInformation("Modified {Count} Spotify playlists, rebuilding response",
updatedItems.Count(i => i.ContainsKey("ChildCount")));
// Rebuild the response with updated items
var responseDict = JsonSerializer.Deserialize<Dictionary<string, object>>(response.RootElement.GetRawText());
if (responseDict != null)
{
responseDict["Items"] = updatedItems;
var updatedJson = JsonSerializer.Serialize(responseDict);
return JsonDocument.Parse(updatedJson);
}
return response;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to update Spotify playlist counts");
return response;
}
}
/// <summary>
/// Logs endpoint usage to a file for analysis.
/// Creates a CSV file with timestamp, method, path, and query string.
/// </summary>
private async Task LogEndpointUsageAsync(string path, string method)
{
try
{
var logDir = "/app/cache/endpoint-usage";
Directory.CreateDirectory(logDir);
var logFile = Path.Combine(logDir, "endpoints.csv");
var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss");
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : "";
// Sanitize path and query for CSV (remove commas, quotes, newlines)
var sanitizedPath = path.Replace(",", ";").Replace("\"", "'").Replace("\n", " ").Replace("\r", " ");
var sanitizedQuery = queryString.Replace(",", ";").Replace("\"", "'").Replace("\n", " ").Replace("\r", " ");
var logLine = $"{timestamp},{method},{sanitizedPath},{sanitizedQuery}\n";
// Append to file (thread-safe)
await System.IO.File.AppendAllTextAsync(logFile, logLine);
}
catch (Exception ex)
{
// Don't let logging failures break the request
_logger.LogDebug(ex, "Failed to log endpoint usage");
}
}
private static string[]? ParseItemTypes(string? includeItemTypes) private static string[]? ParseItemTypes(string? includeItemTypes)
{ {
if (string.IsNullOrWhiteSpace(includeItemTypes)) if (string.IsNullOrWhiteSpace(includeItemTypes))
@@ -1761,28 +2808,52 @@ public class JellyfinController : ControllerBase
private static List<(T Item, int Score)> ScoreSearchResults<T>( private static List<(T Item, int Score)> ScoreSearchResults<T>(
string query, string query,
List<T> items, List<T> items,
Func<T, string> primaryField, Func<T, string> titleField,
Func<T, string?> secondaryField, Func<T, string?> artistField,
Func<T, string?> albumField,
bool isExternal = false) bool isExternal = false)
{ {
return items.Select(item => return items.Select(item =>
{ {
var primary = primaryField(item) ?? ""; var title = titleField(item) ?? "";
var secondary = secondaryField(item) ?? ""; var artist = artistField(item) ?? "";
var album = albumField(item) ?? "";
// Score against primary field (title/name) // Token-based fuzzy matching: split query and fields into words
var primaryScore = FuzzyMatcher.CalculateSimilarity(query, primary); var queryTokens = query.ToLower()
.Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries)
.ToList();
// Score against secondary field (artist) if provided var fieldText = $"{title} {artist} {album}".ToLower();
var secondaryScore = string.IsNullOrEmpty(secondary) var fieldTokens = fieldText
? 0 .Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries)
: FuzzyMatcher.CalculateSimilarity(query, secondary); .ToList();
// Use the better of the two scores if (queryTokens.Count == 0) return (item, 0);
var baseScore = Math.Max(primaryScore, secondaryScore);
// Count how many query tokens match field tokens (with fuzzy tolerance)
var matchedTokens = 0;
foreach (var queryToken in queryTokens)
{
// Check if any field token matches this query token
var hasMatch = fieldTokens.Any(fieldToken =>
{
// Exact match or substring match
if (fieldToken.Contains(queryToken) || queryToken.Contains(fieldToken))
return true;
// Fuzzy match with Levenshtein distance
var similarity = FuzzyMatcher.CalculateSimilarity(queryToken, fieldToken);
return similarity >= 70; // 70% similarity threshold for individual words
});
if (hasMatch) matchedTokens++;
}
// Score = percentage of query tokens that matched
var baseScore = (matchedTokens * 100) / queryTokens.Count;
// Give external results a small boost (+5 points) to prioritize the larger catalog // Give external results a small boost (+5 points) to prioritize the larger catalog
// This means external results will rank slightly higher when scores are close
var finalScore = isExternal ? Math.Min(100, baseScore + 5) : baseScore; var finalScore = isExternal ? Math.Min(100, baseScore + 5) : baseScore;
return (item, finalScore); return (item, finalScore);
@@ -1790,5 +2861,1079 @@ public class JellyfinController : ControllerBase
} }
#endregion #endregion
#region Spotify Playlist Injection
/// <summary>
/// Gets tracks for a Spotify playlist by matching missing tracks against external providers
/// and merging with existing local tracks from Jellyfin.
///
/// Supports two modes:
/// 1. Direct Spotify API (new): Uses SpotifyPlaylistFetcher for ordered tracks with ISRC matching
/// 2. Jellyfin Plugin (legacy): Uses MissingTrack data from Jellyfin Spotify Import plugin
/// </summary>
private async Task<IActionResult> GetSpotifyPlaylistTracksAsync(string spotifyPlaylistName, string playlistId)
{
try
{
// Try ordered cache first (from direct Spotify API mode)
if (_spotifyApiSettings.Enabled && _spotifyPlaylistFetcher != null)
{
var orderedResult = await GetSpotifyPlaylistTracksOrderedAsync(spotifyPlaylistName, playlistId);
if (orderedResult != null) return orderedResult;
}
// Fall back to legacy unordered mode
return await GetSpotifyPlaylistTracksLegacyAsync(spotifyPlaylistName, playlistId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting Spotify playlist tracks {PlaylistName}", spotifyPlaylistName);
return _responseBuilder.CreateError(500, "Failed to get Spotify playlist tracks");
}
}
/// <summary>
/// New mode: Gets playlist tracks with correct ordering using direct Spotify API data.
/// </summary>
private async Task<IActionResult?> GetSpotifyPlaylistTracksOrderedAsync(string spotifyPlaylistName, string playlistId)
{
// Check for ordered matched tracks from SpotifyTrackMatchingService
var orderedCacheKey = $"spotify:matched:ordered:{spotifyPlaylistName}";
var orderedTracks = await _cache.GetAsync<List<MatchedTrack>>(orderedCacheKey);
if (orderedTracks == null || orderedTracks.Count == 0)
{
_logger.LogDebug("No ordered matched tracks in cache for {Playlist}, checking if we can fetch",
spotifyPlaylistName);
return null; // Fall back to legacy mode
}
_logger.LogDebug("Using {Count} ordered matched tracks for {Playlist}",
orderedTracks.Count, spotifyPlaylistName);
// Get existing Jellyfin playlist items (tracks the Spotify Import plugin already found)
// CRITICAL: Must include UserId parameter or Jellyfin returns empty results
var userId = _settings.UserId;
if (string.IsNullOrEmpty(userId))
{
_logger.LogError("❌ JELLYFIN_USER_ID is NOT configured! Cannot fetch playlist tracks. Set it in .env or admin UI.");
return null; // Fall back to legacy mode
}
var playlistItemsUrl = $"Playlists/{playlistId}/Items?UserId={userId}";
_logger.LogInformation("🔍 Fetching existing tracks from Jellyfin playlist {PlaylistId} with UserId {UserId}",
playlistId, userId);
var (existingTracksResponse, statusCode) = await _proxyService.GetJsonAsync(
playlistItemsUrl,
null,
Request.Headers);
if (statusCode != 200)
{
_logger.LogError("❌ Failed to fetch Jellyfin playlist items: HTTP {StatusCode}. Check JELLYFIN_USER_ID is correct.", statusCode);
return null;
}
var existingTracks = new List<Song>();
if (existingTracksResponse != null &&
existingTracksResponse.RootElement.TryGetProperty("Items", out var items))
{
foreach (var item in items.EnumerateArray())
{
var song = _modelMapper.ParseSong(item);
existingTracks.Add(song);
_logger.LogDebug(" 📌 Local track: {Title} - {Artist}", song.Title, song.Artist);
}
_logger.LogInformation("✅ Found {Count} existing LOCAL tracks in Jellyfin playlist - will match by name only",
existingTracks.Count);
}
else
{
_logger.LogWarning("⚠️ No existing tracks found in Jellyfin playlist {PlaylistId} - playlist may be empty", playlistId);
// Don't return null - continue with external tracks only
}
// Get the full playlist from Spotify to know the correct order
var spotifyTracks = await _spotifyPlaylistFetcher!.GetPlaylistTracksAsync(spotifyPlaylistName);
if (spotifyTracks.Count == 0)
{
_logger.LogWarning("Could not get Spotify playlist tracks for {Playlist}", spotifyPlaylistName);
return null; // Fall back to legacy
}
// Build the final track list in correct Spotify order
// STRATEGY: Match Jellyfin tracks to Spotify positions, then fill gaps with external
var finalTracks = new List<Song>();
var localUsedCount = 0;
var externalUsedCount = 0;
var skippedCount = 0;
_logger.LogInformation("🔍 Matching {JellyfinCount} Jellyfin tracks to {SpotifyCount} Spotify positions...",
existingTracks.Count, spotifyTracks.Count);
// Step 1: Check for manual mappings first
var manualMappings = new Dictionary<string, string>(); // Spotify ID -> Jellyfin ID
foreach (var spotifyTrack in spotifyTracks)
{
var mappingKey = $"spotify:manual-map:{spotifyPlaylistName}:{spotifyTrack.SpotifyId}";
var jellyfinId = await _cache.GetAsync<string>(mappingKey);
if (!string.IsNullOrEmpty(jellyfinId))
{
manualMappings[spotifyTrack.SpotifyId] = jellyfinId;
_logger.LogInformation("📌 Manual mapping found: Spotify {SpotifyId} → Jellyfin {JellyfinId}",
spotifyTrack.SpotifyId, jellyfinId);
}
}
// Step 2: For each Spotify position, find the best matching Jellyfin track
var spotifyToJellyfinMap = new Dictionary<int, Song>(); // Spotify position -> Jellyfin track
var usedJellyfinTracks = new HashSet<string>(); // Track which Jellyfin tracks we've used
foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position))
{
if (existingTracks.Count == 0) break;
// Check for manual mapping first
if (manualMappings.TryGetValue(spotifyTrack.SpotifyId, out var mappedJellyfinId))
{
var mappedTrack = existingTracks.FirstOrDefault(t => t.Id == mappedJellyfinId);
if (mappedTrack != null && !usedJellyfinTracks.Contains(mappedTrack.Id))
{
spotifyToJellyfinMap[spotifyTrack.Position] = mappedTrack;
usedJellyfinTracks.Add(mappedTrack.Id);
_logger.LogInformation("✅ Position #{Pos}: '{SpotifyTitle}' → LOCAL (manual): '{JellyfinTitle}'",
spotifyTrack.Position, spotifyTrack.Title, mappedTrack.Title);
continue;
}
}
// Find best matching Jellyfin track that hasn't been used yet
var bestMatch = existingTracks
.Where(song => !usedJellyfinTracks.Contains(song.Id))
.Select(song => new
{
Song = song,
TitleScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.Title, song.Title),
ArtistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, song.Artist)
})
.Select(x => new
{
x.Song,
x.TitleScore,
x.ArtistScore,
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
})
.OrderByDescending(x => x.TotalScore)
.FirstOrDefault();
// Use 70% threshold for matching
if (bestMatch != null && bestMatch.TotalScore >= 70)
{
spotifyToJellyfinMap[spotifyTrack.Position] = bestMatch.Song;
usedJellyfinTracks.Add(bestMatch.Song.Id);
_logger.LogInformation("✅ Position #{Pos}: '{SpotifyTitle}' by {SpotifyArtist} → LOCAL: '{JellyfinTitle}' by {JellyfinArtist} (score: {Score:F1}%)",
spotifyTrack.Position,
spotifyTrack.Title,
spotifyTrack.PrimaryArtist,
bestMatch.Song.Title,
bestMatch.Song.Artist,
bestMatch.TotalScore);
}
else if (bestMatch != null)
{
_logger.LogDebug(" ⚠️ Position #{Pos} '{SpotifyTitle}' - Best Jellyfin match too low: {Score:F1}% (need 70%)",
spotifyTrack.Position, spotifyTrack.Title, bestMatch.TotalScore);
}
}
_logger.LogInformation("📊 Matched {Matched}/{Total} Spotify positions to Jellyfin tracks ({Manual} manual)",
spotifyToJellyfinMap.Count, spotifyTracks.Count, manualMappings.Count);
// Step 3: Build final playlist in Spotify order
foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position))
{
// Check if we have a Jellyfin track for this position
if (spotifyToJellyfinMap.TryGetValue(spotifyTrack.Position, out var jellyfinTrack))
{
finalTracks.Add(jellyfinTrack);
localUsedCount++;
continue; // Use local track, skip external search
}
// No local match - try to find external track
// First check pre-matched cache
var matched = orderedTracks?.FirstOrDefault(t => t.SpotifyId == spotifyTrack.SpotifyId);
if (matched != null)
{
finalTracks.Add(matched.MatchedSong);
externalUsedCount++;
_logger.LogInformation("📥 Position #{Pos}: '{Title}' by {Artist} → EXTERNAL (cached): {Provider}/{Id}",
spotifyTrack.Position,
spotifyTrack.Title,
spotifyTrack.PrimaryArtist,
matched.MatchedSong.ExternalProvider,
matched.MatchedSong.ExternalId);
}
else
{
// No cached match - search external providers on-demand
try
{
var query = $"{spotifyTrack.Title} {spotifyTrack.PrimaryArtist}";
var searchResults = await _metadataService.SearchSongsAsync(query, limit: 5);
if (searchResults.Count > 0)
{
// Fuzzy match to find best result
var bestExternalMatch = searchResults
.Select(song => new
{
Song = song,
TitleScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.Title, song.Title),
ArtistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, song.Artist)
})
.Select(x => new
{
x.Song,
x.TitleScore,
x.ArtistScore,
TotalScore = (x.TitleScore * 0.6) + (x.ArtistScore * 0.4)
})
.OrderByDescending(x => x.TotalScore)
.FirstOrDefault();
if (bestExternalMatch != null && bestExternalMatch.TotalScore >= 60)
{
finalTracks.Add(bestExternalMatch.Song);
externalUsedCount++;
_logger.LogInformation("📥 Position #{Pos}: '{Title}' by {Artist} → EXTERNAL (on-demand): {Provider}/{Id} (score: {Score:F1}%)",
spotifyTrack.Position,
spotifyTrack.Title,
spotifyTrack.PrimaryArtist,
bestExternalMatch.Song.ExternalProvider,
bestExternalMatch.Song.ExternalId,
bestExternalMatch.TotalScore);
}
else
{
skippedCount++;
_logger.LogWarning("❌ Position #{Pos}: '{Title}' by {Artist} → NO MATCH (best external score: {Score:F1}%, need 60%)",
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist,
bestExternalMatch?.TotalScore ?? 0);
}
}
else
{
skippedCount++;
_logger.LogWarning("❌ Position #{Pos}: '{Title}' by {Artist} → NO MATCH (no external results)",
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist);
}
}
catch (Exception ex)
{
skippedCount++;
_logger.LogError(ex, "❌ Position #{Pos}: '{Title}' by {Artist} → ERROR searching external providers",
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist);
}
}
}
// Step 3: Add any unmatched Jellyfin tracks at the end
var unmatchedJellyfinTracks = existingTracks
.Where(song => !usedJellyfinTracks.Contains(song.Id))
.ToList();
if (unmatchedJellyfinTracks.Count > 0)
{
_logger.LogInformation("📌 Adding {Count} unmatched Jellyfin tracks at the end (not in Spotify playlist)",
unmatchedJellyfinTracks.Count);
foreach (var track in unmatchedJellyfinTracks)
{
finalTracks.Add(track);
localUsedCount++;
_logger.LogInformation(" + '{Title}' by {Artist} (Jellyfin only)", track.Title, track.Artist);
}
}
// Cache the result
var cacheKey = $"spotify:matched:{spotifyPlaylistName}";
await _cache.SetAsync(cacheKey, finalTracks, TimeSpan.FromHours(1));
await SaveMatchedTracksToFile(spotifyPlaylistName, finalTracks);
_logger.LogInformation(
"🎵 Final playlist '{Playlist}': {Total} tracks ({Local} LOCAL + {External} EXTERNAL, {Skipped} not available)",
spotifyPlaylistName,
finalTracks.Count,
localUsedCount,
externalUsedCount,
skippedCount);
if (localUsedCount == 0 && existingTracks.Count > 0)
{
_logger.LogWarning("⚠️ WARNING: Found {Count} tracks in Jellyfin playlist but NONE matched by name!", existingTracks.Count);
_logger.LogWarning(" → Track names may be too different between Spotify and Jellyfin");
_logger.LogWarning(" → Check that the Jellyfin playlist has the correct tracks");
}
else if (localUsedCount > 0)
{
_logger.LogInformation("✅ Successfully used {Local} LOCAL tracks from Jellyfin playlist", localUsedCount);
}
return _responseBuilder.CreateItemsResponse(finalTracks);
}
/// <summary>
/// Legacy mode: Gets playlist tracks without ordering (from Jellyfin Spotify Import plugin).
/// </summary>
private async Task<IActionResult> GetSpotifyPlaylistTracksLegacyAsync(string spotifyPlaylistName, string playlistId)
{
var cacheKey = $"spotify:matched:{spotifyPlaylistName}";
var cachedTracks = await _cache.GetAsync<List<Song>>(cacheKey);
if (cachedTracks != null && cachedTracks.Count > 0)
{
_logger.LogDebug("Returning {Count} cached matched tracks from Redis for {Playlist}",
cachedTracks.Count, spotifyPlaylistName);
return _responseBuilder.CreateItemsResponse(cachedTracks);
}
// Try file cache if Redis is empty
if (cachedTracks == null || cachedTracks.Count == 0)
{
cachedTracks = await LoadMatchedTracksFromFile(spotifyPlaylistName);
if (cachedTracks != null && cachedTracks.Count > 0)
{
// Restore to Redis with 1 hour TTL
await _cache.SetAsync(cacheKey, cachedTracks, TimeSpan.FromHours(1));
_logger.LogInformation("Loaded {Count} matched tracks from file cache for {Playlist}",
cachedTracks.Count, spotifyPlaylistName);
return _responseBuilder.CreateItemsResponse(cachedTracks);
}
}
// Get existing Jellyfin playlist items (tracks the plugin already found)
// CRITICAL: Must include UserId parameter or Jellyfin returns empty results
var userId = _settings.UserId;
var playlistItemsUrl = $"Playlists/{playlistId}/Items";
if (!string.IsNullOrEmpty(userId))
{
playlistItemsUrl += $"?UserId={userId}";
}
else
{
_logger.LogWarning("No UserId configured - may not be able to fetch existing playlist tracks");
}
var (existingTracksResponse, _) = await _proxyService.GetJsonAsync(
playlistItemsUrl,
null,
Request.Headers);
var existingTracks = new List<Song>();
var existingSpotifyIds = new HashSet<string>();
if (existingTracksResponse != null &&
existingTracksResponse.RootElement.TryGetProperty("Items", out var items))
{
foreach (var item in items.EnumerateArray())
{
var song = _modelMapper.ParseSong(item);
existingTracks.Add(song);
// Track Spotify IDs to avoid duplicates
if (item.TryGetProperty("ProviderIds", out var providerIds) &&
providerIds.TryGetProperty("Spotify", out var spotifyId))
{
existingSpotifyIds.Add(spotifyId.GetString() ?? "");
}
}
_logger.LogInformation("Found {Count} existing tracks in Jellyfin playlist", existingTracks.Count);
}
else
{
_logger.LogWarning("No existing tracks found in Jellyfin playlist - may need UserId parameter");
}
var missingTracksKey = $"spotify:missing:{spotifyPlaylistName}";
var missingTracks = await _cache.GetAsync<List<MissingTrack>>(missingTracksKey);
// Fallback to file cache if Redis is empty
if (missingTracks == null || missingTracks.Count == 0)
{
missingTracks = await LoadMissingTracksFromFile(spotifyPlaylistName);
// If we loaded from file, restore to Redis with no expiration
if (missingTracks != null && missingTracks.Count > 0)
{
await _cache.SetAsync(missingTracksKey, missingTracks, TimeSpan.FromDays(365));
_logger.LogInformation("Restored {Count} missing tracks from file cache for {Playlist} (no expiration)",
missingTracks.Count, spotifyPlaylistName);
}
}
if (missingTracks == null || missingTracks.Count == 0)
{
_logger.LogInformation("No missing tracks found for {Playlist}, returning {Count} existing tracks",
spotifyPlaylistName, existingTracks.Count);
return _responseBuilder.CreateItemsResponse(existingTracks);
}
_logger.LogInformation("Matching {Count} missing tracks for {Playlist}",
missingTracks.Count, spotifyPlaylistName);
// Match missing tracks sequentially with rate limiting (excluding ones we already have locally)
var matchedBySpotifyId = new Dictionary<string, Song>();
var tracksToMatch = missingTracks
.Where(track => !existingSpotifyIds.Contains(track.SpotifyId))
.ToList();
foreach (var track in tracksToMatch)
{
try
{
// Search with just title and artist for better matching
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 = 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) // Weight title more
})
.OrderByDescending(x => x.TotalScore)
.FirstOrDefault();
// Only add if match is good enough (>60% combined score)
if (bestMatch != null && bestMatch.TotalScore >= 60)
{
_logger.LogDebug("Matched '{Title}' by {Artist} -> '{MatchTitle}' by {MatchArtist} (score: {Score:F1})",
track.Title, track.PrimaryArtist,
bestMatch.Song.Title, bestMatch.Song.Artist,
bestMatch.TotalScore);
matchedBySpotifyId[track.SpotifyId] = bestMatch.Song;
}
else
{
_logger.LogDebug("No good match for '{Title}' by {Artist} (best score: {Score:F1})",
track.Title, track.PrimaryArtist, bestMatch?.TotalScore ?? 0);
}
}
// Rate limiting: small delay between searches to avoid overwhelming the service
await Task.Delay(100); // 100ms delay = max 10 searches/second
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
track.Title, track.PrimaryArtist);
}
}
// Build final track list based on playlist configuration
// Local tracks position is configurable per-playlist
var playlistConfig = _spotifySettings.GetPlaylistById(playlistId);
var localTracksPosition = playlistConfig?.LocalTracksPosition ?? LocalTracksPosition.First;
var finalTracks = new List<Song>();
if (localTracksPosition == LocalTracksPosition.First)
{
// Local tracks first, external tracks at the end
finalTracks.AddRange(existingTracks);
finalTracks.AddRange(matchedBySpotifyId.Values);
}
else
{
// External tracks first, local tracks at the end
finalTracks.AddRange(matchedBySpotifyId.Values);
finalTracks.AddRange(existingTracks);
}
await _cache.SetAsync(cacheKey, finalTracks, TimeSpan.FromHours(1));
// Also save to file cache for persistence across restarts
await SaveMatchedTracksToFile(spotifyPlaylistName, finalTracks);
_logger.LogInformation("Final playlist: {Total} tracks ({Existing} local + {Matched} external, LocalTracksPosition: {Position})",
finalTracks.Count,
existingTracks.Count,
matchedBySpotifyId.Count,
localTracksPosition);
return _responseBuilder.CreateItemsResponse(finalTracks);
}
/// <summary>
/// Copies an external track to the kept folder when favorited.
/// </summary>
private async Task CopyExternalTrackToKeptAsync(string itemId, string provider, string externalId)
{
try
{
// Get the song metadata first to check if already in kept folder
var song = await _metadataService.GetSongAsync(provider, externalId);
if (song == null)
{
_logger.LogWarning("Could not find song metadata for {ItemId}", itemId);
return;
}
// Build kept folder path: /app/kept/Artist/Album/
var keptBasePath = "/app/kept";
var keptArtistPath = Path.Combine(keptBasePath, PathHelper.SanitizeFileName(song.Artist));
var keptAlbumPath = Path.Combine(keptArtistPath, PathHelper.SanitizeFileName(song.Album));
// Check if track already exists in kept folder BEFORE downloading
// Look for any file matching the song title pattern (any extension)
if (Directory.Exists(keptAlbumPath))
{
var sanitizedTitle = PathHelper.SanitizeFileName(song.Title);
var existingFiles = Directory.GetFiles(keptAlbumPath, $"{sanitizedTitle}.*");
if (existingFiles.Length > 0)
{
_logger.LogInformation("Track already exists in kept folder: {Path}", existingFiles[0]);
return;
}
}
// Track not in kept folder - download it
_logger.LogInformation("Downloading track for kept folder: {ItemId}", itemId);
string downloadPath;
try
{
downloadPath = await _downloadService.DownloadSongAsync(provider, externalId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to download track {ItemId}", itemId);
return;
}
// Create the kept folder structure
Directory.CreateDirectory(keptAlbumPath);
// Copy file to kept folder
var fileName = Path.GetFileName(downloadPath);
var keptFilePath = Path.Combine(keptAlbumPath, fileName);
// Double-check in case of race condition (multiple favorite clicks)
if (System.IO.File.Exists(keptFilePath))
{
_logger.LogInformation("Track already exists in kept folder (race condition): {Path}", keptFilePath);
return;
}
System.IO.File.Copy(downloadPath, keptFilePath, overwrite: false);
_logger.LogInformation("✓ Copied favorited track to kept folder: {Path}", keptFilePath);
// Also copy cover art if it exists
var coverPath = Path.Combine(Path.GetDirectoryName(downloadPath)!, "cover.jpg");
if (System.IO.File.Exists(coverPath))
{
var keptCoverPath = Path.Combine(keptAlbumPath, "cover.jpg");
if (!System.IO.File.Exists(keptCoverPath))
{
System.IO.File.Copy(coverPath, keptCoverPath, overwrite: false);
_logger.LogDebug("Copied cover art to kept folder");
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error copying external track {ItemId} to kept folder", itemId);
}
}
/// <summary>
/// Loads missing tracks from file cache as fallback when Redis is empty.
/// </summary>
private async Task<List<allstarr.Models.Spotify.MissingTrack>?> LoadMissingTracksFromFile(string playlistName)
{
try
{
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
var filePath = Path.Combine("/app/cache/spotify", $"{safeName}_missing.json");
if (!System.IO.File.Exists(filePath))
{
_logger.LogDebug("No file cache found for {Playlist} at {Path}", playlistName, filePath);
return null;
}
// No expiration check - cache persists until next Jellyfin job generates new file
var fileAge = DateTime.UtcNow - System.IO.File.GetLastWriteTimeUtc(filePath);
_logger.LogDebug("File cache for {Playlist} age: {Age:F1}h (no expiration)", playlistName, fileAge.TotalHours);
var json = await System.IO.File.ReadAllTextAsync(filePath);
var tracks = JsonSerializer.Deserialize<List<allstarr.Models.Spotify.MissingTrack>>(json);
_logger.LogInformation("Loaded {Count} missing tracks from file cache for {Playlist} (age: {Age:F1}h)",
tracks?.Count ?? 0, playlistName, fileAge.TotalHours);
return tracks;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load missing tracks from file for {Playlist}", playlistName);
return null;
}
}
/// <summary>
/// Loads matched/combined tracks from file cache as fallback when Redis is empty.
/// </summary>
private async Task<List<Song>?> LoadMatchedTracksFromFile(string playlistName)
{
try
{
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
var filePath = Path.Combine("/app/cache/spotify", $"{safeName}_matched.json");
if (!System.IO.File.Exists(filePath))
{
_logger.LogDebug("No matched tracks file cache found for {Playlist} at {Path}", playlistName, filePath);
return null;
}
var fileAge = DateTime.UtcNow - System.IO.File.GetLastWriteTimeUtc(filePath);
// Check if cache is too old (more than 24 hours)
if (fileAge.TotalHours > 24)
{
_logger.LogInformation("Matched tracks file cache for {Playlist} is too old ({Age:F1}h), will rebuild",
playlistName, fileAge.TotalHours);
return null;
}
_logger.LogDebug("Matched tracks file cache for {Playlist} age: {Age:F1}h", playlistName, fileAge.TotalHours);
var json = await System.IO.File.ReadAllTextAsync(filePath);
var tracks = JsonSerializer.Deserialize<List<Song>>(json);
_logger.LogInformation("Loaded {Count} matched tracks from file cache for {Playlist} (age: {Age:F1}h)",
tracks?.Count ?? 0, playlistName, fileAge.TotalHours);
return tracks;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load matched tracks from file for {Playlist}", playlistName);
return null;
}
}
/// <summary>
/// Saves matched/combined tracks to file cache for persistence across restarts.
/// </summary>
private async Task SaveMatchedTracksToFile(string playlistName, List<Song> tracks)
{
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(tracks, new JsonSerializerOptions { WriteIndented = true });
await System.IO.File.WriteAllTextAsync(filePath, json);
_logger.LogInformation("Saved {Count} matched tracks to file cache for {Playlist}",
tracks.Count, playlistName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save matched tracks to file for {Playlist}", playlistName);
}
}
/// <summary>
/// Manual trigger endpoint to force fetch Spotify missing tracks.
/// GET /spotify/sync?api_key=YOUR_KEY
/// </summary>
[HttpGet("spotify/sync", Order = 1)]
[ServiceFilter(typeof(ApiKeyAuthFilter))]
public async Task<IActionResult> TriggerSpotifySync([FromServices] IEnumerable<IHostedService> hostedServices)
{
if (!_spotifySettings.Enabled)
{
return BadRequest(new { error = "Spotify Import is not enabled" });
}
_logger.LogInformation("Manual Spotify sync triggered");
// Find the SpotifyMissingTracksFetcher service
var fetcherService = hostedServices
.OfType<allstarr.Services.Spotify.SpotifyMissingTracksFetcher>()
.FirstOrDefault();
if (fetcherService == null)
{
return StatusCode(500, new { error = "SpotifyMissingTracksFetcher not found" });
}
// Trigger fetch manually
await fetcherService.TriggerFetchAsync();
// Check what was cached
var results = new Dictionary<string, object>();
foreach (var playlist in _spotifySettings.Playlists)
{
var cacheKey = $"spotify:missing:{playlist.Name}";
var tracks = await _cache.GetAsync<List<allstarr.Models.Spotify.MissingTrack>>(cacheKey);
if (tracks != null && tracks.Count > 0)
{
results[playlist.Name] = new {
status = "success",
tracks = tracks.Count,
localTracksPosition = playlist.LocalTracksPosition.ToString()
};
}
else
{
results[playlist.Name] = new {
status = "not_found",
message = "No missing tracks found"
};
}
}
return Ok(results);
}
/// <summary>
/// Manually trigger track matching for all Spotify playlists.
/// GET /spotify/match?api_key=YOUR_KEY
/// </summary>
[HttpGet("spotify/match", Order = 1)]
[ServiceFilter(typeof(ApiKeyAuthFilter))]
public async Task<IActionResult> TriggerSpotifyMatch([FromServices] IEnumerable<IHostedService> hostedServices)
{
if (!_spotifySettings.Enabled)
{
return BadRequest(new { error = "Spotify Import is not enabled" });
}
_logger.LogInformation("Manual Spotify track matching triggered");
// Find the SpotifyTrackMatchingService
var matchingService = hostedServices
.OfType<allstarr.Services.Spotify.SpotifyTrackMatchingService>()
.FirstOrDefault();
if (matchingService == null)
{
return StatusCode(500, new { error = "SpotifyTrackMatchingService not found" });
}
// Trigger matching asynchronously
_ = Task.Run(async () =>
{
try
{
await matchingService.TriggerMatchingAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during manual track matching");
}
});
return Ok(new
{
status = "started",
message = "Track matching started in background. Check logs for progress.",
playlists = _spotifySettings.Playlists.Select(p => new { p.Name, p.Id, localTracksPosition = p.LocalTracksPosition.ToString() })
});
}
private List<allstarr.Models.Spotify.MissingTrack> ParseMissingTracksJson(string json)
{
var tracks = new List<allstarr.Models.Spotify.MissingTrack>();
try
{
var doc = JsonDocument.Parse(json);
foreach (var item in doc.RootElement.EnumerateArray())
{
var track = new allstarr.Models.Spotify.MissingTrack
{
SpotifyId = item.GetProperty("Id").GetString() ?? "",
Title = item.GetProperty("Name").GetString() ?? "",
Album = item.GetProperty("AlbumName").GetString() ?? "",
Artists = item.GetProperty("ArtistNames")
.EnumerateArray()
.Select(a => a.GetString() ?? "")
.Where(a => !string.IsNullOrEmpty(a))
.ToList()
};
if (!string.IsNullOrEmpty(track.Title))
{
tracks.Add(track);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to parse missing tracks JSON");
}
return tracks;
}
#endregion
#region Spotify Debug
/// <summary>
/// Clear Spotify playlist cache to force re-matching.
/// GET /spotify/clear-cache?api_key=YOUR_KEY
/// </summary>
[HttpGet("spotify/clear-cache")]
[ServiceFilter(typeof(ApiKeyAuthFilter))]
public async Task<IActionResult> ClearSpotifyCache()
{
if (!_spotifySettings.Enabled)
{
return BadRequest(new { error = "Spotify Import is not enabled" });
}
var cleared = new List<string>();
foreach (var playlist in _spotifySettings.Playlists)
{
var matchedKey = $"spotify:matched:{playlist.Name}";
await _cache.DeleteAsync(matchedKey);
cleared.Add(playlist.Name);
_logger.LogInformation("Cleared cache for {Playlist}", playlist.Name);
}
return Ok(new { status = "success", cleared = cleared });
}
#endregion
#region Debug & Monitoring
/// <summary>
/// Gets endpoint usage statistics from the log file.
/// GET /debug/endpoint-usage?api_key=YOUR_KEY
/// Optional query params: top=50 (default 100), since=2024-01-01
/// </summary>
[HttpGet("debug/endpoint-usage")]
[ServiceFilter(typeof(ApiKeyAuthFilter))]
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 collected yet",
endpoints = Array.Empty<object>()
});
}
var lines = await System.IO.File.ReadAllLinesAsync(logFile);
// Parse CSV and filter by date if provided
DateTime? sinceDate = null;
if (!string.IsNullOrEmpty(since) && DateTime.TryParse(since, out var parsedDate))
{
sinceDate = parsedDate;
}
var entries = lines
.Select(line => line.Split(','))
.Where(parts => parts.Length >= 3)
.Where(parts => !sinceDate.HasValue ||
(DateTime.TryParse(parts[0], out var entryDate) && entryDate >= sinceDate.Value))
.Select(parts => new
{
Timestamp = parts[0],
Method = parts.Length > 1 ? parts[1] : "",
Path = parts.Length > 2 ? parts[2] : "",
Query = parts.Length > 3 ? parts[3] : ""
})
.ToList();
// Group by path and count
var pathCounts = entries
.GroupBy(e => new { e.Method, e.Path })
.Select(g => new
{
Method = g.Key.Method,
Path = g.Key.Path,
Count = g.Count(),
FirstSeen = g.Min(e => e.Timestamp),
LastSeen = g.Max(e => e.Timestamp)
})
.OrderByDescending(x => x.Count)
.Take(top)
.ToList();
return Ok(new
{
totalRequests = entries.Count,
uniqueEndpoints = pathCounts.Count,
topEndpoints = pathCounts,
logFile = logFile,
logSize = new FileInfo(logFile).Length
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get endpoint usage");
return StatusCode(500, new { error = ex.Message });
}
}
/// <summary>
/// Clears the endpoint usage log file.
/// DELETE /debug/endpoint-usage?api_key=YOUR_KEY
/// </summary>
[HttpDelete("debug/endpoint-usage")]
[ServiceFilter(typeof(ApiKeyAuthFilter))]
public IActionResult ClearEndpointUsage()
{
try
{
var logFile = "/app/cache/endpoint-usage/endpoints.csv";
if (System.IO.File.Exists(logFile))
{
System.IO.File.Delete(logFile);
return Ok(new { status = "success", message = "Endpoint usage log cleared" });
}
return Ok(new { status = "success", message = "No log file to clear" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to clear endpoint usage log");
return StatusCode(500, new { error = ex.Message });
}
}
#endregion
/// <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>
/// Extracts device information from Authorization header.
/// </summary>
private (string? deviceId, string? client, string? device, string? version) ExtractDeviceInfo(IHeaderDictionary headers)
{
string? deviceId = null;
string? client = null;
string? device = null;
string? version = null;
// Check X-Emby-Authorization FIRST (most Jellyfin clients use this)
// Then fall back to Authorization header
string? authStr = null;
if (headers.TryGetValue("X-Emby-Authorization", out var embyAuthHeader))
{
authStr = embyAuthHeader.ToString();
}
else if (headers.TryGetValue("Authorization", out var authHeader))
{
authStr = authHeader.ToString();
}
if (!string.IsNullOrEmpty(authStr))
{
// Parse: MediaBrowser Client="...", Device="...", DeviceId="...", Version="..."
var parts = authStr.Replace("MediaBrowser ", "").Split(',');
foreach (var part in parts)
{
var kv = part.Trim().Split('=');
if (kv.Length == 2)
{
var key = kv[0].Trim();
var value = kv[1].Trim('"');
if (key == "DeviceId") deviceId = value;
else if (key == "Client") client = value;
else if (key == "Device") device = value;
else if (key == "Version") version = value;
}
}
}
return (deviceId, client, device, version);
}
} }
// force rebuild Sun Jan 25 13:22:47 EST 2026 // force rebuild Sun Jan 25 13:22:47 EST 2026

View File

@@ -0,0 +1,28 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace allstarr.Filters;
/// <summary>
/// Filter that restricts access to admin endpoints to only the admin port (5275).
/// This prevents the admin API from being accessed through the main proxy port.
/// </summary>
public class AdminPortFilter : IActionFilter
{
private const int AdminPort = 5275;
public void OnActionExecuting(ActionExecutingContext context)
{
var requestPort = context.HttpContext.Connection.LocalPort;
if (requestPort != AdminPort)
{
context.Result = new NotFoundResult();
}
}
public void OnActionExecuted(ActionExecutedContext context)
{
// No action needed after execution
}
}

View File

@@ -0,0 +1,52 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Options;
using allstarr.Models.Settings;
namespace allstarr.Filters;
/// <summary>
/// Simple API key authentication filter for admin endpoints.
/// Validates against Jellyfin API key via query parameter or header.
/// </summary>
public class ApiKeyAuthFilter : IAsyncActionFilter
{
private readonly JellyfinSettings _settings;
private readonly ILogger<ApiKeyAuthFilter> _logger;
public ApiKeyAuthFilter(
IOptions<JellyfinSettings> settings,
ILogger<ApiKeyAuthFilter> logger)
{
_settings = settings.Value;
_logger = logger;
}
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
var request = context.HttpContext.Request;
// Extract API key from query parameter or header
var apiKey = request.Query["api_key"].FirstOrDefault()
?? request.Headers["X-Api-Key"].FirstOrDefault()
?? request.Headers["X-Emby-Token"].FirstOrDefault();
// Validate API key
if (string.IsNullOrEmpty(apiKey) || !string.Equals(apiKey, _settings.ApiKey, StringComparison.Ordinal))
{
_logger.LogWarning("Unauthorized access attempt to {Path} from {IP}",
request.Path,
context.HttpContext.Connection.RemoteIpAddress);
context.Result = new UnauthorizedObjectResult(new
{
error = "Unauthorized",
message = "Valid API key required. Provide via ?api_key=YOUR_KEY or X-Api-Key header."
});
return;
}
_logger.LogDebug("API key authentication successful for {Path}", request.Path);
await next();
}
}

View File

@@ -0,0 +1,76 @@
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.FileProviders;
namespace allstarr.Middleware;
/// <summary>
/// Middleware that only serves static files on the admin port (5275).
/// This keeps the admin UI isolated from the main proxy port.
/// </summary>
public class AdminStaticFilesMiddleware
{
private readonly RequestDelegate _next;
private readonly IWebHostEnvironment _env;
private const int AdminPort = 5275;
public AdminStaticFilesMiddleware(
RequestDelegate next,
IWebHostEnvironment env)
{
_next = next;
_env = env;
}
public async Task InvokeAsync(HttpContext context)
{
var port = context.Connection.LocalPort;
if (port == AdminPort)
{
var path = context.Request.Path.Value ?? "/";
// Serve index.html for root path
if (path == "/" || path == "/index.html")
{
var indexPath = Path.Combine(_env.WebRootPath, "index.html");
if (File.Exists(indexPath))
{
context.Response.ContentType = "text/html";
await context.Response.SendFileAsync(indexPath);
return;
}
}
// Try to serve static file from wwwroot
var filePath = Path.Combine(_env.WebRootPath, path.TrimStart('/'));
if (File.Exists(filePath))
{
var contentType = GetContentType(filePath);
context.Response.ContentType = contentType;
await context.Response.SendFileAsync(filePath);
return;
}
}
// Not admin port or file not found - continue pipeline
await _next(context);
}
private static string GetContentType(string filePath)
{
var ext = Path.GetExtension(filePath).ToLowerInvariant();
return ext switch
{
".html" => "text/html",
".css" => "text/css",
".js" => "application/javascript",
".json" => "application/json",
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".gif" => "image/gif",
".svg" => "image/svg+xml",
".ico" => "image/x-icon",
_ => "application/octet-stream"
};
}
}

View File

@@ -0,0 +1,273 @@
using System.Net.WebSockets;
using Microsoft.Extensions.Options;
using allstarr.Models.Settings;
using allstarr.Services.Jellyfin;
namespace allstarr.Middleware;
/// <summary>
/// Middleware that proxies WebSocket connections to Jellyfin server.
/// This enables real-time features like session tracking, remote control, and live updates.
/// </summary>
public class WebSocketProxyMiddleware
{
private readonly RequestDelegate _next;
private readonly JellyfinSettings _settings;
private readonly ILogger<WebSocketProxyMiddleware> _logger;
private readonly JellyfinSessionManager _sessionManager;
public WebSocketProxyMiddleware(
RequestDelegate next,
IOptions<JellyfinSettings> settings,
ILogger<WebSocketProxyMiddleware> logger,
JellyfinSessionManager sessionManager)
{
_next = next;
_settings = settings.Value;
_logger = logger;
_sessionManager = sessionManager;
_logger.LogDebug("🔧 WEBSOCKET: WebSocketProxyMiddleware initialized - Jellyfin URL: {Url}", _settings.Url);
}
public async Task InvokeAsync(HttpContext context)
{
// Log ALL requests for debugging
var path = context.Request.Path.Value ?? "";
var isWebSocket = context.WebSockets.IsWebSocketRequest;
// Log any request that might be WebSocket-related
if (path.Contains("socket", StringComparison.OrdinalIgnoreCase) ||
path.Contains("ws", StringComparison.OrdinalIgnoreCase) ||
isWebSocket ||
context.Request.Headers.ContainsKey("Upgrade"))
{
_logger.LogDebug("🔍 WEBSOCKET: Potential WebSocket request: Path={Path}, IsWs={IsWs}, Method={Method}, Upgrade={Upgrade}, Connection={Connection}",
path,
isWebSocket,
context.Request.Method,
context.Request.Headers["Upgrade"].ToString(),
context.Request.Headers["Connection"].ToString());
}
// Check if this is a WebSocket request to /socket
if (context.Request.Path.StartsWithSegments("/socket", StringComparison.OrdinalIgnoreCase) &&
context.WebSockets.IsWebSocketRequest)
{
_logger.LogInformation("🔌 WEBSOCKET: WebSocket connection request received from {RemoteIp}",
context.Connection.RemoteIpAddress);
await HandleWebSocketProxyAsync(context);
return;
}
// Not a WebSocket request, pass to next middleware
await _next(context);
}
private async Task HandleWebSocketProxyAsync(HttpContext context)
{
ClientWebSocket? serverWebSocket = null;
WebSocket? clientWebSocket = null;
string? deviceId = null;
try
{
// Extract device ID from query string or headers for session tracking
deviceId = context.Request.Query["deviceId"].ToString();
if (string.IsNullOrEmpty(deviceId))
{
// Try to extract from X-Emby-Authorization header
if (context.Request.Headers.TryGetValue("X-Emby-Authorization", out var authHeader))
{
var authValue = authHeader.ToString();
var deviceIdMatch = System.Text.RegularExpressions.Regex.Match(authValue, @"DeviceId=""([^""]+)""");
if (deviceIdMatch.Success)
{
deviceId = deviceIdMatch.Groups[1].Value;
}
}
}
if (!string.IsNullOrEmpty(deviceId))
{
_logger.LogDebug("🔍 WEBSOCKET: Client WebSocket for device {DeviceId}", deviceId);
}
// Accept the WebSocket connection from the client
clientWebSocket = await context.WebSockets.AcceptWebSocketAsync();
_logger.LogDebug("✓ WEBSOCKET: Client WebSocket accepted");
// 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";
// Add query parameters if present (e.g., ?api_key=xxx or ?deviceId=xxx)
if (context.Request.QueryString.HasValue)
{
jellyfinWsUrl += context.Request.QueryString.Value;
}
_logger.LogDebug("🔗 WEBSOCKET: Connecting to Jellyfin WebSocket: {Url}", jellyfinWsUrl);
// Connect to Jellyfin WebSocket
serverWebSocket = new ClientWebSocket();
// Forward authentication headers - check X-Emby-Authorization FIRST
// Most Jellyfin clients use X-Emby-Authorization, not Authorization
if (context.Request.Headers.TryGetValue("X-Emby-Authorization", out var embyAuthHeader))
{
serverWebSocket.Options.SetRequestHeader("X-Emby-Authorization", embyAuthHeader.ToString());
_logger.LogDebug("🔑 WEBSOCKET: Forwarded X-Emby-Authorization header");
}
else if (context.Request.Headers.TryGetValue("Authorization", out var authHeader2))
{
var authValue = authHeader2.ToString();
// If it's a MediaBrowser auth header, use X-Emby-Authorization
if (authValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase))
{
serverWebSocket.Options.SetRequestHeader("X-Emby-Authorization", authValue);
_logger.LogDebug("🔑 WEBSOCKET: Converted Authorization to X-Emby-Authorization header");
}
else
{
serverWebSocket.Options.SetRequestHeader("Authorization", authValue);
_logger.LogDebug("🔑 WEBSOCKET: Forwarded Authorization header");
}
}
// Set user agent
serverWebSocket.Options.SetRequestHeader("User-Agent", "Allstarr/1.0");
await serverWebSocket.ConnectAsync(new Uri(jellyfinWsUrl), context.RequestAborted);
_logger.LogInformation("✓ WEBSOCKET: Connected to Jellyfin WebSocket");
// Start bidirectional proxying
var clientToServer = ProxyMessagesAsync(clientWebSocket, serverWebSocket, "Client→Server", context.RequestAborted);
var serverToClient = ProxyMessagesAsync(serverWebSocket, clientWebSocket, "Server→Client", context.RequestAborted);
// Wait for either direction to complete
await Task.WhenAny(clientToServer, serverToClient);
_logger.LogDebug("🔌 WEBSOCKET: WebSocket proxy connection closed");
}
catch (WebSocketException wsEx)
{
_logger.LogWarning(wsEx, "⚠️ WEBSOCKET: WebSocket error: {Message}", wsEx.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "❌ WEBSOCKET: Error in WebSocket proxy");
}
finally
{
// Clean up connections
if (clientWebSocket?.State == WebSocketState.Open)
{
try
{
await clientWebSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Proxy closing", CancellationToken.None);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error closing client WebSocket");
}
}
if (serverWebSocket?.State == WebSocketState.Open)
{
try
{
await serverWebSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Proxy closing", CancellationToken.None);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error closing server WebSocket");
}
}
clientWebSocket?.Dispose();
serverWebSocket?.Dispose();
// CRITICAL: Notify session manager that client disconnected
if (!string.IsNullOrEmpty(deviceId))
{
_logger.LogInformation("🧹 WEBSOCKET: Client disconnected, removing session for device {DeviceId}", deviceId);
await _sessionManager.RemoveSessionAsync(deviceId);
}
_logger.LogDebug("🧹 WEBSOCKET: WebSocket connections cleaned up");
}
}
private async Task ProxyMessagesAsync(
WebSocket source,
WebSocket destination,
string direction,
CancellationToken cancellationToken)
{
var buffer = new byte[1024 * 4]; // 4KB buffer
var messageBuffer = new List<byte>();
try
{
while (source.State == WebSocketState.Open && destination.State == WebSocketState.Open)
{
var result = await source.ReceiveAsync(new ArraySegment<byte>(buffer), cancellationToken);
if (result.MessageType == WebSocketMessageType.Close)
{
_logger.LogDebug("🔌 WEBSOCKET {Direction}: Close message received", direction);
await destination.CloseAsync(
result.CloseStatus ?? WebSocketCloseStatus.NormalClosure,
result.CloseStatusDescription,
cancellationToken);
break;
}
// Accumulate message fragments
messageBuffer.AddRange(buffer.Take(result.Count));
// If this is the end of the message, forward it
if (result.EndOfMessage)
{
var messageBytes = messageBuffer.ToArray();
// Log message for debugging (only in debug mode to avoid spam)
if (_logger.IsEnabled(LogLevel.Debug))
{
var messageText = System.Text.Encoding.UTF8.GetString(messageBytes);
_logger.LogDebug("{Direction}: {MessageType} message ({Size} bytes): {Preview}",
direction,
result.MessageType,
messageBytes.Length,
messageText.Length > 200 ? messageText[..200] + "..." : messageText);
}
// Forward the complete message
await destination.SendAsync(
new ArraySegment<byte>(messageBytes),
result.MessageType,
true,
cancellationToken);
messageBuffer.Clear();
}
}
}
catch (OperationCanceledException)
{
_logger.LogDebug("⚠️ WEBSOCKET {Direction}: Operation cancelled", direction);
}
catch (WebSocketException wsEx) when (wsEx.WebSocketErrorCode == WebSocketError.ConnectionClosedPrematurely)
{
_logger.LogDebug("⚠️ WEBSOCKET {Direction}: Connection closed prematurely", direction);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "⚠️ WEBSOCKET {Direction}: Error proxying messages", direction);
}
}
}

View File

@@ -14,6 +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; }
/// <summary>
/// All artists for this track (main + featured). For display in Jellyfin clients.
/// </summary>
public List<string> Artists { get; set; } = new();
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
@@ -94,4 +99,10 @@ public class Song
/// 0 = Naturally clean, 1 = Explicit, 2 = Not applicable, 3 = Clean/edited version, 6/7 = Unknown /// 0 = Naturally clean, 1 = Explicit, 2 = Not applicable, 3 = Clean/edited version, 6/7 = Unknown
/// </summary> /// </summary>
public int? ExplicitContentLyrics { get; set; } public int? ExplicitContentLyrics { get; set; }
/// <summary>
/// Raw Jellyfin metadata (MediaSources, etc.) for local tracks
/// Preserved to maintain bitrate and other technical details
/// </summary>
public Dictionary<string, object?>? JellyfinMetadata { get; set; }
} }

View File

@@ -0,0 +1,72 @@
namespace allstarr.Models.Settings;
/// <summary>
/// Configuration for direct Spotify API access.
/// This enables fetching playlist data directly from Spotify rather than relying on the Jellyfin plugin.
///
/// Benefits over Jellyfin plugin approach:
/// - Track ordering is preserved (critical for playlists like Release Radar)
/// - ISRC codes available for exact matching
/// - Real-time data without waiting for plugin sync
/// - Full track metadata (duration, release date, etc.)
/// </summary>
public class SpotifyApiSettings
{
/// <summary>
/// Enable direct Spotify API integration.
/// When enabled, playlists will be fetched directly from Spotify instead of the Jellyfin plugin.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Spotify Client ID from https://developer.spotify.com/dashboard
/// Used for OAuth token refresh and API access.
/// </summary>
public string ClientId { get; set; } = string.Empty;
/// <summary>
/// Spotify Client Secret from https://developer.spotify.com/dashboard
/// Optional - only needed for certain OAuth flows.
/// </summary>
public string ClientSecret { get; set; } = string.Empty;
/// <summary>
/// Spotify session cookie (sp_dc).
/// Required for accessing editorial/personalized playlists like Release Radar and Discover Weekly.
/// These playlists are not available via the official API.
///
/// To get this cookie:
/// 1. Log into open.spotify.com in your browser
/// 2. Open DevTools (F12) > Application > Cookies > https://open.spotify.com
/// 3. Copy the value of the "sp_dc" cookie
///
/// Note: This cookie expires periodically and will need to be refreshed.
/// </summary>
public string SessionCookie { get; set; } = string.Empty;
/// <summary>
/// Cache duration in minutes for playlist data.
/// Playlists like Release Radar only update weekly, so caching is beneficial.
/// Default: 60 minutes
/// </summary>
public int CacheDurationMinutes { get; set; } = 60;
/// <summary>
/// Rate limit delay between Spotify API requests in milliseconds.
/// Default: 100ms (Spotify allows ~100 requests per minute)
/// </summary>
public int RateLimitDelayMs { get; set; } = 100;
/// <summary>
/// Whether to prefer ISRC matching over fuzzy title/artist matching when ISRC is available.
/// ISRC provides exact track identification across services.
/// Default: true
/// </summary>
public bool PreferIsrcMatching { get; set; } = true;
/// <summary>
/// ISO date string of when the session cookie was last set/updated.
/// Used to track cookie age and warn when it's approaching expiration (~1 year).
/// </summary>
public string? SessionCookieSetDate { get; set; }
}

View File

@@ -0,0 +1,135 @@
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>
/// 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>
/// 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>
/// 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));
}

View File

@@ -0,0 +1,12 @@
namespace allstarr.Models.Spotify;
public class MissingTrack
{
public string SpotifyId { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string Album { get; set; } = string.Empty;
public List<string> Artists { get; set; } = new();
public string PrimaryArtist => Artists.FirstOrDefault() ?? "";
public string AllArtists => string.Join(", ", Artists);
}

View File

@@ -0,0 +1,231 @@
using allstarr.Models.Domain;
namespace allstarr.Models.Spotify;
/// <summary>
/// Represents a track from a Spotify playlist with full metadata including position.
/// This model preserves track ordering which is critical for playlists like Release Radar.
/// </summary>
public class SpotifyPlaylistTrack
{
/// <summary>
/// Spotify track ID (e.g., "3a8mo25v74BMUOJ1IDUEBL")
/// </summary>
public string SpotifyId { get; set; } = string.Empty;
/// <summary>
/// Track's position in the playlist (0-based index).
/// This is critical for maintaining correct playlist order.
/// </summary>
public int Position { get; set; }
/// <summary>
/// Track title
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// Album name
/// </summary>
public string Album { get; set; } = string.Empty;
/// <summary>
/// Album Spotify ID
/// </summary>
public string AlbumId { get; set; } = string.Empty;
/// <summary>
/// List of artist names
/// </summary>
public List<string> Artists { get; set; } = new();
/// <summary>
/// List of artist Spotify IDs
/// </summary>
public List<string> ArtistIds { get; set; } = new();
/// <summary>
/// ISRC (International Standard Recording Code) for exact track identification.
/// This enables precise matching across different streaming services.
/// </summary>
public string? Isrc { get; set; }
/// <summary>
/// Track duration in milliseconds
/// </summary>
public int DurationMs { get; set; }
/// <summary>
/// Whether the track contains explicit content
/// </summary>
public bool Explicit { get; set; }
/// <summary>
/// Track's popularity score (0-100)
/// </summary>
public int Popularity { get; set; }
/// <summary>
/// Preview URL for 30-second audio clip (may be null)
/// </summary>
public string? PreviewUrl { get; set; }
/// <summary>
/// Album artwork URL (largest available)
/// </summary>
public string? AlbumArtUrl { get; set; }
/// <summary>
/// Release date of the album (format varies: YYYY, YYYY-MM, or YYYY-MM-DD)
/// </summary>
public string? ReleaseDate { get; set; }
/// <summary>
/// When this track was added to the playlist
/// </summary>
public DateTime? AddedAt { get; set; }
/// <summary>
/// Disc number within the album
/// </summary>
public int DiscNumber { get; set; } = 1;
/// <summary>
/// Track number within the disc
/// </summary>
public int TrackNumber { get; set; } = 1;
/// <summary>
/// Primary (first) artist name
/// </summary>
public string PrimaryArtist => Artists.FirstOrDefault() ?? string.Empty;
/// <summary>
/// All artists as a comma-separated string
/// </summary>
public string AllArtists => string.Join(", ", Artists);
/// <summary>
/// Track duration as TimeSpan
/// </summary>
public TimeSpan Duration => TimeSpan.FromMilliseconds(DurationMs);
/// <summary>
/// Converts to the legacy MissingTrack format for compatibility with existing matching logic.
/// </summary>
public MissingTrack ToMissingTrack() => new()
{
SpotifyId = SpotifyId,
Title = Title,
Album = Album,
Artists = Artists
};
}
/// <summary>
/// Represents a Spotify playlist with its tracks in order.
/// </summary>
public class SpotifyPlaylist
{
/// <summary>
/// Spotify playlist ID
/// </summary>
public string SpotifyId { get; set; } = string.Empty;
/// <summary>
/// Playlist name
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Playlist description
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Playlist owner's display name
/// </summary>
public string? OwnerName { get; set; }
/// <summary>
/// Playlist owner's Spotify ID
/// </summary>
public string? OwnerId { get; set; }
/// <summary>
/// Total number of tracks in the playlist
/// </summary>
public int TotalTracks { get; set; }
/// <summary>
/// Playlist cover image URL
/// </summary>
public string? ImageUrl { get; set; }
/// <summary>
/// Whether this is a collaborative playlist
/// </summary>
public bool Collaborative { get; set; }
/// <summary>
/// Whether this playlist is public
/// </summary>
public bool Public { get; set; }
/// <summary>
/// Tracks in the playlist, ordered by position
/// </summary>
public List<SpotifyPlaylistTrack> Tracks { get; set; } = new();
/// <summary>
/// When this data was fetched from Spotify
/// </summary>
public DateTime FetchedAt { get; set; } = DateTime.UtcNow;
/// <summary>
/// Snapshot ID for change detection (Spotify's playlist version identifier)
/// </summary>
public string? SnapshotId { get; set; }
}
/// <summary>
/// Represents a Spotify track that has been matched to an external provider track.
/// Preserves position for correct playlist ordering.
/// </summary>
public class MatchedTrack
{
/// <summary>
/// Position in the original Spotify playlist (0-based)
/// </summary>
public int Position { get; set; }
/// <summary>
/// Original Spotify track ID
/// </summary>
public string SpotifyId { get; set; } = string.Empty;
/// <summary>
/// Original Spotify track title (for debugging/logging)
/// </summary>
public string SpotifyTitle { get; set; } = string.Empty;
/// <summary>
/// Original Spotify artist (for debugging/logging)
/// </summary>
public string SpotifyArtist { get; set; } = string.Empty;
/// <summary>
/// ISRC used for matching (if available)
/// </summary>
public string? Isrc { get; set; }
/// <summary>
/// How the match was made: "isrc" or "fuzzy"
/// </summary>
public string MatchType { get; set; } = string.Empty;
/// <summary>
/// The matched song from the external provider
/// </summary>
public Song MatchedSong { get; set; } = null!;
}

View File

@@ -22,12 +22,16 @@ static List<string> DecodeSquidWtfUrls()
{ {
var encodedUrls = new[] var encodedUrls = new[]
{ {
"aHR0cHM6Ly90cml0b24uc3F1aWQud3Rm", // triton "aHR0cHM6Ly90cml0b24uc3F1aWQud3Rm", // triton
"aHR0cHM6Ly93b2xmLnFxZGwuc2l0ZQ==", // wolf "aHR0cHM6Ly90aWRhbC1hcGkuYmluaW11bS5vcmc=", // binimum
"aHR0cDovL2h1bmQucXFkbC5zaXRl", // hund "aHR0cHM6Ly90aWRhbC5raW5vcGx1cy5vbmxpbmU=", // kinoplus
"aHR0cHM6Ly9tYXVzLnFxZGwuc2l0ZQ==", // maus "aHR0cHM6Ly9oaWZpLXR3by5zcG90aXNhdmVyLm5ldA==", // spoti-2
"aHR0cHM6Ly92b2dlbC5xcWRsLnNpdGU=", // vogel "aHR0cHM6Ly9oaWZpLW9uZS5zcG90aXNhdmVyLm5ldA==", // spoti-1
"aHR0cHM6Ly9rYXR6ZS5xcWRsLnNpdGU=" // katze "aHR0cHM6Ly93b2xmLnFxZGwuc2l0ZQ==", // wolf
"aHR0cDovL2h1bmQucXFkbC5zaXRl", // hund
"aHR0cHM6Ly9rYXR6ZS5xcWRsLnNpdGU=", // katze
"aHR0cHM6Ly92b2dlbC5xcWRsLnNpdGU=", // vogel
"aHR0cHM6Ly9tYXVzLnFxZGwuc2l0ZQ==" // maus
}; };
return encodedUrls return encodedUrls
@@ -39,11 +43,18 @@ static List<string> DecodeSquidWtfUrls()
var backendType = builder.Configuration.GetValue<BackendType>("Backend:Type"); var backendType = builder.Configuration.GetValue<BackendType>("Backend:Type");
// Configure Kestrel for large responses over VPN/Tailscale // Configure Kestrel for large responses over VPN/Tailscale
// Also configure admin port on 5275 (internal only, not exposed)
builder.WebHost.ConfigureKestrel(serverOptions => builder.WebHost.ConfigureKestrel(serverOptions =>
{ {
serverOptions.Limits.MaxResponseBufferSize = null; // Disable response buffering limit serverOptions.Limits.MaxResponseBufferSize = null; // Disable response buffering limit
serverOptions.Limits.MaxRequestBodySize = null; // Allow large request bodies serverOptions.Limits.MaxRequestBodySize = null; // Allow large request bodies
serverOptions.Limits.MinResponseDataRate = null; // Disable minimum data rate for slow connections serverOptions.Limits.MinResponseDataRate = null; // Disable minimum data rate for slow connections
// Main proxy port (exposed)
serverOptions.ListenAnyIP(8080);
// Admin UI port (internal only - do NOT expose through reverse proxy)
serverOptions.ListenAnyIP(5275);
}); });
// Add response compression for large JSON responses (helps with Tailscale/VPN MTU issues) // Add response compression for large JSON responses (helps with Tailscale/VPN MTU issues)
@@ -95,6 +106,9 @@ builder.Services.AddHttpContextAccessor();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>(); builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails(); builder.Services.AddProblemDetails();
// Admin port filter (restricts admin API to port 5275)
builder.Services.AddScoped<allstarr.Filters.AdminPortFilter>();
// Configuration - register both settings, active one determined by backend type // Configuration - register both settings, active one determined by backend type
builder.Services.Configure<SubsonicSettings>( builder.Services.Configure<SubsonicSettings>(
builder.Configuration.GetSection("Subsonic")); builder.Configuration.GetSection("Subsonic"));
@@ -108,6 +122,240 @@ builder.Services.Configure<SquidWTFSettings>(
builder.Configuration.GetSection("SquidWTF")); builder.Configuration.GetSection("SquidWTF"));
builder.Services.Configure<RedisSettings>( builder.Services.Configure<RedisSettings>(
builder.Configuration.GetSection("Redis")); builder.Configuration.GetSection("Redis"));
// Configure Spotify Import settings with custom playlist parsing from env var
builder.Services.Configure<SpotifyImportSettings>(options =>
{
builder.Configuration.GetSection("SpotifyImport").Bind(options);
// Debug: Check what Bind() populated
Console.WriteLine($"DEBUG: After Bind(), Playlists.Count = {options.Playlists.Count}");
#pragma warning disable CS0618 // Type or member is obsolete
Console.WriteLine($"DEBUG: After Bind(), PlaylistIds.Count = {options.PlaylistIds.Count}");
Console.WriteLine($"DEBUG: After Bind(), PlaylistNames.Count = {options.PlaylistNames.Count}");
#pragma warning restore CS0618
// Parse SPOTIFY_IMPORT_PLAYLISTS env var (JSON array format)
// Format: [["Name","SpotifyId","JellyfinId","first|last"],["Name2","SpotifyId2","JellyfinId2","first|last"]]
var playlistsEnv = builder.Configuration.GetValue<string>("SpotifyImport:Playlists");
if (!string.IsNullOrWhiteSpace(playlistsEnv))
{
Console.WriteLine($"Found SPOTIFY_IMPORT_PLAYLISTS env var: {playlistsEnv.Length} chars");
try
{
// Parse as JSON array of arrays
var playlistArrays = System.Text.Json.JsonSerializer.Deserialize<string[][]>(playlistsEnv);
if (playlistArrays != null && playlistArrays.Length > 0)
{
// Clear any playlists that Bind() may have incorrectly populated
options.Playlists.Clear();
Console.WriteLine($"Parsed {playlistArrays.Length} playlists from JSON format");
foreach (var arr in playlistArrays)
{
if (arr.Length >= 2)
{
var config = 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
};
options.Playlists.Add(config);
Console.WriteLine($" Added: {config.Name} (Spotify: {config.Id}, Jellyfin: {config.JellyfinId}, Position: {config.LocalTracksPosition})");
}
}
}
else
{
Console.WriteLine("JSON format was empty or invalid, will try legacy format");
}
}
catch (System.Text.Json.JsonException ex)
{
Console.WriteLine($"Warning: Failed to parse SPOTIFY_IMPORT_PLAYLISTS: {ex.Message}");
Console.WriteLine("Expected format: [[\"Name\",\"SpotifyId\",\"JellyfinId\",\"first|last\"],[\"Name2\",\"SpotifyId2\",\"JellyfinId2\",\"first|last\"]]");
Console.WriteLine("Will try legacy format instead");
}
}
else
{
Console.WriteLine("No SPOTIFY_IMPORT_PLAYLISTS env var found, will try legacy format");
}
// Legacy support: Parse old SPOTIFY_IMPORT_PLAYLIST_IDS/NAMES env vars
// Only used if new Playlists format is not configured
// Check if we have legacy env vars to parse
var playlistIdsEnv = builder.Configuration.GetValue<string>("SpotifyImport:PlaylistIds");
var playlistNamesEnv = builder.Configuration.GetValue<string>("SpotifyImport:PlaylistNames");
var hasLegacyConfig = !string.IsNullOrWhiteSpace(playlistIdsEnv) || !string.IsNullOrWhiteSpace(playlistNamesEnv);
if (hasLegacyConfig && options.Playlists.Count == 0)
{
Console.WriteLine("Parsing legacy Spotify playlist format...");
#pragma warning disable CS0618 // Type or member is obsolete
// Clear any auto-bound values from the Bind() call above
// The auto-binder doesn't handle comma-separated strings correctly
options.PlaylistIds.Clear();
options.PlaylistNames.Clear();
options.PlaylistLocalTracksPositions.Clear();
if (!string.IsNullOrWhiteSpace(playlistIdsEnv))
{
options.PlaylistIds = playlistIdsEnv
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(id => id.Trim())
.Where(id => !string.IsNullOrEmpty(id))
.ToList();
Console.WriteLine($" Parsed {options.PlaylistIds.Count} playlist IDs from env var");
}
if (!string.IsNullOrWhiteSpace(playlistNamesEnv))
{
options.PlaylistNames = playlistNamesEnv
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(name => name.Trim())
.Where(name => !string.IsNullOrEmpty(name))
.ToList();
Console.WriteLine($" Parsed {options.PlaylistNames.Count} playlist names from env var");
}
var playlistPositionsEnv = builder.Configuration.GetValue<string>("SpotifyImport:PlaylistLocalTracksPositions");
if (!string.IsNullOrWhiteSpace(playlistPositionsEnv))
{
options.PlaylistLocalTracksPositions = playlistPositionsEnv
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(pos => pos.Trim())
.Where(pos => !string.IsNullOrEmpty(pos))
.ToList();
Console.WriteLine($" Parsed {options.PlaylistLocalTracksPositions.Count} playlist positions from env var");
}
else
{
Console.WriteLine(" No playlist positions env var found, will use defaults");
}
// Convert legacy format to new Playlists array
Console.WriteLine($" Converting {options.PlaylistIds.Count} playlists to new format...");
for (int i = 0; i < options.PlaylistIds.Count; i++)
{
var name = i < options.PlaylistNames.Count ? options.PlaylistNames[i] : options.PlaylistIds[i];
var position = LocalTracksPosition.First; // Default
// Parse position if provided
if (i < options.PlaylistLocalTracksPositions.Count)
{
var posStr = options.PlaylistLocalTracksPositions[i];
if (posStr.Equals("last", StringComparison.OrdinalIgnoreCase))
{
position = LocalTracksPosition.Last;
}
}
options.Playlists.Add(new SpotifyPlaylistConfig
{
Name = name,
Id = options.PlaylistIds[i],
LocalTracksPosition = position
});
Console.WriteLine($" [{i}] {name} (ID: {options.PlaylistIds[i]}, Position: {position})");
}
#pragma warning restore CS0618
}
else if (hasLegacyConfig && options.Playlists.Count > 0)
{
// Bind() incorrectly populated Playlists from legacy env vars
// Clear it and re-parse properly
Console.WriteLine($"DEBUG: Bind() incorrectly populated {options.Playlists.Count} playlists, clearing and re-parsing...");
options.Playlists.Clear();
#pragma warning disable CS0618 // Type or member is obsolete
options.PlaylistIds.Clear();
options.PlaylistNames.Clear();
options.PlaylistLocalTracksPositions.Clear();
Console.WriteLine("Parsing legacy Spotify playlist format...");
if (!string.IsNullOrWhiteSpace(playlistIdsEnv))
{
options.PlaylistIds = playlistIdsEnv
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(id => id.Trim())
.Where(id => !string.IsNullOrEmpty(id))
.ToList();
Console.WriteLine($" Parsed {options.PlaylistIds.Count} playlist IDs from env var");
}
if (!string.IsNullOrWhiteSpace(playlistNamesEnv))
{
options.PlaylistNames = playlistNamesEnv
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(name => name.Trim())
.Where(name => !string.IsNullOrEmpty(name))
.ToList();
Console.WriteLine($" Parsed {options.PlaylistNames.Count} playlist names from env var");
}
var playlistPositionsEnv = builder.Configuration.GetValue<string>("SpotifyImport:PlaylistLocalTracksPositions");
if (!string.IsNullOrWhiteSpace(playlistPositionsEnv))
{
options.PlaylistLocalTracksPositions = playlistPositionsEnv
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(pos => pos.Trim())
.Where(pos => !string.IsNullOrEmpty(pos))
.ToList();
Console.WriteLine($" Parsed {options.PlaylistLocalTracksPositions.Count} playlist positions from env var");
}
else
{
Console.WriteLine(" No playlist positions env var found, will use defaults");
}
// Convert legacy format to new Playlists array
Console.WriteLine($" Converting {options.PlaylistIds.Count} playlists to new format...");
for (int i = 0; i < options.PlaylistIds.Count; i++)
{
var name = i < options.PlaylistNames.Count ? options.PlaylistNames[i] : options.PlaylistIds[i];
var position = LocalTracksPosition.First; // Default
// Parse position if provided
if (i < options.PlaylistLocalTracksPositions.Count)
{
var posStr = options.PlaylistLocalTracksPositions[i];
if (posStr.Equals("last", StringComparison.OrdinalIgnoreCase))
{
position = LocalTracksPosition.Last;
}
}
options.Playlists.Add(new SpotifyPlaylistConfig
{
Name = name,
Id = options.PlaylistIds[i],
LocalTracksPosition = position
});
Console.WriteLine($" [{i}] {name} (ID: {options.PlaylistIds[i]}, Position: {position})");
}
#pragma warning restore CS0618
}
else
{
Console.WriteLine($"Using new Playlists format: {options.Playlists.Count} playlists configured");
}
// Log configuration at startup
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");
foreach (var playlist in options.Playlists)
{
Console.WriteLine($" - {playlist.Name} (ID: {playlist.Id}, LocalTracks: {playlist.LocalTracksPosition})");
}
});
// Get shared settings from the active backend config // Get shared settings from the active backend config
MusicService musicService; MusicService musicService;
@@ -137,7 +385,9 @@ if (backendType == BackendType.Jellyfin)
builder.Services.AddSingleton<JellyfinResponseBuilder>(); builder.Services.AddSingleton<JellyfinResponseBuilder>();
builder.Services.AddSingleton<JellyfinModelMapper>(); builder.Services.AddSingleton<JellyfinModelMapper>();
builder.Services.AddScoped<JellyfinProxyService>(); builder.Services.AddScoped<JellyfinProxyService>();
builder.Services.AddSingleton<JellyfinSessionManager>();
builder.Services.AddScoped<JellyfinAuthFilter>(); builder.Services.AddScoped<JellyfinAuthFilter>();
builder.Services.AddScoped<allstarr.Filters.ApiKeyAuthFilter>();
} }
else else
{ {
@@ -229,6 +479,80 @@ builder.Services.AddHostedService<StartupValidationOrchestrator>();
// Register cache cleanup service (only runs when StorageMode is Cache) // Register cache cleanup service (only runs when StorageMode is Cache)
builder.Services.AddHostedService<CacheCleanupService>(); builder.Services.AddHostedService<CacheCleanupService>();
// Register Spotify API client, lyrics service, and settings for direct API access
// Configure from environment variables with SPOTIFY_API_ prefix
builder.Services.Configure<allstarr.Models.Settings.SpotifyApiSettings>(options =>
{
builder.Configuration.GetSection("SpotifyApi").Bind(options);
// Override from environment variables
var enabled = builder.Configuration.GetValue<string>("SpotifyApi:Enabled");
if (!string.IsNullOrEmpty(enabled))
{
options.Enabled = enabled.Equals("true", StringComparison.OrdinalIgnoreCase);
}
var clientId = builder.Configuration.GetValue<string>("SpotifyApi:ClientId");
if (!string.IsNullOrEmpty(clientId))
{
options.ClientId = clientId;
}
var clientSecret = builder.Configuration.GetValue<string>("SpotifyApi:ClientSecret");
if (!string.IsNullOrEmpty(clientSecret))
{
options.ClientSecret = clientSecret;
}
var sessionCookie = builder.Configuration.GetValue<string>("SpotifyApi:SessionCookie");
if (!string.IsNullOrEmpty(sessionCookie))
{
options.SessionCookie = sessionCookie;
}
var sessionCookieSetDate = builder.Configuration.GetValue<string>("SpotifyApi:SessionCookieSetDate");
if (!string.IsNullOrEmpty(sessionCookieSetDate))
{
options.SessionCookieSetDate = sessionCookieSetDate;
}
var cacheDuration = builder.Configuration.GetValue<int?>("SpotifyApi:CacheDurationMinutes");
if (cacheDuration.HasValue)
{
options.CacheDurationMinutes = cacheDuration.Value;
}
var preferIsrc = builder.Configuration.GetValue<string>("SpotifyApi:PreferIsrcMatching");
if (!string.IsNullOrEmpty(preferIsrc))
{
options.PreferIsrcMatching = preferIsrc.Equals("true", StringComparison.OrdinalIgnoreCase);
}
// Log configuration (mask sensitive values)
Console.WriteLine($"SpotifyApi Configuration:");
Console.WriteLine($" Enabled: {options.Enabled}");
Console.WriteLine($" ClientId: {(string.IsNullOrEmpty(options.ClientId) ? "(not set)" : options.ClientId[..8] + "...")}");
Console.WriteLine($" SessionCookie: {(string.IsNullOrEmpty(options.SessionCookie) ? "(not set)" : "***" + options.SessionCookie[^8..])}");
Console.WriteLine($" SessionCookieSetDate: {options.SessionCookieSetDate ?? "(not set)"}");
Console.WriteLine($" CacheDurationMinutes: {options.CacheDurationMinutes}");
Console.WriteLine($" PreferIsrcMatching: {options.PreferIsrcMatching}");
});
builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyApiClient>();
// Register Spotify lyrics service (uses Spotify's color-lyrics API)
builder.Services.AddSingleton<allstarr.Services.Lyrics.SpotifyLyricsService>();
// Register Spotify playlist fetcher (uses direct Spotify API when SpotifyApi is enabled)
builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyPlaylistFetcher>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<allstarr.Services.Spotify.SpotifyPlaylistFetcher>());
// Register Spotify missing tracks fetcher (legacy - only runs when SpotifyImport is enabled and SpotifyApi is disabled)
builder.Services.AddHostedService<allstarr.Services.Spotify.SpotifyMissingTracksFetcher>();
// Register Spotify track matching service (pre-matches tracks with rate limiting)
builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyTrackMatchingService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<allstarr.Services.Spotify.SpotifyTrackMatchingService>());
builder.Services.AddCors(options => builder.Services.AddCors(options =>
{ {
options.AddDefaultPolicy(policy => options.AddDefaultPolicy(policy =>
@@ -248,6 +572,15 @@ app.UseExceptionHandler(_ => { }); // Global exception handler
// Enable response compression EARLY in the pipeline // Enable response compression EARLY in the pipeline
app.UseResponseCompression(); app.UseResponseCompression();
// Enable WebSocket support
app.UseWebSockets(new WebSocketOptions
{
KeepAliveInterval = TimeSpan.FromSeconds(120)
});
// Add WebSocket proxy middleware (BEFORE routing)
app.UseMiddleware<WebSocketProxyMiddleware>();
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ {
app.UseSwagger(); app.UseSwagger();
@@ -256,6 +589,9 @@ if (app.Environment.IsDevelopment())
app.UseHttpsRedirection(); app.UseHttpsRedirection();
// Serve static files only on admin port (5275)
app.UseMiddleware<allstarr.Middleware.AdminStaticFilesMiddleware>();
app.UseAuthorization(); app.UseAuthorization();
app.UseCors(); app.UseCors();
@@ -285,6 +621,9 @@ class BackendControllerFeatureProvider : Microsoft.AspNetCore.Mvc.Controllers.Co
var isController = base.IsController(typeInfo); var isController = base.IsController(typeInfo);
if (!isController) return false; if (!isController) return false;
// AdminController should always be registered (for web UI)
if (typeInfo.Name == "AdminController") return true;
// Only register the controller matching the configured backend type // Only register the controller matching the configured backend type
return _backendType switch return _backendType switch
{ {

View File

@@ -186,6 +186,14 @@ public class JellyfinModelMapper
// Cover art URL construction // Cover art URL construction
song.CoverArtUrl = $"/Items/{id}/Images/Primary"; song.CoverArtUrl = $"/Items/{id}/Images/Primary";
// Preserve Jellyfin metadata (MediaSources, etc.) for local tracks
// This ensures bitrate and other technical details are maintained
song.JellyfinMetadata = new Dictionary<string, object?>();
if (item.TryGetProperty("MediaSources", out var mediaSources))
{
song.JellyfinMetadata["MediaSources"] = JsonSerializer.Deserialize<object>(mediaSources.GetRawText());
}
return song; return song;
} }

View File

@@ -103,8 +103,9 @@ public class JellyfinProxyService
/// <summary> /// <summary>
/// Sends a GET request to the Jellyfin server. /// Sends a GET request to the Jellyfin server.
/// If endpoint already contains query parameters, they will be preserved and merged with queryParams. /// If endpoint already contains query parameters, they will be preserved and merged with queryParams.
/// Returns the response body and HTTP status code.
/// </summary> /// </summary>
public async Task<JsonDocument?> GetJsonAsync(string endpoint, Dictionary<string, string>? queryParams = null, IHeaderDictionary? clientHeaders = null) public async Task<(JsonDocument? Body, int StatusCode)> GetJsonAsync(string endpoint, Dictionary<string, string>? queryParams = null, IHeaderDictionary? clientHeaders = null)
{ {
// If endpoint contains query string, parse and merge with queryParams // If endpoint contains query string, parse and merge with queryParams
if (endpoint.Contains('?')) if (endpoint.Contains('?'))
@@ -141,12 +142,32 @@ public class JellyfinProxyService
return await GetJsonAsyncInternal(finalUrl, clientHeaders); return await GetJsonAsyncInternal(finalUrl, clientHeaders);
} }
private async Task<JsonDocument?> GetJsonAsyncInternal(string url, IHeaderDictionary? clientHeaders) private async Task<(JsonDocument? Body, int StatusCode)> GetJsonAsyncInternal(string url, IHeaderDictionary? clientHeaders)
{ {
using var request = new HttpRequestMessage(HttpMethod.Get, url); using var request = new HttpRequestMessage(HttpMethod.Get, url);
// Forward client IP address to Jellyfin so it can identify the real client
if (_httpContextAccessor.HttpContext != null)
{
var clientIp = _httpContextAccessor.HttpContext.Connection.RemoteIpAddress?.ToString();
if (!string.IsNullOrEmpty(clientIp))
{
request.Headers.TryAddWithoutValidation("X-Forwarded-For", clientIp);
request.Headers.TryAddWithoutValidation("X-Real-IP", clientIp);
}
}
bool authHeaderAdded = false; bool authHeaderAdded = false;
// Check if this is a browser request for static assets (favicon, etc.)
bool isBrowserStaticRequest = url.Contains("/favicon.ico", StringComparison.OrdinalIgnoreCase) ||
url.Contains("/web/", StringComparison.OrdinalIgnoreCase) ||
(clientHeaders?.Any(h => h.Key.Equals("User-Agent", StringComparison.OrdinalIgnoreCase) &&
h.Value.ToString().Contains("Mozilla", StringComparison.OrdinalIgnoreCase)) == true &&
clientHeaders?.Any(h => h.Key.Equals("sec-fetch-dest", StringComparison.OrdinalIgnoreCase) &&
(h.Value.ToString().Contains("image", StringComparison.OrdinalIgnoreCase) ||
h.Value.ToString().Contains("document", StringComparison.OrdinalIgnoreCase))) == true);
// 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)
{ {
@@ -194,35 +215,31 @@ public class JellyfinProxyService
} }
} }
if (!authHeaderAdded) // Only log warnings for non-browser static requests
if (!authHeaderAdded && !isBrowserStaticRequest)
{ {
_logger.LogWarning("✗ No auth header found. Available headers: {Headers}", _logger.LogWarning("✗ No auth header found. Available headers: {Headers}",
string.Join(", ", clientHeaders.Select(h => $"{h.Key}={h.Value}"))); string.Join(", ", clientHeaders.Select(h => $"{h.Key}={h.Value}")));
} }
} }
else else if (!isBrowserStaticRequest)
{ {
_logger.LogWarning("✗ No client headers provided for {Url}", url); _logger.LogWarning("✗ No client headers provided for {Url}", url);
} }
// Use API key if no valid client auth was found // DO NOT use server API key as fallback - let Jellyfin handle unauthenticated requests
if (!authHeaderAdded) // If client doesn't provide auth, they get what they deserve (401 from Jellyfin)
if (!authHeaderAdded && !isBrowserStaticRequest)
{ {
if (!string.IsNullOrEmpty(_settings.ApiKey)) _logger.LogInformation("No client auth provided for {Url} - forwarding without auth", url);
{
request.Headers.Add("Authorization", GetAuthorizationHeader());
_logger.LogInformation("→ Using API key for {Url}", url);
}
else
{
_logger.LogWarning("✗ No authentication available for {Url} - request will fail", url);
}
} }
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var response = await _httpClient.SendAsync(request); var response = await _httpClient.SendAsync(request);
var statusCode = (int)response.StatusCode;
// Always parse the response, even for errors // Always parse the response, even for errors
// The caller needs to see 401s so the client can re-authenticate // The caller needs to see 401s so the client can re-authenticate
var content = await response.Content.ReadAsStringAsync(); var content = await response.Content.ReadAsStringAsync();
@@ -233,56 +250,45 @@ public class JellyfinProxyService
{ {
_logger.LogWarning("Jellyfin returned 401 Unauthorized for {Url} - passing through to client", url); _logger.LogWarning("Jellyfin returned 401 Unauthorized for {Url} - passing through to client", url);
} }
else 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);
} }
// Return null so caller knows request failed // Return null body with the actual status code
// TODO: We should return the status code too so caller can pass it through return (null, statusCode);
return null;
} }
return JsonDocument.Parse(content); return (JsonDocument.Parse(content), statusCode);
} }
/// <summary> /// <summary>
/// Sends a POST request to the Jellyfin server with JSON body. /// Sends a POST request to the Jellyfin server with JSON body.
/// Forwards client headers for authentication passthrough. /// Forwards client headers for authentication passthrough.
/// Returns the response body and HTTP status code.
/// </summary> /// </summary>
public async Task<JsonDocument?> PostJsonAsync(string endpoint, string body, IHeaderDictionary clientHeaders) public async Task<(JsonDocument? Body, int StatusCode)> PostJsonAsync(string endpoint, string body, IHeaderDictionary clientHeaders)
{ {
var url = BuildUrl(endpoint, null); var url = BuildUrl(endpoint, null);
using var request = new HttpRequestMessage(HttpMethod.Post, url); using var request = new HttpRequestMessage(HttpMethod.Post, url);
// Handle special case for playback endpoints - Jellyfin expects wrapped body // Forward client IP address to Jellyfin so it can identify the real client
var bodyToSend = body; if (_httpContextAccessor.HttpContext != null)
if (!string.IsNullOrWhiteSpace(body))
{ {
// Check if this is a playback progress endpoint var clientIp = _httpContextAccessor.HttpContext.Connection.RemoteIpAddress?.ToString();
if (endpoint.Contains("Sessions/Playing/Progress", StringComparison.OrdinalIgnoreCase)) if (!string.IsNullOrEmpty(clientIp))
{ {
// Wrap the body in playbackProgressInfo field request.Headers.TryAddWithoutValidation("X-Forwarded-For", clientIp);
bodyToSend = $"{{\"playbackProgressInfo\":{body}}}"; request.Headers.TryAddWithoutValidation("X-Real-IP", clientIp);
_logger.LogDebug("Wrapped body for playback progress endpoint");
}
else if (endpoint.Contains("Sessions/Playing/Stopped", StringComparison.OrdinalIgnoreCase))
{
// Wrap the body in playbackStopInfo field
bodyToSend = $"{{\"playbackStopInfo\":{body}}}";
_logger.LogDebug("Wrapped body for playback stopped endpoint");
}
else if (endpoint.Contains("Sessions/Playing", StringComparison.OrdinalIgnoreCase) &&
!endpoint.Contains("Progress", StringComparison.OrdinalIgnoreCase) &&
!endpoint.Contains("Stopped", StringComparison.OrdinalIgnoreCase))
{
// Wrap the body in playbackStartInfo field for /Sessions/Playing
bodyToSend = $"{{\"playbackStartInfo\":{body}}}";
_logger.LogDebug("Wrapped body for playback start endpoint");
} }
} }
else
// Handle special case for playback endpoints
// NOTE: Jellyfin API expects PlaybackStartInfo/PlaybackProgressInfo/PlaybackStopInfo
// DIRECTLY as the body, NOT wrapped in a field. Do NOT wrap the body.
var bodyToSend = body;
if (string.IsNullOrWhiteSpace(body))
{ {
bodyToSend = "{}"; bodyToSend = "{}";
_logger.LogWarning("POST body was empty for {Url}, sending empty JSON object", url); _logger.LogWarning("POST body was empty for {Url}, sending empty JSON object", url);
@@ -297,8 +303,10 @@ public class JellyfinProxyService
{ {
if (header.Key.Equals("X-Emby-Authorization", StringComparison.OrdinalIgnoreCase)) if (header.Key.Equals("X-Emby-Authorization", StringComparison.OrdinalIgnoreCase))
{ {
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", header.Value.ToString()); var headerValue = header.Value.ToString();
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
authHeaderAdded = true; authHeaderAdded = true;
_logger.LogDebug("Forwarded X-Emby-Authorization from client");
break; break;
} }
} }
@@ -309,21 +317,34 @@ public class JellyfinProxyService
{ {
if (header.Key.Equals("Authorization", StringComparison.OrdinalIgnoreCase)) if (header.Key.Equals("Authorization", StringComparison.OrdinalIgnoreCase))
{ {
request.Headers.TryAddWithoutValidation("Authorization", header.Value.ToString()); var headerValue = header.Value.ToString();
// Check if it's MediaBrowser/Jellyfin format
if (headerValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase) ||
headerValue.Contains("Client=", StringComparison.OrdinalIgnoreCase))
{
// Forward as X-Emby-Authorization
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
_logger.LogDebug("Converted Authorization to X-Emby-Authorization");
}
else
{
// Standard Bearer token
request.Headers.TryAddWithoutValidation("Authorization", headerValue);
_logger.LogDebug("Forwarded Authorization header");
}
authHeaderAdded = true; authHeaderAdded = true;
break; break;
} }
} }
} }
// For login requests without auth headers, provide a minimal client auth header // DO NOT use server credentials as fallback
// Exception: For auth endpoints, client provides their own credentials in the body
// For all other endpoints, if client doesn't provide auth, let Jellyfin reject it
if (!authHeaderAdded) if (!authHeaderAdded)
{ {
var clientAuthHeader = $"MediaBrowser Client=\"{_settings.ClientName}\", " + _logger.LogInformation("No client auth provided for POST {Url} - forwarding without auth", url);
$"Device=\"{_settings.DeviceName}\", " +
$"DeviceId=\"{_settings.DeviceId}\", " +
$"Version=\"{_settings.ClientVersion}\"";
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", clientAuthHeader);
} }
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
@@ -346,18 +367,26 @@ public class JellyfinProxyService
var response = await _httpClient.SendAsync(request); var response = await _httpClient.SendAsync(request);
var statusCode = (int)response.StatusCode;
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
var errorContent = await response.Content.ReadAsStringAsync(); var errorContent = await response.Content.ReadAsStringAsync();
_logger.LogWarning("Jellyfin POST request failed: {StatusCode} for {Url}. Response: {Response}", _logger.LogWarning("❌ SESSION: Jellyfin POST request failed: {StatusCode} for {Url}. Response: {Response}",
response.StatusCode, url, errorContent); response.StatusCode, url, errorContent);
return null; return (null, statusCode);
}
// Log successful session-related responses
if (endpoint.Contains("Sessions", StringComparison.OrdinalIgnoreCase))
{
_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)
if (response.StatusCode == System.Net.HttpStatusCode.NoContent) if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
{ {
return null; return (null, statusCode);
} }
var responseContent = await response.Content.ReadAsStringAsync(); var responseContent = await response.Content.ReadAsStringAsync();
@@ -365,10 +394,17 @@ public class JellyfinProxyService
// Handle empty responses // Handle empty responses
if (string.IsNullOrWhiteSpace(responseContent)) if (string.IsNullOrWhiteSpace(responseContent))
{ {
return null; return (null, statusCode);
} }
return JsonDocument.Parse(responseContent); // 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);
} }
/// <summary> /// <summary>
@@ -390,6 +426,109 @@ public class JellyfinProxyService
return (body, contentType); return (body, contentType);
} }
/// <summary>
/// Sends a DELETE request to the Jellyfin server.
/// Forwards client headers for authentication passthrough.
/// Returns the response body and HTTP status code.
/// </summary>
public async Task<(JsonDocument? Body, int StatusCode)> DeleteAsync(string endpoint, IHeaderDictionary clientHeaders)
{
var url = BuildUrl(endpoint, null);
using var request = new HttpRequestMessage(HttpMethod.Delete, url);
// Forward client IP address to Jellyfin so it can identify the real client
if (_httpContextAccessor.HttpContext != null)
{
var clientIp = _httpContextAccessor.HttpContext.Connection.RemoteIpAddress?.ToString();
if (!string.IsNullOrEmpty(clientIp))
{
request.Headers.TryAddWithoutValidation("X-Forwarded-For", clientIp);
request.Headers.TryAddWithoutValidation("X-Real-IP", clientIp);
}
}
bool authHeaderAdded = false;
// Forward authentication headers from client (case-insensitive)
foreach (var header in clientHeaders)
{
if (header.Key.Equals("X-Emby-Authorization", StringComparison.OrdinalIgnoreCase))
{
var headerValue = header.Value.ToString();
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
authHeaderAdded = true;
_logger.LogDebug("Forwarded X-Emby-Authorization from client");
break;
}
}
if (!authHeaderAdded)
{
foreach (var header in clientHeaders)
{
if (header.Key.Equals("Authorization", StringComparison.OrdinalIgnoreCase))
{
var headerValue = header.Value.ToString();
// Check if it's MediaBrowser/Jellyfin format
if (headerValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase) ||
headerValue.Contains("Client=", StringComparison.OrdinalIgnoreCase))
{
// Forward as X-Emby-Authorization
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
_logger.LogDebug("Converted Authorization to X-Emby-Authorization");
}
else
{
// Standard Bearer token
request.Headers.TryAddWithoutValidation("Authorization", headerValue);
_logger.LogDebug("Forwarded Authorization header");
}
authHeaderAdded = true;
break;
}
}
}
if (!authHeaderAdded)
{
_logger.LogInformation("No client auth provided for DELETE {Url} - forwarding without auth", url);
}
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
_logger.LogInformation("DELETE to Jellyfin: {Url}", url);
var response = await _httpClient.SendAsync(request);
var statusCode = (int)response.StatusCode;
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
_logger.LogWarning("Jellyfin DELETE request failed: {StatusCode} for {Url}. Response: {Response}",
response.StatusCode, url, errorContent);
return (null, statusCode);
}
// Handle 204 No Content responses
if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
{
return (null, statusCode);
}
var responseContent = await response.Content.ReadAsStringAsync();
// Handle empty responses
if (string.IsNullOrWhiteSpace(responseContent))
{
return (null, statusCode);
}
return (JsonDocument.Parse(responseContent), statusCode);
}
/// <summary> /// <summary>
/// Safely sends a GET request to the Jellyfin server, returning null on failure. /// Safely sends a GET request to the Jellyfin server, returning null on failure.
/// </summary> /// </summary>
@@ -413,7 +552,7 @@ public class JellyfinProxyService
/// Searches for items in Jellyfin. /// Searches for items in Jellyfin.
/// Uses configured or auto-detected LibraryId to filter search to music library only. /// Uses configured or auto-detected LibraryId to filter search to music library only.
/// </summary> /// </summary>
public async Task<JsonDocument?> SearchAsync( public async Task<(JsonDocument? Body, int StatusCode)> SearchAsync(
string searchTerm, string searchTerm,
string[]? includeItemTypes = null, string[]? includeItemTypes = null,
int limit = 20, int limit = 20,
@@ -451,7 +590,7 @@ public class JellyfinProxyService
/// <summary> /// <summary>
/// Gets items from a specific parent (album, artist, playlist). /// Gets items from a specific parent (album, artist, playlist).
/// </summary> /// </summary>
public async Task<JsonDocument?> GetItemsAsync( public async Task<(JsonDocument? Body, int StatusCode)> GetItemsAsync(
string? parentId = null, string? parentId = null,
string[]? includeItemTypes = null, string[]? includeItemTypes = null,
string? sortBy = null, string? sortBy = null,
@@ -507,7 +646,7 @@ public class JellyfinProxyService
/// <summary> /// <summary>
/// Gets a single item by ID. /// Gets a single item by ID.
/// </summary> /// </summary>
public async Task<JsonDocument?> GetItemAsync(string itemId, IHeaderDictionary? clientHeaders = null) public async Task<(JsonDocument? Body, int StatusCode)> GetItemAsync(string itemId, IHeaderDictionary? clientHeaders = null)
{ {
var queryParams = new Dictionary<string, string>(); var queryParams = new Dictionary<string, string>();
@@ -522,7 +661,7 @@ public class JellyfinProxyService
/// <summary> /// <summary>
/// Gets artists from the library. /// Gets artists from the library.
/// </summary> /// </summary>
public async Task<JsonDocument?> GetArtistsAsync( public async Task<(JsonDocument? Body, int StatusCode)> GetArtistsAsync(
string? searchTerm = null, string? searchTerm = null,
int? limit = null, int? limit = null,
int? startIndex = null, int? startIndex = null,
@@ -559,7 +698,7 @@ public class JellyfinProxyService
/// <summary> /// <summary>
/// Gets an artist by name or ID. /// Gets an artist by name or ID.
/// </summary> /// </summary>
public async Task<JsonDocument?> GetArtistAsync(string artistIdOrName, IHeaderDictionary? clientHeaders = null) public async Task<(JsonDocument? Body, int StatusCode)> GetArtistAsync(string artistIdOrName, IHeaderDictionary? clientHeaders = null)
{ {
var queryParams = new Dictionary<string, string>(); var queryParams = new Dictionary<string, string>();
@@ -720,8 +859,8 @@ public class JellyfinProxyService
{ {
try try
{ {
var result = await GetJsonAsync("System/Info/Public"); var (result, statusCode) = await GetJsonAsync("System/Info/Public");
if (result == null) if (result == null || statusCode != 200)
{ {
return (false, null, null); return (false, null, null);
} }
@@ -755,7 +894,7 @@ public class JellyfinProxyService
queryParams["userId"] = _settings.UserId; queryParams["userId"] = _settings.UserId;
} }
var result = await GetJsonAsync("Library/MediaFolders", queryParams); var (result, statusCode) = await GetJsonAsync("Library/MediaFolders", queryParams);
if (result == null) if (result == null)
{ {
return null; return null;

View File

@@ -186,7 +186,7 @@ public class JellyfinResponseBuilder
["Type"] = "Audio", ["Type"] = "Audio",
["Album"] = song.Album, ["Album"] = song.Album,
["AlbumArtist"] = song.Artist, ["AlbumArtist"] = song.Artist,
["Artists"] = new[] { song.Artist }, ["Artists"] = song.Artists.Count > 0 ? song.Artists.ToArray() : new[] { song.Artist },
["RunTimeTicks"] = (song.Duration ?? 0) * TimeSpan.TicksPerSecond, ["RunTimeTicks"] = (song.Duration ?? 0) * TimeSpan.TicksPerSecond,
["ImageTags"] = new Dictionary<string, string> ["ImageTags"] = new Dictionary<string, string>
{ {
@@ -231,10 +231,17 @@ public class JellyfinResponseBuilder
/// </summary> /// </summary>
public Dictionary<string, object?> ConvertSongToJellyfinItem(Song song) public Dictionary<string, object?> ConvertSongToJellyfinItem(Song song)
{ {
// Add " [S]" suffix to external song titles (S = streaming source)
var songTitle = song.Title;
if (!song.IsLocal)
{
songTitle = $"{song.Title} [S]";
}
var item = new Dictionary<string, object?> var item = new Dictionary<string, object?>
{ {
["Id"] = song.Id, ["Id"] = song.Id,
["Name"] = song.Title, ["Name"] = songTitle,
["ServerId"] = "allstarr", ["ServerId"] = "allstarr",
["Type"] = "Audio", ["Type"] = "Audio",
["MediaType"] = "Audio", ["MediaType"] = "Audio",
@@ -242,15 +249,23 @@ public class JellyfinResponseBuilder
["Album"] = song.Album, ["Album"] = song.Album,
["AlbumId"] = song.AlbumId ?? song.Id, ["AlbumId"] = song.AlbumId ?? song.Id,
["AlbumArtist"] = song.AlbumArtist ?? song.Artist, ["AlbumArtist"] = song.AlbumArtist ?? song.Artist,
["Artists"] = new[] { song.Artist }, ["Artists"] = song.Artists.Count > 0 ? song.Artists.ToArray() : new[] { song.Artist },
["ArtistItems"] = new[] ["ArtistItems"] = song.Artists.Count > 0
{ ? song.Artists.Select((name, index) => new Dictionary<string, object?>
new Dictionary<string, object?>
{ {
["Id"] = song.ArtistId ?? song.Id, ["Name"] = name,
["Name"] = song.Artist ["Id"] = index == 0 && song.ArtistId != null
} ? song.ArtistId
}, : $"{song.Id}-artist-{index}"
}).ToArray()
: new[]
{
new Dictionary<string, object?>
{
["Id"] = song.ArtistId ?? song.Id,
["Name"] = song.Artist
}
},
["IndexNumber"] = song.Track, ["IndexNumber"] = song.Track,
["ParentIndexNumber"] = song.DiscNumber ?? 1, ["ParentIndexNumber"] = song.DiscNumber ?? 1,
["ProductionYear"] = song.Year, ["ProductionYear"] = song.Year,
@@ -289,6 +304,29 @@ public class JellyfinResponseBuilder
var providerIds = (Dictionary<string, string>)item["ProviderIds"]!; var providerIds = (Dictionary<string, string>)item["ProviderIds"]!;
providerIds["ISRC"] = song.Isrc; providerIds["ISRC"] = song.Isrc;
} }
// Add MediaSources with bitrate for external tracks
item["MediaSources"] = new[]
{
new Dictionary<string, object?>
{
["Id"] = song.Id,
["Type"] = "Default",
["Container"] = "flac",
["Size"] = (song.Duration ?? 180) * 1337 * 128, // Approximate file size
["Bitrate"] = 1337000, // 1337 kbps in bps
["Path"] = $"/music/{song.Artist}/{song.Album}/{song.Title}.flac",
["Protocol"] = "File",
["SupportsDirectStream"] = true,
["SupportsTranscoding"] = true,
["SupportsDirectPlay"] = true
}
};
}
else if (song.IsLocal && song.JellyfinMetadata != null && song.JellyfinMetadata.ContainsKey("MediaSources"))
{
// Use preserved Jellyfin metadata for local tracks to maintain bitrate info
item["MediaSources"] = song.JellyfinMetadata["MediaSources"];
} }
if (!string.IsNullOrEmpty(song.Genre)) if (!string.IsNullOrEmpty(song.Genre))
@@ -304,11 +342,11 @@ public class JellyfinResponseBuilder
/// </summary> /// </summary>
public Dictionary<string, object?> ConvertAlbumToJellyfinItem(Album album) public Dictionary<string, object?> ConvertAlbumToJellyfinItem(Album album)
{ {
// Add " - SW" suffix to external album names // Add " [S]" suffix to external album names (S = streaming source)
var albumName = album.Title; var albumName = album.Title;
if (!album.IsLocal) if (!album.IsLocal)
{ {
albumName = $"{album.Title} - SW"; albumName = $"{album.Title} [S]";
} }
var item = new Dictionary<string, object?> var item = new Dictionary<string, object?>
@@ -371,11 +409,11 @@ public class JellyfinResponseBuilder
/// </summary> /// </summary>
public Dictionary<string, object?> ConvertArtistToJellyfinItem(Artist artist) public Dictionary<string, object?> ConvertArtistToJellyfinItem(Artist artist)
{ {
// Add " - SW" suffix to external artist names // Add " [S]" suffix to external artist names (S = streaming source)
var artistName = artist.Name; var artistName = artist.Name;
if (!artist.IsLocal) if (!artist.IsLocal)
{ {
artistName = $"{artist.Name} - SW"; artistName = $"{artist.Name} [S]";
} }
var item = new Dictionary<string, object?> var item = new Dictionary<string, object?>

View File

@@ -0,0 +1,462 @@
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.
/// </summary>
public async Task<bool> EnsureSessionAsync(string deviceId, string client, string device, string version, IHeaderDictionary headers)
{
if (string.IsNullOrEmpty(deviceId))
{
_logger.LogWarning("⚠️ SESSION: 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.LogDebug("✓ SESSION: Session already exists for device {DeviceId}", deviceId);
// Refresh capabilities to keep session alive
await PostCapabilitiesAsync(headers);
return true;
}
_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
{
// Post session capabilities to Jellyfin - this creates the session
await PostCapabilitiesAsync(headers);
_logger.LogInformation("✓ SESSION: Session created for {DeviceId}", deviceId);
// Track this session
_sessions[deviceId] = new SessionInfo
{
DeviceId = deviceId,
Client = client,
Device = device,
Version = version,
LastActivity = DateTime.UtcNow,
Headers = CloneHeaders(headers)
};
// Start a WebSocket connection to Jellyfin on behalf of this client
_ = Task.Run(() => MaintainWebSocketForSessionAsync(deviceId, headers));
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "❌ SESSION: Error creating session for {DeviceId}", deviceId);
return false;
}
}
/// <summary>
/// Posts session capabilities to Jellyfin.
/// </summary>
private async Task 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.LogDebug("✓ SESSION: Posted capabilities successfully ({StatusCode})", statusCode);
}
else
{
// 401 is common when cached headers have expired - not a critical error
_logger.LogDebug("SESSION: Capabilities post returned {StatusCode} (may be expected if token expired)", statusCode);
}
}
/// <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>
/// Removes a session when the client disconnects.
/// </summary>
public async Task RemoveSessionAsync(string deviceId)
{
if (_sessions.TryRemove(deviceId, out var session))
{
_logger.LogInformation("🗑️ 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.LogWarning(ex, "⚠️ WEBSOCKET: Error closing WebSocket for {DeviceId}", deviceId);
}
finally
{
session.WebSocket?.Dispose();
}
}
try
{
// Optionally notify Jellyfin that the session is ending
// (Jellyfin will auto-cleanup inactive sessions anyway)
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;
// Log available headers for debugging
_logger.LogDebug("🔍 WEBSOCKET: Available headers for {DeviceId}: {Headers}",
deviceId, string.Join(", ", headers.Keys));
// Forward authentication headers from the CLIENT - this is critical for session to appear under the right user
bool authFound = false;
if (headers.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 (headers.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.LogWarning("⚠️ 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}!", 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.LogInformation("✓ 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 info level
_logger.LogInformation("📥 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.LogWarning(wsEx, "⚠️ WEBSOCKET: WebSocket error 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.
/// </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.LogDebug("💓 SESSION: Keeping {Count} sessions alive", activeSessions.Count);
foreach (var session in activeSessions)
{
try
{
// Post capabilities again to keep session alive
// Note: This may fail with 401 if the client's token has expired
// That's okay - the WebSocket connection keeps the session alive anyway
await PostCapabilitiesAsync(session.Headers);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "SESSION: Error keeping session alive for {DeviceId} (WebSocket still active)", session.DeviceId);
}
}
// Clean up stale sessions (inactive for > 10 minutes)
var staleSessions = _sessions.Where(kvp => now - kvp.Value.LastActivity > TimeSpan.FromMinutes(10)).ToList();
foreach (var stale in staleSessions)
{
_logger.LogInformation("🧹 SESSION: Removing stale session for {DeviceId}", stale.Key);
_sessions.TryRemove(stale.Key, out _);
}
}
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 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();
}
}
}
}
}

View File

@@ -25,6 +25,12 @@ public class LrclibService
public async Task<LyricsInfo?> GetLyricsAsync(string trackName, string artistName, string albumName, int durationSeconds) public async Task<LyricsInfo?> GetLyricsAsync(string trackName, string artistName, string albumName, int durationSeconds)
{ {
return await GetLyricsAsync(trackName, new[] { artistName }, albumName, durationSeconds);
}
public async Task<LyricsInfo?> GetLyricsAsync(string trackName, string[] artistNames, string albumName, int durationSeconds)
{
var artistName = string.Join(", ", artistNames);
var cacheKey = $"lyrics:{artistName}:{trackName}:{albumName}:{durationSeconds}"; var cacheKey = $"lyrics:{artistName}:{trackName}:{albumName}:{durationSeconds}";
var cached = await _cache.GetStringAsync(cacheKey); var cached = await _cache.GetStringAsync(cacheKey);
@@ -42,25 +48,112 @@ public class LrclibService
try try
{ {
var url = $"{BaseUrl}/get?" + // Try searching with all artists joined (space-separated for better matching)
$"track_name={Uri.EscapeDataString(trackName)}&" + var searchArtistName = string.Join(" ", artistNames);
$"artist_name={Uri.EscapeDataString(artistName)}&" +
$"album_name={Uri.EscapeDataString(albumName)}&" +
$"duration={durationSeconds}";
_logger.LogDebug("Fetching lyrics from LRCLIB: {Url}", url);
var response = await _httpClient.GetAsync(url);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound) // First try search API for fuzzy matching (more forgiving)
var searchUrl = $"{BaseUrl}/search?" +
$"track_name={Uri.EscapeDataString(trackName)}&" +
$"artist_name={Uri.EscapeDataString(searchArtistName)}";
_logger.LogInformation("Searching LRCLIB: {Url} (expecting {ArtistCount} artists)", searchUrl, artistNames.Length);
var searchResponse = await _httpClient.GetAsync(searchUrl);
if (searchResponse.IsSuccessStatusCode)
{
var searchJson = await searchResponse.Content.ReadAsStringAsync();
var searchResults = JsonSerializer.Deserialize<List<LrclibResponse>>(searchJson, JsonOptions);
if (searchResults != null && searchResults.Count > 0)
{
// Find best match by comparing track name, artist, and duration
LrclibResponse? bestMatch = null;
double bestScore = 0;
foreach (var result in searchResults)
{
// Calculate similarity scores
var trackScore = CalculateSimilarity(trackName, result.TrackName ?? "");
// Count artists in the result
var resultArtistCount = CountArtists(result.ArtistName ?? "");
var expectedArtistCount = artistNames.Length;
// Artist matching - check if all our artists are present
var artistScore = CalculateArtistSimilarity(artistNames, result.ArtistName ?? "");
// STRONG bonus for matching artist count (this is critical!)
var artistCountBonus = resultArtistCount == expectedArtistCount ? 50.0 : 0.0;
// Duration match (within 5 seconds is good)
var durationDiff = Math.Abs(result.Duration - durationSeconds);
var durationScore = durationDiff <= 5 ? 100.0 : Math.Max(0, 100 - (durationDiff * 2));
// Bonus for having synced lyrics (prefer synced over plain)
var syncedBonus = !string.IsNullOrEmpty(result.SyncedLyrics) ? 15.0 : 0.0;
// Weighted score: track name important, artist match critical, artist count VERY important
var totalScore = (trackScore * 0.3) + (artistScore * 0.3) + (durationScore * 0.15) + artistCountBonus + syncedBonus;
_logger.LogDebug("Candidate: {Track} by {Artist} ({ArtistCount} artists) - Score: {Score:F1} (track:{TrackScore:F1}, artist:{ArtistScore:F1}, duration:{DurationScore:F1}, countBonus:{CountBonus:F1}, synced:{Synced})",
result.TrackName, result.ArtistName, resultArtistCount, totalScore, trackScore, artistScore, durationScore, artistCountBonus, !string.IsNullOrEmpty(result.SyncedLyrics));
if (totalScore > bestScore)
{
bestScore = totalScore;
bestMatch = result;
}
}
// Only use result if score is good enough (>60%)
if (bestMatch != null && bestScore >= 60)
{
_logger.LogInformation("✓ Found lyrics via search for {Artist} - {Track} (ID: {Id}, score: {Score:F1}, synced: {HasSynced})",
artistName, trackName, bestMatch.Id, bestScore, !string.IsNullOrEmpty(bestMatch.SyncedLyrics));
var result = new LyricsInfo
{
Id = bestMatch.Id,
TrackName = bestMatch.TrackName ?? trackName,
ArtistName = bestMatch.ArtistName ?? artistName,
AlbumName = bestMatch.AlbumName ?? albumName,
Duration = (int)Math.Round(bestMatch.Duration),
Instrumental = bestMatch.Instrumental,
PlainLyrics = bestMatch.PlainLyrics,
SyncedLyrics = bestMatch.SyncedLyrics
};
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(result, JsonOptions), TimeSpan.FromDays(30));
return result;
}
else
{
_logger.LogInformation("Best match score too low ({Score:F1}), trying exact match", bestScore);
}
}
}
// Fall back to exact match API if search didn't find good results
var exactUrl = $"{BaseUrl}/get?" +
$"track_name={Uri.EscapeDataString(trackName)}&" +
$"artist_name={Uri.EscapeDataString(artistName)}&" +
$"album_name={Uri.EscapeDataString(albumName)}&" +
$"duration={durationSeconds}";
_logger.LogDebug("Trying exact match from LRCLIB: {Url}", exactUrl);
var exactResponse = await _httpClient.GetAsync(exactUrl);
if (exactResponse.StatusCode == System.Net.HttpStatusCode.NotFound)
{ {
_logger.LogDebug("Lyrics not found for {Artist} - {Track}", artistName, trackName); _logger.LogDebug("Lyrics not found for {Artist} - {Track}", artistName, trackName);
return null; return null;
} }
response.EnsureSuccessStatusCode(); exactResponse.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(); var json = await exactResponse.Content.ReadAsStringAsync();
var lyrics = JsonSerializer.Deserialize<LrclibResponse>(json, JsonOptions); var lyrics = JsonSerializer.Deserialize<LrclibResponse>(json, JsonOptions);
if (lyrics == null) if (lyrics == null)
@@ -68,7 +161,7 @@ public class LrclibService
return null; return null;
} }
var result = new LyricsInfo var exactResult = new LyricsInfo
{ {
Id = lyrics.Id, Id = lyrics.Id,
TrackName = lyrics.TrackName ?? trackName, TrackName = lyrics.TrackName ?? trackName,
@@ -80,11 +173,11 @@ public class LrclibService
SyncedLyrics = lyrics.SyncedLyrics SyncedLyrics = lyrics.SyncedLyrics
}; };
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(result, JsonOptions), TimeSpan.FromDays(30)); await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(exactResult, JsonOptions), TimeSpan.FromDays(30));
_logger.LogInformation("Retrieved lyrics for {Artist} - {Track} (ID: {Id})", artistName, trackName, lyrics.Id); _logger.LogInformation("Retrieved lyrics via exact match for {Artist} - {Track} (ID: {Id})", artistName, trackName, lyrics.Id);
return result; return exactResult;
} }
catch (HttpRequestException ex) catch (HttpRequestException ex)
{ {
@@ -98,6 +191,91 @@ public class LrclibService
} }
} }
/// <summary>
/// Counts the number of artists in an artist string (separated by comma, ampersand, or 'e')
/// </summary>
private static int CountArtists(string artistString)
{
if (string.IsNullOrWhiteSpace(artistString))
return 0;
// Split by common separators: comma, ampersand, " e " (Portuguese/Spanish "and")
var separators = new[] { ',', '&' };
var parts = artistString.Split(separators, StringSplitOptions.RemoveEmptyEntries);
// Also check for " e " pattern (like "Julia Michaels e Alessia Cara")
var count = parts.Length;
foreach (var part in parts)
{
if (part.Contains(" e ", StringComparison.OrdinalIgnoreCase))
{
count += part.Split(new[] { " e " }, StringSplitOptions.RemoveEmptyEntries).Length - 1;
}
}
return Math.Max(1, count);
}
/// <summary>
/// Calculates how well the expected artists match the result's artist string
/// </summary>
private static double CalculateArtistSimilarity(string[] expectedArtists, string resultArtistString)
{
if (expectedArtists.Length == 0 || string.IsNullOrWhiteSpace(resultArtistString))
return 0;
var resultLower = resultArtistString.ToLowerInvariant();
var matchedCount = 0;
foreach (var artist in expectedArtists)
{
var artistLower = artist.ToLowerInvariant();
// Check if this artist appears in the result string
if (resultLower.Contains(artistLower))
{
matchedCount++;
}
else
{
// Try token-based matching for partial matches
var artistTokens = artistLower.Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries);
var matchedTokens = artistTokens.Count(token => resultLower.Contains(token));
// If most tokens match, count it as a partial match
if (matchedTokens >= artistTokens.Length * 0.7)
{
matchedCount++;
}
}
}
// Return percentage of artists matched
return (matchedCount * 100.0) / expectedArtists.Length;
}
private static double CalculateSimilarity(string str1, string str2)
{
if (string.IsNullOrEmpty(str1) || string.IsNullOrEmpty(str2))
return 0;
str1 = str1.ToLowerInvariant();
str2 = str2.ToLowerInvariant();
if (str1 == str2)
return 100;
// Simple token-based matching
var tokens1 = str1.Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries);
var tokens2 = str2.Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries);
if (tokens1.Length == 0 || tokens2.Length == 0)
return 0;
var matchedTokens = tokens1.Count(t1 => tokens2.Any(t2 => t2.Contains(t1) || t1.Contains(t2)));
return (matchedTokens * 100.0) / Math.Max(tokens1.Length, tokens2.Length);
}
public async Task<LyricsInfo?> GetLyricsCachedAsync(string trackName, string artistName, string albumName, int durationSeconds) public async Task<LyricsInfo?> GetLyricsCachedAsync(string trackName, string artistName, string albumName, int durationSeconds)
{ {
try try

View File

@@ -0,0 +1,474 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
using allstarr.Models.Lyrics;
using allstarr.Models.Settings;
using allstarr.Services.Common;
using allstarr.Services.Spotify;
using Microsoft.Extensions.Options;
namespace allstarr.Services.Lyrics;
/// <summary>
/// Service for fetching synchronized lyrics from Spotify's internal color-lyrics API.
///
/// Spotify's lyrics API provides:
/// - Line-by-line synchronized lyrics with precise timestamps
/// - Word-level timing for karaoke-style display (syllable sync)
/// - Background color suggestions based on album art
/// - Support for multiple languages and translations
///
/// This requires the sp_dc session cookie for authentication.
/// </summary>
public class SpotifyLyricsService
{
private readonly ILogger<SpotifyLyricsService> _logger;
private readonly SpotifyApiSettings _settings;
private readonly SpotifyApiClient _spotifyClient;
private readonly RedisCacheService _cache;
private readonly HttpClient _httpClient;
private const string LyricsApiBase = "https://spclient.wg.spotify.com/color-lyrics/v2/track";
public SpotifyLyricsService(
ILogger<SpotifyLyricsService> logger,
IOptions<SpotifyApiSettings> settings,
SpotifyApiClient spotifyClient,
RedisCacheService cache,
IHttpClientFactory httpClientFactory)
{
_logger = logger;
_settings = settings.Value;
_spotifyClient = spotifyClient;
_cache = cache;
_httpClient = httpClientFactory.CreateClient();
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0");
_httpClient.DefaultRequestHeaders.Add("App-Platform", "WebPlayer");
}
/// <summary>
/// Gets synchronized lyrics for a Spotify track by its ID.
/// </summary>
/// <param name="spotifyTrackId">Spotify track ID (e.g., "3a8mo25v74BMUOJ1IDUEBL")</param>
/// <returns>Lyrics info with synced lyrics in LRC format, or null if not available</returns>
public async Task<SpotifyLyricsResult?> GetLyricsByTrackIdAsync(string spotifyTrackId)
{
if (!_settings.Enabled || string.IsNullOrEmpty(_settings.SessionCookie))
{
_logger.LogDebug("Spotify API not enabled or no session cookie configured");
return null;
}
// Normalize track ID (remove URI prefix if present)
spotifyTrackId = ExtractTrackId(spotifyTrackId);
// Check cache
var cacheKey = $"spotify:lyrics:{spotifyTrackId}";
var cached = await _cache.GetAsync<SpotifyLyricsResult>(cacheKey);
if (cached != null)
{
_logger.LogDebug("Returning cached Spotify lyrics for track {TrackId}", spotifyTrackId);
return cached;
}
try
{
// Get access token
var token = await _spotifyClient.GetWebAccessTokenAsync();
if (string.IsNullOrEmpty(token))
{
_logger.LogWarning("Could not get Spotify access token for lyrics");
return null;
}
// Request lyrics from Spotify's color-lyrics API
var url = $"{LyricsApiBase}/{spotifyTrackId}?format=json&vocalRemoval=false&market=from_token";
var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
request.Headers.Add("Accept", "application/json");
var response = await _httpClient.SendAsync(request);
if (response.StatusCode == HttpStatusCode.NotFound)
{
_logger.LogDebug("No lyrics found on Spotify for track {TrackId}", spotifyTrackId);
return null;
}
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("Spotify lyrics API returned {StatusCode} for track {TrackId}",
response.StatusCode, spotifyTrackId);
return null;
}
var json = await response.Content.ReadAsStringAsync();
var result = ParseLyricsResponse(json, spotifyTrackId);
if (result != null)
{
// Cache for 30 days (lyrics don't change)
await _cache.SetAsync(cacheKey, result, TimeSpan.FromDays(30));
_logger.LogInformation("Cached Spotify lyrics for track {TrackId} ({LineCount} lines)",
spotifyTrackId, result.Lines.Count);
}
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching Spotify lyrics for track {TrackId}", spotifyTrackId);
return null;
}
}
/// <summary>
/// Searches for a track on Spotify and returns its lyrics.
/// Useful when you have track metadata but not a Spotify ID.
/// </summary>
public async Task<SpotifyLyricsResult?> SearchAndGetLyricsAsync(
string trackName,
string artistName,
string? albumName = null,
int? durationMs = null)
{
if (!_settings.Enabled || string.IsNullOrEmpty(_settings.SessionCookie))
{
return null;
}
try
{
var token = await _spotifyClient.GetWebAccessTokenAsync();
if (string.IsNullOrEmpty(token))
{
return null;
}
// Search for the track
var query = $"track:{trackName} artist:{artistName}";
if (!string.IsNullOrEmpty(albumName))
{
query += $" album:{albumName}";
}
var searchUrl = $"https://api.spotify.com/v1/search?q={Uri.EscapeDataString(query)}&type=track&limit=5";
var request = new HttpRequestMessage(HttpMethod.Get, searchUrl);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await _httpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
return null;
}
var json = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (!root.TryGetProperty("tracks", out var tracks) ||
!tracks.TryGetProperty("items", out var items) ||
items.GetArrayLength() == 0)
{
return null;
}
// Find best match considering duration if provided
string? bestMatchId = null;
var bestScore = 0;
foreach (var item in items.EnumerateArray())
{
var id = item.TryGetProperty("id", out var idProp) ? idProp.GetString() : null;
if (string.IsNullOrEmpty(id)) continue;
var score = 100; // Base score
// Check duration match
if (durationMs.HasValue && item.TryGetProperty("duration_ms", out var durProp))
{
var trackDuration = durProp.GetInt32();
var durationDiff = Math.Abs(trackDuration - durationMs.Value);
if (durationDiff < 2000) score += 50; // Within 2 seconds
else if (durationDiff < 5000) score += 25; // Within 5 seconds
}
if (score > bestScore)
{
bestScore = score;
bestMatchId = id;
}
}
if (!string.IsNullOrEmpty(bestMatchId))
{
return await GetLyricsByTrackIdAsync(bestMatchId);
}
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error searching Spotify for lyrics: {Track} - {Artist}", trackName, artistName);
return null;
}
}
/// <summary>
/// Converts Spotify lyrics to LRCLIB-compatible LyricsInfo format.
/// </summary>
public LyricsInfo? ToLyricsInfo(SpotifyLyricsResult spotifyLyrics)
{
if (spotifyLyrics.Lines.Count == 0)
{
return null;
}
// Build synced lyrics in LRC format
var lrcLines = new List<string>();
foreach (var line in spotifyLyrics.Lines)
{
var timestamp = TimeSpan.FromMilliseconds(line.StartTimeMs);
var mm = (int)timestamp.TotalMinutes;
var ss = timestamp.Seconds;
var ms = timestamp.Milliseconds / 10; // LRC uses centiseconds
lrcLines.Add($"[{mm:D2}:{ss:D2}.{ms:D2}]{line.Words}");
}
return new LyricsInfo
{
TrackName = spotifyLyrics.TrackName ?? "",
ArtistName = spotifyLyrics.ArtistName ?? "",
AlbumName = spotifyLyrics.AlbumName ?? "",
Duration = (int)(spotifyLyrics.DurationMs / 1000),
Instrumental = spotifyLyrics.Lines.Count == 0,
SyncedLyrics = string.Join("\n", lrcLines),
PlainLyrics = string.Join("\n", spotifyLyrics.Lines.Select(l => l.Words))
};
}
private SpotifyLyricsResult? ParseLyricsResponse(string json, string trackId)
{
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var result = new SpotifyLyricsResult
{
SpotifyTrackId = trackId
};
// Parse lyrics lines
if (root.TryGetProperty("lyrics", out var lyrics))
{
// Check sync type
if (lyrics.TryGetProperty("syncType", out var syncType))
{
result.SyncType = syncType.GetString() ?? "LINE_SYNCED";
}
// Parse lines
if (lyrics.TryGetProperty("lines", out var lines))
{
foreach (var line in lines.EnumerateArray())
{
var lyricsLine = new SpotifyLyricsLine
{
StartTimeMs = line.TryGetProperty("startTimeMs", out var start)
? long.Parse(start.GetString() ?? "0") : 0,
Words = line.TryGetProperty("words", out var words)
? words.GetString() ?? "" : "",
EndTimeMs = line.TryGetProperty("endTimeMs", out var end)
? long.Parse(end.GetString() ?? "0") : 0
};
// Parse syllables if available (for word-level sync)
if (line.TryGetProperty("syllables", out var syllables))
{
foreach (var syllable in syllables.EnumerateArray())
{
lyricsLine.Syllables.Add(new SpotifyLyricsSyllable
{
StartTimeMs = syllable.TryGetProperty("startTimeMs", out var sStart)
? long.Parse(sStart.GetString() ?? "0") : 0,
Text = syllable.TryGetProperty("charsIndex", out var text)
? text.GetString() ?? "" : ""
});
}
}
result.Lines.Add(lyricsLine);
}
}
// Parse color information
if (lyrics.TryGetProperty("colors", out var colors))
{
result.Colors = new SpotifyLyricsColors
{
Background = colors.TryGetProperty("background", out var bg)
? ParseColorValue(bg) : null,
Text = colors.TryGetProperty("text", out var txt)
? ParseColorValue(txt) : null,
HighlightText = colors.TryGetProperty("highlightText", out var ht)
? ParseColorValue(ht) : null
};
}
// Language
if (lyrics.TryGetProperty("language", out var lang))
{
result.Language = lang.GetString();
}
// Provider info
if (lyrics.TryGetProperty("provider", out var provider))
{
result.Provider = provider.GetString();
}
// Display info
if (lyrics.TryGetProperty("providerDisplayName", out var providerDisplay))
{
result.ProviderDisplayName = providerDisplay.GetString();
}
}
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error parsing Spotify lyrics response");
return null;
}
}
private static int? ParseColorValue(JsonElement element)
{
if (element.ValueKind == JsonValueKind.Number)
{
return element.GetInt32();
}
if (element.ValueKind == JsonValueKind.String)
{
var str = element.GetString();
if (!string.IsNullOrEmpty(str) && int.TryParse(str, out var val))
{
return val;
}
}
return null;
}
private static string ExtractTrackId(string input)
{
if (string.IsNullOrEmpty(input)) return input;
// Handle spotify:track:xxxxx format
if (input.StartsWith("spotify:track:"))
{
return input.Substring("spotify:track:".Length);
}
// Handle https://open.spotify.com/track/xxxxx format
if (input.Contains("open.spotify.com/track/"))
{
var start = input.IndexOf("/track/") + "/track/".Length;
var end = input.IndexOf('?', start);
return end > 0 ? input.Substring(start, end - start) : input.Substring(start);
}
return input;
}
}
/// <summary>
/// Result from Spotify's color-lyrics API.
/// </summary>
public class SpotifyLyricsResult
{
public string SpotifyTrackId { get; set; } = string.Empty;
public string? TrackName { get; set; }
public string? ArtistName { get; set; }
public string? AlbumName { get; set; }
public long DurationMs { get; set; }
/// <summary>
/// Sync type: "LINE_SYNCED", "SYLLABLE_SYNCED", or "UNSYNCED"
/// </summary>
public string SyncType { get; set; } = "LINE_SYNCED";
/// <summary>
/// Language code (e.g., "en", "es", "ja")
/// </summary>
public string? Language { get; set; }
/// <summary>
/// Lyrics provider (e.g., "MusixMatch", "Spotify")
/// </summary>
public string? Provider { get; set; }
public string? ProviderDisplayName { get; set; }
/// <summary>
/// Lyrics lines in order
/// </summary>
public List<SpotifyLyricsLine> Lines { get; set; } = new();
/// <summary>
/// Color suggestions based on album art
/// </summary>
public SpotifyLyricsColors? Colors { get; set; }
}
public class SpotifyLyricsLine
{
/// <summary>
/// Start time in milliseconds
/// </summary>
public long StartTimeMs { get; set; }
/// <summary>
/// End time in milliseconds
/// </summary>
public long EndTimeMs { get; set; }
/// <summary>
/// The lyrics text for this line
/// </summary>
public string Words { get; set; } = string.Empty;
/// <summary>
/// Syllable-level timing for karaoke display (if available)
/// </summary>
public List<SpotifyLyricsSyllable> Syllables { get; set; } = new();
}
public class SpotifyLyricsSyllable
{
public long StartTimeMs { get; set; }
public string Text { get; set; } = string.Empty;
}
public class SpotifyLyricsColors
{
/// <summary>
/// Suggested background color (ARGB integer)
/// </summary>
public int? Background { get; set; }
/// <summary>
/// Suggested text color (ARGB integer)
/// </summary>
public int? Text { get; set; }
/// <summary>
/// Suggested highlight/active text color (ARGB integer)
/// </summary>
public int? HighlightText { get; set; }
}

View File

@@ -0,0 +1,900 @@
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);
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)
{
var token = await GetWebAccessTokenAsync(cancellationToken);
if (string.IsNullOrEmpty(token))
{
return new List<SpotifyPlaylist>();
}
try
{
var playlists = new List<SpotifyPlaylist>();
var offset = 0;
const int limit = 50;
while (true)
{
var url = $"{OfficialApiBase}/me/playlists?offset={offset}&limit={limit}";
var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await _httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode) break;
var json = await response.Content.ReadAsStringAsync(cancellationToken);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (!root.TryGetProperty("items", out var items) || items.GetArrayLength() == 0)
break;
foreach (var item in items.EnumerateArray())
{
var itemName = item.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
// Check if name matches (case-insensitive)
if (itemName.Contains(searchName, StringComparison.OrdinalIgnoreCase))
{
playlists.Add(new SpotifyPlaylist
{
SpotifyId = item.TryGetProperty("id", out var itemId) ? itemId.GetString() ?? "" : "",
Name = itemName,
Description = item.TryGetProperty("description", out var desc) ? desc.GetString() : null,
TotalTracks = item.TryGetProperty("tracks", out var tracks) &&
tracks.TryGetProperty("total", out var total)
? total.GetInt32() : 0,
SnapshotId = item.TryGetProperty("snapshot_id", out var snap) ? snap.GetString() : null
});
}
}
if (items.GetArrayLength() < limit) break;
offset += limit;
if (_settings.RateLimitDelayMs > 0)
{
await Task.Delay(_settings.RateLimitDelayMs, cancellationToken);
}
}
return playlists;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error searching user playlists for '{SearchName}'", 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();
}
}

View File

@@ -0,0 +1,673 @@
using allstarr.Models.Settings;
using allstarr.Models.Spotify;
using allstarr.Services.Common;
using allstarr.Services.Jellyfin;
using Microsoft.Extensions.Options;
using System.Text.Json;
namespace allstarr.Services.Spotify;
public class SpotifyMissingTracksFetcher : BackgroundService
{
private readonly IOptions<SpotifyImportSettings> _spotifySettings;
private readonly IOptions<SpotifyApiSettings> _spotifyApiSettings;
private readonly IOptions<JellyfinSettings> _jellyfinSettings;
private readonly IHttpClientFactory _httpClientFactory;
private readonly RedisCacheService _cache;
private readonly ILogger<SpotifyMissingTracksFetcher> _logger;
private readonly IServiceProvider _serviceProvider;
private bool _hasRunOnce = false;
private Dictionary<string, string> _playlistIdToName = new();
private const string CacheDirectory = "/app/cache/spotify";
public SpotifyMissingTracksFetcher(
IOptions<SpotifyImportSettings> spotifySettings,
IOptions<SpotifyApiSettings> spotifyApiSettings,
IOptions<JellyfinSettings> jellyfinSettings,
IHttpClientFactory httpClientFactory,
RedisCacheService cache,
IServiceProvider serviceProvider,
ILogger<SpotifyMissingTracksFetcher> logger)
{
_spotifySettings = spotifySettings;
_spotifyApiSettings = spotifyApiSettings;
_jellyfinSettings = jellyfinSettings;
_httpClientFactory = httpClientFactory;
_cache = cache;
_serviceProvider = serviceProvider;
_logger = logger;
}
/// <summary>
/// Public method to trigger fetching manually (called from controller).
/// </summary>
public async Task TriggerFetchAsync()
{
_logger.LogInformation("Manual fetch triggered");
await FetchMissingTracksAsync(CancellationToken.None, bypassSyncWindowCheck: true);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("========================================");
_logger.LogInformation("SpotifyMissingTracksFetcher: Starting up...");
// Ensure cache directory exists
Directory.CreateDirectory(CacheDirectory);
// Check if SpotifyApi is enabled with a valid session cookie
// If so, SpotifyPlaylistFetcher will handle everything - we don't need to scrape Jellyfin
if (_spotifyApiSettings.Value.Enabled && !string.IsNullOrEmpty(_spotifyApiSettings.Value.SessionCookie))
{
_logger.LogInformation("SpotifyApi is enabled with session cookie - using direct Spotify API instead of Jellyfin scraping");
_logger.LogInformation("This service will remain dormant. SpotifyPlaylistFetcher is handling playlists.");
_logger.LogInformation("========================================");
return;
}
if (!_spotifySettings.Value.Enabled)
{
_logger.LogInformation("Spotify playlist injection is DISABLED");
_logger.LogInformation("========================================");
return;
}
var jellyfinUrl = _jellyfinSettings.Value.Url;
var apiKey = _jellyfinSettings.Value.ApiKey;
if (string.IsNullOrEmpty(jellyfinUrl) || string.IsNullOrEmpty(apiKey))
{
_logger.LogWarning("Jellyfin URL or API key not configured, Spotify playlist injection disabled");
_logger.LogInformation("========================================");
return;
}
_logger.LogInformation("Spotify Import ENABLED");
_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");
// Fetch playlist names from Jellyfin
await LoadPlaylistNamesAsync();
_logger.LogInformation("Configured Playlists:");
foreach (var kvp in _playlistIdToName)
{
_logger.LogInformation(" - {Name} (ID: {Id})", kvp.Value, kvp.Key);
}
_logger.LogInformation("========================================");
// Check if we should run on startup
if (!_hasRunOnce)
{
var shouldRun = await ShouldRunOnStartupAsync();
if (shouldRun)
{
_logger.LogInformation("Running initial fetch on startup");
try
{
await FetchMissingTracksAsync(stoppingToken, bypassSyncWindowCheck: true);
_hasRunOnce = true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during startup fetch");
}
}
else
{
_logger.LogInformation("Skipping startup fetch - already have current files");
_hasRunOnce = true;
}
}
while (!stoppingToken.IsCancellationRequested)
{
try
{
// Only fetch if we're past today's sync window AND we haven't fetched today yet
var shouldFetch = await ShouldFetchNowAsync();
if (shouldFetch)
{
await FetchMissingTracksAsync(stoppingToken);
_hasRunOnce = true;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching Spotify missing tracks");
}
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
}
}
private async Task<bool> ShouldFetchNowAsync()
{
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);
// 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)
{
var filePath = GetCacheFilePath(playlistName);
if (File.Exists(filePath))
{
var fileTime = File.GetLastWriteTimeUtc(filePath);
// If file is from today's sync or later, we already have it
if (fileTime >= todaySync)
{
continue;
}
}
// Missing today's file for this playlist
return true;
}
// All playlists have today's files
return false;
}
private async Task LoadPlaylistNamesAsync()
{
_playlistIdToName.Clear();
// Use configured playlists
foreach (var playlist in _spotifySettings.Value.Playlists)
{
_playlistIdToName[playlist.Id] = playlist.Name;
}
}
private async Task<bool> ShouldRunOnStartupAsync()
{
_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;
foreach (var playlistName in _playlistIdToName.Values)
{
var filePath = GetCacheFilePath(playlistName);
var cacheKey = $"spotify:missing:{playlistName}";
// Check file cache
if (File.Exists(filePath))
{
var fileAge = DateTime.UtcNow - File.GetLastWriteTimeUtc(filePath);
_logger.LogInformation(" {Playlist}: Found file cache (age: {Age:F1}h)", playlistName, fileAge.TotalHours);
// Load into Redis if not already there
if (!await _cache.ExistsAsync(cacheKey))
{
await LoadFromFileCache(playlistName);
}
continue;
}
// Check Redis cache
if (await _cache.ExistsAsync(cacheKey))
{
_logger.LogInformation(" {Playlist}: Found in Redis cache", playlistName);
continue;
}
// No cache found for this playlist
_logger.LogInformation(" {Playlist}: No cache found", playlistName);
allPlaylistsHaveCache = false;
}
if (allPlaylistsHaveCache)
{
_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;
}
}
// 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 ===");
return true;
}
private string GetCacheFilePath(string playlistName)
{
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
return Path.Combine(CacheDirectory, $"{safeName}_missing.json");
}
private async Task LoadFromFileCache(string playlistName)
{
try
{
var filePath = GetCacheFilePath(playlistName);
if (!File.Exists(filePath))
return;
var json = await File.ReadAllTextAsync(filePath);
var tracks = JsonSerializer.Deserialize<List<MissingTrack>>(json);
if (tracks != null && tracks.Count > 0)
{
var cacheKey = $"spotify:missing:{playlistName}";
var fileAge = DateTime.UtcNow - File.GetLastWriteTimeUtc(filePath);
// No expiration - cache persists until next Jellyfin job generates new file
await _cache.SetAsync(cacheKey, tracks, TimeSpan.FromDays(365));
_logger.LogInformation("Loaded {Count} tracks from file cache for {Playlist} (age: {Age:F1}h, no expiration)",
tracks.Count, playlistName, fileAge.TotalHours);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load file cache for {Playlist}", playlistName);
}
}
private async Task SaveToFileCache(string playlistName, List<MissingTrack> tracks)
{
try
{
var filePath = GetCacheFilePath(playlistName);
var json = JsonSerializer.Serialize(tracks, new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(filePath, json);
_logger.LogInformation("Saved {Count} tracks to file cache for {Playlist}",
tracks.Count, playlistName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save file cache for {Playlist}", playlistName);
}
}
private async Task FetchMissingTracksAsync(CancellationToken cancellationToken, bool bypassSyncWindowCheck = false)
{
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);
// Track when we find files to optimize search for other playlists
DateTime? firstFoundTime = null;
var foundPlaylists = new HashSet<string>();
foreach (var kvp in _playlistIdToName)
{
_logger.LogInformation("Fetching playlist: {Name}", kvp.Value);
var foundTime = await FetchPlaylistMissingTracksAsync(kvp.Value, cancellationToken, firstFoundTime);
if (foundTime.HasValue)
{
foundPlaylists.Add(kvp.Value);
if (!firstFoundTime.HasValue)
{
firstFoundTime = foundTime;
_logger.LogInformation(" → Will search within ±1h of {Time:yyyy-MM-dd HH:mm} for remaining playlists", firstFoundTime.Value);
}
}
}
_logger.LogInformation("=== FINISHED FETCHING MISSING TRACKS ({Found}/{Total} playlists found) ===",
foundPlaylists.Count, _playlistIdToName.Count);
}
private async Task<DateTime?> FetchPlaylistMissingTracksAsync(
string playlistName,
CancellationToken cancellationToken,
DateTime? hintTime = null)
{
var cacheKey = $"spotify:missing:{playlistName}";
// Check if we have existing cache
var existingTracks = await _cache.GetAsync<List<MissingTrack>>(cacheKey);
var filePath = GetCacheFilePath(playlistName);
if (File.Exists(filePath))
{
var fileAge = DateTime.UtcNow - File.GetLastWriteTimeUtc(filePath);
_logger.LogInformation(" Existing cache file age: {Age:F1}h", fileAge.TotalHours);
}
if (existingTracks != null && existingTracks.Count > 0)
{
_logger.LogInformation(" Current cache has {Count} tracks, will search for newer file", existingTracks.Count);
}
else
{
_logger.LogInformation(" No existing cache, will search for missing tracks file");
}
var settings = _spotifySettings.Value;
var jellyfinUrl = _jellyfinSettings.Value.Url;
var apiKey = _jellyfinSettings.Value.ApiKey;
if (string.IsNullOrEmpty(jellyfinUrl) || string.IsNullOrEmpty(apiKey))
{
_logger.LogWarning(" Jellyfin URL or API key not configured, skipping fetch");
return null;
}
var httpClient = _httpClientFactory.CreateClient();
// Search starting from 24 hours ahead, going backwards for 72 hours
// This handles timezone differences where the plugin may have run "in the future" from our perspective
var now = DateTime.UtcNow;
var searchStart = now.AddHours(24); // Start 24 hours from now
var totalMinutesToSearch = 72 * 60; // 72 hours = 4320 minutes
_logger.LogInformation(" Current UTC time: {Now:yyyy-MM-dd HH:mm}", now);
_logger.LogInformation(" Search start: {Start:yyyy-MM-dd HH:mm} (24h ahead)", searchStart);
_logger.LogInformation(" Searching backwards for 72 hours ({Minutes} minutes)", totalMinutesToSearch);
var found = false;
DateTime? foundFileTime = null;
// If we have a hint time from another playlist, search ±1 hour around it first
if (hintTime.HasValue)
{
_logger.LogInformation(" Hint: Searching ±1h around {Time:yyyy-MM-dd HH:mm} (from another playlist)", hintTime.Value);
// Search ±60 minutes around the hint time
for (var minuteOffset = 0; minuteOffset <= 60; minuteOffset++)
{
if (cancellationToken.IsCancellationRequested) break;
// Try both forward and backward from hint
if (minuteOffset > 0)
{
// Try forward
var timeForward = hintTime.Value.AddMinutes(minuteOffset);
var resultForward = await TryFetchMissingTracksFile(playlistName, timeForward, jellyfinUrl, apiKey, httpClient, cancellationToken);
if (resultForward.found)
{
found = true;
foundFileTime = resultForward.fileTime;
_logger.LogInformation(" ✓ Found using hint (+{Minutes}min from hint)", minuteOffset);
return foundFileTime;
}
}
// Try backward
var timeBackward = hintTime.Value.AddMinutes(-minuteOffset);
var resultBackward = await TryFetchMissingTracksFile(playlistName, timeBackward, jellyfinUrl, apiKey, httpClient, cancellationToken);
if (resultBackward.found)
{
found = true;
foundFileTime = resultBackward.fileTime;
_logger.LogInformation(" ✓ Found using hint (-{Minutes}min from hint)", minuteOffset);
return foundFileTime;
}
}
_logger.LogInformation(" Not found within ±1h of hint, doing full search...");
}
// Search from 24h ahead, going backwards minute by minute for 72 hours
_logger.LogInformation(" Searching from {Start:yyyy-MM-dd HH:mm} backwards to {End:yyyy-MM-dd HH:mm}...",
searchStart, searchStart.AddMinutes(-totalMinutesToSearch));
for (var minutesBehind = 0; minutesBehind <= totalMinutesToSearch; minutesBehind++)
{
if (cancellationToken.IsCancellationRequested) break;
var time = searchStart.AddMinutes(-minutesBehind);
var result = await TryFetchMissingTracksFile(playlistName, time, jellyfinUrl, apiKey, httpClient, cancellationToken);
if (result.found)
{
found = true;
foundFileTime = result.fileTime;
return foundFileTime;
}
// Small delay every 60 requests to avoid rate limiting
if (minutesBehind > 0 && minutesBehind % 60 == 0)
{
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
}
}
if (!found)
{
_logger.LogWarning(" ✗ Could not find new missing tracks file (searched +24h forward, -48h backward)");
// Keep the existing cache - don't let it expire
if (existingTracks != null && existingTracks.Count > 0)
{
_logger.LogInformation(" ✓ Keeping existing cache with {Count} tracks (no expiration)", existingTracks.Count);
// Re-save with no expiration to ensure it persists
await _cache.SetAsync(cacheKey, existingTracks, TimeSpan.FromDays(365)); // Effectively no expiration
}
else if (File.Exists(filePath))
{
// Load from file if Redis cache is empty
_logger.LogInformation(" 📦 Loading existing file cache to keep playlist populated");
try
{
var json = await File.ReadAllTextAsync(filePath, cancellationToken);
var tracks = JsonSerializer.Deserialize<List<MissingTrack>>(json);
if (tracks != null && tracks.Count > 0)
{
await _cache.SetAsync(cacheKey, tracks, TimeSpan.FromDays(365)); // No expiration
_logger.LogInformation(" ✓ Loaded {Count} tracks from file cache (no expiration)", tracks.Count);
}
}
catch (Exception ex)
{
_logger.LogError(ex, " Failed to reload cache from file for {Playlist}", playlistName);
}
}
else
{
_logger.LogWarning(" No existing cache to keep - playlist will be empty until tracks are found");
}
}
return foundFileTime;
}
private async Task<(bool found, DateTime? fileTime)> TryFetchMissingTracksFile(
string playlistName,
DateTime time,
string jellyfinUrl,
string apiKey,
HttpClient httpClient,
CancellationToken cancellationToken)
{
var filename = $"{playlistName}_missing_{time:yyyy-MM-dd_HH-mm}.json";
var url = $"{jellyfinUrl}/Viperinius.Plugin.SpotifyImport/MissingTracksFile" +
$"?name={Uri.EscapeDataString(filename)}&api_key={apiKey}";
try
{
// Log every request with the actual filename
_logger.LogInformation("Checking: {Playlist} at {DateTime}", playlistName, time.ToString("yyyy-MM-dd HH:mm"));
var response = await httpClient.GetAsync(url, cancellationToken);
if (response.IsSuccessStatusCode)
{
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var tracks = ParseMissingTracks(json);
if (tracks.Count > 0)
{
var cacheKey = $"spotify:missing:{playlistName}";
// Save to both Redis and file with extended TTL until next job runs
// Set to 365 days (effectively no expiration) - will be replaced when Jellyfin generates new file
await _cache.SetAsync(cacheKey, tracks, TimeSpan.FromDays(365));
await SaveToFileCache(playlistName, tracks);
_logger.LogInformation(
"✓ FOUND! Cached {Count} missing tracks for {Playlist} from {Filename}",
tracks.Count, playlistName, filename);
return (true, time);
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to fetch {Filename}", filename);
}
return (false, null);
}
private List<MissingTrack> ParseMissingTracks(string json)
{
var tracks = new List<MissingTrack>();
try
{
var doc = JsonDocument.Parse(json);
foreach (var item in doc.RootElement.EnumerateArray())
{
var track = new MissingTrack
{
SpotifyId = item.GetProperty("Id").GetString() ?? "",
Title = item.GetProperty("Name").GetString() ?? "",
Album = item.GetProperty("AlbumName").GetString() ?? "",
Artists = item.GetProperty("ArtistNames")
.EnumerateArray()
.Select(a => a.GetString() ?? "")
.Where(a => !string.IsNullOrEmpty(a))
.ToList()
};
if (!string.IsNullOrEmpty(track.Title))
{
tracks.Add(track);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to parse missing tracks JSON");
}
return tracks;
}
}

View File

@@ -0,0 +1,336 @@
using allstarr.Models.Settings;
using allstarr.Models.Spotify;
using allstarr.Services.Common;
using Microsoft.Extensions.Options;
using System.Text.Json;
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.)
/// </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.
/// </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;
if (age.TotalMinutes < _spotifyApiSettings.CacheDurationMinutes)
{
_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 playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName);
if (playlistConfig != null && !string.IsNullOrEmpty(playlistConfig.Id))
{
// Use the configured Spotify playlist ID directly
spotifyId = playlistConfig.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>();
}
// Update cache
await _cache.SetAsync(cacheKey, playlist, TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes * 2));
await SaveToFileCacheAsync(playlistName, playlist);
_logger.LogInformation("Fetched and cached playlist '{Name}' with {Count} tracks in order",
playlistName, playlist.Tracks.Count);
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("Cache duration: {Minutes} minutes", _spotifyApiSettings.CacheDurationMinutes);
_logger.LogInformation("ISRC matching: {Enabled}", _spotifyApiSettings.PreferIsrcMatching ? "enabled" : "disabled");
_logger.LogInformation("Configured Playlists: {Count}", _spotifyImportSettings.Playlists.Count);
foreach (var playlist in _spotifyImportSettings.Playlists)
{
_logger.LogInformation(" - {Name}", playlist.Name);
}
_logger.LogInformation("========================================");
// Initial fetch of all playlists
await FetchAllPlaylistsAsync(stoppingToken);
// Periodic refresh loop
while (!stoppingToken.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes), stoppingToken);
try
{
await FetchAllPlaylistsAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during periodic playlist refresh");
}
}
}
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);
}
}
}

View File

@@ -0,0 +1,652 @@
using allstarr.Models.Domain;
using allstarr.Models.Settings;
using allstarr.Models.Spotify;
using allstarr.Services.Common;
using allstarr.Services.Jellyfin;
using Microsoft.Extensions.Options;
using System.Text.Json;
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.
/// </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)
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;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("SpotifyTrackMatchingService: Starting up...");
if (!_spotifySettings.Enabled)
{
_logger.LogInformation("Spotify playlist injection is DISABLED, matching service will not run");
return;
}
var matchMode = _spotifyApiSettings.Enabled && _spotifyApiSettings.PreferIsrcMatching
? "ISRC-preferred" : "fuzzy";
_logger.LogInformation("Matching mode: {Mode}", matchMode);
// 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");
await MatchAllPlaylistsAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during startup track matching");
}
// Now start the periodic matching loop
while (!stoppingToken.IsCancellationRequested)
{
// Wait 30 minutes before next run
await Task.Delay(TimeSpan.FromMinutes(30), stoppingToken);
try
{
await MatchAllPlaylistsAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in track matching service");
}
}
}
/// <summary>
/// Public method to trigger matching manually for all playlists (called from controller).
/// </summary>
public async Task TriggerMatchingAsync()
{
_logger.LogInformation("Manual track matching triggered for all playlists");
await MatchAllPlaylistsAsync(CancellationToken.None);
}
/// <summary>
/// Public method to trigger matching for a specific playlist (called from controller).
/// </summary>
public async Task TriggerMatchingForPlaylistAsync(string playlistName)
{
_logger.LogInformation("Manual track matching triggered for playlist: {Playlist}", playlistName);
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.None);
}
else
{
// Fall back to legacy mode
await MatchPlaylistTracksLegacyAsync(
playlist.Name, metadataService, CancellationToken.None);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error matching tracks for playlist {Playlist}", playlist.Name);
throw;
}
}
private async Task MatchAllPlaylistsAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("=== STARTING TRACK MATCHING ===");
var playlists = _spotifySettings.Playlists;
if (playlists.Count == 0)
{
_logger.LogInformation("No playlists configured for matching");
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>();
}
foreach (var playlist in playlists)
{
if (cancellationToken.IsCancellationRequested) break;
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);
}
}
_logger.LogInformation("=== FINISHED TRACK MATCHING ===");
}
/// <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.
/// </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";
if (!string.IsNullOrEmpty(userId))
{
playlistItemsUrl += $"?UserId={userId}";
}
else
{
_logger.LogWarning("No UserId configured - may not be able to fetch existing playlist tracks for {Playlist}", playlistName);
}
var (existingTracksResponse, _) = await proxyService.GetJsonAsync(
playlistItemsUrl,
null,
null);
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})",
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);
if (existingMatched != null && existingMatched.Count >= tracksToMatch.Count)
{
_logger.LogInformation("Playlist {Playlist} already has {Count} matched tracks cached, skipping",
playlistName, existingMatched.Count);
return;
}
var matchedTracks = new List<MatchedTrack>();
var isrcMatches = 0;
var fuzzyMatches = 0;
var noMatch = 0;
// 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
{
Song? matchedSong = null;
var matchType = "none";
// Try ISRC match first if available and enabled
if (_spotifyApiSettings.PreferIsrcMatching && !string.IsNullOrEmpty(spotifyTrack.Isrc))
{
matchedSong = await TryMatchByIsrcAsync(spotifyTrack.Isrc, metadataService);
if (matchedSong != null)
{
matchType = "isrc";
}
}
// Fall back to fuzzy matching
if (matchedSong == null)
{
matchedSong = await TryMatchByFuzzyAsync(
spotifyTrack.Title,
spotifyTrack.Artists,
metadataService);
if (matchedSong != null)
{
matchType = "fuzzy";
}
}
if (matchedSong != null)
{
var matched = new MatchedTrack
{
Position = spotifyTrack.Position,
SpotifyId = spotifyTrack.SpotifyId,
SpotifyTitle = spotifyTrack.Title,
SpotifyArtist = spotifyTrack.PrimaryArtist,
Isrc = spotifyTrack.Isrc,
MatchType = matchType,
MatchedSong = matchedSong
};
_logger.LogDebug(" #{Position} {Title} - {Artist} → {MatchType} match: {MatchedTitle}",
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist,
matchType, matchedSong.Title);
return (matched, matchType);
}
else
{
_logger.LogDebug(" #{Position} {Title} - {Artist} → no match",
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist);
return (null, "none");
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
spotifyTrack.Title, spotifyTrack.PrimaryArtist);
return (null, "none");
}
}).ToList();
// Wait for all tracks in this batch to complete
var batchResults = await Task.WhenAll(batchTasks);
// Collect results
foreach (var (matched, matchType) in batchResults)
{
if (matched != null)
{
matchedTracks.Add(matched);
if (matchType == "isrc") isrcMatches++;
else if (matchType == "fuzzy") fuzzyMatches++;
}
else
{
noMatch++;
}
}
// Rate limiting between batches (not between individual tracks)
if (i + BatchSize < orderedTracks.Count)
{
await Task.Delay(DelayBetweenSearchesMs, cancellationToken);
}
}
if (matchedTracks.Count > 0)
{
// Cache matched tracks with position data
await _cache.SetAsync(matchedTracksKey, matchedTracks, TimeSpan.FromHours(1));
// 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, TimeSpan.FromHours(1));
_logger.LogInformation(
"✓ Cached {Matched}/{Total} tracks for {Playlist} (ISRC: {Isrc}, Fuzzy: {Fuzzy}, No match: {NoMatch})",
matchedTracks.Count, tracksToMatch.Count, playlistName, isrcMatches, fuzzyMatches, noMatch);
}
else
{
_logger.LogInformation("No tracks matched for {Playlist}", playlistName);
}
}
/// <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 fuzzy matching.
/// </summary>
private async Task<Song?> TryMatchByFuzzyAsync(
string title,
List<string> artists,
IMusicMetadataService metadataService)
{
try
{
var primaryArtist = artists.FirstOrDefault() ?? "";
var query = $"{title} {primaryArtist}";
var results = await metadataService.SearchSongsAsync(query, limit: 5);
if (results.Count == 0) return null;
var bestMatch = results
.Select(song => new
{
Song = song,
TitleScore = FuzzyMatcher.CalculateSimilarity(title, song.Title),
ArtistScore = CalculateArtistMatchScore(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)
{
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 = 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;
}
}

View File

@@ -28,6 +28,7 @@ public class SquidWTFDownloadService : BaseDownloadService
private readonly List<string> _apiUrls; private readonly List<string> _apiUrls;
private int _currentUrlIndex = 0; private int _currentUrlIndex = 0;
private readonly object _urlIndexLock = new object();
protected override string ProviderName => "squidwtf"; protected override string ProviderName => "squidwtf";
@@ -48,23 +49,39 @@ public class SquidWTFDownloadService : BaseDownloadService
_apiUrls = apiUrls; _apiUrls = apiUrls;
} }
/// <summary>
/// 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.
/// </summary>
private async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action) private async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action)
{ {
// Start with the next URL in round-robin to distribute load
var startIndex = 0;
lock (_urlIndexLock)
{
startIndex = _currentUrlIndex;
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
}
// Try all URLs starting from the round-robin selected one
for (int attempt = 0; attempt < _apiUrls.Count; attempt++) for (int attempt = 0; attempt < _apiUrls.Count; attempt++)
{ {
var urlIndex = (startIndex + attempt) % _apiUrls.Count;
var baseUrl = _apiUrls[urlIndex];
try try
{ {
var baseUrl = _apiUrls[_currentUrlIndex]; Logger.LogDebug("Trying endpoint {Endpoint} (attempt {Attempt}/{Total})",
baseUrl, attempt + 1, _apiUrls.Count);
return await action(baseUrl); return await action(baseUrl);
} }
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogWarning(ex, "Request failed with endpoint {Endpoint}, trying next...", _apiUrls[_currentUrlIndex]); Logger.LogWarning(ex, "Request failed with endpoint {Endpoint}, trying next...", baseUrl);
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
if (attempt == _apiUrls.Count - 1) if (attempt == _apiUrls.Count - 1)
{ {
Logger.LogError("All SquidWTF endpoints failed"); Logger.LogError("All {Count} SquidWTF endpoints failed", _apiUrls.Count);
throw; throw;
} }
} }
@@ -113,7 +130,8 @@ 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;
var outputPath = PathHelper.BuildTrackPath(DownloadPath, artistForPath, song.Album, song.Title, song.Track, extension); var basePath = SubsonicSettings.StorageMode == StorageMode.Cache ? CachePath : DownloadPath;
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
var albumFolder = Path.GetDirectoryName(outputPath)!; var albumFolder = Path.GetDirectoryName(outputPath)!;

View File

@@ -23,6 +23,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
private readonly RedisCacheService _cache; private readonly RedisCacheService _cache;
private readonly List<string> _apiUrls; private readonly List<string> _apiUrls;
private int _currentUrlIndex = 0; private int _currentUrlIndex = 0;
private readonly object _urlIndexLock = new object();
public SquidWTFMetadataService( public SquidWTFMetadataService(
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
@@ -43,25 +44,52 @@ public class SquidWTFMetadataService : IMusicMetadataService
"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");
} }
private string GetCurrentBaseUrl() => _apiUrls[_currentUrlIndex]; /// <summary>
/// Gets the next URL in round-robin fashion to distribute load across providers
/// </summary>
private string GetNextBaseUrl()
{
lock (_urlIndexLock)
{
var url = _apiUrls[_currentUrlIndex];
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
return url;
}
}
/// <summary>
/// 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.
/// </summary>
private async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action, T defaultValue) private async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action, T defaultValue)
{ {
// Start with the next URL in round-robin to distribute load
var startIndex = 0;
lock (_urlIndexLock)
{
startIndex = _currentUrlIndex;
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
}
// Try all URLs starting from the round-robin selected one
for (int attempt = 0; attempt < _apiUrls.Count; attempt++) for (int attempt = 0; attempt < _apiUrls.Count; attempt++)
{ {
var urlIndex = (startIndex + attempt) % _apiUrls.Count;
var baseUrl = _apiUrls[urlIndex];
try try
{ {
var baseUrl = _apiUrls[_currentUrlIndex]; _logger.LogDebug("Trying endpoint {Endpoint} (attempt {Attempt}/{Total})",
baseUrl, attempt + 1, _apiUrls.Count);
return await action(baseUrl); return await action(baseUrl);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "Request failed with endpoint {Endpoint}, trying next...", _apiUrls[_currentUrlIndex]); _logger.LogWarning(ex, "Request failed with endpoint {Endpoint}, trying next...", baseUrl);
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
if (attempt == _apiUrls.Count - 1) if (attempt == _apiUrls.Count - 1)
{ {
_logger.LogError("All SquidWTF endpoints failed"); _logger.LogError("All {Count} SquidWTF endpoints failed", _apiUrls.Count);
return defaultValue; return defaultValue;
} }
} }
@@ -78,11 +106,18 @@ public class SquidWTFMetadataService : IMusicMetadataService
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
return new List<Song>(); throw new HttpRequestException($"HTTP {response.StatusCode}");
} }
var json = await response.Content.ReadAsStringAsync(); var json = await response.Content.ReadAsStringAsync();
// Check for error in response body
var result = JsonDocument.Parse(json); var result = JsonDocument.Parse(json);
if (result.RootElement.TryGetProperty("detail", out _) ||
result.RootElement.TryGetProperty("error", out _))
{
throw new HttpRequestException("API returned error response");
}
var songs = new List<Song>(); var songs = new List<Song>();
if (result.RootElement.TryGetProperty("data", out var data) && if (result.RootElement.TryGetProperty("data", out var data) &&
@@ -188,7 +223,15 @@ public class SquidWTFMetadataService : IMusicMetadataService
{ {
foreach(var playlist in items.EnumerateArray()) foreach(var playlist in items.EnumerateArray())
{ {
playlists.Add(ParseTidalPlaylist(playlist)); try
{
playlists.Add(ParseTidalPlaylist(playlist));
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to parse playlist, skipping");
// Skip this playlist and continue with others
}
} }
} }
return playlists; return playlists;
@@ -508,26 +551,36 @@ public class SquidWTFMetadataService : IMusicMetadataService
? volNum.GetInt32() ? volNum.GetInt32()
: null; : null;
// 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>();
string artistName = ""; string artistName = "";
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();
if (!string.IsNullOrEmpty(name))
{
allArtists.Add(name);
}
}
// First artist is the main artist
if (allArtists.Count > 0)
{
artistName = allArtists[0];
artistId = $"ext-squidwtf-artist-{artists[0].GetProperty("id").GetInt64()}";
}
}
// Fallback to singular "artist" field
else if (track.TryGetProperty("artist", out var artist))
{ {
artistName = artist.GetProperty("name").GetString() ?? ""; artistName = artist.GetProperty("name").GetString() ?? "";
} artistId = $"ext-squidwtf-artist-{artist.GetProperty("id").GetInt64()}";
else if (track.TryGetProperty("artists", out var artists) && artists.GetArrayLength() > 0) allArtists.Add(artistName);
{
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()}";
} }
// Get album info // Get album info
@@ -553,6 +606,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
Title = track.GetProperty("title").GetString() ?? "", Title = track.GetProperty("title").GetString() ?? "",
Artist = artistName, Artist = artistName,
ArtistId = artistId, ArtistId = artistId,
Artists = allArtists,
Album = albumTitle, Album = albumTitle,
AlbumId = albumId, AlbumId = albumId,
Duration = track.TryGetProperty("duration", out var duration) Duration = track.TryGetProperty("duration", out var duration)
@@ -606,9 +660,34 @@ public class SquidWTFMetadataService : IMusicMetadataService
} }
} }
// Get artist info // Get all artists - prefer "artists" array for collaborations
string artistName = track.GetProperty("artist").GetProperty("name").GetString() ?? ""; var allArtists = new List<string>();
long artistIdNum = track.GetProperty("artist").GetProperty("id").GetInt64(); 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();
if (!string.IsNullOrEmpty(name))
{
allArtists.Add(name);
}
}
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);
}
// Album artist - same as main artist for Tidal tracks // Album artist - same as main artist for Tidal tracks
string? albumArtist = artistName; string? albumArtist = artistName;
@@ -642,6 +721,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}",
Artists = allArtists,
Album = albumTitle, Album = albumTitle,
AlbumId = $"ext-squidwtf-album-{albumIdNum}", AlbumId = $"ext-squidwtf-album-{albumIdNum}",
AlbumArtist = albumArtist, AlbumArtist = albumArtist,

View File

@@ -14,6 +14,7 @@ public class SquidWTFStartupValidator : BaseStartupValidator
private readonly SquidWTFSettings _settings; private readonly SquidWTFSettings _settings;
private readonly List<string> _apiUrls; private readonly List<string> _apiUrls;
private int _currentUrlIndex = 0; private int _currentUrlIndex = 0;
private readonly object _urlIndexLock = new object();
public override string ServiceName => "SquidWTF"; public override string ServiceName => "SquidWTF";
@@ -24,22 +25,37 @@ public class SquidWTFStartupValidator : BaseStartupValidator
_apiUrls = apiUrls; _apiUrls = apiUrls;
} }
/// <summary>
/// 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.
/// </summary>
private async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action, T defaultValue) private async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action, T defaultValue)
{ {
// Start with the next URL in round-robin to distribute load
var startIndex = 0;
lock (_urlIndexLock)
{
startIndex = _currentUrlIndex;
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
}
// Try all URLs starting from the round-robin selected one
for (int attempt = 0; attempt < _apiUrls.Count; attempt++) for (int attempt = 0; attempt < _apiUrls.Count; attempt++)
{ {
var urlIndex = (startIndex + attempt) % _apiUrls.Count;
var baseUrl = _apiUrls[urlIndex];
try try
{ {
var baseUrl = _apiUrls[_currentUrlIndex];
return await action(baseUrl); return await action(baseUrl);
} }
catch catch
{ {
WriteDetail($"Endpoint {_apiUrls[_currentUrlIndex]} failed, trying next..."); WriteDetail($"Endpoint {baseUrl} failed, trying next...");
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
if (attempt == _apiUrls.Count - 1) if (attempt == _apiUrls.Count - 1)
{ {
WriteDetail($"All {_apiUrls.Count} endpoints failed");
return defaultValue; return defaultValue;
} }
} }

View File

@@ -22,9 +22,13 @@ public class StartupValidationOrchestrator : IHostedService
public async Task StartAsync(CancellationToken cancellationToken) public async Task StartAsync(CancellationToken cancellationToken)
{ {
// Get version from assembly
var version = typeof(StartupValidationOrchestrator).Assembly
.GetName().Version?.ToString(3) ?? "unknown";
Console.WriteLine(); Console.WriteLine();
Console.WriteLine("========================================"); Console.WriteLine("========================================");
Console.WriteLine(" allstarr starting up... "); Console.WriteLine($" allstarr v{version} ");
Console.WriteLine("========================================"); Console.WriteLine("========================================");
Console.WriteLine(); Console.WriteLine();

View File

@@ -5,11 +5,15 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>allstarr</RootNamespace> <RootNamespace>allstarr</RootNamespace>
<Version>1.0.0</Version>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" /> <PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
<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="StackExchange.Redis" Version="2.8.16" /> <PackageReference Include="StackExchange.Redis" Version="2.8.16" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
<PackageReference Include="TagLibSharp" Version="2.3.0" /> <PackageReference Include="TagLibSharp" Version="2.3.0" />

View File

@@ -4,5 +4,12 @@
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
} },
"SpotifyImport": {
"Enabled": false,
"SyncStartHour": 16,
"SyncStartMinute": 15,
"SyncWindowHours": 2,
"Playlists": []
}
} }

View File

@@ -3,7 +3,7 @@
"Type": "Subsonic" "Type": "Subsonic"
}, },
"Subsonic": { "Subsonic": {
"Url": "https://navidrome.local.bransonb.com", "Url": "http://localhost:4533",
"MusicService": "SquidWTF", "MusicService": "SquidWTF",
"ExplicitFilter": "All", "ExplicitFilter": "All",
"DownloadMode": "Track", "DownloadMode": "Track",
@@ -42,5 +42,21 @@
"Redis": { "Redis": {
"Enabled": true, "Enabled": true,
"ConnectionString": "localhost:6379" "ConnectionString": "localhost:6379"
},
"SpotifyImport": {
"Enabled": false,
"SyncStartHour": 16,
"SyncStartMinute": 15,
"SyncWindowHours": 2,
"Playlists": []
},
"SpotifyApi": {
"Enabled": false,
"ClientId": "",
"ClientSecret": "",
"SessionCookie": "",
"CacheDurationMinutes": 60,
"RateLimitDelayMs": 100,
"PreferIsrcMatching": true
} }
} }

1848
allstarr/wwwroot/index.html Normal file
View File

@@ -0,0 +1,1848 @@
<!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); }
@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: 500px;
width: 90%;
max-height: 80vh;
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">Active Playlists</div>
<div class="tab" data-tab="config">Configuration</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">
<div class="card">
<h2>
Active Spotify Playlists
<div class="actions">
<button onclick="matchAllPlaylists()">Match All Tracks</button>
<button onclick="refreshPlaylists()">Refresh All</button>
</div>
</h2>
<p style="color: var(--text-secondary); margin-bottom: 12px;">
These are the Spotify playlists currently being monitored and filled with tracks from your music service.
</p>
<table class="playlist-table">
<thead>
<tr>
<th>Name</th>
<th>Spotify ID</th>
<th>Tracks</th>
<th>Completion</th>
<th>Cache Age</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="playlist-table-body">
<tr>
<td colspan="6" class="loading">
<span class="spinner"></span> Loading playlists...
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Configuration Tab -->
<div class="tab-content" id="tab-config">
<div class="card">
<h2>Spotify API Settings</h2>
<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>
<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>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>
<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>
<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>
<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>Sync Schedule</h2>
<div class="config-section">
<div class="config-item">
<span class="label">Sync Start Time</span>
<span class="value" id="config-sync-time">-</span>
<button onclick="openEditSetting('SPOTIFY_IMPORT_SYNC_START_HOUR', 'Sync Start Hour (0-23)', 'number')">Edit</button>
</div>
<div class="config-item">
<span class="label">Sync Window</span>
<span class="value" id="config-sync-window">-</span>
<button onclick="openEditSetting('SPOTIFY_IMPORT_SYNC_WINDOW_HOURS', 'Sync Window (hours)', 'number')">Edit</button>
</div>
</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>
</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: 700px;">
<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 Local File</h3>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
This track is currently using an external provider. Search for and select the local Jellyfin track to use instead.
</p>
<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>
<div class="form-group">
<label>Search Jellyfin Tracks</label>
<input type="text" id="map-search-query" placeholder="Search by title or artist..." oninput="searchJellyfinTracks()">
</div>
<div id="map-search-results" style="max-height: 300px; overflow-y: auto; margin-top: 12px;">
<p style="text-align: center; color: var(--text-secondary); padding: 20px;">
Type to search for local tracks...
</p>
</div>
<input type="hidden" id="map-playlist-name">
<input type="hidden" id="map-spotify-id">
<input type="hidden" id="map-selected-jellyfin-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;">
Enter the Spotify playlist ID or URL. 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>
<div class="form-group">
<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>
<div class="modal-actions">
<button onclick="closeModal('link-playlist-modal')">Cancel</button>
<button class="primary" onclick="linkPlaylist()">Link Playlist</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);
}
});
// Toast notification
function showToast(message, type = 'success') {
const toast = document.createElement('div');
toast.className = 'toast ' + type;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
// 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() {
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) {
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;
// Build detailed stats string
let statsHtml = `<span class="track-count">${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
const completionPct = spotifyTotal > 0 ? Math.round((totalInJellyfin / spotifyTotal) * 100) : 0;
const completionColor = completionPct === 100 ? 'var(--success)' : completionPct >= 80 ? 'var(--accent)' : 'var(--warning)';
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>${statsHtml}${breakdown}</td>
<td>
<div style="display:flex;align-items:center;gap:8px;">
<div style="flex:1;background:var(--bg-tertiary);height:6px;border-radius:3px;overflow:hidden;">
<div style="width:${completionPct}%;height:100%;background:${completionColor};transition:width 0.3s;"></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="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 fetchConfig() {
try {
const res = await fetch('/api/admin/config');
const data = await res.json();
// 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;
// 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 || '-';
// Sync settings
const syncHour = data.spotifyImport.syncStartHour;
const syncMin = data.spotifyImport.syncStartMinute;
document.getElementById('config-sync-time').textContent = `${String(syncHour).padStart(2, '0')}:${String(syncMin).padStart(2, '0')}`;
document.getElementById('config-sync-window').textContent = data.spotifyImport.syncWindowHours + ' 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>';
}
}
function openLinkPlaylist(jellyfinId, name) {
document.getElementById('link-jellyfin-id').value = jellyfinId;
document.getElementById('link-jellyfin-name').value = name;
document.getElementById('link-spotify-id').value = '';
openModal('link-playlist-modal');
}
async function linkPlaylist() {
const jellyfinId = document.getElementById('link-jellyfin-id').value;
const name = document.getElementById('link-jellyfin-name').value;
const 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 })
});
const data = await res.json();
if (res.ok) {
showToast('Playlist linked!', 'success');
showRestartBanner();
closeModal('link-playlist-modal');
// 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();
// 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 matchPlaylistTracks(name) {
try {
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, 2000);
} else {
showToast(data.error || 'Failed to match tracks', 'error');
}
} catch (error) {
showToast('Failed to match tracks', 'error');
}
}
async function matchAllPlaylists() {
if (!confirm('Match tracks for ALL playlists? This may take a few minutes.')) return;
try {
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, 3000);
} else {
showToast(data.error || 'Failed to match tracks', 'error');
}
} catch (error) {
showToast('Failed to match tracks', 'error');
}
}
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 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 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');
const data = await res.json();
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 = '';
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>';
} else if (t.isLocal === false) {
statusBadge = '<span class="status-badge warning" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>External</span>';
// Add manual map button for external tracks
// Use JSON.stringify to properly escape strings for JavaScript
const escapedName = JSON.stringify(name);
const escapedTitle = JSON.stringify(t.title || '');
// Safely get first artist, defaulting to empty string
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
const escapedArtist = JSON.stringify(firstArtist);
const escapedSpotifyId = JSON.stringify(t.spotifyId || '');
mapButton = `<button class="small" onclick="openManualMap(${escapedName}, ${t.position}, ${escapedTitle}, ${escapedArtist}, ${escapedSpotifyId})" style="margin-left:8px;font-size:0.75rem;padding:4px 8px;">Map to Local</button>`;
}
return `
<div class="track-item">
<span class="track-position">${t.position + 1}</span>
<div class="track-info">
<h4>${escapeHtml(t.title)}${statusBadge}${mapButton}</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>' : ''}
</div>
</div>
`;
}).join('');
} catch (error) {
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Failed to load tracks</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;
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;
document.getElementById('map-search-query').value = '';
document.getElementById('map-selected-jellyfin-id').value = '';
document.getElementById('map-save-btn').disabled = true;
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">Type to search for local tracks...</p>';
openModal('manual-map-modal');
}
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...</p>';
return;
}
// 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);
}
function selectJellyfinTrack(jellyfinId, element) {
// Remove selection from all tracks
document.querySelectorAll('#map-search-results .track-item').forEach(el => {
el.style.border = '2px solid transparent';
});
// Highlight selected track
element.style.border = '2px solid var(--primary)';
// Store selected ID and enable save button
document.getElementById('map-selected-jellyfin-id').value = jellyfinId;
document.getElementById('map-save-btn').disabled = false;
}
async function saveManualMapping() {
const playlistName = document.getElementById('map-playlist-name').value;
const spotifyId = document.getElementById('map-spotify-id').value;
const jellyfinId = document.getElementById('map-selected-jellyfin-id').value;
if (!jellyfinId) {
showToast('Please select a track', 'error');
return;
}
try {
const res = await fetch('/api/admin/playlists/' + encodeURIComponent(playlistName) + '/map', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ spotifyId, jellyfinId })
});
const data = await res.json();
if (res.ok) {
showToast('Track mapped successfully! Refresh the playlist to see changes.', 'success');
closeModal('manual-map-modal');
// Refresh the tracks view
viewTracks(playlistName);
} else {
showToast(data.error || 'Failed to save mapping', 'error');
}
} catch (error) {
showToast('Failed to save mapping', 'error');
}
}
function escapeJs(text) {
if (!text) return '';
return text.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"');
}
// Initial load
fetchStatus();
fetchPlaylists();
fetchJellyfinUsers();
fetchJellyfinPlaylists();
fetchConfig();
// Auto-refresh every 30 seconds
setInterval(() => {
fetchStatus();
fetchPlaylists();
}, 30000);
</script>
</body>
</html>

View File

@@ -6,12 +6,14 @@ services:
# Redis is only accessible internally - no external port exposure # Redis is only accessible internally - no external port exposure
expose: expose:
- "6379" - "6379"
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru command: redis-server --maxmemory 1gb --maxmemory-policy allkeys-lru --save 60 1 --appendonly yes
healthcheck: healthcheck:
test: ["CMD", "redis-cli", "ping"] test: ["CMD", "redis-cli", "ping"]
interval: 10s interval: 10s
timeout: 3s timeout: 3s
retries: 3 retries: 3
volumes:
- ${REDIS_DATA_PATH:-./redis-data}:/data
networks: networks:
- allstarr-network - allstarr-network
@@ -32,6 +34,9 @@ services:
restart: unless-stopped restart: unless-stopped
ports: ports:
- "5274:8080" - "5274:8080"
# Admin UI on port 5275 - for local/Tailscale access only
# DO NOT expose through reverse proxy - contains sensitive config
- "5275:5275"
depends_on: depends_on:
redis: redis:
condition: service_healthy condition: service_healthy
@@ -74,6 +79,26 @@ services:
- Jellyfin__CacheDurationHours=${CACHE_DURATION_HOURS:-1} - Jellyfin__CacheDurationHours=${CACHE_DURATION_HOURS:-1}
- Jellyfin__EnableExternalPlaylists=${ENABLE_EXTERNAL_PLAYLISTS:-true} - Jellyfin__EnableExternalPlaylists=${ENABLE_EXTERNAL_PLAYLISTS:-true}
# ===== SPOTIFY PLAYLIST INJECTION (JELLYFIN ONLY) =====
- SpotifyImport__Enabled=${SPOTIFY_IMPORT_ENABLED:-false}
- SpotifyImport__SyncStartHour=${SPOTIFY_IMPORT_SYNC_START_HOUR:-16}
- SpotifyImport__SyncStartMinute=${SPOTIFY_IMPORT_SYNC_START_MINUTE:-15}
- SpotifyImport__SyncWindowHours=${SPOTIFY_IMPORT_SYNC_WINDOW_HOURS:-2}
- SpotifyImport__Playlists=${SPOTIFY_IMPORT_PLAYLISTS:-}
- SpotifyImport__PlaylistIds=${SPOTIFY_IMPORT_PLAYLIST_IDS:-}
- SpotifyImport__PlaylistNames=${SPOTIFY_IMPORT_PLAYLIST_NAMES:-}
- SpotifyImport__PlaylistLocalTracksPositions=${SPOTIFY_IMPORT_PLAYLIST_LOCAL_TRACKS_POSITIONS:-}
# ===== SPOTIFY DIRECT API (for lyrics, ISRC matching, track ordering) =====
- SpotifyApi__Enabled=${SPOTIFY_API_ENABLED:-false}
- SpotifyApi__ClientId=${SPOTIFY_API_CLIENT_ID:-}
- SpotifyApi__ClientSecret=${SPOTIFY_API_CLIENT_SECRET:-}
- SpotifyApi__SessionCookie=${SPOTIFY_API_SESSION_COOKIE:-}
- SpotifyApi__SessionCookieSetDate=${SPOTIFY_API_SESSION_COOKIE_SET_DATE:-}
- SpotifyApi__CacheDurationMinutes=${SPOTIFY_API_CACHE_DURATION_MINUTES:-60}
- SpotifyApi__RateLimitDelayMs=${SPOTIFY_API_RATE_LIMIT_DELAY_MS:-100}
- SpotifyApi__PreferIsrcMatching=${SPOTIFY_API_PREFER_ISRC_MATCHING:-true}
# ===== SHARED ===== # ===== SHARED =====
- Library__DownloadPath=/app/downloads - Library__DownloadPath=/app/downloads
- SquidWTF__Quality=${SQUIDWTF_QUALITY:-FLAC} - SquidWTF__Quality=${SQUIDWTF_QUALITY:-FLAC}
@@ -85,6 +110,12 @@ services:
- Qobuz__Quality=${QOBUZ_QUALITY:-FLAC} - Qobuz__Quality=${QOBUZ_QUALITY:-FLAC}
volumes: volumes:
- ${DOWNLOAD_PATH:-./downloads}:/app/downloads - ${DOWNLOAD_PATH:-./downloads}:/app/downloads
- ${KEPT_PATH:-./kept}:/app/kept
- ${CACHE_PATH:-./cache}:/app/cache
# Mount .env file for runtime configuration updates from admin UI
- ./.env:/app/.env
# Docker socket for self-restart capability (admin UI only)
- /var/run/docker.sock:/var/run/docker.sock:ro
networks: networks:
allstarr-network: allstarr-network:

50
test-websocket.html Normal file
View File

@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html>
<head>
<title>WebSocket Test</title>
</head>
<body>
<h1>Jellyfin WebSocket Test</h1>
<div id="status">Connecting...</div>
<div id="messages"></div>
<script>
// Replace with your actual token and device ID
const token = "4d19e81402394d40a7e787222606b3c2";
const deviceId = "test-device-123";
// Connect to your proxy
const wsUrl = `ws://jfm.joshpatra.me/socket?api_key=${token}&deviceId=${deviceId}`;
console.log("Connecting to:", wsUrl);
document.getElementById('status').textContent = `Connecting to: ${wsUrl}`;
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log("✓ WebSocket connected!");
document.getElementById('status').textContent = "✓ Connected!";
document.getElementById('status').style.color = "green";
};
ws.onmessage = (event) => {
console.log("Message received:", event.data);
const msgDiv = document.createElement('div');
msgDiv.textContent = `[${new Date().toLocaleTimeString()}] ${event.data}`;
document.getElementById('messages').appendChild(msgDiv);
};
ws.onerror = (error) => {
console.error("WebSocket error:", error);
document.getElementById('status').textContent = "✗ Error!";
document.getElementById('status').style.color = "red";
};
ws.onclose = (event) => {
console.log("WebSocket closed:", event.code, event.reason);
document.getElementById('status').textContent = `✗ Closed (${event.code})`;
document.getElementById('status').style.color = "orange";
};
</script>
</body>
</html>