Compare commits

..

143 Commits

Author SHA1 Message Date
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
55 changed files with 1946 additions and 17589 deletions

View File

@@ -18,30 +18,28 @@ SUBSONIC_URL=http://localhost:4533
# Server URL (required if using Jellyfin backend)
JELLYFIN_URL=http://localhost:8096
# API key for SERVER-SIDE operations only (get from Jellyfin Dashboard > API Keys)
# This is used by Allstarr to query Jellyfin's library on behalf of the server
# CLIENT authentication is handled transparently - clients authenticate directly with Jellyfin
# API key for authentication (get from Jellyfin Dashboard > API Keys)
JELLYFIN_API_KEY=
# User ID for SERVER-SIDE library queries (get from Jellyfin Dashboard > Users > click user > check URL)
# This determines which user's library Allstarr queries when searching/browsing
# User ID (get from Jellyfin Dashboard > Users > click user > check URL)
JELLYFIN_USER_ID=
# Music library ID (optional, auto-detected if not set)
# If you have multiple libraries, set this to filter to music only
JELLYFIN_LIBRARY_ID=
# ===== MUSIC SOURCE SELECTION =====
# Music service to use: SquidWTF, Deezer, or Qobuz (default: SquidWTF)
MUSIC_SERVICE=SquidWTF
# Base directory for all downloads (default: ./downloads)
# This creates three subdirectories:
# - downloads/permanent/ - Permanently downloaded tracks (STORAGE_MODE=Permanent)
# - downloads/cache/ - Temporarily cached tracks (STORAGE_MODE=Cache)
# - downloads/kept/ - Favorited external tracks (always permanent)
# Path where downloaded songs will be stored on the host (only applies if STORAGE_MODE=Permanent)
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 =====
# Different quality options for SquidWTF. Only FLAC supported right now
SQUIDWTF_QUALITY=FLAC
@@ -110,75 +108,31 @@ 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 and fills them with tracks from external providers
# This feature intercepts Spotify Import plugin playlists (Release Radar, Discover Weekly) and fills them
# with tracks auto-matched from external providers (SquidWTF, Deezer, Qobuz)
# Uses JELLYFIN_URL and JELLYFIN_API_KEY configured above (no separate credentials needed)
# Enable Spotify playlist injection (optional, default: false)
SPOTIFY_IMPORT_ENABLED=false
# Matching interval: How often to run track matching (in hours)
# Spotify playlists like Discover Weekly update once per week, Release Radar updates weekly
# Set to 0 to only run once on startup (manual trigger via admin UI still works)
# Default: 24 hours
SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS=24
# 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
# 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=[]
# 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
# ===== 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)
# Playlist IDs to inject (comma-separated)
# Get IDs from Jellyfin playlist URLs: https://jellyfin.example.com/web/#/details?id=PLAYLIST_ID
# Example: SPOTIFY_IMPORT_PLAYLIST_IDS=4383a46d8bcac3be2ef9385053ea18df,ba50e26c867ec9d57ab2f7bf24cfd6b0
SPOTIFY_IMPORT_PLAYLIST_IDS=
# 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
# Spotify Lyrics API URL (default: http://spotify-lyrics:8080)
# Uses the spotify-lyrics-api sidecar service for fetching synchronized lyrics
# This service is automatically started in docker-compose
# Leave as default unless running a custom deployment
SPOTIFY_LYRICS_API_URL=http://spotify-lyrics:8080
# Playlist names (comma-separated, must match Spotify Import plugin format)
# IMPORTANT: Use the exact playlist names as they appear in Jellyfin
# Must be in same order as SPOTIFY_IMPORT_PLAYLIST_IDS
# Example: SPOTIFY_IMPORT_PLAYLIST_NAMES=Discover Weekly,Release Radar
SPOTIFY_IMPORT_PLAYLIST_NAMES=

18
.gitignore vendored
View File

@@ -84,26 +84,12 @@ cache/
redis-data/
# API keys and specs (ignore markdown docs, keep OpenAPI spec)
apis/steering/
apis/api-calls/*.json
!apis/api-calls/jellyfin-openapi-stable.json
apis/temp.json
# Temporary documentation files
apis/*.md
# Log files for debugging
apis/api-calls/*.log
# Endpoint usage tracking
apis/api-calls/endpoint-usage.json
/app/cache/endpoint-usage/
apis/*.json
!apis/jellyfin-openapi-stable.json
# Original source code for reference
originals/
# Sample missing playlists for Spotify integration testing
sampleMissingPlaylists/
# Migration guide (local only)
MIGRATION.md

View File

@@ -24,8 +24,7 @@ RUN mkdir -p /app/downloads
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
ENV ASPNETCORE_URLS=http://+:8080
ENTRYPOINT ["dotnet", "allstarr.dll"]

344
README.md
View File

@@ -5,7 +5,11 @@
[![Docker Image](https://img.shields.io/badge/docker-ghcr.io%2Fsopat712%2Fallstarr-blue)](https://github.com/SoPat712/allstarr/pkgs/container/allstarr)
[![License](https://img.shields.io/badge/license-GPL--3.0-green)](LICENSE)
A media server proxy that integrates music streaming providers with your local library. Works with **Jellyfin** and **Subsonic-compatible** servers. When a song isn't in your local library, it gets fetched from your configured provider, downloaded, and served to your client. The downloaded song then lives in your library for next time.
A media server proxy that integrates music streaming providers with your local library. Works with **Jellyfin** and **Subsonic-compatible** servers (Navidrome). When a song isn't in your local library, it gets fetched from your configured provider, downloaded, and served to your client. The downloaded song then lives in your library for next time.
**THIS IS UNDER ACTIVE DEVELOPMENT**
Please report all bugs as soon as possible, as the Jellyfin addition is entirely a test at this point
## Quick Start
@@ -34,48 +38,6 @@ docker-compose logs -f
The proxy will be available at `http://localhost:5274`.
## Web Dashboard
Allstarr includes a web UI for easy configuration and playlist management, accessible at `http://localhost:5275`
### Features
- **Playlist Management**: Link Jellyfin playlists to Spotify playlists with just a few clicks
- **Provider Matching**: It should fill in the gaps of your Jellyfin library with tracks from your selected provider
- **WebUI**: Update settings without manually editing .env files
- **Music**: Using multiple sources for music (optimized for SquidWTF right now, though)
- **Lyrics**: Using multiple sources for lyrics, first Jellyfin Lyrics, then Spotify Lyrics, then LrcLib as a last resort
### Quick Setup with Web UI
1. **Access the dashboard** at `http://localhost:5275`
2. **Configure Spotify** (Configuration tab):
- Enable Spotify API
- Add your `sp_dc` cookie from Spotify (see instructions in UI)
- The cookie age is automatically tracked
3. **Link playlists** (Link Playlists tab):
- View all your Jellyfin playlists
- Click "Link to Spotify" on any playlist
- Paste the Spotify playlist ID, URL, or `spotify:playlist:` URI
- Accepts formats like:
- `37i9dQZF1DXcBWIGoYBM5M` (just the ID)
- `spotify:playlist:37i9dQZF1DXcBWIGoYBM5M` (Spotify URI)
- `https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M` (full URL)
4. **Restart** to apply changes (should be a banner)
Then, proceeed to **Active Playlists**, which shows you which Spotify playlists are currently being monitored and filled with tracks, and lets you do a bunch of useful operations on them.
### Configuration Persistence
The web UI updates your `.env` file directly. Changes persist across container restarts, but require a restart to take effect. In development mode, the `.env` file is in your project root. In Docker, it's at `/app/.env`.
There's an environment variable to modify this.
**Recommended workflow**: Use the `sp_dc` cookie method alongside the [Spotify Import Plugin](https://github.com/Viperinius/jellyfin-plugin-spotify-import?tab=readme-ov-file).
### Nginx Proxy Setup (Required)
This service only exposes ports internally. You can use nginx to proxy to it, however PLEASE take significant precautions before exposing this! Everyone decides their own level of risk, but this is currently untested, potentially dangerous software, with almost unfettered access to your Jellyfin server. My recommendation is use Tailscale or something similar!
@@ -140,7 +102,6 @@ This project brings together all the music streaming providers into one unified
- [Feishin](https://github.com/jeffvli/feishin) (Mac/Windows/Linux)
- [Musiver](https://music.aqzscn.cn/en/) (Android/IOS/Windows/Android)
- [Finamp](https://github.com/jmshrv/finamp) ()
_Working on getting more currently_
@@ -334,7 +295,7 @@ Subsonic__EnableExternalPlaylists=false
### Spotify Playlist Injection (Jellyfin Only)
Allstarr automatically fills your Spotify playlists (like Release Radar and Discover Weekly) with tracks from your configured streaming provider (SquidWTF, Deezer, or Qobuz). This works by intercepting playlists created by the Jellyfin Spotify Import plugin and matching missing tracks with your streaming service.
Allstarr can automatically fill your Spotify playlists (like Release Radar and Discover Weekly) with tracks from your configured streaming provider (SquidWTF, Deezer, or Qobuz). This feature works by intercepting playlists created by the Jellyfin Spotify Import plugin and matching missing tracks with your streaming service.
#### Prerequisites
@@ -348,112 +309,133 @@ Allstarr automatically fills your Spotify playlists (like Release Radar and Disc
- Go to Jellyfin Dashboard → Plugins → Spotify Import
- Connect your Spotify account
- Select which playlists to sync (e.g., Release Radar, Discover Weekly)
- Set a sync schedule (the plugin will create playlists in Jellyfin)
- 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**
- Enable Spotify Import in Allstarr (see configuration below)
- Link your Jellyfin playlists to Spotify playlists via the Web UI
- Uses your existing `JELLYFIN_URL` and `JELLYFIN_API_KEY` settings
- 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:MatchingIntervalHours` | How often to run track matching in hours (default: 24, set to 0 for startup only) |
| `SpotifyImport:Playlists` | JSON array of playlists (managed via Web UI) |
| `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
# Matching interval (24 hours = once per day)
SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS=24
# 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
# Playlists (use Web UI to manage instead of editing manually)
SPOTIFY_IMPORT_PLAYLISTS=[["Discover Weekly","37i9dQZEVXcV6s7Dm7RXsU","first"],["Release Radar","37i9dQZEVXbng2vDHnfQlC","first"]]
# 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**
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 Matches Tracks** (on startup + every 24 hours by default)
- Reads missing tracks files from the Jellyfin plugin
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)
- Pre-builds playlist cache for instant loading
- Caches matched results for 1 hour
3. **You Open the Playlist in Jellyfin**
4. **You Open the Playlist in Jellyfin**
- Allstarr intercepts the request
- Returns a merged list: local tracks + matched streaming tracks
- Loads instantly from cache!
- Loads instantly from cache (no searching needed!)
4. **You Play a Track**
- Local tracks stream from Jellyfin normally
- Matched tracks download from streaming provider on-demand
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 API Triggers
#### Manual Triggers
You can manually trigger operations via the admin API:
You can manually trigger syncing and matching via API:
```bash
# Get API key from your .env file
API_KEY="your-api-key-here"
# Fetch missing tracks from Jellyfin plugin
curl "http://localhost:5274/spotify/sync?api_key=$API_KEY"
curl "https://your-jellyfin-proxy.com/spotify/sync?api_key=YOUR_API_KEY"
# Trigger track matching (searches streaming provider)
curl "http://localhost:5274/spotify/match?api_key=$API_KEY"
curl "https://your-jellyfin-proxy.com/spotify/match?api_key=YOUR_API_KEY"
# Match all playlists (refresh all matches)
curl "http://localhost:5274/spotify/match-all?api_key=$API_KEY"
# Clear cache and rebuild
curl "http://localhost:5274/spotify/clear-cache?api_key=$API_KEY"
# Refresh specific playlist
curl "http://localhost:5274/spotify/refresh-playlist?playlistId=PLAYLIST_ID&api_key=$API_KEY"
# Clear cache to force re-matching
curl "https://your-jellyfin-proxy.com/spotify/clear-cache?api_key=YOUR_API_KEY"
```
#### Web UI Management
#### Startup Behavior
The easiest way to manage Spotify playlists is through the Web UI at `http://localhost:5275`:
When Allstarr starts with Spotify Import enabled:
1. **Link Playlists Tab**: Link Jellyfin playlists to Spotify playlists
2. **Active Playlists Tab**: View status, trigger matching, and manage playlists
3. **Configuration Tab**: Enable/disable Spotify Import and adjust settings
**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 playlists are linked in the Web UI
- Verify `SPOTIFY_IMPORT_PLAYLIST_IDS` match your Jellyfin playlist IDs
- Check logs: `docker-compose logs -f allstarr | grep -i spotify`
**Tracks aren't matching:**
- Ensure your streaming provider is configured (`MUSIC_SERVICE`, credentials)
- Manually trigger matching via Web UI or API
- Check that the Jellyfin plugin generated missing tracks files
- Check that playlist names in `SPOTIFY_IMPORT_PLAYLIST_NAMES` match exactly
- Manually trigger matching: `curl "https://your-proxy.com/spotify/match?api_key=KEY"`
**Performance:**
- Matching runs in background with rate limiting (150ms between searches)
- First match may take a few minutes for large playlists
- Subsequent loads are instant (served from cache)
**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
- Uses your existing `JELLYFIN_URL` and `JELLYFIN_API_KEY` settings
- Matched tracks cached for fast loading
- Missing tracks cache persists across restarts (Redis + file cache)
- Rate limiting prevents overwhelming your streaming provider
- 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
@@ -567,46 +549,9 @@ If you prefer to run Allstarr without Docker:
## API Endpoints
### Jellyfin Backend (Primary Focus)
The proxy provides comprehensive Jellyfin API support with streaming provider integration:
| Endpoint | Description |
|----------|-------------|
| `GET /Items` | Search and browse library items (local + streaming providers) |
| `GET /Artists` | Browse artists with merged results from local + streaming |
| `GET /Artists/AlbumArtists` | Album artists with streaming provider results |
| `GET /Users/{userId}/Items` | User library items with external content |
| `GET /Audio/{id}/stream` | Stream audio, downloading from provider on-demand |
| `GET /Audio/{id}/Lyrics` | Lyrics from Jellyfin, Spotify, or LRCLib |
| `GET /Items/{id}/Images/{type}` | Proxy cover art for external content |
| `GET /Playlists/{id}/Items` | Playlist items (Spotify Import integration) |
| `POST /UserFavoriteItems/{id}` | Favorite items; copies external tracks to kept folder |
| `DELETE /UserFavoriteItems/{id}` | Unfavorite items |
| `POST /Sessions/Playing` | Playback reporting for external tracks |
| `POST /Sessions/Playing/Progress` | Playback progress tracking |
| `POST /Sessions/Playing/Stopped` | Playback stopped reporting |
| `WebSocket /socket` | Real-time session management and remote control |
**Admin API (Port 5275):**
| Endpoint | Description |
|----------|-------------|
| `GET /api/config` | Get current configuration |
| `POST /api/config` | Update configuration |
| `GET /api/playlists` | List Spotify Import playlists |
| `POST /api/playlists/link` | Link Jellyfin playlist to Spotify |
| `DELETE /api/playlists/{id}` | Unlink playlist |
| `POST /spotify/sync` | Fetch missing tracks from Jellyfin plugin |
| `POST /spotify/match` | Trigger track matching |
| `POST /spotify/match-all` | Match all playlists |
| `POST /spotify/clear-cache` | Clear playlist cache |
| `POST /spotify/refresh-playlist` | Refresh specific playlist |
All other Jellyfin API endpoints are passed through unchanged.
### Subsonic Backend
The proxy implements the Subsonic API with streaming provider integration:
The proxy implements the Subsonic API and adds transparent streaming provider integration:
| Endpoint | Description |
|----------|-------------|
@@ -620,6 +565,20 @@ The proxy implements the Subsonic API with streaming provider integration:
All other Subsonic API endpoints are passed through to Navidrome unchanged.
### Jellyfin Backend
The proxy implements a subset of the Jellyfin API:
| Endpoint | Description |
|----------|-------------|
| `GET /Items` | Search and browse library items |
| `GET /Artists` | Browse artists with streaming provider results |
| `GET /Audio/{id}/stream` | Stream audio, downloading from provider if needed |
| `GET /Items/{id}/Images/{type}` | Proxy cover art for external content |
| `POST /UserFavoriteItems/{id}` | Favorite items; triggers playlist download |
All other Jellyfin API endpoints are passed through unchanged.
## External ID Format
External (streaming provider) content uses typed IDs:
@@ -634,37 +593,25 @@ Legacy format `ext-deezer-{id}` is also supported (assumes song type).
## Download Folder Structure
All downloads are organized under a single base directory (default: `./downloads`):
Downloaded music is organized as:
```
downloads/
├── permanent/ # Permanent downloads (STORAGE_MODE=Permanent)
│ ├── Artist Name/
│ │ ├── Album Title/
│ │ ├── 01 - Track One.flac
│ │ │ ├── 02 - Track Two.flac
│ │ │ └── ...
│ │ └── Another Album/
├── Artist Name/
│ ├── Album Title/
│ │ ├── 01 - Track One.mp3
│ │ ├── 02 - Track Two.mp3
│ │ └── ...
│ └── playlists/
── My Favorite Songs.m3u
│ └── Chill Vibes.m3u
├── cache/ # Temporary cache (STORAGE_MODE=Cache)
│ └── Artist Name/
── Album Title/
│ └── Track.flac
└── kept/ # Favorited external tracks (always permanent)
└── Artist Name/
└── Album Title/
└── Track.flac
│ └── Another Album/
── ...
├── Another Artist/
│ └── ...
└── playlists/
── My Favorite Songs.m3u
├── Chill Vibes.m3u
└── ...
```
**Storage modes:**
- **Permanent** (`downloads/permanent/`): Files saved permanently and registered in your media server
- **Cache** (`downloads/cache/`): Temporary files, auto-cleaned after `CACHE_DURATION_HOURS`
- **Kept** (`downloads/kept/`): External tracks you've favorited - always permanent, separate from cache
Playlists are stored as M3U files with relative paths, making them portable and compatible with most music players.
Playlists are stored as M3U files with relative paths to downloaded tracks, making them portable and compatible with most music players.
## Metadata Embedding
@@ -695,17 +642,10 @@ dotnet test
```
allstarr/
├── Controllers/
│ ├── AdminController.cs # Admin dashboard API
── JellyfinController.cs # Jellyfin API controller
│ └── SubsonicController.cs # Subsonic API controller
├── Filters/
│ ├── AdminPortFilter.cs # Admin port access control
│ ├── ApiKeyAuthFilter.cs # API key authentication
│ └── JellyfinAuthFilter.cs # Jellyfin authentication
│ ├── JellyfinController.cs # Jellyfin API controller (registered when Backend:Type=Jellyfin)
── SubsonicController.cs # Subsonic API controller (registered when Backend:Type=Subsonic)
├── Middleware/
── AdminStaticFilesMiddleware.cs # Admin UI static file serving
│ ├── GlobalExceptionHandler.cs # Global error handling
│ └── WebSocketProxyMiddleware.cs # WebSocket proxying for Jellyfin
── GlobalExceptionHandler.cs # Global error handling
├── Models/
│ ├── Domain/ # Domain entities
│ │ ├── Song.cs
@@ -714,39 +654,18 @@ allstarr/
│ ├── Settings/ # Configuration models
│ │ ├── SubsonicSettings.cs
│ │ ├── DeezerSettings.cs
│ │ ── QobuzSettings.cs
│ │ ├── SquidWTFSettings.cs
│ │ ├── SpotifyApiSettings.cs
│ │ ├── SpotifyImportSettings.cs
│ │ ├── MusicBrainzSettings.cs
│ │ └── RedisSettings.cs
│ │ ── QobuzSettings.cs
│ ├── Download/ # Download-related models
│ │ ├── DownloadInfo.cs
│ │ └── DownloadStatus.cs
│ ├── Lyrics/
│ │ └── LyricsInfo.cs
│ ├── Search/
│ │ └── SearchResult.cs
│ ├── Spotify/
│ │ ├── MissingTrack.cs
│ │ └── SpotifyPlaylistTrack.cs
│ └── Subsonic/
│ ├── ExternalPlaylist.cs
│ └── ScanStatus.cs
├── Services/
│ ├── Common/ # Shared services
│ │ ├── BaseDownloadService.cs # Template method base class
│ │ ├── CacheCleanupService.cs # Cache cleanup background service
│ │ ├── CacheWarmingService.cs # Startup cache warming
│ │ ├── EndpointBenchmarkService.cs # Endpoint performance benchmarking
│ │ ├── FuzzyMatcher.cs # Fuzzy string matching
│ │ ├── GenreEnrichmentService.cs # MusicBrainz genre enrichment
│ │ ├── OdesliService.cs # Odesli/song.link conversion
│ │ ├── ParallelMetadataService.cs # Parallel metadata fetching
│ │ ├── PathHelper.cs # Path utilities
│ │ ├── PlaylistIdHelper.cs # Playlist ID helpers
│ │ ├── RedisCacheService.cs # Redis caching
│ │ ├── RoundRobinFallbackHelper.cs # Load balancing and failover
│ │ ├── Result.cs # Result<T> pattern
│ │ └── Error.cs # Error types
│ ├── Deezer/ # Deezer provider
@@ -758,35 +677,12 @@ allstarr/
│ │ ├── QobuzMetadataService.cs
│ │ ├── QobuzBundleService.cs
│ │ └── QobuzStartupValidator.cs
│ ├── SquidWTF/ # SquidWTF provider
│ │ ├── SquidWTFDownloadService.cs
│ │ ├── SquidWTFMetadataService.cs
│ │ └── SquidWTFStartupValidator.cs
│ ├── Jellyfin/ # Jellyfin integration
│ │ ├── JellyfinModelMapper.cs # Model mapping
│ │ ├── JellyfinProxyService.cs # Request proxying
│ │ ├── JellyfinResponseBuilder.cs # Response building
│ │ ├── JellyfinSessionManager.cs # Session management
│ │ └── JellyfinStartupValidator.cs # Startup validation
│ ├── Lyrics/ # Lyrics services
│ │ ├── LrclibService.cs # LRCLIB lyrics
│ │ ├── LyricsPrefetchService.cs # Background lyrics prefetching
│ │ ├── LyricsStartupValidator.cs # Lyrics validation
│ │ └── SpotifyLyricsService.cs # Spotify lyrics
│ ├── MusicBrainz/
│ │ └── MusicBrainzService.cs # MusicBrainz metadata
│ ├── Spotify/ # Spotify integration
│ │ ├── SpotifyApiClient.cs # Spotify API client
│ │ ├── SpotifyMissingTracksFetcher.cs # Missing tracks fetcher
│ │ ├── SpotifyPlaylistFetcher.cs # Playlist fetcher
│ │ └── SpotifyTrackMatchingService.cs # Track matching
│ ├── Local/ # Local library
│ │ ├── ILocalLibraryService.cs
│ │ └── LocalLibraryService.cs
│ ├── Subsonic/ # Subsonic API logic
│ │ ├── PlaylistSyncService.cs # Playlist synchronization
│ │ ├── SubsonicModelMapper.cs # Model mapping
│ │ ├── SubsonicProxyService.cs # Request proxying
│ │ ├── SubsonicModelMapper.cs # Model mapping
│ │ ├── SubsonicRequestParser.cs # Request parsing
│ │ └── SubsonicResponseBuilder.cs # Response building
│ ├── Validation/ # Startup validation
@@ -798,17 +694,13 @@ allstarr/
│ ├── IDownloadService.cs # Download interface
│ ├── IMusicMetadataService.cs # Metadata interface
│ └── StartupValidationService.cs
├── wwwroot/ # Admin UI static files
│ ├── index.html # Admin dashboard
│ └── placeholder.png # Placeholder image
├── Program.cs # Application entry point
└── appsettings.json # Configuration
allstarr.Tests/
├── DeezerDownloadServiceTests.cs # Deezer download tests
├── DeezerMetadataServiceTests.cs # Deezer metadata tests
├── JellyfinResponseStructureTests.cs # Jellyfin response tests
├── QobuzDownloadServiceTests.cs # Qobuz download tests
├── QobuzDownloadServiceTests.cs # Qobuz download tests (127 tests)
├── LocalLibraryServiceTests.cs # Local library tests
├── SubsonicModelMapperTests.cs # Model mapping tests
├── SubsonicProxyServiceTests.cs # Proxy service tests
@@ -882,7 +774,7 @@ We welcome contributions! Here's how to get started:
- Follow existing code patterns and conventions
- Add tests for new features
- Update documentation as needed
- Keep commits feature focused
- Keep commits focused and atomic
### Testing
@@ -904,14 +796,8 @@ GPL-3.0
## Acknowledgments
- [octo-fiesta](https://github.com/V1ck3s/octo-fiesta) - The original
- [octo-fiestarr](https://github.com/bransoned/octo-fiestarr) - The fork that introduced me to this idea based on the above
- [Jellyfin Spotify Import Plugin](https://github.com/Viperinius/jellyfin-plugin-spotify-import?tab=readme-ov-file) - The plugin that I **strongly** recommend using alongside this repo
- [Jellyfin](https://jellyfin.org/) - The free and open-source media server
- [Navidrome](https://www.navidrome.org/) - The excellent self-hosted music server
- [Subsonic API](http://www.subsonic.org/pages/api.jsp) - The API specification
- [Hi-Fi API](https://github.com/binimum/hifi-api) - These people do some great work, and you should thank them for this even existing!
- [Jellyfin](https://jellyfin.org/) - The free and open-source media server
- [Deezer](https://www.deezer.com/) - Music streaming service
- [Qobuz](https://www.qobuz.com/) - Hi-Res music streaming service
- [spotify-lyrics-api](https://github.com/akashrchandran/spotify-lyrics-api) - Thank them for the fact that we have access to Spotify's lyrics!
- [LRCLIB](https://github.com/tranxuanthang/lrclib) - The GOATS for giving us a free api for lyrics! They power LRCGET, which I'm sure some of you have heard of
- [Subsonic API](http://www.subsonic.org/pages/api.jsp) - The API specification

View File

@@ -1,334 +0,0 @@
using System.Text.Json;
using Xunit;
using allstarr.Models.Domain;
using allstarr.Services.Jellyfin;
namespace allstarr.Tests;
/// <summary>
/// Integration tests to verify Jellyfin response structure matches real API responses.
/// </summary>
public class JellyfinResponseStructureTests
{
private readonly JellyfinResponseBuilder _builder;
public JellyfinResponseStructureTests()
{
_builder = new JellyfinResponseBuilder();
}
[Fact]
public void Track_Response_Should_Have_All_Required_Fields()
{
// Arrange
var song = new Song
{
Id = "test-id",
Title = "Test Song",
Artist = "Test Artist",
ArtistId = "artist-id",
Album = "Test Album",
AlbumId = "album-id",
Duration = 180,
Year = 2024,
Track = 1,
Genre = "Pop",
IsLocal = false,
ExternalProvider = "Deezer",
ExternalId = "123456"
};
// Act
var result = _builder.ConvertSongToJellyfinItem(song);
// Assert - Required top-level fields
Assert.NotNull(result["Name"]);
Assert.NotNull(result["ServerId"]);
Assert.NotNull(result["Id"]);
Assert.NotNull(result["Type"]);
Assert.Equal("Audio", result["Type"]);
Assert.NotNull(result["MediaType"]);
Assert.Equal("Audio", result["MediaType"]);
// Assert - Metadata fields
Assert.NotNull(result["Container"]);
Assert.Equal("flac", result["Container"]);
Assert.NotNull(result["HasLyrics"]);
Assert.False((bool)result["HasLyrics"]!);
// Assert - Genres (must be array, never null)
Assert.NotNull(result["Genres"]);
Assert.IsType<string[]>(result["Genres"]);
Assert.NotNull(result["GenreItems"]);
Assert.IsAssignableFrom<System.Collections.IEnumerable>(result["GenreItems"]);
// Assert - UserData
Assert.NotNull(result["UserData"]);
var userData = result["UserData"] as Dictionary<string, object>;
Assert.NotNull(userData);
Assert.Contains("ItemId", userData.Keys);
Assert.Contains("Key", userData.Keys);
// Assert - Image fields
Assert.NotNull(result["ImageTags"]);
Assert.NotNull(result["BackdropImageTags"]);
Assert.NotNull(result["ImageBlurHashes"]);
// Assert - Location
Assert.NotNull(result["LocationType"]);
Assert.Equal("FileSystem", result["LocationType"]);
// Assert - Parent references
Assert.NotNull(result["ParentLogoItemId"]);
Assert.NotNull(result["ParentBackdropItemId"]);
Assert.NotNull(result["ParentBackdropImageTags"]);
}
[Fact]
public void Track_MediaSources_Should_Have_Complete_Structure()
{
// Arrange
var song = new Song
{
Id = "test-id",
Title = "Test Song",
Artist = "Test Artist",
Album = "Test Album",
Duration = 180,
IsLocal = false,
ExternalProvider = "Deezer",
ExternalId = "123456"
};
// Act
var result = _builder.ConvertSongToJellyfinItem(song);
// Assert - MediaSources exists
Assert.NotNull(result["MediaSources"]);
var mediaSources = result["MediaSources"] as object[];
Assert.NotNull(mediaSources);
Assert.Single(mediaSources);
var mediaSource = mediaSources[0] as Dictionary<string, object?>;
Assert.NotNull(mediaSource);
// Assert - Required MediaSource fields
Assert.Contains("Protocol", mediaSource.Keys);
Assert.Contains("Id", mediaSource.Keys);
Assert.Contains("Path", mediaSource.Keys);
Assert.Contains("Type", mediaSource.Keys);
Assert.Contains("Container", mediaSource.Keys);
Assert.Contains("Bitrate", mediaSource.Keys);
Assert.Contains("ETag", mediaSource.Keys);
Assert.Contains("RunTimeTicks", mediaSource.Keys);
// Assert - Boolean flags
Assert.Contains("IsRemote", mediaSource.Keys);
Assert.Contains("IsInfiniteStream", mediaSource.Keys);
Assert.Contains("RequiresOpening", mediaSource.Keys);
Assert.Contains("RequiresClosing", mediaSource.Keys);
Assert.Contains("RequiresLooping", mediaSource.Keys);
Assert.Contains("SupportsProbing", mediaSource.Keys);
Assert.Contains("SupportsTranscoding", mediaSource.Keys);
Assert.Contains("SupportsDirectStream", mediaSource.Keys);
Assert.Contains("SupportsDirectPlay", mediaSource.Keys);
Assert.Contains("ReadAtNativeFramerate", mediaSource.Keys);
Assert.Contains("IgnoreDts", mediaSource.Keys);
Assert.Contains("IgnoreIndex", mediaSource.Keys);
Assert.Contains("GenPtsInput", mediaSource.Keys);
Assert.Contains("UseMostCompatibleTranscodingProfile", mediaSource.Keys);
Assert.Contains("HasSegments", mediaSource.Keys);
// Assert - Arrays (must not be null)
Assert.Contains("MediaStreams", mediaSource.Keys);
Assert.NotNull(mediaSource["MediaStreams"]);
Assert.Contains("MediaAttachments", mediaSource.Keys);
Assert.NotNull(mediaSource["MediaAttachments"]);
Assert.Contains("Formats", mediaSource.Keys);
Assert.NotNull(mediaSource["Formats"]);
Assert.Contains("RequiredHttpHeaders", mediaSource.Keys);
Assert.NotNull(mediaSource["RequiredHttpHeaders"]);
// Assert - Other fields
Assert.Contains("TranscodingSubProtocol", mediaSource.Keys);
Assert.Contains("DefaultAudioStreamIndex", mediaSource.Keys);
}
[Fact]
public void Track_MediaStreams_Should_Have_Complete_Audio_Stream()
{
// Arrange
var song = new Song
{
Id = "test-id",
Title = "Test Song",
Artist = "Test Artist",
IsLocal = false,
ExternalProvider = "Deezer"
};
// Act
var result = _builder.ConvertSongToJellyfinItem(song);
var mediaSources = result["MediaSources"] as object[];
var mediaSource = mediaSources![0] as Dictionary<string, object?>;
var mediaStreams = mediaSource!["MediaStreams"] as object[];
// Assert
Assert.NotNull(mediaStreams);
Assert.Single(mediaStreams);
var audioStream = mediaStreams[0] as Dictionary<string, object?>;
Assert.NotNull(audioStream);
// Assert - Required audio stream fields
Assert.Contains("Codec", audioStream.Keys);
Assert.Equal("flac", audioStream["Codec"]);
Assert.Contains("Type", audioStream.Keys);
Assert.Equal("Audio", audioStream["Type"]);
Assert.Contains("BitRate", audioStream.Keys);
Assert.Contains("Channels", audioStream.Keys);
Assert.Contains("SampleRate", audioStream.Keys);
Assert.Contains("BitDepth", audioStream.Keys);
Assert.Contains("ChannelLayout", audioStream.Keys);
Assert.Contains("TimeBase", audioStream.Keys);
Assert.Contains("DisplayTitle", audioStream.Keys);
// Assert - Video-related fields (required even for audio)
Assert.Contains("VideoRange", audioStream.Keys);
Assert.Contains("VideoRangeType", audioStream.Keys);
Assert.Contains("AudioSpatialFormat", audioStream.Keys);
// Assert - Localization
Assert.Contains("LocalizedDefault", audioStream.Keys);
Assert.Contains("LocalizedExternal", audioStream.Keys);
// Assert - Boolean flags
Assert.Contains("IsInterlaced", audioStream.Keys);
Assert.Contains("IsAVC", audioStream.Keys);
Assert.Contains("IsDefault", audioStream.Keys);
Assert.Contains("IsForced", audioStream.Keys);
Assert.Contains("IsHearingImpaired", audioStream.Keys);
Assert.Contains("IsExternal", audioStream.Keys);
Assert.Contains("IsTextSubtitleStream", audioStream.Keys);
Assert.Contains("SupportsExternalStream", audioStream.Keys);
// Assert - Index and Level
Assert.Contains("Index", audioStream.Keys);
Assert.Contains("Level", audioStream.Keys);
}
[Fact]
public void Album_Response_Should_Have_All_Required_Fields()
{
// Arrange
var album = new Album
{
Id = "album-id",
Title = "Test Album",
Artist = "Test Artist",
Year = 2024,
Genre = "Rock",
IsLocal = false,
ExternalProvider = "Deezer"
};
// Act
var result = _builder.ConvertAlbumToJellyfinItem(album);
// Assert
Assert.NotNull(result["Name"]);
Assert.NotNull(result["ServerId"]);
Assert.NotNull(result["Id"]);
Assert.NotNull(result["Type"]);
Assert.Equal("MusicAlbum", result["Type"]);
Assert.True((bool)result["IsFolder"]!);
Assert.NotNull(result["MediaType"]);
Assert.Equal("Unknown", result["MediaType"]);
// Assert - Genres
Assert.NotNull(result["Genres"]);
Assert.IsType<string[]>(result["Genres"]);
Assert.NotNull(result["GenreItems"]);
// Assert - Artists
Assert.NotNull(result["Artists"]);
Assert.NotNull(result["ArtistItems"]);
Assert.NotNull(result["AlbumArtist"]);
Assert.NotNull(result["AlbumArtists"]);
// Assert - Parent references
Assert.NotNull(result["ParentLogoItemId"]);
Assert.NotNull(result["ParentBackdropItemId"]);
Assert.NotNull(result["ParentLogoImageTag"]);
}
[Fact]
public void Artist_Response_Should_Have_All_Required_Fields()
{
// Arrange
var artist = new Artist
{
Id = "artist-id",
Name = "Test Artist",
AlbumCount = 5,
IsLocal = false,
ExternalProvider = "Deezer"
};
// Act
var result = _builder.ConvertArtistToJellyfinItem(artist);
// Assert
Assert.NotNull(result["Name"]);
Assert.NotNull(result["ServerId"]);
Assert.NotNull(result["Id"]);
Assert.NotNull(result["Type"]);
Assert.Equal("MusicArtist", result["Type"]);
Assert.True((bool)result["IsFolder"]!);
Assert.NotNull(result["MediaType"]);
Assert.Equal("Unknown", result["MediaType"]);
// Assert - Genres (empty array for artists)
Assert.NotNull(result["Genres"]);
Assert.IsType<string[]>(result["Genres"]);
Assert.NotNull(result["GenreItems"]);
// Assert - Album count
Assert.NotNull(result["AlbumCount"]);
Assert.Equal(5, result["AlbumCount"]);
// Assert - RunTimeTicks
Assert.NotNull(result["RunTimeTicks"]);
Assert.Equal(0, result["RunTimeTicks"]);
}
[Fact]
public void All_Entities_Should_Have_UserData_With_ItemId()
{
// Arrange
var song = new Song { Id = "song-id", Title = "Test", Artist = "Test" };
var album = new Album { Id = "album-id", Title = "Test", Artist = "Test" };
var artist = new Artist { Id = "artist-id", Name = "Test" };
// Act
var songResult = _builder.ConvertSongToJellyfinItem(song);
var albumResult = _builder.ConvertAlbumToJellyfinItem(album);
var artistResult = _builder.ConvertArtistToJellyfinItem(artist);
// Assert
var songUserData = songResult["UserData"] as Dictionary<string, object>;
Assert.NotNull(songUserData);
Assert.Contains("ItemId", songUserData.Keys);
Assert.Equal("song-id", songUserData["ItemId"]);
var albumUserData = albumResult["UserData"] as Dictionary<string, object>;
Assert.NotNull(albumUserData);
Assert.Contains("ItemId", albumUserData.Keys);
Assert.Equal("album-id", albumUserData["ItemId"]);
var artistUserData = artistResult["UserData"] as Dictionary<string, object>;
Assert.NotNull(artistUserData);
Assert.Contains("ItemId", artistUserData.Keys);
Assert.Equal("artist-id", artistUserData["ItemId"]);
}
}

View File

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

View File

@@ -2,17 +2,14 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using System.Text.Json;
using allstarr.Models.Domain;
using allstarr.Models.Lyrics;
using allstarr.Models.Settings;
using allstarr.Models.Subsonic;
using allstarr.Models.Spotify;
using allstarr.Services;
using allstarr.Services.Common;
using allstarr.Services.Local;
using allstarr.Services.Jellyfin;
using allstarr.Services.Subsonic;
using allstarr.Services.Lyrics;
using allstarr.Services.Spotify;
using allstarr.Filters;
namespace allstarr.Controllers;
@@ -27,63 +24,39 @@ public class JellyfinController : ControllerBase
{
private readonly JellyfinSettings _settings;
private readonly SpotifyImportSettings _spotifySettings;
private readonly SpotifyApiSettings _spotifyApiSettings;
private readonly IMusicMetadataService _metadataService;
private readonly ParallelMetadataService? _parallelMetadataService;
private readonly ILocalLibraryService _localLibraryService;
private readonly IDownloadService _downloadService;
private readonly JellyfinResponseBuilder _responseBuilder;
private readonly JellyfinModelMapper _modelMapper;
private readonly JellyfinProxyService _proxyService;
private readonly JellyfinSessionManager _sessionManager;
private readonly PlaylistSyncService? _playlistSyncService;
private readonly SpotifyPlaylistFetcher? _spotifyPlaylistFetcher;
private readonly SpotifyLyricsService? _spotifyLyricsService;
private readonly LrclibService? _lrclibService;
private readonly OdesliService _odesliService;
private readonly RedisCacheService _cache;
private readonly IConfiguration _configuration;
private readonly ILogger<JellyfinController> _logger;
public JellyfinController(
IOptions<JellyfinSettings> settings,
IOptions<SpotifyImportSettings> spotifySettings,
IOptions<SpotifyApiSettings> spotifyApiSettings,
IMusicMetadataService metadataService,
ILocalLibraryService localLibraryService,
IDownloadService downloadService,
JellyfinResponseBuilder responseBuilder,
JellyfinModelMapper modelMapper,
JellyfinProxyService proxyService,
JellyfinSessionManager sessionManager,
OdesliService odesliService,
RedisCacheService cache,
IConfiguration configuration,
ILogger<JellyfinController> logger,
ParallelMetadataService? parallelMetadataService = null,
PlaylistSyncService? playlistSyncService = null,
SpotifyPlaylistFetcher? spotifyPlaylistFetcher = null,
SpotifyLyricsService? spotifyLyricsService = null,
LrclibService? lrclibService = null)
PlaylistSyncService? playlistSyncService = null)
{
_settings = settings.Value;
_spotifySettings = spotifySettings.Value;
_spotifyApiSettings = spotifyApiSettings.Value;
_metadataService = metadataService;
_parallelMetadataService = parallelMetadataService;
_localLibraryService = localLibraryService;
_downloadService = downloadService;
_responseBuilder = responseBuilder;
_modelMapper = modelMapper;
_proxyService = proxyService;
_sessionManager = sessionManager;
_playlistSyncService = playlistSyncService;
_spotifyPlaylistFetcher = spotifyPlaylistFetcher;
_spotifyLyricsService = spotifyLyricsService;
_lrclibService = lrclibService;
_odesliService = odesliService;
_cache = cache;
_configuration = configuration;
_logger = logger;
if (string.IsNullOrWhiteSpace(_settings.Url))
@@ -96,7 +69,7 @@ public class JellyfinController : ControllerBase
/// <summary>
/// Searches local Jellyfin library and external providers.
/// Combines songs/albums/artists. Works with /Items and /Users/{userId}/Items.
/// Dedupes artists, combines songs/albums. Works with /Items and /Users/{userId}/Items.
/// </summary>
[HttpGet("Items", Order = 1)]
[HttpGet("Users/{userId}/Items", Order = 1)]
@@ -114,20 +87,6 @@ public class JellyfinController : ControllerBase
_logger.LogInformation("=== SEARCHITEMS V2 CALLED === searchTerm={SearchTerm}, includeItemTypes={ItemTypes}, parentId={ParentId}, artistIds={ArtistIds}, userId={UserId}",
searchTerm, includeItemTypes, parentId, artistIds, userId);
// Cache search results in Redis only (no file persistence, 15 min TTL)
// Only cache actual searches, not browse operations
if (!string.IsNullOrWhiteSpace(searchTerm) && string.IsNullOrWhiteSpace(artistIds))
{
var cacheKey = $"search:{searchTerm?.ToLowerInvariant()}:{includeItemTypes}:{limit}:{startIndex}";
var cachedResult = await _cache.GetAsync<object>(cacheKey);
if (cachedResult != null)
{
_logger.LogDebug("✅ Returning cached search results for '{SearchTerm}'", searchTerm);
return new JsonResult(cachedResult);
}
}
// If filtering by artist, handle external artists
if (!string.IsNullOrWhiteSpace(artistIds))
{
@@ -149,56 +108,10 @@ public class JellyfinController : ControllerBase
// Build the full endpoint path with query string
var endpoint = userId != null ? $"Users/{userId}/Items" : "Items";
// Ensure MediaSources is included in Fields parameter for bitrate info
var queryString = Request.QueryString.Value ?? "";
if (!string.IsNullOrEmpty(queryString))
if (Request.QueryString.HasValue)
{
// Parse query string to modify Fields parameter
var queryParams = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString);
if (queryParams.ContainsKey("Fields"))
{
var fieldsValue = queryParams["Fields"].ToString();
if (!fieldsValue.Contains("MediaSources", StringComparison.OrdinalIgnoreCase))
{
// Append MediaSources to existing Fields
var newFields = string.IsNullOrEmpty(fieldsValue)
? "MediaSources"
: $"{fieldsValue},MediaSources";
// Rebuild query string with updated Fields
var newQueryParams = new Dictionary<string, string>();
foreach (var kvp in queryParams)
{
if (kvp.Key == "Fields")
{
newQueryParams[kvp.Key] = newFields;
endpoint = $"{endpoint}{Request.QueryString.Value}";
}
else
{
newQueryParams[kvp.Key] = kvp.Value.ToString();
}
}
queryString = "?" + string.Join("&", newQueryParams.Select(kvp =>
$"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value)}"));
}
}
else
{
// No Fields parameter, add it
queryString = $"{queryString}&Fields=MediaSources";
}
}
else
{
// No query string at all
queryString = "?Fields=MediaSources";
}
endpoint = $"{endpoint}{queryString}";
var (browseResult, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
@@ -253,11 +166,7 @@ public class JellyfinController : ControllerBase
// Run local and external searches in parallel
var itemTypes = ParseItemTypes(includeItemTypes);
var jellyfinTask = _proxyService.SearchAsync(cleanQuery, itemTypes, limit, recursive, Request.Headers);
// Use parallel metadata service if available (races providers), otherwise use primary
var externalTask = _parallelMetadataService != null
? _parallelMetadataService.SearchAllAsync(cleanQuery, limit, limit, limit)
: _metadataService.SearchAllAsync(cleanQuery, limit, limit, limit);
var externalTask = _metadataService.SearchAllAsync(cleanQuery, limit, limit, limit);
var playlistTask = _settings.EnableExternalPlaylists
? _metadataService.SearchPlaylistsAsync(cleanQuery, limit)
@@ -279,106 +188,55 @@ public class JellyfinController : ControllerBase
// Parse Jellyfin results into domain models
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult);
// Respect source ordering (SquidWTF/Tidal has better search ranking than our fuzzy matching)
// Just interleave local and external results based on which source has better overall match
// Score and filter Jellyfin results by relevance
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, _ => null, isExternal: false);
var scoredLocalArtists = ScoreSearchResults(cleanQuery, localArtists, a => a.Name, _ => null, _ => null, isExternal: false);
// Calculate average match score for each source to determine which should come first
var localSongsAvgScore = localSongs.Any()
? localSongs.Average(s => FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title))
: 0.0;
var externalSongsAvgScore = externalResult.Songs.Any()
? externalResult.Songs.Average(s => FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title))
: 0.0;
// Score external results with a small boost
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, _ => null, isExternal: true);
var scoredExternalArtists = ScoreSearchResults(cleanQuery, externalResult.Artists, a => a.Name, _ => null, _ => null, isExternal: true);
var localAlbumsAvgScore = localAlbums.Any()
? localAlbums.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title))
: 0.0;
var externalAlbumsAvgScore = externalResult.Albums.Any()
? externalResult.Albums.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title))
: 0.0;
// Merge and sort by score (no filtering - just reorder by relevance)
var allSongs = scoredLocalSongs.Concat(scoredExternalSongs)
.OrderByDescending(x => x.Score)
.Select(x => x.Item)
.ToList();
var localArtistsAvgScore = localArtists.Any()
? localArtists.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name))
: 0.0;
var externalArtistsAvgScore = externalResult.Artists.Any()
? externalResult.Artists.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name))
: 0.0;
var allAlbums = scoredLocalAlbums.Concat(scoredExternalAlbums)
.OrderByDescending(x => x.Score)
.Select(x => x.Item)
.ToList();
// Interleave results: put better-matching source first, preserve original ordering within each source
var allSongs = localSongsAvgScore >= externalSongsAvgScore
? localSongs.Concat(externalResult.Songs).ToList()
: externalResult.Songs.Concat(localSongs).ToList();
var allAlbums = localAlbumsAvgScore >= externalAlbumsAvgScore
? localAlbums.Concat(externalResult.Albums).ToList()
: externalResult.Albums.Concat(localAlbums).ToList();
var allArtists = localArtistsAvgScore >= externalArtistsAvgScore
? localArtists.Concat(externalResult.Artists).ToList()
: externalResult.Artists.Concat(localArtists).ToList();
// Log results for debugging
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("🎵 Songs: Local avg score={LocalScore:F2}, External avg score={ExtScore:F2}, Local first={LocalFirst}",
localSongsAvgScore, externalSongsAvgScore, localSongsAvgScore >= externalSongsAvgScore);
_logger.LogDebug("💿 Albums: Local avg score={LocalScore:F2}, External avg score={ExtScore:F2}, Local first={LocalFirst}",
localAlbumsAvgScore, externalAlbumsAvgScore, localAlbumsAvgScore >= externalAlbumsAvgScore);
_logger.LogDebug("🎤 Artists: Local avg score={LocalScore:F2}, External avg score={ExtScore:F2}, Local first={LocalFirst}",
localArtistsAvgScore, externalArtistsAvgScore, localArtistsAvgScore >= externalArtistsAvgScore);
}
// Dedupe artists by name, keeping highest scored version
var artistScores = scoredLocalArtists.Concat(scoredExternalArtists)
.GroupBy(x => x.Item.Name, StringComparer.OrdinalIgnoreCase)
.Select(g => g.OrderByDescending(x => x.Score).First())
.OrderByDescending(x => x.Score)
.Select(x => x.Item)
.ToList();
// Convert to Jellyfin format
var mergedSongs = allSongs.Select(s => _responseBuilder.ConvertSongToJellyfinItem(s)).ToList();
var mergedAlbums = allAlbums.Select(a => _responseBuilder.ConvertAlbumToJellyfinItem(a)).ToList();
var mergedArtists = allArtists.Select(a => _responseBuilder.ConvertArtistToJellyfinItem(a)).ToList();
var mergedArtists = artistScores.Select(a => _responseBuilder.ConvertArtistToJellyfinItem(a)).ToList();
// Add playlists (preserve their order too)
// Add playlists (score them too)
if (playlistResult.Count > 0)
{
var playlistItems = playlistResult
.Select(p => _responseBuilder.ConvertPlaylistToJellyfinItem(p))
var scoredPlaylists = playlistResult
.Select(p => new { Playlist = p, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, p.Name) })
.OrderByDescending(x => x.Score)
.Select(x => _responseBuilder.ConvertPlaylistToJellyfinItem(x.Playlist))
.ToList();
mergedAlbums.AddRange(playlistItems);
mergedAlbums.AddRange(scoredPlaylists);
}
_logger.LogInformation("Merged results (preserving source order): Songs={Songs}, Albums={Albums}, Artists={Artists}",
_logger.LogInformation("Scored and filtered results: Songs={Songs}, Albums={Albums}, Artists={Artists}",
mergedSongs.Count, mergedAlbums.Count, mergedArtists.Count);
// Pre-fetch lyrics for top 3 songs in background (don't await)
if (_lrclibService != null && mergedSongs.Count > 0)
{
_ = Task.Run(async () =>
{
try
{
var top3 = mergedSongs.Take(3).ToList();
_logger.LogDebug("🎵 Pre-fetching lyrics for top {Count} search results", top3.Count);
foreach (var songItem in top3)
{
if (songItem.TryGetValue("Name", out var nameObj) && nameObj is JsonElement nameEl &&
songItem.TryGetValue("Artists", out var artistsObj) && artistsObj is JsonElement artistsEl &&
artistsEl.GetArrayLength() > 0)
{
var title = nameEl.GetString() ?? "";
var artist = artistsEl[0].GetString() ?? "";
if (!string.IsNullOrEmpty(title) && !string.IsNullOrEmpty(artist))
{
await _lrclibService.GetLyricsAsync(title, artist, "", 0);
}
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to pre-fetch lyrics for search results");
}
});
}
// Filter by item types if specified
var items = new List<Dictionary<string, object?>>();
@@ -415,14 +273,6 @@ public class JellyfinController : ControllerBase
StartIndex = startIndex
};
// Cache search results in Redis (15 min TTL, no file persistence)
if (!string.IsNullOrWhiteSpace(searchTerm) && string.IsNullOrWhiteSpace(artistIds))
{
var cacheKey = $"search:{searchTerm?.ToLowerInvariant()}:{includeItemTypes}:{limit}:{startIndex}";
await _cache.SetAsync(cacheKey, response, TimeSpan.FromMinutes(15));
_logger.LogDebug("💾 Cached search results for '{SearchTerm}' (15 min TTL)", searchTerm);
}
_logger.LogInformation("About to serialize response...");
var json = System.Text.Json.JsonSerializer.Serialize(response, new System.Text.Json.JsonSerializerOptions
@@ -516,10 +366,20 @@ public class JellyfinController : ControllerBase
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult);
// NO deduplication - merge all results and take top matches
// Merge and convert to search hints format
var allSongs = localSongs.Concat(externalResult.Songs).Take(limit).ToList();
var allAlbums = localAlbums.Concat(externalResult.Albums).Take(limit).ToList();
var allArtists = localArtists.Concat(externalResult.Artists).Take(limit).ToList();
// Dedupe artists by name
var artistNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var allArtists = new List<Artist>();
foreach (var artist in localArtists.Concat(externalResult.Artists))
{
if (artistNames.Add(artist.Name))
{
allArtists.Add(artist);
}
}
return _responseBuilder.CreateSearchHintsResponse(
allSongs.Take(limit).ToList(),
@@ -612,13 +472,9 @@ public class JellyfinController : ControllerBase
{
var itemTypes = ParseItemTypes(includeItemTypes);
_logger.LogInformation("GetExternalChildItems: provider={Provider}, externalId={ExternalId}, itemTypes={ItemTypes}",
provider, externalId, string.Join(",", itemTypes ?? Array.Empty<string>()));
// Check if asking for audio (album tracks)
if (itemTypes?.Contains("Audio") == true)
{
_logger.LogDebug("Fetching album tracks for {Provider}/{ExternalId}", provider, externalId);
var album = await _metadataService.GetAlbumAsync(provider, externalId);
if (album == null)
{
@@ -629,12 +485,9 @@ public class JellyfinController : ControllerBase
}
// Otherwise assume it's artist albums
_logger.LogDebug("Fetching artist albums for {Provider}/{ExternalId}", provider, externalId);
var albums = await _metadataService.GetArtistAlbumsAsync(provider, externalId);
var artist = await _metadataService.GetArtistAsync(provider, externalId);
_logger.LogInformation("Found {Count} albums for artist {ArtistName}", albums.Count, artist?.Name ?? "unknown");
// Fill artist info
if (artist != null)
{
@@ -694,11 +547,27 @@ public class JellyfinController : ControllerBase
}
}
// NO deduplication - merge all artists and sort by relevance
// Show ALL matches (local + external) sorted by best match first
var mergedArtists = localArtists.Concat(externalArtists).ToList();
// Merge and deduplicate by name
var artistNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var mergedArtists = new List<Artist>();
_logger.LogInformation("Returning {Count} total artists (local + external, no deduplication)", mergedArtists.Count);
foreach (var artist in localArtists)
{
if (artistNames.Add(artist.Name))
{
mergedArtists.Add(artist);
}
}
foreach (var artist in externalArtists)
{
if (artistNames.Add(artist.Name))
{
mergedArtists.Add(artist);
}
}
_logger.LogInformation("Returning {Count} merged artists", mergedArtists.Count);
// Convert to Jellyfin format
var artistItems = mergedArtists.Select(a => _responseBuilder.ConvertArtistToJellyfinItem(a)).ToList();
@@ -995,34 +864,12 @@ public class JellyfinController : ControllerBase
}
/// <summary>
/// Universal audio endpoint - handles transcoding, format negotiation, and adaptive streaming.
/// This is the primary endpoint used by Jellyfin Web and most clients.
/// Universal audio endpoint that redirects to the stream endpoint.
/// </summary>
[HttpGet("Audio/{itemId}/universal")]
[HttpHead("Audio/{itemId}/universal")]
public async Task<IActionResult> UniversalAudio(string itemId)
public Task<IActionResult> UniversalAudio(string 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!);
return StreamAudio(itemId);
}
#endregion
@@ -1056,20 +903,24 @@ public class JellyfinController : ControllerBase
if (!isExternal)
{
// Proxy image from Jellyfin for local content
var (imageBytes, contentType) = await _proxyService.GetImageAsync(
itemId,
imageType,
maxWidth,
maxHeight);
// Redirect to Jellyfin directly for local content images
var queryString = new List<string>();
if (maxWidth.HasValue) queryString.Add($"maxWidth={maxWidth.Value}");
if (maxHeight.HasValue) queryString.Add($"maxHeight={maxHeight.Value}");
if (imageBytes == null || contentType == null)
var path = $"Items/{itemId}/Images/{imageType}";
if (imageIndex > 0)
{
// Return placeholder if Jellyfin doesn't have image
return await GetPlaceholderImageAsync();
path = $"Items/{itemId}/Images/{imageType}/{imageIndex}";
}
return File(imageBytes, contentType);
if (queryString.Any())
{
path = $"{path}?{string.Join("&", queryString)}";
}
var jellyfinUrl = $"{_settings.Url?.TrimEnd('/')}/{path}";
return Redirect(jellyfinUrl);
}
// Get external cover art URL
@@ -1083,8 +934,7 @@ public class JellyfinController : ControllerBase
if (string.IsNullOrEmpty(coverUrl))
{
// Return placeholder "no image available" image
return await GetPlaceholderImageAsync();
return NotFound();
}
// Fetch and return the image using the proxy service's HttpClient
@@ -1093,8 +943,7 @@ public class JellyfinController : ControllerBase
var response = await _proxyService.HttpClient.GetAsync(coverUrl);
if (!response.IsSuccessStatusCode)
{
// Return placeholder on fetch failure
return await GetPlaceholderImageAsync();
return NotFound();
}
var imageBytes = await response.Content.ReadAsByteArrayAsync();
@@ -1104,48 +953,21 @@ public class JellyfinController : ControllerBase
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch cover art from {Url}", coverUrl);
// Return placeholder on exception
return await GetPlaceholderImageAsync();
return NotFound();
}
}
/// <summary>
/// Returns a placeholder "no image available" image.
/// Generates a simple 1x1 transparent PNG as a minimal placeholder.
/// TODO: Replace with actual "no image available" graphic from wwwroot/placeholder.png
/// </summary>
private async Task<IActionResult> GetPlaceholderImageAsync()
{
// Check if custom placeholder exists in wwwroot
var placeholderPath = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "placeholder.png");
if (System.IO.File.Exists(placeholderPath))
{
var imageBytes = await System.IO.File.ReadAllBytesAsync(placeholderPath);
return File(imageBytes, "image/png");
}
// Fallback: Return a 1x1 transparent PNG as minimal placeholder
var transparentPng = Convert.FromBase64String(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
);
return File(transparentPng, "image/png");
}
#endregion
#region Lyrics
/// <summary>
/// Gets lyrics for an item.
/// Priority: 1. Jellyfin embedded lyrics, 2. Spotify synced lyrics, 3. LRCLIB
/// </summary>
[HttpGet("Audio/{itemId}/Lyrics")]
[HttpGet("Items/{itemId}/Lyrics")]
public async Task<IActionResult> GetLyrics(string itemId)
{
_logger.LogInformation("🎵 GetLyrics called for itemId: {ItemId}", itemId);
if (string.IsNullOrWhiteSpace(itemId))
{
return NotFound();
@@ -1153,9 +975,6 @@ public class JellyfinController : ControllerBase
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
_logger.LogInformation("🎵 Lyrics request: itemId={ItemId}, isExternal={IsExternal}, provider={Provider}, externalId={ExternalId}",
itemId, isExternal, provider, externalId);
// For local tracks, check if Jellyfin already has embedded lyrics
if (!isExternal)
{
@@ -1164,72 +983,21 @@ public class JellyfinController : ControllerBase
// 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);
_logger.LogInformation("Jellyfin lyrics check result: statusCode={StatusCode}, hasLyrics={HasLyrics}",
statusCode, jellyfinLyrics != null);
if (jellyfinLyrics != null && statusCode == 200)
{
_logger.LogInformation("Found embedded lyrics in Jellyfin for track {ItemId}", itemId);
_logger.LogInformation("Found embedded lyrics in Jellyfin for track {ItemId}", itemId);
return new JsonResult(JsonSerializer.Deserialize<object>(jellyfinLyrics.RootElement.GetRawText()));
}
_logger.LogInformation("No embedded lyrics found in Jellyfin (status: {StatusCode}), trying Spotify/LRCLIB", statusCode);
_logger.LogInformation("No embedded lyrics found in Jellyfin, falling back to LRCLIB search");
}
// Get song metadata for lyrics search
// For external tracks or when Jellyfin doesn't have lyrics, search LRCLIB
Song? song = null;
string? spotifyTrackId = null;
if (isExternal)
{
song = await _metadataService.GetSongAsync(provider!, externalId!);
// Use Spotify ID from song metadata if available (populated during GetSongAsync)
if (song != null && !string.IsNullOrEmpty(song.SpotifyId))
{
spotifyTrackId = song.SpotifyId;
_logger.LogInformation("Using Spotify ID {SpotifyId} from song metadata for {Provider}/{ExternalId}",
spotifyTrackId, provider, externalId);
}
// Fallback: Try to find Spotify ID from matched tracks cache
else if (song != null)
{
spotifyTrackId = await FindSpotifyIdForExternalTrackAsync(song);
if (!string.IsNullOrEmpty(spotifyTrackId))
{
_logger.LogInformation("Found Spotify ID {SpotifyId} for external track {Provider}/{ExternalId} from cache",
spotifyTrackId, provider, externalId);
}
else
{
// Last resort: Try to convert via Odesli/song.link
if (provider == "squidwtf")
{
spotifyTrackId = await _odesliService.ConvertTidalToSpotifyIdAsync(externalId!, HttpContext.RequestAborted);
}
else
{
// For other providers, build the URL and convert
var sourceUrl = provider?.ToLowerInvariant() switch
{
"deezer" => $"https://www.deezer.com/track/{externalId}",
"qobuz" => $"https://www.qobuz.com/us-en/album/-/-/{externalId}",
_ => null
};
if (!string.IsNullOrEmpty(sourceUrl))
{
spotifyTrackId = await _odesliService.ConvertUrlToSpotifyIdAsync(sourceUrl, HttpContext.RequestAborted);
}
}
if (!string.IsNullOrEmpty(spotifyTrackId))
{
_logger.LogInformation("Converted {Provider}/{ExternalId} to Spotify ID {SpotifyId} via Odesli",
provider, externalId, spotifyTrackId);
}
}
}
}
else
{
@@ -1245,15 +1013,6 @@ public class JellyfinController : ControllerBase
Album = item.RootElement.TryGetProperty("Album", out var album) ? album.GetString() ?? "" : "",
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();
}
}
}
}
@@ -1262,68 +1021,19 @@ public class JellyfinController : ControllerBase
return NotFound(new { error = "Song not found" });
}
// Strip [S] suffix from title, artist, and album for lyrics search
// The [S] tag is added to external tracks but shouldn't be used in lyrics queries
var searchTitle = song.Title.Replace(" [S]", "").Trim();
var searchArtist = song.Artist?.Replace(" [S]", "").Trim() ?? "";
var searchAlbum = song.Album?.Replace(" [S]", "").Trim() ?? "";
var searchArtists = song.Artists.Select(a => a.Replace(" [S]", "").Trim()).ToList();
if (searchArtists.Count == 0 && !string.IsNullOrEmpty(searchArtist))
// Try to get lyrics from LRCLIB
_logger.LogInformation("Searching LRCLIB for lyrics: {Artist} - {Title}", song.Artist, song.Title);
var lyricsService = HttpContext.RequestServices.GetService<LrclibService>();
if (lyricsService == null)
{
searchArtists.Add(searchArtist);
return NotFound(new { error = "Lyrics service not available" });
}
LyricsInfo? lyrics = null;
// Try Spotify lyrics ONLY if we have a valid Spotify track ID
// Spotify lyrics only work for tracks from injected playlists that have been matched
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled && !string.IsNullOrEmpty(spotifyTrackId))
{
// Validate that this is a real Spotify ID (not spotify:local or other invalid formats)
var cleanSpotifyId = spotifyTrackId.Replace("spotify:track:", "").Trim();
// Spotify track IDs are 22 characters, base62 encoded
if (cleanSpotifyId.Length == 22 && !cleanSpotifyId.Contains(":") && !cleanSpotifyId.Contains("local"))
{
_logger.LogInformation("Trying Spotify lyrics for track ID: {SpotifyId} ({Artist} - {Title})",
cleanSpotifyId, searchArtist, searchTitle);
var spotifyLyrics = await _spotifyLyricsService.GetLyricsByTrackIdAsync(cleanSpotifyId);
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
{
_logger.LogInformation("Found Spotify lyrics for {Artist} - {Title} ({LineCount} lines, type: {SyncType})",
searchArtist, searchTitle, spotifyLyrics.Lines.Count, spotifyLyrics.SyncType);
lyrics = _spotifyLyricsService.ToLyricsInfo(spotifyLyrics);
}
else
{
_logger.LogDebug("No Spotify lyrics found for track ID {SpotifyId}", cleanSpotifyId);
}
}
else
{
_logger.LogDebug("Invalid Spotify ID format: {SpotifyId}, skipping Spotify lyrics", spotifyTrackId);
}
}
// Fall back to LRCLIB if no Spotify lyrics
if (lyrics == null)
{
_logger.LogInformation("Searching LRCLIB for lyrics: {Artists} - {Title}",
string.Join(", ", searchArtists),
searchTitle);
var lrclibService = HttpContext.RequestServices.GetService<LrclibService>();
if (lrclibService != null)
{
lyrics = await lrclibService.GetLyricsAsync(
searchTitle,
searchArtists.ToArray(),
searchAlbum,
var lyrics = await lyricsService.GetLyricsAsync(
song.Title,
song.Artist ?? "",
song.Album ?? "",
song.Duration ?? 0);
}
}
if (lyrics == null)
{
@@ -1425,122 +1135,6 @@ public class JellyfinController : ControllerBase
return Ok(response);
}
/// <summary>
/// Proactively fetches and caches lyrics for a track in the background.
/// Called when playback starts to ensure lyrics are ready when requested.
/// </summary>
private async Task PrefetchLyricsForTrackAsync(string itemId, bool isExternal, string? provider, string? externalId)
{
try
{
Song? song = null;
string? spotifyTrackId = null;
if (isExternal && !string.IsNullOrEmpty(provider) && !string.IsNullOrEmpty(externalId))
{
// Get external track metadata
song = await _metadataService.GetSongAsync(provider, externalId);
// Try to find Spotify ID from matched tracks cache
if (song != null)
{
spotifyTrackId = await FindSpotifyIdForExternalTrackAsync(song);
// If no cached Spotify ID, try Odesli conversion
if (string.IsNullOrEmpty(spotifyTrackId) && provider == "squidwtf")
{
spotifyTrackId = await _odesliService.ConvertTidalToSpotifyIdAsync(externalId, HttpContext.RequestAborted);
}
}
}
else
{
// Get local track metadata from Jellyfin
var (item, _) = await _proxyService.GetItemAsync(itemId, Request.Headers);
if (item != null && item.RootElement.TryGetProperty("Type", out var typeEl) &&
typeEl.GetString() == "Audio")
{
song = new Song
{
Title = item.RootElement.TryGetProperty("Name", out var name) ? name.GetString() ?? "" : "",
Artist = item.RootElement.TryGetProperty("AlbumArtist", out var artist) ? artist.GetString() ?? "" : "",
Album = item.RootElement.TryGetProperty("Album", out var album) ? album.GetString() ?? "" : "",
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();
}
}
}
}
if (song == null)
{
_logger.LogDebug("Could not get song metadata for lyrics prefetch: {ItemId}", itemId);
return;
}
// Strip [S] suffix for lyrics search
var searchTitle = song.Title.Replace(" [S]", "").Trim();
var searchArtist = song.Artist?.Replace(" [S]", "").Trim() ?? "";
var searchAlbum = song.Album?.Replace(" [S]", "").Trim() ?? "";
var searchArtists = song.Artists.Select(a => a.Replace(" [S]", "").Trim()).ToList();
if (searchArtists.Count == 0 && !string.IsNullOrEmpty(searchArtist))
{
searchArtists.Add(searchArtist);
}
_logger.LogDebug("🎵 Prefetching lyrics for: {Artist} - {Title}", searchArtist, searchTitle);
// Try Spotify lyrics if we have a valid Spotify track ID
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled && !string.IsNullOrEmpty(spotifyTrackId))
{
var cleanSpotifyId = spotifyTrackId.Replace("spotify:track:", "").Trim();
if (cleanSpotifyId.Length == 22 && !cleanSpotifyId.Contains(":") && !cleanSpotifyId.Contains("local"))
{
var spotifyLyrics = await _spotifyLyricsService.GetLyricsByTrackIdAsync(cleanSpotifyId);
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
{
_logger.LogDebug("✓ Prefetched Spotify lyrics for {Artist} - {Title} ({LineCount} lines)",
searchArtist, searchTitle, spotifyLyrics.Lines.Count);
return; // Success, lyrics are now cached
}
}
}
// Fall back to LRCLIB
if (_lrclibService != null)
{
var lyrics = await _lrclibService.GetLyricsAsync(
searchTitle,
searchArtists.ToArray(),
searchAlbum,
song.Duration ?? 0);
if (lyrics != null)
{
_logger.LogDebug("✓ Prefetched LRCLIB lyrics for {Artist} - {Title}", searchArtist, searchTitle);
}
else
{
_logger.LogDebug("No lyrics found for {Artist} - {Title}", searchArtist, searchTitle);
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error prefetching lyrics for track {ItemId}", itemId);
}
}
#endregion
#region Favorites
@@ -1652,25 +1246,11 @@ public class JellyfinController : ControllerBase
_logger.LogInformation("UnmarkFavorite called: userId={UserId}, itemId={ItemId}, route={Route}",
userId, itemId, Request.Path);
// External items - remove from kept folder if it exists
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
// External items can't be unfavorited (they're not really favorited in Jellyfin)
var (isExternal, _, _) = _localLibraryService.ParseSongId(itemId);
if (isExternal || PlaylistIdHelper.IsExternalPlaylist(itemId))
{
_logger.LogInformation("Unfavoriting external item {ItemId} - removing from kept folder", itemId);
// Remove from kept folder in background
_ = Task.Run(async () =>
{
try
{
await RemoveExternalTrackFromKeptAsync(itemId, provider!, externalId!);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to remove external track {ItemId} from kept folder", itemId);
}
});
_logger.LogInformation("Unfavoriting external item {ItemId} - returning success", itemId);
return Ok(new
{
IsFavorite = false,
@@ -1759,10 +1339,11 @@ public class JellyfinController : ControllerBase
}
// Check if this is a Spotify playlist (by ID)
_logger.LogInformation("Spotify Import Enabled: {Enabled}, Configured Playlists: {Count}",
_spotifySettings.Enabled, _spotifySettings.Playlists.Count);
_logger.LogInformation("Spotify Import Enabled: {Enabled}, Configured IDs: {Count}",
_spotifySettings.Enabled, _spotifySettings.PlaylistIds.Count);
if (_spotifySettings.Enabled && _spotifySettings.IsSpotifyPlaylist(playlistId))
if (_spotifySettings.Enabled &&
_spotifySettings.PlaylistIds.Any(id => id.Equals(playlistId, StringComparison.OrdinalIgnoreCase)))
{
// Get playlist info from Jellyfin to get the name for matching missing tracks
_logger.LogInformation("Fetching playlist info from Jellyfin for ID: {PlaylistId}", playlistId);
@@ -1807,16 +1388,6 @@ public class JellyfinController : ControllerBase
{
try
{
// Check cache first (1 hour TTL for playlist images since they can change)
var cacheKey = $"playlist:image:{playlistId}";
var cachedImage = await _cache.GetAsync<byte[]>(cacheKey);
if (cachedImage != null)
{
_logger.LogDebug("Serving cached playlist image for {PlaylistId}", playlistId);
return File(cachedImage, "image/jpeg");
}
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(playlistId);
var playlist = await _metadataService.GetPlaylistAsync(provider, externalId);
@@ -1833,11 +1404,6 @@ public class JellyfinController : ControllerBase
var imageBytes = await response.Content.ReadAsByteArrayAsync();
var contentType = response.Content.Headers.ContentType?.ToString() ?? "image/jpeg";
// Cache for 1 hour (playlists can change, so don't cache too long)
await _cache.SetAsync(cacheKey, imageBytes, TimeSpan.FromHours(1));
_logger.LogDebug("Cached playlist image for {PlaylistId}", playlistId);
return File(imageBytes, contentType);
}
catch (Exception ex)
@@ -1873,43 +1439,26 @@ public class JellyfinController : ControllerBase
_logger.LogInformation("Authentication request received");
// DO NOT log request body or detailed headers - contains password
// Forward to Jellyfin server with client headers - completely transparent proxy
// Forward to Jellyfin server with client headers
var (result, statusCode) = await _proxyService.PostJsonAsync("Users/AuthenticateByName", body, Request.Headers);
// Pass through Jellyfin's response exactly as-is (transparent proxy)
if (result != null)
if (result == null)
{
var responseJson = result.RootElement.GetRawText();
// On successful auth, extract access token and post session capabilities in background
if (statusCode == 200)
_logger.LogWarning("Authentication failed - status {StatusCode}", statusCode);
if (statusCode == 401)
{
_logger.LogInformation("Authentication successful");
// Extract access token from response for session capabilities
string? accessToken = null;
if (result.RootElement.TryGetProperty("AccessToken", out var tokenEl))
{
accessToken = tokenEl.GetString();
return Unauthorized(new { error = "Invalid username or password" });
}
return StatusCode(statusCode, new { error = "Authentication failed" });
}
// Post session capabilities in background if we have a token
if (!string.IsNullOrEmpty(accessToken))
{
// Capture token in closure - don't use Request.Headers (will be disposed)
var token = accessToken;
_ = Task.Run(async () =>
{
_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.LogDebug("🔧 Posting session capabilities after authentication");
// Build auth header with the new token
var authHeaders = new HeaderDictionary
{
["X-Emby-Token"] = token
};
_logger.LogInformation("🔧 Posting session capabilities after authentication");
var capabilities = new
{
PlayableMediaTypes = new[] { "Audio" },
@@ -1920,36 +1469,23 @@ public class JellyfinController : ControllerBase
};
var capabilitiesJson = JsonSerializer.Serialize(capabilities);
var (capResult, capStatus) = await _proxyService.PostJsonAsync("Sessions/Capabilities/Full", capabilitiesJson, authHeaders);
var (capResult, capStatus) = await _proxyService.PostJsonAsync("Sessions/Capabilities/Full", capabilitiesJson, Request.Headers);
if (capStatus == 204 || capStatus == 200)
{
_logger.LogDebug("✓ Session capabilities posted after auth ({StatusCode})", capStatus);
_logger.LogInformation("✓ Session capabilities posted after auth ({StatusCode})", capStatus);
}
else
{
_logger.LogDebug("⚠ Session capabilities returned {StatusCode} after auth", capStatus);
_logger.LogWarning("⚠ Session capabilities returned {StatusCode} after auth", capStatus);
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to post session capabilities after auth");
}
});
}
}
else
{
_logger.LogWarning("Authentication failed - status {StatusCode}", statusCode);
_logger.LogWarning(ex, "Failed to post session capabilities after auth, continuing anyway");
}
// Return Jellyfin's exact response
return Content(responseJson, "application/json");
}
// No response body from Jellyfin - return status code only
_logger.LogWarning("Authentication request returned {StatusCode} with no response body", statusCode);
return StatusCode(statusCode);
return Content(result.RootElement.GetRawText(), "application/json");
}
catch (Exception ex)
{
@@ -2183,7 +1719,7 @@ public class JellyfinController : ControllerBase
var method = Request.Method;
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : "";
_logger.LogDebug("📡 Session capabilities reported - Method: {Method}, Query: {Query}", method, queryString);
_logger.LogInformation("📡 Session capabilities reported - Method: {Method}, Query: {Query}", method, queryString);
_logger.LogInformation("Headers: {Headers}",
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}")));
@@ -2208,11 +1744,7 @@ public class JellyfinController : ControllerBase
if (statusCode == 204 || statusCode == 200)
{
_logger.LogDebug("✓ Session capabilities forwarded to Jellyfin ({StatusCode})", statusCode);
}
else if (statusCode == 401)
{
_logger.LogDebug("⚠ Jellyfin returned 401 for capabilities (token expired)");
_logger.LogInformation("✓ Session capabilities forwarded to Jellyfin ({StatusCode})", statusCode);
}
else
{
@@ -2246,13 +1778,101 @@ public class JellyfinController : ControllerBase
}
Request.Body.Position = 0;
_logger.LogDebug("📻 Playback START reported");
_logger.LogInformation("📻 Playback START reported - Body: {Body}", body);
_logger.LogInformation("Auth headers: {Headers}",
string.Join(", ", Request.Headers.Where(h => h.Key.Contains("Auth", StringComparison.OrdinalIgnoreCase)).Select(h => $"{h.Key}={h.Value}")));
// Extract device info from auth headers for session initialization
string? deviceId = null;
string? client = null;
string? device = null;
string? version = null;
if (Request.Headers.TryGetValue("Authorization", out var authHeader))
{
var authStr = authHeader.ToString();
// 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;
}
}
}
// Ensure session capabilities are posted to Jellyfin (if not already done)
// Jellyfin automatically creates a session when the client authenticates, but we need to
// post capabilities so the session shows up in the dashboard with proper device info
if (!string.IsNullOrEmpty(deviceId))
{
_logger.LogInformation("🔧 Ensuring session exists for device: {DeviceId} ({Client} {Version})", deviceId, client, version);
// First, check if a session exists for this device
try
{
var (sessionsResult, sessionsStatus) = await _proxyService.GetJsonAsync($"Sessions?deviceId={deviceId}", null, Request.Headers);
if (sessionsResult != null && sessionsStatus == 200)
{
var sessions = sessionsResult.RootElement;
_logger.LogInformation("📊 Jellyfin sessions for device {DeviceId}: {Sessions}", deviceId, sessions.GetRawText());
}
else
{
_logger.LogWarning("⚠ Could not query sessions ({StatusCode})", sessionsStatus);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to query sessions");
}
// Post capabilities - Jellyfin will match this to the authenticated session by device ID
// The query parameters tell Jellyfin what this device can do
var capabilitiesEndpoint = $"Sessions/Capabilities/Full";
try
{
// Send full capabilities as JSON body (more reliable than query params)
var capabilities = new
{
PlayableMediaTypes = new[] { "Audio" },
SupportedCommands = Array.Empty<string>(),
SupportsMediaControl = false,
SupportsPersistentIdentifier = true,
SupportsSync = false,
DeviceProfile = (object?)null // Let Jellyfin use defaults
};
var capabilitiesJson = JsonSerializer.Serialize(capabilities);
_logger.LogInformation("📤 Posting capabilities: {Json}", capabilitiesJson);
var (capResult, capStatus) = await _proxyService.PostJsonAsync(capabilitiesEndpoint, capabilitiesJson, Request.Headers);
_logger.LogInformation("✓ Session capabilities posted ({StatusCode})", capStatus);
// Check sessions again after posting capabilities
var (sessionsResult2, sessionsStatus2) = await _proxyService.GetJsonAsync($"Sessions?deviceId={deviceId}", null, Request.Headers);
if (sessionsResult2 != null && sessionsStatus2 == 200)
{
var sessions2 = sessionsResult2.RootElement;
_logger.LogInformation("📊 Jellyfin sessions AFTER capabilities: {Sessions}", sessions2.GetRawText());
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to post session capabilities, continuing anyway");
}
}
// 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))
{
@@ -2264,18 +1884,6 @@ public class JellyfinController : ControllerBase
itemName = itemNameProp.GetString();
}
if (doc.RootElement.TryGetProperty("PositionTicks", out var posProp))
{
positionTicks = posProp.GetInt64();
}
// Track the playing item for scrobbling on session cleanup
var (deviceId, client, device, version) = ExtractDeviceInfo(Request.Headers);
if (!string.IsNullOrEmpty(deviceId) && !string.IsNullOrEmpty(itemId))
{
_sessionManager.UpdatePlayingItem(deviceId, itemId, positionTicks);
}
if (!string.IsNullOrEmpty(itemId))
{
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
@@ -2284,75 +1892,20 @@ public class JellyfinController : ControllerBase
{
_logger.LogInformation("🎵 External track playback started: {Name} ({Provider}/{ExternalId})",
itemName ?? "Unknown", provider, externalId);
// Proactively fetch lyrics in background for external tracks
_ = Task.Run(async () =>
{
try
{
await PrefetchLyricsForTrackAsync(itemId, isExternal: true, provider, externalId);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to prefetch lyrics for external track {ItemId}", itemId);
}
});
// Create a ghost/fake item to report to Jellyfin so "Now Playing" shows up
// Generate a deterministic UUID from the external ID
var ghostUuid = GenerateUuidFromString(itemId);
// Build minimal playback start with just the ghost UUID
// Don't include the Item object - Jellyfin will just track the session without item details
var playbackStart = new
{
ItemId = ghostUuid,
PositionTicks = positionTicks ?? 0,
CanSeek = true,
IsPaused = false,
IsMuted = false,
PlayMethod = "DirectPlay"
};
var playbackJson = JsonSerializer.Serialize(playbackStart);
_logger.LogDebug("📤 Sending ghost playback start for external track: {Json}", playbackJson);
// Forward to Jellyfin with ghost UUID
var (ghostResult, ghostStatusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", playbackJson, Request.Headers);
if (ghostStatusCode == 204 || ghostStatusCode == 200)
{
_logger.LogDebug("✓ Ghost playback start forwarded to Jellyfin for external track ({StatusCode})", ghostStatusCode);
}
else
{
_logger.LogWarning("⚠️ Ghost playback start returned status {StatusCode} for external track", ghostStatusCode);
}
// 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);
// Proactively fetch lyrics in background for local tracks
_ = Task.Run(async () =>
{
try
{
await PrefetchLyricsForTrackAsync(itemId, isExternal: false, null, null);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to prefetch lyrics for local track {ItemId}", itemId);
}
});
}
// For local tracks, forward playback start to Jellyfin FIRST
_logger.LogDebug("Forwarding playback start to Jellyfin...");
// For local tracks, forward to Jellyfin with client auth
_logger.LogInformation("Forwarding playback start to Jellyfin...");
// Fetch full item details to include in playback report
// This makes the session show up properly in Jellyfin dashboard with "Now Playing"
try
{
var (itemResult, itemStatus) = await _proxyService.GetJsonAsync($"Items/{itemId}", null, Request.Headers);
@@ -2361,44 +1914,27 @@ public class JellyfinController : ControllerBase
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
// Build enhanced playback start info with full item details
var enhancedBody = new
{
ItemId = itemId,
PositionTicks = positionTicks ?? 0,
// Let Jellyfin fetch the item details - don't include NowPlayingItem
PositionTicks = doc.RootElement.TryGetProperty("PositionTicks", out var posProp) ? posProp.GetInt64() : 0,
// Include the full item so Jellyfin can display "Now Playing"
NowPlayingItem = item.Clone()
};
var playbackJson = JsonSerializer.Serialize(playbackStart);
_logger.LogInformation("📤 Sending playback start: {Json}", playbackJson);
var enhancedJson = JsonSerializer.Serialize(enhancedBody);
_logger.LogInformation("📤 Sending enhanced playback start with item details");
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", playbackJson, Request.Headers);
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", enhancedJson, Request.Headers);
if (statusCode == 204 || statusCode == 200)
{
_logger.LogDebug("✓ Playback start forwarded to Jellyfin ({StatusCode})", statusCode);
// NOW ensure session exists with capabilities (after playback is reported)
if (!string.IsNullOrEmpty(deviceId))
{
var sessionCreated = await _sessionManager.EnsureSessionAsync(deviceId, client ?? "Unknown", device ?? "Unknown", version ?? "1.0", Request.Headers);
if (sessionCreated)
{
_logger.LogDebug("✓ SESSION: Session ensured for device {DeviceId} after playback start", deviceId);
_logger.LogInformation("✓ Enhanced playback start forwarded to Jellyfin ({StatusCode})", statusCode);
}
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);
_logger.LogWarning("⚠️ Enhanced playback start returned status {StatusCode}", statusCode);
}
}
else
@@ -2408,13 +1944,13 @@ public class JellyfinController : ControllerBase
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", body, Request.Headers);
if (statusCode == 204 || statusCode == 200)
{
_logger.LogDebug("✓ Basic playback start forwarded to Jellyfin ({StatusCode})", statusCode);
_logger.LogInformation("✓ Basic playback start forwarded to Jellyfin ({StatusCode})", statusCode);
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send playback start, trying basic");
_logger.LogWarning(ex, "Failed to send enhanced 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)
@@ -2448,94 +1984,22 @@ public class JellyfinController : ControllerBase
}
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();
}
// Track the playing item for scrobbling on session cleanup
if (!string.IsNullOrEmpty(deviceId) && !string.IsNullOrEmpty(itemId))
{
_sessionManager.UpdatePlayingItem(deviceId, itemId, positionTicks);
}
if (!string.IsNullOrEmpty(itemId))
{
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
var itemId = itemIdProp.GetString();
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId ?? "");
if (isExternal)
{
// For external tracks, report progress with ghost UUID to Jellyfin
var ghostUuid = GenerateUuidFromString(itemId);
// Build progress report with ghost UUID
var progressReport = new
{
ItemId = ghostUuid,
PositionTicks = positionTicks ?? 0,
IsPaused = false,
IsMuted = false,
CanSeek = true,
PlayMethod = "DirectPlay"
};
var progressJson = JsonSerializer.Serialize(progressReport);
// Forward to Jellyfin with ghost UUID
var (progressResult, progressStatusCode) = await _proxyService.PostJsonAsync("Sessions/Playing/Progress", progressJson, Request.Headers);
// Log progress occasionally for debugging (every ~30 seconds)
if (positionTicks.HasValue)
{
var position = TimeSpan.FromTicks(positionTicks.Value);
if (position.Seconds % 30 == 0 && position.Milliseconds < 500)
{
_logger.LogDebug("▶️ External track progress: {Position:mm\\:ss} ({Provider}/{ExternalId}) - Status: {StatusCode}",
position, provider, externalId, progressStatusCode);
}
}
// For external tracks, just acknowledge
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.LogDebug("▶️ Progress: {Position:mm\\:ss} for item {ItemId}", position, itemId);
}
}
}
// For local tracks, forward to Jellyfin
_logger.LogDebug("📤 Sending playback progress body: {Body}", body);
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)
@@ -2561,14 +2025,13 @@ public class JellyfinController : ControllerBase
}
Request.Body.Position = 0;
_logger.LogDebug("⏹️ Playback STOPPED reported");
_logger.LogInformation("⏹️ Playback STOPPED reported");
// Parse the body to check if it's an external track
var doc = JsonDocument.Parse(body);
string? itemId = null;
string? itemName = null;
long? positionTicks = null;
string? deviceId = null;
if (doc.RootElement.TryGetProperty("ItemId", out var itemIdProp))
{
@@ -2585,12 +2048,6 @@ public class JellyfinController : ControllerBase
positionTicks = posProp.GetInt64();
}
// Try to get device ID from headers for session management
if (Request.Headers.TryGetValue("X-Emby-Device-Id", out var deviceIdHeader))
{
deviceId = deviceIdHeader.FirstOrDefault();
}
if (!string.IsNullOrEmpty(itemId))
{
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
@@ -2602,26 +2059,6 @@ public class JellyfinController : ControllerBase
: "unknown";
_logger.LogInformation("🎵 External track playback stopped: {Name} at {Position} ({Provider}/{ExternalId})",
itemName ?? "Unknown", position, provider, externalId);
// Report stop to Jellyfin with ghost UUID
var ghostUuid = GenerateUuidFromString(itemId);
var stopInfo = new
{
ItemId = ghostUuid,
PositionTicks = positionTicks ?? 0
};
var stopJson = JsonSerializer.Serialize(stopInfo);
_logger.LogDebug("📤 Sending ghost playback stop for external track: {Json}", stopJson);
var (stopResult, stopStatusCode) = await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", stopJson, Request.Headers);
if (stopStatusCode == 204 || stopStatusCode == 200)
{
_logger.LogDebug("✓ Ghost playback stop forwarded to Jellyfin ({StatusCode})", stopStatusCode);
}
return NoContent();
}
@@ -2630,34 +2067,12 @@ public class JellyfinController : ControllerBase
}
// For local tracks, forward to Jellyfin
_logger.LogDebug("Forwarding playback stop to Jellyfin...");
// Log the body being sent for debugging
_logger.LogInformation("📤 Sending playback stop body: {Body}", body);
// Validate that body is not empty
if (string.IsNullOrWhiteSpace(body) || body == "{}")
{
_logger.LogWarning("⚠️ Playback stop body is empty, building minimal valid payload");
// Build a minimal valid PlaybackStopInfo
var stopInfo = new
{
ItemId = itemId,
PositionTicks = positionTicks ?? 0
};
body = JsonSerializer.Serialize(stopInfo);
_logger.LogInformation("📤 Built playback stop body: {Body}", body);
}
_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.LogDebug("✓ Playback stop forwarded to Jellyfin ({StatusCode})", statusCode);
}
else if (statusCode == 401)
{
_logger.LogDebug("Playback stop returned 401 (token expired)");
_logger.LogInformation("✓ Playback stop forwarded to Jellyfin ({StatusCode})", statusCode);
}
else
{
@@ -2716,7 +2131,7 @@ public class JellyfinController : ControllerBase
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : "";
var endpoint = string.IsNullOrEmpty(path) ? $"Sessions{queryString}" : $"Sessions/{path}{queryString}";
_logger.LogDebug("🔄 Proxying session request: {Method} {Endpoint}", method, endpoint);
_logger.LogInformation("🔄 Proxying session request: {Method} {Endpoint}", method, endpoint);
_logger.LogDebug("Session proxy headers: {Headers}",
string.Join(", ", Request.Headers.Where(h => h.Key.Contains("Auth", StringComparison.OrdinalIgnoreCase))
.Select(h => $"{h.Key}={h.Value}")));
@@ -2746,11 +2161,11 @@ public class JellyfinController : ControllerBase
if (result != null)
{
_logger.LogDebug("✓ Session request proxied successfully ({StatusCode})", statusCode);
_logger.LogInformation("✓ Session request proxied successfully ({StatusCode})", statusCode);
return new JsonResult(result.RootElement.Clone());
}
_logger.LogDebug("✓ Session request proxied ({StatusCode}, no body)", statusCode);
_logger.LogInformation("✓ Session request proxied ({StatusCode}, no body)", statusCode);
return StatusCode(statusCode);
}
catch (Exception ex)
@@ -2808,7 +2223,7 @@ public class JellyfinController : ControllerBase
if (path.Contains("session", StringComparison.OrdinalIgnoreCase) ||
path.Contains("capabilit", StringComparison.OrdinalIgnoreCase))
{
_logger.LogDebug("🔍 SESSION/CAPABILITY REQUEST: {Method} /{Path}{Query}", Request.Method, path, Request.QueryString);
_logger.LogWarning("🔍 SESSION/CAPABILITY REQUEST: {Method} /{Path}{Query}", Request.Method, path, Request.QueryString);
}
else
{
@@ -2865,11 +2280,11 @@ public class JellyfinController : ControllerBase
_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));
_logger.LogInformation("Configured IDs: {Ids}", string.Join(", ", _spotifySettings.PlaylistIds));
_logger.LogInformation("Is configured: {IsConfigured}", _spotifySettings.PlaylistIds.Contains(playlistId, StringComparer.OrdinalIgnoreCase));
// Check if this playlist ID is configured for Spotify injection
if (_spotifySettings.IsSpotifyPlaylist(playlistId))
if (_spotifySettings.PlaylistIds.Any(id => id.Equals(playlistId, StringComparison.OrdinalIgnoreCase)))
{
_logger.LogInformation("========================================");
_logger.LogInformation("=== INTERCEPTING SPOTIFY PLAYLIST ===");
@@ -2880,18 +2295,9 @@ public class JellyfinController : ControllerBase
}
}
// 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))
// Handle non-JSON responses (robots.txt, etc.)
if (path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase) ||
path.EndsWith(".xml", StringComparison.OrdinalIgnoreCase))
{
var fullPath = path;
if (Request.QueryString.HasValue)
@@ -2903,42 +2309,14 @@ public class JellyfinController : ControllerBase
try
{
// Forward authentication headers for image requests
using var request = new HttpRequestMessage(HttpMethod.Get, url);
// 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);
var response = await _proxyService.HttpClient.GetAsync(url);
var content = await response.Content.ReadAsStringAsync();
var contentType = response.Content.Headers.ContentType?.ToString() ?? "text/plain";
return Content(content, contentType);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to proxy binary request for {Path}", path);
_logger.LogWarning(ex, "Failed to proxy non-JSON request for {Path}", path);
return NotFound();
}
}
@@ -3162,121 +2540,46 @@ public class JellyfinController : ControllerBase
var playlistId = idProp.GetString();
_logger.LogDebug("Checking item with ID: {Id}", playlistId);
if (!string.IsNullOrEmpty(playlistId) && _spotifySettings.IsSpotifyPlaylist(playlistId))
if (!string.IsNullOrEmpty(playlistId) &&
_spotifySettings.PlaylistIds.Any(id => id.Equals(playlistId, StringComparison.OrdinalIgnoreCase)))
{
_logger.LogInformation("Found Spotify playlist: {Id}", playlistId);
// This is a Spotify playlist - get the actual track count
var playlistConfig = _spotifySettings.GetPlaylistByJellyfinId(playlistId);
var playlistIndex = _spotifySettings.PlaylistIds.FindIndex(id =>
id.Equals(playlistId, StringComparison.OrdinalIgnoreCase));
if (playlistConfig != null)
if (playlistIndex >= 0 && playlistIndex < _spotifySettings.PlaylistNames.Count)
{
_logger.LogInformation("Found playlist config for Jellyfin ID {JellyfinId}: {Name} (Spotify ID: {SpotifyId})",
playlistId, playlistConfig.Name, playlistConfig.Id);
var playlistName = playlistConfig.Name;
var playlistName = _spotifySettings.PlaylistNames[playlistIndex];
var missingTracksKey = $"spotify:missing:{playlistName}";
var missingTracks = await _cache.GetAsync<List<allstarr.Models.Spotify.MissingTrack>>(missingTracksKey);
// 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} tracks",
missingTracksKey, missingTracks?.Count ?? 0);
_logger.LogDebug("Cache lookup for {Key}: {Count} matched tracks",
matchedTracksKey, matchedTracks?.Count ?? 0);
// Fallback to legacy cache format
if (matchedTracks == null || matchedTracks.Count == 0)
// Fallback to file cache
if (missingTracks == null || missingTracks.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.LogDebug("Loaded {Count} tracks from legacy cache", matchedTracks.Count);
}
_logger.LogInformation("Trying file cache for {Name}", playlistName);
missingTracks = await LoadMissingTracksFromFile(playlistName);
_logger.LogInformation("File cache result: {Count} tracks", missingTracks?.Count ?? 0);
}
// Try loading from file cache if Redis is empty
if (matchedTracks == null || matchedTracks.Count == 0)
if (missingTracks != null && missingTracks.Count > 0)
{
var fileItems = await LoadPlaylistItemsFromFile(playlistName);
if (fileItems != null && fileItems.Count > 0)
{
_logger.LogInformation("💿 Loaded {Count} playlist items from file cache for count update", fileItems.Count);
// Use file cache count directly
itemDict["ChildCount"] = fileItems.Count;
// Update ChildCount to show the number of tracks we'll provide
itemDict["ChildCount"] = missingTracks.Count;
modified = true;
}
}
// Only fetch from Jellyfin if we didn't get count from file cache
if (!itemDict.ContainsKey("ChildCount") ||
(itemDict["ChildCount"] is JsonElement childCountElement && childCountElement.GetInt32() == 0) ||
(itemDict["ChildCount"] is int childCountInt && childCountInt == 0))
{
// Get local tracks count from Jellyfin
var localTracksCount = 0;
try
{
// Include UserId parameter to avoid 401 Unauthorized
var userId = _settings.UserId;
var playlistItemsUrl = $"Playlists/{playlistId}/Items";
var queryParams = new Dictionary<string, string>();
if (!string.IsNullOrEmpty(userId))
{
queryParams["UserId"] = userId;
}
var (localTracksResponse, _) = await _proxyService.GetJsonAsyncInternal(
playlistItemsUrl,
queryParams);
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 = local tracks in Jellyfin + external matched tracks
// This represents what users will actually hear when playing the playlist
var totalAvailableCount = localTracksCount + externalMatchedCount;
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} ({Local} local + {External} external)",
playlistName, totalAvailableCount, localTracksCount, externalMatchedCount);
_logger.LogInformation("✓ Updated ChildCount for Spotify playlist {Name} to {Count}",
playlistName, missingTracks.Count);
}
else
{
_logger.LogWarning("No tracks found for {Name} ({Local} local + {External} external = {Total} total)",
playlistName, localTracksCount, externalMatchedCount, totalAvailableCount);
_logger.LogWarning("No missing tracks found for {Name}", playlistName);
}
}
}
else
{
_logger.LogWarning("No playlist config found for Jellyfin ID {JellyfinId} - skipping count update", playlistId);
}
}
}
updatedItems.Add(itemDict);
@@ -3432,318 +2735,24 @@ public class JellyfinController : ControllerBase
/// <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 Redis cache first for fast serving
var cacheKey = $"spotify:playlist:items:{spotifyPlaylistName}";
var cachedItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(cacheKey);
if (cachedItems != null && cachedItems.Count > 0)
{
_logger.LogInformation("✅ Loaded {Count} playlist items from Redis cache for {Playlist}",
cachedItems.Count, spotifyPlaylistName);
// Log sample item to verify Spotify IDs are present
if (cachedItems.Count > 0 && cachedItems[0].ContainsKey("ProviderIds"))
{
var providerIds = cachedItems[0]["ProviderIds"] as Dictionary<string, object>;
var hasSpotifyId = providerIds?.ContainsKey("Spotify") ?? false;
_logger.LogDebug("Sample cached item has Spotify ID: {HasSpotifyId}", hasSpotifyId);
}
return new JsonResult(new
{
Items = cachedItems,
TotalRecordCount = cachedItems.Count,
StartIndex = 0
});
}
// Check file cache as fallback
var fileItems = await LoadPlaylistItemsFromFile(spotifyPlaylistName);
if (fileItems != null && fileItems.Count > 0)
{
_logger.LogInformation("✅ Loaded {Count} playlist items from file cache for {Playlist}",
fileItems.Count, spotifyPlaylistName);
// Restore to Redis cache
await _cache.SetAsync(cacheKey, fileItems, TimeSpan.FromHours(24));
return new JsonResult(new
{
Items = fileItems,
TotalRecordCount = fileItems.Count,
StartIndex = 0
});
}
// 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 (RAW - don't convert!)
// 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
}
// Request MediaSources field to get bitrate info
var playlistItemsUrl = $"Playlists/{playlistId}/Items?UserId={userId}&Fields=MediaSources";
_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;
}
// Keep raw Jellyfin items - don't convert to Song objects!
var jellyfinItems = new List<JsonElement>();
var jellyfinItemsByName = new Dictionary<string, JsonElement>();
if (existingTracksResponse != null &&
existingTracksResponse.RootElement.TryGetProperty("Items", out var items))
{
foreach (var item in items.EnumerateArray())
{
jellyfinItems.Add(item);
// Index by title+artist for matching
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
var artist = "";
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
{
artist = artistsEl[0].GetString() ?? "";
}
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
{
artist = albumArtistEl.GetString() ?? "";
}
var key = $"{title}|{artist}".ToLowerInvariant();
if (!jellyfinItemsByName.ContainsKey(key))
{
jellyfinItemsByName[key] = item;
}
}
_logger.LogInformation("✅ Found {Count} existing LOCAL tracks in Jellyfin playlist", jellyfinItems.Count);
}
else
{
_logger.LogWarning("⚠️ No existing tracks found in Jellyfin playlist {PlaylistId} - playlist may be empty", playlistId);
}
// 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
var finalItems = new List<Dictionary<string, object?>>();
var usedJellyfinItems = new HashSet<string>();
var localUsedCount = 0;
var externalUsedCount = 0;
_logger.LogInformation("🔍 Building playlist in Spotify order with {SpotifyCount} positions...", spotifyTracks.Count);
foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position))
{
// Try to find matching Jellyfin item by fuzzy matching
JsonElement? matchedJellyfinItem = null;
string? matchedKey = null;
double bestScore = 0;
foreach (var kvp in jellyfinItemsByName)
{
if (usedJellyfinItems.Contains(kvp.Key)) continue;
var item = kvp.Value;
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
var artist = "";
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
{
artist = artistsEl[0].GetString() ?? "";
}
var titleScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.Title, title);
var artistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, artist);
var totalScore = (titleScore * 0.7) + (artistScore * 0.3);
if (totalScore > bestScore && totalScore >= 70)
{
bestScore = totalScore;
matchedJellyfinItem = item;
matchedKey = kvp.Key;
}
}
if (matchedJellyfinItem.HasValue && matchedKey != null)
{
// Use the raw Jellyfin item (preserves ALL metadata including MediaSources!)
var itemDict = JsonSerializer.Deserialize<Dictionary<string, object?>>(matchedJellyfinItem.Value.GetRawText());
if (itemDict != null)
{
finalItems.Add(itemDict);
usedJellyfinItems.Add(matchedKey);
localUsedCount++;
_logger.LogDebug("✅ Position #{Pos}: '{Title}' → LOCAL (score: {Score:F1}%)",
spotifyTrack.Position, spotifyTrack.Title, bestScore);
}
}
else
{
// No local match - try to find external track
var matched = orderedTracks?.FirstOrDefault(t => t.SpotifyId == spotifyTrack.SpotifyId);
if (matched != null && matched.MatchedSong != null)
{
// Convert external song to Jellyfin item format
var externalItem = _responseBuilder.ConvertSongToJellyfinItem(matched.MatchedSong);
// Add Spotify ID to ProviderIds so lyrics can work
if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId))
{
if (!externalItem.ContainsKey("ProviderIds"))
{
externalItem["ProviderIds"] = new Dictionary<string, string>();
}
var providerIds = externalItem["ProviderIds"] as Dictionary<string, string>;
if (providerIds != null && !providerIds.ContainsKey("Spotify"))
{
providerIds["Spotify"] = spotifyTrack.SpotifyId;
}
}
finalItems.Add(externalItem);
externalUsedCount++;
_logger.LogDebug("📥 Position #{Pos}: '{Title}' → EXTERNAL: {Provider}/{Id} (Spotify ID: {SpotifyId})",
spotifyTrack.Position, spotifyTrack.Title,
matched.MatchedSong.ExternalProvider, matched.MatchedSong.ExternalId, spotifyTrack.SpotifyId);
}
else
{
_logger.LogDebug("❌ Position #{Pos}: '{Title}' → NO MATCH",
spotifyTrack.Position, spotifyTrack.Title);
}
}
}
_logger.LogInformation(
"🎵 Final playlist '{Playlist}': {Total} tracks ({Local} LOCAL + {External} EXTERNAL)",
spotifyPlaylistName, finalItems.Count, localUsedCount, externalUsedCount);
// Save to file cache for persistence across restarts
await SavePlaylistItemsToFile(spotifyPlaylistName, finalItems);
// Also cache in Redis for fast serving (reuse the same cache key from top of method)
await _cache.SetAsync(cacheKey, finalItems, TimeSpan.FromHours(24));
// Return raw Jellyfin response format
return new JsonResult(new
{
Items = finalItems,
TotalRecordCount = finalItems.Count,
StartIndex = 0
});
}
/// <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)
if (cachedTracks != null)
{
_logger.LogDebug("Returning {Count} cached matched tracks from Redis for {Playlist}",
_logger.LogDebug("Returning {Count} cached matched tracks 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,
$"Playlists/{playlistId}/Items",
null,
Request.Headers);
@@ -3767,13 +2776,9 @@ public class JellyfinController : ControllerBase
}
_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);
var missingTracks = await _cache.GetAsync<List<allstarr.Models.Spotify.MissingTrack>>(missingTracksKey);
// Fallback to file cache if Redis is empty
if (missingTracks == null || missingTracks.Count == 0)
@@ -3822,7 +2827,8 @@ public class JellyfinController : ControllerBase
{
Song = song,
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, song.Title),
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors)
// Calculate artist score by checking ALL artists match
ArtistScore = CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors)
})
.Select(x => new
{
@@ -3860,38 +2866,42 @@ public class JellyfinController : ControllerBase
}
}
// Build final track list based on playlist configuration
// Local tracks position is configurable per-playlist
var playlistConfig = _spotifySettings.GetPlaylistByJellyfinId(playlistId);
var localTracksPosition = playlistConfig?.LocalTracksPosition ?? LocalTracksPosition.First;
// Build final track list in Spotify playlist order
var finalTracks = new List<Song>();
if (localTracksPosition == LocalTracksPosition.First)
foreach (var missingTrack in missingTracks)
{
// Local tracks first, external tracks at the end
finalTracks.AddRange(existingTracks);
finalTracks.AddRange(matchedBySpotifyId.Values);
// Check if we have it locally first
var existingTrack = existingTracks.FirstOrDefault(t =>
t.Title.Equals(missingTrack.Title, StringComparison.OrdinalIgnoreCase) &&
t.Artist.Equals(missingTrack.PrimaryArtist, StringComparison.OrdinalIgnoreCase));
if (existingTrack != null)
{
finalTracks.Add(existingTrack);
}
else
else if (matchedBySpotifyId.TryGetValue(missingTrack.SpotifyId, out var matchedTrack))
{
// External tracks first, local tracks at the end
finalTracks.AddRange(matchedBySpotifyId.Values);
finalTracks.AddRange(existingTracks);
finalTracks.Add(matchedTrack);
}
// Skip tracks we couldn't match
}
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})",
_logger.LogInformation("Final playlist: {Total} tracks ({Existing} local, {Matched} matched, {Missing} missing)",
finalTracks.Count,
existingTracks.Count,
matchedBySpotifyId.Count,
localTracksPosition);
missingTracks.Count - existingTracks.Count - matchedBySpotifyId.Count);
return _responseBuilder.CreateItemsResponse(finalTracks);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting Spotify playlist tracks {PlaylistName}", spotifyPlaylistName);
return _responseBuilder.CreateError(500, "Failed to get Spotify playlist tracks");
}
}
/// <summary>
/// Copies an external track to the kept folder when favorited.
@@ -3900,14 +2910,7 @@ public class JellyfinController : ControllerBase
{
try
{
// Check if already favorited (persistent tracking)
if (await IsTrackFavoritedAsync(itemId))
{
_logger.LogInformation("Track already favorited (persistent): {ItemId}", itemId);
return;
}
// Get the song metadata first to build paths
// Get the song metadata
var song = await _metadataService.GetSongAsync(provider, externalId);
if (song == null)
{
@@ -3915,90 +2918,51 @@ public class JellyfinController : ControllerBase
return;
}
// Build kept folder path: Artist/Album/
var keptBasePath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
var keptArtistPath = Path.Combine(keptBasePath, PathHelper.SanitizeFileName(song.Artist));
var keptAlbumPath = Path.Combine(keptArtistPath, PathHelper.SanitizeFileName(song.Album));
// Trigger download first
_logger.LogInformation("Downloading track for kept folder: {ItemId}", itemId);
string downloadPath;
// Check if track already exists in kept folder
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]);
// Mark as favorited even if we didn't download it
await MarkTrackAsFavoritedAsync(itemId, song);
return;
}
}
// Look for the track in cache folder first
var cacheBasePath = "/tmp/allstarr-cache";
var cacheArtistPath = Path.Combine(cacheBasePath, PathHelper.SanitizeFileName(song.Artist));
var cacheAlbumPath = Path.Combine(cacheArtistPath, PathHelper.SanitizeFileName(song.Album));
string? sourceFilePath = null;
if (Directory.Exists(cacheAlbumPath))
{
var sanitizedTitle = PathHelper.SanitizeFileName(song.Title);
var cacheFiles = Directory.GetFiles(cacheAlbumPath, $"*{sanitizedTitle}*");
if (cacheFiles.Length > 0)
{
sourceFilePath = cacheFiles[0];
_logger.LogInformation("Found track in cache folder: {Path}", sourceFilePath);
}
}
// If not in cache, download it first
if (sourceFilePath == null)
{
_logger.LogInformation("Track not in cache, downloading: {ItemId}", itemId);
try
{
sourceFilePath = await _downloadService.DownloadSongAsync(provider, externalId);
downloadPath = await _downloadService.DownloadSongAsync(provider, externalId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to download track {ItemId}", itemId);
return;
}
}
// Create the kept folder structure
// Create kept folder structure: /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));
Directory.CreateDirectory(keptAlbumPath);
// Copy file to kept folder
var fileName = Path.GetFileName(sourceFilePath);
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);
await MarkTrackAsFavoritedAsync(itemId, song);
_logger.LogInformation("Track already exists in kept folder: {Path}", keptFilePath);
return;
}
System.IO.File.Copy(sourceFilePath, keptFilePath, overwrite: false);
_logger.LogInformation("✓ Copied track to kept folder: {Path}", keptFilePath);
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 sourceCoverPath = Path.Combine(Path.GetDirectoryName(sourceFilePath)!, "cover.jpg");
if (System.IO.File.Exists(sourceCoverPath))
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(sourceCoverPath, keptCoverPath, overwrite: false);
System.IO.File.Copy(coverPath, keptCoverPath, overwrite: false);
_logger.LogDebug("Copied cover art to kept folder");
}
}
// Mark as favorited in persistent storage
await MarkTrackAsFavoritedAsync(itemId, song);
}
catch (Exception ex)
{
@@ -4006,248 +2970,6 @@ public class JellyfinController : ControllerBase
}
}
/// <summary>
/// Removes an external track from the kept folder when unfavorited.
/// </summary>
private async Task RemoveExternalTrackFromKeptAsync(string itemId, string provider, string externalId)
{
try
{
// Mark for deletion instead of immediate deletion
await MarkTrackForDeletionAsync(itemId);
_logger.LogInformation("✓ Marked track for deletion: {ItemId}", itemId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error marking external track {ItemId} for deletion", itemId);
}
}
#region Persistent Favorites Tracking
private readonly string _favoritesFilePath = "/app/cache/favorites.json";
/// <summary>
/// Checks if a track is already favorited (persistent across restarts).
/// </summary>
private async Task<bool> IsTrackFavoritedAsync(string itemId)
{
try
{
if (!System.IO.File.Exists(_favoritesFilePath))
return false;
var json = await System.IO.File.ReadAllTextAsync(_favoritesFilePath);
var favorites = JsonSerializer.Deserialize<Dictionary<string, FavoriteTrackInfo>>(json) ?? new();
return favorites.ContainsKey(itemId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to check favorite status for {ItemId}", itemId);
return false;
}
}
/// <summary>
/// Marks a track as favorited in persistent storage.
/// </summary>
private async Task MarkTrackAsFavoritedAsync(string itemId, Song song)
{
try
{
var favorites = new Dictionary<string, FavoriteTrackInfo>();
if (System.IO.File.Exists(_favoritesFilePath))
{
var json = await System.IO.File.ReadAllTextAsync(_favoritesFilePath);
favorites = JsonSerializer.Deserialize<Dictionary<string, FavoriteTrackInfo>>(json) ?? new();
}
favorites[itemId] = new FavoriteTrackInfo
{
ItemId = itemId,
Title = song.Title,
Artist = song.Artist,
Album = song.Album,
FavoritedAt = DateTime.UtcNow
};
// Ensure cache directory exists
Directory.CreateDirectory(Path.GetDirectoryName(_favoritesFilePath)!);
var updatedJson = JsonSerializer.Serialize(favorites, new JsonSerializerOptions { WriteIndented = true });
await System.IO.File.WriteAllTextAsync(_favoritesFilePath, updatedJson);
_logger.LogDebug("Marked track as favorited: {ItemId}", itemId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to mark track as favorited: {ItemId}", itemId);
}
}
/// <summary>
/// Removes a track from persistent favorites storage.
/// </summary>
private async Task UnmarkTrackAsFavoritedAsync(string itemId)
{
try
{
if (!System.IO.File.Exists(_favoritesFilePath))
return;
var json = await System.IO.File.ReadAllTextAsync(_favoritesFilePath);
var favorites = JsonSerializer.Deserialize<Dictionary<string, FavoriteTrackInfo>>(json) ?? new();
if (favorites.Remove(itemId))
{
var updatedJson = JsonSerializer.Serialize(favorites, new JsonSerializerOptions { WriteIndented = true });
await System.IO.File.WriteAllTextAsync(_favoritesFilePath, updatedJson);
_logger.LogDebug("Removed track from favorites: {ItemId}", itemId);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to remove track from favorites: {ItemId}", itemId);
}
}
/// <summary>
/// Marks a track for deletion (delayed deletion for safety).
/// </summary>
private async Task MarkTrackForDeletionAsync(string itemId)
{
try
{
var deletionFilePath = "/app/cache/pending_deletions.json";
var pendingDeletions = new Dictionary<string, DateTime>();
if (System.IO.File.Exists(deletionFilePath))
{
var json = await System.IO.File.ReadAllTextAsync(deletionFilePath);
pendingDeletions = JsonSerializer.Deserialize<Dictionary<string, DateTime>>(json) ?? new();
}
// Mark for deletion 24 hours from now
pendingDeletions[itemId] = DateTime.UtcNow.AddHours(24);
// Ensure cache directory exists
Directory.CreateDirectory(Path.GetDirectoryName(deletionFilePath)!);
var updatedJson = JsonSerializer.Serialize(pendingDeletions, new JsonSerializerOptions { WriteIndented = true });
await System.IO.File.WriteAllTextAsync(deletionFilePath, updatedJson);
// Also remove from favorites immediately
await UnmarkTrackAsFavoritedAsync(itemId);
_logger.LogDebug("Marked track for deletion in 24 hours: {ItemId}", itemId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to mark track for deletion: {ItemId}", itemId);
}
}
/// <summary>
/// Information about a favorited track for persistent storage.
/// </summary>
private class FavoriteTrackInfo
{
public string ItemId { get; set; } = "";
public string Title { get; set; } = "";
public string Artist { get; set; } = "";
public string Album { get; set; } = "";
public DateTime FavoritedAt { get; set; }
}
/// <summary>
/// Processes pending deletions (called by cleanup service).
/// </summary>
public async Task ProcessPendingDeletionsAsync()
{
try
{
var deletionFilePath = "/app/cache/pending_deletions.json";
if (!System.IO.File.Exists(deletionFilePath))
return;
var json = await System.IO.File.ReadAllTextAsync(deletionFilePath);
var pendingDeletions = JsonSerializer.Deserialize<Dictionary<string, DateTime>>(json) ?? new();
var now = DateTime.UtcNow;
var toDelete = pendingDeletions.Where(kvp => kvp.Value <= now).ToList();
var remaining = pendingDeletions.Where(kvp => kvp.Value > now).ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
foreach (var (itemId, _) in toDelete)
{
await ActuallyDeleteTrackAsync(itemId);
}
if (toDelete.Count > 0)
{
// Update pending deletions file
var updatedJson = JsonSerializer.Serialize(remaining, new JsonSerializerOptions { WriteIndented = true });
await System.IO.File.WriteAllTextAsync(deletionFilePath, updatedJson);
_logger.LogInformation("Processed {Count} pending deletions", toDelete.Count);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing pending deletions");
}
}
/// <summary>
/// Actually deletes a track from the kept folder.
/// </summary>
private async Task ActuallyDeleteTrackAsync(string itemId)
{
try
{
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
if (!isExternal) return;
var song = await _metadataService.GetSongAsync(provider!, externalId!);
if (song == null) return;
var keptBasePath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
var keptArtistPath = Path.Combine(keptBasePath, PathHelper.SanitizeFileName(song.Artist));
var keptAlbumPath = Path.Combine(keptArtistPath, PathHelper.SanitizeFileName(song.Album));
if (!Directory.Exists(keptAlbumPath)) return;
var sanitizedTitle = PathHelper.SanitizeFileName(song.Title);
var trackFiles = Directory.GetFiles(keptAlbumPath, $"*{sanitizedTitle}*");
foreach (var trackFile in trackFiles)
{
System.IO.File.Delete(trackFile);
_logger.LogInformation("✓ Deleted track from kept folder: {Path}", trackFile);
}
// Clean up empty directories
if (Directory.GetFiles(keptAlbumPath).Length == 0 && Directory.GetDirectories(keptAlbumPath).Length == 0)
{
Directory.Delete(keptAlbumPath);
if (Directory.Exists(keptArtistPath) &&
Directory.GetFiles(keptArtistPath).Length == 0 &&
Directory.GetDirectories(keptArtistPath).Length == 0)
{
Directory.Delete(keptArtistPath);
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete track {ItemId}", itemId);
}
}
#endregion
/// <summary>
/// Loads missing tracks from file cache as fallback when Redis is empty.
/// </summary>
@@ -4284,138 +3006,317 @@ public class JellyfinController : ControllerBase
}
/// <summary>
/// Loads matched/combined tracks from file cache as fallback when Redis is empty.
/// Manual trigger endpoint to force fetch Spotify missing tracks.
/// GET /spotify/sync?api_key=YOUR_KEY
/// </summary>
private async Task<List<Song>?> LoadMatchedTracksFromFile(string playlistName)
[HttpGet("spotify/sync", Order = 1)]
[ServiceFilter(typeof(ApiKeyAuthFilter))]
public async Task<IActionResult> TriggerSpotifySync()
{
if (!_spotifySettings.Enabled)
{
return BadRequest(new { error = "Spotify Import is not enabled" });
}
_logger.LogInformation("Manual Spotify sync triggered");
var results = new Dictionary<string, object>();
for (int i = 0; i < _spotifySettings.PlaylistIds.Count; i++)
{
var playlistId = _spotifySettings.PlaylistIds[i];
try
{
// Use configured name if available, otherwise use ID
var playlistName = i < _spotifySettings.PlaylistNames.Count
? _spotifySettings.PlaylistNames[i]
: playlistId;
_logger.LogInformation("Fetching missing tracks for {Playlist} (ID: {Id})", playlistName, playlistId);
// Try to fetch the missing tracks file - search last 24 hours
var now = DateTime.UtcNow;
var searchStart = now.AddHours(-24);
var httpClient = new HttpClient();
var found = false;
// Search every minute for the last 24 hours (1440 attempts max)
for (var time = searchStart; time <= now; time = time.AddMinutes(1))
{
var filename = $"{playlistName}_missing_{time:yyyy-MM-dd_HH-mm}.json";
var url = $"{_settings.Url}/Viperinius.Plugin.SpotifyImport/MissingTracksFile" +
$"?name={Uri.EscapeDataString(filename)}&api_key={_settings.ApiKey}";
try
{
_logger.LogDebug("Trying {Filename}", filename);
var response = await httpClient.GetAsync(url);
if (response.IsSuccessStatusCode)
{
var json = await response.Content.ReadAsStringAsync();
var tracks = ParseMissingTracksJson(json);
if (tracks.Count > 0)
{
var cacheKey = $"spotify:missing:{playlistName}";
await _cache.SetAsync(cacheKey, tracks, TimeSpan.FromHours(24));
results[playlistName] = new {
status = "success",
tracks = tracks.Count,
filename = filename
};
_logger.LogInformation("✓ Cached {Count} missing tracks for {Playlist} from {Filename}",
tracks.Count, playlistName, filename);
found = true;
break;
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to fetch {Filename}", filename);
}
}
if (!found)
{
results[playlistName] = new { status = "not_found", message = "No missing tracks file found" };
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error syncing playlist {PlaylistId}", playlistId);
results[playlistId] = new { status = "error", message = ex.Message };
}
}
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
{
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
var filePath = Path.Combine("/app/cache/spotify", $"{safeName}_matched.json");
if (!System.IO.File.Exists(filePath))
await matchingService.TriggerMatchingAsync();
}
catch (Exception ex)
{
_logger.LogDebug("No matched tracks file cache found for {Playlist} at {Path}", playlistName, filePath);
return null;
_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.PlaylistNames.Count > 0
? _spotifySettings.PlaylistNames
: _spotifySettings.PlaylistIds
});
}
var fileAge = DateTime.UtcNow - System.IO.File.GetLastWriteTimeUtc(filePath);
// Check if cache is too old (more than 24 hours)
if (fileAge.TotalHours > 24)
private List<allstarr.Models.Spotify.MissingTrack> ParseMissingTracksJson(string json)
{
_logger.LogInformation("Matched tracks file cache for {Playlist} is too old ({Age:F1}h), will rebuild",
playlistName, fileAge.TotalHours);
return null;
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");
}
_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;
}
#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 playlistName in _spotifySettings.PlaylistNames)
{
var matchedKey = $"spotify:matched:{playlistName}";
await _cache.DeleteAsync(matchedKey);
cleared.Add(playlistName);
_logger.LogInformation("Cleared cache for {Playlist}", playlistName);
}
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.LogWarning(ex, "Failed to load matched tracks from file for {Playlist}", playlistName);
return null;
_logger.LogError(ex, "Failed to get endpoint usage");
return StatusCode(500, new { error = ex.Message });
}
}
/// <summary>
/// Saves matched/combined tracks to file cache for persistence across restarts.
/// Clears the endpoint usage log file.
/// DELETE /debug/endpoint-usage?api_key=YOUR_KEY
/// </summary>
private async Task SaveMatchedTracksToFile(string playlistName, List<Song> tracks)
[HttpDelete("debug/endpoint-usage")]
[ServiceFilter(typeof(ApiKeyAuthFilter))]
public IActionResult ClearEndpointUsage()
{
try
{
var cacheDir = "/app/cache/spotify";
Directory.CreateDirectory(cacheDir);
var logFile = "/app/cache/endpoint-usage/endpoints.csv";
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
var filePath = Path.Combine(cacheDir, $"{safeName}_matched.json");
if (System.IO.File.Exists(logFile))
{
System.IO.File.Delete(logFile);
return Ok(new { status = "success", message = "Endpoint usage log cleared" });
}
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);
return Ok(new { status = "success", message = "No log file to clear" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save matched tracks to file for {Playlist}", playlistName);
}
}
/// <summary>
/// Saves playlist items (raw Jellyfin JSON) to file cache for persistence across restarts.
/// </summary>
private async Task SavePlaylistItemsToFile(string playlistName, List<Dictionary<string, object?>> items)
{
try
{
var cacheDir = "/app/cache/spotify";
Directory.CreateDirectory(cacheDir);
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
var filePath = Path.Combine(cacheDir, $"{safeName}_items.json");
var json = JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true });
await System.IO.File.WriteAllTextAsync(filePath, json);
_logger.LogInformation("💾 Saved {Count} playlist items to file cache for {Playlist}",
items.Count, playlistName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save playlist items to file for {Playlist}", playlistName);
}
}
/// <summary>
/// Loads playlist items (raw Jellyfin JSON) from file cache.
/// </summary>
private async Task<List<Dictionary<string, object?>>?> LoadPlaylistItemsFromFile(string playlistName)
{
try
{
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
var filePath = Path.Combine("/app/cache/spotify", $"{safeName}_items.json");
if (!System.IO.File.Exists(filePath))
{
_logger.LogDebug("No playlist items 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("Playlist items file cache for {Playlist} is too old ({Age:F1}h), will rebuild",
playlistName, fileAge.TotalHours);
return null;
}
_logger.LogDebug("Playlist items file cache for {Playlist} age: {Age:F1}h", playlistName, fileAge.TotalHours);
var json = await System.IO.File.ReadAllTextAsync(filePath);
var items = JsonSerializer.Deserialize<List<Dictionary<string, object?>>>(json);
_logger.LogInformation("💿 Loaded {Count} playlist items from file cache for {Playlist} (age: {Age:F1}h)",
items?.Count ?? 0, playlistName, fileAge.TotalHours);
return items;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load playlist items from file for {Playlist}", playlistName);
return null;
_logger.LogError(ex, "Failed to clear endpoint usage log");
return StatusCode(500, new { error = ex.Message });
}
}
@@ -4468,113 +3369,5 @@ public class JellyfinController : ControllerBase
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);
}
/// <summary>
/// Generates a deterministic UUID (v5) from a string.
/// This allows us to create consistent UUIDs for external track IDs.
/// </summary>
private string GenerateUuidFromString(string input)
{
// Use MD5 hash to generate a deterministic UUID
using var md5 = System.Security.Cryptography.MD5.Create();
var hash = md5.ComputeHash(System.Text.Encoding.UTF8.GetBytes(input));
// Convert to UUID format (version 5, namespace-based)
hash[6] = (byte)((hash[6] & 0x0F) | 0x50); // Version 5
hash[8] = (byte)((hash[8] & 0x3F) | 0x80); // Variant
var guid = new Guid(hash);
return guid.ToString();
}
/// <summary>
/// Finds the Spotify ID for an external track by searching through all playlist matched tracks caches.
/// This allows us to get Spotify lyrics for external tracks that were matched from Spotify playlists.
/// </summary>
private async Task<string?> FindSpotifyIdForExternalTrackAsync(Song externalSong)
{
try
{
// Get all configured playlists
var playlists = _spotifySettings.Playlists;
// Search through each playlist's matched tracks cache
foreach (var playlist in playlists)
{
var cacheKey = $"spotify:matched:ordered:{playlist.Name}";
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(cacheKey);
if (matchedTracks == null || matchedTracks.Count == 0)
continue;
// Look for a match by external ID
var match = matchedTracks.FirstOrDefault(t =>
t.MatchedSong != null &&
t.MatchedSong.ExternalProvider == externalSong.ExternalProvider &&
t.MatchedSong.ExternalId == externalSong.ExternalId);
if (match != null && !string.IsNullOrEmpty(match.SpotifyId))
{
_logger.LogDebug("Found Spotify ID {SpotifyId} for {Provider}/{ExternalId} in playlist {Playlist}",
match.SpotifyId, externalSong.ExternalProvider, externalSong.ExternalId, playlist.Name);
return match.SpotifyId;
}
}
_logger.LogDebug("No Spotify ID found for external track {Provider}/{ExternalId}",
externalSong.ExternalProvider, externalSong.ExternalId);
return null;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error finding Spotify ID for external track");
return null;
}
}
}
// force rebuild Sun Jan 25 13:22:47 EST 2026

View File

@@ -28,7 +28,6 @@ public class SubsonicController : ControllerBase
private readonly SubsonicModelMapper _modelMapper;
private readonly SubsonicProxyService _proxyService;
private readonly PlaylistSyncService? _playlistSyncService;
private readonly RedisCacheService _cache;
private readonly ILogger<SubsonicController> _logger;
public SubsonicController(
@@ -40,7 +39,6 @@ public class SubsonicController : ControllerBase
SubsonicResponseBuilder responseBuilder,
SubsonicModelMapper modelMapper,
SubsonicProxyService proxyService,
RedisCacheService cache,
ILogger<SubsonicController> logger,
PlaylistSyncService? playlistSyncService = null)
{
@@ -53,7 +51,6 @@ public class SubsonicController : ControllerBase
_modelMapper = modelMapper;
_proxyService = proxyService;
_playlistSyncService = playlistSyncService;
_cache = cache;
_logger = logger;
if (string.IsNullOrWhiteSpace(_subsonicSettings.Url))
@@ -562,16 +559,6 @@ public class SubsonicController : ControllerBase
{
try
{
// Check cache first (1 hour TTL for playlist images since they can change)
var cacheKey = $"playlist:image:{id}";
var cachedImage = await _cache.GetAsync<byte[]>(cacheKey);
if (cachedImage != null)
{
_logger.LogDebug("Serving cached playlist cover art for {Id}", id);
return File(cachedImage, "image/jpeg");
}
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(id);
var playlist = await _metadataService.GetPlaylistAsync(provider, externalId);
@@ -589,11 +576,6 @@ public class SubsonicController : ControllerBase
var imageBytes = await imageResponse.Content.ReadAsByteArrayAsync();
var contentType = imageResponse.Content.Headers.ContentType?.ToString() ?? "image/jpeg";
// Cache for 1 hour (playlists can change, so don't cache too long)
await _cache.SetAsync(cacheKey, imageBytes, TimeSpan.FromHours(1));
_logger.LogDebug("Cached playlist cover art for {Id}", id);
return File(imageBytes, contentType);
}
catch (Exception ex)

View File

@@ -1,28 +0,0 @@
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

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

View File

@@ -1,76 +0,0 @@
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

@@ -1,7 +1,6 @@
using System.Net.WebSockets;
using Microsoft.Extensions.Options;
using allstarr.Models.Settings;
using allstarr.Services.Jellyfin;
namespace allstarr.Middleware;
@@ -14,47 +13,35 @@ 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)
ILogger<WebSocketProxyMiddleware> logger)
{
_next = next;
_settings = settings.Value;
_logger = logger;
_sessionManager = sessionManager;
_logger.LogDebug("🔧 WEBSOCKET: WebSocketProxyMiddleware initialized - Jellyfin URL: {Url}", _settings.Url);
_logger.LogInformation("🔧 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"))
// Log ALL requests to /socket path for debugging
if (context.Request.Path.StartsWithSegments("/socket", StringComparison.OrdinalIgnoreCase))
{
_logger.LogDebug("🔍 WEBSOCKET: Potential WebSocket request: Path={Path}, IsWs={IsWs}, Method={Method}, Upgrade={Upgrade}, Connection={Connection}",
path,
isWebSocket,
_logger.LogInformation("📡 Request to /socket path - IsWebSocketRequest: {IsWs}, Method: {Method}, Headers: {Headers}",
context.WebSockets.IsWebSocketRequest,
context.Request.Method,
context.Request.Headers["Upgrade"].ToString(),
context.Request.Headers["Connection"].ToString());
string.Join(", ", context.Request.Headers.Select(h => $"{h.Key}={h.Value}")));
}
// Check if this is a WebSocket request to /socket
if (context.Request.Path.StartsWithSegments("/socket", StringComparison.OrdinalIgnoreCase) &&
context.WebSockets.IsWebSocketRequest)
{
_logger.LogDebug("🔌 WEBSOCKET: WebSocket connection request received from {RemoteIp}",
_logger.LogInformation("🔌 WebSocket connection request received from {RemoteIp}",
context.Connection.RemoteIpAddress);
await HandleWebSocketProxyAsync(context);
@@ -69,34 +56,12 @@ public class WebSocketProxyMiddleware
{
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");
_logger.LogInformation("✓ Client WebSocket accepted");
// Build Jellyfin WebSocket URL
var jellyfinUrl = _settings.Url?.TrimEnd('/') ?? "";
@@ -110,39 +75,28 @@ public class WebSocketProxyMiddleware
jellyfinWsUrl += context.Request.QueryString.Value;
}
_logger.LogDebug("🔗 WEBSOCKET: Connecting to Jellyfin WebSocket: {Url}", jellyfinWsUrl);
_logger.LogInformation("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))
// Forward authentication headers
if (context.Request.Headers.TryGetValue("Authorization", out var authHeader))
{
serverWebSocket.Options.SetRequestHeader("Authorization", authHeader.ToString());
_logger.LogDebug("Forwarded Authorization header");
}
else 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");
}
_logger.LogDebug("Forwarded X-Emby-Authorization header");
}
// Set user agent
serverWebSocket.Options.SetRequestHeader("User-Agent", "Allstarr/1.0");
await serverWebSocket.ConnectAsync(new Uri(jellyfinWsUrl), context.RequestAborted);
_logger.LogDebug("✓ WEBSOCKET: Connected to Jellyfin WebSocket");
_logger.LogInformation("✓ Connected to Jellyfin WebSocket");
// Start bidirectional proxying
var clientToServer = ProxyMessagesAsync(clientWebSocket, serverWebSocket, "Client→Server", context.RequestAborted);
@@ -151,23 +105,15 @@ public class WebSocketProxyMiddleware
// Wait for either direction to complete
await Task.WhenAny(clientToServer, serverToClient);
_logger.LogDebug("🔌 WEBSOCKET: WebSocket proxy connection closed");
_logger.LogInformation("WebSocket proxy connection closed");
}
catch (WebSocketException wsEx)
{
// 403 is expected when tokens expire or session ends - don't spam logs
if (wsEx.Message.Contains("403"))
{
_logger.LogDebug("WEBSOCKET: Connection rejected with 403 (token expired or session ended)");
}
else
{
_logger.LogWarning(wsEx, "⚠️ WEBSOCKET: WebSocket error: {Message}", wsEx.Message);
}
_logger.LogWarning(wsEx, "WebSocket error: {Message}", wsEx.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "❌ WEBSOCKET: Error in WebSocket proxy");
_logger.LogError(ex, "Error in WebSocket proxy");
}
finally
{
@@ -199,14 +145,7 @@ public class WebSocketProxyMiddleware
clientWebSocket?.Dispose();
serverWebSocket?.Dispose();
// CRITICAL: Notify session manager that client disconnected
if (!string.IsNullOrEmpty(deviceId))
{
_logger.LogDebug("🧹 WEBSOCKET: Client disconnected, removing session for device {DeviceId}", deviceId);
await _sessionManager.RemoveSessionAsync(deviceId);
}
_logger.LogDebug("🧹 WEBSOCKET: WebSocket connections cleaned up");
_logger.LogInformation("WebSocket connections cleaned up");
}
}
@@ -227,7 +166,7 @@ public class WebSocketProxyMiddleware
if (result.MessageType == WebSocketMessageType.Close)
{
_logger.LogDebug("🔌 WEBSOCKET {Direction}: Close message received", direction);
_logger.LogInformation("{Direction}: Close message received", direction);
await destination.CloseAsync(
result.CloseStatus ?? WebSocketCloseStatus.NormalClosure,
result.CloseStatusDescription,
@@ -243,15 +182,8 @@ public class WebSocketProxyMiddleware
{
var messageBytes = messageBuffer.ToArray();
// Log message for Server→Client direction to see remote control commands
if (direction == "Server→Client")
{
var messageText = System.Text.Encoding.UTF8.GetString(messageBytes);
_logger.LogTrace("📥 WEBSOCKET {Direction}: {Preview}",
direction,
messageText.Length > 500 ? messageText[..500] + "..." : messageText);
}
else if (_logger.IsEnabled(LogLevel.Debug))
// 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}",
@@ -274,15 +206,15 @@ public class WebSocketProxyMiddleware
}
catch (OperationCanceledException)
{
_logger.LogDebug("⚠️ WEBSOCKET {Direction}: Operation cancelled", direction);
_logger.LogInformation("{Direction}: Operation cancelled", direction);
}
catch (WebSocketException wsEx) when (wsEx.WebSocketErrorCode == WebSocketError.ConnectionClosedPrematurely)
{
_logger.LogDebug("⚠️ WEBSOCKET {Direction}: Connection closed prematurely", direction);
_logger.LogInformation("{Direction}: Connection closed prematurely", direction);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "WEBSOCKET {Direction}: Error proxying messages (connection closed)", direction);
_logger.LogWarning(ex, "{Direction}: Error proxying messages", direction);
}
}
}

View File

@@ -14,11 +14,6 @@ public class Song
public string Title { get; set; } = string.Empty;
public string Artist { get; set; } = string.Empty;
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? AlbumId { get; set; }
public int? Duration { get; set; } // In seconds
@@ -44,11 +39,6 @@ public class Song
/// </summary>
public string? Isrc { get; set; }
/// <summary>
/// Spotify track ID (for lyrics and matching)
/// </summary>
public string? SpotifyId { get; set; }
/// <summary>
/// Full release date (format: YYYY-MM-DD)
/// </summary>
@@ -104,10 +94,4 @@ public class Song
/// 0 = Naturally clean, 1 = Explicit, 2 = Not applicable, 3 = Clean/edited version, 6/7 = Unknown
/// </summary>
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

@@ -1,21 +0,0 @@
namespace allstarr.Models.Settings;
/// <summary>
/// Settings for MusicBrainz API integration.
/// </summary>
public class MusicBrainzSettings
{
public bool Enabled { get; set; } = true;
public string? Username { get; set; }
public string? Password { get; set; }
/// <summary>
/// Base URL for MusicBrainz API.
/// </summary>
public string BaseUrl { get; set; } = "https://musicbrainz.org/ws/2";
/// <summary>
/// Rate limit: 1 request per second for unauthenticated, 1 per second for authenticated.
/// </summary>
public int RateLimitMs { get; set; } = 1000;
}

View File

@@ -1,79 +0,0 @@
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; }
/// <summary>
/// URL of the Spotify Lyrics API sidecar service.
/// Default: http://spotify-lyrics:8080 (docker-compose service name)
/// This service wraps Spotify's color-lyrics API for easier access.
/// </summary>
public string LyricsApiUrl { get; set; } = "http://spotify-lyrics:8080";
}

View File

@@ -1,52 +1,5 @@
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
@@ -60,64 +13,38 @@ public class SpotifyImportSettings
public bool Enabled { get; set; }
/// <summary>
/// How often to run track matching in hours.
/// Spotify playlists like Discover Weekly update once per week, Release Radar updates weekly.
/// Most playlists don't change frequently, so running every 24 hours is reasonable.
/// Set to 0 to only run once on startup (manual trigger via admin UI still works).
/// Default: 24 hours
/// 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 MatchingIntervalHours { get; set; } = 24;
public int SyncStartHour { get; set; } = 16;
/// <summary>
/// Combined playlist configuration as JSON array.
/// Format: [["Name","Id","first|last"],...]
/// Example: [["Discover Weekly","abc123","first"],["Release Radar","def456","last"]]
/// Minute when Spotify Import plugin runs (0-59)
/// NOTE: This setting is now optional and only used for the sync window check.
/// </summary>
public List<SpotifyPlaylistConfig> Playlists { get; set; } = new();
public int SyncStartMinute { get; set; } = 15;
/// <summary>
/// Legacy: Comma-separated list of Jellyfin playlist IDs to inject
/// Deprecated: Use Playlists instead
/// 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>
/// Comma-separated list of Jellyfin playlist IDs to inject
/// Example: "4383a46d8bcac3be2ef9385053ea18df,ba50e26c867ec9d57ab2f7bf24cfd6b0"
/// Get IDs from Jellyfin playlist URLs
/// </summary>
[Obsolete("Use Playlists instead")]
public List<string> PlaylistIds { get; set; } = new();
/// <summary>
/// Legacy: Comma-separated list of playlist names
/// Deprecated: Use Playlists instead
/// Comma-separated list of playlist names (must match Spotify Import plugin format)
/// Example: "Discover_Weekly,Release_Radar"
/// Must be in same order as PlaylistIds
/// Plugin replaces spaces with underscores in filenames
/// </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

@@ -1,231 +0,0 @@
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,21 +22,16 @@ static List<string> DecodeSquidWtfUrls()
{
var encodedUrls = new[]
{
"aHR0cHM6Ly90cml0b24uc3F1aWQud3Rm", // triton.squid.wtf
"aHR0cHM6Ly90aWRhbC1hcGkuYmluaW11bS5vcmc=", // tidal-api.binimum.org
"aHR0cHM6Ly90aWRhbC5raW5vcGx1cy5vbmxpbmU=", // tidal.kinoplus.online
"aHR0cHM6Ly9oaWZpLXR3by5zcG90aXNhdmVyLm5ldA==", // hifi-two.spotisaver.net
"aHR0cHM6Ly9oaWZpLW9uZS5zcG90aXNhdmVyLm5ldA==", // hifi-one.spotisaver.net
"aHR0cHM6Ly93b2xmLnFxZGwuc2l0ZQ==", // wolf.qqdl.site
"aHR0cDovL2h1bmQucXFkbC5zaXRl", // hund.qqdl.site (http)
"aHR0cHM6Ly9rYXR6ZS5xcWRsLnNpdGU=", // katze.qqdl.site
"aHR0cHM6Ly92b2dlbC5xcWRsLnNpdGU=", // vogel.qqdl.site
"aHR0cHM6Ly9tYXVzLnFxZGwuc2l0ZQ==", // maus.qqdl.site
"aHR0cHM6Ly9ldS1jZW50cmFsLm1vbm9jaHJvbWUudGY=", // eu-central.monochrome.tf
"aHR0cHM6Ly91cy13ZXN0Lm1vbm9jaHJvbWUudGY=", // us-west.monochrome.tf
"aHR0cHM6Ly9hcnJhbi5tb25vY2hyb21lLnRm", // arran.monochrome.tf
"aHR0cHM6Ly9hcGkubW9ub2Nocm9tZS50Zg==", // api.monochrome.tf
"aHR0cHM6Ly9odW5kLnFxZGwuc2l0ZQ==" // hund.qqdl.site (https)
"aHR0cHM6Ly90cml0b24uc3F1aWQud3Rm", // triton
"aHR0cHM6Ly90aWRhbC1hcGkuYmluaW11bS5vcmc=", // binimum
"aHR0cHM6Ly90aWRhbC5raW5vcGx1cy5vbmxpbmU=", // kinoplus
"aHR0cHM6Ly9oaWZpLXR3by5zcG90aXNhdmVyLm5ldA==", // spoti-2
"aHR0cHM6Ly9oaWZpLW9uZS5zcG90aXNhdmVyLm5ldA==", // spoti-1
"aHR0cHM6Ly93b2xmLnFxZGwuc2l0ZQ==", // wolf
"aHR0cDovL2h1bmQucXFkbC5zaXRl", // hund
"aHR0cHM6Ly9rYXR6ZS5xcWRsLnNpdGU=", // katze
"aHR0cHM6Ly92b2dlbC5xcWRsLnNpdGU=", // vogel
"aHR0cHM6Ly9tYXVzLnFxZGwuc2l0ZQ==" // maus
};
return encodedUrls
@@ -48,18 +43,11 @@ static List<string> DecodeSquidWtfUrls()
var backendType = builder.Configuration.GetValue<BackendType>("Backend:Type");
// Configure Kestrel for large responses over VPN/Tailscale
// Also configure admin port on 5275 (internal only, not exposed)
builder.WebHost.ConfigureKestrel(serverOptions =>
{
serverOptions.Limits.MaxResponseBufferSize = null; // Disable response buffering limit
serverOptions.Limits.MaxRequestBodySize = null; // Allow large request bodies
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)
@@ -102,10 +90,6 @@ builder.Services.ConfigureAll<HttpClientFactoryOptions>(options =>
MaxAutomaticRedirections = 5
};
});
// Suppress verbose HTTP logging - these are logged at Debug level by default
// but we want to reduce noise in production logs
options.SuppressHandlerScope = true;
});
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
@@ -115,9 +99,6 @@ builder.Services.AddHttpContextAccessor();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
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
builder.Services.Configure<SubsonicSettings>(
builder.Configuration.GetSection("Subsonic"));
@@ -136,233 +117,35 @@ 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
// Parse SPOTIFY_IMPORT_PLAYLIST_IDS env var (comma-separated) into PlaylistIds array
var playlistIdsEnv = builder.Configuration.GetValue<string>("SpotifyImport:PlaylistIds");
if (!string.IsNullOrWhiteSpace(playlistIdsEnv) && options.PlaylistIds.Count == 0)
{
options.PlaylistIds = playlistIdsEnv
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(id => id.Trim())
.Where(id => !string.IsNullOrEmpty(id))
.ToList();
}
// Parse SPOTIFY_IMPORT_PLAYLIST_NAMES env var (comma-separated) into PlaylistNames array
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))
if (!string.IsNullOrWhiteSpace(playlistNamesEnv) && options.PlaylistNames.Count == 0)
{
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}, MatchingInterval={options.MatchingIntervalHours}h");
Console.WriteLine($"Spotify Import Playlists: {options.Playlists.Count} configured");
foreach (var playlist in options.Playlists)
Console.WriteLine($"Spotify Import: Enabled={options.Enabled}, SyncHour={options.SyncStartHour}:{options.SyncStartMinute:D2}, WindowHours={options.SyncWindowHours}");
Console.WriteLine($"Spotify Import Playlist IDs: {options.PlaylistIds.Count} configured");
for (int i = 0; i < options.PlaylistIds.Count; i++)
{
Console.WriteLine($" - {playlist.Name} (ID: {playlist.Id}, LocalTracks: {playlist.LocalTracksPosition})");
var name = i < options.PlaylistNames.Count ? options.PlaylistNames[i] : options.PlaylistIds[i];
Console.WriteLine($" - {name} (ID: {options.PlaylistIds[i]})");
}
});
@@ -384,7 +167,6 @@ else
// Business services - shared across backends
builder.Services.AddSingleton<RedisCacheService>();
builder.Services.AddSingleton<OdesliService>();
builder.Services.AddSingleton<ILocalLibraryService, LocalLibraryService>();
builder.Services.AddSingleton<LrclibService>();
@@ -395,12 +177,8 @@ if (backendType == BackendType.Jellyfin)
builder.Services.AddSingleton<JellyfinResponseBuilder>();
builder.Services.AddSingleton<JellyfinModelMapper>();
builder.Services.AddScoped<JellyfinProxyService>();
builder.Services.AddSingleton<JellyfinSessionManager>();
builder.Services.AddScoped<JellyfinAuthFilter>();
builder.Services.AddScoped<allstarr.Filters.ApiKeyAuthFilter>();
// Register JellyfinController as a service for dependency injection
builder.Services.AddScoped<allstarr.Controllers.JellyfinController>();
}
else
{
@@ -465,13 +243,9 @@ else if (musicService == MusicService.SquidWTF)
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
sp,
sp.GetRequiredService<ILogger<SquidWTFDownloadService>>(),
sp.GetRequiredService<OdesliService>(),
squidWtfApiUrls));
}
// Register ParallelMetadataService to race all registered providers for faster searches
builder.Services.AddSingleton<ParallelMetadataService>();
// Startup validation - register validators based on backend
if (backendType == BackendType.Jellyfin)
{
@@ -482,19 +256,13 @@ else
builder.Services.AddSingleton<IStartupValidator, SubsonicStartupValidator>();
}
// Register endpoint benchmark service
builder.Services.AddSingleton<EndpointBenchmarkService>();
builder.Services.AddSingleton<IStartupValidator, DeezerStartupValidator>();
builder.Services.AddSingleton<IStartupValidator, QobuzStartupValidator>();
builder.Services.AddSingleton<IStartupValidator>(sp =>
new SquidWTFStartupValidator(
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
sp.GetRequiredService<IHttpClientFactory>().CreateClient(),
squidWtfApiUrls,
sp.GetRequiredService<EndpointBenchmarkService>(),
sp.GetRequiredService<ILogger<SquidWTFStartupValidator>>()));
builder.Services.AddSingleton<IStartupValidator, LyricsStartupValidator>();
squidWtfApiUrls));
// Register orchestrator as hosted service
builder.Services.AddHostedService<StartupValidationOrchestrator>();
@@ -502,116 +270,11 @@ builder.Services.AddHostedService<StartupValidationOrchestrator>();
// Register cache cleanup service (only runs when StorageMode is Cache)
builder.Services.AddHostedService<CacheCleanupService>();
// Register cache warming service (loads file caches into Redis on startup)
builder.Services.AddHostedService<CacheWarmingService>();
// 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)
// Register Spotify missing tracks fetcher (only runs when SpotifyImport is enabled)
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>());
// Register lyrics prefetch service (prefetches lyrics for all playlist tracks)
// DISABLED - No need to prefetch since Jellyfin and Spotify lyrics are fast
// builder.Services.AddSingleton<allstarr.Services.Lyrics.LyricsPrefetchService>();
// builder.Services.AddHostedService(sp => sp.GetRequiredService<allstarr.Services.Lyrics.LyricsPrefetchService>());
// Register MusicBrainz service for metadata enrichment
builder.Services.Configure<allstarr.Models.Settings.MusicBrainzSettings>(options =>
{
builder.Configuration.GetSection("MusicBrainz").Bind(options);
// Override from environment variables
var enabled = builder.Configuration.GetValue<string>("MusicBrainz:Enabled");
if (!string.IsNullOrEmpty(enabled))
{
options.Enabled = enabled.Equals("true", StringComparison.OrdinalIgnoreCase);
}
var username = builder.Configuration.GetValue<string>("MusicBrainz:Username");
if (!string.IsNullOrEmpty(username))
{
options.Username = username;
}
var password = builder.Configuration.GetValue<string>("MusicBrainz:Password");
if (!string.IsNullOrEmpty(password))
{
options.Password = password;
}
});
builder.Services.AddSingleton<allstarr.Services.MusicBrainz.MusicBrainzService>();
// Register genre enrichment service
builder.Services.AddSingleton<allstarr.Services.Common.GenreEnrichmentService>();
builder.Services.AddHostedService<allstarr.Services.Spotify.SpotifyTrackMatchingService>();
builder.Services.AddCors(options =>
{
@@ -649,9 +312,6 @@ if (app.Environment.IsDevelopment())
app.UseHttpsRedirection();
// Serve static files only on admin port (5275)
app.UseMiddleware<allstarr.Middleware.AdminStaticFilesMiddleware>();
app.UseAuthorization();
app.UseCors();
@@ -681,9 +341,6 @@ class BackendControllerFeatureProvider : Microsoft.AspNetCore.Mvc.Controllers.Co
var isController = base.IsController(typeInfo);
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
return _backendType switch
{

View File

@@ -5,7 +5,6 @@ using allstarr.Models.Search;
using allstarr.Models.Subsonic;
using allstarr.Services.Local;
using allstarr.Services.Subsonic;
using System.Collections.Concurrent;
using TagLib;
using IOFile = System.IO.File;
@@ -28,14 +27,9 @@ public abstract class BaseDownloadService : IDownloadService
protected readonly string DownloadPath;
protected readonly string CachePath;
protected readonly ConcurrentDictionary<string, DownloadInfo> ActiveDownloads = new();
protected readonly Dictionary<string, DownloadInfo> ActiveDownloads = new();
protected readonly SemaphoreSlim DownloadLock = new(1, 1);
// Rate limiting fields
private readonly SemaphoreSlim _requestLock = new(1, 1);
private DateTime _lastRequestTime = DateTime.MinValue;
private readonly int _minRequestIntervalMs = 200;
/// <summary>
/// Lazy-loaded PlaylistSyncService to avoid circular dependency
/// </summary>
@@ -95,89 +89,23 @@ public abstract class BaseDownloadService : IDownloadService
public async Task<Stream> DownloadAndStreamAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
{
var startTime = DateTime.UtcNow;
// Check if already downloaded locally
var localPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId);
if (localPath != null && IOFile.Exists(localPath))
{
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
Logger.LogInformation("Streaming from local cache ({ElapsedMs}ms): {Path}", elapsed, localPath);
// Update access time for cache cleanup
if (SubsonicSettings.StorageMode == StorageMode.Cache)
{
IOFile.SetLastAccessTime(localPath, DateTime.UtcNow);
}
// Start background Odesli conversion for lyrics (if not already cached)
StartBackgroundOdesliConversion(externalProvider, externalId);
Logger.LogInformation("Streaming from local cache: {Path}", localPath);
return IOFile.OpenRead(localPath);
}
// Download to disk first to ensure complete file with metadata
// For on-demand streaming, download to disk first to ensure complete file
// This is necessary because:
// 1. Clients may seek to arbitrary positions (requires full file)
// 2. Metadata embedding requires complete file
// 3. Caching for future plays
Logger.LogInformation("Downloading song for streaming: {Provider}:{ExternalId}", externalProvider, externalId);
try
{
localPath = await DownloadSongInternalAsync(externalProvider, externalId, triggerAlbumDownload: true, cancellationToken);
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
Logger.LogInformation("Download completed, starting stream ({ElapsedMs}ms total): {Path}", elapsed, localPath);
// Start background Odesli conversion for lyrics (after stream starts)
StartBackgroundOdesliConversion(externalProvider, externalId);
return IOFile.OpenRead(localPath);
}
catch (OperationCanceledException)
{
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
Logger.LogWarning("Download cancelled by client after {ElapsedMs}ms for {Provider}:{ExternalId}", elapsed, externalProvider, externalId);
throw;
}
catch (Exception ex)
{
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
Logger.LogError(ex, "Download failed after {ElapsedMs}ms for {Provider}:{ExternalId}", elapsed, externalProvider, externalId);
throw;
}
}
/// <summary>
/// Starts background Odesli conversion for lyrics support.
/// This is called AFTER streaming starts so it doesn't block the client.
/// </summary>
private void StartBackgroundOdesliConversion(string externalProvider, string externalId)
{
_ = Task.Run(async () =>
{
try
{
// Provider-specific conversion (override in subclasses if needed)
await ConvertToSpotifyIdAsync(externalProvider, externalId);
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Background Spotify ID conversion failed for {Provider}:{ExternalId}", externalProvider, externalId);
}
});
}
/// <summary>
/// Converts external track ID to Spotify ID for lyrics support.
/// Override in provider-specific services if needed.
/// </summary>
protected virtual Task ConvertToSpotifyIdAsync(string externalProvider, string externalId)
{
// Default implementation does nothing
// Provider-specific services can override this
return Task.CompletedTask;
}
public DownloadInfo? GetDownloadStatus(string songId)
{
@@ -192,13 +120,20 @@ public abstract class BaseDownloadService : IDownloadService
return null;
}
// Check local library (works for both cache and permanent storage)
// Check local library
var localPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId);
if (localPath != null && IOFile.Exists(localPath))
{
return localPath;
}
// Check cache directory
var cachedPath = GetCachedFilePath(externalProvider, externalId);
if (cachedPath != null && IOFile.Exists(cachedPath))
{
return cachedPath;
}
return null;
}
@@ -267,44 +202,47 @@ public abstract class BaseDownloadService : IDownloadService
try
{
// Check if already downloaded (works for both cache and permanent modes)
// Check if already downloaded (skip for cache mode as we want to check cache folder)
if (!isCache)
{
var existingPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId);
if (existingPath != null && IOFile.Exists(existingPath))
{
Logger.LogInformation("Song already downloaded: {Path}", existingPath);
// For cache mode, update file access time for cache cleanup logic
if (isCache)
{
IOFile.SetLastAccessTime(existingPath, DateTime.UtcNow);
}
return existingPath;
}
}
else
{
// For cache mode, check if file exists in cache directory
var cachedPath = GetCachedFilePath(externalProvider, externalId);
if (cachedPath != null && IOFile.Exists(cachedPath))
{
Logger.LogInformation("Song found in cache: {Path}", cachedPath);
// Update file access time for cache cleanup logic
IOFile.SetLastAccessTime(cachedPath, DateTime.UtcNow);
return cachedPath;
}
}
// Check if download in progress
if (ActiveDownloads.TryGetValue(songId, out var activeDownload) && activeDownload.Status == DownloadStatus.InProgress)
{
Logger.LogDebug("Download already in progress for {SongId}, waiting for completion...", songId);
Logger.LogInformation("Download already in progress for {SongId}, waiting...", songId);
// Release lock while waiting
DownloadLock.Release();
// Wait for download to complete, checking every 100ms (faster than 500ms)
// Also respect cancellation token so client timeouts are handled immediately
while (ActiveDownloads.TryGetValue(songId, out activeDownload) && activeDownload.Status == DownloadStatus.InProgress)
{
cancellationToken.ThrowIfCancellationRequested();
await Task.Delay(100, cancellationToken);
await Task.Delay(500, cancellationToken);
}
if (activeDownload?.Status == DownloadStatus.Completed && activeDownload.LocalPath != null)
{
Logger.LogDebug("Download completed while waiting, returning path: {Path}", activeDownload.LocalPath);
return activeDownload.LocalPath;
}
// Download failed or was cancelled
throw new Exception(activeDownload?.ErrorMessage ?? "Download failed while waiting");
throw new Exception(activeDownload?.ErrorMessage ?? "Download failed");
}
// Get metadata
@@ -360,14 +298,6 @@ public abstract class BaseDownloadService : IDownloadService
song.LocalPath = localPath;
// Clean up completed download from tracking after a short delay
_ = Task.Run(async () =>
{
await Task.Delay(TimeSpan.FromMinutes(5)); // Keep for 5 minutes for status checks
ActiveDownloads.TryRemove(songId, out _);
Logger.LogDebug("Cleaned up completed download tracking for {SongId}", songId);
});
// Register BEFORE releasing lock to prevent race conditions (both cache and download modes)
await LocalLibraryService.RegisterDownloadedSongAsync(song, localPath);
@@ -430,14 +360,6 @@ public abstract class BaseDownloadService : IDownloadService
{
downloadInfo.Status = DownloadStatus.Failed;
downloadInfo.ErrorMessage = ex.Message;
// Clean up failed download from tracking after a short delay
_ = Task.Run(async () =>
{
await Task.Delay(TimeSpan.FromMinutes(2)); // Keep for 2 minutes for error reporting
ActiveDownloads.TryRemove(songId, out _);
Logger.LogDebug("Cleaned up failed download tracking for {SongId}", songId);
});
}
Logger.LogError(ex, "Download failed for {SongId}", songId);
throw;
@@ -638,34 +560,29 @@ public abstract class BaseDownloadService : IDownloadService
}
}
#endregion
#region Rate Limiting
/// <summary>
/// Queues a request with rate limiting to prevent overwhelming the API.
/// Ensures minimum interval between requests.
/// Gets the cached file path for a given provider and external ID
/// Returns null if no cached file exists
/// </summary>
protected async Task<T> QueueRequestAsync<T>(Func<Task<T>> action)
protected string? GetCachedFilePath(string provider, string externalId)
{
await _requestLock.WaitAsync();
try
{
var now = DateTime.UtcNow;
var timeSinceLastRequest = (now - _lastRequestTime).TotalMilliseconds;
// Search for cached files matching the pattern: {provider}_{externalId}.*
var pattern = $"{provider}_{externalId}.*";
var files = Directory.GetFiles(CachePath, pattern, SearchOption.AllDirectories);
if (timeSinceLastRequest < _minRequestIntervalMs)
if (files.Length > 0)
{
await Task.Delay((int)(_minRequestIntervalMs - timeSinceLastRequest));
return files[0]; // Return first match
}
_lastRequestTime = DateTime.UtcNow;
return await action();
return null;
}
finally
catch (Exception ex)
{
_requestLock.Release();
Logger.LogWarning(ex, "Failed to search for cached file: {Provider}_{ExternalId}", provider, externalId);
return null;
}
}

View File

@@ -1,6 +1,5 @@
using Microsoft.Extensions.Options;
using allstarr.Models.Settings;
using allstarr.Controllers;
namespace allstarr.Services.Common;
@@ -12,19 +11,16 @@ public class CacheCleanupService : BackgroundService
{
private readonly IConfiguration _configuration;
private readonly SubsonicSettings _subsonicSettings;
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<CacheCleanupService> _logger;
private readonly TimeSpan _cleanupInterval = TimeSpan.FromHours(1);
public CacheCleanupService(
IConfiguration configuration,
IOptions<SubsonicSettings> subsonicSettings,
IServiceProvider serviceProvider,
ILogger<CacheCleanupService> logger)
{
_configuration = configuration;
_subsonicSettings = subsonicSettings.Value;
_serviceProvider = serviceProvider;
_logger = logger;
}
@@ -45,7 +41,6 @@ public class CacheCleanupService : BackgroundService
try
{
await CleanupOldCachedFilesAsync(stoppingToken);
await ProcessPendingDeletionsAsync(stoppingToken);
await Task.Delay(_cleanupInterval, stoppingToken);
}
catch (OperationCanceledException)
@@ -94,16 +89,16 @@ public class CacheCleanupService : BackgroundService
{
var fileInfo = new FileInfo(filePath);
// Use last write time (when file was created/downloaded) to determine if file should be deleted
// LastAccessTime is unreliable on many filesystems (noatime mount option)
if (fileInfo.LastWriteTimeUtc < cutoffTime)
// Use last access time to determine if file should be deleted
// This gets updated when a cached file is streamed
if (fileInfo.LastAccessTimeUtc < cutoffTime)
{
var size = fileInfo.Length;
File.Delete(filePath);
deletedCount++;
totalSize += size;
_logger.LogDebug("Deleted cached file: {Path} (age: {Age:F1} hours)",
filePath, (DateTime.UtcNow - fileInfo.LastWriteTimeUtc).TotalHours);
_logger.LogDebug("Deleted cached file: {Path} (last accessed: {LastAccess})",
filePath, fileInfo.LastAccessTimeUtc);
}
}
catch (Exception ex)
@@ -165,30 +160,4 @@ public class CacheCleanupService : BackgroundService
await Task.CompletedTask;
}
/// <summary>
/// Processes pending track deletions from the kept folder.
/// </summary>
private async Task ProcessPendingDeletionsAsync(CancellationToken cancellationToken)
{
try
{
// Create a scope to get the JellyfinController
using var scope = _serviceProvider.CreateScope();
var jellyfinController = scope.ServiceProvider.GetService<JellyfinController>();
if (jellyfinController != null)
{
await jellyfinController.ProcessPendingDeletionsAsync();
}
else
{
_logger.LogWarning("Could not resolve JellyfinController for pending deletions processing");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing pending deletions");
}
}
}

View File

@@ -1,400 +0,0 @@
using System.Text.Json;
using allstarr.Models.Domain;
namespace allstarr.Services.Common;
/// <summary>
/// Background service that warms up Redis cache from file system on startup.
/// Ensures fast access to cached data after container restarts.
/// </summary>
public class CacheWarmingService : IHostedService
{
private readonly RedisCacheService _cache;
private readonly ILogger<CacheWarmingService> _logger;
private readonly IServiceProvider _serviceProvider;
private const string GenreCacheDirectory = "/app/cache/genres";
private const string PlaylistCacheDirectory = "/app/cache/spotify";
private const string MappingsCacheDirectory = "/app/cache/mappings";
private const string LyricsCacheDirectory = "/app/cache/lyrics";
public CacheWarmingService(
RedisCacheService cache,
IServiceProvider serviceProvider,
ILogger<CacheWarmingService> logger)
{
_cache = cache;
_serviceProvider = serviceProvider;
_logger = logger;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("🔥 Starting cache warming from file system...");
var startTime = DateTime.UtcNow;
var genresWarmed = 0;
var playlistsWarmed = 0;
var mappingsWarmed = 0;
var lyricsWarmed = 0;
var lyricsMappingsWarmed = 0;
try
{
// Warm genre cache
genresWarmed = await WarmGenreCacheAsync(cancellationToken);
// Warm playlist cache
playlistsWarmed = await WarmPlaylistCacheAsync(cancellationToken);
// Warm manual mappings cache
mappingsWarmed = await WarmManualMappingsCacheAsync(cancellationToken);
// Warm lyrics mappings cache
lyricsMappingsWarmed = await WarmLyricsMappingsCacheAsync(cancellationToken);
// Warm lyrics cache
lyricsWarmed = await WarmLyricsCacheAsync(cancellationToken);
var duration = DateTime.UtcNow - startTime;
_logger.LogInformation(
"✅ Cache warming complete in {Duration:F1}s: {Genres} genres, {Playlists} playlists, {Mappings} manual mappings, {LyricsMappings} lyrics mappings, {Lyrics} lyrics",
duration.TotalSeconds, genresWarmed, playlistsWarmed, mappingsWarmed, lyricsMappingsWarmed, lyricsWarmed);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to warm cache from file system");
}
}
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
/// <summary>
/// Warms genre cache from file system.
/// </summary>
private async Task<int> WarmGenreCacheAsync(CancellationToken cancellationToken)
{
if (!Directory.Exists(GenreCacheDirectory))
{
return 0;
}
var files = Directory.GetFiles(GenreCacheDirectory, "*.json");
var warmedCount = 0;
foreach (var file in files)
{
if (cancellationToken.IsCancellationRequested)
break;
try
{
// Check if cache is expired (30 days)
var fileInfo = new FileInfo(file);
if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc > TimeSpan.FromDays(30))
{
File.Delete(file);
continue;
}
var json = await File.ReadAllTextAsync(file, cancellationToken);
var cacheEntry = JsonSerializer.Deserialize<GenreCacheEntry>(json);
if (cacheEntry != null && !string.IsNullOrEmpty(cacheEntry.CacheKey))
{
var redisKey = $"genre:{cacheEntry.CacheKey}";
await _cache.SetAsync(redisKey, cacheEntry.Genre, TimeSpan.FromDays(30));
warmedCount++;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to warm genre cache from file: {File}", file);
}
}
if (warmedCount > 0)
{
_logger.LogInformation("🔥 Warmed {Count} genre entries from file cache", warmedCount);
}
return warmedCount;
}
/// <summary>
/// Warms playlist cache from file system.
/// </summary>
private async Task<int> WarmPlaylistCacheAsync(CancellationToken cancellationToken)
{
if (!Directory.Exists(PlaylistCacheDirectory))
{
return 0;
}
var itemsFiles = Directory.GetFiles(PlaylistCacheDirectory, "*_items.json");
var matchedFiles = Directory.GetFiles(PlaylistCacheDirectory, "*_matched.json");
var warmedCount = 0;
// Warm playlist items cache
foreach (var file in itemsFiles)
{
if (cancellationToken.IsCancellationRequested)
break;
try
{
// Check if cache is expired (24 hours)
var fileInfo = new FileInfo(file);
if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc > TimeSpan.FromHours(24))
{
continue; // Don't delete, let the normal flow handle it
}
var json = await File.ReadAllTextAsync(file, cancellationToken);
var items = JsonSerializer.Deserialize<List<Dictionary<string, object?>>>(json);
if (items != null && items.Count > 0)
{
// Extract playlist name from filename
var fileName = Path.GetFileNameWithoutExtension(file);
var playlistName = fileName.Replace("_items", "");
var redisKey = $"spotify:playlist:items:{playlistName}";
await _cache.SetAsync(redisKey, items, TimeSpan.FromHours(24));
warmedCount++;
_logger.LogDebug("🔥 Warmed playlist items cache for {Playlist} ({Count} items)",
playlistName, items.Count);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to warm playlist items cache from file: {File}", file);
}
}
// Warm matched tracks cache
foreach (var file in matchedFiles)
{
if (cancellationToken.IsCancellationRequested)
break;
try
{
// Check if cache is expired (1 hour)
var fileInfo = new FileInfo(file);
if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc > TimeSpan.FromHours(1))
{
continue; // Skip expired matched tracks
}
var json = await File.ReadAllTextAsync(file, cancellationToken);
var matchedTracks = JsonSerializer.Deserialize<List<MatchedTrack>>(json);
if (matchedTracks != null && matchedTracks.Count > 0)
{
// Extract playlist name from filename
var fileName = Path.GetFileNameWithoutExtension(file);
var playlistName = fileName.Replace("_matched", "");
var redisKey = $"spotify:matched:ordered:{playlistName}";
await _cache.SetAsync(redisKey, matchedTracks, TimeSpan.FromHours(1));
warmedCount++;
_logger.LogDebug("🔥 Warmed matched tracks cache for {Playlist} ({Count} tracks)",
playlistName, matchedTracks.Count);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to warm matched tracks cache from file: {File}", file);
}
}
if (warmedCount > 0)
{
_logger.LogInformation("🔥 Warmed {Count} playlist caches from file system", warmedCount);
}
return warmedCount;
}
/// <summary>
/// Warms manual mappings cache from file system.
/// Manual mappings NEVER expire - they are permanent user decisions.
/// </summary>
private async Task<int> WarmManualMappingsCacheAsync(CancellationToken cancellationToken)
{
if (!Directory.Exists(MappingsCacheDirectory))
{
return 0;
}
var files = Directory.GetFiles(MappingsCacheDirectory, "*_mappings.json");
var warmedCount = 0;
foreach (var file in files)
{
if (cancellationToken.IsCancellationRequested)
break;
try
{
var json = await File.ReadAllTextAsync(file, cancellationToken);
var mappings = JsonSerializer.Deserialize<Dictionary<string, ManualMappingEntry>>(json);
if (mappings != null && mappings.Count > 0)
{
// Extract playlist name from filename
var fileName = Path.GetFileNameWithoutExtension(file);
var playlistName = fileName.Replace("_mappings", "");
foreach (var mapping in mappings.Values)
{
if (!string.IsNullOrEmpty(mapping.JellyfinId))
{
// Jellyfin mapping
var redisKey = $"spotify:manual-map:{playlistName}:{mapping.SpotifyId}";
await _cache.SetAsync(redisKey, mapping.JellyfinId);
warmedCount++;
}
else if (!string.IsNullOrEmpty(mapping.ExternalProvider) && !string.IsNullOrEmpty(mapping.ExternalId))
{
// External mapping
var redisKey = $"spotify:external-map:{playlistName}:{mapping.SpotifyId}";
var externalMapping = new { provider = mapping.ExternalProvider, id = mapping.ExternalId };
await _cache.SetAsync(redisKey, externalMapping);
warmedCount++;
}
}
_logger.LogDebug("🔥 Warmed {Count} manual mappings for {Playlist}",
mappings.Count, playlistName);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to warm manual mappings from file: {File}", file);
}
}
if (warmedCount > 0)
{
_logger.LogInformation("🔥 Warmed {Count} manual mappings from file system", warmedCount);
}
return warmedCount;
}
/// <summary>
/// Warms lyrics mappings cache from file system.
/// Lyrics mappings NEVER expire - they are permanent user decisions.
/// </summary>
private async Task<int> WarmLyricsMappingsCacheAsync(CancellationToken cancellationToken)
{
var mappingsFile = "/app/cache/lyrics_mappings.json";
if (!File.Exists(mappingsFile))
{
return 0;
}
try
{
var json = await File.ReadAllTextAsync(mappingsFile, cancellationToken);
var mappings = JsonSerializer.Deserialize<List<LyricsMappingEntry>>(json);
if (mappings != null && mappings.Count > 0)
{
foreach (var mapping in mappings)
{
if (cancellationToken.IsCancellationRequested)
break;
// Store in Redis with NO EXPIRATION (permanent)
var redisKey = $"lyrics:manual-map:{mapping.Artist}:{mapping.Title}";
await _cache.SetStringAsync(redisKey, mapping.LyricsId.ToString());
}
_logger.LogInformation("🔥 Warmed {Count} lyrics mappings from file system", mappings.Count);
return mappings.Count;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to warm lyrics mappings from file: {File}", mappingsFile);
}
return 0;
}
/// <summary>
/// Warms lyrics cache from file system using the LyricsPrefetchService.
/// </summary>
private async Task<int> WarmLyricsCacheAsync(CancellationToken cancellationToken)
{
try
{
// Get the LyricsPrefetchService from DI
using var scope = _serviceProvider.CreateScope();
var lyricsPrefetchService = scope.ServiceProvider.GetService<allstarr.Services.Lyrics.LyricsPrefetchService>();
if (lyricsPrefetchService != null)
{
await lyricsPrefetchService.WarmCacheFromFilesAsync();
// Count files to return warmed count
if (Directory.Exists(LyricsCacheDirectory))
{
return Directory.GetFiles(LyricsCacheDirectory, "*.json").Length;
}
}
return 0;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to warm lyrics cache");
return 0;
}
}
private class GenreCacheEntry
{
public string CacheKey { get; set; } = "";
public string Genre { get; set; } = "";
public DateTime CachedAt { get; set; }
}
private class MatchedTrack
{
public int Position { get; set; }
public string SpotifyId { get; set; } = "";
public string SpotifyTitle { get; set; } = "";
public string SpotifyArtist { get; set; } = "";
public string? Isrc { get; set; }
public string MatchType { get; set; } = "";
public Song? MatchedSong { get; set; }
}
private class ManualMappingEntry
{
public string SpotifyId { get; set; } = "";
public string? JellyfinId { get; set; }
public string? ExternalProvider { get; set; }
public string? ExternalId { get; set; }
public DateTime CreatedAt { get; set; }
}
private class LyricsMappingEntry
{
public string Artist { get; set; } = "";
public string Title { get; set; } = "";
public string? Album { get; set; }
public int DurationSeconds { get; set; }
public int LyricsId { get; set; }
public DateTime CreatedAt { get; set; }
}
}

View File

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

View File

@@ -2,64 +2,12 @@ namespace allstarr.Services.Common;
/// <summary>
/// Provides fuzzy string matching for search result scoring.
/// OPTIMAL ORDER: 1. Strip decorators → 2. Substring matching → 3. Levenshtein → 4. Greedy assignment
/// </summary>
public static class FuzzyMatcher
{
/// <summary>
/// STEP 1: Strips common decorators from track titles to improve matching.
/// Removes: (feat. X), (with Y), (ft. Z), - From "Album", [Remix], etc.
/// This MUST be done first to avoid systematic noise in matching.
/// </summary>
public static string StripDecorators(string title)
{
if (string.IsNullOrWhiteSpace(title))
{
return string.Empty;
}
var cleaned = title;
// Remove (feat. ...), (ft. ...), (with ...), (featuring ...)
cleaned = System.Text.RegularExpressions.Regex.Replace(
cleaned,
@"\s*[\(\[]?\s*(feat\.?|ft\.?|with|featuring)\s+[^\)\]]+[\)\]]?",
"",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
// Remove - From "Album Name" or - From Album Name
cleaned = System.Text.RegularExpressions.Regex.Replace(
cleaned,
@"\s*-\s*from\s+[""']?[^""']+[""']?",
"",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
// Remove - Remastered, - Radio Edit, etc.
cleaned = System.Text.RegularExpressions.Regex.Replace(
cleaned,
@"\s*-\s*(remaster|radio edit|single version|album version|extended|original mix)[^\-]*",
"",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
// Remove [Remix], [Remaster], [Live], [Explicit], etc.
cleaned = System.Text.RegularExpressions.Regex.Replace(
cleaned,
@"\s*[\[\(](remix|remaster|live|acoustic|radio edit|explicit|clean|official|audio|video|lyric)[^\]\)]*[\]\)]",
"",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
// Remove trailing/leading whitespace and normalize
cleaned = cleaned.Trim();
return cleaned;
}
/// <summary>
/// Calculates similarity score following OPTIMAL ORDER:
/// 1. Strip decorators (already done by caller)
/// 2. Substring matching (cheap, high-precision)
/// 3. Levenshtein distance (expensive, fuzzy)
/// Returns score 0-100.
/// Calculates a similarity score between two strings (0-100).
/// Higher score means better match.
/// </summary>
public static int CalculateSimilarity(string query, string target)
{
@@ -68,115 +16,47 @@ public static class FuzzyMatcher
return 0;
}
var queryNorm = NormalizeForMatching(query);
var targetNorm = NormalizeForMatching(target);
// STEP 2: SUBSTRING MATCHING (cheap, high-precision)
var queryLower = query.ToLowerInvariant().Trim();
var targetLower = target.ToLowerInvariant().Trim();
// Exact match
if (queryNorm == targetNorm)
if (queryLower == targetLower)
{
return 100;
}
// One string fully contains the other (substring match)
// Example: "luther" ⊂ "luther remastered" → instant win
if (targetNorm.Contains(queryNorm) || queryNorm.Contains(targetNorm))
{
return 95;
}
// Starts with query
if (targetNorm.StartsWith(queryNorm) || queryNorm.StartsWith(targetNorm))
if (targetLower.StartsWith(queryLower))
{
return 90;
}
// Contains query as whole word
if (targetNorm.Contains($" {queryNorm} ") ||
targetNorm.StartsWith($"{queryNorm} ") ||
targetNorm.EndsWith($" {queryNorm}") ||
queryNorm.Contains($" {targetNorm} ") ||
queryNorm.StartsWith($"{targetNorm} ") ||
queryNorm.EndsWith($" {targetNorm}"))
if (targetLower.Contains($" {queryLower} ") ||
targetLower.StartsWith($"{queryLower} ") ||
targetLower.EndsWith($" {queryLower}"))
{
return 85;
return 80;
}
// STEP 3: LEVENSHTEIN DISTANCE (expensive, fuzzy)
// Only use this for candidates that survived substring checks
// Contains query anywhere
if (targetLower.Contains(queryLower))
{
return 70;
}
var distance = LevenshteinDistance(queryNorm, targetNorm);
var maxLength = Math.Max(queryNorm.Length, targetNorm.Length);
// Calculate Levenshtein distance for fuzzy matching
var distance = LevenshteinDistance(queryLower, targetLower);
var maxLength = Math.Max(queryLower.Length, targetLower.Length);
if (maxLength == 0)
{
return 100;
}
// Normalize distance by length: score = 1 - (distance / max_length)
var normalizedSimilarity = 1.0 - ((double)distance / maxLength);
// Convert to 0-80 range (reserve 80-100 for substring matches)
var score = (int)(normalizedSimilarity * 80);
return Math.Max(0, score);
}
/// <summary>
/// AGGRESSIVE matching that follows optimal order:
/// 1. Strip decorators FIRST
/// 2. Substring matching
/// 3. Levenshtein distance
/// Returns the best score.
/// </summary>
public static int CalculateSimilarityAggressive(string query, string target)
{
if (string.IsNullOrWhiteSpace(query) || string.IsNullOrWhiteSpace(target))
{
return 0;
}
// STEP 1: Strip decorators FIRST (always)
var queryStripped = StripDecorators(query);
var targetStripped = StripDecorators(target);
// STEP 2-3: Substring matching + Levenshtein
var strippedScore = CalculateSimilarity(queryStripped, targetStripped);
// Also try without stripping in case decorators are part of the actual title
var rawScore = CalculateSimilarity(query, target);
// Return the best score
return Math.Max(rawScore, strippedScore);
}
/// <summary>
/// Normalizes a string for matching by:
/// - Converting to lowercase
/// - Normalizing apostrophes (', ', ') to standard '
/// - Removing extra whitespace
/// </summary>
private static string NormalizeForMatching(string text)
{
if (string.IsNullOrWhiteSpace(text))
{
return string.Empty;
}
var normalized = text.ToLowerInvariant().Trim();
// Normalize different apostrophe types to standard apostrophe
normalized = normalized
.Replace("\u2019", "'") // Right single quotation mark (')
.Replace("\u2018", "'") // Left single quotation mark (')
.Replace("`", "'") // Grave accent
.Replace("\u00B4", "'"); // Acute accent (´)
// Normalize whitespace
normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"\s+", " ");
return normalized;
// Convert distance to similarity score (0-60 range for fuzzy matches)
var similarity = (1.0 - (double)distance / maxLength) * 60;
return (int)Math.Max(0, similarity);
}
/// <summary>
@@ -221,54 +101,4 @@ public static class FuzzyMatcher
return distance[sourceLength, targetLength];
}
/// <summary>
/// Calculates artist match score between Spotify artists and local song artists.
/// Checks bidirectional matching and penalizes mismatches.
/// Penalizes if artist counts don't match or if any artist is missing.
/// Returns score 0-100.
/// </summary>
public 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 =>
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 =>
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

@@ -1,228 +0,0 @@
using allstarr.Models.Domain;
using allstarr.Services.MusicBrainz;
using allstarr.Services.Common;
using System.Text.Json;
namespace allstarr.Services.Common;
/// <summary>
/// Service for enriching songs and playlists with genre information from MusicBrainz.
/// </summary>
public class GenreEnrichmentService
{
private readonly MusicBrainzService _musicBrainz;
private readonly RedisCacheService _cache;
private readonly ILogger<GenreEnrichmentService> _logger;
private const string GenreCachePrefix = "genre:";
private const string GenreCacheDirectory = "/app/cache/genres";
private static readonly TimeSpan GenreCacheDuration = TimeSpan.FromDays(30);
public GenreEnrichmentService(
MusicBrainzService musicBrainz,
RedisCacheService cache,
ILogger<GenreEnrichmentService> logger)
{
_musicBrainz = musicBrainz;
_cache = cache;
_logger = logger;
// Ensure cache directory exists
Directory.CreateDirectory(GenreCacheDirectory);
}
/// <summary>
/// Enriches a song with genre information from MusicBrainz (with caching).
/// Updates the song's Genre property with the top genre.
/// </summary>
public async Task EnrichSongGenreAsync(Song song)
{
// Skip if song already has a genre
if (!string.IsNullOrEmpty(song.Genre))
{
return;
}
var cacheKey = $"{song.Title}:{song.Artist}";
// Check Redis cache first
var redisCacheKey = $"{GenreCachePrefix}{cacheKey}";
var cachedGenre = await _cache.GetAsync<string>(redisCacheKey);
if (cachedGenre != null)
{
song.Genre = cachedGenre;
_logger.LogDebug("Using Redis cached genre for {Title} - {Artist}: {Genre}",
song.Title, song.Artist, cachedGenre);
return;
}
// Check file cache
var fileCachedGenre = await GetFromFileCacheAsync(cacheKey);
if (fileCachedGenre != null)
{
song.Genre = fileCachedGenre;
// Restore to Redis cache
await _cache.SetAsync(redisCacheKey, fileCachedGenre, GenreCacheDuration);
_logger.LogDebug("Using file cached genre for {Title} - {Artist}: {Genre}",
song.Title, song.Artist, fileCachedGenre);
return;
}
// Fetch from MusicBrainz
try
{
var genres = await _musicBrainz.GetGenresForSongAsync(song.Title, song.Artist, song.Isrc);
if (genres.Count > 0)
{
// Use the top genre
song.Genre = genres[0];
// Cache in both Redis and file
await _cache.SetAsync(redisCacheKey, song.Genre, GenreCacheDuration);
await SaveToFileCacheAsync(cacheKey, song.Genre);
_logger.LogInformation("Enriched {Title} - {Artist} with genre: {Genre}",
song.Title, song.Artist, song.Genre);
}
else
{
// Cache negative result to avoid repeated lookups
await SaveToFileCacheAsync(cacheKey, "");
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to enrich genre for {Title} - {Artist}",
song.Title, song.Artist);
}
}
/// <summary>
/// Enriches multiple songs with genre information (batch operation).
/// </summary>
public async Task EnrichSongsGenresAsync(List<Song> songs)
{
var tasks = songs
.Where(s => string.IsNullOrEmpty(s.Genre))
.Select(s => EnrichSongGenreAsync(s));
await Task.WhenAll(tasks);
}
/// <summary>
/// Aggregates genres from a list of songs to determine playlist genres.
/// Returns the top 5 most common genres.
/// </summary>
public List<string> AggregatePlaylistGenres(List<Song> songs)
{
var genreCounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var song in songs)
{
if (!string.IsNullOrEmpty(song.Genre))
{
if (genreCounts.ContainsKey(song.Genre))
{
genreCounts[song.Genre]++;
}
else
{
genreCounts[song.Genre] = 1;
}
}
}
return genreCounts
.OrderByDescending(kvp => kvp.Value)
.Take(5)
.Select(kvp => kvp.Key)
.ToList();
}
/// <summary>
/// Gets genre from file cache.
/// </summary>
private async Task<string?> GetFromFileCacheAsync(string cacheKey)
{
try
{
var fileName = GetCacheFileName(cacheKey);
var filePath = Path.Combine(GenreCacheDirectory, fileName);
if (!File.Exists(filePath))
{
return null;
}
// Check if cache is expired (30 days)
var fileInfo = new FileInfo(filePath);
if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc > GenreCacheDuration)
{
File.Delete(filePath);
return null;
}
var json = await File.ReadAllTextAsync(filePath);
var cacheEntry = JsonSerializer.Deserialize<GenreCacheEntry>(json);
return cacheEntry?.Genre;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read genre from file cache for {Key}", cacheKey);
return null;
}
}
/// <summary>
/// Saves genre to file cache.
/// </summary>
private async Task SaveToFileCacheAsync(string cacheKey, string genre)
{
try
{
var fileName = GetCacheFileName(cacheKey);
var filePath = Path.Combine(GenreCacheDirectory, fileName);
var cacheEntry = new GenreCacheEntry
{
CacheKey = cacheKey,
Genre = genre,
CachedAt = DateTime.UtcNow
};
var json = JsonSerializer.Serialize(cacheEntry, new JsonSerializerOptions
{
WriteIndented = true
});
await File.WriteAllTextAsync(filePath, json);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to save genre to file cache for {Key}", cacheKey);
}
}
/// <summary>
/// Generates a safe file name from cache key.
/// </summary>
private static string GetCacheFileName(string cacheKey)
{
// Use base64 encoding to create safe file names
var bytes = System.Text.Encoding.UTF8.GetBytes(cacheKey);
var base64 = Convert.ToBase64String(bytes)
.Replace("+", "-")
.Replace("/", "_")
.Replace("=", "");
return $"{base64}.json";
}
private class GenreCacheEntry
{
public string CacheKey { get; set; } = "";
public string Genre { get; set; } = "";
public DateTime CachedAt { get; set; }
}
}

View File

@@ -1,143 +0,0 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace allstarr.Services.Common;
/// <summary>
/// Service for converting music URLs between platforms using Odesli/song.link API
/// </summary>
public class OdesliService
{
private readonly HttpClient _httpClient;
private readonly ILogger<OdesliService> _logger;
private readonly RedisCacheService _cache;
public OdesliService(
IHttpClientFactory httpClientFactory,
ILogger<OdesliService> logger,
RedisCacheService cache)
{
_httpClient = httpClientFactory.CreateClient();
_logger = logger;
_cache = cache;
}
/// <summary>
/// Converts a Tidal track ID to a Spotify track ID using Odesli
/// Results are cached for 7 days
/// </summary>
public async Task<string?> ConvertTidalToSpotifyIdAsync(string tidalTrackId, CancellationToken cancellationToken = default)
{
// Check cache first (7 day TTL - these mappings don't change)
var cacheKey = $"odesli:tidal-to-spotify:{tidalTrackId}";
var cached = await _cache.GetAsync<string>(cacheKey);
if (!string.IsNullOrEmpty(cached))
{
_logger.LogDebug("✓ Using cached Spotify ID for Tidal track {TidalId}", tidalTrackId);
return cached;
}
try
{
var tidalUrl = $"https://tidal.com/browse/track/{tidalTrackId}";
var odesliUrl = $"https://api.song.link/v1-alpha.1/links?url={Uri.EscapeDataString(tidalUrl)}&userCountry=US";
_logger.LogDebug("🔗 Converting Tidal track {TidalId} to Spotify ID via Odesli", tidalTrackId);
var odesliResponse = await _httpClient.GetAsync(odesliUrl, cancellationToken);
if (odesliResponse.IsSuccessStatusCode)
{
var odesliJson = await odesliResponse.Content.ReadAsStringAsync(cancellationToken);
var odesliDoc = JsonDocument.Parse(odesliJson);
// Extract Spotify track ID from the Spotify URL
if (odesliDoc.RootElement.TryGetProperty("linksByPlatform", out var platforms) &&
platforms.TryGetProperty("spotify", out var spotifyPlatform) &&
spotifyPlatform.TryGetProperty("url", out var spotifyUrlEl))
{
var spotifyUrl = spotifyUrlEl.GetString();
if (!string.IsNullOrEmpty(spotifyUrl))
{
// Extract ID from URL: https://open.spotify.com/track/{id}
var match = System.Text.RegularExpressions.Regex.Match(spotifyUrl, @"spotify\.com/track/([a-zA-Z0-9]+)");
if (match.Success)
{
var spotifyId = match.Groups[1].Value;
_logger.LogInformation("✓ Converted Tidal/{TidalId} → Spotify ID {SpotifyId}", tidalTrackId, spotifyId);
// Cache for 7 days
await _cache.SetAsync(cacheKey, spotifyId, TimeSpan.FromDays(7));
return spotifyId;
}
}
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to convert Tidal track to Spotify ID via Odesli");
}
return null;
}
/// <summary>
/// Converts any music URL to a Spotify track ID using Odesli
/// Results are cached for 7 days
/// </summary>
public async Task<string?> ConvertUrlToSpotifyIdAsync(string musicUrl, CancellationToken cancellationToken = default)
{
// Check cache first
var cacheKey = $"odesli:url-to-spotify:{musicUrl}";
var cached = await _cache.GetAsync<string>(cacheKey);
if (!string.IsNullOrEmpty(cached))
{
_logger.LogDebug("✓ Using cached Spotify ID for URL {Url}", musicUrl);
return cached;
}
try
{
var odesliUrl = $"https://api.song.link/v1-alpha.1/links?url={Uri.EscapeDataString(musicUrl)}&userCountry=US";
_logger.LogDebug("🔗 Converting URL to Spotify ID via Odesli: {Url}", musicUrl);
var odesliResponse = await _httpClient.GetAsync(odesliUrl, cancellationToken);
if (odesliResponse.IsSuccessStatusCode)
{
var odesliJson = await odesliResponse.Content.ReadAsStringAsync(cancellationToken);
var odesliDoc = JsonDocument.Parse(odesliJson);
// Extract Spotify track ID from the Spotify URL
if (odesliDoc.RootElement.TryGetProperty("linksByPlatform", out var platforms) &&
platforms.TryGetProperty("spotify", out var spotifyPlatform) &&
spotifyPlatform.TryGetProperty("url", out var spotifyUrlEl))
{
var spotifyUrl = spotifyUrlEl.GetString();
if (!string.IsNullOrEmpty(spotifyUrl))
{
// Extract ID from URL: https://open.spotify.com/track/{id}
var match = System.Text.RegularExpressions.Regex.Match(spotifyUrl, @"spotify\.com/track/([a-zA-Z0-9]+)");
if (match.Success)
{
var spotifyId = match.Groups[1].Value;
_logger.LogInformation("✓ Converted URL → Spotify ID {SpotifyId}", spotifyId);
// Cache for 7 days
await _cache.SetAsync(cacheKey, spotifyId, TimeSpan.FromDays(7));
return spotifyId;
}
}
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to convert URL to Spotify ID via Odesli");
}
return null;
}
}

View File

@@ -1,135 +0,0 @@
using allstarr.Models.Domain;
using allstarr.Models.Search;
namespace allstarr.Services.Common;
/// <summary>
/// Races multiple metadata providers in parallel and returns the fastest result.
/// Used for search operations to minimize latency.
/// </summary>
public class ParallelMetadataService
{
private readonly IEnumerable<IMusicMetadataService> _providers;
private readonly ILogger<ParallelMetadataService> _logger;
public ParallelMetadataService(
IEnumerable<IMusicMetadataService> providers,
ILogger<ParallelMetadataService> logger)
{
_providers = providers;
_logger = logger;
}
/// <summary>
/// Races all providers and returns the first successful result.
/// Falls back to next provider if first one fails.
/// </summary>
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20)
{
if (!_providers.Any())
{
_logger.LogWarning("No metadata providers available for parallel search");
return new SearchResult();
}
_logger.LogDebug("🏁 Racing {Count} providers for search: {Query}", _providers.Count(), query);
// Create tasks for all providers
var tasks = _providers.Select(async provider =>
{
var providerName = provider.GetType().Name;
try
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var result = await provider.SearchAllAsync(query, songLimit, albumLimit, artistLimit);
sw.Stop();
_logger.LogInformation("✅ {Provider} completed search in {Ms}ms ({Songs} songs, {Albums} albums, {Artists} artists)",
providerName, sw.ElapsedMilliseconds, result.Songs.Count, result.Albums.Count, result.Artists.Count);
return (Success: true, Result: result, Provider: providerName, ElapsedMs: sw.ElapsedMilliseconds);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "❌ {Provider} search failed", providerName);
return (Success: false, Result: new SearchResult(), Provider: providerName, ElapsedMs: 0L);
}
}).ToList();
// Wait for first successful result
while (tasks.Any())
{
var completedTask = await Task.WhenAny(tasks);
var result = await completedTask;
if (result.Success && (result.Result.Songs.Any() || result.Result.Albums.Any() || result.Result.Artists.Any()))
{
_logger.LogInformation("🏆 Using results from {Provider} ({Ms}ms) - fastest with results",
result.Provider, result.ElapsedMs);
return result.Result;
}
// Remove completed task and try next
tasks.Remove(completedTask);
}
// All providers failed or returned empty
_logger.LogWarning("⚠️ All providers failed or returned empty results for: {Query}", query);
return new SearchResult();
}
/// <summary>
/// Searches for a specific song by title and artist across all providers in parallel.
/// Returns the first successful match.
/// </summary>
public async Task<Song?> SearchSongAsync(string title, string artist, int limit = 5)
{
if (!_providers.Any())
{
return null;
}
_logger.LogDebug("🏁 Racing {Count} providers for song: {Title} - {Artist}", _providers.Count(), title, artist);
var tasks = _providers.Select(async provider =>
{
var providerName = provider.GetType().Name;
try
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var songs = await provider.SearchSongsAsync($"{title} {artist}", limit);
sw.Stop();
var bestMatch = songs.FirstOrDefault();
if (bestMatch != null)
{
_logger.LogInformation("✅ {Provider} found song in {Ms}ms", providerName, sw.ElapsedMilliseconds);
}
return (Success: bestMatch != null, Song: bestMatch, Provider: providerName, ElapsedMs: sw.ElapsedMilliseconds);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "❌ {Provider} song search failed", providerName);
return (Success: false, Song: (Song?)null, Provider: providerName, ElapsedMs: 0L);
}
}).ToList();
// Wait for first successful result
while (tasks.Any())
{
var completedTask = await Task.WhenAny(tasks);
var result = await completedTask;
if (result.Success && result.Song != null)
{
_logger.LogInformation("🏆 Using song from {Provider} ({Ms}ms)", result.Provider, result.ElapsedMs);
return result.Song;
}
tasks.Remove(completedTask);
}
return null;
}
}

View File

@@ -57,14 +57,13 @@ public class RedisCacheService
try
{
var value = await _db!.StringGetAsync(key);
if (value.HasValue)
{
_logger.LogDebug("Redis cache HIT: {Key}", key);
_logger.LogInformation("Redis cache HIT: {Key}", key);
}
else
{
_logger.LogDebug("Redis cache MISS: {Key}", key);
_logger.LogInformation("Redis cache MISS: {Key}", key);
}
return value;
}
@@ -106,7 +105,7 @@ public class RedisCacheService
var result = await _db!.StringSetAsync(key, value, expiry);
if (result)
{
_logger.LogDebug("Redis cache SET: {Key} (TTL: {Expiry})", key, expiry?.ToString() ?? "none");
_logger.LogInformation("Redis cache SET: {Key} (TTL: {Expiry})", key, expiry?.ToString() ?? "none");
}
return result;
}
@@ -169,34 +168,4 @@ public class RedisCacheService
return false;
}
}
/// <summary>
/// Deletes all keys matching a pattern (e.g., "search:*").
/// WARNING: Use with caution as this scans all keys.
/// </summary>
public async Task<int> DeleteByPatternAsync(string pattern)
{
if (!IsEnabled) return 0;
try
{
var server = _redis!.GetServer(_redis.GetEndPoints().First());
var keys = server.Keys(pattern: pattern).ToArray();
if (keys.Length == 0)
{
_logger.LogDebug("No keys found matching pattern: {Pattern}", pattern);
return 0;
}
var deleted = await _db!.KeyDeleteAsync(keys);
_logger.LogInformation("Deleted {Count} Redis keys matching pattern: {Pattern}", deleted, pattern);
return (int)deleted;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Redis DELETE BY PATTERN failed for pattern: {Pattern}", pattern);
return 0;
}
}
}

View File

@@ -1,313 +0,0 @@
namespace allstarr.Services.Common;
/// <summary>
/// Helper for round-robin load balancing with fallback across multiple API endpoints.
/// Distributes load evenly while maintaining reliability through automatic failover.
/// </summary>
public class RoundRobinFallbackHelper
{
private readonly List<string> _apiUrls;
private int _currentUrlIndex = 0;
private readonly object _urlIndexLock = new object();
private readonly ILogger _logger;
private readonly string _serviceName;
private readonly HttpClient _healthCheckClient;
// Cache health check results for 30 seconds to avoid excessive checks
private readonly Dictionary<string, (bool isHealthy, DateTime checkedAt)> _healthCache = new();
private readonly object _healthCacheLock = new object();
private readonly TimeSpan _healthCacheExpiry = TimeSpan.FromSeconds(30);
public int EndpointCount => _apiUrls.Count;
public RoundRobinFallbackHelper(List<string> apiUrls, ILogger logger, string serviceName)
{
_apiUrls = apiUrls ?? throw new ArgumentNullException(nameof(apiUrls));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_serviceName = serviceName ?? "Service";
if (_apiUrls.Count == 0)
{
throw new ArgumentException("API URLs list cannot be empty", nameof(apiUrls));
}
// Create a dedicated HttpClient for health checks with short timeout
_healthCheckClient = new HttpClient
{
Timeout = TimeSpan.FromSeconds(3) // Quick health check timeout
};
}
/// <summary>
/// Quickly checks if an endpoint is healthy (responds within 3 seconds).
/// Results are cached for 30 seconds to avoid excessive health checks.
/// </summary>
private async Task<bool> IsEndpointHealthyAsync(string baseUrl)
{
// Check cache first
lock (_healthCacheLock)
{
if (_healthCache.TryGetValue(baseUrl, out var cached))
{
if (DateTime.UtcNow - cached.checkedAt < _healthCacheExpiry)
{
return cached.isHealthy;
}
}
}
// Perform health check
try
{
var response = await _healthCheckClient.GetAsync(baseUrl, HttpCompletionOption.ResponseHeadersRead);
var isHealthy = response.IsSuccessStatusCode;
// Cache result
lock (_healthCacheLock)
{
_healthCache[baseUrl] = (isHealthy, DateTime.UtcNow);
}
if (!isHealthy)
{
_logger.LogDebug("{Service} endpoint {Endpoint} health check failed: {StatusCode}",
_serviceName, baseUrl, response.StatusCode);
}
return isHealthy;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "{Service} endpoint {Endpoint} health check failed", _serviceName, baseUrl);
// Cache as unhealthy
lock (_healthCacheLock)
{
_healthCache[baseUrl] = (false, DateTime.UtcNow);
}
return false;
}
}
/// <summary>
/// Gets a list of healthy endpoints, checking them in parallel.
/// Falls back to all endpoints if none are healthy.
/// </summary>
private async Task<List<string>> GetHealthyEndpointsAsync()
{
var healthCheckTasks = _apiUrls.Select(async url => new
{
Url = url,
IsHealthy = await IsEndpointHealthyAsync(url)
}).ToList();
var results = await Task.WhenAll(healthCheckTasks);
var healthyEndpoints = results.Where(r => r.IsHealthy).Select(r => r.Url).ToList();
if (healthyEndpoints.Count == 0)
{
_logger.LogWarning("{Service} health check: no healthy endpoints found, will try all", _serviceName);
return _apiUrls;
}
_logger.LogDebug("{Service} health check: {Healthy}/{Total} endpoints healthy",
_serviceName, healthyEndpoints.Count, _apiUrls.Count);
return healthyEndpoints;
}
/// <summary>
/// Updates the endpoint order based on benchmark results (fastest first).
/// </summary>
public void SetEndpointOrder(List<string> orderedEndpoints)
{
lock (_urlIndexLock)
{
// Reorder _apiUrls to match the benchmarked order
var reordered = orderedEndpoints.Where(e => _apiUrls.Contains(e)).ToList();
// Add any endpoints that weren't benchmarked (shouldn't happen, but be safe)
foreach (var url in _apiUrls.Where(u => !reordered.Contains(u)))
{
reordered.Add(url);
}
_apiUrls.Clear();
_apiUrls.AddRange(reordered);
_currentUrlIndex = 0;
_logger.LogInformation("📊 {Service} endpoints reordered by benchmark: {Endpoints}",
_serviceName, string.Join(", ", _apiUrls.Take(3)));
}
}
/// <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.
/// Performs quick health checks first to avoid wasting time on dead endpoints.
/// Throws exception if all endpoints fail.
/// </summary>
public async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action)
{
// Get healthy endpoints first (with caching to avoid excessive checks)
var healthyEndpoints = await GetHealthyEndpointsAsync();
// Start with the next URL in round-robin to distribute load
var startIndex = 0;
lock (_urlIndexLock)
{
startIndex = _currentUrlIndex;
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
}
// Try healthy endpoints first, then fall back to all if needed
var endpointsToTry = healthyEndpoints.Count < _apiUrls.Count
? healthyEndpoints.Concat(_apiUrls.Except(healthyEndpoints)).ToList()
: healthyEndpoints;
// Try all URLs starting from the round-robin selected one
for (int attempt = 0; attempt < endpointsToTry.Count; attempt++)
{
var urlIndex = (startIndex + attempt) % endpointsToTry.Count;
var baseUrl = endpointsToTry[urlIndex];
try
{
_logger.LogDebug("Trying {Service} endpoint {Endpoint} (attempt {Attempt}/{Total})",
_serviceName, baseUrl, attempt + 1, endpointsToTry.Count);
return await action(baseUrl);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "{Service} request failed with endpoint {Endpoint}, trying next...",
_serviceName, baseUrl);
// Mark as unhealthy in cache
lock (_healthCacheLock)
{
_healthCache[baseUrl] = (false, DateTime.UtcNow);
}
if (attempt == endpointsToTry.Count - 1)
{
_logger.LogError("All {Count} {Service} endpoints failed", endpointsToTry.Count, _serviceName);
throw;
}
}
}
throw new Exception($"All {_serviceName} endpoints failed");
}
/// <summary>
/// Races all endpoints in parallel and returns the first successful result.
/// Cancels remaining requests once one succeeds. Great for latency-sensitive operations.
/// </summary>
public async Task<T> RaceAllEndpointsAsync<T>(Func<string, CancellationToken, Task<T>> action, CancellationToken cancellationToken = default)
{
if (_apiUrls.Count == 1)
{
// No point racing with one endpoint
return await action(_apiUrls[0], cancellationToken);
}
using var raceCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var tasks = new List<Task<(T result, string endpoint, bool success)>>();
// Start all requests in parallel
foreach (var baseUrl in _apiUrls)
{
var task = Task.Run(async () =>
{
try
{
_logger.LogDebug("Racing {Service} endpoint {Endpoint}", _serviceName, baseUrl);
var result = await action(baseUrl, raceCts.Token);
return (result, baseUrl, true);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "{Service} race failed for endpoint {Endpoint}", _serviceName, baseUrl);
return (default(T)!, baseUrl, false);
}
}, raceCts.Token);
tasks.Add(task);
}
// Wait for first successful completion
while (tasks.Count > 0)
{
var completedTask = await Task.WhenAny(tasks);
var (result, endpoint, success) = await completedTask;
if (success)
{
_logger.LogInformation("🏁 {Service} race won by {Endpoint}, canceling others", _serviceName, endpoint);
raceCts.Cancel(); // Cancel all other requests
return result;
}
tasks.Remove(completedTask);
}
throw new Exception($"All {_serviceName} endpoints failed in race");
}
/// <summary>
/// Tries the request with the next provider in round-robin, then falls back to others on failure.
/// Performs quick health checks first to avoid wasting time on dead endpoints.
/// Returns default value if all endpoints fail (does not throw).
/// </summary>
public async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action, T defaultValue)
{
// Get healthy endpoints first (with caching to avoid excessive checks)
var healthyEndpoints = await GetHealthyEndpointsAsync();
// Start with the next URL in round-robin to distribute load
var startIndex = 0;
lock (_urlIndexLock)
{
startIndex = _currentUrlIndex;
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
}
// Try healthy endpoints first, then fall back to all if needed
var endpointsToTry = healthyEndpoints.Count < _apiUrls.Count
? healthyEndpoints.Concat(_apiUrls.Except(healthyEndpoints)).ToList()
: healthyEndpoints;
// Try all URLs starting from the round-robin selected one
for (int attempt = 0; attempt < endpointsToTry.Count; attempt++)
{
var urlIndex = (startIndex + attempt) % endpointsToTry.Count;
var baseUrl = endpointsToTry[urlIndex];
try
{
_logger.LogDebug("Trying {Service} endpoint {Endpoint} (attempt {Attempt}/{Total})",
_serviceName, baseUrl, attempt + 1, endpointsToTry.Count);
return await action(baseUrl);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "{Service} request failed with endpoint {Endpoint}, trying next...",
_serviceName, baseUrl);
// Mark as unhealthy in cache
lock (_healthCacheLock)
{
_healthCache[baseUrl] = (false, DateTime.UtcNow);
}
if (attempt == endpointsToTry.Count - 1)
{
_logger.LogError("All {Count} {Service} endpoints failed, returning default value",
endpointsToTry.Count, _serviceName);
return defaultValue;
}
}
}
return defaultValue;
}
}

View File

@@ -24,6 +24,7 @@ namespace allstarr.Services.Deezer;
public class DeezerDownloadService : BaseDownloadService
{
private readonly HttpClient _httpClient;
private readonly SemaphoreSlim _requestLock = new(1, 1);
private readonly string? _arl;
private readonly string? _arlFallback;
@@ -32,6 +33,9 @@ public class DeezerDownloadService : BaseDownloadService
private string? _apiToken;
private string? _licenseToken;
private DateTime _lastRequestTime = DateTime.MinValue;
private readonly int _minRequestIntervalMs = 200;
private const string DeezerApiBase = "https://api.deezer.com";
// Deezer's standard Blowfish CBC encryption key for track decryption
@@ -107,10 +111,7 @@ public class DeezerDownloadService : BaseDownloadService
// Build organized folder structure: Artist/Album/Track using AlbumArtist (fallback to Artist for singles)
var artistForPath = song.AlbumArtist ?? song.Artist;
// Cache mode uses downloads/cache/ folder, Permanent mode uses downloads/permanent/
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache
? Path.Combine("downloads", "cache")
: Path.Combine("downloads", "permanent");
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
@@ -493,6 +494,27 @@ public class DeezerDownloadService : BaseDownloadService
await RetryWithBackoffAsync<bool>(action, maxRetries, initialDelayMs);
}
private async Task<T> QueueRequestAsync<T>(Func<Task<T>> action)
{
await _requestLock.WaitAsync();
try
{
var now = DateTime.UtcNow;
var timeSinceLastRequest = (now - _lastRequestTime).TotalMilliseconds;
if (timeSinceLastRequest < _minRequestIntervalMs)
{
await Task.Delay((int)(_minRequestIntervalMs - timeSinceLastRequest));
}
_lastRequestTime = DateTime.UtcNow;
return await action();
}
finally
{
_requestLock.Release();
}
}
#endregion

View File

@@ -187,14 +187,6 @@ public class JellyfinModelMapper
// Cover art URL construction
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;
}

View File

@@ -146,17 +146,6 @@ public class JellyfinProxyService
{
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;
// Check if this is a browser request for static assets (favicon, etc.)
@@ -168,11 +157,6 @@ public class JellyfinProxyService
(h.Value.ToString().Contains("image", StringComparison.OrdinalIgnoreCase) ||
h.Value.ToString().Contains("document", StringComparison.OrdinalIgnoreCase))) == true);
// Check if this is a public endpoint that doesn't require authentication
bool isPublicEndpoint = url.Contains("/System/Info/Public", StringComparison.OrdinalIgnoreCase) ||
url.Contains("/Branding/", StringComparison.OrdinalIgnoreCase) ||
url.Contains("/Startup/", StringComparison.OrdinalIgnoreCase);
// Forward authentication headers from client if provided
if (clientHeaders != null && clientHeaders.Count > 0)
{
@@ -184,27 +168,11 @@ public class JellyfinProxyService
var headerValue = header.Value.ToString();
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
authHeaderAdded = true;
_logger.LogTrace("Forwarded X-Emby-Authorization header");
_logger.LogInformation("Forwarded X-Emby-Authorization: {Value}", headerValue);
break;
}
}
// Try X-Emby-Token (simpler format used by some clients)
if (!authHeaderAdded)
{
foreach (var header in clientHeaders)
{
if (header.Key.Equals("X-Emby-Token", StringComparison.OrdinalIgnoreCase))
{
var headerValue = header.Value.ToString();
request.Headers.TryAddWithoutValidation("X-Emby-Token", headerValue);
authHeaderAdded = true;
_logger.LogTrace("Forwarded X-Emby-Token header");
break;
}
}
}
// If no X-Emby-Authorization, check if Authorization header contains MediaBrowser format
// Some clients send it as "Authorization" instead of "X-Emby-Authorization"
if (!authHeaderAdded)
@@ -222,32 +190,37 @@ public class JellyfinProxyService
// Forward as X-Emby-Authorization (Jellyfin's expected header)
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
authHeaderAdded = true;
_logger.LogTrace("Converted Authorization to X-Emby-Authorization");
_logger.LogInformation("Converted Authorization to X-Emby-Authorization: {Value}", headerValue);
}
else
{
// Standard Bearer token - forward as-is
request.Headers.TryAddWithoutValidation("Authorization", headerValue);
authHeaderAdded = true;
_logger.LogTrace("Forwarded Authorization header");
_logger.LogInformation("Forwarded Authorization (Bearer): {Value}", headerValue);
}
break;
}
}
}
// Check for api_key query parameter (some clients use this)
if (!authHeaderAdded && url.Contains("api_key=", StringComparison.OrdinalIgnoreCase))
// Only log warnings for non-browser static requests
if (!authHeaderAdded && !isBrowserStaticRequest)
{
authHeaderAdded = true; // It's in the URL, no need to add header
_logger.LogTrace("Using api_key from query string");
_logger.LogWarning("✗ No auth header found. Available headers: {Headers}",
string.Join(", ", clientHeaders.Select(h => $"{h.Key}={h.Value}")));
}
}
else if (!isBrowserStaticRequest)
{
_logger.LogWarning("✗ No client headers provided for {Url}", url);
}
// Only log warnings for non-public, non-browser requests without auth
if (!authHeaderAdded && !isBrowserStaticRequest && !isPublicEndpoint)
// DO NOT use server API key as fallback - let Jellyfin handle unauthenticated requests
// If client doesn't provide auth, they get what they deserve (401 from Jellyfin)
if (!authHeaderAdded && !isBrowserStaticRequest)
{
_logger.LogDebug("No client auth provided for {Url} - Jellyfin will handle authentication", url);
_logger.LogInformation("No client auth provided for {Url} - forwarding without auth", url);
}
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
@@ -264,28 +237,14 @@ public class JellyfinProxyService
{
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
// 401 means token expired or invalid - client needs to re-authenticate
_logger.LogInformation("Jellyfin returned 401 Unauthorized for {Url} - client should re-authenticate", url);
_logger.LogWarning("Jellyfin returned 401 Unauthorized for {Url} - passing through to client", url);
}
else if (!isBrowserStaticRequest && !isPublicEndpoint)
else if (!isBrowserStaticRequest) // Don't log 404s for browser static requests
{
_logger.LogWarning("Jellyfin request failed: {StatusCode} for {Url}", response.StatusCode, url);
}
// Try to parse error response to pass through to client
if (!string.IsNullOrWhiteSpace(content))
{
try
{
var errorDoc = JsonDocument.Parse(content);
return (errorDoc, statusCode);
}
catch
{
// Not valid JSON, return null
}
}
// Return null body with the actual status code
return (null, statusCode);
}
@@ -303,22 +262,33 @@ public class JellyfinProxyService
using var request = new HttpRequestMessage(HttpMethod.Post, 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);
}
}
// 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.
// Handle special case for playback endpoints - Jellyfin expects wrapped body
var bodyToSend = body;
if (string.IsNullOrWhiteSpace(body))
if (!string.IsNullOrWhiteSpace(body))
{
// Check if this is a playback progress endpoint
if (endpoint.Contains("Sessions/Playing/Progress", StringComparison.OrdinalIgnoreCase))
{
// Wrap the body in playbackProgressInfo field
bodyToSend = $"{{\"playbackProgressInfo\":{body}}}";
_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
{
bodyToSend = "{}";
_logger.LogWarning("POST body was empty for {Url}, sending empty JSON object", url);
@@ -327,10 +297,8 @@ public class JellyfinProxyService
request.Content = new StringContent(bodyToSend, System.Text.Encoding.UTF8, "application/json");
bool authHeaderAdded = false;
bool isAuthEndpoint = endpoint.Contains("Authenticate", StringComparison.OrdinalIgnoreCase);
// Forward authentication headers from client (case-insensitive)
// Try X-Emby-Authorization first
foreach (var header in clientHeaders)
{
if (header.Key.Equals("X-Emby-Authorization", StringComparison.OrdinalIgnoreCase))
@@ -338,28 +306,11 @@ public class JellyfinProxyService
var headerValue = header.Value.ToString();
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
authHeaderAdded = true;
_logger.LogTrace("Forwarded X-Emby-Authorization header");
_logger.LogDebug("Forwarded X-Emby-Authorization from client");
break;
}
}
// Try X-Emby-Token
if (!authHeaderAdded)
{
foreach (var header in clientHeaders)
{
if (header.Key.Equals("X-Emby-Token", StringComparison.OrdinalIgnoreCase))
{
var headerValue = header.Value.ToString();
request.Headers.TryAddWithoutValidation("X-Emby-Token", headerValue);
authHeaderAdded = true;
_logger.LogTrace("Forwarded X-Emby-Token header");
break;
}
}
}
// Try Authorization header
if (!authHeaderAdded)
{
foreach (var header in clientHeaders)
@@ -374,13 +325,13 @@ public class JellyfinProxyService
{
// Forward as X-Emby-Authorization
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
_logger.LogTrace("Converted Authorization to X-Emby-Authorization");
_logger.LogDebug("Converted Authorization to X-Emby-Authorization");
}
else
{
// Standard Bearer token
request.Headers.TryAddWithoutValidation("Authorization", headerValue);
_logger.LogTrace("Forwarded Authorization header");
_logger.LogDebug("Forwarded Authorization header");
}
authHeaderAdded = true;
break;
@@ -388,23 +339,30 @@ public class JellyfinProxyService
}
}
// For authentication endpoints, credentials are in the body, not headers
// For other endpoints without auth, let Jellyfin reject the request
if (!authHeaderAdded && !isAuthEndpoint)
// 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)
{
_logger.LogDebug("No client auth provided for POST {Url} - Jellyfin will handle authentication", url);
_logger.LogInformation("No client auth provided for POST {Url} - forwarding without auth", url);
}
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
// DO NOT log the body for auth endpoints - it contains passwords!
if (isAuthEndpoint)
if (endpoint.Contains("Authenticate", StringComparison.OrdinalIgnoreCase))
{
_logger.LogDebug("POST to Jellyfin: {Url} (auth request - body not logged)", url);
}
else
{
_logger.LogTrace("POST to Jellyfin: {Url}, body length: {Length} bytes", url, bodyToSend.Length);
_logger.LogInformation("POST to Jellyfin: {Url}, body length: {Length} bytes", url, bodyToSend.Length);
// Log body content for playback endpoints to debug
if (endpoint.Contains("Playing", StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation("Sending body to Jellyfin: {Body}", bodyToSend);
}
}
var response = await _httpClient.SendAsync(request);
@@ -414,41 +372,11 @@ public class JellyfinProxyService
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
// 401 is expected when tokens expire - don't spam logs
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
_logger.LogInformation("Jellyfin POST returned 401 for {Url} - client should re-authenticate", url);
}
else
{
_logger.LogWarning("Jellyfin POST request failed: {StatusCode} for {Url}. Response: {Response}",
response.StatusCode, url, errorContent.Length > 200 ? errorContent[..200] + "..." : errorContent);
}
// Try to parse error response as JSON to pass through to client
if (!string.IsNullOrWhiteSpace(errorContent))
{
try
{
var errorDoc = JsonDocument.Parse(errorContent);
return (errorDoc, statusCode);
}
catch
{
// Not valid JSON, return null
}
}
response.StatusCode, url, errorContent);
return (null, statusCode);
}
// Log successful session-related responses
if (endpoint.Contains("Sessions", StringComparison.OrdinalIgnoreCase))
{
_logger.LogTrace("Jellyfin responded {StatusCode} for {Endpoint}", statusCode, endpoint);
}
// Handle 204 No Content responses (e.g., /sessions/playing, /sessions/playing/progress)
if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
{
@@ -468,7 +396,6 @@ public class JellyfinProxyService
/// <summary>
/// Sends a GET request and returns raw bytes (for images, audio streams).
/// WARNING: This loads entire response into memory - use StreamAsync for large files!
/// </summary>
public async Task<(byte[] Body, string? ContentType)> GetBytesAsync(string endpoint, Dictionary<string, string>? queryParams = null)
{
@@ -483,35 +410,9 @@ public class JellyfinProxyService
var body = await response.Content.ReadAsByteArrayAsync();
var contentType = response.Content.Headers.ContentType?.ToString();
// Trigger GC for large files to prevent memory leaks
if (body.Length > 1024 * 1024) // 1MB threshold
{
GC.Collect(2, GCCollectionMode.Optimized, blocking: false);
}
return (body, contentType);
}
/// <summary>
/// Streams content directly without loading into memory (for large files like audio).
/// </summary>
public async Task<(Stream Stream, string? ContentType, long? ContentLength)> GetStreamAsync(string endpoint, Dictionary<string, string>? queryParams = null)
{
var url = BuildUrl(endpoint, queryParams);
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("Authorization", GetAuthorizationHeader());
var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
var stream = await response.Content.ReadAsStreamAsync();
var contentType = response.Content.Headers.ContentType?.ToString();
var contentLength = response.Content.Headers.ContentLength;
return (stream, contentType, contentLength);
}
/// <summary>
/// Sends a DELETE request to the Jellyfin server.
/// Forwards client headers for authentication passthrough.
@@ -523,17 +424,6 @@ public class JellyfinProxyService
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)
@@ -1026,43 +916,4 @@ public class JellyfinProxyService
return url;
}
/// <summary>
/// Sends a GET request to the Jellyfin server using the server's API key for internal operations.
/// This should only be used for server-side operations, not for proxying client requests.
/// </summary>
public async Task<(JsonDocument? Body, int StatusCode)> GetJsonAsyncInternal(string endpoint, Dictionary<string, string>? queryParams = null)
{
var url = BuildUrl(endpoint, queryParams);
using var request = new HttpRequestMessage(HttpMethod.Get, url);
// Use server's API key for authentication
var authHeader = GetAuthorizationHeader();
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", authHeader);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var response = await _httpClient.SendAsync(request);
var statusCode = (int)response.StatusCode;
var content = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("Jellyfin internal request returned {StatusCode} for {Url}: {Content}",
statusCode, url, content);
return (null, statusCode);
}
try
{
var jsonDocument = JsonDocument.Parse(content);
return (jsonDocument, statusCode);
}
catch (JsonException ex)
{
_logger.LogError(ex, "Failed to parse JSON response from {Url}: {Content}", url, content);
return (null, statusCode);
}
}
}

View File

@@ -186,7 +186,7 @@ public class JellyfinResponseBuilder
["Type"] = "Audio",
["Album"] = song.Album,
["AlbumArtist"] = song.Artist,
["Artists"] = song.Artists.Count > 0 ? song.Artists.ToArray() : new[] { song.Artist },
["Artists"] = new[] { song.Artist },
["RunTimeTicks"] = (song.Duration ?? 0) * TimeSpan.TicksPerSecond,
["ImageTags"] = new Dictionary<string, string>
{
@@ -231,113 +231,47 @@ public class JellyfinResponseBuilder
/// </summary>
public Dictionary<string, object?> ConvertSongToJellyfinItem(Song song)
{
// Add " [S]" suffix to external song titles (S = streaming source)
var songTitle = song.Title;
var artistName = song.Artist;
var albumName = song.Album;
var artistNames = song.Artists.ToList();
if (!song.IsLocal)
{
songTitle = $"{song.Title} [S]";
// Also add [S] to artist and album names for consistency
if (!string.IsNullOrEmpty(artistName) && !artistName.EndsWith(" [S]"))
{
artistName = $"{artistName} [S]";
}
if (!string.IsNullOrEmpty(albumName) && !albumName.EndsWith(" [S]"))
{
albumName = $"{albumName} [S]";
}
// Add [S] to all artist names in the list
artistNames = artistNames.Select(a =>
!string.IsNullOrEmpty(a) && !a.EndsWith(" [S]") ? $"{a} [S]" : a
).ToList();
}
var item = new Dictionary<string, object?>
{
["Name"] = songTitle,
["ServerId"] = "allstarr",
["Id"] = song.Id,
["HasLyrics"] = false, // Could be enhanced to check if lyrics exist
["Container"] = "flac",
["PremiereDate"] = song.Year.HasValue ? $"{song.Year}-01-01T00:00:00.0000000Z" : null,
["RunTimeTicks"] = (song.Duration ?? 0) * TimeSpan.TicksPerSecond,
["ProductionYear"] = song.Year,
["IndexNumber"] = song.Track,
["ParentIndexNumber"] = song.DiscNumber ?? 1,
["IsFolder"] = false,
["Name"] = song.Title,
["ServerId"] = "allstarr",
["Type"] = "Audio",
["ChannelId"] = (object?)null,
["Genres"] = !string.IsNullOrEmpty(song.Genre)
? new[] { song.Genre }
: new string[0],
["GenreItems"] = !string.IsNullOrEmpty(song.Genre)
? new[]
["MediaType"] = "Audio",
["IsFolder"] = false,
["Album"] = song.Album,
["AlbumId"] = song.AlbumId ?? song.Id,
["AlbumArtist"] = song.AlbumArtist ?? song.Artist,
["Artists"] = new[] { song.Artist },
["ArtistItems"] = new[]
{
new Dictionary<string, object?>
{
["Name"] = song.Genre,
["Id"] = $"genre-{song.Genre?.ToLowerInvariant()}"
["Id"] = song.ArtistId ?? song.Id,
["Name"] = song.Artist
}
}
: new Dictionary<string, object?>[0],
["ParentLogoItemId"] = song.AlbumId,
["ParentBackdropItemId"] = song.AlbumId,
["ParentBackdropImageTags"] = new string[0],
},
["IndexNumber"] = song.Track,
["ParentIndexNumber"] = song.DiscNumber ?? 1,
["ProductionYear"] = song.Year,
["RunTimeTicks"] = (song.Duration ?? 0) * TimeSpan.TicksPerSecond,
["ImageTags"] = new Dictionary<string, string>
{
["Primary"] = song.Id
},
["BackdropImageTags"] = new string[0],
["ImageBlurHashes"] = new Dictionary<string, object>(),
["LocationType"] = "FileSystem", // External content appears as local files to clients
["Path"] = $"/music/{song.Artist}/{song.Album}/{song.Title}.flac", // Fake path for client compatibility
["ChannelId"] = (object?)null, // Match Jellyfin structure
["UserData"] = new Dictionary<string, object>
{
["PlaybackPositionTicks"] = 0,
["PlayCount"] = 0,
["IsFavorite"] = false,
["Played"] = false,
["Key"] = $"Audio-{song.Id}",
["ItemId"] = song.Id
["Key"] = $"Audio-{song.Id}"
},
["Artists"] = artistNames.Count > 0 ? artistNames.ToArray() : new[] { artistName ?? "" },
["ArtistItems"] = artistNames.Count > 0
? artistNames.Select((name, index) => new Dictionary<string, object?>
{
["Name"] = name,
["Id"] = index == 0 && song.ArtistId != null
? song.ArtistId
: $"{song.Id}-artist-{index}"
}).ToArray()
: new[]
{
new Dictionary<string, object?>
{
["Id"] = song.ArtistId ?? song.Id,
["Name"] = artistName ?? ""
}
},
["Album"] = albumName,
["AlbumId"] = song.AlbumId ?? song.Id,
["AlbumPrimaryImageTag"] = song.AlbumId ?? song.Id,
["AlbumArtist"] = song.AlbumArtist ?? artistName,
["AlbumArtists"] = new[]
{
new Dictionary<string, object?>
{
["Name"] = song.AlbumArtist ?? artistName ?? "",
["Id"] = song.ArtistId ?? song.Id
}
},
["ImageTags"] = new Dictionary<string, string>
{
["Primary"] = song.Id
},
["BackdropImageTags"] = new string[0],
["ParentLogoImageTag"] = song.AlbumId ?? song.Id,
["ImageBlurHashes"] = new Dictionary<string, object>(),
["LocationType"] = "FileSystem",
["MediaType"] = "Audio",
["NormalizationGain"] = 0.0,
["Path"] = $"/music/{song.Artist}/{song.Album}/{song.Title}.flac",
["CanDownload"] = true,
["SupportsSync"] = true
};
@@ -356,78 +290,28 @@ public class JellyfinResponseBuilder
providerIds["ISRC"] = song.Isrc;
}
// Add MediaSources with complete structure matching real Jellyfin
// Add MediaSources with bitrate for external tracks
item["MediaSources"] = new[]
{
new Dictionary<string, object?>
{
["Protocol"] = "File",
["Id"] = song.Id,
["Path"] = $"/music/{song.Artist}/{song.Album}/{song.Title}.flac",
["Type"] = "Default",
["Container"] = "flac",
["Size"] = (song.Duration ?? 180) * 1337 * 128,
["Name"] = song.Title,
["IsRemote"] = false,
["ETag"] = song.Id, // Use song ID as ETag
["RunTimeTicks"] = (song.Duration ?? 180) * 10000000L,
["ReadAtNativeFramerate"] = false,
["IgnoreDts"] = false,
["IgnoreIndex"] = false,
["GenPtsInput"] = false,
["SupportsTranscoding"] = true,
["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,
["SupportsDirectPlay"] = true,
["IsInfiniteStream"] = false,
["UseMostCompatibleTranscodingProfile"] = false,
["RequiresOpening"] = false,
["RequiresClosing"] = false,
["RequiresLooping"] = false,
["SupportsProbing"] = true,
["MediaStreams"] = new[]
{
new Dictionary<string, object?>
{
["Codec"] = "flac",
["TimeBase"] = "1/44100",
["VideoRange"] = "Unknown",
["VideoRangeType"] = "Unknown",
["AudioSpatialFormat"] = "None",
["LocalizedDefault"] = "Default",
["LocalizedExternal"] = "External",
["DisplayTitle"] = "FLAC - Stereo",
["IsInterlaced"] = false,
["IsAVC"] = false,
["ChannelLayout"] = "stereo",
["BitRate"] = 1337000,
["BitDepth"] = 16,
["Channels"] = 2,
["SampleRate"] = 44100,
["IsDefault"] = false,
["IsForced"] = false,
["IsHearingImpaired"] = false,
["Type"] = "Audio",
["Index"] = 0,
["IsExternal"] = false,
["IsTextSubtitleStream"] = false,
["SupportsExternalStream"] = false,
["Level"] = 0
}
},
["MediaAttachments"] = new List<object>(),
["Formats"] = new List<string>(),
["Bitrate"] = 1337000,
["RequiredHttpHeaders"] = new Dictionary<string, string>(),
["TranscodingSubProtocol"] = "http",
["DefaultAudioStreamIndex"] = 0,
["HasSegments"] = false
["SupportsTranscoding"] = true,
["SupportsDirectPlay"] = true
}
};
}
else if (song.IsLocal && song.JellyfinMetadata != null && song.JellyfinMetadata.ContainsKey("MediaSources"))
if (!string.IsNullOrEmpty(song.Genre))
{
// Use preserved Jellyfin metadata for local tracks to maintain bitrate info
item["MediaSources"] = song.JellyfinMetadata["MediaSources"];
item["Genres"] = new[] { song.Genre };
}
return item;
@@ -438,77 +322,49 @@ public class JellyfinResponseBuilder
/// </summary>
public Dictionary<string, object?> ConvertAlbumToJellyfinItem(Album album)
{
// Add " [S]" suffix to external album names (S = streaming source)
// Add " - S" suffix to external album names (S = SquidWTF)
var albumName = album.Title;
if (!album.IsLocal)
{
albumName = $"{album.Title} [S]";
albumName = $"{album.Title} - S";
}
var item = new Dictionary<string, object?>
{
["Id"] = album.Id,
["Name"] = albumName,
["ServerId"] = "allstarr",
["Id"] = album.Id,
["PremiereDate"] = album.Year.HasValue ? $"{album.Year}-01-01T05:00:00.0000000Z" : null,
["ChannelId"] = (object?)null,
["Genres"] = !string.IsNullOrEmpty(album.Genre)
? new[] { album.Genre }
: new string[0],
["RunTimeTicks"] = 0, // Could calculate from songs
["ProductionYear"] = album.Year,
["IsFolder"] = true,
["Type"] = "MusicAlbum",
["GenreItems"] = !string.IsNullOrEmpty(album.Genre)
? new[]
["IsFolder"] = true,
["AlbumArtist"] = album.Artist,
["AlbumArtists"] = new[]
{
new Dictionary<string, object?>
{
["Name"] = album.Genre,
["Id"] = $"genre-{album.Genre?.ToLowerInvariant()}"
["Id"] = album.ArtistId ?? album.Id,
["Name"] = album.Artist
}
}
: new Dictionary<string, object?>[0],
["ParentLogoItemId"] = album.ArtistId ?? album.Id,
["ParentBackdropItemId"] = album.ArtistId ?? album.Id,
["ParentBackdropImageTags"] = new string[0],
},
["ProductionYear"] = album.Year,
["ChildCount"] = album.SongCount ?? album.Songs.Count,
["ImageTags"] = new Dictionary<string, string>
{
["Primary"] = album.Id
},
["BackdropImageTags"] = new string[0],
["ImageBlurHashes"] = new Dictionary<string, object>(),
["LocationType"] = "FileSystem",
["MediaType"] = (object?)null,
["ChannelId"] = (object?)null,
["CollectionType"] = (object?)null,
["UserData"] = new Dictionary<string, object>
{
["PlaybackPositionTicks"] = 0,
["PlayCount"] = 0,
["IsFavorite"] = false,
["Played"] = false,
["Key"] = $"{album.Artist}-{album.Title}",
["ItemId"] = album.Id
},
["Artists"] = new[] { album.Artist },
["ArtistItems"] = new[]
{
new Dictionary<string, object?>
{
["Name"] = album.Artist,
["Id"] = album.ArtistId ?? album.Id
["Key"] = album.Id
}
},
["AlbumArtist"] = album.Artist,
["AlbumArtists"] = new[]
{
new Dictionary<string, object?>
{
["Name"] = album.Artist,
["Id"] = album.ArtistId ?? album.Id
}
},
["ImageTags"] = new Dictionary<string, string>
{
["Primary"] = album.Id
},
["BackdropImageTags"] = new string[0],
["ParentLogoImageTag"] = album.ArtistId ?? album.Id,
["ImageBlurHashes"] = new Dictionary<string, object>(),
["LocationType"] = "FileSystem",
["MediaType"] = "Unknown",
["ChildCount"] = album.SongCount ?? album.Songs.Count
};
// Add provider IDs for external content
@@ -520,6 +376,11 @@ public class JellyfinResponseBuilder
};
}
if (!string.IsNullOrEmpty(album.Genre))
{
item["Genres"] = new[] { album.Genre };
}
return item;
}
@@ -528,42 +389,39 @@ public class JellyfinResponseBuilder
/// </summary>
public Dictionary<string, object?> ConvertArtistToJellyfinItem(Artist artist)
{
// Add " [S]" suffix to external artist names (S = streaming source)
// Add " - S" suffix to external artist names (S = SquidWTF)
var artistName = artist.Name;
if (!artist.IsLocal)
{
artistName = $"{artist.Name} [S]";
artistName = $"{artist.Name} - S";
}
var item = new Dictionary<string, object?>
{
["Id"] = artist.Id,
["Name"] = artistName,
["ServerId"] = "allstarr",
["Id"] = artist.Id,
["ChannelId"] = (object?)null,
["Genres"] = new string[0], // Artists aggregate genres from albums/tracks
["RunTimeTicks"] = 0,
["IsFolder"] = true,
["Type"] = "MusicArtist",
["GenreItems"] = new Dictionary<string, object?>[0],
["UserData"] = new Dictionary<string, object>
{
["PlaybackPositionTicks"] = 0,
["PlayCount"] = 0,
["IsFavorite"] = false,
["Played"] = false,
["Key"] = $"Artist-{artist.Name}",
["ItemId"] = artist.Id
},
["IsFolder"] = true,
["AlbumCount"] = artist.AlbumCount ?? 0,
["ImageTags"] = new Dictionary<string, string>
{
["Primary"] = artist.Id
},
["BackdropImageTags"] = new string[0],
["ImageBlurHashes"] = new Dictionary<string, object>(),
["LocationType"] = "FileSystem",
["MediaType"] = "Unknown",
["AlbumCount"] = artist.AlbumCount ?? 0
["LocationType"] = "FileSystem", // External content appears as local files to clients
["MediaType"] = (object?)null, // Match Jellyfin structure
["ChannelId"] = (object?)null, // Match Jellyfin structure
["CollectionType"] = (object?)null, // Match Jellyfin structure
["UserData"] = new Dictionary<string, object>
{
["PlaybackPositionTicks"] = 0,
["PlayCount"] = 0,
["IsFavorite"] = false,
["Played"] = false,
["Key"] = artist.Id
}
};
// Add provider IDs for external content
@@ -600,7 +458,7 @@ public class JellyfinResponseBuilder
}
/// <summary>
/// Converts an ExternalPlaylist to a Jellyfin playlist item.
/// Converts an ExternalPlaylist to a Jellyfin album item.
/// </summary>
public Dictionary<string, object?> ConvertPlaylistToJellyfinItem(ExternalPlaylist playlist)
{
@@ -610,24 +468,13 @@ public class JellyfinResponseBuilder
var item = new Dictionary<string, object?>
{
["Id"] = playlist.Id,
["Name"] = playlist.Name,
["ServerId"] = "allstarr",
["Id"] = playlist.Id,
["ChannelId"] = (object?)null,
["Genres"] = new string[0], // Playlists aggregate genres from tracks
["RunTimeTicks"] = playlist.Duration * TimeSpan.TicksPerSecond,
["IsFolder"] = true,
["Type"] = "Playlist",
["GenreItems"] = new Dictionary<string, object?>[0],
["UserData"] = new Dictionary<string, object>
{
["PlaybackPositionTicks"] = 0,
["PlayCount"] = 0,
["IsFavorite"] = false,
["Played"] = false,
["Key"] = playlist.Id,
["ItemId"] = playlist.Id
},
["IsFolder"] = true,
["AlbumArtist"] = curatorName,
["Genres"] = new[] { "Playlist" },
["ChildCount"] = playlist.TrackCount,
["ImageTags"] = new Dictionary<string, string>
{
@@ -636,10 +483,20 @@ public class JellyfinResponseBuilder
["BackdropImageTags"] = new string[0],
["ImageBlurHashes"] = new Dictionary<string, object>(),
["LocationType"] = "FileSystem",
["MediaType"] = "Audio",
["MediaType"] = (object?)null,
["ChannelId"] = (object?)null,
["CollectionType"] = (object?)null,
["ProviderIds"] = new Dictionary<string, string>
{
[playlist.Provider] = playlist.ExternalId
},
["UserData"] = new Dictionary<string, object>
{
["PlaybackPositionTicks"] = 0,
["PlayCount"] = 0,
["IsFavorite"] = false,
["Played"] = false,
["Key"] = playlist.Id
}
};

View File

@@ -1,591 +0,0 @@
using System.Collections.Concurrent;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using allstarr.Models.Settings;
namespace allstarr.Services.Jellyfin;
/// <summary>
/// Manages Jellyfin sessions for connected clients.
/// Creates sessions on first playback and keeps them alive with periodic pings.
/// Also maintains server-side WebSocket connections to Jellyfin on behalf of clients.
/// </summary>
public class JellyfinSessionManager : IDisposable
{
private readonly JellyfinProxyService _proxyService;
private readonly JellyfinSettings _settings;
private readonly ILogger<JellyfinSessionManager> _logger;
private readonly ConcurrentDictionary<string, SessionInfo> _sessions = new();
private readonly Timer _keepAliveTimer;
public JellyfinSessionManager(
JellyfinProxyService proxyService,
IOptions<JellyfinSettings> settings,
ILogger<JellyfinSessionManager> logger)
{
_proxyService = proxyService;
_settings = settings.Value;
_logger = logger;
// Keep sessions alive every 10 seconds (Jellyfin considers sessions stale after ~15 seconds of inactivity)
_keepAliveTimer = new Timer(KeepSessionsAlive, null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10));
_logger.LogDebug("🔧 SESSION: JellyfinSessionManager initialized with 10-second keep-alive and WebSocket support");
}
/// <summary>
/// Ensures a session exists for the given device. Creates one if needed.
/// Returns false if token is expired (401), indicating client needs to re-authenticate.
/// </summary>
public async Task<bool> EnsureSessionAsync(string deviceId, string client, string device, string version, IHeaderDictionary headers)
{
if (string.IsNullOrEmpty(deviceId))
{
_logger.LogWarning("Cannot create session - no device ID");
return false;
}
// Check if we already have this session tracked
if (_sessions.TryGetValue(deviceId, out var existingSession))
{
existingSession.LastActivity = DateTime.UtcNow;
_logger.LogTrace("Session already exists for device {DeviceId}", deviceId);
// Refresh capabilities to keep session alive
// If this returns false (401), the token expired and client needs to re-auth
var success = await PostCapabilitiesAsync(headers);
if (!success)
{
// Token expired - remove the stale session
_logger.LogInformation("Token expired for device {DeviceId} - removing session", deviceId);
await RemoveSessionAsync(deviceId);
return false;
}
return true;
}
_logger.LogDebug("Creating new session for device: {DeviceId} ({Client} on {Device})", deviceId, client, device);
try
{
// Post session capabilities to Jellyfin - this creates the session
var success = await PostCapabilitiesAsync(headers);
if (!success)
{
// Token expired or invalid - client needs to re-authenticate
_logger.LogInformation("Failed to create session for {DeviceId} - token may be expired", deviceId);
return false;
}
_logger.LogDebug("Session created for {DeviceId}", deviceId);
// Track this session
_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, "Error creating session for {DeviceId}", deviceId);
return false;
}
}
/// <summary>
/// Posts session capabilities to Jellyfin.
/// Returns true if successful, false if token expired (401).
/// </summary>
private async Task<bool> PostCapabilitiesAsync(IHeaderDictionary headers)
{
var capabilities = new
{
PlayableMediaTypes = new[] { "Audio" },
SupportedCommands = new[]
{
"Play",
"Playstate",
"PlayNext"
},
SupportsMediaControl = true,
SupportsPersistentIdentifier = true,
SupportsSync = false
};
var json = JsonSerializer.Serialize(capabilities);
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Capabilities/Full", json, headers);
if (statusCode == 204 || statusCode == 200)
{
_logger.LogTrace("Posted capabilities successfully ({StatusCode})", statusCode);
return true;
}
else if (statusCode == 401)
{
// Token expired - this is expected, client needs to re-authenticate
_logger.LogDebug("Capabilities returned 401 (token expired) - client should re-authenticate");
return false;
}
else
{
_logger.LogDebug("Capabilities post returned {StatusCode}", statusCode);
return false;
}
}
/// <summary>
/// Updates session activity timestamp.
/// </summary>
public void UpdateActivity(string deviceId)
{
if (_sessions.TryGetValue(deviceId, out var session))
{
session.LastActivity = DateTime.UtcNow;
_logger.LogDebug("🔄 SESSION: Updated activity for {DeviceId}", deviceId);
}
else
{
_logger.LogDebug("⚠️ SESSION: Cannot update activity - device {DeviceId} not found", deviceId);
}
}
/// <summary>
/// Updates the currently playing item for a session (for scrobbling on cleanup).
/// </summary>
public void UpdatePlayingItem(string deviceId, string? itemId, long? positionTicks)
{
if (_sessions.TryGetValue(deviceId, out var session))
{
session.LastPlayingItemId = itemId;
session.LastPlayingPositionTicks = positionTicks;
session.LastActivity = DateTime.UtcNow;
_logger.LogDebug("🎵 SESSION: Updated playing item for {DeviceId}: {ItemId} at {Position}",
deviceId, itemId, positionTicks);
}
}
/// <summary>
/// Marks a session as potentially ended (e.g., after playback stops).
/// The session will be cleaned up if no new activity occurs within the timeout.
/// </summary>
public void MarkSessionPotentiallyEnded(string deviceId, TimeSpan timeout)
{
if (_sessions.TryGetValue(deviceId, out var session))
{
_logger.LogDebug("⏰ SESSION: Marking session {DeviceId} as potentially ended, will cleanup in {Seconds}s if no activity",
deviceId, timeout.TotalSeconds);
_ = Task.Run(async () =>
{
var markedTime = DateTime.UtcNow;
await Task.Delay(timeout);
// Check if there's been activity since we marked it
if (_sessions.TryGetValue(deviceId, out var currentSession) &&
currentSession.LastActivity <= markedTime)
{
_logger.LogDebug("🧹 SESSION: Auto-removing inactive session {DeviceId} after playback stop", deviceId);
await RemoveSessionAsync(deviceId);
}
else
{
_logger.LogDebug("✓ SESSION: Session {DeviceId} had activity, keeping alive", deviceId);
}
});
}
}
/// <summary>
/// Gets information about current active sessions for debugging.
/// </summary>
public object GetSessionsInfo()
{
var now = DateTime.UtcNow;
var sessions = _sessions.Values.Select(s => new
{
DeviceId = s.DeviceId,
Client = s.Client,
Device = s.Device,
Version = s.Version,
LastActivity = s.LastActivity,
InactiveMinutes = Math.Round((now - s.LastActivity).TotalMinutes, 1),
HasWebSocket = s.WebSocket != null,
WebSocketState = s.WebSocket?.State.ToString() ?? "None"
}).ToList();
return new
{
TotalSessions = sessions.Count,
ActiveSessions = sessions.Count(s => s.InactiveMinutes < 2),
StaleSessions = sessions.Count(s => s.InactiveMinutes >= 2),
Sessions = sessions.OrderBy(s => s.InactiveMinutes)
};
}
/// <summary>
/// Removes a session when the client disconnects.
/// </summary>
public async Task RemoveSessionAsync(string deviceId)
{
if (_sessions.TryRemove(deviceId, out var session))
{
_logger.LogDebug("🗑️ SESSION: Removing session for device {DeviceId}", deviceId);
// Close WebSocket if it exists
if (session.WebSocket != null && session.WebSocket.State == WebSocketState.Open)
{
try
{
await session.WebSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Session ended", CancellationToken.None);
_logger.LogDebug("🔌 WEBSOCKET: Closed WebSocket for device {DeviceId}", deviceId);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "WEBSOCKET: Error closing WebSocket for {DeviceId}", deviceId);
}
finally
{
session.WebSocket?.Dispose();
}
}
try
{
// Report playback stopped to Jellyfin if we have a playing item (for scrobbling)
if (!string.IsNullOrEmpty(session.LastPlayingItemId))
{
var stopPayload = new
{
ItemId = session.LastPlayingItemId,
PositionTicks = session.LastPlayingPositionTicks ?? 0
};
var stopJson = JsonSerializer.Serialize(stopPayload);
await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", stopJson, session.Headers);
_logger.LogDebug("🛑 SESSION: Reported playback stopped for {DeviceId} (ItemId: {ItemId}, Position: {Position})",
deviceId, session.LastPlayingItemId, session.LastPlayingPositionTicks);
}
// Notify Jellyfin that the session is ending
await _proxyService.PostJsonAsync("Sessions/Logout", "{}", session.Headers);
}
catch (Exception ex)
{
_logger.LogWarning("⚠️ SESSION: Error removing session for {DeviceId}: {Message}", deviceId, ex.Message);
}
}
}
/// <summary>
/// Maintains a WebSocket connection to Jellyfin on behalf of a client session.
/// This allows the session to appear in Jellyfin's dashboard.
/// </summary>
private async Task MaintainWebSocketForSessionAsync(string deviceId, IHeaderDictionary headers)
{
if (!_sessions.TryGetValue(deviceId, out var session))
{
_logger.LogDebug("⚠️ WEBSOCKET: Cannot create WebSocket - session {DeviceId} not found", deviceId);
return;
}
ClientWebSocket? webSocket = null;
try
{
// Build Jellyfin WebSocket URL
var jellyfinUrl = _settings.Url?.TrimEnd('/') ?? "";
var wsScheme = jellyfinUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ? "wss://" : "ws://";
var jellyfinHost = jellyfinUrl.Replace("https://", "").Replace("http://", "");
var jellyfinWsUrl = $"{wsScheme}{jellyfinHost}/socket";
// IMPORTANT: Do NOT add api_key to URL - we want to authenticate as the CLIENT, not the server
// The client's token is passed via X-Emby-Authorization header
// Using api_key would create a session for the server/admin, not the actual user's client
webSocket = new ClientWebSocket();
session.WebSocket = webSocket;
// Use stored session headers instead of parameter (parameter might be disposed)
var sessionHeaders = session.Headers;
// Log available headers for debugging
_logger.LogDebug("🔍 WEBSOCKET: Available headers for {DeviceId}: {Headers}",
deviceId, string.Join(", ", sessionHeaders.Keys));
// Forward authentication headers from the CLIENT - this is critical for session to appear under the right user
bool authFound = false;
if (sessionHeaders.TryGetValue("X-Emby-Authorization", out var embyAuth))
{
webSocket.Options.SetRequestHeader("X-Emby-Authorization", embyAuth.ToString());
_logger.LogDebug("🔑 WEBSOCKET: Using X-Emby-Authorization for {DeviceId}: {Auth}",
deviceId, embyAuth.ToString().Length > 50 ? embyAuth.ToString()[..50] + "..." : embyAuth.ToString());
authFound = true;
}
else if (sessionHeaders.TryGetValue("Authorization", out var auth))
{
var authValue = auth.ToString();
if (authValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase))
{
webSocket.Options.SetRequestHeader("X-Emby-Authorization", authValue);
_logger.LogDebug("🔑 WEBSOCKET: Converted Authorization to X-Emby-Authorization for {DeviceId}: {Auth}",
deviceId, authValue.Length > 50 ? authValue[..50] + "..." : authValue);
authFound = true;
}
else
{
webSocket.Options.SetRequestHeader("Authorization", authValue);
_logger.LogDebug("🔑 WEBSOCKET: Using Authorization for {DeviceId}: {Auth}",
deviceId, authValue.Length > 50 ? authValue[..50] + "..." : authValue);
authFound = true;
}
}
if (!authFound)
{
// No client auth found - fall back to server API key as last resort
if (!string.IsNullOrEmpty(_settings.ApiKey))
{
jellyfinWsUrl += $"?api_key={_settings.ApiKey}";
_logger.LogDebug("WEBSOCKET: No client auth found in headers, falling back to server API key for {DeviceId}", deviceId);
}
else
{
_logger.LogWarning("❌ WEBSOCKET: No authentication available for {DeviceId} - WebSocket will fail", deviceId);
}
}
_logger.LogDebug("🔗 WEBSOCKET: Connecting to Jellyfin for device {DeviceId}: {Url}", deviceId, jellyfinWsUrl);
// Set user agent
webSocket.Options.SetRequestHeader("User-Agent", $"Allstarr-Proxy/{session.Client}");
// Connect to Jellyfin
await webSocket.ConnectAsync(new Uri(jellyfinWsUrl), CancellationToken.None);
_logger.LogDebug("✓ WEBSOCKET: Connected to Jellyfin for device {DeviceId}", deviceId);
// CRITICAL: Send ForceKeepAlive message to initialize session in Jellyfin
// This tells Jellyfin to create/show the session in the dashboard
// Without this message, the WebSocket is connected but no session appears
var forceKeepAliveMessage = "{\"MessageType\":\"ForceKeepAlive\",\"Data\":100}";
var messageBytes = Encoding.UTF8.GetBytes(forceKeepAliveMessage);
await webSocket.SendAsync(new ArraySegment<byte>(messageBytes), WebSocketMessageType.Text, true, CancellationToken.None);
_logger.LogDebug("📤 WEBSOCKET: Sent ForceKeepAlive to initialize session for {DeviceId}", deviceId);
// Also send SessionsStart to subscribe to session updates
var sessionsStartMessage = "{\"MessageType\":\"SessionsStart\",\"Data\":\"0,1500\"}";
messageBytes = Encoding.UTF8.GetBytes(sessionsStartMessage);
await webSocket.SendAsync(new ArraySegment<byte>(messageBytes), WebSocketMessageType.Text, true, CancellationToken.None);
_logger.LogDebug("📤 WEBSOCKET: Sent SessionsStart for {DeviceId}", deviceId);
// Keep the WebSocket alive by reading messages and sending periodic keep-alive
var buffer = new byte[1024 * 4];
var lastKeepAlive = DateTime.UtcNow;
using var cts = new CancellationTokenSource();
while (webSocket.State == WebSocketState.Open && _sessions.ContainsKey(deviceId))
{
try
{
// Use a timeout so we can send keep-alive messages periodically
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(30));
try
{
var result = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), timeoutCts.Token);
if (result.MessageType == WebSocketMessageType.Close)
{
_logger.LogDebug("🔌 WEBSOCKET: Jellyfin closed WebSocket for device {DeviceId}", deviceId);
break;
}
// Log received messages for debugging (only non-routine messages)
if (result.MessageType == WebSocketMessageType.Text)
{
var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
// Respond to KeepAlive requests from Jellyfin
if (message.Contains("\"MessageType\":\"KeepAlive\""))
{
_logger.LogDebug("💓 WEBSOCKET: Received KeepAlive from Jellyfin for {DeviceId}", deviceId);
}
else if (message.Contains("\"MessageType\":\"Sessions\""))
{
// Session updates are routine, log at debug level
_logger.LogDebug("📥 WEBSOCKET: Session update for {DeviceId}", deviceId);
}
else
{
// Log other message types at trace level
_logger.LogTrace("📥 WEBSOCKET: {DeviceId}: {Message}",
deviceId, message.Length > 100 ? message[..100] + "..." : message);
}
}
}
catch (OperationCanceledException) when (!cts.IsCancellationRequested)
{
// Timeout - this is expected, send keep-alive if needed
}
// Send periodic keep-alive every 30 seconds
if (DateTime.UtcNow - lastKeepAlive > TimeSpan.FromSeconds(30))
{
var keepAliveMsg = "{\"MessageType\":\"KeepAlive\"}";
var keepAliveBytes = Encoding.UTF8.GetBytes(keepAliveMsg);
await webSocket.SendAsync(new ArraySegment<byte>(keepAliveBytes), WebSocketMessageType.Text, true, CancellationToken.None);
_logger.LogDebug("💓 WEBSOCKET: Sent KeepAlive for {DeviceId}", deviceId);
lastKeepAlive = DateTime.UtcNow;
}
}
catch (WebSocketException wsEx)
{
_logger.LogDebug(wsEx, "WEBSOCKET: Connection closed for device {DeviceId}", deviceId);
break;
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "❌ WEBSOCKET: Failed to maintain WebSocket for device {DeviceId}", deviceId);
}
finally
{
if (webSocket != null)
{
if (webSocket.State == WebSocketState.Open)
{
try
{
await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Session ended", CancellationToken.None);
}
catch { }
}
webSocket.Dispose();
_logger.LogDebug("🧹 WEBSOCKET: Cleaned up WebSocket for device {DeviceId}", deviceId);
}
// Clear WebSocket reference from session
if (_sessions.TryGetValue(deviceId, out var sess))
{
sess.WebSocket = null;
}
}
}
/// <summary>
/// Periodically pings Jellyfin to keep sessions alive.
/// Note: This is a backup mechanism. The WebSocket connection is the primary keep-alive.
/// Removes sessions with expired tokens (401 responses).
/// </summary>
private async void KeepSessionsAlive(object? state)
{
var now = DateTime.UtcNow;
var activeSessions = _sessions.Values.Where(s => now - s.LastActivity < TimeSpan.FromMinutes(5)).ToList();
if (activeSessions.Count == 0)
{
return;
}
_logger.LogTrace("Keeping {Count} sessions alive", activeSessions.Count);
var expiredSessions = new List<string>();
foreach (var session in activeSessions)
{
try
{
// Post capabilities again to keep session alive
// If this returns false (401), the token has expired
var success = await PostCapabilitiesAsync(session.Headers);
if (!success)
{
_logger.LogInformation("Token expired for device {DeviceId} during keep-alive - marking for removal", session.DeviceId);
expiredSessions.Add(session.DeviceId);
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error keeping session alive for {DeviceId}", session.DeviceId);
}
}
// Remove sessions with expired tokens
foreach (var deviceId in expiredSessions)
{
_logger.LogInformation("Removing session with expired token: {DeviceId}", deviceId);
await RemoveSessionAsync(deviceId);
}
// Clean up stale sessions after 3 minutes of inactivity
// This balances cleaning up finished sessions with allowing brief pauses/network issues
var staleSessions = _sessions.Where(kvp => now - kvp.Value.LastActivity > TimeSpan.FromMinutes(3)).ToList();
foreach (var stale in staleSessions)
{
_logger.LogDebug("Removing stale session for {DeviceId} (inactive for {Minutes:F1} minutes)",
stale.Key, (now - stale.Value.LastActivity).TotalMinutes);
await RemoveSessionAsync(stale.Key);
}
}
private static IHeaderDictionary CloneHeaders(IHeaderDictionary headers)
{
var cloned = new HeaderDictionary();
foreach (var header in headers)
{
cloned[header.Key] = header.Value;
}
return cloned;
}
private class SessionInfo
{
public required string DeviceId { get; init; }
public required string Client { get; init; }
public required string Device { get; init; }
public required string Version { get; init; }
public DateTime LastActivity { get; set; }
public required IHeaderDictionary Headers { get; init; }
public ClientWebSocket? WebSocket { get; set; }
public string? LastPlayingItemId { get; set; }
public long? LastPlayingPositionTicks { get; set; }
}
public 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,47 +25,8 @@ public class LrclibService
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)
{
// Validate input parameters
if (string.IsNullOrWhiteSpace(trackName) || artistNames == null || artistNames.Length == 0)
{
_logger.LogDebug("Invalid parameters for lyrics search: trackName={TrackName}, artistCount={ArtistCount}",
trackName, artistNames?.Length ?? 0);
return null;
}
var artistName = string.Join(", ", artistNames);
var cacheKey = $"lyrics:{artistName}:{trackName}:{albumName}:{durationSeconds}";
// FIRST: Check for manual lyrics mapping
var manualMappingKey = $"lyrics:manual-map:{artistName}:{trackName}";
var manualLyricsIdStr = await _cache.GetStringAsync(manualMappingKey);
if (!string.IsNullOrEmpty(manualLyricsIdStr) && int.TryParse(manualLyricsIdStr, out var manualLyricsId) && manualLyricsId > 0)
{
_logger.LogInformation("✓ Manual lyrics mapping found for {Artist} - {Track}: Lyrics ID {Id}",
artistName, trackName, manualLyricsId);
// Fetch lyrics by ID
var manualLyrics = await GetLyricsByIdAsync(manualLyricsId);
if (manualLyrics != null && !string.IsNullOrEmpty(manualLyrics.PlainLyrics))
{
// Cache the lyrics using the standard cache key
await _cache.SetAsync(cacheKey, manualLyrics.PlainLyrics!);
return manualLyrics;
}
else
{
_logger.LogWarning("Manual lyrics mapping points to invalid ID {Id} for {Artist} - {Track}",
manualLyricsId, artistName, trackName);
}
}
// SECOND: Check standard cache
var cached = await _cache.GetStringAsync(cacheKey);
if (!string.IsNullOrEmpty(cached))
{
@@ -81,15 +42,12 @@ public class LrclibService
try
{
// Try searching with all artists joined (space-separated for better matching)
var searchArtistName = string.Join(" ", artistNames);
// First try search API for fuzzy matching (more forgiving)
var searchUrl = $"{BaseUrl}/search?" +
$"track_name={Uri.EscapeDataString(trackName)}&" +
$"artist_name={Uri.EscapeDataString(searchArtistName)}";
$"artist_name={Uri.EscapeDataString(artistName)}";
_logger.LogInformation("Searching LRCLIB: {Url} (expecting {ArtistCount} artists)", searchUrl, artistNames.Length);
_logger.LogInformation("Searching LRCLIB: {Url}", searchUrl);
var searchResponse = await _httpClient.GetAsync(searchUrl);
@@ -108,29 +66,20 @@ public class LrclibService
{
// 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;
var artistScore = CalculateSimilarity(artistName, result.ArtistName ?? "");
// Duration match (within 5 seconds is good)
var durationDiff = result.Duration.HasValue ? Math.Abs(result.Duration.Value - durationSeconds) : 999;
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;
var syncedBonus = !string.IsNullOrEmpty(result.SyncedLyrics) ? 20.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;
// Weighted score: track name most important, then artist, then duration, plus synced bonus
var totalScore = (trackScore * 0.5) + (artistScore * 0.3) + (durationScore * 0.2) + 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));
_logger.LogDebug("Candidate: {Track} by {Artist} - Score: {Score:F1} (track:{TrackScore:F1}, artist:{ArtistScore:F1}, duration:{DurationScore:F1}, synced:{Synced})",
result.TrackName, result.ArtistName, totalScore, trackScore, artistScore, durationScore, !string.IsNullOrEmpty(result.SyncedLyrics));
if (totalScore > bestScore)
{
@@ -151,7 +100,7 @@ public class LrclibService
TrackName = bestMatch.TrackName ?? trackName,
ArtistName = bestMatch.ArtistName ?? artistName,
AlbumName = bestMatch.AlbumName ?? albumName,
Duration = bestMatch.Duration.HasValue ? (int)Math.Round(bestMatch.Duration.Value) : durationSeconds,
Duration = (int)Math.Round(bestMatch.Duration),
Instrumental = bestMatch.Instrumental,
PlainLyrics = bestMatch.PlainLyrics,
SyncedLyrics = bestMatch.SyncedLyrics
@@ -200,7 +149,7 @@ public class LrclibService
TrackName = lyrics.TrackName ?? trackName,
ArtistName = lyrics.ArtistName ?? artistName,
AlbumName = lyrics.AlbumName ?? albumName,
Duration = lyrics.Duration.HasValue ? (int)Math.Round(lyrics.Duration.Value) : durationSeconds,
Duration = (int)Math.Round(lyrics.Duration),
Instrumental = lyrics.Instrumental,
PlainLyrics = lyrics.PlainLyrics,
SyncedLyrics = lyrics.SyncedLyrics
@@ -224,69 +173,6 @@ 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))
@@ -342,7 +228,7 @@ public class LrclibService
TrackName = lyrics.TrackName ?? trackName,
ArtistName = lyrics.ArtistName ?? artistName,
AlbumName = lyrics.AlbumName ?? albumName,
Duration = lyrics.Duration.HasValue ? (int)Math.Round(lyrics.Duration.Value) : durationSeconds,
Duration = (int)Math.Round(lyrics.Duration),
Instrumental = lyrics.Instrumental,
PlainLyrics = lyrics.PlainLyrics,
SyncedLyrics = lyrics.SyncedLyrics
@@ -398,7 +284,7 @@ public class LrclibService
TrackName = lyrics.TrackName ?? string.Empty,
ArtistName = lyrics.ArtistName ?? string.Empty,
AlbumName = lyrics.AlbumName ?? string.Empty,
Duration = lyrics.Duration.HasValue ? (int)Math.Round(lyrics.Duration.Value) : 0,
Duration = (int)Math.Round(lyrics.Duration),
Instrumental = lyrics.Instrumental,
PlainLyrics = lyrics.PlainLyrics,
SyncedLyrics = lyrics.SyncedLyrics
@@ -427,7 +313,7 @@ public class LrclibService
public string? TrackName { get; set; }
public string? ArtistName { get; set; }
public string? AlbumName { get; set; }
public double? Duration { get; set; }
public double Duration { get; set; }
public bool Instrumental { get; set; }
public string? PlainLyrics { get; set; }
public string? SyncedLyrics { get; set; }

View File

@@ -1,535 +0,0 @@
using System.Text.Json;
using allstarr.Models.Lyrics;
using allstarr.Models.Settings;
using allstarr.Services.Common;
using allstarr.Services.Jellyfin;
using allstarr.Services.Spotify;
using Microsoft.Extensions.Options;
namespace allstarr.Services.Lyrics;
/// <summary>
/// Background service that prefetches lyrics for all tracks in injected Spotify playlists.
/// Lyrics are cached in Redis and persisted to disk for fast loading on startup.
/// </summary>
public class LyricsPrefetchService : BackgroundService
{
private readonly SpotifyImportSettings _spotifySettings;
private readonly LrclibService _lrclibService;
private readonly SpotifyPlaylistFetcher _playlistFetcher;
private readonly RedisCacheService _cache;
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<LyricsPrefetchService> _logger;
private readonly string _lyricsCacheDir = "/app/cache/lyrics";
private const int DelayBetweenRequestsMs = 500; // 500ms = 2 requests/second to be respectful
public LyricsPrefetchService(
IOptions<SpotifyImportSettings> spotifySettings,
LrclibService lrclibService,
SpotifyPlaylistFetcher playlistFetcher,
RedisCacheService cache,
IServiceProvider serviceProvider,
ILogger<LyricsPrefetchService> logger)
{
_spotifySettings = spotifySettings.Value;
_lrclibService = lrclibService;
_playlistFetcher = playlistFetcher;
_cache = cache;
_serviceProvider = serviceProvider;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("LyricsPrefetchService: Starting up...");
if (!_spotifySettings.Enabled)
{
_logger.LogInformation("Spotify playlist injection is DISABLED, lyrics prefetch will not run");
return;
}
// Ensure cache directory exists
Directory.CreateDirectory(_lyricsCacheDir);
// Wait for playlist fetcher to initialize
await Task.Delay(TimeSpan.FromMinutes(3), stoppingToken);
// Run initial prefetch
try
{
_logger.LogInformation("Running initial lyrics prefetch on startup");
await PrefetchAllPlaylistLyricsAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during startup lyrics prefetch");
}
// Run periodic prefetch (daily)
while (!stoppingToken.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromHours(24), stoppingToken);
try
{
await PrefetchAllPlaylistLyricsAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in lyrics prefetch service");
}
}
}
private async Task PrefetchAllPlaylistLyricsAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("🎵 Starting lyrics prefetch for {Count} playlists", _spotifySettings.Playlists.Count);
var totalFetched = 0;
var totalCached = 0;
var totalMissing = 0;
foreach (var playlist in _spotifySettings.Playlists)
{
if (cancellationToken.IsCancellationRequested) break;
try
{
var (fetched, cached, missing) = await PrefetchPlaylistLyricsAsync(playlist.Name, cancellationToken);
totalFetched += fetched;
totalCached += cached;
totalMissing += missing;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error prefetching lyrics for playlist {Playlist}", playlist.Name);
}
}
_logger.LogInformation("✅ Lyrics prefetch complete: {Fetched} fetched, {Cached} already cached, {Missing} not found",
totalFetched, totalCached, totalMissing);
}
public async Task<(int Fetched, int Cached, int Missing)> PrefetchPlaylistLyricsAsync(
string playlistName,
CancellationToken cancellationToken)
{
_logger.LogInformation("Prefetching lyrics for playlist: {Playlist}", playlistName);
var tracks = await _playlistFetcher.GetPlaylistTracksAsync(playlistName);
if (tracks.Count == 0)
{
_logger.LogWarning("No tracks found for playlist {Playlist}", playlistName);
return (0, 0, 0);
}
// Get the pre-built playlist items cache which includes Jellyfin item IDs for local tracks
var playlistItemsKey = $"spotify:playlist:items:{playlistName}";
var playlistItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(playlistItemsKey);
// Build a map of Spotify ID -> Jellyfin Item ID for quick lookup
var spotifyToJellyfinId = new Dictionary<string, string>();
if (playlistItems != null)
{
foreach (var item in playlistItems)
{
// Check if this is a local Jellyfin track (has Id field, no ProviderIds for external)
if (item.TryGetValue("Id", out var idObj) && idObj != null)
{
var jellyfinId = idObj.ToString();
// Try to get Spotify provider ID
if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null)
{
var providerIdsJson = JsonSerializer.Serialize(providerIdsObj);
using var doc = JsonDocument.Parse(providerIdsJson);
if (doc.RootElement.TryGetProperty("Spotify", out var spotifyIdEl))
{
var spotifyId = spotifyIdEl.GetString();
if (!string.IsNullOrEmpty(spotifyId) && !string.IsNullOrEmpty(jellyfinId))
{
spotifyToJellyfinId[spotifyId] = jellyfinId;
}
}
}
}
}
_logger.LogDebug("Found {Count} local Jellyfin tracks with Spotify IDs in playlist {Playlist}",
spotifyToJellyfinId.Count, playlistName);
}
var fetched = 0;
var cached = 0;
var missing = 0;
foreach (var track in tracks)
{
if (cancellationToken.IsCancellationRequested) break;
try
{
// Check if lyrics are already cached
// Use same cache key format as LrclibService: join all artists with ", "
var artistName = string.Join(", ", track.Artists);
var cacheKey = $"lyrics:{artistName}:{track.Title}:{track.Album}:{track.DurationMs / 1000}";
var existingLyrics = await _cache.GetStringAsync(cacheKey);
if (!string.IsNullOrEmpty(existingLyrics))
{
cached++;
_logger.LogDebug("✓ Lyrics already cached for {Artist} - {Track}", track.PrimaryArtist, track.Title);
continue;
}
// Priority 1: Check if this track has local Jellyfin lyrics (embedded in file)
// Use the Jellyfin item ID from the playlist cache if available
if (spotifyToJellyfinId.TryGetValue(track.SpotifyId, out var jellyfinItemId))
{
var hasLocalLyrics = await CheckForLocalJellyfinLyricsByIdAsync(jellyfinItemId, track.PrimaryArtist, track.Title);
if (hasLocalLyrics)
{
cached++;
_logger.LogInformation("✓ Local Jellyfin lyrics found for {Artist} - {Track}, skipping external fetch",
track.PrimaryArtist, track.Title);
// Remove any previously cached LRCLib lyrics for this track
var artistNameForRemoval = string.Join(", ", track.Artists);
await RemoveCachedLyricsAsync(artistNameForRemoval, track.Title, track.Album, track.DurationMs / 1000);
continue;
}
}
// Priority 2: Try Spotify lyrics if we have a Spotify ID
LyricsInfo? lyrics = null;
if (!string.IsNullOrEmpty(track.SpotifyId))
{
lyrics = await TryGetSpotifyLyricsAsync(track.SpotifyId, track.Title, track.PrimaryArtist);
}
// Priority 3: Fall back to LRCLib if no Spotify lyrics
if (lyrics == null)
{
lyrics = await _lrclibService.GetLyricsAsync(
track.Title,
track.Artists.ToArray(),
track.Album,
track.DurationMs / 1000);
}
if (lyrics != null)
{
fetched++;
_logger.LogInformation("✓ Fetched lyrics for {Artist} - {Track} (synced: {HasSynced})",
track.PrimaryArtist, track.Title, !string.IsNullOrEmpty(lyrics.SyncedLyrics));
// Save to file cache
var artistNameForSave = string.Join(", ", track.Artists);
await SaveLyricsToFileAsync(artistNameForSave, track.Title, track.Album, track.DurationMs / 1000, lyrics);
}
else
{
missing++;
_logger.LogDebug("✗ No lyrics found for {Artist} - {Track}", track.PrimaryArtist, track.Title);
}
// Rate limiting
await Task.Delay(DelayBetweenRequestsMs, cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to prefetch lyrics for {Artist} - {Track}", track.PrimaryArtist, track.Title);
missing++;
}
}
_logger.LogInformation("Playlist {Playlist}: {Fetched} fetched, {Cached} cached, {Missing} missing",
playlistName, fetched, cached, missing);
return (fetched, cached, missing);
}
private async Task SaveLyricsToFileAsync(string artist, string title, string album, int duration, LyricsInfo lyrics)
{
try
{
var fileName = $"{SanitizeFileName(artist)}_{SanitizeFileName(title)}_{duration}.json";
var filePath = Path.Combine(_lyricsCacheDir, fileName);
var json = JsonSerializer.Serialize(lyrics, new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(filePath, json);
_logger.LogDebug("💾 Saved lyrics to file: {FileName}", fileName);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to save lyrics to file for {Artist} - {Track}", artist, title);
}
}
/// <summary>
/// Loads lyrics from file cache into Redis on startup
/// </summary>
public async Task WarmCacheFromFilesAsync()
{
try
{
if (!Directory.Exists(_lyricsCacheDir))
{
_logger.LogInformation("Lyrics cache directory does not exist, skipping cache warming");
return;
}
var files = Directory.GetFiles(_lyricsCacheDir, "*.json");
if (files.Length == 0)
{
_logger.LogInformation("No lyrics cache files found");
return;
}
_logger.LogInformation("🔥 Warming lyrics cache from {Count} files...", files.Length);
var loaded = 0;
foreach (var file in files)
{
try
{
var json = await File.ReadAllTextAsync(file);
var lyrics = JsonSerializer.Deserialize<LyricsInfo>(json);
if (lyrics != null)
{
var cacheKey = $"lyrics:{lyrics.ArtistName}:{lyrics.TrackName}:{lyrics.AlbumName}:{lyrics.Duration}";
await _cache.SetStringAsync(cacheKey, json, TimeSpan.FromDays(30));
loaded++;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load lyrics from file {File}", Path.GetFileName(file));
}
}
_logger.LogInformation("✅ Warmed {Count} lyrics from file cache", loaded);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error warming lyrics cache from files");
}
}
private static string SanitizeFileName(string fileName)
{
var invalid = Path.GetInvalidFileNameChars();
return string.Join("_", fileName.Split(invalid, StringSplitOptions.RemoveEmptyEntries))
.Replace(" ", "_")
.ToLowerInvariant();
}
/// <summary>
/// Removes cached LRCLib lyrics from both Redis and file cache.
/// Used when a track has local Jellyfin lyrics, making the LRCLib cache obsolete.
/// </summary>
private async Task RemoveCachedLyricsAsync(string artist, string title, string album, int duration)
{
try
{
// Remove from Redis cache
var cacheKey = $"lyrics:{artist}:{title}:{album}:{duration}";
await _cache.DeleteAsync(cacheKey);
// Remove from file cache
var fileName = $"{SanitizeFileName(artist)}_{SanitizeFileName(title)}_{duration}.json";
var filePath = Path.Combine(_lyricsCacheDir, fileName);
if (File.Exists(filePath))
{
File.Delete(filePath);
_logger.LogDebug("🗑️ Removed cached LRCLib lyrics file: {FileName}", fileName);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to remove cached lyrics for {Artist} - {Track}", artist, title);
}
}
/// <summary>
/// Tries to get lyrics from Spotify using the track's Spotify ID.
/// Returns null if Spotify API is not enabled or lyrics not found.
/// </summary>
private async Task<LyricsInfo?> TryGetSpotifyLyricsAsync(string spotifyTrackId, string trackTitle, string artistName)
{
try
{
using var scope = _serviceProvider.CreateScope();
var spotifyLyricsService = scope.ServiceProvider.GetService<SpotifyLyricsService>();
if (spotifyLyricsService == null)
{
return null;
}
var spotifyLyrics = await spotifyLyricsService.GetLyricsByTrackIdAsync(spotifyTrackId);
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
{
_logger.LogInformation("✓ Found Spotify lyrics for {Artist} - {Track} ({LineCount} lines)",
artistName, trackTitle, spotifyLyrics.Lines.Count);
return spotifyLyricsService.ToLyricsInfo(spotifyLyrics);
}
return null;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error fetching Spotify lyrics for track {SpotifyId}", spotifyTrackId);
return null;
}
}
/// <summary>
/// Checks if a track has embedded lyrics in Jellyfin using the Jellyfin item ID.
/// This is the most efficient method as it directly queries the lyrics endpoint.
/// </summary>
private async Task<bool> CheckForLocalJellyfinLyricsByIdAsync(string jellyfinItemId, string artistName, string trackTitle)
{
try
{
using var scope = _serviceProvider.CreateScope();
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
if (proxyService == null)
{
return false;
}
// Directly check if this track has lyrics using the item ID
// Use internal method with server API key since this is a background operation
var (lyricsResult, lyricsStatusCode) = await proxyService.GetJsonAsyncInternal(
$"Audio/{jellyfinItemId}/Lyrics",
null);
if (lyricsResult != null && lyricsStatusCode == 200)
{
// Track has embedded lyrics in Jellyfin
_logger.LogDebug("Found embedded lyrics in Jellyfin for {Artist} - {Track} (ID: {JellyfinId})",
artistName, trackTitle, jellyfinItemId);
return true;
}
return false;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error checking Jellyfin lyrics for item {ItemId}", jellyfinItemId);
return false;
}
}
/// <summary>
/// Checks if a track has embedded lyrics in Jellyfin by querying the Jellyfin API.
/// This prevents downloading lyrics from LRCLib when the local file already has them.
/// </summary>
private async Task<bool> CheckForLocalJellyfinLyricsAsync(string spotifyTrackId, string artistName, string trackTitle)
{
try
{
using var scope = _serviceProvider.CreateScope();
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
if (proxyService == null)
{
return false;
}
// Search for the track in Jellyfin by artist and title
// Jellyfin doesn't support anyProviderIdEquals - that's an Emby API parameter
var searchTerm = $"{artistName} {trackTitle}";
var searchParams = new Dictionary<string, string>
{
["searchTerm"] = searchTerm,
["includeItemTypes"] = "Audio",
["recursive"] = "true",
["limit"] = "5" // Get a few results to find best match
};
var (searchResult, statusCode) = await proxyService.GetJsonAsyncInternal("Items", searchParams);
if (searchResult == null || statusCode != 200)
{
// Track not found in local library
return false;
}
// Check if we found any items
if (!searchResult.RootElement.TryGetProperty("Items", out var items) ||
items.GetArrayLength() == 0)
{
return false;
}
// Find the best matching track by comparing artist and title
string? bestMatchId = null;
foreach (var item in items.EnumerateArray())
{
if (!item.TryGetProperty("Name", out var nameEl) ||
!item.TryGetProperty("Id", out var idEl))
{
continue;
}
var itemTitle = nameEl.GetString() ?? "";
var itemId = idEl.GetString();
// Check if title matches (case-insensitive)
if (itemTitle.Equals(trackTitle, StringComparison.OrdinalIgnoreCase))
{
// Also check artist if available
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
{
var itemArtist = artistsEl[0].GetString() ?? "";
if (itemArtist.Equals(artistName, StringComparison.OrdinalIgnoreCase))
{
bestMatchId = itemId;
break; // Exact match found
}
}
// If no exact artist match but title matches, use it as fallback
if (bestMatchId == null)
{
bestMatchId = itemId;
}
}
}
if (string.IsNullOrEmpty(bestMatchId))
{
return false;
}
// Check if this track has lyrics
// Use internal method with server API key since this is a background operation
var (lyricsResult, lyricsStatusCode) = await proxyService.GetJsonAsyncInternal(
$"Audio/{bestMatchId}/Lyrics",
null);
if (lyricsResult != null && lyricsStatusCode == 200)
{
// Track has embedded lyrics in Jellyfin
_logger.LogDebug("Found embedded lyrics in Jellyfin for {Artist} - {Track} (Jellyfin ID: {JellyfinId})",
artistName, trackTitle, bestMatchId);
return true;
}
return false;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error checking for local Jellyfin lyrics for Spotify track {SpotifyId}", spotifyTrackId);
return false;
}
}
}

View File

@@ -1,189 +0,0 @@
using System.Text.Json;
using Microsoft.Extensions.Options;
using allstarr.Models.Settings;
using allstarr.Services.Validation;
namespace allstarr.Services.Lyrics;
/// <summary>
/// Validates lyrics services (LRCLib, Spotify Lyrics Sidecar, Spotify API) at startup
/// Tests with "22" by Taylor Swift (Spotify ID: 3yII7UwgLF6K5zW3xad3MP)
/// </summary>
public class LyricsStartupValidator : BaseStartupValidator
{
private readonly SpotifyApiSettings _spotifySettings;
// Test song: "22" by Taylor Swift
private const string TestSongTitle = "22";
private const string TestArtist = "Taylor Swift";
private const string TestAlbum = "Red";
private const int TestDuration = 232; // seconds
private const string TestSpotifyId = "3yII7UwgLF6K5zW3xad3MP";
public override string ServiceName => "Lyrics Services";
public LyricsStartupValidator(
IOptions<SpotifyApiSettings> spotifySettings,
IHttpClientFactory httpClientFactory)
: base(httpClientFactory.CreateClient())
{
_spotifySettings = spotifySettings.Value;
_httpClient.Timeout = TimeSpan.FromSeconds(10);
}
public override async Task<ValidationResult> ValidateAsync(CancellationToken cancellationToken)
{
Console.WriteLine();
WriteStatus("Lyrics Test Song", $"{TestSongTitle} by {TestArtist}", ConsoleColor.Cyan);
WriteDetail($"Spotify ID: {TestSpotifyId}");
var allSuccess = true;
// Test 1: LRCLib
allSuccess &= await TestLrclibAsync(cancellationToken);
// Test 2: Spotify Lyrics Sidecar
allSuccess &= await TestSpotifyLyricsSidecarAsync(cancellationToken);
// Test 3: Spotify API (if enabled)
if (_spotifySettings.Enabled)
{
allSuccess &= await TestSpotifyApiAsync(cancellationToken);
}
else
{
WriteStatus("Spotify API", "DISABLED", ConsoleColor.Yellow);
WriteDetail("Enable SpotifyApi__Enabled to test Spotify API lyrics");
}
return allSuccess
? ValidationResult.Success("Lyrics services validation completed")
: ValidationResult.Failure("PARTIAL", "Some lyrics services had issues", ConsoleColor.Yellow);
}
private async Task<bool> TestLrclibAsync(CancellationToken cancellationToken)
{
try
{
var url = $"https://lrclib.net/api/get?artist_name={Uri.EscapeDataString(TestArtist)}&track_name={Uri.EscapeDataString(TestSongTitle)}&album_name={Uri.EscapeDataString(TestAlbum)}&duration={TestDuration}";
var response = await _httpClient.GetAsync(url, cancellationToken);
if (response.IsSuccessStatusCode)
{
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var doc = JsonDocument.Parse(json);
var hasSyncedLyrics = doc.RootElement.TryGetProperty("syncedLyrics", out var synced) &&
!string.IsNullOrEmpty(synced.GetString());
var hasPlainLyrics = doc.RootElement.TryGetProperty("plainLyrics", out var plain) &&
!string.IsNullOrEmpty(plain.GetString());
WriteStatus("LRCLib", "WORKING", ConsoleColor.Green);
WriteDetail($"✓ Synced: {hasSyncedLyrics}, Plain: {hasPlainLyrics}");
return true;
}
else if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
WriteStatus("LRCLib", "NO LYRICS FOUND", ConsoleColor.Yellow);
WriteDetail("Service is working but no lyrics available for test song");
return true; // Service is working, just no lyrics
}
else
{
WriteStatus("LRCLib", $"HTTP {(int)response.StatusCode}", ConsoleColor.Red);
return false;
}
}
catch (Exception ex)
{
WriteStatus("LRCLib", "ERROR", ConsoleColor.Red);
WriteDetail($"Failed to connect: {ex.Message}");
return false;
}
}
private async Task<bool> TestSpotifyLyricsSidecarAsync(CancellationToken cancellationToken)
{
try
{
if (string.IsNullOrEmpty(_spotifySettings.LyricsApiUrl))
{
WriteStatus("Spotify Lyrics Sidecar", "NOT CONFIGURED", ConsoleColor.Yellow);
WriteDetail("Set SpotifyApi__LyricsApiUrl to enable");
return true; // Not an error, just not configured
}
var url = $"{_spotifySettings.LyricsApiUrl}/?trackid={TestSpotifyId}&format=id3";
var response = await _httpClient.GetAsync(url, cancellationToken);
if (response.IsSuccessStatusCode)
{
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var doc = JsonDocument.Parse(json);
var hasError = doc.RootElement.TryGetProperty("error", out var error) && error.GetBoolean();
if (hasError)
{
var message = doc.RootElement.TryGetProperty("message", out var msg)
? msg.GetString()
: "Unknown error";
WriteStatus("Spotify Lyrics Sidecar", "API ERROR", ConsoleColor.Yellow);
WriteDetail($"⚠ {message}");
WriteDetail("Check if sp_dc cookie is valid");
return false;
}
var syncType = doc.RootElement.TryGetProperty("syncType", out var st)
? st.GetString()
: "UNKNOWN";
var lineCount = doc.RootElement.TryGetProperty("lines", out var lines)
? lines.GetArrayLength()
: 0;
WriteStatus("Spotify Lyrics Sidecar", "WORKING", ConsoleColor.Green);
WriteDetail($"✓ Type: {syncType}, Lines: {lineCount}");
return true;
}
else
{
WriteStatus("Spotify Lyrics Sidecar", $"HTTP {(int)response.StatusCode}", ConsoleColor.Red);
WriteDetail("Check if spotify-lyrics container is running");
return false;
}
}
catch (Exception ex)
{
WriteStatus("Spotify Lyrics Sidecar", "ERROR", ConsoleColor.Red);
WriteDetail($"Failed to connect: {ex.Message}");
WriteDetail("Ensure spotify-lyrics container is running in docker-compose");
return false;
}
}
private async Task<bool> TestSpotifyApiAsync(CancellationToken cancellationToken)
{
try
{
if (string.IsNullOrEmpty(_spotifySettings.ClientId))
{
WriteStatus("Spotify API", "NOT CONFIGURED", ConsoleColor.Yellow);
WriteDetail("Set SpotifyApi__ClientId to enable");
return true;
}
WriteStatus("Spotify API", "CONFIGURED", ConsoleColor.Green);
WriteDetail($"Client ID: {_spotifySettings.ClientId.Substring(0, Math.Min(8, _spotifySettings.ClientId.Length))}...");
WriteDetail("Note: Spotify API is used for track matching, not lyrics");
return true;
}
catch (Exception ex)
{
WriteStatus("Spotify API", "ERROR", ConsoleColor.Red);
WriteDetail($"Validation failed: {ex.Message}");
return false;
}
}
}

View File

@@ -1,321 +0,0 @@
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 RedisCacheService _cache;
private readonly HttpClient _httpClient;
public SpotifyLyricsService(
ILogger<SpotifyLyricsService> logger,
IOptions<SpotifyApiSettings> settings,
RedisCacheService cache,
IHttpClientFactory httpClientFactory)
{
_logger = logger;
_settings = settings.Value;
_cache = cache;
_httpClient = httpClientFactory.CreateClient();
_httpClient.Timeout = TimeSpan.FromSeconds(10);
}
/// <summary>
/// Gets synchronized lyrics for a Spotify track by its ID using the sidecar API.
/// </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;
}
if (string.IsNullOrEmpty(_settings.LyricsApiUrl))
{
_logger.LogWarning("Spotify lyrics API URL not configured");
return null;
}
// Normalize track ID (remove URI prefix if present)
spotifyTrackId = ExtractTrackId(spotifyTrackId);
// NO CACHING - Spotify lyrics come from local Docker container (fast)
try
{
var url = $"{_settings.LyricsApiUrl}/?trackid={spotifyTrackId}&format=id3";
_logger.LogDebug("Fetching lyrics from sidecar API: {Url}", url);
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
_logger.LogDebug("Sidecar API returned {StatusCode} for track {TrackId}",
response.StatusCode, spotifyTrackId);
return null;
}
var json = await response.Content.ReadAsStringAsync();
var result = ParseSidecarResponse(json, spotifyTrackId);
if (result != null)
{
_logger.LogInformation("Got Spotify lyrics from sidecar for track {TrackId} ({LineCount} lines)",
spotifyTrackId, result.Lines.Count);
}
return result;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error fetching lyrics from sidecar API for track {TrackId}", spotifyTrackId);
return null;
}
}
/// <summary>
/// Searches for a track on Spotify and returns its lyrics using the sidecar API.
/// Useful when you have track metadata but not a Spotify ID.
/// Note: This requires the sidecar to handle search, or we skip it.
/// </summary>
public async Task<SpotifyLyricsResult?> SearchAndGetLyricsAsync(
string trackName,
string artistName,
string? albumName = null,
int? durationMs = null)
{
if (!_settings.Enabled || string.IsNullOrEmpty(_settings.SessionCookie))
{
_logger.LogDebug("Spotify lyrics search skipped: API not enabled or no session cookie");
return null;
}
// The sidecar API only supports track ID, not search
// So we skip Spotify lyrics for search-based requests
// LRCLib will be used as fallback
_logger.LogDebug("Spotify lyrics search by metadata not supported with sidecar API, skipping");
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))
};
}
/// <summary>
/// Parses the response from the sidecar spotify-lyrics-api service.
/// Format: {"error": false, "syncType": "LINE_SYNCED", "lines": [...]}
/// </summary>
private SpotifyLyricsResult? ParseSidecarResponse(string json, string trackId)
{
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
// Check for error
if (root.TryGetProperty("error", out var error) && error.GetBoolean())
{
_logger.LogDebug("Sidecar API returned error for track {TrackId}", trackId);
return null;
}
var result = new SpotifyLyricsResult
{
SpotifyTrackId = trackId
};
// Get sync type
if (root.TryGetProperty("syncType", out var syncType))
{
result.SyncType = syncType.GetString() ?? "LINE_SYNCED";
}
// Parse lines
if (root.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
};
result.Lines.Add(lyricsLine);
}
}
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error parsing sidecar API response");
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

@@ -1,342 +0,0 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using allstarr.Models.Domain;
using allstarr.Models.Settings;
using Microsoft.Extensions.Options;
namespace allstarr.Services.MusicBrainz;
/// <summary>
/// Service for querying MusicBrainz API for metadata enrichment.
/// </summary>
public class MusicBrainzService
{
private readonly HttpClient _httpClient;
private readonly MusicBrainzSettings _settings;
private readonly ILogger<MusicBrainzService> _logger;
private DateTime _lastRequestTime = DateTime.MinValue;
private readonly SemaphoreSlim _rateLimitSemaphore = new(1, 1);
public MusicBrainzService(
IHttpClientFactory httpClientFactory,
IOptions<MusicBrainzSettings> settings,
ILogger<MusicBrainzService> logger)
{
_httpClient = httpClientFactory.CreateClient();
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0.0 (https://github.com/SoPat712/allstarr)");
_httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
_settings = settings.Value;
_logger = logger;
// Set up digest authentication if credentials provided
if (!string.IsNullOrEmpty(_settings.Username) && !string.IsNullOrEmpty(_settings.Password))
{
var credentials = Convert.ToBase64String(
Encoding.ASCII.GetBytes($"{_settings.Username}:{_settings.Password}"));
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Basic", credentials);
}
}
/// <summary>
/// Looks up a recording by ISRC code.
/// </summary>
public async Task<MusicBrainzRecording?> LookupByIsrcAsync(string isrc)
{
if (!_settings.Enabled)
{
return null;
}
await RateLimitAsync();
try
{
var url = $"{_settings.BaseUrl}/isrc/{isrc}?fmt=json&inc=artists+releases+release-groups+genres+tags";
_logger.LogDebug("MusicBrainz ISRC lookup: {Url}", url);
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("MusicBrainz ISRC lookup failed: {StatusCode}", response.StatusCode);
return null;
}
var json = await response.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<MusicBrainzIsrcResponse>(json, JsonOptions);
if (result?.Recordings == null || result.Recordings.Count == 0)
{
_logger.LogDebug("No MusicBrainz recordings found for ISRC: {Isrc}", isrc);
return null;
}
// Return the first recording (ISRCs should be unique)
var recording = result.Recordings[0];
var genres = recording.Genres?.Select(g => g.Name).Where(n => !string.IsNullOrEmpty(n)).ToList() ?? new List<string?>();
_logger.LogInformation("✓ Found MusicBrainz recording for ISRC {Isrc}: {Title} by {Artist} (Genres: {Genres})",
isrc, recording.Title, recording.ArtistCredit?[0]?.Name ?? "Unknown", string.Join(", ", genres));
return recording;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error looking up ISRC {Isrc} in MusicBrainz", isrc);
return null;
}
}
/// <summary>
/// Searches for recordings by title and artist.
/// </summary>
public async Task<List<MusicBrainzRecording>> SearchRecordingsAsync(string title, string artist, int limit = 5)
{
if (!_settings.Enabled)
{
return new List<MusicBrainzRecording>();
}
await RateLimitAsync();
try
{
// Build Lucene query
var query = $"recording:\"{title}\" AND artist:\"{artist}\"";
var encodedQuery = Uri.EscapeDataString(query);
var url = $"{_settings.BaseUrl}/recording?query={encodedQuery}&fmt=json&limit={limit}&inc=genres+tags";
_logger.LogDebug("MusicBrainz search: {Url}", url);
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("MusicBrainz search failed: {StatusCode}", response.StatusCode);
return new List<MusicBrainzRecording>();
}
var json = await response.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<MusicBrainzSearchResponse>(json, JsonOptions);
if (result?.Recordings == null || result.Recordings.Count == 0)
{
_logger.LogDebug("No MusicBrainz recordings found for: {Title} - {Artist}", title, artist);
return new List<MusicBrainzRecording>();
}
_logger.LogInformation("Found {Count} MusicBrainz recordings for: {Title} - {Artist}",
result.Recordings.Count, title, artist);
return result.Recordings;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error searching MusicBrainz for: {Title} - {Artist}", title, artist);
return new List<MusicBrainzRecording>();
}
}
/// <summary>
/// Enriches a song with genre information from MusicBrainz.
/// First tries ISRC lookup, then falls back to title/artist search.
/// </summary>
public async Task<List<string>> GetGenresForSongAsync(string title, string artist, string? isrc = null)
{
if (!_settings.Enabled)
{
return new List<string>();
}
MusicBrainzRecording? recording = null;
// Try ISRC lookup first (most accurate)
if (!string.IsNullOrEmpty(isrc))
{
recording = await LookupByIsrcAsync(isrc);
}
// Fall back to search if ISRC lookup failed or no ISRC provided
if (recording == null)
{
var recordings = await SearchRecordingsAsync(title, artist, limit: 1);
recording = recordings.FirstOrDefault();
}
if (recording == null)
{
return new List<string>();
}
// Extract genres (prioritize official genres over tags)
var genres = new List<string>();
if (recording.Genres != null && recording.Genres.Count > 0)
{
// Get top genres by vote count
genres.AddRange(recording.Genres
.OrderByDescending(g => g.Count)
.Take(5)
.Select(g => g.Name)
.Where(n => !string.IsNullOrEmpty(n))
.Select(n => n!)
.ToList());
}
_logger.LogInformation("Found {Count} genres for {Title} - {Artist}: {Genres}",
genres.Count, title, artist, string.Join(", ", genres));
return genres;
}
/// <summary>
/// Rate limiting to comply with MusicBrainz API rules (1 request per second).
/// </summary>
private async Task RateLimitAsync()
{
await _rateLimitSemaphore.WaitAsync();
try
{
var timeSinceLastRequest = DateTime.UtcNow - _lastRequestTime;
var minInterval = TimeSpan.FromMilliseconds(_settings.RateLimitMs);
if (timeSinceLastRequest < minInterval)
{
var delay = minInterval - timeSinceLastRequest;
await Task.Delay(delay);
}
_lastRequestTime = DateTime.UtcNow;
}
finally
{
_rateLimitSemaphore.Release();
}
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.KebabCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
}
/// <summary>
/// MusicBrainz ISRC lookup response.
/// </summary>
public class MusicBrainzIsrcResponse
{
[JsonPropertyName("recordings")]
public List<MusicBrainzRecording>? Recordings { get; set; }
}
/// <summary>
/// MusicBrainz search response.
/// </summary>
public class MusicBrainzSearchResponse
{
[JsonPropertyName("recordings")]
public List<MusicBrainzRecording>? Recordings { get; set; }
[JsonPropertyName("count")]
public int Count { get; set; }
}
/// <summary>
/// MusicBrainz recording.
/// </summary>
public class MusicBrainzRecording
{
[JsonPropertyName("id")]
public string? Id { get; set; }
[JsonPropertyName("title")]
public string? Title { get; set; }
[JsonPropertyName("length")]
public int? Length { get; set; } // in milliseconds
[JsonPropertyName("artist-credit")]
public List<MusicBrainzArtistCredit>? ArtistCredit { get; set; }
[JsonPropertyName("releases")]
public List<MusicBrainzRelease>? Releases { get; set; }
[JsonPropertyName("isrcs")]
public List<string>? Isrcs { get; set; }
[JsonPropertyName("genres")]
public List<MusicBrainzGenre>? Genres { get; set; }
[JsonPropertyName("tags")]
public List<MusicBrainzTag>? Tags { get; set; }
}
/// <summary>
/// MusicBrainz artist credit.
/// </summary>
public class MusicBrainzArtistCredit
{
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("artist")]
public MusicBrainzArtist? Artist { get; set; }
}
/// <summary>
/// MusicBrainz artist.
/// </summary>
public class MusicBrainzArtist
{
[JsonPropertyName("id")]
public string? Id { get; set; }
[JsonPropertyName("name")]
public string? Name { get; set; }
}
/// <summary>
/// MusicBrainz release.
/// </summary>
public class MusicBrainzRelease
{
[JsonPropertyName("id")]
public string? Id { get; set; }
[JsonPropertyName("title")]
public string? Title { get; set; }
[JsonPropertyName("date")]
public string? Date { get; set; }
}
/// <summary>
/// MusicBrainz genre.
/// </summary>
public class MusicBrainzGenre
{
[JsonPropertyName("id")]
public string? Id { get; set; }
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("count")]
public int Count { get; set; }
}
/// <summary>
/// MusicBrainz tag (folksonomy).
/// </summary>
public class MusicBrainzTag
{
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("count")]
public int Count { get; set; }
}

View File

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

View File

@@ -1,900 +0,0 @@
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

@@ -10,7 +10,6 @@ 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;
@@ -22,7 +21,6 @@ public class SpotifyMissingTracksFetcher : BackgroundService
public SpotifyMissingTracksFetcher(
IOptions<SpotifyImportSettings> spotifySettings,
IOptions<SpotifyApiSettings> spotifyApiSettings,
IOptions<JellyfinSettings> jellyfinSettings,
IHttpClientFactory httpClientFactory,
RedisCacheService cache,
@@ -30,7 +28,6 @@ public class SpotifyMissingTracksFetcher : BackgroundService
ILogger<SpotifyMissingTracksFetcher> logger)
{
_spotifySettings = spotifySettings;
_spotifyApiSettings = spotifyApiSettings;
_jellyfinSettings = jellyfinSettings;
_httpClientFactory = httpClientFactory;
_cache = cache;
@@ -38,15 +35,6 @@ public class SpotifyMissingTracksFetcher : BackgroundService
_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);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("========================================");
@@ -55,16 +43,6 @@ public class SpotifyMissingTracksFetcher : BackgroundService
// 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");
@@ -83,20 +61,18 @@ public class SpotifyMissingTracksFetcher : BackgroundService
}
_logger.LogInformation("Spotify Import ENABLED");
_logger.LogInformation("Configured Playlists: {Count}", _spotifySettings.Value.Playlists.Count);
_logger.LogInformation("Background check interval: 5 minutes");
_logger.LogInformation("Configured Playlist IDs: {Count}", _spotifySettings.Value.PlaylistIds.Count);
// 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("========================================");
// Run on startup if we don't have cache
// Check if we should run on startup
if (!_hasRunOnce)
{
var shouldRun = await ShouldRunOnStartupAsync();
@@ -105,7 +81,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
_logger.LogInformation("Running initial fetch on startup");
try
{
await FetchMissingTracksAsync(stoppingToken);
await FetchMissingTracksAsync(stoppingToken, bypassSyncWindowCheck: true);
_hasRunOnce = true;
}
catch (Exception ex)
@@ -115,22 +91,17 @@ public class SpotifyMissingTracksFetcher : BackgroundService
}
else
{
_logger.LogInformation("Skipping startup fetch - already have cached files");
_logger.LogInformation("Skipping startup fetch - existing cache is still current");
_hasRunOnce = true;
}
}
// Background loop - check for new files every 5 minutes
while (!stoppingToken.IsCancellationRequested)
{
try
{
var shouldFetch = await ShouldFetchNowAsync();
if (shouldFetch)
{
await FetchMissingTracksAsync(stoppingToken);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching Spotify missing tracks");
@@ -140,42 +111,19 @@ public class SpotifyMissingTracksFetcher : BackgroundService
}
}
private async Task<bool> ShouldFetchNowAsync()
{
// Check if we have recent cache files (within last 24 hours)
var now = DateTime.UtcNow;
var cacheThreshold = now.AddHours(-24);
foreach (var playlistName in _playlistIdToName.Values)
{
var filePath = GetCacheFilePath(playlistName);
if (!File.Exists(filePath))
{
// Missing cache file for this playlist
return true;
}
var fileTime = File.GetLastWriteTimeUtc(filePath);
if (fileTime < cacheThreshold)
{
// Cache file is older than 24 hours
return true;
}
}
// All playlists have recent cache files
return false;
}
private async Task LoadPlaylistNamesAsync()
{
_playlistIdToName.Clear();
// Use configured playlists
foreach (var playlist in _spotifySettings.Value.Playlists)
// Use configured playlist names instead of fetching from API
for (int i = 0; i < _spotifySettings.Value.PlaylistIds.Count; i++)
{
_playlistIdToName[playlist.Id] = playlist.Name;
var playlistId = _spotifySettings.Value.PlaylistIds[i];
var playlistName = i < _spotifySettings.Value.PlaylistNames.Count
? _spotifySettings.Value.PlaylistNames[i]
: playlistId; // Fallback to ID if name not configured
_playlistIdToName[playlistId] = playlistName;
}
}
@@ -183,6 +131,22 @@ public class SpotifyMissingTracksFetcher : BackgroundService
{
_logger.LogInformation("=== STARTUP CACHE CHECK ===");
var settings = _spotifySettings.Value;
var now = DateTime.UtcNow;
// Calculate when today's sync window ends
var todaySync = now.Date
.AddHours(settings.SyncStartHour)
.AddMinutes(settings.SyncStartMinute);
var todaySyncEnd = todaySync.AddHours(settings.SyncWindowHours);
// If we haven't reached today's sync window end yet, check if we have yesterday's file
if (now < todaySyncEnd)
{
_logger.LogInformation("Today's sync window hasn't ended yet (ends at {End})", todaySyncEnd);
_logger.LogInformation("Checking if we have a recent cache file...");
// Check if we have any cache (file or Redis) for all playlists
var allPlaylistsHaveCache = true;
foreach (var playlistName in _playlistIdToName.Values)
@@ -221,6 +185,12 @@ public class SpotifyMissingTracksFetcher : BackgroundService
_logger.LogInformation("=== ALL PLAYLISTS HAVE CACHE - SKIPPING STARTUP FETCH ===");
return false;
}
}
else
{
_logger.LogInformation("Today's sync window has passed (ended at {End})", todaySyncEnd);
_logger.LogInformation("Will search for new files");
}
_logger.LogInformation("=== WILL FETCH ON STARTUP ===");
return true;
@@ -276,50 +246,58 @@ public class SpotifyMissingTracksFetcher : BackgroundService
}
}
private async Task FetchMissingTracksAsync(CancellationToken cancellationToken)
private async Task FetchMissingTracksAsync(CancellationToken cancellationToken, bool bypassSyncWindowCheck = false)
{
_logger.LogInformation("=== FETCHING MISSING TRACKS ===");
var settings = _spotifySettings.Value;
var now = DateTime.UtcNow;
var syncStart = now.Date
.AddHours(settings.SyncStartHour)
.AddMinutes(settings.SyncStartMinute);
var syncEnd = syncStart.AddHours(settings.SyncWindowHours);
// Only run after the sync window has passed (unless bypassing for startup)
if (!bypassSyncWindowCheck && now < syncEnd)
{
_logger.LogInformation("Skipping fetch - sync window not passed yet (now: {Now}, window ends: {End})",
now, syncEnd);
return;
}
if (bypassSyncWindowCheck)
{
_logger.LogInformation("=== FETCHING MISSING TRACKS (STARTUP MODE) ===");
}
else
{
_logger.LogInformation("=== FETCHING MISSING TRACKS (SYNC WINDOW PASSED) ===");
}
_logger.LogInformation("Processing {Count} playlists", _playlistIdToName.Count);
// 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);
}
}
await FetchPlaylistMissingTracksAsync(kvp.Value, cancellationToken);
}
_logger.LogInformation("=== FINISHED FETCHING MISSING TRACKS ({Found}/{Total} playlists found) ===",
foundPlaylists.Count, _playlistIdToName.Count);
_logger.LogInformation("=== FINISHED FETCHING MISSING TRACKS ===");
}
private async Task<DateTime?> FetchPlaylistMissingTracksAsync(
private async Task FetchPlaylistMissingTracksAsync(
string playlistName,
CancellationToken cancellationToken,
DateTime? hintTime = null)
CancellationToken cancellationToken)
{
var cacheKey = $"spotify:missing:{playlistName}";
// Check if we have existing cache
// Check if we have existing cache and when it was last updated
var existingTracks = await _cache.GetAsync<List<MissingTrack>>(cacheKey);
var existingFileTime = DateTime.MinValue;
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);
existingFileTime = File.GetLastWriteTimeUtc(filePath);
_logger.LogInformation(" Existing cache file from: {Time} ({Age:F1}h ago)",
existingFileTime, (DateTime.UtcNow - existingFileTime).TotalHours);
}
if (existingTracks != null && existingTracks.Count > 0)
@@ -338,80 +316,66 @@ public class SpotifyMissingTracksFetcher : BackgroundService
if (string.IsNullOrEmpty(jellyfinUrl) || string.IsNullOrEmpty(apiKey))
{
_logger.LogWarning(" Jellyfin URL or API key not configured, skipping fetch");
return null;
return;
}
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
// Search forward first (newest files), then backwards to handle timezone differences
// We want the file with the furthest forward timestamp (most recent)
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);
_logger.LogInformation(" Searching +24h forward, then -48h backward from {Now}", now);
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++)
// First search forward 24 hours (most likely to find newest files with timezone ahead)
_logger.LogInformation(" Phase 1: Searching forward 24 hours...");
for (var minutesAhead = 1; minutesAhead <= 1440; minutesAhead++)
{
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);
var time = now.AddMinutes(minutesAhead);
var result = await TryFetchMissingTracksFile(playlistName, time, jellyfinUrl, apiKey, httpClient, cancellationToken, existingFileTime);
if (result.found)
{
found = true;
foundFileTime = result.fileTime;
return foundFileTime;
if (foundFileTime.HasValue)
{
_logger.LogInformation(" ✓ Found file from {Time} (+{Offset:F1}h ahead)",
foundFileTime.Value, (foundFileTime.Value - now).TotalHours);
}
break; // Found newest file, stop searching
}
// Small delay every 60 requests to avoid rate limiting
if (minutesAhead % 60 == 0)
{
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
}
}
// If not found forward, search backwards 48 hours
if (!found)
{
_logger.LogInformation(" Phase 2: Searching backward 48 hours...");
for (var minutesBehind = 0; minutesBehind <= 2880; minutesBehind++)
{
if (cancellationToken.IsCancellationRequested) break;
var time = now.AddMinutes(-minutesBehind);
var result = await TryFetchMissingTracksFile(playlistName, time, jellyfinUrl, apiKey, httpClient, cancellationToken, existingFileTime);
if (result.found)
{
found = true;
foundFileTime = result.fileTime;
if (foundFileTime.HasValue)
{
_logger.LogInformation(" ✓ Found file from {Time} (-{Offset:F1}h ago)",
foundFileTime.Value, (now - foundFileTime.Value).TotalHours);
}
break;
}
// Small delay every 60 requests to avoid rate limiting
@@ -420,6 +384,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
}
}
}
if (!found)
{
@@ -457,8 +422,10 @@ public class SpotifyMissingTracksFetcher : BackgroundService
_logger.LogWarning(" No existing cache to keep - playlist will be empty until tracks are found");
}
}
return foundFileTime;
else if (foundFileTime.HasValue)
{
_logger.LogInformation(" ✓ Updated cache with newer file from {Time}", foundFileTime.Value);
}
}
private async Task<(bool found, DateTime? fileTime)> TryFetchMissingTracksFile(
@@ -467,7 +434,8 @@ public class SpotifyMissingTracksFetcher : BackgroundService
string jellyfinUrl,
string apiKey,
HttpClient httpClient,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
DateTime existingFileTime)
{
var filename = $"{playlistName}_missing_{time:yyyy-MM-dd_HH-mm}.json";
var url = $"{jellyfinUrl}/Viperinius.Plugin.SpotifyImport/MissingTracksFile" +
@@ -475,9 +443,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
try
{
// Log every request with the actual filename
_logger.LogInformation("Checking: {Playlist} at {DateTime}", playlistName, time.ToString("yyyy-MM-dd HH:mm"));
_logger.LogDebug("Trying {Filename}", filename);
var response = await httpClient.GetAsync(url, cancellationToken);
if (response.IsSuccessStatusCode)
{
@@ -486,6 +452,13 @@ public class SpotifyMissingTracksFetcher : BackgroundService
if (tracks.Count > 0)
{
// Check if this file is newer than what we already have
if (time <= existingFileTime)
{
_logger.LogDebug(" Skipping {Filename} - not newer than existing cache", filename);
return (false, null);
}
var cacheKey = $"spotify:missing:{playlistName}";
// Save to both Redis and file with extended TTL until next job runs
@@ -494,7 +467,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
await SaveToFileCache(playlistName, tracks);
_logger.LogInformation(
"✓ FOUND! Cached {Count} missing tracks for {Playlist} from {Filename}",
"✓ Cached {Count} missing tracks for {Playlist} from {Filename} (no expiration until next Jellyfin job)",
tracks.Count, playlistName, filename);
return (true, time);
}

View File

@@ -1,336 +0,0 @@
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

@@ -2,73 +2,45 @@ using allstarr.Models.Domain;
using allstarr.Models.Settings;
using allstarr.Models.Spotify;
using allstarr.Services.Common;
using allstarr.Services.Jellyfin;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using System.Text.Json;
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.
/// Background service that pre-matches Spotify missing tracks with external providers.
/// Runs after SpotifyMissingTracksFetcher completes to avoid rate limiting during playlist loading.
/// </summary>
public class SpotifyTrackMatchingService : BackgroundService
{
private readonly SpotifyImportSettings _spotifySettings;
private readonly SpotifyApiSettings _spotifyApiSettings;
private readonly IOptions<SpotifyImportSettings> _spotifySettings;
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)
private DateTime _lastMatchingRun = DateTime.MinValue;
private readonly TimeSpan _minimumMatchingInterval = TimeSpan.FromMinutes(5); // Don't run more than once per 5 minutes
public SpotifyTrackMatchingService(
IOptions<SpotifyImportSettings> spotifySettings,
IOptions<SpotifyApiSettings> spotifyApiSettings,
RedisCacheService cache,
IServiceProvider serviceProvider,
ILogger<SpotifyTrackMatchingService> logger)
{
_spotifySettings = spotifySettings.Value;
_spotifyApiSettings = spotifyApiSettings.Value;
_spotifySettings = spotifySettings;
_cache = cache;
_serviceProvider = serviceProvider;
_logger = logger;
}
/// <summary>
/// Helper method to safely check if a dynamic cache result has a value
/// Handles the case where JsonElement cannot be compared to null directly
/// </summary>
private static bool HasValue(object? obj)
{
if (obj == null) return false;
if (obj is JsonElement jsonEl) return jsonEl.ValueKind != JsonValueKind.Null && jsonEl.ValueKind != JsonValueKind.Undefined;
return true;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("SpotifyTrackMatchingService: Starting up...");
if (!_spotifySettings.Enabled)
if (!_spotifySettings.Value.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);
@@ -83,19 +55,8 @@ public class SpotifyTrackMatchingService : BackgroundService
_logger.LogError(ex, "Error during startup track matching");
}
// Now start the periodic matching loop
while (!stoppingToken.IsCancellationRequested)
{
// Wait for configured interval before next run (default 24 hours)
var intervalHours = _spotifySettings.MatchingIntervalHours;
if (intervalHours <= 0)
{
_logger.LogInformation("Periodic matching disabled (MatchingIntervalHours = {Hours}), only startup run will execute", intervalHours);
break; // Exit loop - only run once on startup
}
await Task.Delay(TimeSpan.FromHours(intervalHours), stoppingToken);
try
{
await MatchAllPlaylistsAsync(stoppingToken);
@@ -104,82 +65,27 @@ public class SpotifyTrackMatchingService : BackgroundService
{
_logger.LogError(ex, "Error in track matching service");
}
// Run every 30 minutes to catch new missing tracks
await Task.Delay(TimeSpan.FromMinutes(30), stoppingToken);
}
}
/// <summary>
/// Public method to trigger matching manually for all playlists (called from controller).
/// Public method to trigger matching manually (called from controller).
/// </summary>
public async Task TriggerMatchingAsync()
{
_logger.LogInformation("Manual track matching triggered for all playlists");
_logger.LogInformation("Manual track matching triggered");
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)
{
// Check if we've run too recently (cooldown period)
var timeSinceLastRun = DateTime.UtcNow - _lastMatchingRun;
if (timeSinceLastRun < _minimumMatchingInterval)
{
_logger.LogInformation("Skipping track matching - last run was {Seconds}s ago (minimum interval: {MinSeconds}s)",
(int)timeSinceLastRun.TotalSeconds, (int)_minimumMatchingInterval.TotalSeconds);
return;
}
_logger.LogInformation("=== STARTING TRACK MATCHING ===");
_lastMatchingRun = DateTime.UtcNow;
var playlists = _spotifySettings.Playlists;
if (playlists.Count == 0)
var playlistNames = _spotifySettings.Value.PlaylistNames;
if (playlistNames.Count == 0)
{
_logger.LogInformation("No playlists configured for matching");
return;
@@ -188,513 +94,24 @@ public class SpotifyTrackMatchingService : BackgroundService
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)
foreach (var playlistName in playlistNames)
{
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);
}
await MatchPlaylistTracksAsync(playlistName, metadataService, cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error matching tracks for playlist {Playlist}", playlist.Name);
_logger.LogError(ex, "Error matching tracks for playlist {Playlist}", playlistName);
}
}
_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.
/// Uses GREEDY ASSIGNMENT to maximize total matches.
/// </summary>
private async Task MatchPlaylistTracksWithIsrcAsync(
string playlistName,
SpotifyPlaylistFetcher playlistFetcher,
IMusicMetadataService metadataService,
CancellationToken cancellationToken)
{
var matchedTracksKey = $"spotify:matched:ordered:{playlistName}";
// Get playlist tracks with full metadata including ISRC and position
var spotifyTracks = await playlistFetcher.GetPlaylistTracksAsync(playlistName);
if (spotifyTracks.Count == 0)
{
_logger.LogInformation("No tracks found for {Playlist}, skipping matching", playlistName);
return;
}
// Get the Jellyfin playlist ID to check which tracks already exist
var playlistConfig = _spotifySettings.Playlists
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
HashSet<string> existingSpotifyIds = new();
if (!string.IsNullOrEmpty(playlistConfig?.JellyfinId))
{
// Get existing tracks from Jellyfin playlist to avoid re-matching
using var scope = _serviceProvider.CreateScope();
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
var jellyfinSettings = scope.ServiceProvider.GetService<IOptions<JellyfinSettings>>()?.Value;
if (proxyService != null && jellyfinSettings != null)
{
try
{
// CRITICAL: Must include UserId parameter or Jellyfin returns empty results
var userId = jellyfinSettings.UserId;
var playlistItemsUrl = $"Playlists/{playlistConfig.JellyfinId}/Items";
var queryParams = new Dictionary<string, string>();
if (!string.IsNullOrEmpty(userId))
{
queryParams["UserId"] = userId;
}
else
{
_logger.LogWarning("No UserId configured - may not be able to fetch existing playlist tracks for {Playlist}", playlistName);
}
var (existingTracksResponse, _) = await proxyService.GetJsonAsyncInternal(
playlistItemsUrl,
queryParams);
if (existingTracksResponse != null &&
existingTracksResponse.RootElement.TryGetProperty("Items", out var items))
{
foreach (var item in items.EnumerateArray())
{
if (item.TryGetProperty("ProviderIds", out var providerIds) &&
providerIds.TryGetProperty("Spotify", out var spotifyId))
{
var id = spotifyId.GetString();
if (!string.IsNullOrEmpty(id))
{
existingSpotifyIds.Add(id);
}
}
}
_logger.LogInformation("Found {Count} tracks already in Jellyfin playlist {Playlist}, will skip matching these",
existingSpotifyIds.Count, playlistName);
}
else
{
_logger.LogWarning("No Items found in Jellyfin playlist response for {Playlist} - may need UserId parameter", playlistName);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not fetch existing Jellyfin tracks for {Playlist}, will match all tracks", playlistName);
}
}
}
// Filter to only tracks not already in Jellyfin
var tracksToMatch = spotifyTracks
.Where(t => !existingSpotifyIds.Contains(t.SpotifyId))
.ToList();
if (tracksToMatch.Count == 0)
{
_logger.LogInformation("All {Count} tracks for {Playlist} already exist in Jellyfin, skipping matching",
spotifyTracks.Count, playlistName);
return;
}
_logger.LogInformation("Matching {ToMatch}/{Total} tracks for {Playlist} (skipping {Existing} already in Jellyfin, ISRC: {IsrcEnabled}, AGGRESSIVE MODE)",
tracksToMatch.Count, spotifyTracks.Count, playlistName, existingSpotifyIds.Count, _spotifyApiSettings.PreferIsrcMatching);
// Check cache - use snapshot/timestamp to detect changes
var existingMatched = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
// CRITICAL: Skip matching if cache exists and is valid
// Only re-match if cache is missing OR if we detect manual mappings that need to be applied
if (existingMatched != null && existingMatched.Count > 0)
{
// Check if we have NEW manual mappings that aren't in the cache
var hasNewManualMappings = false;
foreach (var track in tracksToMatch)
{
// Check if this track has a manual mapping but isn't in the cached results
var manualMappingKey = $"spotify:manual-map:{playlistName}:{track.SpotifyId}";
var manualMapping = await _cache.GetAsync<string>(manualMappingKey);
var externalMappingKey = $"spotify:external-map:{playlistName}:{track.SpotifyId}";
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
var hasManualMapping = !string.IsNullOrEmpty(manualMapping) || !string.IsNullOrEmpty(externalMappingJson);
var isInCache = existingMatched.Any(m => m.SpotifyId == track.SpotifyId);
// If track has manual mapping but isn't in cache, we need to rebuild
if (hasManualMapping && !isInCache)
{
hasNewManualMappings = true;
break;
}
}
if (!hasNewManualMappings)
{
_logger.LogInformation("✓ Playlist {Playlist} already has {Count} matched tracks cached (skipping {ToMatch} new tracks), no re-matching needed",
playlistName, existingMatched.Count, tracksToMatch.Count);
return;
}
_logger.LogInformation("New manual mappings detected for {Playlist}, rebuilding cache to apply them", playlistName);
}
var matchedTracks = new List<MatchedTrack>();
var isrcMatches = 0;
var fuzzyMatches = 0;
var noMatch = 0;
// GREEDY ASSIGNMENT: Collect all possible matches first, then assign optimally
var allCandidates = new List<(SpotifyPlaylistTrack SpotifyTrack, Song MatchedSong, double Score, string MatchType)>();
// Process tracks in batches for parallel searching
var orderedTracks = tracksToMatch.OrderBy(t => t.Position).ToList();
for (int i = 0; i < orderedTracks.Count; i += BatchSize)
{
if (cancellationToken.IsCancellationRequested) break;
var batch = orderedTracks.Skip(i).Take(BatchSize).ToList();
_logger.LogDebug("Processing batch {Start}-{End} of {Total}",
i + 1, Math.Min(i + BatchSize, orderedTracks.Count), orderedTracks.Count);
// Process all tracks in this batch in parallel
var batchTasks = batch.Select(async spotifyTrack =>
{
try
{
var candidates = new List<(Song Song, double Score, string MatchType)>();
// Try ISRC match first if available and enabled
if (_spotifyApiSettings.PreferIsrcMatching && !string.IsNullOrEmpty(spotifyTrack.Isrc))
{
var isrcSong = await TryMatchByIsrcAsync(spotifyTrack.Isrc, metadataService);
if (isrcSong != null)
{
candidates.Add((isrcSong, 100.0, "isrc"));
}
}
// Always try fuzzy matching to get more candidates
var fuzzySongs = await TryMatchByFuzzyMultipleAsync(
spotifyTrack.Title,
spotifyTrack.Artists,
metadataService);
foreach (var (song, score) in fuzzySongs)
{
candidates.Add((song, score, "fuzzy"));
}
return (spotifyTrack, candidates);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
spotifyTrack.Title, spotifyTrack.PrimaryArtist);
return (spotifyTrack, new List<(Song, double, string)>());
}
}).ToList();
// Wait for all tracks in this batch to complete
var batchResults = await Task.WhenAll(batchTasks);
// Collect all candidates
foreach (var (spotifyTrack, candidates) in batchResults)
{
foreach (var (song, score, matchType) in candidates)
{
allCandidates.Add((spotifyTrack, song, score, matchType));
}
}
// Rate limiting between batches
if (i + BatchSize < orderedTracks.Count)
{
await Task.Delay(DelayBetweenSearchesMs, cancellationToken);
}
}
// GREEDY ASSIGNMENT: Assign each Spotify track to its best unique match
var usedSongIds = new HashSet<string>();
var assignments = new Dictionary<string, (Song Song, double Score, string MatchType)>();
// Sort candidates by score (highest first)
var sortedCandidates = allCandidates
.OrderByDescending(c => c.Score)
.ToList();
foreach (var (spotifyTrack, song, score, matchType) in sortedCandidates)
{
// Skip if this Spotify track already has a match
if (assignments.ContainsKey(spotifyTrack.SpotifyId))
continue;
// Skip if this song is already used
if (usedSongIds.Contains(song.Id))
continue;
// Assign this match
assignments[spotifyTrack.SpotifyId] = (song, score, matchType);
usedSongIds.Add(song.Id);
}
// Build final matched tracks list
foreach (var spotifyTrack in orderedTracks)
{
if (assignments.TryGetValue(spotifyTrack.SpotifyId, out var match))
{
var matched = new MatchedTrack
{
Position = spotifyTrack.Position,
SpotifyId = spotifyTrack.SpotifyId,
SpotifyTitle = spotifyTrack.Title,
SpotifyArtist = spotifyTrack.PrimaryArtist,
Isrc = spotifyTrack.Isrc,
MatchType = match.MatchType,
MatchedSong = match.Song
};
matchedTracks.Add(matched);
if (match.MatchType == "isrc") isrcMatches++;
else if (match.MatchType == "fuzzy") fuzzyMatches++;
_logger.LogDebug(" #{Position} {Title} - {Artist} → {MatchType} match (score: {Score:F1}): {MatchedTitle}",
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist,
match.MatchType, match.Score, match.Song.Title);
}
else
{
noMatch++;
_logger.LogDebug(" #{Position} {Title} - {Artist} → no match",
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist);
}
}
if (matchedTracks.Count > 0)
{
// Cache matched tracks with position data
await _cache.SetAsync(matchedTracksKey, matchedTracks, TimeSpan.FromHours(1));
// Save matched tracks to file for persistence across restarts
await SaveMatchedTracksToFileAsync(playlistName, matchedTracks);
// Also update legacy cache for backward compatibility
var legacyKey = $"spotify:matched:{playlistName}";
var legacySongs = matchedTracks.OrderBy(t => t.Position).Select(t => t.MatchedSong).ToList();
await _cache.SetAsync(legacyKey, legacySongs, TimeSpan.FromHours(1));
_logger.LogInformation(
"✓ Cached {Matched}/{Total} tracks for {Playlist} via GREEDY ASSIGNMENT (ISRC: {Isrc}, Fuzzy: {Fuzzy}, No match: {NoMatch}) - manual mappings will be applied next",
matchedTracks.Count, tracksToMatch.Count, playlistName, isrcMatches, fuzzyMatches, noMatch);
// Pre-build playlist items cache for instant serving
// This is what makes the UI show all matched tracks at once
await PreBuildPlaylistItemsCacheAsync(playlistName, playlistConfig?.JellyfinId, spotifyTracks, matchedTracks, cancellationToken);
}
else
{
_logger.LogInformation("No tracks matched for {Playlist}", playlistName);
}
}
/// <summary>
/// Returns multiple candidate matches with scores for greedy assignment.
/// FOLLOWS OPTIMAL ORDER:
/// 1. Strip decorators (done in FuzzyMatcher)
/// 2. Substring matching (done in FuzzyMatcher)
/// 3. Levenshtein distance (done in FuzzyMatcher)
/// This method just collects candidates; greedy assignment happens later.
/// </summary>
private async Task<List<(Song Song, double Score)>> TryMatchByFuzzyMultipleAsync(
string title,
List<string> artists,
IMusicMetadataService metadataService)
{
try
{
var primaryArtist = artists.FirstOrDefault() ?? "";
// STEP 1: Strip decorators FIRST (before searching)
var titleStripped = FuzzyMatcher.StripDecorators(title);
var query = $"{titleStripped} {primaryArtist}";
var results = await metadataService.SearchSongsAsync(query, limit: 10);
if (results.Count == 0) return new List<(Song, double)>();
// STEP 2-3: Score all results (substring + Levenshtein already in CalculateSimilarityAggressive)
var scoredResults = results
.Select(song => new
{
Song = song,
// Use aggressive matching which follows optimal order internally
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
})
.Select(x => new
{
x.Song,
x.TitleScore,
x.ArtistScore,
// Weight: 70% title, 30% artist (prioritize title matching)
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
})
.Where(x =>
x.TotalScore >= 40 ||
(x.ArtistScore >= 70 && x.TitleScore >= 30) ||
x.TitleScore >= 85)
.OrderByDescending(x => x.TotalScore)
.Select(x => (x.Song, x.TotalScore))
.ToList();
return scoredResults;
}
catch
{
return new List<(Song, double)>();
}
}
/// <summary>
/// Attempts to match a track by ISRC using provider search.
/// </summary>
private async Task<Song?> TryMatchByIsrcAsync(string isrc, IMusicMetadataService metadataService)
{
try
{
// Search by ISRC directly - most providers support this
var results = await metadataService.SearchSongsAsync($"isrc:{isrc}", limit: 1);
if (results.Count > 0 && results[0].Isrc == isrc)
{
return results[0];
}
// Some providers may not support isrc: prefix, try without
results = await metadataService.SearchSongsAsync(isrc, limit: 5);
var exactMatch = results.FirstOrDefault(r =>
!string.IsNullOrEmpty(r.Isrc) &&
r.Isrc.Equals(isrc, StringComparison.OrdinalIgnoreCase));
return exactMatch;
}
catch
{
return null;
}
}
/// <summary>
/// Attempts to match a track by title and artist using AGGRESSIVE fuzzy matching.
/// FOLLOWS OPTIMAL ORDER:
/// 1. Strip decorators FIRST (before searching)
/// 2. Substring matching (in FuzzyMatcher)
/// 3. Levenshtein distance (in FuzzyMatcher)
/// PRIORITY: Match as many tracks as possible, even with lower confidence.
/// </summary>
private async Task<Song?> TryMatchByFuzzyAsync(
string title,
List<string> artists,
IMusicMetadataService metadataService)
{
try
{
var primaryArtist = artists.FirstOrDefault() ?? "";
// STEP 1: Strip decorators FIRST (before searching)
var titleStripped = FuzzyMatcher.StripDecorators(title);
var query = $"{titleStripped} {primaryArtist}";
var results = await metadataService.SearchSongsAsync(query, limit: 10);
if (results.Count == 0) return null;
// STEP 2-3: Score all results (substring + Levenshtein in CalculateSimilarityAggressive)
var scoredResults = results
.Select(song => new
{
Song = song,
// Use aggressive matching which follows optimal order internally
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
})
.Select(x => new
{
x.Song,
x.TitleScore,
x.ArtistScore,
// Weight: 70% title, 30% artist (prioritize title matching)
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
})
.OrderByDescending(x => x.TotalScore)
.ToList();
var bestMatch = scoredResults.FirstOrDefault();
if (bestMatch == null) return null;
// AGGRESSIVE: Accept matches with score >= 40 (was 50)
if (bestMatch.TotalScore >= 40)
{
_logger.LogDebug("✓ Matched (score: {Score:F1}, title: {TitleScore}, artist: {ArtistScore}): {SpotifyTitle} → {MatchedTitle}",
bestMatch.TotalScore, bestMatch.TitleScore, bestMatch.ArtistScore, title, bestMatch.Song.Title);
return bestMatch.Song;
}
// SUPER AGGRESSIVE: If artist matches well (70+), accept even lower title scores
// This handles cases like "a" → "a-blah" where artist is the same
if (bestMatch.ArtistScore >= 70 && bestMatch.TitleScore >= 30)
{
_logger.LogDebug("✓ Matched via artist priority (artist: {ArtistScore}, title: {TitleScore}): {SpotifyTitle} → {MatchedTitle}",
bestMatch.ArtistScore, bestMatch.TitleScore, title, bestMatch.Song.Title);
return bestMatch.Song;
}
// ULTRA AGGRESSIVE: If title has high substring match (85+), accept it
// This handles "luther" → "luther (feat. sza)"
if (bestMatch.TitleScore >= 85)
{
_logger.LogDebug("✓ Matched via substring (title: {TitleScore}): {SpotifyTitle} → {MatchedTitle}",
bestMatch.TitleScore, title, bestMatch.Song.Title);
return bestMatch.Song;
}
return null;
}
catch
{
return null;
}
}
/// <summary>
/// Legacy matching mode using MissingTrack from Jellyfin plugin.
/// </summary>
private async Task MatchPlaylistTracksLegacyAsync(
private async Task MatchPlaylistTracksAsync(
string playlistName,
IMusicMetadataService metadataService,
CancellationToken cancellationToken)
@@ -744,7 +161,7 @@ public class SpotifyTrackMatchingService : BackgroundService
Song = song,
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, song.Title),
// Calculate artist score by checking ALL artists match
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors)
ArtistScore = CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors)
})
.Select(x => new
{
@@ -839,437 +256,4 @@ public class SpotifyTrackMatchingService : BackgroundService
return avgScore;
}
/// <summary>
/// Pre-builds the playlist items cache for instant serving.
/// This combines local Jellyfin tracks with external matched tracks in the correct Spotify order.
/// </summary>
private async Task PreBuildPlaylistItemsCacheAsync(
string playlistName,
string? jellyfinPlaylistId,
List<SpotifyPlaylistTrack> spotifyTracks,
List<MatchedTrack> matchedTracks,
CancellationToken cancellationToken)
{
try
{
_logger.LogInformation("🔨 Pre-building playlist items cache for {Playlist}...", playlistName);
if (string.IsNullOrEmpty(jellyfinPlaylistId))
{
_logger.LogWarning("No Jellyfin playlist ID configured for {Playlist}, cannot pre-build cache", playlistName);
return;
}
// Get existing tracks from Jellyfin playlist
using var scope = _serviceProvider.CreateScope();
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
var responseBuilder = scope.ServiceProvider.GetService<JellyfinResponseBuilder>();
var jellyfinSettings = scope.ServiceProvider.GetService<IOptions<JellyfinSettings>>()?.Value;
if (proxyService == null || responseBuilder == null || jellyfinSettings == null)
{
_logger.LogWarning("Required services not available for pre-building cache");
return;
}
var userId = jellyfinSettings.UserId;
if (string.IsNullOrEmpty(userId))
{
_logger.LogWarning("No UserId configured, cannot pre-build playlist cache for {Playlist}", playlistName);
return;
}
// Create authentication headers for background service call
var headers = new HeaderDictionary();
if (!string.IsNullOrEmpty(jellyfinSettings.ApiKey))
{
headers["X-Emby-Authorization"] = $"MediaBrowser Token=\"{jellyfinSettings.ApiKey}\"";
}
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items?UserId={userId}&Fields=MediaSources";
var (existingTracksResponse, statusCode) = await proxyService.GetJsonAsync(playlistItemsUrl, null, headers);
if (statusCode != 200 || existingTracksResponse == null)
{
_logger.LogWarning("Failed to fetch Jellyfin playlist items for {Playlist}: HTTP {StatusCode}", playlistName, statusCode);
return;
}
// Index Jellyfin items by title+artist for matching
var jellyfinItemsByName = new Dictionary<string, JsonElement>();
if (existingTracksResponse.RootElement.TryGetProperty("Items", out var items))
{
foreach (var item in items.EnumerateArray())
{
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
var artist = "";
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
{
artist = artistsEl[0].GetString() ?? "";
}
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
{
artist = albumArtistEl.GetString() ?? "";
}
var key = $"{title}|{artist}".ToLowerInvariant();
if (!jellyfinItemsByName.ContainsKey(key))
{
jellyfinItemsByName[key] = item;
}
}
}
// Build the final track list in correct Spotify order
var finalItems = new List<Dictionary<string, object?>>();
var usedJellyfinItems = new HashSet<string>();
var localUsedCount = 0;
var externalUsedCount = 0;
var manualExternalCount = 0;
foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position))
{
if (cancellationToken.IsCancellationRequested) break;
JsonElement? matchedJellyfinItem = null;
string? matchedKey = null;
// FIRST: Check for manual Jellyfin mapping
var manualMappingKey = $"spotify:manual-map:{playlistName}:{spotifyTrack.SpotifyId}";
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
if (!string.IsNullOrEmpty(manualJellyfinId))
{
// Find the Jellyfin item by ID
foreach (var kvp in jellyfinItemsByName)
{
var item = kvp.Value;
if (item.TryGetProperty("Id", out var idEl) && idEl.GetString() == manualJellyfinId)
{
matchedJellyfinItem = item;
matchedKey = kvp.Key;
_logger.LogInformation("✓ Using manual Jellyfin mapping for {Title}: Jellyfin ID {Id}",
spotifyTrack.Title, manualJellyfinId);
break;
}
}
if (matchedJellyfinItem.HasValue)
{
// Use the raw Jellyfin item (preserves ALL metadata)
var itemDict = JsonSerializer.Deserialize<Dictionary<string, object?>>(matchedJellyfinItem.Value.GetRawText());
if (itemDict != null)
{
// Add Spotify ID to ProviderIds so lyrics can work for local tracks too
if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId))
{
if (!itemDict.ContainsKey("ProviderIds"))
{
itemDict["ProviderIds"] = new Dictionary<string, string>();
}
var providerIds = itemDict["ProviderIds"] as Dictionary<string, string>;
if (providerIds != null && !providerIds.ContainsKey("Spotify"))
{
providerIds["Spotify"] = spotifyTrack.SpotifyId;
_logger.LogDebug("Added Spotify ID {SpotifyId} to local track for lyrics support", spotifyTrack.SpotifyId);
}
}
finalItems.Add(itemDict);
if (matchedKey != null)
{
usedJellyfinItems.Add(matchedKey);
}
localUsedCount++;
}
continue; // Skip to next track
}
}
// SECOND: Check for external manual mapping
var externalMappingKey = $"spotify:external-map:{playlistName}:{spotifyTrack.SpotifyId}";
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
if (!string.IsNullOrEmpty(externalMappingJson))
{
try
{
using var doc = JsonDocument.Parse(externalMappingJson);
var root = doc.RootElement;
string? provider = null;
string? externalId = null;
if (root.TryGetProperty("provider", out var providerEl))
{
provider = providerEl.GetString();
}
if (root.TryGetProperty("id", out var idEl))
{
externalId = idEl.GetString();
}
if (!string.IsNullOrEmpty(provider) && !string.IsNullOrEmpty(externalId))
{
// Fetch full metadata from the provider instead of using minimal Spotify data
Song? externalSong = null;
try
{
using var metadataScope = _serviceProvider.CreateScope();
var metadataServiceForFetch = metadataScope.ServiceProvider.GetRequiredService<IMusicMetadataService>();
externalSong = await metadataServiceForFetch.GetSongAsync(provider, externalId);
if (externalSong != null)
{
_logger.LogInformation("✓ Fetched full metadata for manual external mapping: {Title} by {Artist}",
externalSong.Title, externalSong.Artist);
}
else
{
_logger.LogWarning("Failed to fetch metadata for {Provider} ID {ExternalId}, using fallback",
provider, externalId);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error fetching metadata for {Provider} ID {ExternalId}, using fallback",
provider, externalId);
}
// Fallback to minimal metadata if fetch failed
if (externalSong == null)
{
externalSong = new Song
{
Id = $"ext-{provider}-song-{externalId}",
Title = spotifyTrack.Title,
Artist = spotifyTrack.PrimaryArtist,
Album = spotifyTrack.Album,
Duration = spotifyTrack.DurationMs / 1000,
Isrc = spotifyTrack.Isrc,
IsLocal = false,
ExternalProvider = provider,
ExternalId = externalId
};
}
var matchedTrack = new MatchedTrack
{
Position = spotifyTrack.Position,
SpotifyId = spotifyTrack.SpotifyId,
MatchedSong = externalSong
};
matchedTracks.Add(matchedTrack);
// Convert external song to Jellyfin item format and add to finalItems
var externalItem = responseBuilder.ConvertSongToJellyfinItem(externalSong);
// Add Spotify ID to ProviderIds so lyrics can work
if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId))
{
if (!externalItem.ContainsKey("ProviderIds"))
{
externalItem["ProviderIds"] = new Dictionary<string, string>();
}
var providerIds = externalItem["ProviderIds"] as Dictionary<string, string>;
if (providerIds != null && !providerIds.ContainsKey("Spotify"))
{
providerIds["Spotify"] = spotifyTrack.SpotifyId;
}
}
finalItems.Add(externalItem);
externalUsedCount++;
manualExternalCount++;
_logger.LogInformation("✓ Using manual external mapping for {Title}: {Provider} {ExternalId}",
spotifyTrack.Title, provider, externalId);
continue; // Skip to next track
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", spotifyTrack.Title);
}
}
// If no manual external mapping, try AGGRESSIVE fuzzy matching with local Jellyfin tracks
double bestScore = 0;
foreach (var kvp in jellyfinItemsByName)
{
if (usedJellyfinItems.Contains(kvp.Key)) continue;
var item = kvp.Value;
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
var artist = "";
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
{
artist = artistsEl[0].GetString() ?? "";
}
// Use AGGRESSIVE matching with decorator stripping
var titleScore = FuzzyMatcher.CalculateSimilarityAggressive(spotifyTrack.Title, title);
var artistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, artist);
// Weight: 70% title, 30% artist (prioritize title matching)
var totalScore = (titleScore * 0.7) + (artistScore * 0.3);
// AGGRESSIVE: Accept score >= 40 (was 70)
// Also accept if artist matches well (70+) and title is decent (30+)
var isGoodMatch = totalScore >= 40 || (artistScore >= 70 && titleScore >= 30);
if (totalScore > bestScore && isGoodMatch)
{
bestScore = totalScore;
matchedJellyfinItem = item;
matchedKey = kvp.Key;
}
}
if (matchedJellyfinItem.HasValue)
{
// Use the raw Jellyfin item (preserves ALL metadata)
var itemDict = JsonSerializer.Deserialize<Dictionary<string, object?>>(matchedJellyfinItem.Value.GetRawText());
if (itemDict != null)
{
// Add Spotify ID to ProviderIds so lyrics can work for fuzzy-matched local tracks too
if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId))
{
if (!itemDict.ContainsKey("ProviderIds"))
{
itemDict["ProviderIds"] = new Dictionary<string, string>();
}
var providerIds = itemDict["ProviderIds"] as Dictionary<string, string>;
if (providerIds != null && !providerIds.ContainsKey("Spotify"))
{
providerIds["Spotify"] = spotifyTrack.SpotifyId;
_logger.LogDebug("Added Spotify ID {SpotifyId} to fuzzy-matched local track for lyrics support", spotifyTrack.SpotifyId);
}
}
finalItems.Add(itemDict);
if (matchedKey != null)
{
usedJellyfinItems.Add(matchedKey);
}
localUsedCount++;
}
}
else
{
// No local match - try to find external track
var matched = matchedTracks.FirstOrDefault(t => t.SpotifyId == spotifyTrack.SpotifyId);
if (matched != null && matched.MatchedSong != null)
{
// Convert external song to Jellyfin item format
var externalItem = responseBuilder.ConvertSongToJellyfinItem(matched.MatchedSong);
// Add Spotify ID to ProviderIds so lyrics can work
if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId))
{
if (!externalItem.ContainsKey("ProviderIds"))
{
externalItem["ProviderIds"] = new Dictionary<string, string>();
}
var providerIds = externalItem["ProviderIds"] as Dictionary<string, string>;
if (providerIds != null && !providerIds.ContainsKey("Spotify"))
{
providerIds["Spotify"] = spotifyTrack.SpotifyId;
}
}
finalItems.Add(externalItem);
externalUsedCount++;
}
}
}
if (finalItems.Count > 0)
{
// Save to Redis cache
var cacheKey = $"spotify:playlist:items:{playlistName}";
await _cache.SetAsync(cacheKey, finalItems, TimeSpan.FromHours(24));
// Save to file cache for persistence
await SavePlaylistItemsToFileAsync(playlistName, finalItems);
var manualMappingInfo = "";
if (manualExternalCount > 0)
{
manualMappingInfo = $" [Manual external: {manualExternalCount}]";
}
_logger.LogInformation(
"✅ Pre-built playlist cache for {Playlist}: {Total} tracks ({Local} LOCAL + {External} EXTERNAL){ManualInfo}",
playlistName, finalItems.Count, localUsedCount, externalUsedCount, manualMappingInfo);
}
else
{
_logger.LogWarning("No items to cache for {Playlist}", playlistName);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to pre-build playlist items cache for {Playlist}", playlistName);
}
}
/// <summary>
/// Saves playlist items to file cache for persistence across restarts.
/// </summary>
private async Task SavePlaylistItemsToFileAsync(string playlistName, List<Dictionary<string, object?>> items)
{
try
{
var cacheDir = "/app/cache/spotify";
Directory.CreateDirectory(cacheDir);
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
var filePath = Path.Combine(cacheDir, $"{safeName}_items.json");
var json = JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true });
await System.IO.File.WriteAllTextAsync(filePath, json);
_logger.LogDebug("💾 Saved {Count} playlist items to file cache: {Path}", items.Count, filePath);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save playlist items to file for {Playlist}", playlistName);
}
}
/// <summary>
/// Saves matched tracks to file cache for persistence across restarts.
/// </summary>
private async Task SaveMatchedTracksToFileAsync(string playlistName, List<MatchedTrack> matchedTracks)
{
try
{
var cacheDir = "/app/cache/spotify";
Directory.CreateDirectory(cacheDir);
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
var filePath = Path.Combine(cacheDir, $"{safeName}_matched.json");
var json = JsonSerializer.Serialize(matchedTracks, new JsonSerializerOptions { WriteIndented = true });
await System.IO.File.WriteAllTextAsync(filePath, json);
_logger.LogDebug("💾 Saved {Count} matched tracks to file cache: {Path}", matchedTracks.Count, filePath);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save matched tracks to file for {Playlist}", playlistName);
}
}
}

View File

@@ -7,7 +7,6 @@ using allstarr.Models.Search;
using allstarr.Models.Subsonic;
using allstarr.Services.Local;
using allstarr.Services.Common;
using allstarr.Services.Lyrics;
using Microsoft.Extensions.Options;
using IOFile = System.IO.File;
using Microsoft.Extensions.Logging;
@@ -15,48 +14,21 @@ using Microsoft.Extensions.Logging;
namespace allstarr.Services.SquidWTF;
/// <summary>
/// Handles track downloading from tidal.squid.wtf (no encryption, no auth required).
///
/// Downloads are direct from Tidal's CDN via the squid.wtf proxy. The service:
/// 1. Fetches download info from hifi-api /track/ endpoint
/// 2. Decodes base64 manifest to get actual Tidal CDN URL
/// 3. Downloads directly from Tidal CDN (no decryption needed)
/// 4. Converts Tidal track ID to Spotify ID in parallel (for lyrics matching)
/// 5. Writes ID3/FLAC metadata tags and embeds cover art
///
/// Per hifi-api spec, the /track/ endpoint returns:
/// { "version": "2.0", "data": {
/// trackId, assetPresentation, audioMode, audioQuality,
/// manifestMimeType: "application/vnd.tidal.bts",
/// manifest: "base64-encoded-json",
/// albumReplayGain, trackReplayGain, bitDepth, sampleRate
/// }}
///
/// The manifest decodes to:
/// { "mimeType": "audio/flac", "codecs": "flac", "encryptionType": "NONE",
/// "urls": ["https://lgf.audio.tidal.com/mediatracks/..."] }
///
/// Quality Mapping:
/// - HI_RES → HI_RES_LOSSLESS (24-bit/192kHz FLAC)
/// - FLAC/LOSSLESS → LOSSLESS (16-bit/44.1kHz FLAC)
/// - HIGH → HIGH (320kbps AAC)
/// - LOW → LOW (96kbps AAC)
///
/// Features:
/// - Racing multiple endpoints for fastest download
/// - Automatic failover to backup endpoints
/// - Parallel Spotify ID conversion via Odesli
/// - Organized folder structure: Artist/Album/Track
/// - Unique filename resolution for duplicates
/// - Support for both cache and permanent storage modes
/// Handles track downloading from tidal.squid.wtf (no encryption, no auth required)
/// Downloads are direct from Tidal's CDN via the squid.wtf proxy
/// </summary>
public class SquidWTFDownloadService : BaseDownloadService
{
private readonly HttpClient _httpClient;
private readonly SemaphoreSlim _requestLock = new(1, 1);
private readonly SquidWTFSettings _squidwtfSettings;
private readonly OdesliService _odesliService;
private readonly RoundRobinFallbackHelper _fallbackHelper;
private readonly IServiceProvider _serviceProvider;
private DateTime _lastRequestTime = DateTime.MinValue;
private readonly int _minRequestIntervalMs = 200;
private readonly List<string> _apiUrls;
private int _currentUrlIndex = 0;
private readonly object _urlIndexLock = new object();
protected override string ProviderName => "squidwtf";
@@ -69,26 +41,59 @@ public class SquidWTFDownloadService : BaseDownloadService
IOptions<SquidWTFSettings> SquidWTFSettings,
IServiceProvider serviceProvider,
ILogger<SquidWTFDownloadService> logger,
OdesliService odesliService,
List<string> apiUrls)
: base(configuration, localLibraryService, metadataService, subsonicSettings.Value, serviceProvider, logger)
{
_httpClient = httpClientFactory.CreateClient();
_squidwtfSettings = SquidWTFSettings.Value;
_odesliService = odesliService;
_fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF");
_serviceProvider = serviceProvider;
// Increase timeout for large downloads and slow endpoints
_httpClient.Timeout = TimeSpan.FromMinutes(5);
_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)
{
// 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++)
{
var urlIndex = (startIndex + attempt) % _apiUrls.Count;
var baseUrl = _apiUrls[urlIndex];
try
{
Logger.LogDebug("Trying endpoint {Endpoint} (attempt {Attempt}/{Total})",
baseUrl, attempt + 1, _apiUrls.Count);
return await action(baseUrl);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Request failed with endpoint {Endpoint}, trying next...", baseUrl);
if (attempt == _apiUrls.Count - 1)
{
Logger.LogError("All {Count} SquidWTF endpoints failed", _apiUrls.Count);
throw;
}
}
}
throw new Exception("All SquidWTF endpoints failed");
}
#region BaseDownloadService Implementation
public override async Task<bool> IsAvailableAsync()
{
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
return await TryWithFallbackAsync(async (baseUrl) =>
{
var response = await _httpClient.GetAsync(baseUrl);
Console.WriteLine($"Response code from is available async: {response.IsSuccessStatusCode}");
@@ -111,8 +116,8 @@ public class SquidWTFDownloadService : BaseDownloadService
{
var downloadInfo = await GetTrackDownloadInfoAsync(trackId, cancellationToken);
Logger.LogInformation("Track download URL obtained from hifi-api: {Url}", downloadInfo.DownloadUrl);
Logger.LogInformation("Using format: {Format} (Quality: {Quality})", downloadInfo.MimeType, downloadInfo.AudioQuality);
Logger.LogInformation("Track token obtained: {Url}", downloadInfo.DownloadUrl);
Logger.LogInformation("Using format: {Format}", downloadInfo.MimeType);
// Determine extension from MIME type
var extension = downloadInfo.MimeType?.ToLower() switch
@@ -125,11 +130,7 @@ public class SquidWTFDownloadService : BaseDownloadService
// Build organized folder structure: Artist/Album/Track using AlbumArtist (fallback to Artist for singles)
var artistForPath = song.AlbumArtist ?? song.Artist;
// Cache mode uses downloads/cache/ folder, Permanent mode uses downloads/permanent/
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache
? Path.Combine("downloads", "cache")
: Path.Combine("downloads", "permanent");
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension);
var outputPath = PathHelper.BuildTrackPath(DownloadPath, artistForPath, song.Album, song.Title, song.Track, extension);
// Create directories if they don't exist
var albumFolder = Path.GetDirectoryName(outputPath)!;
@@ -138,53 +139,10 @@ public class SquidWTFDownloadService : BaseDownloadService
// Resolve unique path if file already exists
outputPath = PathHelper.ResolveUniquePath(outputPath);
// Use round-robin with fallback for downloads to reduce CPU usage
Logger.LogDebug("Using round-robin endpoint selection for download");
var response = await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
// Download from Tidal CDN (no authentication needed, token is in URL)
var response = await QueueRequestAsync(async () =>
{
// Map quality settings to Tidal's quality levels per hifi-api spec
var quality = _squidwtfSettings.Quality?.ToUpperInvariant() switch
{
"FLAC" => "LOSSLESS",
"HI_RES" => "HI_RES_LOSSLESS",
"LOSSLESS" => "LOSSLESS",
"HIGH" => "HIGH",
"LOW" => "LOW",
_ => "LOSSLESS"
};
var url = $"{baseUrl}/track/?id={trackId}&quality={quality}";
// Get download info from this endpoint
var infoResponse = await _httpClient.GetAsync(url, cancellationToken);
infoResponse.EnsureSuccessStatusCode();
var json = await infoResponse.Content.ReadAsStringAsync(cancellationToken);
var doc = JsonDocument.Parse(json);
if (!doc.RootElement.TryGetProperty("data", out var data))
{
throw new Exception("Invalid response from API");
}
var manifestBase64 = data.GetProperty("manifest").GetString()
?? throw new Exception("No manifest in response");
// Decode base64 manifest to get actual CDN URL
var manifestJson = Encoding.UTF8.GetString(Convert.FromBase64String(manifestBase64));
var manifest = JsonDocument.Parse(manifestJson);
if (!manifest.RootElement.TryGetProperty("urls", out var urls) || urls.GetArrayLength() == 0)
{
throw new Exception("No download URLs in manifest");
}
var downloadUrl = urls[0].GetString()
?? throw new Exception("Download URL is null");
// Start the actual download from Tidal CDN (no encryption - squid.wtf handles everything)
using var request = new HttpRequestMessage(HttpMethod.Get, downloadUrl);
using var request = new HttpRequestMessage(HttpMethod.Get, downloadInfo.DownloadUrl);
request.Headers.Add("User-Agent", "Mozilla/5.0");
request.Headers.Add("Accept", "*/*");
@@ -202,26 +160,7 @@ public class SquidWTFDownloadService : BaseDownloadService
// Close file before writing metadata
await outputFile.DisposeAsync();
// Start Spotify ID conversion in background (for lyrics support)
// This doesn't block streaming - lyrics endpoint will fetch it on-demand if needed
_ = Task.Run(async () =>
{
try
{
var spotifyId = await _odesliService.ConvertTidalToSpotifyIdAsync(trackId, CancellationToken.None);
if (!string.IsNullOrEmpty(spotifyId))
{
Logger.LogDebug("Background Spotify ID obtained for Tidal/{TrackId}: {SpotifyId}", trackId, spotifyId);
// Spotify ID is cached by Odesli service for future lyrics requests
}
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Background Spotify ID conversion failed for Tidal/{TrackId}", trackId);
}
});
// Write metadata and cover art (without Spotify ID - it's only needed for lyrics)
// Write metadata and cover art
await WriteMetadataAsync(outputPath, song, cancellationToken);
return outputPath;
@@ -231,22 +170,13 @@ public class SquidWTFDownloadService : BaseDownloadService
#region SquidWTF API Methods
/// <summary>
/// Gets track download information from hifi-api /track/ endpoint.
/// Per hifi-api spec: GET /track/?id={trackId}&quality={quality}
/// Returns: { "version": "2.0", "data": { trackId, assetPresentation, audioMode, audioQuality,
/// manifestMimeType, manifestHash, manifest (base64), albumReplayGain, trackReplayGain, bitDepth, sampleRate } }
/// The manifest is base64-encoded JSON containing: { mimeType, codecs, encryptionType, urls: [downloadUrl] }
/// Quality options: HI_RES_LOSSLESS (24-bit/192kHz FLAC), LOSSLESS (16-bit/44.1kHz FLAC), HIGH (320kbps AAC), LOW (96kbps AAC)
/// </summary>
private async Task<DownloadResult> GetTrackDownloadInfoAsync(string trackId, CancellationToken cancellationToken)
{
return await QueueRequestAsync(async () =>
{
// Use round-robin with fallback instead of racing to reduce CPU usage
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
return await TryWithFallbackAsync(async (baseUrl) =>
{
// Map quality settings to Tidal's quality levels per hifi-api spec
// Map quality settings to Tidal's quality levels
var quality = _squidwtfSettings.Quality?.ToUpperInvariant() switch
{
"FLAC" => "LOSSLESS",
@@ -259,7 +189,7 @@ public class SquidWTFDownloadService : BaseDownloadService
var url = $"{baseUrl}/track/?id={trackId}&quality={quality}";
Logger.LogDebug("Fetching track download info from: {Url}", url);
Console.WriteLine($"%%%%%%%%%%%%%%%%%%% URL For downloads??: {url}");
var response = await _httpClient.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
@@ -297,7 +227,8 @@ public class SquidWTFDownloadService : BaseDownloadService
? audioQualityEl.GetString()
: "LOSSLESS";
Logger.LogInformation("Track download URL obtained from hifi-api: {Url}", downloadUrl);
Logger.LogDebug("Decoded manifest - URL: {Url}, MIME: {MimeType}, Quality: {Quality}",
downloadUrl, mimeType, audioQuality);
return new DownloadResult
{
@@ -309,56 +240,29 @@ public class SquidWTFDownloadService : BaseDownloadService
});
}
#endregion
#region Utility Methods
/// <summary>
/// Converts Tidal track ID to Spotify ID for lyrics support.
/// Called in background after streaming starts.
/// Also prefetches lyrics immediately after conversion.
/// </summary>
protected override async Task ConvertToSpotifyIdAsync(string externalProvider, string externalId)
{
if (externalProvider != "squidwtf")
{
return;
}
var spotifyId = await _odesliService.ConvertTidalToSpotifyIdAsync(externalId, CancellationToken.None);
if (!string.IsNullOrEmpty(spotifyId))
{
Logger.LogDebug("Background Spotify ID obtained for Tidal/{TrackId}: {SpotifyId}", externalId, spotifyId);
// Immediately prefetch lyrics now that we have the Spotify ID
// This ensures lyrics are cached and ready when the client requests them
_ = Task.Run(async () =>
private async Task<T> QueueRequestAsync<T>(Func<Task<T>> action)
{
await _requestLock.WaitAsync();
try
{
using var scope = _serviceProvider.CreateScope();
var spotifyLyricsService = scope.ServiceProvider.GetService<SpotifyLyricsService>();
var now = DateTime.UtcNow;
var timeSinceLastRequest = (now - _lastRequestTime).TotalMilliseconds;
if (spotifyLyricsService != null)
if (timeSinceLastRequest < _minRequestIntervalMs)
{
var lyrics = await spotifyLyricsService.GetLyricsByTrackIdAsync(spotifyId);
if (lyrics != null && lyrics.Lines.Count > 0)
await Task.Delay((int)(_minRequestIntervalMs - timeSinceLastRequest));
}
_lastRequestTime = DateTime.UtcNow;
return await action();
}
finally
{
Logger.LogDebug("Background lyrics prefetched for Spotify/{SpotifyId}: {LineCount} lines",
spotifyId, lyrics.Lines.Count);
}
else
{
Logger.LogDebug("No lyrics available for Spotify/{SpotifyId}", spotifyId);
}
}
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Background lyrics prefetch failed for Spotify/{SpotifyId}", spotifyId);
}
});
_requestLock.Release();
}
}

View File

@@ -12,41 +12,7 @@ using System.Text.Json.Nodes;
namespace allstarr.Services.SquidWTF;
/// <summary>
/// Metadata service implementation using the SquidWTF API (free, no key required).
///
/// SquidWTF is a proxy to Tidal's API that provides free access to Tidal's music catalog.
/// This implementation follows the hifi-api specification documented at the forked repository.
///
/// API Endpoints (per hifi-api spec):
/// - GET /search/?s={query} - Search tracks (returns data.items array)
/// - GET /search/?a={query} - Search artists (returns data.artists.items array)
/// - GET /search/?al={query} - Search albums (returns data.albums.items array, undocumented)
/// - GET /search/?p={query} - Search playlists (returns data.playlists.items array, undocumented)
/// - GET /info/?id={trackId} - Get track metadata (returns data object with full track info)
/// - GET /track/?id={trackId}&quality={quality} - Get track download info (returns manifest)
/// - GET /album/?id={albumId} - Get album with tracks (undocumented, returns data.items array)
/// - GET /artist/?f={artistId} - Get artist with albums (undocumented, returns albums.items array)
/// - GET /playlist/?id={playlistId} - Get playlist with tracks (undocumented)
///
/// Quality Options:
/// - HI_RES_LOSSLESS: 24-bit/192kHz FLAC
/// - LOSSLESS: 16-bit/44.1kHz FLAC
/// - HIGH: 320kbps AAC
/// - LOW: 96kbps AAC
///
/// Response Structure:
/// All responses follow: { "version": "2.0", "data": { ... } }
/// Track objects include: id, title, duration, trackNumber, volumeNumber, explicit, bpm, isrc,
/// artist (singular), artists (array), album (object with id, title, cover UUID)
/// Cover art URLs: https://resources.tidal.com/images/{uuid-with-slashes}/{size}.jpg
///
/// Features:
/// - Round-robin load balancing across multiple mirror endpoints
/// - Automatic failover to backup endpoints on failure
/// - Racing endpoints for fastest response on latency-sensitive operations
/// - Redis caching for albums and artists (24-hour TTL)
/// - Explicit content filtering support
/// - Parallel Spotify ID conversion via Odesli for lyrics matching
/// Metadata service implementation using the SquidWTF API (free, no key required)
/// </summary>
public class SquidWTFMetadataService : IMusicMetadataService
@@ -55,7 +21,9 @@ public class SquidWTFMetadataService : IMusicMetadataService
private readonly SubsonicSettings _settings;
private readonly ILogger<SquidWTFMetadataService> _logger;
private readonly RedisCacheService _cache;
private readonly RoundRobinFallbackHelper _fallbackHelper;
private readonly List<string> _apiUrls;
private int _currentUrlIndex = 0;
private readonly object _urlIndexLock = new object();
public SquidWTFMetadataService(
IHttpClientFactory httpClientFactory,
@@ -69,33 +37,79 @@ public class SquidWTFMetadataService : IMusicMetadataService
_settings = settings.Value;
_logger = logger;
_cache = cache;
_fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF");
_apiUrls = apiUrls;
// Set up default headers
_httpClient.DefaultRequestHeaders.Add("User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0");
// Increase timeout for large artist/album responses (some artists have 100+ albums)
_httpClient.Timeout = TimeSpan.FromMinutes(5);
}
/// <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)
{
// 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++)
{
var urlIndex = (startIndex + attempt) % _apiUrls.Count;
var baseUrl = _apiUrls[urlIndex];
try
{
_logger.LogDebug("Trying endpoint {Endpoint} (attempt {Attempt}/{Total})",
baseUrl, attempt + 1, _apiUrls.Count);
return await action(baseUrl);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Request failed with endpoint {Endpoint}, trying next...", baseUrl);
if (attempt == _apiUrls.Count - 1)
{
_logger.LogError("All {Count} SquidWTF endpoints failed", _apiUrls.Count);
return defaultValue;
}
}
}
return defaultValue;
}
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20)
{
// Race all endpoints for fastest search results
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
return await TryWithFallbackAsync(async (baseUrl) =>
{
// Use 's' parameter for track search as per hifi-api spec
var url = $"{baseUrl}/search/?s={Uri.EscapeDataString(query)}";
var response = await _httpClient.GetAsync(url, ct);
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"HTTP {response.StatusCode}");
}
var json = await response.Content.ReadAsStringAsync(ct);
var json = await response.Content.ReadAsStringAsync();
// Check for error in response body
var result = JsonDocument.Parse(json);
@@ -106,7 +120,6 @@ public class SquidWTFMetadataService : IMusicMetadataService
}
var songs = new List<Song>();
// Per hifi-api spec: track search returns data.items array
if (result.RootElement.TryGetProperty("data", out var data) &&
data.TryGetProperty("items", out var items))
{
@@ -116,36 +129,30 @@ public class SquidWTFMetadataService : IMusicMetadataService
if (count >= limit) break;
var song = ParseTidalTrack(track);
if (ShouldIncludeSong(song))
{
songs.Add(song);
}
count++;
}
}
return songs;
});
}, new List<Song>());
}
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20)
{
// Race all endpoints for fastest search results
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
return await TryWithFallbackAsync(async (baseUrl) =>
{
// Note: hifi-api doesn't document album search, but 'al' parameter is commonly used
var url = $"{baseUrl}/search/?al={Uri.EscapeDataString(query)}";
var response = await _httpClient.GetAsync(url, ct);
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"HTTP {response.StatusCode}");
return new List<Album>();
}
var json = await response.Content.ReadAsStringAsync(ct);
var json = await response.Content.ReadAsStringAsync();
var result = JsonDocument.Parse(json);
var albums = new List<Album>();
// Per hifi-api spec: album search returns data.albums.items array
if (result.RootElement.TryGetProperty("data", out var data) &&
data.TryGetProperty("albums", out var albumsObj) &&
albumsObj.TryGetProperty("items", out var items))
@@ -161,31 +168,25 @@ public class SquidWTFMetadataService : IMusicMetadataService
}
return albums;
});
}, new List<Album>());
}
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20)
{
// Race all endpoints for fastest search results
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
return await TryWithFallbackAsync(async (baseUrl) =>
{
// Per hifi-api spec: use 'a' parameter for artist search
var url = $"{baseUrl}/search/?a={Uri.EscapeDataString(query)}";
_logger.LogInformation("🔍 SQUIDWTF: Searching artists with URL: {Url}", url);
var response = await _httpClient.GetAsync(url, ct);
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("⚠️ SQUIDWTF: Artist search failed with status {StatusCode}", response.StatusCode);
throw new HttpRequestException($"HTTP {response.StatusCode}");
return new List<Artist>();
}
var json = await response.Content.ReadAsStringAsync(ct);
var json = await response.Content.ReadAsStringAsync();
var result = JsonDocument.Parse(json);
var artists = new List<Artist>();
// Per hifi-api spec: artist search returns data.artists.items array
if (result.RootElement.TryGetProperty("data", out var data) &&
data.TryGetProperty("artists", out var artistsObj) &&
artistsObj.TryGetProperty("items", out var items))
@@ -195,23 +196,19 @@ public class SquidWTFMetadataService : IMusicMetadataService
{
if (count >= limit) break;
var parsedArtist = ParseTidalArtist(artist);
artists.Add(parsedArtist);
_logger.LogDebug("🎤 SQUIDWTF: Found artist: {Name} (ID: {Id})", parsedArtist.Name, parsedArtist.ExternalId);
artists.Add(ParseTidalArtist(artist));
count++;
}
}
_logger.LogInformation("✓ SQUIDWTF: Artist search returned {Count} results", artists.Count);
return artists;
});
}, new List<Artist>());
}
public async Task<List<ExternalPlaylist>> SearchPlaylistsAsync(string query, int limit = 20)
{
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
return await TryWithFallbackAsync(async (baseUrl) =>
{
// Per hifi-api spec: use 'p' parameter for playlist search
var url = $"{baseUrl}/search/?p={Uri.EscapeDataString(query)}";
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode) return new List<ExternalPlaylist>();
@@ -220,20 +217,15 @@ public class SquidWTFMetadataService : IMusicMetadataService
var result = JsonDocument.Parse(json);
var playlists = new List<ExternalPlaylist>();
// Per hifi-api spec: playlist search returns data.playlists.items array
if (result.RootElement.TryGetProperty("data", out var data) &&
data.TryGetProperty("playlists", out var playlistObj) &&
playlistObj.TryGetProperty("items", out var items))
{
int count = 0;
foreach(var playlist in items.EnumerateArray())
{
if (count >= limit) break;
try
{
playlists.Add(ParseTidalPlaylist(playlist));
count++;
}
catch (Exception ex)
{
@@ -269,9 +261,8 @@ public class SquidWTFMetadataService : IMusicMetadataService
{
if (externalProvider != "squidwtf") return null;
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
return await TryWithFallbackAsync(async (baseUrl) =>
{
// Per hifi-api spec: GET /info/?id={trackId} returns track metadata
var url = $"{baseUrl}/info/?id={externalId}";
var response = await _httpClient.GetAsync(url);
@@ -280,16 +271,10 @@ public class SquidWTFMetadataService : IMusicMetadataService
var json = await response.Content.ReadAsStringAsync();
var result = JsonDocument.Parse(json);
// Per hifi-api spec: response is { "version": "2.0", "data": { track object } }
if (!result.RootElement.TryGetProperty("data", out var track))
return null;
var song = ParseTidalTrackFull(track);
// NOTE: Spotify ID conversion happens during download (in SquidWTFDownloadService)
// This avoids redundant conversions and ensures it's done in parallel with the download
return song;
return ParseTidalTrackFull(track);
}, (Song?)null);
}
@@ -302,9 +287,8 @@ public class SquidWTFMetadataService : IMusicMetadataService
var cached = await _cache.GetAsync<Album>(cacheKey);
if (cached != null) return cached;
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
return await TryWithFallbackAsync(async (baseUrl) =>
{
// Note: hifi-api doesn't document album endpoint, but /album/?id={albumId} is commonly used
var url = $"{baseUrl}/album/?id={externalId}";
var response = await _httpClient.GetAsync(url);
@@ -313,18 +297,17 @@ public class SquidWTFMetadataService : IMusicMetadataService
var json = await response.Content.ReadAsStringAsync();
var result = JsonDocument.Parse(json);
// Response structure: { "data": { album object with "items" array of tracks } }
if (!result.RootElement.TryGetProperty("data", out var albumElement))
return null;
var album = ParseTidalAlbum(albumElement);
// Get album tracks from items array
// Get album tracks
if (albumElement.TryGetProperty("items", out var tracks))
{
foreach (var trackWrapper in tracks.EnumerateArray())
{
// Each item is wrapped: { "item": { track object } }
if (trackWrapper.TryGetProperty("item", out var track))
{
var song = ParseTidalTrack(track);
@@ -358,9 +341,8 @@ public class SquidWTFMetadataService : IMusicMetadataService
return cached;
}
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
return await TryWithFallbackAsync(async (baseUrl) =>
{
// Note: hifi-api doesn't document artist endpoint, but /artist/?f={artistId} is commonly used
var url = $"{baseUrl}/artist/?f={externalId}";
_logger.LogInformation("Fetching artist from {Url}", url);
@@ -378,23 +360,18 @@ public class SquidWTFMetadataService : IMusicMetadataService
JsonElement? artistSource = null;
int albumCount = 0;
// Response structure: { "albums": { "items": [ album objects ] }, "tracks": [ track objects ] }
// Extract artist info from albums.items[0].artist (most reliable source)
// Think this can maybe switch to something using ParseTidalAlbum
if (result.RootElement.TryGetProperty("albums", out var albums) &&
albums.TryGetProperty("items", out var albumItems) &&
albumItems.GetArrayLength() > 0)
{
albumCount = albumItems.GetArrayLength();
if (albumItems[0].TryGetProperty("artist", out var artistEl))
{
artistSource = artistEl;
artistSource = albumItems[0].GetProperty("artist");
_logger.LogInformation("Found artist from albums, albumCount={AlbumCount}", albumCount);
}
}
// Fallback: try to get artist from tracks[0].artists[0]
if (artistSource == null &&
result.RootElement.TryGetProperty("tracks", out var tracks) &&
// Think this can maybe switch to something using ParseTidalTrack
else if (result.RootElement.TryGetProperty("tracks", out var tracks) &&
tracks.GetArrayLength() > 0 &&
tracks[0].TryGetProperty("artists", out var artists) &&
artists.GetArrayLength() > 0)
@@ -405,13 +382,11 @@ public class SquidWTFMetadataService : IMusicMetadataService
if (artistSource == null)
{
_logger.LogDebug("Could not find artist data in response. Response keys: {Keys}",
string.Join(", ", result.RootElement.EnumerateObject().Select(p => p.Name)));
_logger.LogWarning("Could not find artist data in response");
return null;
}
var artistElement = artistSource.Value;
// Normalize artist data to include album count
var normalizedArtist = new JsonObject
{
["id"] = artistElement.GetProperty("id").GetInt64(),
@@ -436,11 +411,10 @@ public class SquidWTFMetadataService : IMusicMetadataService
{
if (externalProvider != "squidwtf") return new List<Album>();
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
return await TryWithFallbackAsync(async (baseUrl) =>
{
_logger.LogInformation("GetArtistAlbumsAsync called for SquidWTF artist {ExternalId}", externalId);
// Note: hifi-api doesn't document artist endpoint, but /artist/?f={artistId} is commonly used
var url = $"{baseUrl}/artist/?f={externalId}";
_logger.LogInformation("Fetching artist albums from URL: {Url}", url);
var response = await _httpClient.GetAsync(url);
@@ -457,7 +431,6 @@ public class SquidWTFMetadataService : IMusicMetadataService
var albums = new List<Album>();
// Response structure: { "albums": { "items": [ album objects ] } }
if (result.RootElement.TryGetProperty("albums", out var albumsObj) &&
albumsObj.TryGetProperty("items", out var items))
{
@@ -483,9 +456,8 @@ public class SquidWTFMetadataService : IMusicMetadataService
{
if (externalProvider != "squidwtf") return null;
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
return await TryWithFallbackAsync(async (baseUrl) =>
{
// Note: hifi-api doesn't document playlist endpoint, but /playlist/?id={playlistId} is commonly used
var url = $"{baseUrl}/playlist/?id={externalId}";
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode) return null;
@@ -493,10 +465,8 @@ public class SquidWTFMetadataService : IMusicMetadataService
var json = await response.Content.ReadAsStringAsync();
var playlistElement = JsonDocument.Parse(json).RootElement;
// Check for error response
if (playlistElement.TryGetProperty("error", out _)) return null;
// Response structure: { "playlist": { playlist object }, "items": [ track wrappers ] }
return ParseTidalPlaylist(playlistElement);
}, (ExternalPlaylist?)null);
}
@@ -505,9 +475,8 @@ public class SquidWTFMetadataService : IMusicMetadataService
{
if (externalProvider != "squidwtf") return new List<Song>();
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
return await TryWithFallbackAsync(async (baseUrl) =>
{
// Note: hifi-api doesn't document playlist endpoint, but /playlist/?id={playlistId} is commonly used
var url = $"{baseUrl}/playlist/?id={externalId}";
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode) return new List<Song>();
@@ -515,13 +484,11 @@ public class SquidWTFMetadataService : IMusicMetadataService
var json = await response.Content.ReadAsStringAsync();
var playlistElement = JsonDocument.Parse(json).RootElement;
// Check for error response
if (playlistElement.TryGetProperty("error", out _)) return new List<Song>();
JsonElement? playlist = null;
JsonElement? tracks = null;
// Response structure: { "playlist": { playlist object }, "items": [ track wrappers ] }
if (playlistElement.TryGetProperty("playlist", out var playlistEl))
{
playlist = playlistEl;
@@ -544,7 +511,6 @@ public class SquidWTFMetadataService : IMusicMetadataService
int trackIndex = 1;
foreach (var entry in tracks.Value.EnumerateArray())
{
// Each item is wrapped: { "item": { track object } }
if (!entry.TryGetProperty("item", out var track))
continue;
@@ -567,14 +533,6 @@ public class SquidWTFMetadataService : IMusicMetadataService
// --- Parser functions start here ---
/// <summary>
/// Parses a Tidal track object from hifi-api search/album/playlist responses.
/// Per hifi-api spec, track objects contain: id, title, duration, trackNumber, volumeNumber,
/// explicit, artist (singular), artists (array), album (object with id, title, cover).
/// </summary>
/// <param name="track">JSON element containing track data</param>
/// <param name="fallbackTrackNumber">Optional track number to use if not present in JSON</param>
/// <returns>Parsed Song object</returns>
private Song ParseTidalTrack(JsonElement track, int? fallbackTrackNumber = null)
{
var externalId = track.GetProperty("id").GetInt64().ToString();
@@ -593,36 +551,26 @@ public class SquidWTFMetadataService : IMusicMetadataService
? volNum.GetInt32()
: null;
// Get all artists - Tidal provides both "artist" (singular) and "artists" (plural array)
var allArtists = new List<string>();
// Get artist name - handle both single artist and artists array
string artistName = "";
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))
if (track.TryGetProperty("artist", out var artist))
{
artistName = artist.GetProperty("name").GetString() ?? "";
artistId = $"ext-squidwtf-artist-{artist.GetProperty("id").GetInt64()}";
allArtists.Add(artistName);
}
else if (track.TryGetProperty("artists", out var artists) && artists.GetArrayLength() > 0)
{
artistName = artists[0].GetProperty("name").GetString() ?? "";
}
// Get artist ID
string? artistId = null;
if (track.TryGetProperty("artist", out var artistForId))
{
artistId = $"ext-squidwtf-artist-{artistForId.GetProperty("id").GetInt64()}";
}
else if (track.TryGetProperty("artists", out var artistsForId) && artistsForId.GetArrayLength() > 0)
{
artistId = $"ext-squidwtf-artist-{artistsForId[0].GetProperty("id").GetInt64()}";
}
// Get album info
@@ -648,7 +596,6 @@ public class SquidWTFMetadataService : IMusicMetadataService
Title = track.GetProperty("title").GetString() ?? "",
Artist = artistName,
ArtistId = artistId,
Artists = allArtists,
Album = albumTitle,
AlbumId = albumId,
Duration = track.TryGetProperty("duration", out var duration)
@@ -664,13 +611,6 @@ public class SquidWTFMetadataService : IMusicMetadataService
};
}
/// <summary>
/// Parses a full Tidal track object from hifi-api /info/ endpoint.
/// Per hifi-api spec, full track objects include additional metadata: bpm, isrc, key, keyScale,
/// streamStartDate (for year), copyright, replayGain, peak, audioQuality, audioModes.
/// </summary>
/// <param name="track">JSON element containing full track data</param>
/// <returns>Parsed Song object with extended metadata</returns>
private Song ParseTidalTrackFull(JsonElement track)
{
var externalId = track.GetProperty("id").GetInt64().ToString();
@@ -709,34 +649,9 @@ public class SquidWTFMetadataService : IMusicMetadataService
}
}
// Get all artists - prefer "artists" array for collaborations
var allArtists = new List<string>();
string artistName = "";
long artistIdNum = 0;
if (track.TryGetProperty("artists", out var artists) && artists.GetArrayLength() > 0)
{
foreach (var artistEl in artists.EnumerateArray())
{
var name = artistEl.GetProperty("name").GetString();
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);
}
// Get artist info
string artistName = track.GetProperty("artist").GetProperty("name").GetString() ?? "";
long artistIdNum = track.GetProperty("artist").GetProperty("id").GetInt64();
// Album artist - same as main artist for Tidal tracks
string? albumArtist = artistName;
@@ -770,7 +685,6 @@ public class SquidWTFMetadataService : IMusicMetadataService
Title = track.GetProperty("title").GetString() ?? "",
Artist = artistName,
ArtistId = $"ext-squidwtf-artist-{artistIdNum}",
Artists = allArtists,
Album = albumTitle,
AlbumId = $"ext-squidwtf-album-{albumIdNum}",
AlbumArtist = albumArtist,
@@ -792,13 +706,6 @@ public class SquidWTFMetadataService : IMusicMetadataService
};
}
/// <summary>
/// Parses a Tidal album object from hifi-api responses.
/// Per hifi-api spec, album objects contain: id, title, releaseDate, numberOfTracks,
/// cover (UUID), artist (object) or artists (array).
/// </summary>
/// <param name="album">JSON element containing album data</param>
/// <returns>Parsed Album object</returns>
private Album ParseTidalAlbum(JsonElement album)
{
var externalId = album.GetProperty("id").GetInt64().ToString();
@@ -852,13 +759,8 @@ public class SquidWTFMetadataService : IMusicMetadataService
};
}
/// <summary>
/// Parses a Tidal artist object from hifi-api responses.
/// Per hifi-api spec, artist objects contain: id, name, picture (UUID).
/// Note: albums_count is not in the standard API response but is added by GetArtistAsync.
/// </summary>
/// <param name="artist">JSON element containing artist data</param>
/// <returns>Parsed Artist object</returns>
// TODO: Think of a way to implement album count when this function is called by search function
// as the API endpoint in search does not include this data
private Artist ParseTidalArtist(JsonElement artist)
{
var externalId = artist.GetProperty("id").GetInt64().ToString();
@@ -884,14 +786,6 @@ public class SquidWTFMetadataService : IMusicMetadataService
};
}
/// <summary>
/// Parses a Tidal playlist from hifi-api /playlist/ endpoint response.
/// Per hifi-api spec (undocumented), response structure is:
/// { "playlist": { uuid, title, description, creator, created, numberOfTracks, duration, squareImage },
/// "items": [ { "item": { track object } } ] }
/// </summary>
/// <param name="playlistElement">Root JSON element containing playlist and items</param>
/// <returns>Parsed ExternalPlaylist object</returns>
private ExternalPlaylist ParseTidalPlaylist(JsonElement playlistElement)
{
JsonElement? playlist = null;

View File

@@ -3,7 +3,6 @@ using System.Text.Json;
using Microsoft.Extensions.Options;
using allstarr.Models.Settings;
using allstarr.Services.Validation;
using allstarr.Services.Common;
namespace allstarr.Services.SquidWTF;
@@ -13,26 +12,56 @@ namespace allstarr.Services.SquidWTF;
public class SquidWTFStartupValidator : BaseStartupValidator
{
private readonly SquidWTFSettings _settings;
private readonly RoundRobinFallbackHelper _fallbackHelper;
private readonly EndpointBenchmarkService _benchmarkService;
private readonly ILogger<SquidWTFStartupValidator> _logger;
private readonly List<string> _apiUrls;
private int _currentUrlIndex = 0;
private readonly object _urlIndexLock = new object();
public override string ServiceName => "SquidWTF";
public SquidWTFStartupValidator(
IOptions<SquidWTFSettings> settings,
HttpClient httpClient,
List<string> apiUrls,
EndpointBenchmarkService benchmarkService,
ILogger<SquidWTFStartupValidator> logger)
public SquidWTFStartupValidator(IOptions<SquidWTFSettings> settings, HttpClient httpClient, List<string> apiUrls)
: base(httpClient)
{
_settings = settings.Value;
_fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF");
_benchmarkService = benchmarkService;
_logger = logger;
_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)
{
// 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++)
{
var urlIndex = (startIndex + attempt) % _apiUrls.Count;
var baseUrl = _apiUrls[urlIndex];
try
{
return await action(baseUrl);
}
catch
{
WriteDetail($"Endpoint {baseUrl} failed, trying next...");
if (attempt == _apiUrls.Count - 1)
{
WriteDetail($"All {_apiUrls.Count} endpoints failed");
return defaultValue;
}
}
}
return defaultValue;
}
public override async Task<ValidationResult> ValidateAsync(CancellationToken cancellationToken)
{
@@ -50,49 +79,8 @@ public class SquidWTFStartupValidator : BaseStartupValidator
WriteStatus("SquidWTF Quality", quality, ConsoleColor.Cyan);
// Benchmark all endpoints to determine fastest
var apiUrls = _fallbackHelper.EndpointCount > 0
? Enumerable.Range(0, _fallbackHelper.EndpointCount).Select(_ => "").ToList() // Placeholder, we'll get actual URLs from fallback helper
: new List<string>();
// Get the actual API URLs by reflection (not ideal, but works for now)
var fallbackHelperType = _fallbackHelper.GetType();
var apiUrlsField = fallbackHelperType.GetField("_apiUrls", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
if (apiUrlsField != null)
{
apiUrls = (List<string>)apiUrlsField.GetValue(_fallbackHelper)!;
}
if (apiUrls.Count > 1)
{
WriteStatus("Benchmarking Endpoints", $"{apiUrls.Count} endpoints", ConsoleColor.Cyan);
var orderedEndpoints = await _benchmarkService.BenchmarkEndpointsAsync(
apiUrls,
async (endpoint, ct) =>
{
try
{
var response = await _httpClient.GetAsync(endpoint, ct);
return response.IsSuccessStatusCode;
}
catch
{
return false;
}
},
pingCount: 2,
cancellationToken);
if (orderedEndpoints.Count > 0)
{
_fallbackHelper.SetEndpointOrder(orderedEndpoints);
WriteDetail($"Fastest endpoint: {orderedEndpoints.First()}");
}
}
// Test connectivity with fallback
var result = await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
var result = await TryWithFallbackAsync(async (baseUrl) =>
{
var response = await _httpClient.GetAsync(baseUrl, cancellationToken);
@@ -119,8 +107,8 @@ public class SquidWTFStartupValidator : BaseStartupValidator
{
try
{
// Test search with "22" by Taylor Swift
var searchUrl = $"{baseUrl}/search/?s=22%20Taylor%20Swift";
// Test search with a simple query
var searchUrl = $"{baseUrl}/search/?s=Taylor%20Swift";
var searchResponse = await _httpClient.GetAsync(searchUrl, cancellationToken);
if (searchResponse.IsSuccessStatusCode)
@@ -133,36 +121,7 @@ public class SquidWTFStartupValidator : BaseStartupValidator
{
var itemCount = items.GetArrayLength();
WriteStatus("Search Functionality", "WORKING", ConsoleColor.Green);
WriteDetail($"Test search for '22' by Taylor Swift returned {itemCount} results");
// Check if we found the actual song
bool foundTaylorSwift22 = false;
foreach (var item in items.EnumerateArray())
{
if (item.TryGetProperty("title", out var title) &&
item.TryGetProperty("artists", out var artists) &&
artists.GetArrayLength() > 0)
{
var titleStr = title.GetString() ?? "";
var artistName = artists[0].TryGetProperty("name", out var name)
? name.GetString() ?? ""
: "";
if (titleStr.Contains("22", StringComparison.OrdinalIgnoreCase) &&
artistName.Contains("Taylor Swift", StringComparison.OrdinalIgnoreCase))
{
foundTaylorSwift22 = true;
var trackId = item.TryGetProperty("id", out var id) ? id.GetInt64() : 0;
WriteDetail($"✓ Found: '{titleStr}' by {artistName} (ID: {trackId})");
break;
}
}
}
if (!foundTaylorSwift22)
{
WriteDetail("⚠ Could not find exact match for '22' by Taylor Swift in results");
}
WriteDetail($"Test search returned {itemCount} results");
}
else
{

View File

@@ -39,12 +39,6 @@ public class SubsonicProxyService
var body = await response.Content.ReadAsByteArrayAsync();
var contentType = response.Content.Headers.ContentType?.ToString();
// Trigger GC for large files to prevent memory leaks
if (body.Length > 1024 * 1024) // 1MB threshold
{
GC.Collect(2, GCCollectionMode.Optimized, blocking: false);
}
return (body, contentType);
}

View File

@@ -22,13 +22,9 @@ public class StartupValidationOrchestrator : IHostedService
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($" allstarr v{version} ");
Console.WriteLine(" allstarr starting up... ");
Console.WriteLine("========================================");
Console.WriteLine();

View File

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

View File

@@ -2,9 +2,7 @@
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"System.Net.Http.HttpClient.Default.LogicalHandler": "Warning",
"System.Net.Http.HttpClient.Default.ClientHandler": "Warning"
"Microsoft.AspNetCore": "Warning"
}
},
"SpotifyImport": {
@@ -12,6 +10,17 @@
"SyncStartHour": 16,
"SyncStartMinute": 15,
"SyncWindowHours": 2,
"Playlists": []
"Playlists": [
{
"Name": "Release Radar",
"SpotifyName": "Release Radar",
"Enabled": true
},
{
"Name": "Discover Weekly",
"SpotifyName": "Discover Weekly",
"Enabled": true
}
]
}
}

View File

@@ -1,12 +1,4 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"System.Net.Http.HttpClient.Default.LogicalHandler": "Warning",
"System.Net.Http.HttpClient.Default.ClientHandler": "Warning"
}
},
"Backend": {
"Type": "Subsonic"
},
@@ -32,8 +24,7 @@
"EnableExternalPlaylists": true
},
"Library": {
"DownloadPath": "./downloads",
"KeptPath": "/app/kept"
"DownloadPath": "./downloads"
},
"Qobuz": {
"UserAuthToken": "your-qobuz-token",
@@ -57,23 +48,17 @@
"SyncStartHour": 16,
"SyncStartMinute": 15,
"SyncWindowHours": 2,
"MatchingIntervalHours": 24,
"Playlists": []
"Playlists": [
{
"Name": "Release Radar",
"SpotifyName": "Release Radar",
"Enabled": true
},
"SpotifyApi": {
"Enabled": false,
"ClientId": "",
"ClientSecret": "",
"SessionCookie": "",
"CacheDurationMinutes": 60,
"RateLimitDelayMs": 100,
"PreferIsrcMatching": true
},
"MusicBrainz": {
"Enabled": true,
"Username": "",
"Password": "",
"BaseUrl": "https://musicbrainz.org/ws/2",
"RateLimitMs": 1000
{
"Name": "Discover Weekly",
"SpotifyName": "Discover Weekly",
"Enabled": true
}
]
}
}

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

View File

@@ -17,17 +17,6 @@ services:
networks:
- allstarr-network
spotify-lyrics:
image: akashrchandran/spotify-lyrics-api:latest
container_name: allstarr-spotify-lyrics
restart: unless-stopped
ports:
- "8365:8080"
environment:
- SP_DC=${SPOTIFY_API_SESSION_COOKIE:-}
networks:
- allstarr-network
allstarr:
# Use pre-built image from GitHub Container Registry
# For latest stable: ghcr.io/sopat712/allstarr:latest
@@ -45,14 +34,9 @@ services:
restart: unless-stopped
ports:
- "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:
redis:
condition: service_healthy
spotify-lyrics:
condition: service_started
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
@@ -97,22 +81,8 @@ services:
- 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}
# Spotify Lyrics API sidecar service URL (internal)
- SpotifyApi__LyricsApiUrl=${SPOTIFY_LYRICS_API_URL:-http://spotify-lyrics:8080}
# ===== SHARED =====
- Library__DownloadPath=/app/downloads
@@ -127,10 +97,6 @@ services:
- ${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:
allstarr-network: