Compare commits

..

148 Commits

Author SHA1 Message Date
6ea03b8005 fix: add proper local Jellyfin mapping modal for Map to Local button
Some checks are pending
CI / build-and-test (push) Waiting to run
Map to Local now opens a Jellyfin search modal instead of the external provider modal.
2026-02-09 18:29:15 -05:00
1369d09cbd fix: invalidate playlist cache when schedule is updated
Playlist summary cache now refreshes immediately when sync schedules are changed.
2026-02-09 18:24:59 -05:00
838151741f fix: show correct track counts and IPs in admin UI
Fixed Spotify playlists showing 0 tracks, filtered out playlist folders, and corrected session IPs to use X-Forwarded-For header.
2026-02-09 18:16:38 -05:00
88bf083386 Fix GraphQL query for fetching user playlists - use libraryV3
- Changed from fetchLibraryPlaylists to libraryV3 operation (correct Spotify GraphQL endpoint)
- Use GET request with query params instead of POST (matches Jellyfin plugin implementation)
- Updated response parsing to match libraryV3 structure (me.libraryV3.items[].item.data)
- Fixed owner field to use 'username' instead of 'name'
- This should resolve the BadRequest (400) errors when fetching user playlists
2026-02-09 16:36:10 -05:00
670544a9d6 Fix AdminController Spotify 429 rate limiting in Link Playlists tab
- Replace direct REST API calls with SpotifyApiClient GraphQL method
- Add GetUserPlaylistsAsync() method to fetch all user playlists via GraphQL
- GraphQL endpoint is much less rate-limited than REST API /me/playlists
- Enhanced playlist data with track count, owner, and image URL from GraphQL
- Simplified AdminController code by delegating to SpotifyApiClient
2026-02-09 16:32:18 -05:00
0dca6b792d Fix Spotify 429 rate limiting and startup performance issues
- Fix: Use correct HttpClient (_webApiClient) for GraphQL library playlists endpoint
  - Was using _httpClient which pointed to wrong base URL causing 429 errors
- Add: Retry logic with Retry-After header support for 429 responses
- Add: Minimum 500ms delay between library playlist pages to prevent rate limiting
- Add: 5-second timeout per endpoint benchmark ping to prevent slow endpoints from blocking startup
- Add: Documentation for timeout requirements in EndpointBenchmarkService
- Fix: ARM64 compatibility for spotify-lyrics service via platform emulation in docker-compose
2026-02-09 16:09:38 -05:00
f135db3f60 fix: use GraphQL for user playlists to avoid 429 rate limits
Some checks failed
CI / build-and-test (push) Has been cancelled
- Switched from REST API /me/playlists to GraphQL fetchLibraryPlaylists
- GraphQL endpoint is less aggressively rate-limited by Spotify
- Fixes 429 errors when using 'Select from My Playlists' dropdown
- Background services already use GraphQL and work fine
2026-02-09 15:10:14 -05:00
2b76fa9e6f Enhance README with features and images
Updated README.md to enhance feature descriptions and add images.
2026-02-09 14:54:43 -05:00
6949f8aed4 feat: implement per-playlist cron scheduling with persistent cache
- Added Cronos package for cron expression parsing
- Each playlist now has independent cron schedule (default: 0 8 * * 1)
- Cache persists until next cron run, not just cache duration
- Prevents excess Spotify API calls - only refreshes on cron trigger
- Manual refresh still allowed with 5-minute cooldown
- Added 429 rate limit handling for user playlist fetching
- Added crontab.guru link to UI for easy schedule building
- Both SpotifyPlaylistFetcher and SpotifyTrackMatchingService use cron
- Automatic matching only runs when cron schedule triggers
2026-02-09 14:23:23 -05:00
a37f7e0b1d feat: add sync schedule editing and improve Spotify rate limit handling
Renamed Active Playlists to Injected Playlists. Added sync schedule column with inline edit button. Added endpoint to update playlist sync schedules. Improved error handling for Spotify rate limits with user-friendly messages.
2026-02-09 13:22:02 -05:00
2b4cd35cf7 feat: add per-playlist cron sync schedules
Each playlist now has its own cron schedule for syncing with Spotify. Default is 0 8 * * 1 (Monday 8 AM). Removed global MatchingIntervalHours in favor of per-playlist scheduling.
2026-02-09 13:15:04 -05:00
faa07c2791 fix: resolve build errors in forwarded headers and config endpoints
Fixed duplicate builder variable, deprecated KnownNetworks property, and removed non-existent SpotifyImportSettings properties from config endpoint.
2026-02-09 13:12:21 -05:00
bdd753fd02 feat: add admin UI improvements and forwarded headers support
Enhanced admin configuration UI with missing fields, required indicators, and sp_dc warning. Added Spotify playlist selector for linking with auto-filtering of already-linked playlists. Configured forwarded headers to pass real client IPs from nginx to Jellyfin. Improved track view modal error handling.
2026-02-09 12:49:50 -05:00
0a07804833 feat: standardize download path configuration and auto-migrate .env
- Change DOWNLOAD_PATH to Library__DownloadPath (ASP.NET Core standard)
- Add EnvMigrationService to automatically update old .env files on startup
- Update .env.example with new variable name
- Ensures cache cleanup and downloads use consistent path configuration
- No manual intervention needed - migration happens automatically
2026-02-09 12:29:57 -05:00
6c14fc299c fix: prevent duplicate downloads and lock release bug
- Track lock state to prevent double-release in finally block
- Fixes exception when download is already in progress
- Prevents duplicate file downloads with (1), (2), (3) suffixes
- Ensures proper lock management during concurrent download requests
2026-02-09 12:26:08 -05:00
b03a4b85c9 fix: cache cleanup service using wrong path
- Update CacheCleanupService to use downloads/cache instead of /tmp/allstarr-cache
- Matches the actual path used by download services
- Fixes cache files not being cleaned up after expiration
2026-02-09 12:24:48 -05:00
565cb46b72 feat: add proper multi-artist support with ArtistIds list
- Add ArtistIds list to Song model to store IDs for all artists
- Update SquidWTF ParseTidalTrack and ParseTidalTrackFull to populate ArtistIds from artists array
- Update Deezer ParseDeezerTrackFull to populate ArtistIds from contributors
- Update JellyfinResponseBuilder to use real ArtistIds instead of fake IDs
- Fixes UnprocessableEntity errors when clicking on secondary artists
- Enables proper navigation to all artist pages in Jellyfin clients
2026-02-09 12:22:40 -05:00
6357b524da remove monochrome-api.samidy.com endpoint
Some checks failed
CI / build-and-test (push) Has been cancelled
Free tier account can't stream or download, only useful for metadata fallback.
2026-02-08 01:49:12 -05:00
aa9f0d0345 fix: use unified download structure for cache and permanent files
- Cache mode now uses downloads/cache/ instead of cache/Music/
- Permanent mode now uses downloads/permanent/ instead of downloads/
- Kept files already use downloads/kept/
- All download paths now unified under downloads/ base directory
2026-02-08 01:38:14 -05:00
b0e07404c9 refactor: unified download folder structure
- Changed from separate paths to unified structure under downloads/
- Structure: downloads/{permanent,cache,kept}/
- Removed Library:KeptPath config, now uses downloads/kept/
- Updated AdminController and JellyfinController to use new paths
- Web UI will now correctly show kept tracks in Active Playlists tab
- Matches user's actual folder structure on server
2026-02-08 01:25:24 -05:00
8dbf37f6a3 refactor: remove sync window logic from Spotify Import
- Simplified SpotifyMissingTracksFetcher to remove complex sync window timing
- Now fetches on startup if cache missing, then checks every 5 minutes for stale cache (>24h)
- Removed SYNC_START_HOUR, SYNC_START_MINUTE, SYNC_WINDOW_HOURS from config
- Updated README and .env.example to reflect simpler configuration
- Sync window was only relevant for legacy Jellyfin plugin scraping method
- When using sp_dc cookie method (recommended), this service is dormant anyway
- Deleted MIGRATION.md (local-only file, not for repo)
2026-02-08 01:21:45 -05:00
baab1e88a5 docs: update README and .env.example with new download structure
- Reorganize downloads into downloads/{permanent,cache,kept}
- Update Spotify Import configuration (keep sync window settings)
- Expand Jellyfin API endpoints documentation (primary focus)
- Move Jellyfin backend section before Subsonic
- Simplify Spotify Import documentation
- Add all manual API trigger endpoints
- Update download folder structure diagram
- Add MIGRATION.md guide for existing installations
2026-02-08 01:17:07 -05:00
972756159d feat: add quick health checks before trying endpoints
- Health checks run in parallel with 3 second timeout
- Results cached for 30 seconds to avoid excessive checks
- Healthy endpoints tried first, unhealthy ones as fallback
- Prevents wasting time on dead endpoints (no more 5 min waits)
- Failed requests mark endpoint as unhealthy in cache
- Significantly improves response time when some endpoints are down
2026-02-08 00:05:27 -05:00
f59f265ad4 feat: increase SquidWTF download service timeout to 5 minutes
- Download service was still using default 100s timeout
- Large artist responses and slow endpoints were timing out
- Now both MetadataService and DownloadService use 5 minute timeout
- Fixes 'The request was canceled due to the configured HttpClient.Timeout of 100 seconds' errors
2026-02-08 00:02:21 -05:00
bc0467b1ff feat: increase HttpClient timeout to 5 minutes for large artist responses
- Some artists have 100+ albums causing large API responses
- Default 100s timeout was insufficient
- Now set to 5 minutes to handle even the largest artist catalogs
- Prevents timeouts when fetching artists like Taylor Swift, etc.
2026-02-08 00:01:08 -05:00
e057f365f4 feat: prefetch lyrics immediately after Odesli conversion
- After Odesli converts Tidal ID to Spotify ID, immediately fetch lyrics
- Lyrics are cached and ready when client requests them
- Happens in background, doesn't block streaming
- Ensures best user experience with instant lyrics availability
2026-02-07 23:52:49 -05:00
e8eb095a23 feat: move Odesli conversion to background after streaming starts
- Override ConvertToSpotifyIdAsync in SquidWTFDownloadService
- Odesli API call now happens AFTER stream starts returning to client
- Reduces initial streaming latency by ~3-4 seconds
- Lyrics still work - Spotify ID is cached for on-demand lyrics requests
- Background conversion happens just-in-case for future lyrics needs
2026-02-07 23:51:03 -05:00
591fd5e8e1 feat: add 6 new SquidWTF endpoints and optimize Odesli conversion
- Added 6 new monochrome.tf endpoints (eu-central, us-west, arran, api, samidy)
- Added https variant of hund.qqdl.site (was only http before)
- Total endpoints increased from 10 to 16 for better load distribution
- Optimized Odesli/Spotify ID conversion with 2-second timeout
- If Odesli is slow, download proceeds without waiting (Spotify ID added in background)
- This reduces download time by up to 2 seconds when Odesli is slow
- Spotify ID still obtained for lyrics, just doesn't block streaming

Performance improvement: Downloads that took 9.7s may now complete in 7.7s
2026-02-07 23:47:36 -05:00
3e840f987b fix: improve download speed and handle concurrent requests better
- Reduced wait interval from 500ms to 100ms when waiting for in-progress downloads
- Added cancellation token checks during wait loops to handle client timeouts immediately
- Added detailed timing logs to track download performance
- Better error messages when downloads fail or are cancelled
- Prevents OperationCanceledException when client times out (typically 10 seconds)

This fixes the issue where concurrent requests for the same track would timeout
because they were waiting too long for the first download to complete.
2026-02-07 23:34:04 -05:00
56bc9d4ea9 fix: transparent proxy authentication and token expiration handling
- Remove broken JellyfinAuthFilter that was checking non-existent CLIENT_USERNAME
- Clients now authenticate directly with Jellyfin (transparent proxy model)
- Improved token expiration detection and session cleanup
- Better logging with reduced verbosity (removed emoji spam)
- Added support for X-Emby-Token header format
- Added detection of public endpoints that don't require auth
- SessionManager now properly detects 401 responses and removes expired sessions
- Clarified .env.example comments about server-side vs client-side auth
- All functionality preserved: Spotify injection, external providers, playback tracking
2026-02-07 23:25:14 -05:00
f1dd01f6d5 refactor: reduce log spam by adjusting log levels
- Change WebSocket logs to Debug/Trace (connection events, message proxying)
- Change session management logs to Debug (creation, removal, capabilities)
- Change auth header forwarding logs to Debug
- Change playback forwarding logs to Debug
- Change 401 responses to Debug (expected when tokens expire)
- Keep Info level for significant business events (external playback, downloads)
- Keep Warning/Error for actual issues

This significantly reduces log noise while maintaining visibility of important events.
2026-02-07 23:11:48 -05:00
6c06c59f61 Add enhanced logging for lyrics fetching to debug Jellyfin embedded lyrics check
Some checks failed
CI / build-and-test (push) Has been cancelled
- Log when GetLyrics endpoint is called with itemId
- Log external vs local track determination
- Log Jellyfin lyrics check status code and result
- Will help identify why embedded lyrics aren't being found
2026-02-07 20:00:19 -05:00
56f2eca867 Fix endpoint usage parsing to show actual endpoints instead of HTTP methods
- Parse column 3 (endpoint path) instead of column 2 (HTTP method)
- Combine method and endpoint for clarity (e.g., 'GET users/{userId}')
- Now shows meaningful endpoint statistics instead of just GET/POST counts
2026-02-07 17:54:35 -05:00
248ab804f3 Add API Analytics tab to WebUI for endpoint usage tracking
- New 'API Analytics' tab displays endpoint usage statistics
- Shows total requests, unique endpoints, and most called endpoint
- Table view with top N endpoints (25/50/100/500 configurable)
- Color-coded by usage intensity and endpoint type
- Auto-refreshes every 30 seconds when tab is active
- Includes clear data functionality and helpful documentation
- Uses existing /api/admin/debug/endpoint-usage endpoint
2026-02-07 17:06:59 -05:00
b1769a35bf Fix download racing, cache cleanup, and WebUI storage mode display
- Replace endpoint racing with round-robin fallback for downloads to reduce CPU usage and prevent cancellation errors
- Fix cache cleanup to use LastWriteTimeUtc instead of unreliable LastAccessTimeUtc
- Add storage mode, cache duration, and download mode to admin config endpoint
- Show actual download path based on storage mode (cache/Music vs downloads)
2026-02-07 16:45:28 -05:00
f741cc5297 fix: simplify ghost playback start to avoid Jellyfin validation errors
Some checks failed
CI / build-and-test (push) Has been cancelled
- Remove complex Item object from playback start (was causing 400 errors)
- Send minimal playback info with just ghost UUID and playback state
- Progress reports already working (204 responses)
- Jellyfin will track session without full item details
2026-02-07 13:30:47 -05:00
1a0e0216f5 feat: implement ghost item reporting for external track WebSocket sessions
- Generate deterministic UUIDs from external track IDs using MD5 hashing
- Create fake BaseItemDto objects with track metadata for external tracks
- Forward playback reports (start/progress/stop) to Jellyfin with ghost items
- Enables 'Now Playing' info in Jellyfin dashboard for external tracks
- Remove redundant JellyfinSessionManager WebSocket creation (client handles via proxy)
- Fix indentation issues in SquidWTF services (tabs to spaces)
- Add apis/*.md to .gitignore for temporary docs
- Fix null reference warning in provider switch expression
2026-02-07 13:27:25 -05:00
73bd3bf308 feat: add endpoint benchmarking on startup
- New EndpointBenchmarkService pings all endpoints on startup
- Measures average response time and success rate
- Reorders endpoints by performance (fastest first)
- RoundRobinFallbackHelper now uses benchmarked order
- Racing still happens, but starts with fastest endpoints
- Reduces latency by prioritizing known-fast servers
- Logs benchmark results for visibility
2026-02-07 12:51:48 -05:00
43bf71c390 fix: race endpoints for download metadata fetching
- GetTrackDownloadInfoAsync now uses RaceAllEndpointsAsync instead of TryWithFallbackAsync
- Prevents sequential fallback through all 10 endpoints on cancellation
- Eliminates cascade of 'task was canceled' warnings
- Consistent racing strategy across all download operations
2026-02-07 12:49:43 -05:00
2254616d32 feat: preserve source search ordering instead of re-scoring
- Respect SquidWTF/Tidal's native search ranking (better than fuzzy matching)
- Interleave local and external results based on average match quality
- Put better-matching source first, preserve original order within each source
- Remove unnecessary re-scoring that was disrupting optimal search results
- Simplifies search logic and improves result relevance
2026-02-07 12:43:15 -05:00
c0444becad feat: add endpoint racing for downloads and searches
- Race all proxy endpoints in parallel for downloads (SquidWTF)
- Use fastest responding server, cancel slower ones
- Apply same racing strategy to search operations
- Reduces download wait times from 5-10s to sub-second
- Reduces search latency from ~1s to ~300-500ms
- Add RaceAllEndpointsAsync method to RoundRobinFallbackHelper
2026-02-07 12:36:50 -05:00
b906a5fd6d Refactor: Extract duplicate code into reusable helpers
- Created RoundRobinFallbackHelper for SquidWTF services (eliminates 3 duplicates)
- Moved QueueRequestAsync to BaseDownloadService (eliminates 2 duplicates)
- Moved CalculateArtistMatchScore to FuzzyMatcher (eliminates 2 duplicates)
- Updated all SquidWTF services to use RoundRobinFallbackHelper
- Updated DeezerDownloadService and SquidWTFDownloadService to use base class rate limiting
- Updated SpotifyTrackMatchingService and JellyfinController to use FuzzyMatcher helper
- All 225 tests passing
2026-02-07 12:27:10 -05:00
e3bcc93597 Add Odesli service for Tidal to Spotify ID conversion
- Created OdesliService to convert Tidal track IDs to Spotify IDs
- Integrated Odesli API calls into SquidWTF download workflow
- Updated SquidWTFDownloadService to use OdesliService for track metadata enrichment
- Fixed dependency injection in Program.cs for OdesliService
- All 225 tests passing
2026-02-07 12:19:41 -05:00
7e6bed51e1 refactor: extract Spotify ID from URL instead of entityUniqueId
- Use linksByPlatform.spotify.url from Odesli response
- Extract track ID from Spotify URL using regex
- More reliable than parsing entityUniqueId format
- Matches the approach used in ConvertToSpotifyIdViaOdesliAsync
2026-02-07 12:06:48 -05:00
47b9427c20 fix: extract Spotify track ID from Odesli entityUniqueId format
- Odesli returns entityUniqueId as 'SPOTIFY_SONG::trackid'
- Now extracts just the track ID part after '::'
- Fixes Spotify lyrics not working due to invalid ID format
- Spotify lyrics service expects clean track IDs like '0PgYPBGqF6Wm5KFHQ81nq5'
2026-02-07 12:05:55 -05:00
bb46db43b1 fix: use persistent cache/Music folder instead of /tmp
- Cache mode now uses cache/Music/ (survives restarts, cleaned after 24h)
- Permanent mode uses downloads/ (keeps forever)
- Fixed all three download services: SquidWTF, Deezer, Qobuz
- Files no longer stored in /tmp/allstarr-cache/ which gets wiped on restart
- Both folders are in project root alongside cache/ and downloads/ directories
2026-02-07 12:02:48 -05:00
3937e637c6 feat: convert Tidal tracks to Spotify ID immediately for lyrics
- Added SpotifyId field to Song model
- SquidWTFMetadataService now calls Odesli API when fetching track metadata
- Spotify ID is populated immediately when track is loaded, not during lyrics fetch
- GetLyrics now checks song.SpotifyId first before falling back to cache/Odesli
- Enables Spotify lyrics for all SquidWTF (Tidal) tracks automatically
- Reduces latency - conversion happens once during track load, not every lyrics request
2026-02-07 11:57:24 -05:00
2272e8d363 fix: use stored session headers for WebSocket auth
- Changed MaintainWebSocketForSessionAsync to use session.Headers instead of parameter
- Parameter headers might be disposed after HTTP request completes
- Session headers are cloned and stored safely in memory
- WebSocket now properly authenticates as the client instead of falling back to server API key
- Sessions will now appear under correct user in Jellyfin dashboard
2026-02-07 11:52:17 -05:00
6169d7a4ac fix: session capabilities using disposed HTTP context
- Extract AccessToken from auth response before background task
- Create new HeaderDictionary with token instead of using Request.Headers
- Prevents ObjectDisposedException when HTTP context is disposed
- Session capabilities now work correctly for all clients

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

This fixes the issue where Spotify playlists showed 0 tracks in playlist lists
but worked correctly when accessed directly.
2026-02-04 23:31:30 -05:00
b7417614b3 Remove memory optimization markdown file 2026-02-04 23:18:38 -05:00
72b1584d51 Fix admin dashboard to show total playable tracks (local + external matched) 2026-02-04 23:16:56 -05:00
4b289e4ddd Move admin endpoints to internal port 5275 for security 2026-02-04 22:55:21 -05:00
07844cc9c5 Add GC hints to prevent memory leaks from large byte arrays 2026-02-04 22:50:35 -05:00
1601b96800 Add memory monitoring endpoint 2026-02-04 22:45:11 -05:00
7db66067f4 Complete mark-for-deletion system and memory optimization 2026-02-04 22:41:08 -05:00
f44d8652b4 Improve favorite/unfavorite logic - copy from cache, avoid re-downloads 2026-02-04 22:34:11 -05:00
49 changed files with 8702 additions and 2350 deletions

View File

@@ -18,27 +18,29 @@ SUBSONIC_URL=http://localhost:4533
# Server URL (required if using Jellyfin backend)
JELLYFIN_URL=http://localhost:8096
# API key for authentication (get from Jellyfin Dashboard > API Keys)
# 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
JELLYFIN_API_KEY=
# User ID (get from Jellyfin Dashboard > Users > click user > check URL)
# 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
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
# 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
# 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)
Library__DownloadPath=./downloads
# ===== SQUIDWTF CONFIGURATION =====
# Different quality options for SquidWTF. Only FLAC supported right now
@@ -108,27 +110,14 @@ CACHE_DURATION_HOURS=1
# ===== SPOTIFY PLAYLIST INJECTION (JELLYFIN ONLY) =====
# REQUIRES: Jellyfin Spotify Import Plugin (https://github.com/Viperinius/jellyfin-plugin-spotify-import)
# This feature intercepts Spotify Import plugin playlists (Release Radar, Discover Weekly) and fills them
# with tracks auto-matched from external providers (SquidWTF, Deezer, Qobuz)
# This feature intercepts Spotify Import plugin playlists and fills them with tracks from external providers
# Uses JELLYFIN_URL and JELLYFIN_API_KEY configured above (no separate credentials needed)
# Enable Spotify playlist injection (optional, default: false)
SPOTIFY_IMPORT_ENABLED=false
# Sync schedule: When does the Spotify Import plugin run?
# Set these to match your plugin's sync schedule in Jellyfin
# Example: If plugin runs daily at 4:15 PM, set HOUR=16 and MINUTE=15
SPOTIFY_IMPORT_SYNC_START_HOUR=16
SPOTIFY_IMPORT_SYNC_START_MINUTE=15
# Sync window: How long to search for missing tracks files (in hours)
# The fetcher will check every 5 minutes within this window
# Example: If plugin runs at 4:15 PM and window is 2 hours, checks from 4:00 PM to 6:00 PM
SPOTIFY_IMPORT_SYNC_WINDOW_HOURS=2
# Matching interval: 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 once per day is reasonable
# 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
@@ -187,3 +176,9 @@ 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

15
.gitignore vendored
View File

@@ -84,15 +84,19 @@ 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
apis/*.json
!apis/jellyfin-openapi-stable.json
# Log files for debugging
apis/*.log
apis/api-calls/*.log
# Endpoint usage tracking
apis/endpoint-usage.json
apis/api-calls/endpoint-usage.json
/app/cache/endpoint-usage/
# Original source code for reference
@@ -100,3 +104,6 @@ originals/
# Sample missing playlists for Spotify integration testing
sampleMissingPlaylists/
# Migration guide (local only)
MIGRATION.md

344
README.md
View File

@@ -5,11 +5,7 @@
[![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 (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
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.
## Quick Start
@@ -40,15 +36,17 @@ The proxy will be available at `http://localhost:5274`.
## Web Dashboard
Allstarr includes a web-based dashboard for easy configuration and playlist management, accessible at `http://localhost:5275` (internal port, not exposed through reverse proxy).
Allstarr includes a web UI for easy configuration and playlist management, accessible at `http://localhost:5275`
<img width="1664" height="1101" alt="image" src="https://github.com/user-attachments/assets/9159100b-7e11-449e-8530-517d336d6bd2" />
### Features
- **Real-time Status**: Monitor Spotify authentication, cookie age, and playlist sync status
- **Playlist Management**: Link Jellyfin playlists to Spotify playlists with a few clicks
- **Configuration Editor**: Update settings without manually editing .env files
- **Track Viewer**: Browse tracks in your configured playlists
- **Cache Management**: Clear cached data and restart the container
- **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
@@ -65,18 +63,18 @@ Allstarr includes a web-based dashboard for easy configuration and playlist mana
- `37i9dQZF1DXcBWIGoYBM5M` (just the ID)
- `spotify:playlist:37i9dQZF1DXcBWIGoYBM5M` (Spotify URI)
- `https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M` (full URL)
4. **Restart** to apply changes (button in Configuration tab)
4. **Restart** to apply changes (should be a banner)
### Why Two Playlist Tabs?
- **Link Playlists**: Shows all Jellyfin playlists and lets you connect them to Spotify
- **Active Playlists**: Shows which Spotify playlists are currently being monitored and filled with tracks
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`.
**Recommended workflow**: Use the `sp_dc` cookie method (simpler and more reliable than the Jellyfin Spotify Import plugin).
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)
@@ -141,7 +139,14 @@ This project brings together all the music streaming providers into one unified
**Compatible Jellyfin clients:**
- [Feishin](https://github.com/jeffvli/feishin) (Mac/Windows/Linux)
- [Musiver](https://music.aqzscn.cn/en/) (Android/IOS/Windows/Android)
<img width="1691" height="1128" alt="image" src="https://github.com/user-attachments/assets/c602f71c-c4dd-49a9-b533-1558e24a9f45" />
- [Musiver](https://music.aqzscn.cn/en/) (Android/iOS/Windows/Android)
<img width="523" height="1025" alt="image" src="https://github.com/user-attachments/assets/135e2721-5fd7-482f-bb06-b0736003cfe7" />
- [Finamp](https://github.com/jmshrv/finamp) (Android/iOS)
_Working on getting more currently_
@@ -335,7 +340,10 @@ Subsonic__EnableExternalPlaylists=false
### Spotify Playlist Injection (Jellyfin Only)
Allstarr can automatically fill your Spotify playlists (like Release Radar and Discover Weekly) with tracks from your configured streaming provider (SquidWTF, Deezer, or Qobuz). This feature works by intercepting playlists created by the Jellyfin Spotify Import plugin and matching missing tracks with your streaming service.
Allstarr automatically fills your Spotify playlists (like Release Radar and Discover Weekly) with tracks from your configured streaming provider (SquidWTF, Deezer, or Qobuz). This works by intercepting playlists created by the Jellyfin Spotify Import plugin and matching missing tracks with your streaming service.
<img width="1649" height="3764" alt="image" src="https://github.com/user-attachments/assets/a4d3d79c-7741-427f-8c01-ffc90f3a579b" />
#### Prerequisites
@@ -349,136 +357,112 @@ Allstarr can automatically fill your Spotify playlists (like Release Radar and D
- Go to Jellyfin Dashboard → Plugins → Spotify Import
- Connect your Spotify account
- Select which playlists to sync (e.g., Release Radar, Discover Weekly)
- Set a daily sync schedule (e.g., 4:15 PM daily)
- The plugin will create playlists in Jellyfin and generate "missing tracks" files for songs not in your library
- Set a sync schedule (the plugin will create playlists in Jellyfin)
3. **Configure Allstarr**
- Allstarr needs to know when the plugin runs and which playlists to intercept
- Uses your existing `JELLYFIN_URL` and `JELLYFIN_API_KEY` settings (no additional credentials needed)
- Enable Spotify Import in Allstarr (see configuration below)
- Link your Jellyfin playlists to Spotify playlists via the Web UI
- Uses your existing `JELLYFIN_URL` and `JELLYFIN_API_KEY` settings
#### Configuration
| Setting | Description |
|---------|-------------|
| `SpotifyImport:Enabled` | Enable Spotify playlist injection (default: `false`) |
| `SpotifyImport: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) |
| `SpotifyImport:MatchingIntervalHours` | How often to run track matching in hours (default: 24, set to 0 for startup only) |
| `SpotifyImport:Playlists` | JSON array of playlists (managed via Web UI) |
**Environment variables example:**
```bash
# Enable the feature
SPOTIFY_IMPORT_ENABLED=true
# 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
# Matching interval (24 hours = once per day)
SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS=24
# 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
# Playlists (use Web UI to manage instead of editing manually)
SPOTIFY_IMPORT_PLAYLISTS=[["Discover Weekly","37i9dQZEVXcV6s7Dm7RXsU","first"],["Release Radar","37i9dQZEVXbng2vDHnfQlC","first"]]
```
#### How It Works
1. **Spotify Import Plugin Runs** (e.g., daily at 4:15 PM)
1. **Spotify Import Plugin Runs**
- Plugin fetches your Spotify playlists
- Creates/updates playlists in Jellyfin with tracks already in your library
- Generates "missing tracks" JSON files for songs not found locally
- Files are named like: `Release Radar_missing_2026-02-01_16-15.json`
2. **Allstarr Fetches Missing Tracks** (within sync window)
- Searches for missing tracks files from the Jellyfin plugin
- Searches **+24 hours forward first** (newest files), then **-48 hours backward** if not found
- This efficiently finds the most recent file regardless of timezone differences
- Example: Server time 12 PM EST, file timestamped 9 PM UTC (same day) → Found in forward search
- Caches the list of missing tracks in Redis + file cache
- Runs automatically on startup (if needed) and every 5 minutes during the sync window
3. **Allstarr Matches Tracks** (2 minutes after startup, then configurable interval)
2. **Allstarr Matches Tracks** (on startup + every 24 hours by default)
- Reads missing tracks files from the Jellyfin plugin
- For each missing track, searches your streaming provider (SquidWTF, Deezer, or Qobuz)
- Uses fuzzy matching to find the best match (title + artist similarity)
- Rate-limited to avoid overwhelming the service (150ms delay between searches)
- Caches matched results for 1 hour
- **Pre-builds playlist items cache** for instant serving (no "on the fly" building)
- Default interval: 24 hours (configurable via `SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS`)
- Set to 0 to only run once on startup (manual trigger via admin UI still works)
- Pre-builds playlist cache for instant loading
4. **You Open the Playlist in Jellyfin**
3. **You Open the Playlist in Jellyfin**
- Allstarr intercepts the request
- Returns a merged list: local tracks + matched streaming tracks
- Loads instantly from cache (no searching needed!)
- Loads instantly from cache!
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
4. **You Play a Track**
- Local tracks stream from Jellyfin normally
- Matched tracks download from streaming provider on-demand
- Downloaded tracks are saved to your library for future use
#### Manual Triggers
#### Manual API Triggers
You can manually trigger syncing and matching via API:
You can manually trigger operations via the admin API:
```bash
# Get API key from your .env file
API_KEY="your-api-key-here"
# Fetch missing tracks from Jellyfin plugin
curl "https://your-jellyfin-proxy.com/spotify/sync?api_key=YOUR_API_KEY"
curl "http://localhost:5274/spotify/sync?api_key=$API_KEY"
# Trigger track matching (searches streaming provider)
curl "https://your-jellyfin-proxy.com/spotify/match?api_key=YOUR_API_KEY"
curl "http://localhost:5274/spotify/match?api_key=$API_KEY"
# Clear cache to force re-matching
curl "https://your-jellyfin-proxy.com/spotify/clear-cache?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"
```
#### Startup Behavior
#### Web UI Management
When Allstarr starts with Spotify Import enabled:
The easiest way to manage Spotify playlists is through the Web UI at `http://localhost:5275`:
**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
1. **Link Playlists Tab**: Link Jellyfin playlists to Spotify playlists
2. **Active Playlists Tab**: View status, trigger matching, and manage playlists
3. **Configuration Tab**: Enable/disable Spotify Import and adjust settings
#### Troubleshooting
**Playlists are empty:**
- Check that the Spotify Import plugin is running and creating playlists
- Verify `SPOTIFY_IMPORT_PLAYLIST_IDS` match your Jellyfin playlist IDs
- Verify playlists are linked in the Web UI
- Check logs: `docker-compose logs -f allstarr | grep -i spotify`
**Tracks aren't matching:**
- Ensure your streaming provider is configured (`MUSIC_SERVICE`, credentials)
- Check that playlist names in `SPOTIFY_IMPORT_PLAYLIST_NAMES` match exactly
- Manually trigger matching: `curl "https://your-proxy.com/spotify/match?api_key=KEY"`
- Manually trigger matching via Web UI or API
- Check that the Jellyfin plugin generated missing tracks files
**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
**Performance:**
- Matching runs in background with rate limiting (150ms between searches)
- First match may take a few minutes for large playlists
- Subsequent loads are instant (served from cache)
#### Notes
- 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)
- Uses your existing `JELLYFIN_URL` and `JELLYFIN_API_KEY` settings
- Matched tracks cached for fast loading
- Missing tracks cache persists across restarts (Redis + file cache)
- Rate limiting prevents overwhelming your streaming provider
- Only works with Jellyfin backend (not Subsonic/Navidrome)
### Getting Credentials
@@ -592,9 +576,46 @@ 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 and adds transparent streaming provider integration:
The proxy implements the Subsonic API with streaming provider integration:
| Endpoint | Description |
|----------|-------------|
@@ -608,20 +629,6 @@ The proxy implements the Subsonic API and adds transparent streaming provider in
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:
@@ -636,25 +643,37 @@ Legacy format `ext-deezer-{id}` is also supported (assumes song type).
## Download Folder Structure
Downloaded music is organized as:
All downloads are organized under a single base directory (default: `./downloads`):
```
downloads/
├── Artist Name/
│ ├── Album Title/
│ │ ├── 01 - Track One.mp3
│ │ ├── 02 - Track Two.mp3
│ │ └── ...
└── Another Album/
└── ...
├── Another Artist/
│ └── ...
└── playlists/
── My Favorite Songs.m3u
├── Chill Vibes.m3u
└── ...
├── permanent/ # Permanent downloads (STORAGE_MODE=Permanent)
│ ├── Artist Name/
│ │ ├── Album Title/
│ │ ├── 01 - Track One.flac
│ │ │ ├── 02 - Track Two.flac
│ │ └── ...
└── Another Album/
│ │ └── ...
│ └── 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
```
Playlists are stored as M3U files with relative paths to downloaded tracks, making them portable and compatible with most music players.
**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.
## Metadata Embedding
@@ -685,10 +704,17 @@ dotnet test
```
allstarr/
├── Controllers/
│ ├── JellyfinController.cs # Jellyfin API controller (registered when Backend:Type=Jellyfin)
── SubsonicController.cs # Subsonic API controller (registered when Backend:Type=Subsonic)
│ ├── 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
├── Middleware/
── GlobalExceptionHandler.cs # Global error handling
── AdminStaticFilesMiddleware.cs # Admin UI static file serving
│ ├── GlobalExceptionHandler.cs # Global error handling
│ └── WebSocketProxyMiddleware.cs # WebSocket proxying for Jellyfin
├── Models/
│ ├── Domain/ # Domain entities
│ │ ├── Song.cs
@@ -697,18 +723,39 @@ allstarr/
│ ├── Settings/ # Configuration models
│ │ ├── SubsonicSettings.cs
│ │ ├── DeezerSettings.cs
│ │ ── QobuzSettings.cs
│ │ ── QobuzSettings.cs
│ │ ├── SquidWTFSettings.cs
│ │ ├── SpotifyApiSettings.cs
│ │ ├── SpotifyImportSettings.cs
│ │ ├── MusicBrainzSettings.cs
│ │ └── RedisSettings.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
@@ -720,12 +767,35 @@ 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
│ │ ├── SubsonicProxyService.cs # Request proxying
│ │ ├── PlaylistSyncService.cs # Playlist synchronization
│ │ ├── SubsonicModelMapper.cs # Model mapping
│ │ ├── SubsonicProxyService.cs # Request proxying
│ │ ├── SubsonicRequestParser.cs # Request parsing
│ │ └── SubsonicResponseBuilder.cs # Response building
│ ├── Validation/ # Startup validation
@@ -737,13 +807,17 @@ 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
├── QobuzDownloadServiceTests.cs # Qobuz download tests (127 tests)
├── JellyfinResponseStructureTests.cs # Jellyfin response tests
├── QobuzDownloadServiceTests.cs # Qobuz download tests
├── LocalLibraryServiceTests.cs # Local library tests
├── SubsonicModelMapperTests.cs # Model mapping tests
├── SubsonicProxyServiceTests.cs # Proxy service tests
@@ -817,7 +891,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 focused and atomic
- Keep commits feature focused
### Testing
@@ -839,8 +913,14 @@ GPL-3.0
## Acknowledgments
- [Navidrome](https://www.navidrome.org/) - The excellent self-hosted music server
- [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!
- [Deezer](https://www.deezer.com/) - Music streaming service
- [Qobuz](https://www.qobuz.com/) - Hi-Res music streaming service
- [Subsonic API](http://www.subsonic.org/pages/api.jsp) - The API specification
- [spotify-lyrics-api](https://github.com/akashrchandran/spotify-lyrics-api) - Thank them for the fact that we have access to Spotify's lyrics!
- [LRCLIB](https://github.com/tranxuanthang/lrclib) - The GOATS for giving us a free api for lyrics! They power LRCGET, which I'm sure some of you have heard of

View File

@@ -5,9 +5,11 @@ 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;
@@ -26,6 +28,7 @@ public class AdminController : ControllerBase
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;
@@ -36,7 +39,11 @@ public class AdminController : ControllerBase
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(
@@ -46,6 +53,7 @@ public class AdminController : ControllerBase
IOptions<SpotifyApiSettings> spotifyApiSettings,
IOptions<SpotifyImportSettings> spotifyImportSettings,
IOptions<JellyfinSettings> jellyfinSettings,
IOptions<SubsonicSettings> subsonicSettings,
IOptions<DeezerSettings> deezerSettings,
IOptions<QobuzSettings> qobuzSettings,
IOptions<SquidWTFSettings> squidWtfSettings,
@@ -54,6 +62,7 @@ public class AdminController : ControllerBase
SpotifyPlaylistFetcher playlistFetcher,
RedisCacheService cache,
IHttpClientFactory httpClientFactory,
IServiceProvider serviceProvider,
SpotifyTrackMatchingService? matchingService = null)
{
_logger = logger;
@@ -62,6 +71,7 @@ public class AdminController : ControllerBase
_spotifyApiSettings = spotifyApiSettings.Value;
_spotifyImportSettings = spotifyImportSettings.Value;
_jellyfinSettings = jellyfinSettings.Value;
_subsonicSettings = subsonicSettings.Value;
_deezerSettings = deezerSettings.Value;
_qobuzSettings = qobuzSettings.Value;
_squidWtfSettings = squidWtfSettings.Value;
@@ -71,14 +81,48 @@ public class AdminController : ControllerBase
_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";
}
_logger.LogInformation("Admin controller initialized. .env path: {EnvFilePath}", _envFilePath);
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>
@@ -122,8 +166,7 @@ public class AdminController : ControllerBase
spotifyImport = new
{
enabled = _spotifyImportSettings.Enabled,
syncTime = $"{_spotifyImportSettings.SyncStartHour:D2}:{_spotifyImportSettings.SyncStartMinute:D2}",
syncWindowHours = _spotifyImportSettings.SyncWindowHours,
matchingIntervalHours = _spotifyImportSettings.MatchingIntervalHours,
playlistCount = _spotifyImportSettings.Playlists.Count
},
deezer = new
@@ -143,15 +186,72 @@ public class AdminController : ControllerBase
});
}
/// <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()
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>();
foreach (var config in _spotifyImportSettings.Playlists)
// 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?>
{
@@ -159,6 +259,7 @@ public class AdminController : ControllerBase
["id"] = config.Id,
["jellyfinId"] = config.JellyfinId,
["localTracksPosition"] = config.LocalTracksPosition.ToString(),
["syncSchedule"] = config.SyncSchedule ?? "0 8 * * 1",
["trackCount"] = 0,
["localTracks"] = 0,
["externalTracks"] = 0,
@@ -274,12 +375,37 @@ public class AdminController : ControllerBase
foreach (var item in cachedPlaylistItems)
{
// Check if it's external by looking for ProviderIds (external songs have this)
// 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)
{
// Has ProviderIds = external track
isExternal = true;
// 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)
@@ -300,9 +426,10 @@ public class AdminController : ControllerBase
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",
config.Name, spotifyTracks.Count, localCount, externalCount, externalMissingCount);
_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
{
@@ -343,30 +470,53 @@ public class AdminController : ControllerBase
foreach (var track in spotifyTracks)
{
var isLocal = false;
var hasExternalMapping = false;
if (localTracks.Count > 0)
// 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))
{
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();
// 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);
// Use 70% threshold (same as playback matching)
if (bestMatch != null && bestMatch.TotalScore >= 70)
if (!string.IsNullOrEmpty(externalMappingJson))
{
isLocal = true;
// 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;
}
}
}
@@ -376,8 +526,8 @@ public class AdminController : ControllerBase
}
else
{
// Check if external track is matched
if (matchedSpotifyIds.Contains(track.SpotifyId))
// Check if external track is matched (either manual mapping or auto-matched)
if (hasExternalMapping || matchedSpotifyIds.Contains(track.SpotifyId))
{
externalMatchedCount++;
}
@@ -393,9 +543,10 @@ public class AdminController : ControllerBase
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",
config.Name, spotifyTracks.Count, localCount, externalMatchedCount, externalMissingCount);
_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
@@ -423,6 +574,24 @@ public class AdminController : ControllerBase
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 });
}
@@ -437,152 +606,298 @@ public class AdminController : ControllerBase
// Get Spotify tracks
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(decodedName);
// Get the playlist config to find Jellyfin ID
var playlistConfig = _spotifyImportSettings.Playlists
.FirstOrDefault(p => p.Name.Equals(decodedName, StringComparison.OrdinalIgnoreCase));
var tracksWithStatus = new List<object>();
if (!string.IsNullOrEmpty(playlistConfig?.JellyfinId))
// 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
{
// Get existing tracks from Jellyfin to determine local/external status
var userId = _jellyfinSettings.UserId;
if (!string.IsNullOrEmpty(userId))
{
try
{
var url = $"{_jellyfinSettings.Url}/Playlists/{playlistConfig.JellyfinId}/Items?UserId={userId}";
var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
var response = await _jellyfinHttpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
var json = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
// Build list of local tracks (match by name only - no Spotify IDs!)
var localTracks = new List<(string Title, string Artist)>();
if (doc.RootElement.TryGetProperty("Items", out var items))
{
foreach (var item in items.EnumerateArray())
{
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
var artist = "";
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
{
artist = artistsEl[0].GetString() ?? "";
}
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
{
artist = albumArtistEl.GetString() ?? "";
}
if (!string.IsNullOrEmpty(title))
{
localTracks.Add((title, artist));
}
}
}
_logger.LogInformation("Found {Count} local tracks in Jellyfin playlist {Playlist}",
localTracks.Count, decodedName);
// Match Spotify tracks to local tracks by name (fuzzy matching)
foreach (var track in spotifyTracks)
{
var isLocal = false;
// FIRST: Check for manual mapping (same as SpotifyTrackMatchingService)
var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}";
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
if (!string.IsNullOrEmpty(manualJellyfinId))
{
// Manual mapping exists - this track is definitely local
isLocal = true;
_logger.LogDebug("✓ Manual mapping found for {Title}: Jellyfin ID {Id}",
track.Title, manualJellyfinId);
}
else if (localTracks.Count > 0)
{
// SECOND: No manual mapping, try fuzzy matching
var bestMatch = localTracks
.Select(local => new
{
Local = local,
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, local.Title),
ArtistScore = FuzzyMatcher.CalculateSimilarity(track.PrimaryArtist, local.Artist)
})
.Select(x => new
{
x.Local,
x.TitleScore,
x.ArtistScore,
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
})
.OrderByDescending(x => x.TotalScore)
.FirstOrDefault();
// Use 70% threshold (same as playback matching)
if (bestMatch != null && bestMatch.TotalScore >= 70)
{
isLocal = true;
}
}
tracksWithStatus.Add(new
{
position = track.Position,
title = track.Title,
artists = track.Artists,
album = track.Album,
isrc = track.Isrc,
spotifyId = track.SpotifyId,
durationMs = track.DurationMs,
albumArtUrl = track.AlbumArtUrl,
isLocal = isLocal,
// For external tracks, show what will be searched
externalProvider = isLocal ? null : _configuration.GetValue<string>("Subsonic:MusicService") ?? _configuration.GetValue<string>("Jellyfin:MusicService") ?? "Deezer",
searchQuery = isLocal ? null : $"{track.Title} {track.PrimaryArtist}"
});
}
return Ok(new
{
name = decodedName,
trackCount = spotifyTracks.Count,
tracks = tracksWithStatus
});
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get local track status for {Playlist}", decodedName);
}
}
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
});
}
// Fallback: return tracks without local/external status
return Ok(new
{
name = decodedName,
trackCount = spotifyTracks.Count,
tracks = spotifyTracks.Select(t => new
{
position = t.Position,
title = t.Title,
artists = t.Artists,
album = t.Album,
isrc = t.Isrc,
spotifyId = t.SpotifyId,
durationMs = t.DurationMs,
albumArtUrl = t.AlbumArtUrl,
isLocal = (bool?)null, // Unknown
externalProvider = _configuration.GetValue<string>("Subsonic:MusicService") ?? _configuration.GetValue<string>("Jellyfin:MusicService") ?? "Deezer",
searchQuery = $"{t.Title} {t.PrimaryArtist}"
})
tracks = tracksWithStatus
});
}
@@ -594,6 +909,10 @@ public class AdminController : ControllerBase
{
_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 });
}
@@ -614,6 +933,10 @@ public class AdminController : ControllerBase
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)
@@ -623,6 +946,77 @@ public class AdminController : ControllerBase
}
}
/// <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>
@@ -775,26 +1169,62 @@ public class AdminController : ControllerBase
}
/// <summary>
/// Save manual track mapping
/// 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) || string.IsNullOrWhiteSpace(request.JellyfinId))
if (string.IsNullOrWhiteSpace(request.SpotifyId))
{
return BadRequest(new { error = "SpotifyId and JellyfinId are required" });
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
{
// Store mapping in cache (you could also persist to a file)
var mappingKey = $"spotify:manual-map:{decodedName}:{request.SpotifyId}";
await _cache.SetAsync(mappingKey, request.JellyfinId, TimeSpan.FromDays(365)); // Long TTL
string? normalizedProvider = null;
_logger.LogInformation("Manual mapping saved: {Playlist} - Spotify {SpotifyId} → Jellyfin {JellyfinId}",
decodedName, request.SpotifyId, request.JellyfinId);
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}";
@@ -805,47 +1235,62 @@ public class AdminController : ControllerBase
await _cache.DeleteAsync(orderedCacheKey);
await _cache.DeleteAsync(playlistItemsKey);
_logger.LogInformation("Cleared playlist caches for {Playlist} to force rebuild", decodedName);
// Fetch the mapped Jellyfin track details to return to the UI
string? trackTitle = null;
string? trackArtist = null;
string? trackAlbum = null;
// Also delete file caches to force rebuild
try
{
var userId = _jellyfinSettings.UserId;
var trackUrl = $"{_jellyfinSettings.Url}/Items/{request.JellyfinId}";
if (!string.IsNullOrEmpty(userId))
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))
{
trackUrl += $"?UserId={userId}";
System.IO.File.Delete(matchedFile);
_logger.LogDebug("Deleted matched tracks file cache for {Playlist}", decodedName);
}
var trackRequest = new HttpRequestMessage(HttpMethod.Get, trackUrl);
trackRequest.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
var response = await _jellyfinHttpClient.SendAsync(trackRequest);
if (response.IsSuccessStatusCode)
if (System.IO.File.Exists(itemsFile))
{
var trackData = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(trackData);
var track = doc.RootElement;
trackTitle = track.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : null;
trackArtist = track.TryGetProperty("AlbumArtist", out var artistEl) ? artistEl.GetString() :
(track.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0
? artistsEl[0].GetString() : null);
trackAlbum = track.TryGetProperty("Album", out var albumEl) ? albumEl.GetString() : null;
}
else
{
_logger.LogWarning("Failed to fetch Jellyfin track {Id}: {StatusCode}", request.JellyfinId, response.StatusCode);
System.IO.File.Delete(itemsFile);
_logger.LogDebug("Deleted playlist items file cache for {Playlist}", decodedName);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch mapped track details, but mapping was saved");
_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
@@ -880,11 +1325,12 @@ public class AdminController : ControllerBase
// Return success with track details if available
var mappedTrack = new
{
id = request.JellyfinId,
id = request.ExternalId,
title = trackTitle ?? "Unknown",
artist = trackArtist ?? "Unknown",
album = trackAlbum ?? "Unknown",
isLocal = true
isLocal = false,
externalProvider = request.ExternalProvider!.ToLowerInvariant()
};
return Ok(new
@@ -901,12 +1347,6 @@ public class AdminController : ControllerBase
}
}
public class ManualMappingRequest
{
public string SpotifyId { get; set; } = "";
public string JellyfinId { get; set; } = "";
}
/// <summary>
/// Trigger track matching for all playlists
/// </summary>
@@ -940,6 +1380,12 @@ public class AdminController : ControllerBase
{
return Ok(new
{
backendType = _configuration.GetValue<string>("Backend:Type") ?? "Jellyfin",
musicService = _configuration.GetValue<string>("MusicService") ?? "SquidWTF",
explicitFilter = _configuration.GetValue<string>("ExplicitFilter") ?? "All",
enableExternalPlaylists = _configuration.GetValue<bool>("EnableExternalPlaylists", false),
playlistsDirectory = _configuration.GetValue<string>("PlaylistsDirectory") ?? "(not set)",
redisEnabled = _configuration.GetValue<bool>("Redis:Enabled", false),
spotifyApi = new
{
enabled = _spotifyApiSettings.Enabled,
@@ -952,9 +1398,7 @@ public class AdminController : ControllerBase
spotifyImport = new
{
enabled = _spotifyImportSettings.Enabled,
syncStartHour = _spotifyImportSettings.SyncStartHour,
syncStartMinute = _spotifyImportSettings.SyncStartMinute,
syncWindowHours = _spotifyImportSettings.SyncWindowHours,
matchingIntervalHours = _spotifyImportSettings.MatchingIntervalHours,
playlists = _spotifyImportSettings.Playlists.Select(p => new
{
name = p.Name,
@@ -969,6 +1413,16 @@ public class AdminController : ControllerBase
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),
@@ -1074,6 +1528,12 @@ public class AdminController : ControllerBase
_logger.LogInformation("Config file updated successfully at {Path}", _envFilePath);
// Invalidate playlist summary cache if playlists were updated
if (appliedUpdates.Contains("SPOTIFY_IMPORT_PLAYLISTS"))
{
InvalidatePlaylistSummaryCache();
}
return Ok(new
{
message = "Configuration updated. Restart container to apply changes.",
@@ -1472,6 +1932,53 @@ public class AdminController : ControllerBase
}
}
/// <summary>
/// Get all playlists from the user's Spotify account
/// </summary>
[HttpGet("spotify/user-playlists")]
public async Task<IActionResult> GetSpotifyUserPlaylists()
{
if (!_spotifyApiSettings.Enabled || string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie))
{
return BadRequest(new { error = "Spotify API not configured. Please set sp_dc session cookie." });
}
try
{
// Get list of already-configured Spotify playlist IDs
var configuredPlaylists = await ReadPlaylistsFromEnvFile();
var linkedSpotifyIds = new HashSet<string>(
configuredPlaylists.Select(p => p.Id),
StringComparer.OrdinalIgnoreCase
);
// Use SpotifyApiClient's GraphQL method - much less rate-limited than REST API
var spotifyPlaylists = await _spotifyClient.GetUserPlaylistsAsync(searchName: null);
if (spotifyPlaylists == null || spotifyPlaylists.Count == 0)
{
return Ok(new { playlists = new List<object>() });
}
var playlists = spotifyPlaylists.Select(p => new
{
id = p.SpotifyId,
name = p.Name,
trackCount = p.TotalTracks,
owner = p.OwnerName ?? "",
isPublic = p.Public,
isLinked = linkedSpotifyIds.Contains(p.SpotifyId)
}).ToList();
return Ok(new { playlists });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching Spotify user playlists");
return StatusCode(500, new { error = "Failed to fetch Spotify playlists", details = ex.Message });
}
}
/// <summary>
/// Get all playlists from Jellyfin
/// </summary>
@@ -1535,14 +2042,24 @@ public class AdminController : ControllerBase
var isConfigured = configuredPlaylist != null;
var linkedSpotifyId = configuredPlaylist?.Id;
// Fetch track details to categorize local vs external
var trackStats = await GetPlaylistTrackStats(id!);
// Only fetch detailed track stats for configured Spotify playlists
// This avoids expensive queries for large non-Spotify playlists
var trackStats = (LocalTracks: 0, ExternalTracks: 0, ExternalAvailable: 0);
if (isConfigured)
{
trackStats = await GetPlaylistTrackStats(id!);
}
// Use actual track stats for configured playlists, otherwise use Jellyfin's count
var actualTrackCount = isConfigured
? trackStats.LocalTracks + trackStats.ExternalTracks
: childCount;
playlists.Add(new
{
id,
name,
trackCount = childCount,
trackCount = actualTrackCount,
linkedSpotifyId,
isConfigured,
localTracks = trackStats.LocalTracks,
@@ -1711,12 +2228,19 @@ public class AdminController : ControllerBase
Name = request.Name,
Id = request.SpotifyPlaylistId,
JellyfinId = jellyfinPlaylistId,
LocalTracksPosition = LocalTracksPosition.First // Use Spotify order
LocalTracksPosition = LocalTracksPosition.First, // Use Spotify order
SyncSchedule = request.SyncSchedule ?? "0 8 * * 1" // Default to Monday 8 AM
});
// Convert to JSON format for env var: [["Name","SpotifyId","JellyfinId","first|last"],...]
// Convert to JSON format for env var: [["Name","SpotifyId","JellyfinId","first|last","cronSchedule"],...]
var playlistsJson = JsonSerializer.Serialize(
currentPlaylists.Select(p => new[] { p.Name, p.Id, p.JellyfinId, p.LocalTracksPosition.ToString().ToLower() }).ToArray()
currentPlaylists.Select(p => new[] {
p.Name,
p.Id,
p.JellyfinId,
p.LocalTracksPosition.ToString().ToLower(),
p.SyncSchedule ?? "0 8 * * 1"
}).ToArray()
);
// Update .env file
@@ -1741,6 +2265,60 @@ public class AdminController : ControllerBase
return await RemovePlaylist(decodedName);
}
/// <summary>
/// Update playlist sync schedule
/// </summary>
[HttpPut("playlists/{name}/schedule")]
public async Task<IActionResult> UpdatePlaylistSchedule(string name, [FromBody] UpdateScheduleRequest request)
{
var decodedName = Uri.UnescapeDataString(name);
if (string.IsNullOrWhiteSpace(request.SyncSchedule))
{
return BadRequest(new { error = "SyncSchedule is required" });
}
// Basic cron validation
var cronParts = request.SyncSchedule.Trim().Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
if (cronParts.Length != 5)
{
return BadRequest(new { error = "Invalid cron format. Expected: minute hour day month dayofweek" });
}
// Read current playlists
var currentPlaylists = await ReadPlaylistsFromEnvFile();
var playlist = currentPlaylists.FirstOrDefault(p => p.Name.Equals(decodedName, StringComparison.OrdinalIgnoreCase));
if (playlist == null)
{
return NotFound(new { error = $"Playlist '{decodedName}' not found" });
}
// Update the schedule
playlist.SyncSchedule = request.SyncSchedule.Trim();
// Save back to .env
var playlistsJson = JsonSerializer.Serialize(
currentPlaylists.Select(p => new[] {
p.Name,
p.Id,
p.JellyfinId,
p.LocalTracksPosition.ToString().ToLower(),
p.SyncSchedule ?? "0 8 * * 1"
}).ToArray()
);
var updateRequest = new ConfigUpdateRequest
{
Updates = new Dictionary<string, string>
{
["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson
}
};
return await UpdateConfig(updateRequest);
}
private string GetJellyfinAuthHeader()
{
return $"MediaBrowser Client=\"Allstarr\", Device=\"Server\", DeviceId=\"allstarr-admin\", Version=\"1.0.0\", Token=\"{_jellyfinSettings.ApiKey}\"";
@@ -1772,7 +2350,7 @@ public class AdminController : ControllerBase
return playlists;
}
// Parse JSON array format: [["Name","SpotifyId","JellyfinId","first|last"],...]
// Parse JSON array format: [["Name","SpotifyId","JellyfinId","first|last","cronSchedule"],...]
var playlistArrays = JsonSerializer.Deserialize<string[][]>(value);
if (playlistArrays != null)
{
@@ -1788,7 +2366,8 @@ public class AdminController : ControllerBase
LocalTracksPosition = arr.Length >= 4 &&
arr[3].Trim().Equals("last", StringComparison.OrdinalIgnoreCase)
? LocalTracksPosition.Last
: LocalTracksPosition.First
: LocalTracksPosition.First,
SyncSchedule = arr.Length >= 5 ? arr[4].Trim() : "0 8 * * 1"
});
}
}
@@ -1901,6 +2480,930 @@ public class AdminController : ControllerBase
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
@@ -1919,4 +3422,209 @@ public class LinkPlaylistRequest
{
public string Name { get; set; } = string.Empty;
public string SpotifyPlaylistId { get; set; } = string.Empty;
public string SyncSchedule { get; set; } = "0 8 * * 1"; // Default: 8 AM every Monday
}
public class UpdateScheduleRequest
{
public string SyncSchedule { get; set; } = string.Empty;
}
/// <summary>
/// GET /api/admin/downloads
/// Lists all downloaded files in the KEPT folder only (favorited tracks)
/// </summary>
[HttpGet("downloads")]
public IActionResult GetDownloads()
{
try
{
var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
_logger.LogInformation("📂 Checking kept folder: {Path}", keptPath);
_logger.LogInformation("📂 Directory exists: {Exists}", Directory.Exists(keptPath));
if (!Directory.Exists(keptPath))
{
_logger.LogWarning("Kept folder does not exist: {Path}", keptPath);
return Ok(new { files = new List<object>(), totalSize = 0, count = 0 });
}
var files = new List<object>();
long totalSize = 0;
// Recursively get all audio files from kept folder
var audioExtensions = new[] { ".flac", ".mp3", ".m4a", ".opus" };
var allFiles = Directory.GetFiles(keptPath, "*.*", SearchOption.AllDirectories)
.Where(f => audioExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
.ToList();
_logger.LogInformation("📂 Found {Count} audio files in kept folder", allFiles.Count);
foreach (var filePath in allFiles)
{
_logger.LogDebug("📂 Processing file: {Path}", filePath);
var fileInfo = new FileInfo(filePath);
var relativePath = Path.GetRelativePath(keptPath, filePath);
// Parse artist/album/track from path structure
var parts = relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var artist = parts.Length > 0 ? parts[0] : "";
var album = parts.Length > 1 ? parts[1] : "";
var fileName = parts.Length > 2 ? parts[^1] : Path.GetFileName(filePath);
files.Add(new
{
path = relativePath,
fullPath = filePath,
artist,
album,
fileName,
size = fileInfo.Length,
sizeFormatted = FormatFileSize(fileInfo.Length),
lastModified = fileInfo.LastWriteTimeUtc,
extension = fileInfo.Extension
});
totalSize += fileInfo.Length;
}
_logger.LogInformation("📂 Returning {Count} kept files, total size: {Size}", files.Count, FormatFileSize(totalSize));
return Ok(new
{
files = files.OrderBy(f => ((dynamic)f).artist).ThenBy(f => ((dynamic)f).album).ThenBy(f => ((dynamic)f).fileName),
totalSize,
totalSizeFormatted = FormatFileSize(totalSize),
count = files.Count
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to list kept downloads");
return StatusCode(500, new { error = "Failed to list kept downloads" });
}
}
/// <summary>
/// DELETE /api/admin/downloads
/// Deletes a specific kept file and cleans up empty folders
/// </summary>
[HttpDelete("downloads")]
public IActionResult DeleteDownload([FromQuery] string path)
{
try
{
if (string.IsNullOrEmpty(path))
{
return BadRequest(new { error = "Path is required" });
}
var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
var fullPath = Path.Combine(keptPath, path);
_logger.LogInformation("🗑️ Delete request for: {Path}", fullPath);
// Security: Ensure the path is within the kept directory
var normalizedFullPath = Path.GetFullPath(fullPath);
var normalizedKeptPath = Path.GetFullPath(keptPath);
if (!normalizedFullPath.StartsWith(normalizedKeptPath))
{
_logger.LogWarning("🗑️ Invalid path (outside kept folder): {Path}", normalizedFullPath);
return BadRequest(new { error = "Invalid path" });
}
if (!System.IO.File.Exists(fullPath))
{
_logger.LogWarning("🗑️ File not found: {Path}", fullPath);
return NotFound(new { error = "File not found" });
}
System.IO.File.Delete(fullPath);
_logger.LogInformation("🗑️ Deleted file: {Path}", fullPath);
// Clean up empty directories (Album folder, then Artist folder if empty)
var directory = Path.GetDirectoryName(fullPath);
while (directory != null && directory != keptPath && directory.StartsWith(keptPath))
{
if (Directory.Exists(directory) && !Directory.EnumerateFileSystemEntries(directory).Any())
{
Directory.Delete(directory);
_logger.LogInformation("🗑️ Deleted empty directory: {Dir}", directory);
directory = Path.GetDirectoryName(directory);
}
else
{
_logger.LogDebug("🗑️ Directory not empty or doesn't exist, stopping cleanup: {Dir}", directory);
break;
}
}
return Ok(new { success = true, message = "File deleted successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete file: {Path}", path);
return StatusCode(500, new { error = "Failed to delete file" });
}
}
/// <summary>
/// GET /api/admin/downloads/file
/// Downloads a specific file from the kept folder
/// </summary>
[HttpGet("downloads/file")]
public IActionResult DownloadFile([FromQuery] string path)
{
try
{
if (string.IsNullOrEmpty(path))
{
return BadRequest(new { error = "Path is required" });
}
var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
var fullPath = Path.Combine(keptPath, path);
// Security: Ensure the path is within the kept directory
var normalizedFullPath = Path.GetFullPath(fullPath);
var normalizedKeptPath = Path.GetFullPath(keptPath);
if (!normalizedFullPath.StartsWith(normalizedKeptPath))
{
return BadRequest(new { error = "Invalid path" });
}
if (!System.IO.File.Exists(fullPath))
{
return NotFound(new { error = "File not found" });
}
var fileName = Path.GetFileName(fullPath);
var fileStream = System.IO.File.OpenRead(fullPath);
return File(fileStream, "application/octet-stream", fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to download file: {Path}", path);
return StatusCode(500, new { error = "Failed to download file" });
}
}
private static string FormatFileSize(long bytes)
{
string[] sizes = { "B", "KB", "MB", "GB", "TB" };
double len = bytes;
int order = 0;
while (len >= 1024 && order < sizes.Length - 1)
{
order++;
len = len / 1024;
}
return $"{len:0.##} {sizes[order]}";
}
}

View File

@@ -40,7 +40,9 @@ public class JellyfinController : ControllerBase
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(
@@ -54,7 +56,9 @@ public class JellyfinController : ControllerBase
JellyfinModelMapper modelMapper,
JellyfinProxyService proxyService,
JellyfinSessionManager sessionManager,
OdesliService odesliService,
RedisCacheService cache,
IConfiguration configuration,
ILogger<JellyfinController> logger,
ParallelMetadataService? parallelMetadataService = null,
PlaylistSyncService? playlistSyncService = null,
@@ -77,7 +81,9 @@ public class JellyfinController : ControllerBase
_spotifyPlaylistFetcher = spotifyPlaylistFetcher;
_spotifyLyricsService = spotifyLyricsService;
_lrclibService = lrclibService;
_odesliService = odesliService;
_cache = cache;
_configuration = configuration;
_logger = logger;
if (string.IsNullOrWhiteSpace(_settings.Url))
@@ -90,7 +96,7 @@ public class JellyfinController : ControllerBase
/// <summary>
/// Searches local Jellyfin library and external providers.
/// Dedupes artists, combines songs/albums. Works with /Items and /Users/{userId}/Items.
/// Combines songs/albums/artists. Works with /Items and /Users/{userId}/Items.
/// </summary>
[HttpGet("Items", Order = 1)]
[HttpGet("Users/{userId}/Items", Order = 1)]
@@ -273,53 +279,71 @@ public class JellyfinController : ControllerBase
// Parse Jellyfin results into domain models
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult);
// 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);
// 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 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);
// 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;
// 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 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;
var allAlbums = scoredLocalAlbums.Concat(scoredExternalAlbums)
.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;
// 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();
// 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);
}
// Convert to Jellyfin format
var mergedSongs = allSongs.Select(s => _responseBuilder.ConvertSongToJellyfinItem(s)).ToList();
var mergedAlbums = allAlbums.Select(a => _responseBuilder.ConvertAlbumToJellyfinItem(a)).ToList();
var mergedArtists = artistScores.Select(a => _responseBuilder.ConvertArtistToJellyfinItem(a)).ToList();
var mergedArtists = allArtists.Select(a => _responseBuilder.ConvertArtistToJellyfinItem(a)).ToList();
// Add playlists (score them too)
// Add playlists (preserve their order too)
if (playlistResult.Count > 0)
{
var scoredPlaylists = playlistResult
.Select(p => new { Playlist = p, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, p.Name) })
.OrderByDescending(x => x.Score)
.Select(x => _responseBuilder.ConvertPlaylistToJellyfinItem(x.Playlist))
var playlistItems = playlistResult
.Select(p => _responseBuilder.ConvertPlaylistToJellyfinItem(p))
.ToList();
mergedAlbums.AddRange(scoredPlaylists);
mergedAlbums.AddRange(playlistItems);
}
_logger.LogInformation("Scored and filtered results: Songs={Songs}, Albums={Albums}, Artists={Artists}",
_logger.LogInformation("Merged results (preserving source order): Songs={Songs}, Albums={Albums}, Artists={Artists}",
mergedSongs.Count, mergedAlbums.Count, mergedArtists.Count);
// Pre-fetch lyrics for top 3 songs in background (don't await)
@@ -492,20 +516,10 @@ public class JellyfinController : ControllerBase
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult);
// Merge and convert to search hints format
// NO deduplication - merge all results and take top matches
var allSongs = localSongs.Concat(externalResult.Songs).Take(limit).ToList();
var allAlbums = localAlbums.Concat(externalResult.Albums).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);
}
}
var allArtists = localArtists.Concat(externalResult.Artists).Take(limit).ToList();
return _responseBuilder.CreateSearchHintsResponse(
allSongs.Take(limit).ToList(),
@@ -680,27 +694,11 @@ public class JellyfinController : ControllerBase
}
}
// Merge and deduplicate by name
var artistNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var mergedArtists = new List<Artist>();
// 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();
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);
_logger.LogInformation("Returning {Count} total artists (local + external, no deduplication)", mergedArtists.Count);
// Convert to Jellyfin format
var artistItems = mergedArtists.Select(a => _responseBuilder.ConvertArtistToJellyfinItem(a)).ToList();
@@ -1067,7 +1065,8 @@ public class JellyfinController : ControllerBase
if (imageBytes == null || contentType == null)
{
return NotFound();
// Return placeholder if Jellyfin doesn't have image
return await GetPlaceholderImageAsync();
}
return File(imageBytes, contentType);
@@ -1084,7 +1083,8 @@ public class JellyfinController : ControllerBase
if (string.IsNullOrEmpty(coverUrl))
{
return NotFound();
// Return placeholder "no image available" image
return await GetPlaceholderImageAsync();
}
// Fetch and return the image using the proxy service's HttpClient
@@ -1093,7 +1093,8 @@ public class JellyfinController : ControllerBase
var response = await _proxyService.HttpClient.GetAsync(coverUrl);
if (!response.IsSuccessStatusCode)
{
return NotFound();
// Return placeholder on fetch failure
return await GetPlaceholderImageAsync();
}
var imageBytes = await response.Content.ReadAsByteArrayAsync();
@@ -1103,10 +1104,34 @@ public class JellyfinController : ControllerBase
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch cover art from {Url}", coverUrl);
return NotFound();
// Return placeholder on exception
return await GetPlaceholderImageAsync();
}
}
/// <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
@@ -1119,6 +1144,8 @@ public class JellyfinController : ControllerBase
[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();
@@ -1126,6 +1153,9 @@ 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)
{
@@ -1134,13 +1164,16 @@ 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);
return new JsonResult(JsonSerializer.Deserialize<object>(jellyfinLyrics.RootElement.GetRawText()));
}
_logger.LogInformation("No embedded lyrics found in Jellyfin, trying Spotify/LRCLIB");
_logger.LogInformation("No embedded lyrics found in Jellyfin (status: {StatusCode}), trying Spotify/LRCLIB", statusCode);
}
// Get song metadata for lyrics search
@@ -1150,7 +1183,53 @@ public class JellyfinController : ControllerBase
if (isExternal)
{
song = await _metadataService.GetSongAsync(provider!, externalId!);
// For Deezer tracks, we'll search Spotify by metadata
// 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
{
@@ -1197,33 +1276,35 @@ public class JellyfinController : ControllerBase
LyricsInfo? lyrics = null;
// Try Spotify lyrics first (better synced lyrics quality)
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled)
// 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))
{
_logger.LogInformation("Trying Spotify lyrics for: {Artist} - {Title}", searchArtist, searchTitle);
// Validate that this is a real Spotify ID (not spotify:local or other invalid formats)
var cleanSpotifyId = spotifyTrackId.Replace("spotify:track:", "").Trim();
SpotifyLyricsResult? spotifyLyrics = null;
// If we have a Spotify track ID, use it directly
if (!string.IsNullOrEmpty(spotifyTrackId))
// Spotify track IDs are 22 characters, base62 encoded
if (cleanSpotifyId.Length == 22 && !cleanSpotifyId.Contains(":") && !cleanSpotifyId.Contains("local"))
{
spotifyLyrics = await _spotifyLyricsService.GetLyricsByTrackIdAsync(spotifyTrackId);
_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
{
// Search by metadata (without [S] tags)
spotifyLyrics = await _spotifyLyricsService.SearchAndGetLyricsAsync(
searchTitle,
searchArtists.Count > 0 ? searchArtists[0] : searchArtist,
searchAlbum,
song.Duration.HasValue ? song.Duration.Value * 1000 : null);
}
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
{
_logger.LogInformation("Found Spotify lyrics for {Artist} - {Title} ({LineCount} lines, type: {SyncType})",
searchArtist, searchTitle, spotifyLyrics.Lines.Count, spotifyLyrics.SyncType);
lyrics = _spotifyLyricsService.ToLyricsInfo(spotifyLyrics);
_logger.LogDebug("Invalid Spotify ID format: {SpotifyId}, skipping Spotify lyrics", spotifyTrackId);
}
}
@@ -1344,6 +1425,122 @@ 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
@@ -1455,11 +1652,25 @@ public class JellyfinController : ControllerBase
_logger.LogInformation("UnmarkFavorite called: userId={UserId}, itemId={ItemId}, route={Route}",
userId, itemId, Request.Path);
// External items can't be unfavorited (they're not really favorited in Jellyfin)
var (isExternal, _, _) = _localLibraryService.ParseSongId(itemId);
// External items - remove from kept folder if it exists
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
if (isExternal || PlaylistIdHelper.IsExternalPlaylist(itemId))
{
_logger.LogInformation("Unfavoriting external item {ItemId} - returning success", 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);
}
});
return Ok(new
{
IsFavorite = false,
@@ -1596,6 +1807,16 @@ 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);
@@ -1612,6 +1833,11 @@ 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)
@@ -1647,53 +1873,83 @@ 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
// Forward to Jellyfin server with client headers - completely transparent proxy
var (result, statusCode) = await _proxyService.PostJsonAsync("Users/AuthenticateByName", body, Request.Headers);
if (result == null)
// Pass through Jellyfin's response exactly as-is (transparent proxy)
if (result != null)
{
_logger.LogWarning("Authentication failed - status {StatusCode}", statusCode);
if (statusCode == 401)
var responseJson = result.RootElement.GetRawText();
// On successful auth, extract access token and post session capabilities in background
if (statusCode == 200)
{
return Unauthorized(new { error = "Invalid username or password" });
}
return StatusCode(statusCode, new { error = "Authentication failed" });
}
_logger.LogInformation("Authentication successful");
_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();
}
// Post session capabilities immediately after authentication
// This ensures Jellyfin creates a session that will show up in the dashboard
try
{
_logger.LogInformation("🔧 Posting session capabilities after authentication");
var capabilities = new
{
PlayableMediaTypes = new[] { "Audio" },
SupportedCommands = Array.Empty<string>(),
SupportsMediaControl = false,
SupportsPersistentIdentifier = true,
SupportsSync = false
};
// 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 () =>
{
try
{
_logger.LogDebug("🔧 Posting session capabilities after authentication");
var capabilitiesJson = JsonSerializer.Serialize(capabilities);
var (capResult, capStatus) = await _proxyService.PostJsonAsync("Sessions/Capabilities/Full", capabilitiesJson, Request.Headers);
// Build auth header with the new token
var authHeaders = new HeaderDictionary
{
["X-Emby-Token"] = token
};
if (capStatus == 204 || capStatus == 200)
{
_logger.LogInformation("✓ Session capabilities posted after auth ({StatusCode})", capStatus);
var capabilities = new
{
PlayableMediaTypes = new[] { "Audio" },
SupportedCommands = Array.Empty<string>(),
SupportsMediaControl = false,
SupportsPersistentIdentifier = true,
SupportsSync = false
};
var capabilitiesJson = JsonSerializer.Serialize(capabilities);
var (capResult, capStatus) = await _proxyService.PostJsonAsync("Sessions/Capabilities/Full", capabilitiesJson, authHeaders);
if (capStatus == 204 || capStatus == 200)
{
_logger.LogDebug("✓ Session capabilities posted after auth ({StatusCode})", capStatus);
}
else
{
_logger.LogDebug("⚠ Session capabilities returned {StatusCode} after auth", capStatus);
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to post session capabilities after auth");
}
});
}
}
else
{
_logger.LogWarning("⚠ Session capabilities returned {StatusCode} after auth", capStatus);
_logger.LogWarning("Authentication failed - status {StatusCode}", statusCode);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to post session capabilities after auth, continuing anyway");
// Return Jellyfin's exact response
return Content(responseJson, "application/json");
}
return Content(result.RootElement.GetRawText(), "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);
}
catch (Exception ex)
{
@@ -1927,7 +2183,7 @@ public class JellyfinController : ControllerBase
var method = Request.Method;
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : "";
_logger.LogInformation("📡 Session capabilities reported - Method: {Method}, Query: {Query}", method, queryString);
_logger.LogDebug("📡 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}")));
@@ -1952,7 +2208,11 @@ public class JellyfinController : ControllerBase
if (statusCode == 204 || statusCode == 200)
{
_logger.LogInformation("✓ Session capabilities forwarded to Jellyfin ({StatusCode})", statusCode);
_logger.LogDebug("✓ Session capabilities forwarded to Jellyfin ({StatusCode})", statusCode);
}
else if (statusCode == 401)
{
_logger.LogDebug("⚠ Jellyfin returned 401 for capabilities (token expired)");
}
else
{
@@ -1986,12 +2246,13 @@ public class JellyfinController : ControllerBase
}
Request.Body.Position = 0;
_logger.LogInformation("📻 Playback START reported");
_logger.LogDebug("📻 Playback START reported");
// Parse the body to check if it's an external track
var doc = JsonDocument.Parse(body);
string? itemId = null;
string? itemName = null;
long? positionTicks = null;
if (doc.RootElement.TryGetProperty("ItemId", out var itemIdProp))
{
@@ -2003,6 +2264,18 @@ 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);
@@ -2011,17 +2284,73 @@ public class JellyfinController : ControllerBase
{
_logger.LogInformation("🎵 External track playback started: {Name} ({Provider}/{ExternalId})",
itemName ?? "Unknown", provider, externalId);
// For external tracks, we can't report to Jellyfin since it doesn't know about them
// Just return success so the client is happy
// 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);
}
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.LogInformation("Forwarding playback start to Jellyfin...");
_logger.LogDebug("Forwarding playback start to Jellyfin...");
// Fetch full item details to include in playback report
try
@@ -2036,7 +2365,7 @@ public class JellyfinController : ControllerBase
var playbackStart = new
{
ItemId = itemId,
PositionTicks = doc.RootElement.TryGetProperty("PositionTicks", out var posProp) ? posProp.GetInt64() : 0,
PositionTicks = positionTicks ?? 0,
// Let Jellyfin fetch the item details - don't include NowPlayingItem
};
@@ -2047,16 +2376,15 @@ public class JellyfinController : ControllerBase
if (statusCode == 204 || statusCode == 200)
{
_logger.LogInformation("✓ Playback start forwarded to Jellyfin ({StatusCode})", statusCode);
_logger.LogDebug("✓ Playback start forwarded to Jellyfin ({StatusCode})", statusCode);
// NOW ensure session exists with capabilities (after playback is reported)
var (deviceId, client, device, version) = ExtractDeviceInfo(Request.Headers);
if (!string.IsNullOrEmpty(deviceId))
{
var sessionCreated = await _sessionManager.EnsureSessionAsync(deviceId, client ?? "Unknown", device ?? "Unknown", version ?? "1.0", Request.Headers);
if (sessionCreated)
{
_logger.LogWarning("✓ SESSION: Session ensured for device {DeviceId} after playback start", deviceId);
_logger.LogDebug("✓ SESSION: Session ensured for device {DeviceId} after playback start", deviceId);
}
else
{
@@ -2080,7 +2408,7 @@ public class JellyfinController : ControllerBase
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", body, Request.Headers);
if (statusCode == 204 || statusCode == 200)
{
_logger.LogInformation("✓ Basic playback start forwarded to Jellyfin ({StatusCode})", statusCode);
_logger.LogDebug("✓ Basic playback start forwarded to Jellyfin ({StatusCode})", statusCode);
}
}
}
@@ -2141,13 +2469,48 @@ public class JellyfinController : ControllerBase
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);
if (isExternal)
{
// For external tracks, just acknowledge (no logging to avoid spam)
// 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);
}
}
return NoContent();
}
@@ -2158,12 +2521,14 @@ public class JellyfinController : ControllerBase
// Only log at 10-second intervals
if (position.Seconds % 10 == 0 && position.Milliseconds < 500)
{
_logger.LogInformation("▶️ Progress: {Position:mm\\:ss} for item {ItemId}", position, itemId);
_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)
@@ -2196,13 +2561,14 @@ public class JellyfinController : ControllerBase
}
Request.Body.Position = 0;
_logger.LogInformation("⏹️ Playback STOPPED reported");
_logger.LogDebug("⏹️ 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))
{
@@ -2219,6 +2585,12 @@ 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);
@@ -2230,6 +2602,26 @@ 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();
}
@@ -2238,12 +2630,34 @@ public class JellyfinController : ControllerBase
}
// For local tracks, forward to Jellyfin
_logger.LogInformation("Forwarding playback stop 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);
}
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", body, Request.Headers);
if (statusCode == 204 || statusCode == 200)
{
_logger.LogInformation("✓ Playback stop forwarded to Jellyfin ({StatusCode})", statusCode);
_logger.LogDebug("✓ Playback stop forwarded to Jellyfin ({StatusCode})", statusCode);
}
else if (statusCode == 401)
{
_logger.LogDebug("Playback stop returned 401 (token expired)");
}
else
{
@@ -2302,7 +2716,7 @@ public class JellyfinController : ControllerBase
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : "";
var endpoint = string.IsNullOrEmpty(path) ? $"Sessions{queryString}" : $"Sessions/{path}{queryString}";
_logger.LogInformation("🔄 Proxying session request: {Method} {Endpoint}", method, endpoint);
_logger.LogDebug("🔄 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}")));
@@ -2332,11 +2746,11 @@ public class JellyfinController : ControllerBase
if (result != null)
{
_logger.LogInformation("✓ Session request proxied successfully ({StatusCode})", statusCode);
_logger.LogDebug("✓ Session request proxied successfully ({StatusCode})", statusCode);
return new JsonResult(result.RootElement.Clone());
}
_logger.LogInformation("✓ Session request proxied ({StatusCode}, no body)", statusCode);
_logger.LogDebug("✓ Session request proxied ({StatusCode}, no body)", statusCode);
return StatusCode(statusCode);
}
catch (Exception ex)
@@ -2394,7 +2808,7 @@ public class JellyfinController : ControllerBase
if (path.Contains("session", StringComparison.OrdinalIgnoreCase) ||
path.Contains("capabilit", StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("🔍 SESSION/CAPABILITY REQUEST: {Method} /{Path}{Query}", Request.Method, path, Request.QueryString);
_logger.LogDebug("🔍 SESSION/CAPABILITY REQUEST: {Method} /{Path}{Query}", Request.Method, path, Request.QueryString);
}
else
{
@@ -2753,10 +3167,12 @@ public class JellyfinController : ControllerBase
_logger.LogInformation("Found Spotify playlist: {Id}", playlistId);
// This is a Spotify playlist - get the actual track count
var playlistConfig = _spotifySettings.GetPlaylistById(playlistId);
var playlistConfig = _spotifySettings.GetPlaylistByJellyfinId(playlistId);
if (playlistConfig != null)
{
_logger.LogInformation("Found playlist config for Jellyfin ID {JellyfinId}: {Name} (Spotify ID: {SpotifyId})",
playlistId, playlistConfig.Name, playlistConfig.Id);
var playlistName = playlistConfig.Name;
// Get matched external tracks (tracks that were successfully downloaded/matched)
@@ -2796,16 +3212,26 @@ public class JellyfinController : ControllerBase
}
// Only fetch from Jellyfin if we didn't get count from file cache
if (!itemDict.ContainsKey("ChildCount") || (int)itemDict["ChildCount"]! == 0)
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
{
var (localTracksResponse, _) = await _proxyService.GetJsonAsync(
$"Playlists/{playlistId}/Items",
null,
Request.Headers);
// 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))
@@ -2827,24 +3253,29 @@ public class JellyfinController : ControllerBase
externalMatchedCount = matchedTracks.Count(t => t.MatchedSong != null && !t.MatchedSong.IsLocal);
}
// Total available tracks = what's actually in Jellyfin (local + external matched)
// This is what clients should see as the track count
var totalAvailableCount = localTracksCount;
// 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} (actual tracks in Jellyfin)",
playlistName, totalAvailableCount);
_logger.LogInformation("✓ Updated ChildCount for Spotify playlist {Name} to {Total} ({Local} local + {External} external)",
playlistName, totalAvailableCount, localTracksCount, externalMatchedCount);
}
else
{
_logger.LogWarning("No tracks found in Jellyfin for {Name}", playlistName);
_logger.LogWarning("No tracks found for {Name} ({Local} local + {External} external = {Total} total)",
playlistName, localTracksCount, externalMatchedCount, totalAvailableCount);
}
}
}
else
{
_logger.LogWarning("No playlist config found for Jellyfin ID {JellyfinId} - skipping count update", playlistId);
}
}
}
@@ -3041,6 +3472,14 @@ public class JellyfinController : ControllerBase
_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,
@@ -3212,11 +3651,27 @@ public class JellyfinController : ControllerBase
{
// 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}",
_logger.LogDebug("📥 Position #{Pos}: '{Title}' → EXTERNAL: {Provider}/{Id} (Spotify ID: {SpotifyId})",
spotifyTrack.Position, spotifyTrack.Title,
matched.MatchedSong.ExternalProvider, matched.MatchedSong.ExternalId);
matched.MatchedSong.ExternalProvider, matched.MatchedSong.ExternalId, spotifyTrack.SpotifyId);
}
else
{
@@ -3367,8 +3822,7 @@ public class JellyfinController : ControllerBase
{
Song = song,
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, song.Title),
// Calculate artist score by checking ALL artists match
ArtistScore = CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors)
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors)
})
.Select(x => new
{
@@ -3408,7 +3862,7 @@ public class JellyfinController : ControllerBase
// Build final track list based on playlist configuration
// Local tracks position is configurable per-playlist
var playlistConfig = _spotifySettings.GetPlaylistById(playlistId);
var playlistConfig = _spotifySettings.GetPlaylistByJellyfinId(playlistId);
var localTracksPosition = playlistConfig?.LocalTracksPosition ?? LocalTracksPosition.First;
var finalTracks = new List<Song>();
@@ -3446,7 +3900,14 @@ public class JellyfinController : ControllerBase
{
try
{
// Get the song metadata first to check if already in kept folder
// 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
var song = await _metadataService.GetSongAsync(provider, externalId);
if (song == null)
{
@@ -3454,66 +3915,90 @@ public class JellyfinController : ControllerBase
return;
}
// Build kept folder path: /app/kept/Artist/Album/
var keptBasePath = "/app/kept";
// 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));
// Check if track already exists in kept folder BEFORE downloading
// Look for any file matching the song title pattern (any extension)
// Check if track already exists in kept folder
if (Directory.Exists(keptAlbumPath))
{
var sanitizedTitle = PathHelper.SanitizeFileName(song.Title);
var existingFiles = Directory.GetFiles(keptAlbumPath, $"{sanitizedTitle}.*");
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;
}
}
// Track not in kept folder - download it
_logger.LogInformation("Downloading track for kept folder: {ItemId}", itemId);
string downloadPath;
// 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));
try
string? sourceFilePath = null;
if (Directory.Exists(cacheAlbumPath))
{
downloadPath = await _downloadService.DownloadSongAsync(provider, externalId);
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);
}
}
catch (Exception ex)
// If not in cache, download it first
if (sourceFilePath == null)
{
_logger.LogWarning(ex, "Failed to download track {ItemId}", itemId);
return;
_logger.LogInformation("Track not in cache, downloading: {ItemId}", itemId);
try
{
sourceFilePath = await _downloadService.DownloadSongAsync(provider, externalId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to download track {ItemId}", itemId);
return;
}
}
// Create the kept folder structure
Directory.CreateDirectory(keptAlbumPath);
// Copy file to kept folder
var fileName = Path.GetFileName(downloadPath);
var fileName = Path.GetFileName(sourceFilePath);
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);
return;
}
System.IO.File.Copy(downloadPath, keptFilePath, overwrite: false);
_logger.LogInformation("✓ Copied favorited track to kept folder: {Path}", keptFilePath);
System.IO.File.Copy(sourceFilePath, keptFilePath, overwrite: false);
_logger.LogInformation("✓ Copied track to kept folder: {Path}", keptFilePath);
// Also copy cover art if it exists
var coverPath = Path.Combine(Path.GetDirectoryName(downloadPath)!, "cover.jpg");
if (System.IO.File.Exists(coverPath))
var sourceCoverPath = Path.Combine(Path.GetDirectoryName(sourceFilePath)!, "cover.jpg");
if (System.IO.File.Exists(sourceCoverPath))
{
var keptCoverPath = Path.Combine(keptAlbumPath, "cover.jpg");
if (!System.IO.File.Exists(keptCoverPath))
{
System.IO.File.Copy(coverPath, keptCoverPath, overwrite: false);
System.IO.File.Copy(sourceCoverPath, keptCoverPath, overwrite: false);
_logger.LogDebug("Copied cover art to kept folder");
}
}
// Mark as favorited in persistent storage
await MarkTrackAsFavoritedAsync(itemId, song);
}
catch (Exception ex)
{
@@ -3521,6 +4006,248 @@ 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>
@@ -3692,282 +4419,6 @@ public class JellyfinController : ControllerBase
}
}
/// <summary>
/// Manual trigger endpoint to force fetch Spotify missing tracks.
/// GET /spotify/sync?api_key=YOUR_KEY
/// </summary>
[HttpGet("spotify/sync", Order = 1)]
[ServiceFilter(typeof(ApiKeyAuthFilter))]
public async Task<IActionResult> TriggerSpotifySync([FromServices] IEnumerable<IHostedService> hostedServices)
{
if (!_spotifySettings.Enabled)
{
return BadRequest(new { error = "Spotify Import is not enabled" });
}
_logger.LogInformation("Manual Spotify sync triggered");
// Find the SpotifyMissingTracksFetcher service
var fetcherService = hostedServices
.OfType<allstarr.Services.Spotify.SpotifyMissingTracksFetcher>()
.FirstOrDefault();
if (fetcherService == null)
{
return StatusCode(500, new { error = "SpotifyMissingTracksFetcher not found" });
}
// Trigger fetch manually
await fetcherService.TriggerFetchAsync();
// Check what was cached
var results = new Dictionary<string, object>();
foreach (var playlist in _spotifySettings.Playlists)
{
var cacheKey = $"spotify:missing:{playlist.Name}";
var tracks = await _cache.GetAsync<List<allstarr.Models.Spotify.MissingTrack>>(cacheKey);
if (tracks != null && tracks.Count > 0)
{
results[playlist.Name] = new {
status = "success",
tracks = tracks.Count,
localTracksPosition = playlist.LocalTracksPosition.ToString()
};
}
else
{
results[playlist.Name] = new {
status = "not_found",
message = "No missing tracks found"
};
}
}
return Ok(results);
}
/// <summary>
/// Manually trigger track matching for all Spotify playlists.
/// GET /spotify/match?api_key=YOUR_KEY
/// </summary>
[HttpGet("spotify/match", Order = 1)]
[ServiceFilter(typeof(ApiKeyAuthFilter))]
public async Task<IActionResult> TriggerSpotifyMatch([FromServices] IEnumerable<IHostedService> hostedServices)
{
if (!_spotifySettings.Enabled)
{
return BadRequest(new { error = "Spotify Import is not enabled" });
}
_logger.LogInformation("Manual Spotify track matching triggered");
// Find the SpotifyTrackMatchingService
var matchingService = hostedServices
.OfType<allstarr.Services.Spotify.SpotifyTrackMatchingService>()
.FirstOrDefault();
if (matchingService == null)
{
return StatusCode(500, new { error = "SpotifyTrackMatchingService not found" });
}
// Trigger matching asynchronously
_ = Task.Run(async () =>
{
try
{
await matchingService.TriggerMatchingAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during manual track matching");
}
});
return Ok(new
{
status = "started",
message = "Track matching started in background. Check logs for progress.",
playlists = _spotifySettings.Playlists.Select(p => new { p.Name, p.Id, localTracksPosition = p.LocalTracksPosition.ToString() })
});
}
private List<allstarr.Models.Spotify.MissingTrack> ParseMissingTracksJson(string json)
{
var tracks = new List<allstarr.Models.Spotify.MissingTrack>();
try
{
var doc = JsonDocument.Parse(json);
foreach (var item in doc.RootElement.EnumerateArray())
{
var track = new allstarr.Models.Spotify.MissingTrack
{
SpotifyId = item.GetProperty("Id").GetString() ?? "",
Title = item.GetProperty("Name").GetString() ?? "",
Album = item.GetProperty("AlbumName").GetString() ?? "",
Artists = item.GetProperty("ArtistNames")
.EnumerateArray()
.Select(a => a.GetString() ?? "")
.Where(a => !string.IsNullOrEmpty(a))
.ToList()
};
if (!string.IsNullOrEmpty(track.Title))
{
tracks.Add(track);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to parse missing tracks JSON");
}
return tracks;
}
#endregion
#region Spotify Debug
/// <summary>
/// Clear Spotify playlist cache to force re-matching.
/// GET /spotify/clear-cache?api_key=YOUR_KEY
/// </summary>
[HttpGet("spotify/clear-cache")]
[ServiceFilter(typeof(ApiKeyAuthFilter))]
public async Task<IActionResult> ClearSpotifyCache()
{
if (!_spotifySettings.Enabled)
{
return BadRequest(new { error = "Spotify Import is not enabled" });
}
var cleared = new List<string>();
foreach (var playlist in _spotifySettings.Playlists)
{
var matchedKey = $"spotify:matched:{playlist.Name}";
await _cache.DeleteAsync(matchedKey);
cleared.Add(playlist.Name);
_logger.LogInformation("Cleared cache for {Playlist}", playlist.Name);
}
return Ok(new { status = "success", cleared = cleared });
}
#endregion
#region Debug & Monitoring
/// <summary>
/// Gets endpoint usage statistics from the log file.
/// GET /debug/endpoint-usage?api_key=YOUR_KEY
/// Optional query params: top=50 (default 100), since=2024-01-01
/// </summary>
[HttpGet("debug/endpoint-usage")]
[ServiceFilter(typeof(ApiKeyAuthFilter))]
public async Task<IActionResult> GetEndpointUsage(
[FromQuery] int top = 100,
[FromQuery] string? since = null)
{
try
{
var logFile = "/app/cache/endpoint-usage/endpoints.csv";
if (!System.IO.File.Exists(logFile))
{
return Ok(new
{
message = "No endpoint usage data collected yet",
endpoints = Array.Empty<object>()
});
}
var lines = await System.IO.File.ReadAllLinesAsync(logFile);
// Parse CSV and filter by date if provided
DateTime? sinceDate = null;
if (!string.IsNullOrEmpty(since) && DateTime.TryParse(since, out var parsedDate))
{
sinceDate = parsedDate;
}
var entries = lines
.Select(line => line.Split(','))
.Where(parts => parts.Length >= 3)
.Where(parts => !sinceDate.HasValue ||
(DateTime.TryParse(parts[0], out var entryDate) && entryDate >= sinceDate.Value))
.Select(parts => new
{
Timestamp = parts[0],
Method = parts.Length > 1 ? parts[1] : "",
Path = parts.Length > 2 ? parts[2] : "",
Query = parts.Length > 3 ? parts[3] : ""
})
.ToList();
// Group by path and count
var pathCounts = entries
.GroupBy(e => new { e.Method, e.Path })
.Select(g => new
{
Method = g.Key.Method,
Path = g.Key.Path,
Count = g.Count(),
FirstSeen = g.Min(e => e.Timestamp),
LastSeen = g.Max(e => e.Timestamp)
})
.OrderByDescending(x => x.Count)
.Take(top)
.ToList();
return Ok(new
{
totalRequests = entries.Count,
uniqueEndpoints = pathCounts.Count,
topEndpoints = pathCounts,
logFile = logFile,
logSize = new FileInfo(logFile).Length
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get endpoint usage");
return StatusCode(500, new { error = ex.Message });
}
}
/// <summary>
/// Clears the endpoint usage log file.
/// DELETE /debug/endpoint-usage?api_key=YOUR_KEY
/// </summary>
[HttpDelete("debug/endpoint-usage")]
[ServiceFilter(typeof(ApiKeyAuthFilter))]
public IActionResult ClearEndpointUsage()
{
try
{
var logFile = "/app/cache/endpoint-usage/endpoints.csv";
if (System.IO.File.Exists(logFile))
{
System.IO.File.Delete(logFile);
return Ok(new { status = "success", message = "Endpoint usage log cleared" });
}
return Ok(new { status = "success", message = "No log file to clear" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to clear endpoint usage log");
return StatusCode(500, new { error = ex.Message });
}
}
#endregion
/// <summary>
@@ -4061,5 +4512,69 @@ public class JellyfinController : ControllerBase
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,6 +28,7 @@ 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(
@@ -39,6 +40,7 @@ public class SubsonicController : ControllerBase
SubsonicResponseBuilder responseBuilder,
SubsonicModelMapper modelMapper,
SubsonicProxyService proxyService,
RedisCacheService cache,
ILogger<SubsonicController> logger,
PlaylistSyncService? playlistSyncService = null)
{
@@ -51,6 +53,7 @@ public class SubsonicController : ControllerBase
_modelMapper = modelMapper;
_proxyService = proxyService;
_playlistSyncService = playlistSyncService;
_cache = cache;
_logger = logger;
if (string.IsNullOrWhiteSpace(_subsonicSettings.Url))
@@ -559,6 +562,16 @@ 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);
@@ -576,6 +589,11 @@ 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

@@ -2,239 +2,44 @@ 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>
/// 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
/// 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.
/// </summary>
public partial class JellyfinAuthFilter : IAsyncActionFilter
public class JellyfinAuthFilter : IAsyncActionFilter
{
private readonly JellyfinSettings _settings;
private readonly ILogger<JellyfinAuthFilter> _logger;
public JellyfinAuthFilter(
IOptions<JellyfinSettings> settings,
ILogger<JellyfinAuthFilter> logger)
public JellyfinAuthFilter(ILogger<JellyfinAuthFilter> logger)
{
_settings = settings.Value;
_logger = logger;
}
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
// 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;
}
// This filter is now a no-op - all authentication is handled by Jellyfin
// Keeping the class for backwards compatibility but it does nothing
var request = context.HttpContext.Request;
_logger.LogTrace("JellyfinAuthFilter: Transparent proxy mode - no authentication check");
// 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

@@ -54,7 +54,7 @@ public class WebSocketProxyMiddleware
if (context.Request.Path.StartsWithSegments("/socket", StringComparison.OrdinalIgnoreCase) &&
context.WebSockets.IsWebSocketRequest)
{
_logger.LogInformation("🔌 WEBSOCKET: WebSocket connection request received from {RemoteIp}",
_logger.LogDebug("🔌 WEBSOCKET: WebSocket connection request received from {RemoteIp}",
context.Connection.RemoteIpAddress);
await HandleWebSocketProxyAsync(context);
@@ -142,7 +142,7 @@ public class WebSocketProxyMiddleware
serverWebSocket.Options.SetRequestHeader("User-Agent", "Allstarr/1.0");
await serverWebSocket.ConnectAsync(new Uri(jellyfinWsUrl), context.RequestAborted);
_logger.LogInformation("✓ WEBSOCKET: Connected to Jellyfin WebSocket");
_logger.LogDebug("✓ WEBSOCKET: Connected to Jellyfin WebSocket");
// Start bidirectional proxying
var clientToServer = ProxyMessagesAsync(clientWebSocket, serverWebSocket, "Client→Server", context.RequestAborted);
@@ -155,7 +155,15 @@ public class WebSocketProxyMiddleware
}
catch (WebSocketException wsEx)
{
_logger.LogWarning(wsEx, "⚠️ WEBSOCKET: WebSocket error: {Message}", wsEx.Message);
// 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);
}
}
catch (Exception ex)
{
@@ -194,7 +202,7 @@ public class WebSocketProxyMiddleware
// CRITICAL: Notify session manager that client disconnected
if (!string.IsNullOrEmpty(deviceId))
{
_logger.LogInformation("🧹 WEBSOCKET: Client disconnected, removing session for device {DeviceId}", deviceId);
_logger.LogDebug("🧹 WEBSOCKET: Client disconnected, removing session for device {DeviceId}", deviceId);
await _sessionManager.RemoveSessionAsync(deviceId);
}
@@ -239,7 +247,7 @@ public class WebSocketProxyMiddleware
if (direction == "Server→Client")
{
var messageText = System.Text.Encoding.UTF8.GetString(messageBytes);
_logger.LogInformation("📥 WEBSOCKET {Direction}: {Preview}",
_logger.LogTrace("📥 WEBSOCKET {Direction}: {Preview}",
direction,
messageText.Length > 500 ? messageText[..500] + "..." : messageText);
}
@@ -274,7 +282,7 @@ public class WebSocketProxyMiddleware
}
catch (Exception ex)
{
_logger.LogWarning(ex, "⚠️ WEBSOCKET {Direction}: Error proxying messages", direction);
_logger.LogDebug(ex, "WEBSOCKET {Direction}: Error proxying messages (connection closed)", direction);
}
}
}

View File

@@ -19,6 +19,12 @@ public class Song
/// All artists for this track (main + featured). For display in Jellyfin clients.
/// </summary>
public List<string> Artists { get; set; } = new();
/// <summary>
/// All artist IDs corresponding to the Artists list. Index-matched with Artists.
/// </summary>
public List<string> ArtistIds { get; set; } = new();
public string Album { get; set; } = string.Empty;
public string? AlbumId { get; set; }
public int? Duration { get; set; } // In seconds
@@ -44,6 +50,11 @@ 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>

View File

@@ -69,4 +69,11 @@ public class SpotifyApiSettings
/// 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

@@ -45,6 +45,14 @@ public class SpotifyPlaylistConfig
/// Where to position local tracks: "first" or "last"
/// </summary>
public LocalTracksPosition LocalTracksPosition { get; set; } = LocalTracksPosition.First;
/// <summary>
/// Cron schedule for syncing this playlist with Spotify
/// Format: minute hour day month dayofweek
/// Example: "0 8 * * 1" = 8 AM every Monday
/// Default: "0 8 * * 1" (weekly on Monday at 8 AM)
/// </summary>
public string SyncSchedule { get; set; } = "0 8 * * 1";
}
/// <summary>
@@ -59,27 +67,6 @@ public class SpotifyImportSettings
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Hour when Spotify Import plugin runs (24-hour format, 0-23)
/// NOTE: This setting is now optional and only used for the sync window check.
/// The fetcher will search backwards from current time for the last 48 hours,
/// so timezone confusion is avoided.
/// </summary>
public int SyncStartHour { get; set; } = 16;
/// <summary>
/// Minute when Spotify Import plugin runs (0-59)
/// NOTE: This setting is now optional and only used for the sync window check.
/// </summary>
public int SyncStartMinute { get; set; } = 15;
/// <summary>
/// How many hours to search for missing tracks files after sync start time
/// This prevents the fetcher from running too frequently.
/// Set to 0 to disable the sync window check and always search on startup.
/// </summary>
public int SyncWindowHours { get; set; } = 2;
/// <summary>
/// How often to run track matching in hours.
/// Spotify playlists like Discover Weekly update once per week, Release Radar updates weekly.

View File

@@ -13,25 +13,49 @@ using allstarr.Middleware;
using allstarr.Filters;
using Microsoft.Extensions.Http;
using System.Text;
using System.Net;
var builder = WebApplication.CreateBuilder(args);
// Configure forwarded headers for reverse proxy support (nginx, etc.)
// This allows ASP.NET Core to read X-Forwarded-For, X-Real-IP, etc.
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedFor
| Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedProto
| Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedHost;
// Clear known networks and proxies to accept headers from any proxy
// This is safe when running behind a trusted reverse proxy (nginx)
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
// Trust X-Forwarded-* headers from any source
// Only do this if your reverse proxy is properly configured and trusted
options.ForwardLimit = null;
});
// Decode SquidWTF API base URLs once at startup
var squidWtfApiUrls = DecodeSquidWtfUrls();
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
"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)
};
return encodedUrls
@@ -97,6 +121,10 @@ 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();
@@ -349,7 +377,7 @@ builder.Services.Configure<SpotifyImportSettings>(options =>
}
// Log configuration at startup
Console.WriteLine($"Spotify Import: Enabled={options.Enabled}, SyncHour={options.SyncStartHour}:{options.SyncStartMinute:D2}, WindowHours={options.SyncWindowHours}");
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)
{
@@ -375,6 +403,7 @@ else
// Business services - shared across backends
builder.Services.AddSingleton<RedisCacheService>();
builder.Services.AddSingleton<OdesliService>();
builder.Services.AddSingleton<ILocalLibraryService, LocalLibraryService>();
builder.Services.AddSingleton<LrclibService>();
@@ -388,6 +417,9 @@ if (backendType == BackendType.Jellyfin)
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
{
@@ -452,6 +484,7 @@ else if (musicService == MusicService.SquidWTF)
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
sp,
sp.GetRequiredService<ILogger<SquidWTFDownloadService>>(),
sp.GetRequiredService<OdesliService>(),
squidWtfApiUrls));
}
@@ -468,13 +501,19 @@ 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));
squidWtfApiUrls,
sp.GetRequiredService<EndpointBenchmarkService>(),
sp.GetRequiredService<ILogger<SquidWTFStartupValidator>>()));
builder.Services.AddSingleton<IStartupValidator, LyricsStartupValidator>();
// Register orchestrator as hosted service
builder.Services.AddHostedService<StartupValidationOrchestrator>();
@@ -559,6 +598,11 @@ builder.Services.AddHostedService<allstarr.Services.Spotify.SpotifyMissingTracks
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 =>
{
@@ -601,7 +645,23 @@ builder.Services.AddCors(options =>
var app = builder.Build();
// Migrate old .env file format on startup
try
{
var migrationService = new EnvMigrationService(app.Services.GetRequiredService<ILogger<EnvMigrationService>>());
migrationService.MigrateEnvFile();
}
catch (Exception ex)
{
app.Logger.LogWarning(ex, "Failed to run .env migration");
}
// Configure the HTTP request pipeline.
// IMPORTANT: UseForwardedHeaders must be called BEFORE other middleware
// This processes X-Forwarded-For, X-Real-IP, etc. from nginx
app.UseForwardedHeaders();
app.UseExceptionHandler(_ => { }); // Global exception handler
// Enable response compression EARLY in the pipeline

View File

@@ -5,6 +5,7 @@ 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;
@@ -27,9 +28,14 @@ public abstract class BaseDownloadService : IDownloadService
protected readonly string DownloadPath;
protected readonly string CachePath;
protected readonly Dictionary<string, DownloadInfo> ActiveDownloads = new();
protected readonly ConcurrentDictionary<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>
@@ -89,22 +95,88 @@ 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))
{
Logger.LogInformation("Streaming from local cache: {Path}", 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);
return IOFile.OpenRead(localPath);
}
// For on-demand streaming, download to disk first to ensure complete file
// Download to disk first to ensure complete file with metadata
// 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);
localPath = await DownloadSongInternalAsync(externalProvider, externalId, triggerAlbumDownload: true, cancellationToken);
return IOFile.OpenRead(localPath);
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)
@@ -120,20 +192,13 @@ public abstract class BaseDownloadService : IDownloadService
return null;
}
// Check local library
// Check local library (works for both cache and permanent storage)
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;
}
@@ -199,50 +264,49 @@ public abstract class BaseDownloadService : IDownloadService
// Acquire lock BEFORE checking existence to prevent race conditions with concurrent requests
await DownloadLock.WaitAsync(cancellationToken);
var lockHeld = true;
try
{
// Check if already downloaded (skip for cache mode as we want to check cache folder)
if (!isCache)
// Check if already downloaded (works for both cache and permanent modes)
var existingPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId);
if (existingPath != null && IOFile.Exists(existingPath))
{
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)
{
Logger.LogInformation("Song already downloaded: {Path}", existingPath);
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;
IOFile.SetLastAccessTime(existingPath, DateTime.UtcNow);
}
return existingPath;
}
// Check if download in progress
if (ActiveDownloads.TryGetValue(songId, out var activeDownload) && activeDownload.Status == DownloadStatus.InProgress)
{
Logger.LogInformation("Download already in progress for {SongId}, waiting...", songId);
Logger.LogDebug("Download already in progress for {SongId}, waiting for completion...", songId);
// Release lock while waiting
DownloadLock.Release();
lockHeld = false;
// Wait for download to complete, checking every 100ms (faster than 500ms)
// Also respect cancellation token so client timeouts are handled immediately
while (ActiveDownloads.TryGetValue(songId, out activeDownload) && activeDownload.Status == DownloadStatus.InProgress)
{
await Task.Delay(500, cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
await Task.Delay(100, cancellationToken);
}
if (activeDownload?.Status == DownloadStatus.Completed && activeDownload.LocalPath != null)
{
Logger.LogDebug("Download completed while waiting, returning path: {Path}", activeDownload.LocalPath);
return activeDownload.LocalPath;
}
throw new Exception(activeDownload?.ErrorMessage ?? "Download failed");
// Download failed or was cancelled
throw new Exception(activeDownload?.ErrorMessage ?? "Download failed while waiting");
}
// Get metadata
@@ -298,6 +362,14 @@ 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);
@@ -360,13 +432,24 @@ 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;
}
finally
{
DownloadLock.Release();
if (lockHeld)
{
DownloadLock.Release();
}
}
}
@@ -560,29 +643,34 @@ public abstract class BaseDownloadService : IDownloadService
}
}
#endregion
#region Rate Limiting
/// <summary>
/// Gets the cached file path for a given provider and external ID
/// Returns null if no cached file exists
/// Queues a request with rate limiting to prevent overwhelming the API.
/// Ensures minimum interval between requests.
/// </summary>
protected string? GetCachedFilePath(string provider, string externalId)
protected async Task<T> QueueRequestAsync<T>(Func<Task<T>> action)
{
await _requestLock.WaitAsync();
try
{
// Search for cached files matching the pattern: {provider}_{externalId}.*
var pattern = $"{provider}_{externalId}.*";
var files = Directory.GetFiles(CachePath, pattern, SearchOption.AllDirectories);
var now = DateTime.UtcNow;
var timeSinceLastRequest = (now - _lastRequestTime).TotalMilliseconds;
if (files.Length > 0)
if (timeSinceLastRequest < _minRequestIntervalMs)
{
return files[0]; // Return first match
await Task.Delay((int)(_minRequestIntervalMs - timeSinceLastRequest));
}
return null;
_lastRequestTime = DateTime.UtcNow;
return await action();
}
catch (Exception ex)
finally
{
Logger.LogWarning(ex, "Failed to search for cached file: {Provider}_{ExternalId}", provider, externalId);
return null;
_requestLock.Release();
}
}

View File

@@ -1,5 +1,6 @@
using Microsoft.Extensions.Options;
using allstarr.Models.Settings;
using allstarr.Controllers;
namespace allstarr.Services.Common;
@@ -11,16 +12,19 @@ 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;
}
@@ -41,6 +45,7 @@ public class CacheCleanupService : BackgroundService
try
{
await CleanupOldCachedFilesAsync(stoppingToken);
await ProcessPendingDeletionsAsync(stoppingToken);
await Task.Delay(_cleanupInterval, stoppingToken);
}
catch (OperationCanceledException)
@@ -61,7 +66,9 @@ public class CacheCleanupService : BackgroundService
private async Task CleanupOldCachedFilesAsync(CancellationToken cancellationToken)
{
var cachePath = PathHelper.GetCachePath();
// Get the actual cache path used by download services
var downloadPath = _configuration["Library:DownloadPath"] ?? "downloads";
var cachePath = Path.Combine(downloadPath, "cache");
if (!Directory.Exists(cachePath))
{
@@ -73,7 +80,7 @@ public class CacheCleanupService : BackgroundService
var deletedCount = 0;
var totalSize = 0L;
_logger.LogInformation("Starting cache cleanup: deleting files older than {CutoffTime}", cutoffTime);
_logger.LogInformation("Starting cache cleanup: deleting files older than {CutoffTime} from {Path}", cutoffTime, cachePath);
try
{
@@ -89,16 +96,16 @@ public class CacheCleanupService : BackgroundService
{
var fileInfo = new FileInfo(filePath);
// Use last access time to determine if file should be deleted
// This gets updated when a cached file is streamed
if (fileInfo.LastAccessTimeUtc < cutoffTime)
// 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)
{
var size = fileInfo.Length;
File.Delete(filePath);
deletedCount++;
totalSize += size;
_logger.LogDebug("Deleted cached file: {Path} (last accessed: {LastAccess})",
filePath, fileInfo.LastAccessTimeUtc);
_logger.LogDebug("Deleted cached file: {Path} (age: {Age:F1} hours)",
filePath, (DateTime.UtcNow - fileInfo.LastWriteTimeUtc).TotalHours);
}
}
catch (Exception ex)
@@ -160,4 +167,30 @@ 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,4 +1,5 @@
using System.Text.Json;
using allstarr.Models.Domain;
namespace allstarr.Services.Common;
@@ -10,14 +11,19 @@ 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;
}
@@ -28,6 +34,9 @@ public class CacheWarmingService : IHostedService
var startTime = DateTime.UtcNow;
var genresWarmed = 0;
var playlistsWarmed = 0;
var mappingsWarmed = 0;
var lyricsWarmed = 0;
var lyricsMappingsWarmed = 0;
try
{
@@ -37,10 +46,19 @@ public class CacheWarmingService : IHostedService
// 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",
duration.TotalSeconds, genresWarmed, playlistsWarmed);
"✅ 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)
{
@@ -115,10 +133,12 @@ public class CacheWarmingService : IHostedService
return 0;
}
var files = Directory.GetFiles(PlaylistCacheDirectory, "*_items.json");
var itemsFiles = Directory.GetFiles(PlaylistCacheDirectory, "*_items.json");
var matchedFiles = Directory.GetFiles(PlaylistCacheDirectory, "*_matched.json");
var warmedCount = 0;
foreach (var file in files)
// Warm playlist items cache
foreach (var file in itemsFiles)
{
if (cancellationToken.IsCancellationRequested)
break;
@@ -145,13 +165,51 @@ public class CacheWarmingService : IHostedService
await _cache.SetAsync(redisKey, items, TimeSpan.FromHours(24));
warmedCount++;
_logger.LogDebug("🔥 Warmed playlist cache for {Playlist} ({Count} items)",
_logger.LogDebug("🔥 Warmed playlist items cache for {Playlist} ({Count} items)",
playlistName, items.Count);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to warm playlist cache from file: {File}", file);
_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);
}
}
@@ -163,10 +221,180 @@ public class CacheWarmingService : IHostedService
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

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

View File

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

View File

@@ -2,12 +2,64 @@ 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>
/// Calculates a similarity score between two strings (0-100).
/// Higher score means better match.
/// 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.
/// </summary>
public static int CalculateSimilarity(string query, string target)
{
@@ -16,47 +68,87 @@ public static class FuzzyMatcher
return 0;
}
var queryLower = NormalizeForMatching(query);
var targetLower = NormalizeForMatching(target);
var queryNorm = NormalizeForMatching(query);
var targetNorm = NormalizeForMatching(target);
// STEP 2: SUBSTRING MATCHING (cheap, high-precision)
// Exact match
if (queryLower == targetLower)
if (queryNorm == targetNorm)
{
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 (targetLower.StartsWith(queryLower))
if (targetNorm.StartsWith(queryNorm) || queryNorm.StartsWith(targetNorm))
{
return 90;
}
// Contains query as whole word
if (targetLower.Contains($" {queryLower} ") ||
targetLower.StartsWith($"{queryLower} ") ||
targetLower.EndsWith($" {queryLower}"))
if (targetNorm.Contains($" {queryNorm} ") ||
targetNorm.StartsWith($"{queryNorm} ") ||
targetNorm.EndsWith($" {queryNorm}") ||
queryNorm.Contains($" {targetNorm} ") ||
queryNorm.StartsWith($"{targetNorm} ") ||
queryNorm.EndsWith($" {targetNorm}"))
{
return 80;
return 85;
}
// Contains query anywhere
if (targetLower.Contains(queryLower))
{
return 70;
}
// STEP 3: LEVENSHTEIN DISTANCE (expensive, fuzzy)
// Only use this for candidates that survived substring checks
// Calculate Levenshtein distance for fuzzy matching
var distance = LevenshteinDistance(queryLower, targetLower);
var maxLength = Math.Max(queryLower.Length, targetLower.Length);
var distance = LevenshteinDistance(queryNorm, targetNorm);
var maxLength = Math.Max(queryNorm.Length, targetNorm.Length);
if (maxLength == 0)
{
return 100;
}
// 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);
// 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>
@@ -129,4 +221,54 @@ 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

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

@@ -57,13 +57,14 @@ public class RedisCacheService
try
{
var value = await _db!.StringGetAsync(key);
if (value.HasValue)
{
_logger.LogInformation("Redis cache HIT: {Key}", key);
_logger.LogDebug("Redis cache HIT: {Key}", key);
}
else
{
_logger.LogInformation("Redis cache MISS: {Key}", key);
_logger.LogDebug("Redis cache MISS: {Key}", key);
}
return value;
}
@@ -105,7 +106,7 @@ public class RedisCacheService
var result = await _db!.StringSetAsync(key, value, expiry);
if (result)
{
_logger.LogInformation("Redis cache SET: {Key} (TTL: {Expiry})", key, expiry?.ToString() ?? "none");
_logger.LogDebug("Redis cache SET: {Key} (TTL: {Expiry})", key, expiry?.ToString() ?? "none");
}
return result;
}

View File

@@ -0,0 +1,313 @@
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,7 +24,6 @@ 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;
@@ -33,9 +32,6 @@ 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
@@ -111,7 +107,10 @@ public class DeezerDownloadService : BaseDownloadService
// Build organized folder structure: Artist/Album/Track using AlbumArtist (fallback to Artist for singles)
var artistForPath = song.AlbumArtist ?? song.Artist;
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache ? CachePath : DownloadPath;
// 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);
// Create directories if they don't exist
@@ -494,27 +493,6 @@ 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

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

View File

@@ -168,6 +168,11 @@ 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)
{
@@ -179,11 +184,27 @@ public class JellyfinProxyService
var headerValue = header.Value.ToString();
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
authHeaderAdded = true;
_logger.LogInformation("Forwarded X-Emby-Authorization: {Value}", headerValue);
_logger.LogTrace("Forwarded X-Emby-Authorization header");
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)
@@ -201,37 +222,32 @@ public class JellyfinProxyService
// Forward as X-Emby-Authorization (Jellyfin's expected header)
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
authHeaderAdded = true;
_logger.LogInformation("Converted Authorization to X-Emby-Authorization: {Value}", headerValue);
_logger.LogTrace("Converted Authorization to X-Emby-Authorization");
}
else
{
// Standard Bearer token - forward as-is
request.Headers.TryAddWithoutValidation("Authorization", headerValue);
authHeaderAdded = true;
_logger.LogInformation("Forwarded Authorization (Bearer): {Value}", headerValue);
_logger.LogTrace("Forwarded Authorization header");
}
break;
}
}
}
// Only log warnings for non-browser static requests
if (!authHeaderAdded && !isBrowserStaticRequest)
// Check for api_key query parameter (some clients use this)
if (!authHeaderAdded && url.Contains("api_key=", StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("✗ No auth header found. Available headers: {Headers}",
string.Join(", ", clientHeaders.Select(h => $"{h.Key}={h.Value}")));
authHeaderAdded = true; // It's in the URL, no need to add header
_logger.LogTrace("Using api_key from query string");
}
}
else if (!isBrowserStaticRequest)
{
_logger.LogWarning("✗ No client headers provided for {Url}", url);
}
// 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)
// Only log warnings for non-public, non-browser requests without auth
if (!authHeaderAdded && !isBrowserStaticRequest && !isPublicEndpoint)
{
_logger.LogInformation("No client auth provided for {Url} - forwarding without auth", url);
_logger.LogDebug("No client auth provided for {Url} - Jellyfin will handle authentication", url);
}
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
@@ -248,14 +264,28 @@ public class JellyfinProxyService
{
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
_logger.LogWarning("Jellyfin returned 401 Unauthorized for {Url} - passing through to client", url);
// 401 means token expired or invalid - client needs to re-authenticate
_logger.LogInformation("Jellyfin returned 401 Unauthorized for {Url} - client should re-authenticate", url);
}
else if (!isBrowserStaticRequest) // Don't log 404s for browser static requests
else if (!isBrowserStaticRequest && !isPublicEndpoint)
{
_logger.LogWarning("Jellyfin request failed: {StatusCode} for {Url}", response.StatusCode, url);
}
// Return null body with the actual status code
// 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, statusCode);
}
@@ -297,8 +327,10 @@ 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))
@@ -306,11 +338,28 @@ public class JellyfinProxyService
var headerValue = header.Value.ToString();
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
authHeaderAdded = true;
_logger.LogDebug("Forwarded X-Emby-Authorization from client");
_logger.LogTrace("Forwarded X-Emby-Authorization header");
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)
@@ -325,13 +374,13 @@ public class JellyfinProxyService
{
// Forward as X-Emby-Authorization
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
_logger.LogDebug("Converted Authorization to X-Emby-Authorization");
_logger.LogTrace("Converted Authorization to X-Emby-Authorization");
}
else
{
// Standard Bearer token
request.Headers.TryAddWithoutValidation("Authorization", headerValue);
_logger.LogDebug("Forwarded Authorization header");
_logger.LogTrace("Forwarded Authorization header");
}
authHeaderAdded = true;
break;
@@ -339,30 +388,23 @@ public class JellyfinProxyService
}
}
// 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)
// For authentication endpoints, credentials are in the body, not headers
// For other endpoints without auth, let Jellyfin reject the request
if (!authHeaderAdded && !isAuthEndpoint)
{
_logger.LogInformation("No client auth provided for POST {Url} - forwarding without auth", url);
_logger.LogDebug("No client auth provided for POST {Url} - Jellyfin will handle authentication", url);
}
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
// DO NOT log the body for auth endpoints - it contains passwords!
if (endpoint.Contains("Authenticate", StringComparison.OrdinalIgnoreCase))
if (isAuthEndpoint)
{
_logger.LogDebug("POST to Jellyfin: {Url} (auth request - body not logged)", url);
}
else
{
_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);
}
_logger.LogTrace("POST to Jellyfin: {Url}, body length: {Length} bytes", url, bodyToSend.Length);
}
var response = await _httpClient.SendAsync(request);
@@ -372,15 +414,39 @@ public class JellyfinProxyService
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
_logger.LogWarning("❌ SESSION: Jellyfin POST request failed: {StatusCode} for {Url}. Response: {Response}",
response.StatusCode, url, errorContent);
// 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
}
}
return (null, statusCode);
}
// Log successful session-related responses
if (endpoint.Contains("Sessions", StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("✓ SESSION: Jellyfin responded {StatusCode} for {Endpoint}", statusCode, endpoint);
_logger.LogTrace("Jellyfin responded {StatusCode} for {Endpoint}", statusCode, endpoint);
}
// Handle 204 No Content responses (e.g., /sessions/playing, /sessions/playing/progress)
@@ -397,18 +463,12 @@ public class JellyfinProxyService
return (null, statusCode);
}
// Log response content for session endpoints
if (endpoint.Contains("Sessions", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(responseContent))
{
var preview = responseContent.Length > 200 ? responseContent[..200] + "..." : responseContent;
_logger.LogWarning("📥 SESSION: Jellyfin response body: {Body}", preview);
}
return (JsonDocument.Parse(responseContent), statusCode);
}
/// <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)
{
@@ -423,9 +483,35 @@ 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.
@@ -940,4 +1026,43 @@ 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

@@ -299,13 +299,11 @@ public class JellyfinResponseBuilder
["ItemId"] = song.Id
},
["Artists"] = artistNames.Count > 0 ? artistNames.ToArray() : new[] { artistName ?? "" },
["ArtistItems"] = artistNames.Count > 0
["ArtistItems"] = artistNames.Count > 0 && song.ArtistIds.Count == artistNames.Count
? artistNames.Select((name, index) => new Dictionary<string, object?>
{
["Name"] = name,
["Id"] = index == 0 && song.ArtistId != null
? song.ArtistId
: $"{song.Id}-artist-{index}"
["Id"] = song.ArtistIds[index]
}).ToArray()
: new[]
{

View File

@@ -38,12 +38,13 @@ public class JellyfinSessionManager : IDisposable
/// <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("⚠️ SESSION: Cannot create session - no device ID");
_logger.LogWarning("Cannot create session - no device ID");
return false;
}
@@ -51,27 +52,43 @@ public class JellyfinSessionManager : IDisposable
if (_sessions.TryGetValue(deviceId, out var existingSession))
{
existingSession.LastActivity = DateTime.UtcNow;
_logger.LogDebug("✓ SESSION: Session already exists for device {DeviceId}", deviceId);
_logger.LogTrace("Session already exists for device {DeviceId}", deviceId);
// Refresh capabilities to keep session alive
await PostCapabilitiesAsync(headers);
// 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.LogInformation("🔧 SESSION: Creating new session for device: {DeviceId} ({Client} on {Device})", deviceId, client, device);
// Log the headers we received for debugging
_logger.LogDebug("🔍 SESSION: Headers received for session creation: {Headers}",
string.Join(", ", headers.Select(h => $"{h.Key}={h.Value.ToString().Substring(0, Math.Min(30, h.Value.ToString().Length))}...")));
_logger.LogDebug("Creating new session for device: {DeviceId} ({Client} on {Device})", deviceId, client, device);
try
{
// Post session capabilities to Jellyfin - this creates the session
await PostCapabilitiesAsync(headers);
var success = await PostCapabilitiesAsync(headers);
_logger.LogInformation("✓ SESSION: Session created for {DeviceId}", deviceId);
if (!success)
{
// Token expired or invalid - client needs to re-authenticate
_logger.LogInformation("Failed to create session for {DeviceId} - token may be expired", deviceId);
return false;
}
_logger.LogDebug("Session created for {DeviceId}", deviceId);
// Track this session
var clientIp = headers["X-Forwarded-For"].FirstOrDefault()?.Split(',')[0].Trim()
?? headers["X-Real-IP"].FirstOrDefault()
?? "Unknown";
_sessions[deviceId] = new SessionInfo
{
DeviceId = deviceId,
@@ -79,7 +96,8 @@ public class JellyfinSessionManager : IDisposable
Device = device,
Version = version,
LastActivity = DateTime.UtcNow,
Headers = CloneHeaders(headers)
Headers = CloneHeaders(headers),
ClientIp = clientIp
};
// Start a WebSocket connection to Jellyfin on behalf of this client
@@ -89,15 +107,16 @@ public class JellyfinSessionManager : IDisposable
}
catch (Exception ex)
{
_logger.LogError(ex, "❌ SESSION: Error creating session for {DeviceId}", deviceId);
_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 PostCapabilitiesAsync(IHeaderDictionary headers)
private async Task<bool> PostCapabilitiesAsync(IHeaderDictionary headers)
{
var capabilities = new
{
@@ -118,12 +137,19 @@ public class JellyfinSessionManager : IDisposable
if (statusCode == 204 || statusCode == 200)
{
_logger.LogDebug("✓ SESSION: Posted capabilities successfully ({StatusCode})", statusCode);
_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
{
// 401 is common when cached headers have expired - not a critical error
_logger.LogDebug("SESSION: Capabilities post returned {StatusCode} (may be expected if token expired)", statusCode);
_logger.LogDebug("Capabilities post returned {StatusCode}", statusCode);
return false;
}
}
@@ -143,6 +169,80 @@ public class JellyfinSessionManager : IDisposable
}
}
/// <summary>
/// Updates the currently playing item for a session (for scrobbling on cleanup).
/// </summary>
public void UpdatePlayingItem(string deviceId, string? itemId, long? positionTicks)
{
if (_sessions.TryGetValue(deviceId, out var session))
{
session.LastPlayingItemId = itemId;
session.LastPlayingPositionTicks = positionTicks;
session.LastActivity = DateTime.UtcNow;
_logger.LogDebug("🎵 SESSION: Updated playing item for {DeviceId}: {ItemId} at {Position}",
deviceId, itemId, positionTicks);
}
}
/// <summary>
/// Marks a session as potentially ended (e.g., after playback stops).
/// The session will be cleaned up if no new activity occurs within the timeout.
/// </summary>
public void MarkSessionPotentiallyEnded(string deviceId, TimeSpan timeout)
{
if (_sessions.TryGetValue(deviceId, out var session))
{
_logger.LogDebug("⏰ SESSION: Marking session {DeviceId} as potentially ended, will cleanup in {Seconds}s if no activity",
deviceId, timeout.TotalSeconds);
_ = Task.Run(async () =>
{
var markedTime = DateTime.UtcNow;
await Task.Delay(timeout);
// Check if there's been activity since we marked it
if (_sessions.TryGetValue(deviceId, out var currentSession) &&
currentSession.LastActivity <= markedTime)
{
_logger.LogDebug("🧹 SESSION: Auto-removing inactive session {DeviceId} after playback stop", deviceId);
await RemoveSessionAsync(deviceId);
}
else
{
_logger.LogDebug("✓ SESSION: Session {DeviceId} had activity, keeping alive", deviceId);
}
});
}
}
/// <summary>
/// Gets information about current active sessions for debugging.
/// </summary>
public object GetSessionsInfo()
{
var now = DateTime.UtcNow;
var sessions = _sessions.Values.Select(s => new
{
DeviceId = s.DeviceId,
Client = s.Client,
Device = s.Device,
Version = s.Version,
ClientIp = s.ClientIp,
LastActivity = s.LastActivity,
InactiveMinutes = Math.Round((now - s.LastActivity).TotalMinutes, 1),
HasWebSocket = s.WebSocket != null,
WebSocketState = s.WebSocket?.State.ToString() ?? "None"
}).ToList();
return new
{
TotalSessions = sessions.Count,
ActiveSessions = sessions.Count(s => s.InactiveMinutes < 2),
StaleSessions = sessions.Count(s => s.InactiveMinutes >= 2),
Sessions = sessions.OrderBy(s => s.InactiveMinutes)
};
}
/// <summary>
/// Removes a session when the client disconnects.
/// </summary>
@@ -150,7 +250,7 @@ public class JellyfinSessionManager : IDisposable
{
if (_sessions.TryRemove(deviceId, out var session))
{
_logger.LogInformation("🗑️ SESSION: Removing session for device {DeviceId}", deviceId);
_logger.LogDebug("🗑️ SESSION: Removing session for device {DeviceId}", deviceId);
// Close WebSocket if it exists
if (session.WebSocket != null && session.WebSocket.State == WebSocketState.Open)
@@ -162,7 +262,7 @@ public class JellyfinSessionManager : IDisposable
}
catch (Exception ex)
{
_logger.LogWarning(ex, "⚠️ WEBSOCKET: Error closing WebSocket for {DeviceId}", deviceId);
_logger.LogDebug(ex, "WEBSOCKET: Error closing WebSocket for {DeviceId}", deviceId);
}
finally
{
@@ -172,13 +272,26 @@ public class JellyfinSessionManager : IDisposable
try
{
// Optionally notify Jellyfin that the session is ending
// (Jellyfin will auto-cleanup inactive sessions anyway)
// 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);
_logger.LogWarning("⚠️ SESSION: Error removing session for {DeviceId}: {Message}", deviceId, ex.Message);
}
}
}
@@ -212,20 +325,23 @@ public class JellyfinSessionManager : IDisposable
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(", ", headers.Keys));
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 (headers.TryGetValue("X-Emby-Authorization", out var embyAuth))
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 (headers.TryGetValue("Authorization", out var auth))
else if (sessionHeaders.TryGetValue("Authorization", out var auth))
{
var authValue = auth.ToString();
if (authValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase))
@@ -250,11 +366,11 @@ public class JellyfinSessionManager : IDisposable
if (!string.IsNullOrEmpty(_settings.ApiKey))
{
jellyfinWsUrl += $"?api_key={_settings.ApiKey}";
_logger.LogWarning("⚠️ WEBSOCKET: No client auth found in headers, falling back to server API key for {DeviceId}", deviceId);
_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}!", deviceId);
_logger.LogWarning("❌ WEBSOCKET: No authentication available for {DeviceId} - WebSocket will fail", deviceId);
}
}
@@ -265,7 +381,7 @@ public class JellyfinSessionManager : IDisposable
// Connect to Jellyfin
await webSocket.ConnectAsync(new Uri(jellyfinWsUrl), CancellationToken.None);
_logger.LogInformation("✓ WEBSOCKET: Connected to Jellyfin for device {DeviceId}", deviceId);
_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
@@ -321,8 +437,8 @@ public class JellyfinSessionManager : IDisposable
}
else
{
// Log other message types at info level
_logger.LogInformation("📥 WEBSOCKET: {DeviceId}: {Message}",
// Log other message types at trace level
_logger.LogTrace("📥 WEBSOCKET: {DeviceId}: {Message}",
deviceId, message.Length > 100 ? message[..100] + "..." : message);
}
}
@@ -344,7 +460,7 @@ public class JellyfinSessionManager : IDisposable
}
catch (WebSocketException wsEx)
{
_logger.LogWarning(wsEx, "⚠️ WEBSOCKET: WebSocket error for device {DeviceId}", deviceId);
_logger.LogDebug(wsEx, "WEBSOCKET: Connection closed for device {DeviceId}", deviceId);
break;
}
}
@@ -380,6 +496,7 @@ public class JellyfinSessionManager : IDisposable
/// <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)
{
@@ -391,29 +508,45 @@ public class JellyfinSessionManager : IDisposable
return;
}
_logger.LogDebug("💓 SESSION: Keeping {Count} sessions alive", activeSessions.Count);
_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
// Note: This may fail with 401 if the client's token has expired
// That's okay - the WebSocket connection keeps the session alive anyway
await PostCapabilitiesAsync(session.Headers);
// 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, "SESSION: Error keeping session alive for {DeviceId} (WebSocket still active)", session.DeviceId);
_logger.LogDebug(ex, "Error keeping session alive for {DeviceId}", session.DeviceId);
}
}
// Clean up stale sessions (inactive for > 10 minutes)
var staleSessions = _sessions.Where(kvp => now - kvp.Value.LastActivity > TimeSpan.FromMinutes(10)).ToList();
// 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.LogInformation("🧹 SESSION: Removing stale session for {DeviceId}", stale.Key);
_sessions.TryRemove(stale.Key, out _);
_logger.LogDebug("Removing stale session for {DeviceId} (inactive for {Minutes:F1} minutes)",
stale.Key, (now - stale.Value.LastActivity).TotalMinutes);
await RemoveSessionAsync(stale.Key);
}
}
@@ -436,6 +569,9 @@ public class JellyfinSessionManager : IDisposable
public DateTime LastActivity { get; set; }
public required IHeaderDictionary Headers { get; init; }
public ClientWebSocket? WebSocket { get; set; }
public string? LastPlayingItemId { get; set; }
public long? LastPlayingPositionTicks { get; set; }
public string? ClientIp { get; set; }
}
public void Dispose()

View File

@@ -30,9 +30,42 @@ public class LrclibService
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))
{
@@ -87,7 +120,7 @@ public class LrclibService
var artistCountBonus = resultArtistCount == expectedArtistCount ? 50.0 : 0.0;
// Duration match (within 5 seconds is good)
var durationDiff = Math.Abs(result.Duration - durationSeconds);
var durationDiff = result.Duration.HasValue ? Math.Abs(result.Duration.Value - durationSeconds) : 999;
var durationScore = durationDiff <= 5 ? 100.0 : Math.Max(0, 100 - (durationDiff * 2));
// Bonus for having synced lyrics (prefer synced over plain)
@@ -118,7 +151,7 @@ public class LrclibService
TrackName = bestMatch.TrackName ?? trackName,
ArtistName = bestMatch.ArtistName ?? artistName,
AlbumName = bestMatch.AlbumName ?? albumName,
Duration = (int)Math.Round(bestMatch.Duration),
Duration = bestMatch.Duration.HasValue ? (int)Math.Round(bestMatch.Duration.Value) : durationSeconds,
Instrumental = bestMatch.Instrumental,
PlainLyrics = bestMatch.PlainLyrics,
SyncedLyrics = bestMatch.SyncedLyrics
@@ -167,7 +200,7 @@ public class LrclibService
TrackName = lyrics.TrackName ?? trackName,
ArtistName = lyrics.ArtistName ?? artistName,
AlbumName = lyrics.AlbumName ?? albumName,
Duration = (int)Math.Round(lyrics.Duration),
Duration = lyrics.Duration.HasValue ? (int)Math.Round(lyrics.Duration.Value) : durationSeconds,
Instrumental = lyrics.Instrumental,
PlainLyrics = lyrics.PlainLyrics,
SyncedLyrics = lyrics.SyncedLyrics
@@ -309,7 +342,7 @@ public class LrclibService
TrackName = lyrics.TrackName ?? trackName,
ArtistName = lyrics.ArtistName ?? artistName,
AlbumName = lyrics.AlbumName ?? albumName,
Duration = (int)Math.Round(lyrics.Duration),
Duration = lyrics.Duration.HasValue ? (int)Math.Round(lyrics.Duration.Value) : durationSeconds,
Instrumental = lyrics.Instrumental,
PlainLyrics = lyrics.PlainLyrics,
SyncedLyrics = lyrics.SyncedLyrics
@@ -365,7 +398,7 @@ public class LrclibService
TrackName = lyrics.TrackName ?? string.Empty,
ArtistName = lyrics.ArtistName ?? string.Empty,
AlbumName = lyrics.AlbumName ?? string.Empty,
Duration = (int)Math.Round(lyrics.Duration),
Duration = lyrics.Duration.HasValue ? (int)Math.Round(lyrics.Duration.Value) : 0,
Instrumental = lyrics.Instrumental,
PlainLyrics = lyrics.PlainLyrics,
SyncedLyrics = lyrics.SyncedLyrics
@@ -394,7 +427,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

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

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

@@ -24,31 +24,25 @@ public class SpotifyLyricsService
{
private readonly ILogger<SpotifyLyricsService> _logger;
private readonly SpotifyApiSettings _settings;
private readonly SpotifyApiClient _spotifyClient;
private readonly RedisCacheService _cache;
private readonly HttpClient _httpClient;
private const string LyricsApiBase = "https://spclient.wg.spotify.com/color-lyrics/v2/track";
public SpotifyLyricsService(
ILogger<SpotifyLyricsService> logger,
IOptions<SpotifyApiSettings> settings,
SpotifyApiClient spotifyClient,
RedisCacheService cache,
IHttpClientFactory httpClientFactory)
{
_logger = logger;
_settings = settings.Value;
_spotifyClient = spotifyClient;
_cache = cache;
_httpClient = httpClientFactory.CreateClient();
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0");
_httpClient.DefaultRequestHeaders.Add("App-Platform", "WebPlayer");
_httpClient.Timeout = TimeSpan.FromSeconds(10);
}
/// <summary>
/// Gets synchronized lyrics for a Spotify track by its ID.
/// 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>
@@ -60,58 +54,37 @@ public class SpotifyLyricsService
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);
// Check cache
var cacheKey = $"spotify:lyrics:{spotifyTrackId}";
var cached = await _cache.GetAsync<SpotifyLyricsResult>(cacheKey);
if (cached != null)
{
_logger.LogDebug("Returning cached Spotify lyrics for track {TrackId}", spotifyTrackId);
return cached;
}
// NO CACHING - Spotify lyrics come from local Docker container (fast)
try
{
// Get access token
var token = await _spotifyClient.GetWebAccessTokenAsync();
if (string.IsNullOrEmpty(token))
{
_logger.LogWarning("Could not get Spotify access token for lyrics");
return null;
}
var url = $"{_settings.LyricsApiUrl}/?trackid={spotifyTrackId}&format=id3";
// Request lyrics from Spotify's color-lyrics API
var url = $"{LyricsApiBase}/{spotifyTrackId}?format=json&vocalRemoval=false&market=from_token";
_logger.LogDebug("Fetching lyrics from sidecar API: {Url}", url);
var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
request.Headers.Add("Accept", "application/json");
var response = await _httpClient.SendAsync(request);
if (response.StatusCode == HttpStatusCode.NotFound)
{
_logger.LogDebug("No lyrics found on Spotify for track {TrackId}", spotifyTrackId);
return null;
}
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("Spotify lyrics API returned {StatusCode} for track {TrackId}",
_logger.LogDebug("Sidecar API returned {StatusCode} for track {TrackId}",
response.StatusCode, spotifyTrackId);
return null;
}
var json = await response.Content.ReadAsStringAsync();
var result = ParseLyricsResponse(json, spotifyTrackId);
var result = ParseSidecarResponse(json, spotifyTrackId);
if (result != null)
{
// Cache for 30 days (lyrics don't change)
await _cache.SetAsync(cacheKey, result, TimeSpan.FromDays(30));
_logger.LogInformation("Cached Spotify lyrics for track {TrackId} ({LineCount} lines)",
_logger.LogInformation("Got Spotify lyrics from sidecar for track {TrackId} ({LineCount} lines)",
spotifyTrackId, result.Lines.Count);
}
@@ -119,14 +92,15 @@ public class SpotifyLyricsService
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching Spotify lyrics for track {TrackId}", spotifyTrackId);
_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.
/// 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,
@@ -136,85 +110,15 @@ public class SpotifyLyricsService
{
if (!_settings.Enabled || string.IsNullOrEmpty(_settings.SessionCookie))
{
_logger.LogDebug("Spotify lyrics search skipped: API not enabled or no session cookie");
return null;
}
try
{
var token = await _spotifyClient.GetWebAccessTokenAsync();
if (string.IsNullOrEmpty(token))
{
return null;
}
// Search for the track
var query = $"track:{trackName} artist:{artistName}";
if (!string.IsNullOrEmpty(albumName))
{
query += $" album:{albumName}";
}
var searchUrl = $"https://api.spotify.com/v1/search?q={Uri.EscapeDataString(query)}&type=track&limit=5";
var request = new HttpRequestMessage(HttpMethod.Get, searchUrl);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await _httpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
return null;
}
var json = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (!root.TryGetProperty("tracks", out var tracks) ||
!tracks.TryGetProperty("items", out var items) ||
items.GetArrayLength() == 0)
{
return null;
}
// Find best match considering duration if provided
string? bestMatchId = null;
var bestScore = 0;
foreach (var item in items.EnumerateArray())
{
var id = item.TryGetProperty("id", out var idProp) ? idProp.GetString() : null;
if (string.IsNullOrEmpty(id)) continue;
var score = 100; // Base score
// Check duration match
if (durationMs.HasValue && item.TryGetProperty("duration_ms", out var durProp))
{
var trackDuration = durProp.GetInt32();
var durationDiff = Math.Abs(trackDuration - durationMs.Value);
if (durationDiff < 2000) score += 50; // Within 2 seconds
else if (durationDiff < 5000) score += 25; // Within 5 seconds
}
if (score > bestScore)
{
bestScore = score;
bestMatchId = id;
}
}
if (!string.IsNullOrEmpty(bestMatchId))
{
return await GetLyricsByTrackIdAsync(bestMatchId);
}
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error searching Spotify for lyrics: {Track} - {Artist}", trackName, artistName);
return null;
}
// 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>
@@ -251,91 +155,51 @@ public class SpotifyLyricsService
};
}
private SpotifyLyricsResult? ParseLyricsResponse(string json, string trackId)
/// <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
};
// Parse lyrics lines
if (root.TryGetProperty("lyrics", out var lyrics))
// Get sync type
if (root.TryGetProperty("syncType", out var syncType))
{
// Check sync type
if (lyrics.TryGetProperty("syncType", out var syncType))
{
result.SyncType = syncType.GetString() ?? "LINE_SYNCED";
}
result.SyncType = syncType.GetString() ?? "LINE_SYNCED";
}
// Parse lines
if (lyrics.TryGetProperty("lines", out var lines))
// Parse lines
if (root.TryGetProperty("lines", out var lines))
{
foreach (var line in lines.EnumerateArray())
{
foreach (var line in lines.EnumerateArray())
var lyricsLine = new SpotifyLyricsLine
{
var lyricsLine = new SpotifyLyricsLine
{
StartTimeMs = line.TryGetProperty("startTimeMs", out var start)
? long.Parse(start.GetString() ?? "0") : 0,
Words = line.TryGetProperty("words", out var words)
? words.GetString() ?? "" : "",
EndTimeMs = line.TryGetProperty("endTimeMs", out var end)
? long.Parse(end.GetString() ?? "0") : 0
};
// Parse syllables if available (for word-level sync)
if (line.TryGetProperty("syllables", out var syllables))
{
foreach (var syllable in syllables.EnumerateArray())
{
lyricsLine.Syllables.Add(new SpotifyLyricsSyllable
{
StartTimeMs = syllable.TryGetProperty("startTimeMs", out var sStart)
? long.Parse(sStart.GetString() ?? "0") : 0,
Text = syllable.TryGetProperty("charsIndex", out var text)
? text.GetString() ?? "" : ""
});
}
}
result.Lines.Add(lyricsLine);
}
}
// Parse color information
if (lyrics.TryGetProperty("colors", out var colors))
{
result.Colors = new SpotifyLyricsColors
{
Background = colors.TryGetProperty("background", out var bg)
? ParseColorValue(bg) : null,
Text = colors.TryGetProperty("text", out var txt)
? ParseColorValue(txt) : null,
HighlightText = colors.TryGetProperty("highlightText", out var ht)
? ParseColorValue(ht) : null
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
};
}
// Language
if (lyrics.TryGetProperty("language", out var lang))
{
result.Language = lang.GetString();
}
// Provider info
if (lyrics.TryGetProperty("provider", out var provider))
{
result.Provider = provider.GetString();
}
// Display info
if (lyrics.TryGetProperty("providerDisplayName", out var providerDisplay))
{
result.ProviderDisplayName = providerDisplay.GetString();
result.Lines.Add(lyricsLine);
}
}
@@ -343,28 +207,11 @@ public class SpotifyLyricsService
}
catch (Exception ex)
{
_logger.LogError(ex, "Error parsing Spotify lyrics response");
_logger.LogError(ex, "Error parsing sidecar API response");
return null;
}
}
private static int? ParseColorValue(JsonElement element)
{
if (element.ValueKind == JsonValueKind.Number)
{
return element.GetInt32();
}
if (element.ValueKind == JsonValueKind.String)
{
var str = element.GetString();
if (!string.IsNullOrEmpty(str) && int.TryParse(str, out var val))
{
return val;
}
}
return null;
}
private static string ExtractTrackId(string input)
{
if (string.IsNullOrEmpty(input)) return input;

View File

@@ -77,7 +77,7 @@ public class MusicBrainzService
// 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>();
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));

View File

@@ -110,7 +110,10 @@ public class QobuzDownloadService : BaseDownloadService
// Build organized folder structure using AlbumArtist (fallback to Artist for singles)
var artistForPath = song.AlbumArtist ?? song.Artist;
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache ? CachePath : DownloadPath;
// 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 outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension);
var albumFolder = Path.GetDirectoryName(outputPath)!;

View File

@@ -349,6 +349,17 @@ public class SpotifyApiClient : IDisposable
var response = await _webApiClient.SendAsync(request, cancellationToken);
// Handle 429 rate limiting with exponential backoff
if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
{
var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(5);
_logger.LogWarning("Spotify rate limit hit (429) when fetching playlist {PlaylistId}. Waiting {Seconds}s before retry...", playlistId, retryAfter.TotalSeconds);
await Task.Delay(retryAfter, cancellationToken);
// Retry the request
response = await _webApiClient.SendAsync(request, cancellationToken);
}
if (!response.IsSuccessStatusCode)
{
_logger.LogError("Failed to fetch playlist via GraphQL: {StatusCode}", response.StatusCode);
@@ -735,6 +746,18 @@ public class SpotifyApiClient : IDisposable
public async Task<List<SpotifyPlaylist>> SearchUserPlaylistsAsync(
string searchName,
CancellationToken cancellationToken = default)
{
return await GetUserPlaylistsAsync(searchName, cancellationToken);
}
/// <summary>
/// Gets all playlists from the user's library, optionally filtered by name.
/// Uses GraphQL API which is less rate-limited than REST API.
/// </summary>
/// <param name="searchName">Optional name filter (case-insensitive). If null, returns all playlists.</param>
public async Task<List<SpotifyPlaylist>> GetUserPlaylistsAsync(
string? searchName = null,
CancellationToken cancellationToken = default)
{
var token = await GetWebAccessTokenAsync(cancellationToken);
if (string.IsNullOrEmpty(token))
@@ -744,61 +767,204 @@ public class SpotifyApiClient : IDisposable
try
{
// Use GraphQL endpoint instead of REST API to avoid rate limiting
// GraphQL is less aggressive with rate limits
var playlists = new List<SpotifyPlaylist>();
var offset = 0;
const int limit = 50;
while (true)
{
var url = $"{OfficialApiBase}/me/playlists?offset={offset}&limit={limit}";
// GraphQL query to fetch user playlists - using libraryV3 operation
var queryParams = new Dictionary<string, string>
{
{ "operationName", "libraryV3" },
{ "variables", $"{{\"filters\":[\"Playlists\",\"By Spotify\"],\"order\":null,\"textFilter\":\"\",\"features\":[\"LIKED_SONGS\",\"YOUR_EPISODES\"],\"offset\":{offset},\"limit\":{limit}}}" },
{ "extensions", "{\"persistedQuery\":{\"version\":1,\"sha256Hash\":\"50650f72ea32a99b5b46240bee22fea83024eec302478a9a75cfd05a0814ba99\"}}" }
};
var queryString = string.Join("&", queryParams.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"));
var url = $"{WebApiBase}/query?{queryString}";
var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await _httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode) break;
var response = await _webApiClient.SendAsync(request, cancellationToken);
// Handle 429 rate limiting with exponential backoff
if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
{
var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(5);
_logger.LogWarning("Spotify rate limit hit (429) when fetching library playlists. Waiting {Seconds}s before retry...", retryAfter.TotalSeconds);
await Task.Delay(retryAfter, cancellationToken);
// Retry the request
response = await _httpClient.SendAsync(request, cancellationToken);
}
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("GraphQL user playlists request failed: {StatusCode}", response.StatusCode);
break;
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (!root.TryGetProperty("items", out var items) || items.GetArrayLength() == 0)
if (!root.TryGetProperty("data", out var data) ||
!data.TryGetProperty("me", out var me) ||
!me.TryGetProperty("libraryV3", out var library) ||
!library.TryGetProperty("items", out var items))
{
break;
}
// Get total count
if (library.TryGetProperty("totalCount", out var totalCount))
{
var total = totalCount.GetInt32();
if (total == 0) break;
}
var itemCount = 0;
foreach (var item in items.EnumerateArray())
{
var itemName = item.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
itemCount++;
// Check if name matches (case-insensitive)
if (itemName.Contains(searchName, StringComparison.OrdinalIgnoreCase))
if (!item.TryGetProperty("item", out var playlistItem) ||
!playlistItem.TryGetProperty("data", out var playlist))
{
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
});
continue;
}
// Check __typename to filter out folders and only include playlists
if (playlistItem.TryGetProperty("__typename", out var typename))
{
var typeStr = typename.GetString();
// Skip folders - only process Playlist types
if (typeStr != null && typeStr.Contains("Folder", StringComparison.OrdinalIgnoreCase))
{
continue;
}
}
// Get playlist URI/ID
string? uri = null;
if (playlistItem.TryGetProperty("uri", out var uriProp))
{
uri = uriProp.GetString();
}
else if (playlistItem.TryGetProperty("_uri", out var uriProp2))
{
uri = uriProp2.GetString();
}
if (string.IsNullOrEmpty(uri)) continue;
// Skip if not a playlist URI (e.g., folders have different URI format)
if (!uri.StartsWith("spotify:playlist:", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var spotifyId = uri.Replace("spotify:playlist:", "", StringComparison.OrdinalIgnoreCase);
var itemName = playlist.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
// Check if name matches (case-insensitive) - if searchName is provided
if (!string.IsNullOrEmpty(searchName) &&
!itemName.Contains(searchName, StringComparison.OrdinalIgnoreCase))
{
continue;
}
// Get track count if available - try multiple possible paths
var trackCount = 0;
if (playlist.TryGetProperty("content", out var content))
{
if (content.TryGetProperty("totalCount", out var totalTrackCount))
{
trackCount = totalTrackCount.GetInt32();
}
}
// Fallback: try attributes.itemCount
else if (playlist.TryGetProperty("attributes", out var attributes) &&
attributes.TryGetProperty("itemCount", out var itemCountProp))
{
trackCount = itemCountProp.GetInt32();
}
// Fallback: try totalCount directly
else if (playlist.TryGetProperty("totalCount", out var directTotalCount))
{
trackCount = directTotalCount.GetInt32();
}
// Log if we couldn't find track count for debugging
if (trackCount == 0)
{
_logger.LogDebug("Could not find track count for playlist {Name} (ID: {Id}). Response structure: {Json}",
itemName, spotifyId, playlist.GetRawText());
}
// Get owner name
string? ownerName = null;
if (playlist.TryGetProperty("ownerV2", out var ownerV2) &&
ownerV2.TryGetProperty("data", out var ownerData) &&
ownerData.TryGetProperty("username", out var ownerNameProp))
{
ownerName = ownerNameProp.GetString();
}
// Get image URL
string? imageUrl = null;
if (playlist.TryGetProperty("images", out var images) &&
images.TryGetProperty("items", out var imageItems) &&
imageItems.GetArrayLength() > 0)
{
var firstImage = imageItems[0];
if (firstImage.TryGetProperty("sources", out var sources) &&
sources.GetArrayLength() > 0)
{
var firstSource = sources[0];
if (firstSource.TryGetProperty("url", out var urlProp))
{
imageUrl = urlProp.GetString();
}
}
}
playlists.Add(new SpotifyPlaylist
{
SpotifyId = spotifyId,
Name = itemName,
Description = playlist.TryGetProperty("description", out var desc) ? desc.GetString() : null,
TotalTracks = trackCount,
OwnerName = ownerName,
ImageUrl = imageUrl,
SnapshotId = null
});
}
if (items.GetArrayLength() < limit) break;
if (itemCount < limit) break;
offset += limit;
if (_settings.RateLimitDelayMs > 0)
{
await Task.Delay(_settings.RateLimitDelayMs, cancellationToken);
}
// Add delay between pages to avoid rate limiting
// Library fetching can be aggressive, so use a longer delay
var delayMs = Math.Max(_settings.RateLimitDelayMs, 500); // Minimum 500ms between pages
_logger.LogDebug("Waiting {DelayMs}ms before fetching next page of library playlists...", delayMs);
await Task.Delay(delayMs, cancellationToken);
}
_logger.LogInformation("Found {Count} playlists{Filter} via GraphQL",
playlists.Count,
string.IsNullOrEmpty(searchName) ? "" : $" matching '{searchName}'");
return playlists;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error searching user playlists for '{SearchName}'", searchName);
_logger.LogError(ex, "Error fetching user playlists{Filter} via GraphQL",
string.IsNullOrEmpty(searchName) ? "" : $" matching '{searchName}'");
return new List<SpotifyPlaylist>();
}
}

View File

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

View File

@@ -3,6 +3,7 @@ using allstarr.Models.Spotify;
using allstarr.Services.Common;
using Microsoft.Extensions.Options;
using System.Text.Json;
using Cronos;
namespace allstarr.Services.Spotify;
@@ -14,6 +15,9 @@ namespace allstarr.Services.Spotify;
/// - ISRC codes available for exact matching
/// - Real-time data without waiting for plugin sync schedules
/// - Full track metadata (duration, release date, etc.)
///
/// CRON SCHEDULING: Playlists are fetched based on their cron schedules, not a global interval.
/// Cache persists until next cron run to prevent excess Spotify API calls.
/// </summary>
public class SpotifyPlaylistFetcher : BackgroundService
{
@@ -45,6 +49,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
/// <summary>
/// Gets the Spotify playlist tracks in order, using cache if available.
/// Cache persists until next cron run to prevent excess API calls.
/// </summary>
/// <param name="playlistName">Playlist name (e.g., "Release Radar", "Discover Weekly")</param>
/// <returns>List of tracks in playlist order, or empty list if not found</returns>
@@ -57,7 +62,38 @@ public class SpotifyPlaylistFetcher : BackgroundService
if (cached != null && cached.Tracks.Count > 0)
{
var age = DateTime.UtcNow - cached.FetchedAt;
if (age.TotalMinutes < _spotifyApiSettings.CacheDurationMinutes)
// Calculate if cache should still be valid based on cron schedule
var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName);
var shouldRefresh = false;
if (playlistConfig != null && !string.IsNullOrEmpty(playlistConfig.SyncSchedule))
{
try
{
var cron = CronExpression.Parse(playlistConfig.SyncSchedule);
var nextRun = cron.GetNextOccurrence(cached.FetchedAt, TimeZoneInfo.Utc);
if (nextRun.HasValue && DateTime.UtcNow >= nextRun.Value)
{
shouldRefresh = true;
_logger.LogInformation("Cache expired for '{Name}' - next cron run was at {NextRun} UTC",
playlistName, nextRun.Value);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not parse cron schedule for '{Name}', falling back to cache duration", playlistName);
shouldRefresh = age.TotalMinutes >= _spotifyApiSettings.CacheDurationMinutes;
}
}
else
{
// No cron schedule, use cache duration from settings
shouldRefresh = age.TotalMinutes >= _spotifyApiSettings.CacheDurationMinutes;
}
if (!shouldRefresh)
{
_logger.LogDebug("Using cached playlist '{Name}' ({Count} tracks, age: {Age:F1}m)",
playlistName, cached.Tracks.Count, age.TotalMinutes);
@@ -94,11 +130,11 @@ public class SpotifyPlaylistFetcher : BackgroundService
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))
var config = _spotifyImportSettings.GetPlaylistByName(playlistName);
if (config != null && !string.IsNullOrEmpty(config.Id))
{
// Use the configured Spotify playlist ID directly
spotifyId = playlistConfig.Id;
spotifyId = config.Id;
_playlistNameToSpotifyId[playlistName] = spotifyId;
_logger.LogInformation("Using configured Spotify playlist ID for '{Name}': {Id}", playlistName, spotifyId);
}
@@ -144,12 +180,39 @@ public class SpotifyPlaylistFetcher : BackgroundService
return cached?.Tracks ?? new List<SpotifyPlaylistTrack>();
}
// Update cache
await _cache.SetAsync(cacheKey, playlist, TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes * 2));
// Calculate cache expiration based on cron schedule
var playlistCfg = _spotifyImportSettings.GetPlaylistByName(playlistName);
var cacheExpiration = TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes * 2); // Default
if (playlistCfg != null && !string.IsNullOrEmpty(playlistCfg.SyncSchedule))
{
try
{
var cron = CronExpression.Parse(playlistCfg.SyncSchedule);
var nextRun = cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc);
if (nextRun.HasValue)
{
var timeUntilNextRun = nextRun.Value - DateTime.UtcNow;
// Add 5 minutes buffer
cacheExpiration = timeUntilNextRun + TimeSpan.FromMinutes(5);
_logger.LogInformation("Playlist '{Name}' cache will persist until next cron run: {NextRun} UTC (in {Hours:F1}h)",
playlistName, nextRun.Value, timeUntilNextRun.TotalHours);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not calculate next cron run for '{Name}', using default cache duration", playlistName);
}
}
// Update cache with cron-based expiration
await _cache.SetAsync(cacheKey, playlist, cacheExpiration);
await SaveToFileCacheAsync(playlistName, playlist);
_logger.LogInformation("Fetched and cached playlist '{Name}' with {Count} tracks in order",
playlistName, playlist.Tracks.Count);
_logger.LogInformation("Fetched and cached playlist '{Name}' with {Count} tracks (expires in {Hours:F1}h)",
playlistName, playlist.Tracks.Count, cacheExpiration.TotalHours);
return playlist.Tracks;
}
@@ -235,32 +298,102 @@ public class SpotifyPlaylistFetcher : BackgroundService
_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);
var schedule = string.IsNullOrEmpty(playlist.SyncSchedule) ? "0 8 * * 1" : playlist.SyncSchedule;
_logger.LogInformation(" - {Name}: {Schedule}", playlist.Name, schedule);
}
_logger.LogInformation("========================================");
// Initial fetch of all playlists
// Initial fetch of all playlists on startup
await FetchAllPlaylistsAsync(stoppingToken);
// Periodic refresh loop
// Cron-based refresh loop - only fetch when cron schedule triggers
// This prevents excess Spotify API calls
while (!stoppingToken.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes), stoppingToken);
try
{
await FetchAllPlaylistsAsync(stoppingToken);
// Check each playlist to see if it needs refreshing based on cron schedule
var now = DateTime.UtcNow;
var needsRefresh = new List<string>();
foreach (var config in _spotifyImportSettings.Playlists)
{
var schedule = string.IsNullOrEmpty(config.SyncSchedule) ? "0 8 * * 1" : config.SyncSchedule;
try
{
var cron = CronExpression.Parse(schedule);
// Check if we have cached data
var cacheKey = $"{CacheKeyPrefix}{config.Name}";
var cached = await _cache.GetAsync<SpotifyPlaylist>(cacheKey);
if (cached != null)
{
// Calculate when the next run should be after the last fetch
var nextRun = cron.GetNextOccurrence(cached.FetchedAt, TimeZoneInfo.Utc);
if (nextRun.HasValue && now >= nextRun.Value)
{
needsRefresh.Add(config.Name);
_logger.LogInformation("Playlist '{Name}' needs refresh - last fetched {Age:F1}h ago, next run was {NextRun}",
config.Name, (now - cached.FetchedAt).TotalHours, nextRun.Value);
}
}
else
{
// No cache, fetch it
needsRefresh.Add(config.Name);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Invalid cron schedule for playlist {Name}: {Schedule}", config.Name, schedule);
}
}
// Fetch playlists that need refreshing
if (needsRefresh.Count > 0)
{
_logger.LogInformation("=== CRON TRIGGER: Fetching {Count} playlists ===", needsRefresh.Count);
foreach (var playlistName in needsRefresh)
{
if (stoppingToken.IsCancellationRequested) break;
try
{
await GetPlaylistTracksAsync(playlistName);
// Rate limiting between playlists
if (playlistName != needsRefresh.Last())
{
_logger.LogDebug("Waiting 3 seconds before next playlist to avoid rate limits...");
await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching playlist '{Name}'", playlistName);
}
}
_logger.LogInformation("=== FINISHED FETCHING PLAYLISTS ===");
}
// Sleep for 1 hour before checking again
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during periodic playlist refresh");
_logger.LogError(ex, "Error in playlist fetcher loop");
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
}
}
}

View File

@@ -6,6 +6,7 @@ using allstarr.Services.Jellyfin;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using System.Text.Json;
using Cronos;
namespace allstarr.Services.Spotify;
@@ -17,6 +18,9 @@ namespace allstarr.Services.Spotify;
/// 2. Direct API mode: Uses SpotifyPlaylistTrack (with ISRC and ordering)
///
/// When ISRC is available, exact matching is preferred. Falls back to fuzzy matching.
///
/// CRON SCHEDULING: Each playlist has its own cron schedule. Matching only runs when the schedule triggers.
/// Manual refresh is always allowed. Cache persists until next cron run.
/// </summary>
public class SpotifyTrackMatchingService : BackgroundService
{
@@ -28,6 +32,10 @@ public class SpotifyTrackMatchingService : BackgroundService
private const int DelayBetweenSearchesMs = 150; // 150ms = ~6.6 searches/second to avoid rate limiting
private const int BatchSize = 11; // Number of parallel searches (matches SquidWTF provider count)
// Track last run time per playlist to prevent duplicate runs
private readonly Dictionary<string, DateTime> _lastRunTimes = new();
private readonly TimeSpan _minimumRunInterval = TimeSpan.FromMinutes(5); // Cooldown between runs
public SpotifyTrackMatchingService(
IOptions<SpotifyImportSettings> spotifySettings,
IOptions<SpotifyApiSettings> spotifyApiSettings,
@@ -42,19 +50,42 @@ public class SpotifyTrackMatchingService : BackgroundService
_logger = logger;
}
/// <summary>
/// Helper method to safely check if a dynamic cache result has a value
/// Handles the case where JsonElement cannot be compared to null directly
/// </summary>
private static bool HasValue(object? obj)
{
if (obj == null) return false;
if (obj is JsonElement jsonEl) return jsonEl.ValueKind != JsonValueKind.Null && jsonEl.ValueKind != JsonValueKind.Undefined;
return true;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("========================================");
_logger.LogInformation("SpotifyTrackMatchingService: Starting up...");
if (!_spotifySettings.Enabled)
{
_logger.LogInformation("Spotify playlist injection is DISABLED, matching service will not run");
_logger.LogInformation("========================================");
return;
}
var matchMode = _spotifyApiSettings.Enabled && _spotifyApiSettings.PreferIsrcMatching
? "ISRC-preferred" : "fuzzy";
_logger.LogInformation("Matching mode: {Mode}", matchMode);
_logger.LogInformation("Cron-based scheduling: Each playlist has independent schedule");
// Log all playlist schedules
foreach (var playlist in _spotifySettings.Playlists)
{
var schedule = string.IsNullOrEmpty(playlist.SyncSchedule) ? "0 8 * * 1" : playlist.SyncSchedule;
_logger.LogInformation(" - {Name}: {Schedule}", playlist.Name, schedule);
}
_logger.LogInformation("========================================");
// Wait a bit for the fetcher to run first
await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
@@ -62,7 +93,7 @@ public class SpotifyTrackMatchingService : BackgroundService
// Run once on startup to match any existing missing tracks
try
{
_logger.LogInformation("Running initial track matching on startup");
_logger.LogInformation("Running initial track matching on startup (one-time)");
await MatchAllPlaylistsAsync(stoppingToken);
}
catch (Exception ex)
@@ -70,46 +101,100 @@ public class SpotifyTrackMatchingService : BackgroundService
_logger.LogError(ex, "Error during startup track matching");
}
// Now start the periodic matching loop
// Now start the cron-based scheduling 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);
// Calculate next run time for each playlist
var now = DateTime.UtcNow;
var nextRuns = new List<(string PlaylistName, DateTime NextRun, CronExpression Cron)>();
foreach (var playlist in _spotifySettings.Playlists)
{
var schedule = string.IsNullOrEmpty(playlist.SyncSchedule) ? "0 8 * * 1" : playlist.SyncSchedule;
try
{
var cron = CronExpression.Parse(schedule);
var nextRun = cron.GetNextOccurrence(now, TimeZoneInfo.Utc);
if (nextRun.HasValue)
{
nextRuns.Add((playlist.Name, nextRun.Value, cron));
}
else
{
_logger.LogWarning("Could not calculate next run for playlist {Name} with schedule {Schedule}",
playlist.Name, schedule);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Invalid cron schedule for playlist {Name}: {Schedule}",
playlist.Name, schedule);
}
}
if (nextRuns.Count == 0)
{
_logger.LogWarning("No valid cron schedules found, sleeping for 1 hour");
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
continue;
}
// Find the next playlist that needs to run
var nextPlaylist = nextRuns.OrderBy(x => x.NextRun).First();
var waitTime = nextPlaylist.NextRun - now;
if (waitTime.TotalSeconds > 0)
{
_logger.LogInformation("Next scheduled run: {Playlist} at {Time} UTC (in {Minutes:F1} minutes)",
nextPlaylist.PlaylistName, nextPlaylist.NextRun, waitTime.TotalMinutes);
// Wait until next run (or max 1 hour to re-check schedules)
var maxWait = TimeSpan.FromHours(1);
var actualWait = waitTime > maxWait ? maxWait : waitTime;
await Task.Delay(actualWait, stoppingToken);
continue;
}
// Time to run this playlist
_logger.LogInformation("=== CRON TRIGGER: Running scheduled match for {Playlist} ===", nextPlaylist.PlaylistName);
// Check cooldown to prevent duplicate runs
if (_lastRunTimes.TryGetValue(nextPlaylist.PlaylistName, out var lastRun))
{
var timeSinceLastRun = now - lastRun;
if (timeSinceLastRun < _minimumRunInterval)
{
_logger.LogInformation("Skipping {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)",
nextPlaylist.PlaylistName, (int)timeSinceLastRun.TotalSeconds, (int)_minimumRunInterval.TotalSeconds);
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
continue;
}
}
// Run matching for this playlist
await MatchSinglePlaylistAsync(nextPlaylist.PlaylistName, stoppingToken);
_lastRunTimes[nextPlaylist.PlaylistName] = DateTime.UtcNow;
_logger.LogInformation("=== FINISHED: {Playlist} - Next run at {NextRun} UTC ===",
nextPlaylist.PlaylistName, nextPlaylist.Cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in track matching service");
_logger.LogError(ex, "Error in cron scheduling loop");
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
}
}
}
/// <summary>
/// Public method to trigger matching manually for all playlists (called from controller).
/// Matches tracks for a single playlist (called by cron scheduler or manual trigger).
/// </summary>
public async Task TriggerMatchingAsync()
private async Task MatchSinglePlaylistAsync(string playlistName, CancellationToken cancellationToken)
{
_logger.LogInformation("Manual track matching triggered for all playlists");
await MatchAllPlaylistsAsync(CancellationToken.None);
}
/// <summary>
/// Public method to trigger matching for a specific playlist (called from controller).
/// </summary>
public async Task TriggerMatchingForPlaylistAsync(string playlistName)
{
_logger.LogInformation("Manual track matching triggered for playlist: {Playlist}", playlistName);
var playlist = _spotifySettings.Playlists
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
@@ -135,13 +220,13 @@ public class SpotifyTrackMatchingService : BackgroundService
{
// Use new direct API mode with ISRC support
await MatchPlaylistTracksWithIsrcAsync(
playlist.Name, playlistFetcher, metadataService, CancellationToken.None);
playlist.Name, playlistFetcher, metadataService, cancellationToken);
}
else
{
// Fall back to legacy mode
await MatchPlaylistTracksLegacyAsync(
playlist.Name, metadataService, CancellationToken.None);
playlist.Name, metadataService, cancellationToken);
}
}
catch (Exception ex)
@@ -151,9 +236,43 @@ public class SpotifyTrackMatchingService : BackgroundService
}
}
/// <summary>
/// Public method to trigger matching manually for all playlists (called from controller).
/// This bypasses cron schedules and runs immediately.
/// </summary>
public async Task TriggerMatchingAsync()
{
_logger.LogInformation("Manual track matching triggered for all playlists (bypassing cron schedules)");
await MatchAllPlaylistsAsync(CancellationToken.None);
}
/// <summary>
/// Public method to trigger matching for a specific playlist (called from controller).
/// This bypasses cron schedules and runs immediately.
/// </summary>
public async Task TriggerMatchingForPlaylistAsync(string playlistName)
{
_logger.LogInformation("Manual track matching triggered for playlist: {Playlist} (bypassing cron schedule)", playlistName);
// Check cooldown to prevent abuse
if (_lastRunTimes.TryGetValue(playlistName, out var lastRun))
{
var timeSinceLastRun = DateTime.UtcNow - lastRun;
if (timeSinceLastRun < _minimumRunInterval)
{
_logger.LogWarning("Skipping manual refresh for {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)",
playlistName, (int)timeSinceLastRun.TotalSeconds, (int)_minimumRunInterval.TotalSeconds);
throw new InvalidOperationException($"Please wait {(int)(_minimumRunInterval - timeSinceLastRun).TotalSeconds} more seconds before refreshing again");
}
}
await MatchSinglePlaylistAsync(playlistName, CancellationToken.None);
_lastRunTimes[playlistName] = DateTime.UtcNow;
}
private async Task MatchAllPlaylistsAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("=== STARTING TRACK MATCHING ===");
_logger.LogInformation("=== STARTING TRACK MATCHING FOR ALL PLAYLISTS ===");
var playlists = _spotifySettings.Playlists;
if (playlists.Count == 0)
@@ -162,34 +281,13 @@ public class SpotifyTrackMatchingService : BackgroundService
return;
}
using var scope = _serviceProvider.CreateScope();
var metadataService = scope.ServiceProvider.GetRequiredService<IMusicMetadataService>();
// Check if we should use the new SpotifyPlaylistFetcher
SpotifyPlaylistFetcher? playlistFetcher = null;
if (_spotifyApiSettings.Enabled)
{
playlistFetcher = scope.ServiceProvider.GetService<SpotifyPlaylistFetcher>();
}
foreach (var playlist in playlists)
{
if (cancellationToken.IsCancellationRequested) break;
try
{
if (playlistFetcher != null)
{
// Use new direct API mode with ISRC support
await MatchPlaylistTracksWithIsrcAsync(
playlist.Name, playlistFetcher, metadataService, cancellationToken);
}
else
{
// Fall back to legacy mode
await MatchPlaylistTracksLegacyAsync(
playlist.Name, metadataService, cancellationToken);
}
await MatchSinglePlaylistAsync(playlist.Name, cancellationToken);
}
catch (Exception ex)
{
@@ -197,13 +295,14 @@ public class SpotifyTrackMatchingService : BackgroundService
}
}
_logger.LogInformation("=== FINISHED TRACK MATCHING ===");
_logger.LogInformation("=== FINISHED TRACK MATCHING FOR ALL PLAYLISTS ===");
}
/// <summary>
/// New matching mode that uses ISRC when available for exact matches.
/// Preserves track position for correct playlist ordering.
/// Only matches tracks that aren't already in the Jellyfin playlist.
/// Uses GREEDY ASSIGNMENT to maximize total matches.
/// </summary>
private async Task MatchPlaylistTracksWithIsrcAsync(
string playlistName,
@@ -241,19 +340,19 @@ public class SpotifyTrackMatchingService : BackgroundService
// 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))
{
playlistItemsUrl += $"?UserId={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.GetJsonAsync(
var (existingTracksResponse, _) = await proxyService.GetJsonAsyncInternal(
playlistItemsUrl,
null,
null);
queryParams);
if (existingTracksResponse != null &&
existingTracksResponse.RootElement.TryGetProperty("Items", out var items))
@@ -297,36 +396,46 @@ public class SpotifyTrackMatchingService : BackgroundService
return;
}
_logger.LogInformation("Matching {ToMatch}/{Total} tracks for {Playlist} (skipping {Existing} already in Jellyfin, ISRC: {IsrcEnabled})",
_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);
// Check if we have manual mappings that need to be preserved
var hasManualMappings = false;
foreach (var track in tracksToMatch)
// 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)
{
var manualMappingKey = $"spotify:manual-map:{playlistName}:{track.SpotifyId}";
var manualMapping = await _cache.GetAsync<string>(manualMappingKey);
if (!string.IsNullOrEmpty(manualMapping))
// Check if we have NEW manual mappings that aren't in the cache
var hasNewManualMappings = false;
foreach (var track in tracksToMatch)
{
hasManualMappings = true;
break;
// 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;
}
}
}
// Skip if cache exists AND no manual mappings need to be applied
if (existingMatched != null && existingMatched.Count >= tracksToMatch.Count && !hasManualMappings)
{
_logger.LogInformation("Playlist {Playlist} already has {Count} matched tracks cached, skipping",
playlistName, existingMatched.Count);
return;
}
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;
}
if (hasManualMappings)
{
_logger.LogInformation("Manual mappings detected for {Playlist}, rebuilding cache to apply them", playlistName);
_logger.LogInformation("New manual mappings detected for {Playlist}, rebuilding cache to apply them", playlistName);
}
var matchedTracks = new List<MatchedTrack>();
@@ -334,6 +443,9 @@ public class SpotifyTrackMatchingService : BackgroundService
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)
@@ -349,109 +461,164 @@ public class SpotifyTrackMatchingService : BackgroundService
{
try
{
Song? matchedSong = null;
var matchType = "none";
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))
{
matchedSong = await TryMatchByIsrcAsync(spotifyTrack.Isrc, metadataService);
if (matchedSong != null)
var isrcSong = await TryMatchByIsrcAsync(spotifyTrack.Isrc, metadataService);
if (isrcSong != null)
{
matchType = "isrc";
candidates.Add((isrcSong, 100.0, "isrc"));
}
}
// Fall back to fuzzy matching
if (matchedSong == null)
{
matchedSong = await TryMatchByFuzzyAsync(
spotifyTrack.Title,
spotifyTrack.Artists,
metadataService);
// Always try fuzzy matching to get more candidates
var fuzzySongs = await TryMatchByFuzzyMultipleAsync(
spotifyTrack.Title,
spotifyTrack.Artists,
metadataService);
if (matchedSong != null)
{
matchType = "fuzzy";
}
foreach (var (song, score) in fuzzySongs)
{
candidates.Add((song, score, "fuzzy"));
}
if (matchedSong != null)
{
var matched = new MatchedTrack
{
Position = spotifyTrack.Position,
SpotifyId = spotifyTrack.SpotifyId,
SpotifyTitle = spotifyTrack.Title,
SpotifyArtist = spotifyTrack.PrimaryArtist,
Isrc = spotifyTrack.Isrc,
MatchType = matchType,
MatchedSong = matchedSong
};
_logger.LogDebug(" #{Position} {Title} - {Artist} → {MatchType} match: {MatchedTitle}",
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist,
matchType, matchedSong.Title);
return ((MatchedTrack?)matched, matchType);
}
else
{
_logger.LogDebug(" #{Position} {Title} - {Artist} → no match",
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist);
return ((MatchedTrack?)null, "none");
}
return (spotifyTrack, candidates);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
spotifyTrack.Title, spotifyTrack.PrimaryArtist);
return ((MatchedTrack?)null, "none");
return (spotifyTrack, new List<(Song, double, string)>());
}
}).ToList();
// Wait for all tracks in this batch to complete
var batchResults = await Task.WhenAll(batchTasks);
// Collect results
foreach (var result in batchResults)
// Collect all candidates
foreach (var (spotifyTrack, candidates) in batchResults)
{
var (matched, matchType) = result;
if (matched != null)
foreach (var (song, score, matchType) in candidates)
{
matchedTracks.Add(matched);
if (matchType == "isrc") isrcMatches++;
else if (matchType == "fuzzy") fuzzyMatches++;
}
else
{
noMatch++;
allCandidates.Add((spotifyTrack, song, score, matchType));
}
}
// Rate limiting between batches (not between individual tracks)
// 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));
// Calculate cache expiration: until next cron run (not just cache duration from settings)
var playlist = _spotifySettings.Playlists
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
var cacheExpiration = TimeSpan.FromHours(24); // Default 24 hours
if (playlist != null && !string.IsNullOrEmpty(playlist.SyncSchedule))
{
try
{
var cron = CronExpression.Parse(playlist.SyncSchedule);
var nextRun = cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc);
if (nextRun.HasValue)
{
var timeUntilNextRun = nextRun.Value - DateTime.UtcNow;
// Add 5 minutes buffer to ensure cache doesn't expire before next run
cacheExpiration = timeUntilNextRun + TimeSpan.FromMinutes(5);
_logger.LogInformation("Cache will persist until next cron run: {NextRun} UTC (in {Hours:F1} hours)",
nextRun.Value, timeUntilNextRun.TotalHours);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not calculate next cron run for {Playlist}, using default cache duration", playlistName);
}
}
// Cache matched tracks with position data until next cron run
await _cache.SetAsync(matchedTracksKey, matchedTracks, cacheExpiration);
// Save matched tracks to file for persistence across restarts
await SaveMatchedTracksToFileAsync(playlistName, matchedTracks);
// Also update legacy cache for backward compatibility
var legacyKey = $"spotify:matched:{playlistName}";
var legacySongs = matchedTracks.OrderBy(t => t.Position).Select(t => t.MatchedSong).ToList();
await _cache.SetAsync(legacyKey, legacySongs, TimeSpan.FromHours(1));
await _cache.SetAsync(legacyKey, legacySongs, cacheExpiration);
_logger.LogInformation(
"✓ Cached {Matched}/{Total} tracks for {Playlist} (ISRC: {Isrc}, Fuzzy: {Fuzzy}, No match: {NoMatch})",
matchedTracks.Count, tracksToMatch.Count, playlistName, isrcMatches, fuzzyMatches, noMatch);
"✓ Cached {Matched}/{Total} tracks for {Playlist} via GREEDY ASSIGNMENT (ISRC: {Isrc}, Fuzzy: {Fuzzy}, No match: {NoMatch}) - cache expires in {Hours:F1}h",
matchedTracks.Count, tracksToMatch.Count, playlistName, isrcMatches, fuzzyMatches, noMatch, cacheExpiration.TotalHours);
// Pre-build playlist items cache for instant serving
await PreBuildPlaylistItemsCacheAsync(playlistName, playlistConfig?.JellyfinId, spotifyTracks, matchedTracks, cancellationToken);
// This is what makes the UI show all matched tracks at once
await PreBuildPlaylistItemsCacheAsync(playlistName, playlistConfig?.JellyfinId, spotifyTracks, matchedTracks, cacheExpiration, cancellationToken);
}
else
{
@@ -459,6 +626,64 @@ public class SpotifyTrackMatchingService : BackgroundService
}
}
/// <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>
@@ -488,7 +713,12 @@ public class SpotifyTrackMatchingService : BackgroundService
}
/// <summary>
/// Attempts to match a track by title and artist using fuzzy matching.
/// 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,
@@ -498,30 +728,62 @@ public class SpotifyTrackMatchingService : BackgroundService
try
{
var primaryArtist = artists.FirstOrDefault() ?? "";
var query = $"{title} {primaryArtist}";
var results = await metadataService.SearchSongsAsync(query, limit: 5);
// 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;
var bestMatch = results
// STEP 2-3: Score all results (substring + Levenshtein in CalculateSimilarityAggressive)
var scoredResults = results
.Select(song => new
{
Song = song,
TitleScore = FuzzyMatcher.CalculateSimilarity(title, song.Title),
ArtistScore = CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
// 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,
TotalScore = (x.TitleScore * 0.6) + (x.ArtistScore * 0.4)
// Weight: 70% title, 30% artist (prioritize title matching)
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
})
.OrderByDescending(x => x.TotalScore)
.FirstOrDefault();
.ToList();
if (bestMatch != null && bestMatch.TotalScore >= 60)
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;
}
@@ -586,7 +848,7 @@ public class SpotifyTrackMatchingService : BackgroundService
Song = song,
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, song.Title),
// Calculate artist score by checking ALL artists match
ArtistScore = CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors)
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors)
})
.Select(x => new
{
@@ -691,6 +953,7 @@ public class SpotifyTrackMatchingService : BackgroundService
string? jellyfinPlaylistId,
List<SpotifyPlaylistTrack> spotifyTracks,
List<MatchedTrack> matchedTracks,
TimeSpan cacheExpiration,
CancellationToken cancellationToken)
{
try
@@ -769,71 +1032,210 @@ public class SpotifyTrackMatchingService : BackgroundService
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;
// FIRST: Check for manual mapping
var manualMappingKey = $"spotify:manual-map:{playlistName}:{spotifyTrack.SpotifyId}";
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
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))
{
// Manual mapping exists - fetch the Jellyfin item by ID
// 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
{
var itemUrl = $"Items/{manualJellyfinId}?UserId={userId}";
var (itemResponse, itemStatusCode) = await proxyService.GetJsonAsync(itemUrl, null, headers);
using var doc = JsonDocument.Parse(externalMappingJson);
var root = doc.RootElement;
if (itemStatusCode == 200 && itemResponse != null)
string? provider = null;
string? externalId = null;
if (root.TryGetProperty("provider", out var providerEl))
{
matchedJellyfinItem = itemResponse.RootElement;
_logger.LogDebug("✓ Using manual mapping for {Title}: Jellyfin ID {Id}",
spotifyTrack.Title, manualJellyfinId);
provider = providerEl.GetString();
}
else
if (root.TryGetProperty("id", out var idEl))
{
_logger.LogWarning("Manual mapping points to invalid Jellyfin ID {Id} for {Title}",
manualJellyfinId, spotifyTrack.Title);
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 fetch manually mapped Jellyfin item {Id}", manualJellyfinId);
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", spotifyTrack.Title);
}
}
// SECOND: If no manual mapping, try fuzzy matching
if (!matchedJellyfinItem.HasValue)
// If no manual external mapping, try AGGRESSIVE fuzzy matching with local Jellyfin tracks
double bestScore = 0;
foreach (var kvp in jellyfinItemsByName)
{
double bestScore = 0;
if (usedJellyfinItems.Contains(kvp.Key)) continue;
foreach (var kvp in jellyfinItemsByName)
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)
{
if (usedJellyfinItems.Contains(kvp.Key)) continue;
artist = artistsEl[0].GetString() ?? "";
}
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);
var titleScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.Title, title);
var artistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, artist);
var totalScore = (titleScore * 0.7) + (artistScore * 0.3);
// Weight: 70% title, 30% artist (prioritize title matching)
var totalScore = (titleScore * 0.7) + (artistScore * 0.3);
if (totalScore > bestScore && totalScore >= 70)
{
bestScore = totalScore;
matchedJellyfinItem = item;
matchedKey = kvp.Key;
}
// 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;
}
}
@@ -843,6 +1245,22 @@ public class SpotifyTrackMatchingService : BackgroundService
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)
{
@@ -859,6 +1277,22 @@ public class SpotifyTrackMatchingService : BackgroundService
{
// 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++;
}
@@ -867,16 +1301,22 @@ public class SpotifyTrackMatchingService : BackgroundService
if (finalItems.Count > 0)
{
// Save to Redis cache
// Save to Redis cache with same expiration as matched tracks (until next cron run)
var cacheKey = $"spotify:playlist:items:{playlistName}";
await _cache.SetAsync(cacheKey, finalItems, TimeSpan.FromHours(24));
await _cache.SetAsync(cacheKey, finalItems, cacheExpiration);
// Save to file cache for persistence
await SavePlaylistItemsToFileAsync(playlistName, finalItems);
var manualMappingInfo = "";
if (manualExternalCount > 0)
{
manualMappingInfo = $" [Manual external: {manualExternalCount}]";
}
_logger.LogInformation(
"✅ Pre-built playlist cache for {Playlist}: {Total} tracks ({Local} LOCAL + {External} EXTERNAL)",
playlistName, finalItems.Count, localUsedCount, externalUsedCount);
"✅ Pre-built playlist cache for {Playlist}: {Total} tracks ({Local} LOCAL + {External} EXTERNAL){ManualInfo} - expires in {Hours:F1}h",
playlistName, finalItems.Count, localUsedCount, externalUsedCount, manualMappingInfo, cacheExpiration.TotalHours);
}
else
{
@@ -912,5 +1352,29 @@ public class SpotifyTrackMatchingService : BackgroundService
_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,6 +7,7 @@ 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;
@@ -14,21 +15,48 @@ 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
/// 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
/// </summary>
public class SquidWTFDownloadService : BaseDownloadService
{
private readonly HttpClient _httpClient;
private readonly SemaphoreSlim _requestLock = new(1, 1);
private readonly SquidWTFSettings _squidwtfSettings;
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();
private readonly OdesliService _odesliService;
private readonly RoundRobinFallbackHelper _fallbackHelper;
private readonly IServiceProvider _serviceProvider;
protected override string ProviderName => "squidwtf";
@@ -41,59 +69,26 @@ 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;
_apiUrls = apiUrls;
_odesliService = odesliService;
_fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF");
_serviceProvider = serviceProvider;
// Increase timeout for large downloads and slow endpoints
_httpClient.Timeout = TimeSpan.FromMinutes(5);
}
/// <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 TryWithFallbackAsync(async (baseUrl) =>
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
var response = await _httpClient.GetAsync(baseUrl);
Console.WriteLine($"Response code from is available async: {response.IsSuccessStatusCode}");
@@ -116,8 +111,8 @@ public class SquidWTFDownloadService : BaseDownloadService
{
var downloadInfo = await GetTrackDownloadInfoAsync(trackId, cancellationToken);
Logger.LogInformation("Track token obtained: {Url}", downloadInfo.DownloadUrl);
Logger.LogInformation("Using format: {Format}", downloadInfo.MimeType);
Logger.LogInformation("Track download URL obtained from hifi-api: {Url}", downloadInfo.DownloadUrl);
Logger.LogInformation("Using format: {Format} (Quality: {Quality})", downloadInfo.MimeType, downloadInfo.AudioQuality);
// Determine extension from MIME type
var extension = downloadInfo.MimeType?.ToLower() switch
@@ -130,7 +125,10 @@ public class SquidWTFDownloadService : BaseDownloadService
// Build organized folder structure: Artist/Album/Track using AlbumArtist (fallback to Artist for singles)
var artistForPath = song.AlbumArtist ?? song.Artist;
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache ? CachePath : DownloadPath;
// 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);
// Create directories if they don't exist
@@ -140,10 +138,53 @@ public class SquidWTFDownloadService : BaseDownloadService
// Resolve unique path if file already exists
outputPath = PathHelper.ResolveUniquePath(outputPath);
// Download from Tidal CDN (no authentication needed, token is in URL)
var response = await QueueRequestAsync(async () =>
// 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) =>
{
using var request = new HttpRequestMessage(HttpMethod.Get, downloadInfo.DownloadUrl);
// 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);
request.Headers.Add("User-Agent", "Mozilla/5.0");
request.Headers.Add("Accept", "*/*");
@@ -161,7 +202,26 @@ public class SquidWTFDownloadService : BaseDownloadService
// Close file before writing metadata
await outputFile.DisposeAsync();
// Write metadata and cover art
// 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)
await WriteMetadataAsync(outputPath, song, cancellationToken);
return outputPath;
@@ -171,13 +231,22 @@ 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 () =>
{
return await TryWithFallbackAsync(async (baseUrl) =>
// Use round-robin with fallback instead of racing to reduce CPU usage
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
// Map quality settings to Tidal's quality levels
// Map quality settings to Tidal's quality levels per hifi-api spec
var quality = _squidwtfSettings.Quality?.ToUpperInvariant() switch
{
"FLAC" => "LOSSLESS",
@@ -190,7 +259,7 @@ public class SquidWTFDownloadService : BaseDownloadService
var url = $"{baseUrl}/track/?id={trackId}&quality={quality}";
Console.WriteLine($"%%%%%%%%%%%%%%%%%%% URL For downloads??: {url}");
Logger.LogDebug("Fetching track download info from: {Url}", url);
var response = await _httpClient.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
@@ -228,8 +297,7 @@ public class SquidWTFDownloadService : BaseDownloadService
? audioQualityEl.GetString()
: "LOSSLESS";
Logger.LogDebug("Decoded manifest - URL: {Url}, MIME: {MimeType}, Quality: {Quality}",
downloadUrl, mimeType, audioQuality);
Logger.LogInformation("Track download URL obtained from hifi-api: {Url}", downloadUrl);
return new DownloadResult
{
@@ -241,29 +309,56 @@ public class SquidWTFDownloadService : BaseDownloadService
});
}
#endregion
#region Utility Methods
private async Task<T> QueueRequestAsync<T>(Func<Task<T>> action)
/// <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)
{
await _requestLock.WaitAsync();
try
if (externalProvider != "squidwtf")
{
var now = DateTime.UtcNow;
var timeSinceLastRequest = (now - _lastRequestTime).TotalMilliseconds;
if (timeSinceLastRequest < _minRequestIntervalMs)
{
await Task.Delay((int)(_minRequestIntervalMs - timeSinceLastRequest));
}
_lastRequestTime = DateTime.UtcNow;
return await action();
return;
}
finally
var spotifyId = await _odesliService.ConvertTidalToSpotifyIdAsync(externalId, CancellationToken.None);
if (!string.IsNullOrEmpty(spotifyId))
{
_requestLock.Release();
Logger.LogDebug("Background Spotify ID obtained for Tidal/{TrackId}: {SpotifyId}", externalId, spotifyId);
// Immediately prefetch lyrics now that we have the Spotify ID
// This ensures lyrics are cached and ready when the client requests them
_ = Task.Run(async () =>
{
try
{
using var scope = _serviceProvider.CreateScope();
var spotifyLyricsService = scope.ServiceProvider.GetService<SpotifyLyricsService>();
if (spotifyLyricsService != null)
{
var lyrics = await spotifyLyricsService.GetLyricsByTrackIdAsync(spotifyId);
if (lyrics != null && lyrics.Lines.Count > 0)
{
Logger.LogDebug("Background lyrics prefetched for Spotify/{SpotifyId}: {LineCount} lines",
spotifyId, lyrics.Lines.Count);
}
else
{
Logger.LogDebug("No lyrics available for Spotify/{SpotifyId}", spotifyId);
}
}
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Background lyrics prefetch failed for Spotify/{SpotifyId}", spotifyId);
}
});
}
}

View File

@@ -12,7 +12,41 @@ using System.Text.Json.Nodes;
namespace allstarr.Services.SquidWTF;
/// <summary>
/// Metadata service implementation using the SquidWTF API (free, no key required)
/// 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
/// </summary>
public class SquidWTFMetadataService : IMusicMetadataService
@@ -21,9 +55,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
private readonly SubsonicSettings _settings;
private readonly ILogger<SquidWTFMetadataService> _logger;
private readonly RedisCacheService _cache;
private readonly List<string> _apiUrls;
private int _currentUrlIndex = 0;
private readonly object _urlIndexLock = new object();
private readonly RoundRobinFallbackHelper _fallbackHelper;
public SquidWTFMetadataService(
IHttpClientFactory httpClientFactory,
@@ -37,79 +69,33 @@ public class SquidWTFMetadataService : IMusicMetadataService
_settings = settings.Value;
_logger = logger;
_cache = cache;
_apiUrls = apiUrls;
_fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF");
// 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)
{
return await TryWithFallbackAsync(async (baseUrl) =>
// Race all endpoints for fastest search results
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
{
// 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);
var response = await _httpClient.GetAsync(url, ct);
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"HTTP {response.StatusCode}");
}
var json = await response.Content.ReadAsStringAsync();
var json = await response.Content.ReadAsStringAsync(ct);
// Check for error in response body
var result = JsonDocument.Parse(json);
@@ -120,6 +106,7 @@ 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))
{
@@ -129,30 +116,36 @@ public class SquidWTFMetadataService : IMusicMetadataService
if (count >= limit) break;
var song = ParseTidalTrack(track);
songs.Add(song);
if (ShouldIncludeSong(song))
{
songs.Add(song);
}
count++;
}
}
return songs;
}, new List<Song>());
});
}
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20)
{
return await TryWithFallbackAsync(async (baseUrl) =>
// Race all endpoints for fastest search results
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
{
// 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);
var response = await _httpClient.GetAsync(url, ct);
if (!response.IsSuccessStatusCode)
{
return new List<Album>();
throw new HttpRequestException($"HTTP {response.StatusCode}");
}
var json = await response.Content.ReadAsStringAsync();
var json = await response.Content.ReadAsStringAsync(ct);
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))
@@ -168,25 +161,31 @@ public class SquidWTFMetadataService : IMusicMetadataService
}
return albums;
}, new List<Album>());
});
}
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20)
{
return await TryWithFallbackAsync(async (baseUrl) =>
// Race all endpoints for fastest search results
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
{
// Per hifi-api spec: use 'a' parameter for artist search
var url = $"{baseUrl}/search/?a={Uri.EscapeDataString(query)}";
var response = await _httpClient.GetAsync(url);
_logger.LogInformation("🔍 SQUIDWTF: Searching artists with URL: {Url}", url);
var response = await _httpClient.GetAsync(url, ct);
if (!response.IsSuccessStatusCode)
{
return new List<Artist>();
_logger.LogWarning("⚠️ SQUIDWTF: Artist search failed with status {StatusCode}", response.StatusCode);
throw new HttpRequestException($"HTTP {response.StatusCode}");
}
var json = await response.Content.ReadAsStringAsync();
var json = await response.Content.ReadAsStringAsync(ct);
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))
@@ -196,19 +195,23 @@ public class SquidWTFMetadataService : IMusicMetadataService
{
if (count >= limit) break;
artists.Add(ParseTidalArtist(artist));
var parsedArtist = ParseTidalArtist(artist);
artists.Add(parsedArtist);
_logger.LogDebug("🎤 SQUIDWTF: Found artist: {Name} (ID: {Id})", parsedArtist.Name, parsedArtist.ExternalId);
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 TryWithFallbackAsync(async (baseUrl) =>
return await _fallbackHelper.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>();
@@ -217,15 +220,20 @@ 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)
{
@@ -261,8 +269,9 @@ public class SquidWTFMetadataService : IMusicMetadataService
{
if (externalProvider != "squidwtf") return null;
return await TryWithFallbackAsync(async (baseUrl) =>
return await _fallbackHelper.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);
@@ -271,10 +280,16 @@ 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;
return ParseTidalTrackFull(track);
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;
}, (Song?)null);
}
@@ -287,8 +302,9 @@ public class SquidWTFMetadataService : IMusicMetadataService
var cached = await _cache.GetAsync<Album>(cacheKey);
if (cached != null) return cached;
return await TryWithFallbackAsync(async (baseUrl) =>
return await _fallbackHelper.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);
@@ -297,17 +313,18 @@ 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
// Get album tracks from items array
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);
@@ -341,8 +358,9 @@ public class SquidWTFMetadataService : IMusicMetadataService
return cached;
}
return await TryWithFallbackAsync(async (baseUrl) =>
return await _fallbackHelper.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);
@@ -360,21 +378,26 @@ public class SquidWTFMetadataService : IMusicMetadataService
JsonElement? artistSource = null;
int albumCount = 0;
// Think this can maybe switch to something using ParseTidalAlbum
// Response structure: { "albums": { "items": [ album objects ] }, "tracks": [ track objects ] }
// Extract artist info from albums.items[0].artist (most reliable source)
if (result.RootElement.TryGetProperty("albums", out var albums) &&
albums.TryGetProperty("items", out var albumItems) &&
albumItems.GetArrayLength() > 0)
{
albumCount = albumItems.GetArrayLength();
artistSource = albumItems[0].GetProperty("artist");
_logger.LogInformation("Found artist from albums, albumCount={AlbumCount}", albumCount);
if (albumItems[0].TryGetProperty("artist", out var artistEl))
{
artistSource = artistEl;
_logger.LogInformation("Found artist from albums, albumCount={AlbumCount}", albumCount);
}
}
// 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)
// Fallback: try to get artist from tracks[0].artists[0]
if (artistSource == null &&
result.RootElement.TryGetProperty("tracks", out var tracks) &&
tracks.GetArrayLength() > 0 &&
tracks[0].TryGetProperty("artists", out var artists) &&
artists.GetArrayLength() > 0)
{
artistSource = artists[0];
_logger.LogInformation("Found artist from tracks");
@@ -382,11 +405,13 @@ public class SquidWTFMetadataService : IMusicMetadataService
if (artistSource == null)
{
_logger.LogWarning("Could not find artist data in response");
_logger.LogDebug("Could not find artist data in response. Response keys: {Keys}",
string.Join(", ", result.RootElement.EnumerateObject().Select(p => p.Name)));
return null;
}
var artistElement = artistSource.Value;
// Normalize artist data to include album count
var normalizedArtist = new JsonObject
{
["id"] = artistElement.GetProperty("id").GetInt64(),
@@ -411,10 +436,11 @@ public class SquidWTFMetadataService : IMusicMetadataService
{
if (externalProvider != "squidwtf") return new List<Album>();
return await TryWithFallbackAsync(async (baseUrl) =>
return await _fallbackHelper.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);
@@ -431,6 +457,7 @@ 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))
{
@@ -456,8 +483,9 @@ public class SquidWTFMetadataService : IMusicMetadataService
{
if (externalProvider != "squidwtf") return null;
return await TryWithFallbackAsync(async (baseUrl) =>
return await _fallbackHelper.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;
@@ -465,8 +493,10 @@ 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);
}
@@ -475,8 +505,9 @@ public class SquidWTFMetadataService : IMusicMetadataService
{
if (externalProvider != "squidwtf") return new List<Song>();
return await TryWithFallbackAsync(async (baseUrl) =>
return await _fallbackHelper.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>();
@@ -484,11 +515,13 @@ 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;
@@ -511,6 +544,7 @@ 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;
@@ -533,6 +567,14 @@ 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();
@@ -553,6 +595,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
// Get all artists - Tidal provides both "artist" (singular) and "artists" (plural array)
var allArtists = new List<string>();
var allArtistIds = new List<string>();
string artistName = "";
string? artistId = null;
@@ -562,9 +605,11 @@ public class SquidWTFMetadataService : IMusicMetadataService
foreach (var artistEl in artists.EnumerateArray())
{
var name = artistEl.GetProperty("name").GetString();
var id = artistEl.GetProperty("id").GetInt64();
if (!string.IsNullOrEmpty(name))
{
allArtists.Add(name);
allArtistIds.Add($"ext-squidwtf-artist-{id}");
}
}
@@ -572,7 +617,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
if (allArtists.Count > 0)
{
artistName = allArtists[0];
artistId = $"ext-squidwtf-artist-{artists[0].GetProperty("id").GetInt64()}";
artistId = allArtistIds[0];
}
}
// Fallback to singular "artist" field
@@ -581,6 +626,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
artistName = artist.GetProperty("name").GetString() ?? "";
artistId = $"ext-squidwtf-artist-{artist.GetProperty("id").GetInt64()}";
allArtists.Add(artistName);
allArtistIds.Add(artistId);
}
// Get album info
@@ -607,6 +653,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
Artist = artistName,
ArtistId = artistId,
Artists = allArtists,
ArtistIds = allArtistIds,
Album = albumTitle,
AlbumId = albumId,
Duration = track.TryGetProperty("duration", out var duration)
@@ -622,6 +669,13 @@ 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();
@@ -662,6 +716,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
// Get all artists - prefer "artists" array for collaborations
var allArtists = new List<string>();
var allArtistIds = new List<string>();
string artistName = "";
long artistIdNum = 0;
@@ -670,9 +725,11 @@ public class SquidWTFMetadataService : IMusicMetadataService
foreach (var artistEl in artists.EnumerateArray())
{
var name = artistEl.GetProperty("name").GetString();
var id = artistEl.GetProperty("id").GetInt64();
if (!string.IsNullOrEmpty(name))
{
allArtists.Add(name);
allArtistIds.Add($"ext-squidwtf-artist-{id}");
}
}
@@ -687,6 +744,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
artistName = artist.GetProperty("name").GetString() ?? "";
artistIdNum = artist.GetProperty("id").GetInt64();
allArtists.Add(artistName);
allArtistIds.Add($"ext-squidwtf-artist-{artistIdNum}");
}
// Album artist - same as main artist for Tidal tracks
@@ -722,6 +780,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
Artist = artistName,
ArtistId = $"ext-squidwtf-artist-{artistIdNum}",
Artists = allArtists,
ArtistIds = allArtistIds,
Album = albumTitle,
AlbumId = $"ext-squidwtf-album-{albumIdNum}",
AlbumArtist = albumArtist,
@@ -743,6 +802,13 @@ 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();
@@ -796,8 +862,13 @@ public class SquidWTFMetadataService : IMusicMetadataService
};
}
// 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
/// <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>
private Artist ParseTidalArtist(JsonElement artist)
{
var externalId = artist.GetProperty("id").GetInt64().ToString();
@@ -823,6 +894,14 @@ 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,6 +3,7 @@ using System.Text.Json;
using Microsoft.Extensions.Options;
using allstarr.Models.Settings;
using allstarr.Services.Validation;
using allstarr.Services.Common;
namespace allstarr.Services.SquidWTF;
@@ -12,56 +13,26 @@ namespace allstarr.Services.SquidWTF;
public class SquidWTFStartupValidator : BaseStartupValidator
{
private readonly SquidWTFSettings _settings;
private readonly List<string> _apiUrls;
private int _currentUrlIndex = 0;
private readonly object _urlIndexLock = new object();
private readonly RoundRobinFallbackHelper _fallbackHelper;
private readonly EndpointBenchmarkService _benchmarkService;
private readonly ILogger<SquidWTFStartupValidator> _logger;
public override string ServiceName => "SquidWTF";
public SquidWTFStartupValidator(IOptions<SquidWTFSettings> settings, HttpClient httpClient, List<string> apiUrls)
public SquidWTFStartupValidator(
IOptions<SquidWTFSettings> settings,
HttpClient httpClient,
List<string> apiUrls,
EndpointBenchmarkService benchmarkService,
ILogger<SquidWTFStartupValidator> logger)
: base(httpClient)
{
_settings = settings.Value;
_apiUrls = apiUrls;
_fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF");
_benchmarkService = benchmarkService;
_logger = logger;
}
/// <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)
{
@@ -79,8 +50,53 @@ 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
{
// 5 second timeout per ping - mark slow endpoints as failed
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(5));
var response = await _httpClient.GetAsync(endpoint, timeoutCts.Token);
return response.IsSuccessStatusCode;
}
catch
{
return false;
}
},
pingCount: 2,
cancellationToken);
if (orderedEndpoints.Count > 0)
{
_fallbackHelper.SetEndpointOrder(orderedEndpoints);
WriteDetail($"Fastest endpoint: {orderedEndpoints.First()}");
}
}
// Test connectivity with fallback
var result = await TryWithFallbackAsync(async (baseUrl) =>
var result = await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
var response = await _httpClient.GetAsync(baseUrl, cancellationToken);
@@ -107,8 +123,8 @@ public class SquidWTFStartupValidator : BaseStartupValidator
{
try
{
// Test search with a simple query
var searchUrl = $"{baseUrl}/search/?s=Taylor%20Swift";
// Test search with "22" by Taylor Swift
var searchUrl = $"{baseUrl}/search/?s=22%20Taylor%20Swift";
var searchResponse = await _httpClient.GetAsync(searchUrl, cancellationToken);
if (searchResponse.IsSuccessStatusCode)
@@ -121,7 +137,36 @@ public class SquidWTFStartupValidator : BaseStartupValidator
{
var itemCount = items.GetArrayLength();
WriteStatus("Search Functionality", "WORKING", ConsoleColor.Green);
WriteDetail($"Test search returned {itemCount} results");
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");
}
}
else
{

View File

@@ -39,6 +39,12 @@ 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

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

View File

@@ -2,7 +2,9 @@
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
"Microsoft.AspNetCore": "Warning",
"System.Net.Http.HttpClient.Default.LogicalHandler": "Warning",
"System.Net.Http.HttpClient.Default.ClientHandler": "Warning"
}
},
"SpotifyImport": {

View File

@@ -1,4 +1,12 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"System.Net.Http.HttpClient.Default.LogicalHandler": "Warning",
"System.Net.Http.HttpClient.Default.ClientHandler": "Warning"
}
},
"Backend": {
"Type": "Subsonic"
},

View File

@@ -387,9 +387,9 @@
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px;
max-width: 500px;
width: 90%;
max-height: 80vh;
max-width: 75%;
width: 75%;
max-height: 65vh;
overflow-y: auto;
}
@@ -537,8 +537,9 @@
<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="playlists">Injected Playlists</div>
<div class="tab" data-tab="config">Configuration</div>
<div class="tab" data-tab="endpoints">API Analytics</div>
</div>
<!-- Dashboard Tab -->
@@ -644,22 +645,29 @@
<!-- 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
Injected Spotify Playlists
<div class="actions">
<button onclick="matchAllPlaylists()">Match All Tracks</button>
<button onclick="refreshPlaylists()">Refresh All</button>
<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.
These are the Spotify playlists currently being injected into Jellyfin with tracks from your music service.
</p>
<table class="playlist-table">
<thead>
<tr>
<th>Name</th>
<th>Spotify ID</th>
<th>Sync Schedule</th>
<th>Tracks</th>
<th>Completion</th>
<th>Cache Age</th>
@@ -668,19 +676,193 @@
</thead>
<tbody id="playlist-table-body">
<tr>
<td colspan="6" class="loading">
<td colspan="7" class="loading">
<span class="spinner"></span> Loading playlists...
</td>
</tr>
</tbody>
</table>
</div>
<!-- Manual Track Mappings Section -->
<div class="card">
<h2>
Manual Track Mappings
<div class="actions">
<button onclick="fetchTrackMappings()">Refresh</button>
</div>
</h2>
<p style="color: var(--text-secondary); margin-bottom: 12px;">
Manual mappings override automatic matching for external providers (SquidWTF, Deezer, Qobuz). For local Jellyfin tracks, use the Spotify Import plugin instead.
</p>
<div id="mappings-summary" style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
<div>
<span style="color: var(--text-secondary);">Total:</span>
<span style="font-weight: 600; margin-left: 8px;" id="mappings-total">0</span>
</div>
<div>
<span style="color: var(--text-secondary);">External:</span>
<span style="font-weight: 600; margin-left: 8px; color: var(--success);" id="mappings-external">0</span>
</div>
</div>
<table class="playlist-table">
<thead>
<tr>
<th>Playlist</th>
<th>Spotify ID</th>
<th>Type</th>
<th>Target</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="mappings-table-body">
<tr>
<td colspan="6" class="loading">
<span class="spinner"></span> Loading mappings...
</td>
</tr>
</tbody>
</table>
</div>
<!-- Missing Tracks Section -->
<div class="card">
<h2>
Missing Tracks (All Playlists)
<div class="actions">
<button onclick="fetchMissingTracks()">Refresh</button>
</div>
</h2>
<p style="color: var(--text-secondary); margin-bottom: 12px;">
Tracks that couldn't be matched locally or externally. Map them manually to add them to your playlists.
</p>
<div id="missing-summary" style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
<div>
<span style="color: var(--text-secondary);">Total Missing:</span>
<span style="font-weight: 600; margin-left: 8px; color: var(--warning);" id="missing-total">0</span>
</div>
</div>
<table class="playlist-table">
<thead>
<tr>
<th>Playlist</th>
<th>Track</th>
<th>Artist</th>
<th>Album</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="missing-tracks-table-body">
<tr>
<td colspan="5" class="loading">
<span class="spinner"></span> Loading missing tracks...
</td>
</tr>
</tbody>
</table>
</div>
<!-- Kept Downloads Section -->
<div class="card">
<h2>
Kept Downloads
<div class="actions">
<button onclick="fetchDownloads()">Refresh</button>
</div>
</h2>
<p style="color: var(--text-secondary); margin-bottom: 12px;">
Downloaded files stored permanently. Download or delete individual tracks.
</p>
<div id="downloads-summary" style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
<div>
<span style="color: var(--text-secondary);">Total Files:</span>
<span style="font-weight: 600; margin-left: 8px; color: var(--accent);" id="downloads-count">0</span>
</div>
<div>
<span style="color: var(--text-secondary);">Total Size:</span>
<span style="font-weight: 600; margin-left: 8px; color: var(--accent);" id="downloads-size">0 B</span>
</div>
</div>
<table class="playlist-table">
<thead>
<tr>
<th>Artist</th>
<th>Album</th>
<th>File</th>
<th>Size</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="downloads-table-body">
<tr>
<td colspan="5" class="loading">
<span class="spinner"></span> Loading downloads...
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Configuration Tab -->
<div class="tab-content" id="tab-config">
<div class="card">
<h2>Core Settings</h2>
<div class="config-section">
<div class="config-item">
<span class="label">Backend Type <span style="color: var(--error);">*</span></span>
<span class="value" id="config-backend-type">-</span>
<button onclick="openEditSetting('BACKEND_TYPE', 'Backend Type', 'select', 'Choose your media server backend', ['Jellyfin', 'Subsonic'])">Edit</button>
</div>
<div class="config-item">
<span class="label">Music Service <span style="color: var(--error);">*</span></span>
<span class="value" id="config-music-service">-</span>
<button onclick="openEditSetting('MUSIC_SERVICE', 'Music Service', 'select', 'Choose your music download provider', ['SquidWTF', 'Deezer', 'Qobuz'])">Edit</button>
</div>
<div class="config-item">
<span class="label">Storage Mode</span>
<span class="value" id="config-storage-mode">-</span>
<button onclick="openEditSetting('STORAGE_MODE', 'Storage Mode', 'select', 'Permanent keeps files forever, Cache auto-deletes after duration', ['Permanent', 'Cache'])">Edit</button>
</div>
<div class="config-item" id="cache-duration-row" style="display: none;">
<span class="label">Cache Duration (hours)</span>
<span class="value" id="config-cache-duration-hours">-</span>
<button onclick="openEditSetting('CACHE_DURATION_HOURS', 'Cache Duration (hours)', 'number', 'How long to keep cached files before deletion')">Edit</button>
</div>
<div class="config-item">
<span class="label">Download Mode</span>
<span class="value" id="config-download-mode">-</span>
<button onclick="openEditSetting('DOWNLOAD_MODE', 'Download Mode', 'select', 'Download individual tracks or full albums', ['Track', 'Album'])">Edit</button>
</div>
<div class="config-item">
<span class="label">Explicit Filter</span>
<span class="value" id="config-explicit-filter">-</span>
<button onclick="openEditSetting('EXPLICIT_FILTER', 'Explicit Filter', 'select', 'Filter explicit content', ['All', 'Explicit', 'Clean'])">Edit</button>
</div>
<div class="config-item">
<span class="label">Enable External Playlists</span>
<span class="value" id="config-enable-external-playlists">-</span>
<button onclick="openEditSetting('ENABLE_EXTERNAL_PLAYLISTS', 'Enable External Playlists', 'toggle')">Edit</button>
</div>
<div class="config-item">
<span class="label">Playlists Directory</span>
<span class="value" id="config-playlists-directory">-</span>
<button onclick="openEditSetting('PLAYLISTS_DIRECTORY', 'Playlists Directory', 'text', 'Directory path for external playlists')">Edit</button>
</div>
<div class="config-item">
<span class="label">Redis Enabled</span>
<span class="value" id="config-redis-enabled">-</span>
<button onclick="openEditSetting('REDIS_ENABLED', 'Redis Enabled', 'toggle')">Edit</button>
</div>
</div>
</div>
<div class="card">
<h2>Spotify API Settings</h2>
<div style="background: rgba(248, 81, 73, 0.15); border: 1px solid var(--error); border-radius: 6px; padding: 12px; margin-bottom: 16px; color: var(--text-primary);">
⚠️ For active playlists and link functionality to work, sp_dc session cookie must be set!
</div>
<div class="config-section">
<div class="config-item">
<span class="label">API Enabled</span>
@@ -688,7 +870,7 @@
<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="label">Session Cookie (sp_dc) <span style="color: var(--error);">*</span></span>
<span class="value" id="config-spotify-cookie">-</span>
<button onclick="openEditSetting('SPOTIFY_API_SESSION_COOKIE', 'Spotify Session Cookie', 'password', 'Get from browser dev tools while logged into Spotify. Cookie typically lasts ~1 year.')">Update</button>
</div>
@@ -777,17 +959,17 @@
<h2>Jellyfin Settings</h2>
<div class="config-section">
<div class="config-item">
<span class="label">URL</span>
<span class="label">URL <span style="color: var(--error);">*</span></span>
<span class="value" id="config-jellyfin-url">-</span>
<button onclick="openEditSetting('JELLYFIN_URL', 'Jellyfin URL', 'text')">Edit</button>
</div>
<div class="config-item">
<span class="label">API Key</span>
<span class="label">API Key <span style="color: var(--error);">*</span></span>
<span class="value" id="config-jellyfin-api-key">-</span>
<button onclick="openEditSetting('JELLYFIN_API_KEY', 'Jellyfin API Key', 'password')">Update</button>
</div>
<div class="config-item">
<span class="label">User ID</span>
<span class="label">User ID <span style="color: var(--error);">*</span></span>
<span class="value" id="config-jellyfin-user-id">-</span>
<button onclick="openEditSetting('JELLYFIN_USER_ID', 'Jellyfin User ID', 'text', 'Required for playlist operations. Get from Jellyfin user profile URL: userId=...')">Edit</button>
</div>
@@ -800,17 +982,33 @@
</div>
<div class="card">
<h2>Sync Schedule</h2>
<h2>Library Settings</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>
<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">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>
<span class="label">Kept Path (Favorited)</span>
<span class="value" id="config-kept-path">-</span>
<button onclick="openEditSetting('LIBRARY_KEPT_PATH', 'Kept Path', 'text')">Edit</button>
</div>
</div>
</div>
<div class="card">
<h2>Spotify Import Settings</h2>
<div class="config-section">
<div class="config-item">
<span class="label">Spotify Import Enabled</span>
<span class="value" id="config-spotify-import-enabled">-</span>
<button onclick="openEditSetting('SPOTIFY_IMPORT_ENABLED', 'Spotify Import Enabled', 'toggle')">Edit</button>
</div>
<div class="config-item">
<span class="label">Matching Interval (hours)</span>
<span class="value" id="config-matching-interval">-</span>
<button onclick="openEditSetting('SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS', 'Matching Interval (hours)', 'number', 'How often to check for playlist updates')">Edit</button>
</div>
</div>
</div>
@@ -838,6 +1036,85 @@
</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 -->
@@ -879,7 +1156,7 @@
<!-- Track List Modal -->
<div class="modal" id="tracks-modal">
<div class="modal-content" style="max-width: 700px;">
<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">
@@ -895,10 +1172,12 @@
<!-- Manual Track Mapping Modal -->
<div class="modal" id="manual-map-modal">
<div class="modal-content" style="max-width: 600px;">
<h3>Map Track to Local File</h3>
<h3>Map Track to External Provider</h3>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
This track is currently using an external provider. Search for and select the local Jellyfin track to use instead.
Map this track to an external provider (SquidWTF, Deezer, or Qobuz). For local Jellyfin tracks, use the Jellyfin mapping modal 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;">
@@ -906,29 +1185,30 @@
<span style="color: var(--text-secondary);" id="map-spotify-artist"></span>
</div>
</div>
<div class="form-group">
<label>Search Jellyfin Tracks</label>
<input type="text" id="map-search-query" placeholder="Search by title, artist, or album..." oninput="searchJellyfinTracks()">
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
Tip: Use commas to search multiple terms (e.g., "It Ain't Easy, David Bowie")
</small>
</div>
<div style="text-align: center; color: var(--text-secondary); margin: 12px 0;">— OR —</div>
<div class="form-group">
<label>Paste Jellyfin Track URL</label>
<input type="text" id="map-jellyfin-url" placeholder="https://jellyfin.example.com/web/#/details?id=..." oninput="extractJellyfinId()">
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
Paste the full URL from your Jellyfin web interface
</small>
</div>
<div id="map-search-results" style="max-height: 300px; overflow-y: auto; margin-top: 12px;">
<p style="text-align: center; color: var(--text-secondary); padding: 20px;">
Type to search for local tracks or paste a Jellyfin URL...
</p>
<!-- 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">
<input type="hidden" id="map-selected-jellyfin-id">
<div class="modal-actions">
<button onclick="closeModal('manual-map-modal')">Cancel</button>
<button class="primary" onclick="saveManualMapping()" id="map-save-btn" disabled>Save Mapping</button>
@@ -936,25 +1216,94 @@
</div>
</div>
<!-- Local Jellyfin Track Mapping Modal -->
<div class="modal" id="local-map-modal">
<div class="modal-content" style="max-width: 700px;">
<h3>Map Track to Local Jellyfin Track</h3>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
Search your Jellyfin library and select a local track to map to this Spotify track.
</p>
<!-- Track Info -->
<div class="form-group">
<label>Spotify Track (Position <span id="local-map-position"></span>)</label>
<div style="background: var(--bg-primary); padding: 12px; border-radius: 8px; margin-bottom: 16px;">
<strong id="local-map-spotify-title"></strong><br>
<span style="color: var(--text-secondary);" id="local-map-spotify-artist"></span>
</div>
</div>
<!-- Search Section -->
<div class="form-group">
<label>Search Jellyfin Library</label>
<input type="text" id="local-map-search" placeholder="Search for track name or artist...">
<button onclick="searchJellyfinTracks()" style="margin-top: 8px; width: 100%;">🔍 Search</button>
</div>
<!-- Search Results -->
<div id="local-map-results" style="max-height: 300px; overflow-y: auto; margin-top: 16px;"></div>
<input type="hidden" id="local-map-playlist-name">
<input type="hidden" id="local-map-spotify-id">
<input type="hidden" id="local-map-jellyfin-id">
<div class="modal-actions">
<button onclick="closeModal('local-map-modal')">Cancel</button>
<button class="primary" onclick="saveLocalMapping()" id="local-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.
Select a playlist from your Spotify library or enter a playlist ID/URL manually. Allstarr will automatically download missing tracks from your configured music service.
</p>
<div class="form-group">
<label>Jellyfin Playlist</label>
<input type="text" id="link-jellyfin-name" readonly style="background: var(--bg-primary);">
<input type="hidden" id="link-jellyfin-id">
</div>
<div class="form-group">
<!-- Toggle between select and manual input -->
<div style="display: flex; gap: 8px; margin-bottom: 16px;">
<button type="button" id="select-mode-btn" class="primary" onclick="switchLinkMode('select')" style="flex: 1;">Select from My Playlists</button>
<button type="button" id="manual-mode-btn" onclick="switchLinkMode('manual')" style="flex: 1;">Enter Manually</button>
</div>
<!-- Select from user playlists -->
<div class="form-group" id="link-select-group">
<label>Your Spotify Playlists</label>
<select id="link-spotify-select" style="width: 100%;">
<option value="">Loading playlists...</option>
</select>
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
Select a playlist from your Spotify library
</small>
</div>
<!-- Manual input -->
<div class="form-group" id="link-manual-group" style="display: none;">
<label>Spotify Playlist ID or URL</label>
<input type="text" id="link-spotify-id" placeholder="37i9dQZF1DXcBWIGoYBM5M or spotify:playlist:... or full URL">
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
Accepts: <code>37i9dQZF1DXcBWIGoYBM5M</code>, <code>spotify:playlist:37i9dQZF1DXcBWIGoYBM5M</code>, or full Spotify URL
</small>
</div>
<!-- Sync Schedule -->
<div class="form-group">
<label>Sync Schedule (Cron)</label>
<input type="text" id="link-sync-schedule" placeholder="0 8 * * 1" value="0 8 * * 1" style="font-family: monospace;">
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
Cron format: <code>minute hour day month dayofweek</code><br>
Default: <code>0 8 * * 1</code> = 8 AM every Monday<br>
Examples: <code>0 6 * * *</code> = daily at 6 AM, <code>0 20 * * 5</code> = Fridays at 8 PM<br>
<a href="https://crontab.guru/" target="_blank" style="color: var(--primary);">Use crontab.guru to build your schedule</a>
</small>
</div>
<div class="modal-actions">
<button onclick="closeModal('link-playlist-modal')">Cancel</button>
<button class="primary" onclick="linkPlaylist()">Link Playlist</button>
@@ -962,6 +1311,45 @@
</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>
@@ -1017,8 +1405,37 @@
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');
@@ -1158,7 +1575,7 @@
}
}
async function fetchPlaylists() {
async function fetchPlaylists(silent = false) {
try {
const res = await fetch('/api/admin/playlists');
const data = await res.json();
@@ -1166,7 +1583,9 @@
const tbody = document.getElementById('playlist-table-body');
if (data.playlists.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists configured. Link playlists from the Jellyfin Playlists tab.</td></tr>';
if (!silent) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists configured. Link playlists from the Jellyfin Playlists tab.</td></tr>';
}
return;
}
@@ -1177,6 +1596,7 @@
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}:`, {
@@ -1185,11 +1605,12 @@
externalMatched,
externalMissing,
totalInJellyfin,
totalPlayable,
rawData: p
});
// Build detailed stats string
let statsHtml = `<span class="track-count">${spotifyTotal}</span>`;
// 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 = [];
@@ -1207,8 +1628,8 @@
? `<br><small style="color:var(--text-secondary)">${breakdownParts.join(' • ')}</small>`
: '';
// Calculate completion percentage
const completionPct = spotifyTotal > 0 ? Math.round((totalInJellyfin / spotifyTotal) * 100) : 0;
// 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;
@@ -1217,10 +1638,16 @@
// Debug logging
console.log(`Progress bar for ${p.name}: local=${localPct}%, external=${externalPct}%, missing=${missingPct}%, total=${completionPct}%`);
const syncSchedule = p.syncSchedule || '0 8 * * 1';
return `
<tr>
<td><strong>${escapeHtml(p.name)}</strong></td>
<td style="font-family:monospace;font-size:0.85rem;color:var(--text-secondary);">${p.id || '-'}</td>
<td style="font-family:monospace;font-size:0.85rem;">
${escapeHtml(syncSchedule)}
<button onclick="editPlaylistSchedule('${escapeJs(p.name)}', '${escapeJs(syncSchedule)}')" style="margin-left:4px;font-size:0.75rem;padding:2px 6px;">Edit</button>
</td>
<td>${statsHtml}${breakdown}</td>
<td>
<div style="display:flex;align-items:center;gap:8px;">
@@ -1234,6 +1661,7 @@
</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>
@@ -1247,11 +1675,254 @@
}
}
async function fetchTrackMappings() {
try {
const res = await fetch('/api/admin/mappings/tracks');
const data = await res.json();
// Update summary (only external now)
document.getElementById('mappings-total').textContent = data.externalCount || 0;
document.getElementById('mappings-external').textContent = data.externalCount || 0;
const tbody = document.getElementById('mappings-table-body');
if (data.mappings.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:40px;">No manual mappings found.</td></tr>';
return;
}
// Filter to only show external mappings
const externalMappings = data.mappings.filter(m => m.type === 'external');
if (externalMappings.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:40px;">No external mappings found. Local Jellyfin mappings should be managed via Spotify Import plugin.</td></tr>';
return;
}
tbody.innerHTML = externalMappings.map((m, index) => {
const typeColor = 'var(--success)';
const typeBadge = `<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:0.8rem;background:${typeColor}20;color:${typeColor};font-weight:500;">external</span>`;
const targetDisplay = `<span style="font-family:monospace;font-size:0.85rem;color:var(--success);">${m.externalProvider}/${m.externalId}</span>`;
const createdDate = m.createdAt ? new Date(m.createdAt).toLocaleString() : '-';
return `
<tr>
<td><strong>${escapeHtml(m.playlist)}</strong></td>
<td style="font-family:monospace;font-size:0.85rem;color:var(--text-secondary);">${m.spotifyId}</td>
<td>${typeBadge}</td>
<td>${targetDisplay}</td>
<td style="color:var(--text-secondary);font-size:0.85rem;">${createdDate}</td>
<td>
<button class="danger delete-mapping-btn" style="padding:4px 12px;font-size:0.8rem;" data-playlist="${escapeHtml(m.playlist)}" data-spotify-id="${m.spotifyId}">Remove</button>
</td>
</tr>
`;
}).join('');
// Add event listeners to all delete buttons
document.querySelectorAll('.delete-mapping-btn').forEach(btn => {
btn.addEventListener('click', function() {
const playlist = this.getAttribute('data-playlist');
const spotifyId = this.getAttribute('data-spotify-id');
deleteTrackMapping(playlist, spotifyId);
});
});
} catch (error) {
console.error('Failed to fetch track mappings:', error);
showToast('Failed to fetch track mappings', 'error');
}
}
async function deleteTrackMapping(playlist, spotifyId) {
if (!confirm(`Remove manual external mapping for ${spotifyId} in playlist "${playlist}"?\n\nThis will:\n• Delete the manual mapping from the cache\n• Allow the track to be matched automatically again\n• The track may be re-matched with potentially better results\n\nThis action cannot be undone.`)) {
return;
}
try {
const res = await fetch(`/api/admin/mappings/tracks?playlist=${encodeURIComponent(playlist)}&spotifyId=${encodeURIComponent(spotifyId)}`, {
method: 'DELETE'
});
if (res.ok) {
showToast('Mapping removed successfully', 'success');
await fetchTrackMappings();
} else {
const error = await res.json();
showToast(error.error || 'Failed to remove mapping', 'error');
}
} catch (error) {
console.error('Failed to delete mapping:', error);
showToast('Failed to remove mapping', 'error');
}
}
async function fetchMissingTracks() {
try {
const res = await fetch('/api/admin/playlists');
const data = await res.json();
const tbody = document.getElementById('missing-tracks-table-body');
const missingTracks = [];
// Collect all missing tracks from all playlists
for (const playlist of data.playlists) {
if (playlist.externalMissing > 0) {
// Fetch tracks for this playlist
try {
const tracksRes = await fetch(`/api/admin/playlists/${encodeURIComponent(playlist.name)}/tracks`);
const tracksData = await tracksRes.json();
// Filter to only missing tracks (isLocal === null)
const missing = tracksData.tracks.filter(t => t.isLocal === null);
missing.forEach(t => {
missingTracks.push({
playlist: playlist.name,
...t
});
});
} catch (err) {
console.error(`Failed to fetch tracks for ${playlist.name}:`, err);
}
}
}
// Update summary
document.getElementById('missing-total').textContent = missingTracks.length;
if (missingTracks.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text-secondary);padding:40px;">🎉 No missing tracks! All tracks are matched.</td></tr>';
return;
}
tbody.innerHTML = missingTracks.map(t => {
const artist = (t.artists && t.artists.length > 0) ? t.artists.join(', ') : '';
const searchQuery = `${t.title} ${artist}`;
return `
<tr>
<td><strong>${escapeHtml(t.playlist)}</strong></td>
<td>${escapeHtml(t.title)}</td>
<td>${escapeHtml(artist)}</td>
<td style="color:var(--text-secondary);">${t.album ? escapeHtml(t.album) : '-'}</td>
<td>
<button onclick="searchProvider('${escapeJs(searchQuery)}', 'squidwtf')"
style="margin-right:4px;font-size:0.75rem;padding:4px 8px;background:#3b82f6;border-color:#3b82f6;color:white;">🔍 Search</button>
<button onclick="openMapToLocal('${escapeJs(t.playlist)}', '${escapeJs(t.spotifyId)}', '${escapeJs(t.title)}', '${escapeJs(artist)}')"
style="margin-right:4px;font-size:0.75rem;padding:4px 8px;background:var(--success);border-color:var(--success);">Map to Local</button>
<button onclick="openMapToExternal('${escapeJs(t.playlist)}', '${escapeJs(t.spotifyId)}', '${escapeJs(t.title)}', '${escapeJs(artist)}')"
style="font-size:0.75rem;padding:4px 8px;background:var(--warning);border-color:var(--warning);">Map to External</button>
</td>
</tr>
`;
}).join('');
} catch (error) {
console.error('Failed to fetch missing tracks:', error);
showToast('Failed to fetch missing tracks', 'error');
}
}
async function fetchDownloads() {
try {
const res = await fetch('/api/admin/downloads');
const data = await res.json();
const tbody = document.getElementById('downloads-table-body');
// Update summary
document.getElementById('downloads-count').textContent = data.count;
document.getElementById('downloads-size').textContent = data.totalSizeFormatted;
if (data.count === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text-secondary);padding:40px;">No downloaded files found.</td></tr>';
return;
}
tbody.innerHTML = data.files.map(f => {
return `
<tr data-path="${escapeHtml(f.path)}">
<td><strong>${escapeHtml(f.artist)}</strong></td>
<td>${escapeHtml(f.album)}</td>
<td style="font-family:monospace;font-size:0.85rem;">${escapeHtml(f.fileName)}</td>
<td style="color:var(--text-secondary);">${f.sizeFormatted}</td>
<td>
<button onclick="downloadFile('${escapeJs(f.path)}')"
style="margin-right:4px;font-size:0.75rem;padding:4px 8px;background:var(--accent);border-color:var(--accent);">Download</button>
<button onclick="deleteDownload('${escapeJs(f.path)}')"
class="danger" style="font-size:0.75rem;padding:4px 8px;">Delete</button>
</td>
</tr>
`;
}).join('');
} catch (error) {
console.error('Failed to fetch downloads:', error);
showToast('Failed to fetch downloads', 'error');
}
}
async function downloadFile(path) {
try {
window.open(`/api/admin/downloads/file?path=${encodeURIComponent(path)}`, '_blank');
} catch (error) {
console.error('Failed to download file:', error);
showToast('Failed to download file', 'error');
}
}
async function deleteDownload(path) {
if (!confirm(`Delete this file?\n\n${path}\n\nThis action cannot be undone.`)) {
return;
}
try {
const res = await fetch(`/api/admin/downloads?path=${encodeURIComponent(path)}`, {
method: 'DELETE'
});
if (res.ok) {
showToast('File deleted successfully', 'success');
// Remove the row immediately for live update
const escapedPath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const row = document.querySelector(`tr[data-path="${escapedPath}"]`);
if (row) {
row.remove();
}
// Refresh to update counts
await fetchDownloads();
} else {
const error = await res.json();
showToast(error.error || 'Failed to delete file', 'error');
}
} catch (error) {
console.error('Failed to delete file:', error);
showToast('Failed to delete file', 'error');
}
}
async function fetchConfig() {
try {
const res = await fetch('/api/admin/config');
const data = await res.json();
// Core settings
document.getElementById('config-backend-type').textContent = data.backendType || 'Jellyfin';
document.getElementById('config-music-service').textContent = data.musicService || 'SquidWTF';
document.getElementById('config-storage-mode').textContent = data.library?.storageMode || 'Cache';
document.getElementById('config-cache-duration-hours').textContent = data.library?.cacheDurationHours || '24';
document.getElementById('config-download-mode').textContent = data.library?.downloadMode || 'Track';
document.getElementById('config-explicit-filter').textContent = data.explicitFilter || 'All';
document.getElementById('config-enable-external-playlists').textContent = data.enableExternalPlaylists ? 'Yes' : 'No';
document.getElementById('config-playlists-directory').textContent = data.playlistsDirectory || '(not set)';
document.getElementById('config-redis-enabled').textContent = data.redisEnabled ? 'Yes' : 'No';
// Show/hide cache duration based on storage mode
const cacheDurationRow = document.getElementById('cache-duration-row');
if (cacheDurationRow) {
cacheDurationRow.style.display = data.library?.storageMode === 'Cache' ? 'grid' : 'none';
}
// Spotify API settings
document.getElementById('config-spotify-enabled').textContent = data.spotifyApi.enabled ? 'Yes' : 'No';
document.getElementById('config-spotify-cookie').textContent = data.spotifyApi.sessionCookie;
@@ -1288,11 +1959,13 @@
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';
document.getElementById('config-spotify-import-enabled').textContent = data.spotifyImport?.enabled ? 'Yes' : 'No';
document.getElementById('config-matching-interval').textContent = (data.spotifyImport?.matchingIntervalHours || 24) + ' hours';
} catch (error) {
console.error('Failed to fetch config:', error);
}
@@ -1368,23 +2041,138 @@
}
}
function openLinkPlaylist(jellyfinId, name) {
let currentLinkMode = 'select'; // 'select' or 'manual'
let spotifyUserPlaylists = []; // Cache of user playlists
function switchLinkMode(mode) {
currentLinkMode = mode;
const selectGroup = document.getElementById('link-select-group');
const manualGroup = document.getElementById('link-manual-group');
const selectBtn = document.getElementById('select-mode-btn');
const manualBtn = document.getElementById('manual-mode-btn');
if (mode === 'select') {
selectGroup.style.display = 'block';
manualGroup.style.display = 'none';
selectBtn.classList.add('primary');
manualBtn.classList.remove('primary');
} else {
selectGroup.style.display = 'none';
manualGroup.style.display = 'block';
selectBtn.classList.remove('primary');
manualBtn.classList.add('primary');
}
}
async function fetchSpotifyUserPlaylists() {
try {
const res = await fetch('/api/admin/spotify/user-playlists');
if (!res.ok) {
const error = await res.json();
console.error('Failed to fetch Spotify playlists:', res.status, error);
// Show user-friendly error message
if (res.status === 429) {
showToast('Spotify rate limit reached. Please wait a moment and try again.', 'warning', 5000);
} else if (res.status === 401) {
showToast('Spotify authentication failed. Check your sp_dc cookie.', 'error', 5000);
}
return [];
}
const data = await res.json();
return data.playlists || [];
} catch (error) {
console.error('Failed to fetch Spotify playlists:', error);
return [];
}
}
async function openLinkPlaylist(jellyfinId, name) {
document.getElementById('link-jellyfin-id').value = jellyfinId;
document.getElementById('link-jellyfin-name').value = name;
document.getElementById('link-spotify-id').value = '';
// Reset to select mode
switchLinkMode('select');
// Fetch user playlists if not already cached
if (spotifyUserPlaylists.length === 0) {
const select = document.getElementById('link-spotify-select');
select.innerHTML = '<option value="">Loading playlists...</option>';
spotifyUserPlaylists = await fetchSpotifyUserPlaylists();
// Filter out already-linked playlists
const availablePlaylists = spotifyUserPlaylists.filter(p => !p.isLinked);
if (availablePlaylists.length === 0) {
if (spotifyUserPlaylists.length > 0) {
select.innerHTML = '<option value="">All your playlists are already linked</option>';
} else {
select.innerHTML = '<option value="">No playlists found or Spotify not configured</option>';
}
// Switch to manual mode if no available playlists
switchLinkMode('manual');
} else {
// Populate dropdown with only unlinked playlists
select.innerHTML = '<option value="">-- Select a playlist --</option>' +
availablePlaylists.map(p =>
`<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)} (${p.trackCount} tracks)</option>`
).join('');
}
} else {
// Re-filter in case playlists were linked since last fetch
const select = document.getElementById('link-spotify-select');
const availablePlaylists = spotifyUserPlaylists.filter(p => !p.isLinked);
if (availablePlaylists.length === 0) {
select.innerHTML = '<option value="">All your playlists are already linked</option>';
switchLinkMode('manual');
} else {
select.innerHTML = '<option value="">-- Select a playlist --</option>' +
availablePlaylists.map(p =>
`<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)} (${p.trackCount} tracks)</option>`
).join('');
}
}
openModal('link-playlist-modal');
}
async function linkPlaylist() {
const jellyfinId = document.getElementById('link-jellyfin-id').value;
const name = document.getElementById('link-jellyfin-name').value;
const spotifyId = document.getElementById('link-spotify-id').value.trim();
const syncSchedule = document.getElementById('link-sync-schedule').value.trim();
if (!spotifyId) {
showToast('Spotify Playlist ID is required', 'error');
// Validate sync schedule (basic cron format check)
if (!syncSchedule) {
showToast('Sync schedule is required', 'error');
return;
}
const cronParts = syncSchedule.split(/\s+/);
if (cronParts.length !== 5) {
showToast('Invalid cron format. Expected: minute hour day month dayofweek', 'error');
return;
}
// Get Spotify ID based on current mode
let spotifyId = '';
if (currentLinkMode === 'select') {
spotifyId = document.getElementById('link-spotify-select').value;
if (!spotifyId) {
showToast('Please select a Spotify playlist', 'error');
return;
}
} else {
spotifyId = document.getElementById('link-spotify-id').value.trim();
if (!spotifyId) {
showToast('Spotify Playlist ID is required', 'error');
return;
}
}
// Extract ID from various Spotify formats:
// - spotify:playlist:37i9dQZF1DXcBWIGoYBM5M
// - https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M
@@ -1407,7 +2195,11 @@
const res = await fetch(`/api/admin/jellyfin/playlists/${encodeURIComponent(jellyfinId)}/link`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, spotifyPlaylistId: cleanSpotifyId })
body: JSON.stringify({
name,
spotifyPlaylistId: cleanSpotifyId,
syncSchedule: syncSchedule
})
});
const data = await res.json();
@@ -1417,6 +2209,9 @@
showRestartBanner();
closeModal('link-playlist-modal');
// Clear the Spotify playlists cache so it refreshes next time
spotifyUserPlaylists = [];
// Update UI state without refetching all playlists
const playlistsTable = document.getElementById('jellyfinPlaylistsTable');
if (playlistsTable) {
@@ -1454,6 +2249,9 @@
showToast('Playlist unlinked.', 'success');
showRestartBanner();
// Clear the Spotify playlists cache so it refreshes next time
spotifyUserPlaylists = [];
// Update UI state without refetching all playlists
const playlistsTable = document.getElementById('jellyfinPlaylistsTable');
if (playlistsTable) {
@@ -1491,8 +2289,40 @@
}
}
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();
@@ -1500,12 +2330,18 @@
if (res.ok) {
showToast(`${data.message}`, 'success');
// Refresh the playlists table after a delay to show updated counts
setTimeout(fetchPlaylists, 2000);
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';
}
}
@@ -1513,6 +2349,9 @@
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();
@@ -1520,26 +2359,95 @@
if (res.ok) {
showToast(`${data.message}`, 'success');
// Refresh the playlists table after a delay to show updated counts
setTimeout(fetchPlaylists, 2000);
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';
}
}
function searchProvider(query, provider) {
// Provider-specific search URLs
const searchUrls = {
'Deezer': `https://www.deezer.com/search/${encodeURIComponent(query)}`,
'Qobuz': `https://www.qobuz.com/us-en/search?q=${encodeURIComponent(query)}`,
'SquidWTF': `https://triton.squid.wtf/search/?s=${encodeURIComponent(query)}`,
'default': `https://www.google.com/search?q=${encodeURIComponent(query + ' music')}`
};
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;
const url = searchUrls[provider] || searchUrls['default'];
window.open(url, '_blank');
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() {
@@ -1707,6 +2615,39 @@
}
}
async function editPlaylistSchedule(playlistName, currentSchedule) {
const newSchedule = prompt(`Edit sync schedule for "${playlistName}"\n\nCron format: minute hour day month dayofweek\nExamples:\n• 0 8 * * 1 = Monday 8 AM\n• 0 6 * * * = Daily 6 AM\n• 0 20 * * 5 = Friday 8 PM\n\nUse https://crontab.guru/ to build your schedule`, currentSchedule);
if (!newSchedule || newSchedule === currentSchedule) return;
// Validate cron format
const cronParts = newSchedule.trim().split(/\s+/);
if (cronParts.length !== 5) {
showToast('Invalid cron format. Expected: minute hour day month dayofweek', 'error');
return;
}
try {
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(playlistName)}/schedule`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ syncSchedule: newSchedule.trim() })
});
if (res.ok) {
showToast('Sync schedule updated!', 'success');
showRestartBanner();
fetchPlaylists();
} else {
const error = await res.json();
showToast(error.error || 'Failed to update schedule', 'error');
}
} catch (error) {
console.error('Failed to update schedule:', error);
showToast('Failed to update schedule', 'error');
}
}
async function removePlaylist(name) {
if (!confirm(`Remove playlist "${name}"?`)) return;
@@ -1736,8 +2677,23 @@
try {
const res = await fetch('/api/admin/playlists/' + encodeURIComponent(name) + '/tracks');
if (!res.ok) {
console.error('Failed to fetch tracks:', res.status, res.statusText);
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Failed to load tracks: ' + res.status + ' ' + res.statusText + '</p>';
return;
}
const data = await res.json();
console.log('Tracks data received:', data);
if (!data || !data.tracks) {
console.error('Invalid data structure:', data);
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Invalid data received from server</p>';
return;
}
if (data.tracks.length === 0) {
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--text-secondary);padding:40px;">No tracks found</p>';
return;
@@ -1746,13 +2702,27 @@
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 = t.externalProvider || 'External';
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 map button for external tracks using data attributes
// 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)}"
@@ -1760,11 +2730,18 @@
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>`;
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 manual map button for missing tracks too
// 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)}"
@@ -1772,20 +2749,36 @@
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>`;
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}${mapButton}</h4>
<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(t.searchQuery) + '</a></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>
`;
@@ -1802,8 +2795,21 @@
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>';
console.error('Error in viewTracks:', error);
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Failed to load tracks: ' + error.message + '</p>';
}
}
@@ -1890,20 +2896,6 @@
// Manual track mapping
let searchTimeout = null;
function openManualMap(playlistName, position, title, artist, spotifyId) {
document.getElementById('map-playlist-name').value = playlistName;
document.getElementById('map-position').textContent = position + 1;
document.getElementById('map-spotify-title').textContent = title;
document.getElementById('map-spotify-artist').textContent = artist;
document.getElementById('map-spotify-id').value = spotifyId;
document.getElementById('map-search-query').value = '';
document.getElementById('map-selected-jellyfin-id').value = '';
document.getElementById('map-save-btn').disabled = true;
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">Type to search for local tracks...</p>';
openModal('manual-map-modal');
}
async function searchJellyfinTracks() {
const query = document.getElementById('map-search-query').value.trim();
@@ -2033,17 +3025,186 @@
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 local Jellyfin mapping modal
function openManualMap(playlistName, position, title, artist, spotifyId) {
document.getElementById('local-map-playlist-name').value = playlistName;
document.getElementById('local-map-position').textContent = position + 1;
document.getElementById('local-map-spotify-title').textContent = title;
document.getElementById('local-map-spotify-artist').textContent = artist;
document.getElementById('local-map-spotify-id').value = spotifyId;
// Pre-fill search with track info
document.getElementById('local-map-search').value = `${title} ${artist}`;
// Reset fields
document.getElementById('local-map-results').innerHTML = '';
document.getElementById('local-map-jellyfin-id').value = '';
document.getElementById('local-map-save-btn').disabled = true;
openModal('local-map-modal');
}
// Open external mapping modal
function openExternalMap(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');
}
// Search Jellyfin tracks for local mapping
async function searchJellyfinTracks() {
const query = document.getElementById('local-map-search').value.trim();
if (!query) {
showToast('Please enter a search query', 'error');
return;
}
const resultsDiv = document.getElementById('local-map-results');
resultsDiv.innerHTML = '<p style="text-align:center;padding:20px;">Searching...</p>';
try {
const res = await fetch('/api/admin/jellyfin/search?query=' + encodeURIComponent(query));
const data = await res.json();
if (!res.ok) {
resultsDiv.innerHTML = '<p style="text-align:center;color:var(--error);padding:20px;">Search failed</p>';
return;
}
if (!data.tracks || data.tracks.length === 0) {
resultsDiv.innerHTML = '<p style="text-align:center;color:var(--text-secondary);padding:20px;">No tracks found</p>';
return;
}
resultsDiv.innerHTML = data.tracks.map(track => `
<div style="padding: 12px; border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px; cursor: pointer; transition: background 0.2s;"
onclick="selectJellyfinTrack('${escapeJs(track.id)}', '${escapeJs(track.name)}', '${escapeJs(track.artist)}')"
onmouseover="this.style.background='var(--bg-primary)'"
onmouseout="this.style.background='transparent'">
<strong>${escapeHtml(track.name)}</strong><br>
<span style="color: var(--text-secondary); font-size: 0.9em;">${escapeHtml(track.artist)}</span>
${track.album ? '<br><span style="color: var(--text-secondary); font-size: 0.85em;">' + escapeHtml(track.album) + '</span>' : ''}
</div>
`).join('');
} catch (error) {
console.error('Search error:', error);
resultsDiv.innerHTML = '<p style="text-align:center;color:var(--error);padding:20px;">Search failed</p>';
}
}
// Select a Jellyfin track for mapping
function selectJellyfinTrack(jellyfinId, name, artist) {
document.getElementById('local-map-jellyfin-id').value = jellyfinId;
document.getElementById('local-map-save-btn').disabled = false;
// Highlight selected track
document.querySelectorAll('#local-map-results > div').forEach(div => {
div.style.background = 'transparent';
div.style.border = '1px solid var(--border)';
});
event.target.closest('div').style.background = 'var(--primary)';
event.target.closest('div').style.border = '1px solid var(--primary)';
}
// Save local Jellyfin mapping
async function saveLocalMapping() {
const playlistName = document.getElementById('local-map-playlist-name').value;
const spotifyId = document.getElementById('local-map-spotify-id').value;
const jellyfinId = document.getElementById('local-map-jellyfin-id').value;
if (!jellyfinId) {
showToast('Please select a Jellyfin track', 'error');
return;
}
const requestBody = {
spotifyId,
jellyfinId
};
// Show loading state
const saveBtn = document.getElementById('local-map-save-btn');
const originalText = saveBtn.textContent;
saveBtn.textContent = 'Saving...';
saveBtn.disabled = true;
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000);
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);
if (res.ok) {
showToast('Track mapped successfully!', 'success');
closeModal('local-map-modal');
// Refresh the tracks view if it's open
const tracksModal = document.getElementById('tracks-modal');
if (tracksModal.style.display === 'flex') {
await viewTracks(playlistName);
}
} else {
const data = await res.json();
showToast(data.error || 'Failed to save mapping', 'error');
saveBtn.textContent = originalText;
saveBtn.disabled = false;
}
} catch (error) {
if (error.name === 'AbortError') {
showToast('Request timed out. The mapping may still be processing.', 'warning');
} else {
showToast('Failed to save mapping', 'error');
}
saveBtn.textContent = originalText;
saveBtn.disabled = false;
}
}
// Save manual mapping (external only) - kept for backward compatibility
async function saveManualMapping() {
const playlistName = document.getElementById('map-playlist-name').value;
const spotifyId = document.getElementById('map-spotify-id').value;
const jellyfinId = document.getElementById('map-selected-jellyfin-id').value;
const position = parseInt(document.getElementById('map-position').textContent) - 1; // Convert back to 0-indexed
if (!jellyfinId) {
showToast('Please select a track', 'error');
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;
@@ -2057,7 +3218,7 @@
const res = await fetch('/api/admin/playlists/' + encodeURIComponent(playlistName) + '/map', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ spotifyId, jellyfinId }),
body: JSON.stringify(requestBody),
signal: controller.signal
});
@@ -2065,7 +3226,7 @@
const data = await res.json();
if (res.ok) {
showToast('✓ Track mapped successfully - rebuilding playlist...', 'success');
showToast(`✓ Track mapped to ${requestBody.externalProvider} - rebuilding playlist...`, 'success');
closeModal('manual-map-modal');
// Show rebuilding indicator
@@ -2073,38 +3234,25 @@
// Show detailed info toast after a moment
setTimeout(() => {
showToast('🔄 Searching external providers to rebuild playlist with your manual mapping...', 'info', 8000);
showToast(`🔄 Rebuilding playlist with your ${requestBody.externalProvider} mapping...`, 'info', 8000);
}, 1000);
// Update the track in the UI without refreshing
if (data.track) {
const trackItem = document.querySelector(`.track-item[data-position="${position}"]`);
if (trackItem) {
// Update the track info
const titleEl = trackItem.querySelector('.track-info h4');
const artistEl = trackItem.querySelector('.track-info .artists');
const statusBadge = trackItem.querySelector('.status-badge');
const mapButton = trackItem.querySelector('.map-track-btn');
const searchLink = trackItem.querySelector('.track-meta a');
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;
}
if (titleEl) {
// Remove the old status badge and map button, add new content
const titleText = data.track.title;
const newStatusBadge = '<span class="status-badge success" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>Local</span>';
titleEl.innerHTML = escapeHtml(titleText) + newStatusBadge;
}
if (artistEl) artistEl.textContent = data.track.artist;
// Remove the search link since it's now local
if (searchLink) {
const metaEl = trackItem.querySelector('.track-meta');
if (metaEl) {
// Keep album and ISRC, remove search link
const albumText = data.track.album ? escapeHtml(data.track.album) : '';
metaEl.innerHTML = albumText;
}
}
// Remove search link since it's now mapped
const searchLink = trackItem.querySelector('.track-meta a');
if (searchLink) {
searchLink.remove();
}
}
@@ -2172,18 +3320,184 @@
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.

After

Width:  |  Height:  |  Size: 53 KiB

View File

@@ -17,6 +17,20 @@ services:
networks:
- allstarr-network
# Spotify Lyrics API sidecar service
# Note: This image only supports AMD64. On ARM64 systems, Docker will use emulation.
spotify-lyrics:
image: akashrchandran/spotify-lyrics-api:latest
platform: linux/amd64
container_name: allstarr-spotify-lyrics
restart: unless-stopped
ports:
- "8365:8080"
environment:
- SP_DC=${SPOTIFY_API_SESSION_COOKIE:-}
networks:
- allstarr-network
allstarr:
# Use pre-built image from GitHub Container Registry
# For latest stable: ghcr.io/sopat712/allstarr:latest
@@ -40,6 +54,8 @@ services:
depends_on:
redis:
condition: service_healthy
spotify-lyrics:
condition: service_started
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
@@ -98,6 +114,8 @@ services:
- 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

View File

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