From 375b7c6909ad0ff5e172f9fade9df19bbbda843a Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Fri, 20 Feb 2026 18:59:37 -0500 Subject: [PATCH] v1.1.1: SCROBBLING, fixed and rewrote caching, refactored and fixed WebUI, fixed logs, fixed cron scheduling bugs, hardened security, added Global Mappings, made the proxy more 'transparent', added playlists from Tidal to search --- .env.example | 60 +- ARCHITECTURE.md | 184 + CLIENTS.md | 49 + CONFIGURATION.md | 315 ++ CONTRIBUTING.md | 267 ++ README.md | 621 ++- allstarr.Tests/ApiKeyAuthFilterTests.cs | 167 + allstarr.Tests/DeezerMetadataServiceTests.cs | 4 +- allstarr.Tests/EnvMigrationServiceTests.cs | 214 + .../EnvironmentVariableParsingTests.cs | 148 + allstarr.Tests/InputValidationTests.cs | 163 + allstarr.Tests/JavaScriptSyntaxTests.cs | 187 + allstarr.Tests/JellyfinModelMapperTests.cs | 4 +- allstarr.Tests/JellyfinProxyServiceTests.cs | 6 + .../JellyfinResponseBuilderTests.cs | 6 +- allstarr.Tests/LastFmSignatureTests.cs | 282 ++ allstarr.Tests/PathHelperExtraTests.cs | 82 + allstarr.Tests/PlaylistIdHelperTests.cs | 26 +- allstarr.Tests/QobuzDownloadServiceTests.cs | 6 + allstarr.Tests/QobuzMetadataServiceTests.cs | 4 +- .../ScrobblingAdminControllerTests.cs | 191 + allstarr.Tests/ScrobblingHelperTests.cs | 189 + allstarr.Tests/SpotifyMappingServiceTests.cs | 217 + .../SpotifyMappingValidationServiceTests.cs | 173 + .../SquidWTFMetadataServiceTests.cs | 346 ++ .../WebSocketProxyMiddlewareTests.cs | 35 + allstarr/AppVersion.cs | 13 + allstarr/Controllers/AdminController.cs | 3473 +++++++++++++++- allstarr/Controllers/ConfigController.cs | 640 +++ allstarr/Controllers/DiagnosticsController.cs | 394 ++ allstarr/Controllers/DownloadsController.cs | 245 ++ allstarr/Controllers/Helpers.cs | 356 ++ .../Controllers/JellyfinAdminController.cs | 473 +++ .../Controllers/JellyfinController.Audio.cs | 224 + .../JellyfinController.Authentication.cs | 123 + .../Controllers/JellyfinController.Lyrics.cs | 471 +++ .../JellyfinController.PlaybackSessions.cs | 915 ++++ .../JellyfinController.PlaylistHandler.cs | 180 + .../Controllers/JellyfinController.Search.cs | 568 +++ .../Controllers/JellyfinController.Spotify.cs | 946 +++++ allstarr/Controllers/JellyfinController.cs | 3668 ++++++++++++++++- allstarr/Controllers/LyricsController.cs | 258 ++ allstarr/Controllers/MappingController.cs | 161 + allstarr/Controllers/PlaylistController.cs | 1612 ++++++++ .../Controllers/ScrobblingAdminController.cs | 539 +++ .../Controllers/SpotifyAdminController.cs | 537 +++ allstarr/Filters/AdminPortFilter.cs | 11 + allstarr/Filters/ApiKeyAuthFilter.cs | 16 +- .../Middleware/RequestLoggingMiddleware.cs | 156 + .../Middleware/WebSocketProxyMiddleware.cs | 36 +- allstarr/Models/Admin/AdminDtos.cs | 83 + allstarr/Models/Scrobbling/PlaybackSession.cs | 66 + allstarr/Models/Scrobbling/ScrobbleResult.cs | 90 + allstarr/Models/Scrobbling/ScrobbleTrack.cs | 55 + allstarr/Models/Settings/JellyfinSettings.cs | 6 + .../Models/Settings/ScrobblingSettings.cs | 87 + .../Models/Settings/SpotifyImportSettings.cs | 12 + .../Models/Spotify/SpotifyTrackMapping.cs | 94 + allstarr/Models/Subsonic/ExternalPlaylist.cs | 7 +- allstarr/Program.cs | 155 +- allstarr/Services/Admin/AdminHelperService.cs | 550 +++ allstarr/Services/Common/AuthHeaderHelper.cs | 168 + .../Services/Common/BaseDownloadService.cs | 140 +- allstarr/Services/Common/CacheKeyBuilder.cs | 107 + .../Services/Common/CacheWarmingService.cs | 16 + .../Services/Common/EnvMigrationService.cs | 117 + .../Services/Common/ExplicitContentFilter.cs | 41 + .../Services/Common/GenreEnrichmentService.cs | 27 +- allstarr/Services/Common/PathHelper.cs | 60 +- allstarr/Services/Common/PlaylistIdHelper.cs | 41 +- allstarr/Services/Common/RetryHelper.cs | 73 + .../Common/RoundRobinFallbackHelper.cs | 228 +- .../Services/Deezer/DeezerDownloadService.cs | 61 +- .../Services/Deezer/DeezerMetadataService.cs | 63 +- allstarr/Services/IMusicMetadataService.cs | 5 + .../Services/Jellyfin/JellyfinProxyService.cs | 205 +- .../Jellyfin/JellyfinResponseBuilder.cs | 170 +- allstarr/Services/Lyrics/LrclibService.cs | 6 + .../Services/Lyrics/LyricsOrchestrator.cs | 238 ++ allstarr/Services/Lyrics/LyricsPlusService.cs | 258 ++ .../Services/Lyrics/LyricsPrefetchService.cs | 2 +- .../MusicBrainz/MusicBrainzService.cs | 100 + .../Services/Qobuz/QobuzDownloadService.cs | 9 - .../Services/Qobuz/QobuzMetadataService.cs | 37 +- .../Services/Scrobbling/IScrobblingService.cs | 46 + .../Scrobbling/LastFmScrobblingService.cs | 458 ++ .../ListenBrainzScrobblingService.cs | 320 ++ .../Services/Scrobbling/ScrobblingHelper.cs | 261 ++ .../Scrobbling/ScrobblingOrchestrator.cs | 338 ++ .../Spotify/SpotifyMappingMigrationService.cs | 167 + .../Services/Spotify/SpotifyMappingService.cs | 394 ++ .../SpotifyMappingValidationService.cs | 332 ++ .../Spotify/SpotifyMissingTracksFetcher.cs | 8 +- .../Spotify/SpotifyPlaylistFetcher.cs | 83 + .../Spotify/SpotifyTrackMatchingService.cs | 1048 ++++- .../SquidWTF/SquidWTFDownloadService.cs | 11 - .../SquidWTF/SquidWTFMetadataService.cs | 570 ++- .../SquidWTF/SquidWTFStartupValidator.cs | 25 +- .../Services/Subsonic/SubsonicModelMapper.cs | 8 +- .../Subsonic/SubsonicResponseBuilder.cs | 12 +- allstarr/allstarr.csproj | 10 + allstarr/appsettings.json | 3 + allstarr/wwwroot/app.js | 16 + allstarr/wwwroot/index.html | 3036 +++++++++++++- allstarr/wwwroot/js/api.js | 345 ++ allstarr/wwwroot/js/helpers.js | 402 ++ allstarr/wwwroot/js/main.js | 1228 ++++++ allstarr/wwwroot/js/modals.js | 17 + allstarr/wwwroot/js/ui.js | 377 ++ allstarr/wwwroot/js/utils.js | 63 + allstarr/wwwroot/spotify-mappings.html | 411 ++ allstarr/wwwroot/spotify-mappings.js | 362 ++ allstarr/wwwroot/styles.css | 507 +++ docker-compose.yml | 14 + 114 files changed, 32947 insertions(+), 1263 deletions(-) create mode 100644 ARCHITECTURE.md create mode 100644 CLIENTS.md create mode 100644 CONFIGURATION.md create mode 100644 CONTRIBUTING.md create mode 100644 allstarr.Tests/ApiKeyAuthFilterTests.cs create mode 100644 allstarr.Tests/EnvMigrationServiceTests.cs create mode 100644 allstarr.Tests/EnvironmentVariableParsingTests.cs create mode 100644 allstarr.Tests/InputValidationTests.cs create mode 100644 allstarr.Tests/JavaScriptSyntaxTests.cs create mode 100644 allstarr.Tests/LastFmSignatureTests.cs create mode 100644 allstarr.Tests/PathHelperExtraTests.cs create mode 100644 allstarr.Tests/ScrobblingAdminControllerTests.cs create mode 100644 allstarr.Tests/ScrobblingHelperTests.cs create mode 100644 allstarr.Tests/SpotifyMappingServiceTests.cs create mode 100644 allstarr.Tests/SpotifyMappingValidationServiceTests.cs create mode 100644 allstarr.Tests/WebSocketProxyMiddlewareTests.cs create mode 100644 allstarr/AppVersion.cs create mode 100644 allstarr/Controllers/ConfigController.cs create mode 100644 allstarr/Controllers/DiagnosticsController.cs create mode 100644 allstarr/Controllers/DownloadsController.cs create mode 100644 allstarr/Controllers/Helpers.cs create mode 100644 allstarr/Controllers/JellyfinAdminController.cs create mode 100644 allstarr/Controllers/JellyfinController.Audio.cs create mode 100644 allstarr/Controllers/JellyfinController.Authentication.cs create mode 100644 allstarr/Controllers/JellyfinController.Lyrics.cs create mode 100644 allstarr/Controllers/JellyfinController.PlaybackSessions.cs create mode 100644 allstarr/Controllers/JellyfinController.PlaylistHandler.cs create mode 100644 allstarr/Controllers/JellyfinController.Search.cs create mode 100644 allstarr/Controllers/JellyfinController.Spotify.cs create mode 100644 allstarr/Controllers/LyricsController.cs create mode 100644 allstarr/Controllers/MappingController.cs create mode 100644 allstarr/Controllers/PlaylistController.cs create mode 100644 allstarr/Controllers/ScrobblingAdminController.cs create mode 100644 allstarr/Controllers/SpotifyAdminController.cs create mode 100644 allstarr/Middleware/RequestLoggingMiddleware.cs create mode 100644 allstarr/Models/Admin/AdminDtos.cs create mode 100644 allstarr/Models/Scrobbling/PlaybackSession.cs create mode 100644 allstarr/Models/Scrobbling/ScrobbleResult.cs create mode 100644 allstarr/Models/Scrobbling/ScrobbleTrack.cs create mode 100644 allstarr/Models/Settings/ScrobblingSettings.cs create mode 100644 allstarr/Models/Spotify/SpotifyTrackMapping.cs create mode 100644 allstarr/Services/Admin/AdminHelperService.cs create mode 100644 allstarr/Services/Common/AuthHeaderHelper.cs create mode 100644 allstarr/Services/Common/CacheKeyBuilder.cs create mode 100644 allstarr/Services/Common/ExplicitContentFilter.cs create mode 100644 allstarr/Services/Common/RetryHelper.cs create mode 100644 allstarr/Services/Scrobbling/IScrobblingService.cs create mode 100644 allstarr/Services/Scrobbling/LastFmScrobblingService.cs create mode 100644 allstarr/Services/Scrobbling/ListenBrainzScrobblingService.cs create mode 100644 allstarr/Services/Scrobbling/ScrobblingHelper.cs create mode 100644 allstarr/Services/Scrobbling/ScrobblingOrchestrator.cs create mode 100644 allstarr/Services/Spotify/SpotifyMappingMigrationService.cs create mode 100644 allstarr/Services/Spotify/SpotifyMappingService.cs create mode 100644 allstarr/Services/Spotify/SpotifyMappingValidationService.cs create mode 100644 allstarr/wwwroot/app.js create mode 100644 allstarr/wwwroot/js/api.js create mode 100644 allstarr/wwwroot/js/helpers.js create mode 100644 allstarr/wwwroot/js/main.js create mode 100644 allstarr/wwwroot/js/modals.js create mode 100644 allstarr/wwwroot/js/ui.js create mode 100644 allstarr/wwwroot/js/utils.js create mode 100644 allstarr/wwwroot/spotify-mappings.html create mode 100644 allstarr/wwwroot/spotify-mappings.js create mode 100644 allstarr/wwwroot/styles.css diff --git a/.env.example b/.env.example index 9c95d01..30180b1 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ # ===== BACKEND SELECTION ===== # Choose which media server backend to use: Subsonic or Jellyfin -BACKEND_TYPE=Subsonic +BACKEND_TYPE=Jellyfin # ===== REDIS CACHE (REQUIRED) ===== # Redis is the primary cache for all runtime data (search results, playlists, lyrics, etc.) @@ -216,3 +216,61 @@ SPOTIFY_API_PREFER_ISRC_MATCHING=true # This service is automatically started in docker-compose # Leave as default unless running a custom deployment SPOTIFY_LYRICS_API_URL=http://spotify-lyrics:8080 + +# ===== SCROBBLING (LAST.FM, LISTENBRAINZ) ===== +# Scrobble your listening history to Last.fm and/or ListenBrainz +# Tracks are scrobbled when you listen to at least half the track or 4 minutes (whichever comes first) +# Tracks shorter than 30 seconds are not scrobbled (per Last.fm rules) + +# Enable scrobbling globally (default: false) +SCROBBLING_ENABLED=false + +# Enable scrobbling for local library tracks (default: false) +# RECOMMENDED: Keep this disabled and use native Jellyfin plugins instead: +# - Last.fm: https://github.com/danielfariati/jellyfin-plugin-lastfm +# - ListenBrainz: https://github.com/lyarenei/jellyfin-plugin-listenbrainz +# This ensures Allstarr only scrobbles external tracks (Spotify, Deezer, Qobuz) +SCROBBLING_LOCAL_TRACKS_ENABLED=false + +# ===== LAST.FM SCROBBLING ===== +# Enable Last.fm scrobbling (default: false) +SCROBBLING_LASTFM_ENABLED=false + +# Last.fm API credentials (OPTIONAL - uses hardcoded credentials by default) +# Only set these if you want to use your own API account +# Get from: https://www.last.fm/api/account/create +SCROBBLING_LASTFM_API_KEY= +SCROBBLING_LASTFM_SHARED_SECRET= + +# Last.fm username and password (for authentication) +# Password is only used to obtain session key via Mobile Authentication +# IMPORTANT: Do NOT quote passwords in Docker Compose .env files! +# Docker Compose handles special characters correctly without quotes. +SCROBBLING_LASTFM_USERNAME= +SCROBBLING_LASTFM_PASSWORD= + +# Last.fm session key (automatically obtained via authentication) +# This key never expires unless you revoke it on Last.fm +# Use the Admin UI (Scrobbling tab) to authenticate and get your session key +SCROBBLING_LASTFM_SESSION_KEY= + +# ===== LISTENBRAINZ SCROBBLING ===== +# Enable ListenBrainz scrobbling (default: false) +# Only scrobbles external tracks (Spotify, Deezer, Qobuz) - local library tracks are not scrobbled +SCROBBLING_LISTENBRAINZ_ENABLED=false + +# ListenBrainz user token (get from https://listenbrainz.org/settings/) +# To get your token: +# 1. Sign up or log in at https://listenbrainz.org +# 2. Go to https://listenbrainz.org/settings/ +# 3. Copy your User Token +SCROBBLING_LISTENBRAINZ_USER_TOKEN= + +# ===== DEBUG SETTINGS ===== +# Enable detailed request logging (default: false) +# When enabled, logs every incoming HTTP request with full details: +# - Method, path, query string +# - Headers (auth tokens are masked) +# - Response status and timing +# Useful for debugging client issues and seeing what API calls are being made +DEBUG_LOG_ALL_REQUESTS=false diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..c9a581b --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,184 @@ +# Architecture + +This document describes the technical architecture of Allstarr. + +## System Architecture + +``` + ┌─────────────────┐ + ┌───▶│ Jellyfin │ +┌─────────────────┐ ┌──────────────────┐ │ │ Server │ +│ Music Client │────▶│ Allstarr │───┤ └─────────────────┘ +│ (Aonsoku, │◀────│ (Proxy) │◀──┤ +│ Finamp, etc.) │ │ │ │ ┌─────────────────┐ +└─────────────────┘ └────────┬─────────┘ └───▶│ Navidrome │ + │ │ (Subsonic) │ + ▼ └─────────────────┘ + ┌─────────────────┐ + │ Music Providers │ + │ - SquidWTF │ + │ - Deezer │ + │ - Qobuz │ + └─────────────────┘ +``` + +The proxy intercepts requests from your music client and: +1. Forwards library requests to your configured backend (Jellyfin or Subsonic) +2. Merges results with content from your music provider +3. Downloads and caches external tracks on-demand +4. Serves audio streams transparently + +**Note**: Only the controller matching your configured `BACKEND_TYPE` is registered at runtime, preventing route conflicts and ensuring clean API separation. + +## 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/admin/health` | Health check endpoint | +| `GET /api/admin/config` | Get current configuration | +| `POST /api/admin/config` | Update configuration | +| `POST /api/admin/cache/clear` | Clear cache | +| `GET /api/admin/status` | Get system status | +| `GET /api/admin/memory-stats` | Get memory usage statistics | +| `POST /api/admin/force-gc` | Force garbage collection | +| `GET /api/admin/sessions` | Get active sessions | +| `GET /api/admin/debug/endpoint-usage` | Get endpoint usage statistics | +| `DELETE /api/admin/debug/endpoint-usage` | Clear endpoint usage log | +| `GET /api/admin/squidwtf-base-url` | Get SquidWTF base URL | +| `GET /api/admin/playlists` | List all playlists with status | +| `GET /api/admin/playlists/{name}/tracks` | Get tracks for playlist | +| `POST /api/admin/playlists/refresh` | Refresh all playlists | +| `POST /api/admin/playlists/{name}/match` | Match tracks for playlist | +| `POST /api/admin/playlists/{name}/clear-cache` | Clear playlist cache | +| `POST /api/admin/playlists/match-all` | Match all playlists | +| `POST /api/admin/playlists` | Add new playlist | +| `DELETE /api/admin/playlists/{name}` | Remove playlist | +| `POST /api/admin/playlists/{name}/map` | Save manual track mapping | +| `GET /api/admin/jellyfin/search` | Search Jellyfin library | +| `GET /api/admin/jellyfin/track/{id}` | Get Jellyfin track details | +| `GET /api/admin/jellyfin/users` | List Jellyfin users | +| `GET /api/admin/jellyfin/libraries` | List Jellyfin libraries | +| `GET /api/admin/jellyfin/playlists` | List Jellyfin playlists | +| `POST /api/admin/jellyfin/playlists/{id}/link` | Link Jellyfin playlist to Spotify | +| `DELETE /api/admin/jellyfin/playlists/{name}/unlink` | Unlink playlist | +| `PUT /api/admin/playlists/{name}/schedule` | Update playlist sync schedule | +| `GET /api/admin/spotify/user-playlists` | Get Spotify user playlists | +| `GET /api/admin/spotify/sync` | Trigger Spotify sync | +| `GET /api/admin/spotify/match` | Trigger Spotify track matching | +| `POST /api/admin/spotify/clear-cache` | Clear Spotify cache | +| `GET /api/admin/spotify/mappings` | Get Spotify track mappings (paginated) | +| `GET /api/admin/spotify/mappings/{spotifyId}` | Get specific Spotify mapping | +| `POST /api/admin/spotify/mappings` | Save Spotify track mapping | +| `DELETE /api/admin/spotify/mappings/{spotifyId}` | Delete Spotify mapping | +| `GET /api/admin/spotify/mappings/stats` | Get Spotify mapping statistics | +| `GET /api/admin/downloads` | List kept downloads | +| `DELETE /api/admin/downloads` | Delete kept file | +| `GET /api/admin/downloads/file` | Download specific file | +| `GET /api/admin/downloads/all` | Download all files as zip | +| `GET /api/admin/scrobbling/status` | Get scrobbling status | +| `POST /api/admin/scrobbling/lastfm/authenticate` | Authenticate Last.fm | +| `GET /api/admin/scrobbling/lastfm/auth-url` | Get Last.fm auth URL | +| `POST /api/admin/scrobbling/lastfm/get-session` | Get Last.fm session key | +| `POST /api/admin/scrobbling/lastfm/test` | Test Last.fm connection | +| `POST /api/admin/scrobbling/lastfm/debug-auth` | Debug Last.fm auth | +| `POST /api/admin/scrobbling/listenbrainz/validate` | Validate ListenBrainz token | +| `POST /api/admin/scrobbling/listenbrainz/test` | Test ListenBrainz connection | + +All other Jellyfin API endpoints are passed through unchanged. + +### Subsonic Backend + +The proxy implements the Subsonic API with streaming provider integration: + +| Endpoint | Description | +|----------|-------------| +| `GET /rest/search3` | Merged search results from Navidrome + streaming provider | +| `GET /rest/stream` | Streams audio, downloading from provider if needed | +| `GET /rest/getSong` | Returns song details (local or from provider) | +| `GET /rest/getAlbum` | Returns album with tracks from both sources | +| `GET /rest/getArtist` | Returns artist with albums from both sources | +| `GET /rest/getCoverArt` | Proxies cover art for external content | +| `GET /rest/star` | Stars items; triggers automatic playlist download for external playlists | + +All other Subsonic API endpoints are passed through to Navidrome unchanged. + +## External ID Format + +External (streaming provider) content uses typed IDs: + +| Type | Format | Example | +|------|--------|---------| +| Song | `ext-{provider}-song-{id}` | `ext-deezer-song-123456`, `ext-qobuz-song-789012` | +| Album | `ext-{provider}-album-{id}` | `ext-deezer-album-789012`, `ext-qobuz-album-456789` | +| Artist | `ext-{provider}-artist-{id}` | `ext-deezer-artist-259`, `ext-qobuz-artist-123` | + +Legacy format `ext-deezer-{id}` is also supported (assumes song type). + +## Download Folder Structure + +All downloads are organized under a single base directory (default: `./downloads`): + +``` +downloads/ +├── 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 +``` + +**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 + +Downloaded files include: +- **Basic**: Title, Artist, Album, Album Artist +- **Track Info**: Track Number, Total Tracks, Disc Number +- **Dates**: Year, Release Date +- **Audio**: BPM, Duration +- **Identifiers**: ISRC (in comments) +- **Credits**: Contributors/Composers +- **Visual**: Embedded cover art (high resolution) +- **Rights**: Copyright, Label \ No newline at end of file diff --git a/CLIENTS.md b/CLIENTS.md new file mode 100644 index 0000000..42c63dd --- /dev/null +++ b/CLIENTS.md @@ -0,0 +1,49 @@ +# Client Compatibility + +This document lists compatible and incompatible music clients for Allstarr. + +## Jellyfin Clients + +[Jellyfin](https://jellyfin.org/) is a free and open-source media server. Allstarr connects via the Jellyfin API using your Jellyfin user login. (I plan to move this to api key if possible) + +### Compatible Jellyfin Clients + +- [Feishin](https://github.com/jeffvli/feishin) (Mac/Windows/Linux) +image + + +- [Musiver](https://music.aqzscn.cn/en/) (Android/iOS/Windows/Android) +image + + +- [Finamp](https://github.com/jmshrv/finamp) (Android/iOS) + +_Working on getting more currently_ + +## Subsonic/Navidrome Clients + +[Navidrome](https://www.navidrome.org/) and other Subsonic-compatible servers are supported via the Subsonic API. + +### Compatible Subsonic Clients + +#### PC +- [Aonsoku](https://github.com/victoralvesf/aonsoku) +- [Feishin](https://github.com/jeffvli/feishin) +- [Subplayer](https://github.com/peguerosdc/subplayer) +- [Aurial](https://github.com/shrimpza/aurial) + +#### Android +- [Tempus](https://github.com/eddyizm/tempus) +- [Substreamer](https://substreamerapp.com/) + +#### iOS +- [Narjo](https://www.reddit.com/r/NarjoApp/) +- [Arpeggi](https://www.reddit.com/r/arpeggiApp/) + +> **Want to improve client compatibility?** Pull requests are welcome! + +## Incompatible Clients + +These clients are **not compatible** with Allstarr due to architectural limitations: + +- [Symfonium](https://symfonium.app/) - Uses offline-first architecture and never queries the server for searches, making streaming provider integration impossible. [See details](https://support.symfonium.app/t/suggestions-on-search-function/1121/) diff --git a/CONFIGURATION.md b/CONFIGURATION.md new file mode 100644 index 0000000..867adfa --- /dev/null +++ b/CONFIGURATION.md @@ -0,0 +1,315 @@ +# Configuration Guide + +This document provides detailed configuration options for Allstarr. + +## Advanced Configuration + +### Backend Selection + +| Setting | Description | +|---------|-------------| +| `Backend:Type` | Backend type: `Subsonic` or `Jellyfin` (default: `Subsonic`) | + +### Jellyfin Settings + +| Setting | Description | +|---------|-------------| +| `Jellyfin:Url` | URL of your Jellyfin server | +| `Jellyfin:ApiKey` | API key (get from Jellyfin Dashboard > API Keys) | +| `Jellyfin:UserId` | User ID for library access | +| `Jellyfin:LibraryId` | Music library ID (optional, auto-detected) | +| `Jellyfin:MusicService` | Music provider: `SquidWTF`, `Deezer`, or `Qobuz` | + +### Subsonic Settings + +| Setting | Description | +|---------|-------------| +| `Subsonic:Url` | URL of your Navidrome/Subsonic server | +| `Subsonic:MusicService` | Music provider: `SquidWTF`, `Deezer`, or `Qobuz` (default: `SquidWTF`) | + +### Shared Settings + +| Setting | Description | +|---------|-------------| +| `Library:DownloadPath` | Directory where downloaded songs are stored | +| `*:ExplicitFilter` | Content filter: `All`, `ExplicitOnly`, or `CleanOnly` | +| `*:DownloadMode` | Download mode: `Track` or `Album` | +| `*:StorageMode` | Storage mode: `Permanent` or `Cache` | +| `*:CacheDurationHours` | Cache expiration time in hours | +| `*:EnableExternalPlaylists` | Enable external playlist support | + +### SquidWTF Settings + +| Setting | Description | +|---------|-------------| +| `SquidWTF:Quality` | Preferred audio quality: `FLAC`, `MP3_320`, `MP3_128`. If not specified, the highest available quality for your account will be used | + +**Load Balancing & Reliability:** + +SquidWTF uses a round-robin load balancing strategy across multiple backup API endpoints to distribute requests evenly and prevent overwhelming any single provider. Each request automatically rotates to the next endpoint in the pool, with automatic fallback to other endpoints if one fails. This ensures high availability and prevents rate limiting by distributing load across multiple providers. + +### Deezer Settings + +| Setting | Description | +|---------|-------------| +| `Deezer:Arl` | Your Deezer ARL token (required if using Deezer) | +| `Deezer:ArlFallback` | Backup ARL token if primary fails | +| `Deezer:Quality` | Preferred audio quality: `FLAC`, `MP3_320`, `MP3_128`. If not specified, the highest available quality for your account will be used | + +### Qobuz Settings + +| Setting | Description | +|---------|-------------| +| `Qobuz:UserAuthToken` | Your Qobuz User Auth Token (required if using Qobuz) - [How to get it](https://github.com/V1ck3s/octo-fiesta/wiki/Getting-Qobuz-Credentials-(User-ID-&-Token)) | +| `Qobuz:UserId` | Your Qobuz User ID (required if using Qobuz) | +| `Qobuz:Quality` | Preferred audio quality: `FLAC`, `FLAC_24_HIGH`, `FLAC_24_LOW`, `FLAC_16`, `MP3_320`. If not specified, the highest available quality will be used | + +### External Playlists + +Allstarr supports discovering and downloading playlists from your streaming providers (SquidWTF, Deezer, and Qobuz). + +| Setting | Description | +|---------|-------------| +| `Subsonic:EnableExternalPlaylists` | Enable/disable external playlist support (default: `true`) | +| `Subsonic:PlaylistsDirectory` | Directory name where M3U playlist files are created (default: `playlists`) | + +**How it works:** +1. Search for playlists from an external provider using the global search in your Subsonic client +2. When you "star" (favorite) a playlist, Allstarr automatically downloads all tracks +3. An M3U playlist file is created in `{DownloadPath}/playlists/` with relative paths to downloaded tracks +4. Individual tracks are added to the M3U as they are played or downloaded + +**Environment variable:** +```bash +# To disable playlists +Subsonic__EnableExternalPlaylists=false +``` + +> **Note**: Due to client-side filtering, playlists from streaming providers may not appear in the "Playlists" tab of some clients, but will show up in global search results. + +### Spotify Playlist Injection (Jellyfin Only) + +Allstarr automatically fills your Spotify playlists (like Release Radar and Discover Weekly) with tracks from your configured streaming provider (SquidWTF, Deezer, or Qobuz). This works by intercepting playlists created by the Jellyfin Spotify Import plugin and matching missing tracks with your streaming service. + +image + + +#### Prerequisites + +1. **Install the Jellyfin Spotify Import Plugin** + - Navigate to Jellyfin Dashboard → Plugins → Catalog + - Search for "Spotify Import" by Viperinius + - Install and restart Jellyfin + - Plugin repository: [Viperinius/jellyfin-plugin-spotify-import](https://github.com/Viperinius/jellyfin-plugin-spotify-import) + +2. **Configure the Spotify Import Plugin** + - Go to Jellyfin Dashboard → Plugins → Spotify Import + - Connect your Spotify account + - Select which playlists to sync (e.g., Release Radar, Discover Weekly) + - Set a sync schedule (the plugin will create playlists in Jellyfin) + +3. **Configure Allstarr** + - Enable Spotify Import in Allstarr (see configuration below) + - Link your Jellyfin playlists to Spotify playlists via the Web UI + - Uses your existing `JELLYFIN_URL` and `JELLYFIN_API_KEY` settings + +#### Configuration + +| Setting | Description | +|---------|-------------| +| `SpotifyImport:Enabled` | Enable Spotify playlist injection (default: `false`) | +| `SpotifyImport:MatchingIntervalHours` | How often to run track matching in hours (default: 24, set to 0 for startup only) | +| `SpotifyImport:Playlists` | JSON array of playlists (managed via Web UI) | + +**Environment variables example:** +```bash +# Enable the feature +SPOTIFY_IMPORT_ENABLED=true + +# Matching interval (24 hours = once per day) +SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS=24 + +# Playlists (use Web UI to manage instead of editing manually) +SPOTIFY_IMPORT_PLAYLISTS=[["Discover Weekly","37i9dQZEVXcV6s7Dm7RXsU","first"],["Release Radar","37i9dQZEVXbng2vDHnfQlC","first"]] +``` + +#### How It Works + +1. **Spotify Import Plugin Runs** + - Plugin fetches your Spotify playlists + - Creates/updates playlists in Jellyfin with tracks already in your library + - Generates "missing tracks" JSON files for songs not found locally + +2. **Allstarr Matches Tracks** (on startup + every 24 hours by default) + - Reads missing tracks files from the Jellyfin plugin + - For each missing track, searches your streaming provider (SquidWTF, Deezer, or Qobuz) + - Uses fuzzy matching to find the best match (title + artist similarity) + - Rate-limited to avoid overwhelming the service (150ms delay between searches) + - Pre-builds playlist cache for instant loading + +3. **You Open the Playlist in Jellyfin** + - Allstarr intercepts the request + - Returns a merged list: local tracks + matched streaming tracks + - Loads instantly from cache! + +4. **You Play a Track** + - Local tracks stream from Jellyfin normally + - Matched tracks download from streaming provider on-demand + - Downloaded tracks are saved to your library for future use + +#### Manual API Triggers + +You can manually trigger operations via the admin API: + +```bash +# Get API key from your .env file +API_KEY="your-api-key-here" + +# Fetch missing tracks from Jellyfin plugin +curl "http://localhost:5274/spotify/sync?api_key=$API_KEY" + +# Trigger track matching (searches streaming provider) +curl "http://localhost:5274/spotify/match?api_key=$API_KEY" + +# Match all playlists (refresh all matches) +curl "http://localhost:5274/spotify/match-all?api_key=$API_KEY" + +# Clear cache and rebuild +curl "http://localhost:5274/spotify/clear-cache?api_key=$API_KEY" + +# Refresh specific playlist +curl "http://localhost:5274/spotify/refresh-playlist?playlistId=PLAYLIST_ID&api_key=$API_KEY" +``` + +#### Web UI Management + +The easiest way to manage Spotify playlists is through the Web UI at `http://localhost:5275`: + +1. **Link Playlists Tab**: Link Jellyfin playlists to Spotify playlists +2. **Active Playlists Tab**: View status, trigger matching, and manage playlists +3. **Configuration Tab**: Enable/disable Spotify Import and adjust settings + +#### Troubleshooting + +**Playlists are empty:** +- Check that the Spotify Import plugin is running and creating playlists +- Verify playlists are linked in the Web UI +- Check logs: `docker-compose logs -f allstarr | grep -i spotify` + +**Tracks aren't matching:** +- Ensure your streaming provider is configured (`MUSIC_SERVICE`, credentials) +- Manually trigger matching via Web UI or API +- Check that the Jellyfin plugin generated missing tracks files + +**Performance:** +- Matching runs in background with rate limiting (150ms between searches) +- First match may take a few minutes for large playlists +- Subsequent loads are instant (served from cache) + +#### Notes + +- Uses your existing `JELLYFIN_URL` and `JELLYFIN_API_KEY` settings +- Matched tracks cached for fast loading +- Missing tracks cache persists across restarts (Redis + file cache) +- Rate limiting prevents overwhelming your streaming provider +- Only works with Jellyfin backend (not Subsonic/Navidrome) + +### Scrobbling (Last.fm & ListenBrainz) + +Track your listening history to Last.fm and/or ListenBrainz. Allstarr automatically scrobbles tracks when you listen to at least half the track or 4 minutes (whichever comes first). + +#### Configuration + +| Setting | Description | +|---------|-------------| +| `Scrobbling:Enabled` | Enable scrobbling globally (default: `false`) | +| `Scrobbling:LocalTracksEnabled` | Enable scrobbling for local library tracks (default: `false`) - See note below | +| `Scrobbling:LastFm:Enabled` | Enable Last.fm scrobbling (default: `false`) | +| `Scrobbling:LastFm:Username` | Your Last.fm username | +| `Scrobbling:LastFm:Password` | Your Last.fm password (only used for authentication) | +| `Scrobbling:LastFm:SessionKey` | Last.fm session key (auto-generated via Web UI) | +| `Scrobbling:ListenBrainz:Enabled` | Enable ListenBrainz scrobbling (default: `false`) | +| `Scrobbling:ListenBrainz:UserToken` | Your ListenBrainz user token | + +**Environment variables example:** +```bash +# Enable scrobbling globally +SCROBBLING_ENABLED=true + +# Local track scrobbling (RECOMMENDED: keep disabled) +# Use native Jellyfin plugins instead: +# - Last.fm: https://github.com/danielfariati/jellyfin-plugin-lastfm +# - ListenBrainz: https://github.com/lyarenei/jellyfin-plugin-listenbrainz +SCROBBLING_LOCAL_TRACKS_ENABLED=false + +# Last.fm configuration +SCROBBLING_LASTFM_ENABLED=true +SCROBBLING_LASTFM_USERNAME=your-username +SCROBBLING_LASTFM_PASSWORD=your-password +# Session key is auto-generated via Web UI + +# ListenBrainz configuration +SCROBBLING_LISTENBRAINZ_ENABLED=true +SCROBBLING_LISTENBRAINZ_USER_TOKEN=your-token-here +``` + +#### Setup via Web UI (Recommended) + +The easiest way to configure scrobbling is through the Web UI at `http://localhost:5275`: + +**Last.fm Setup:** +1. Navigate to the **Scrobbling** tab +2. Toggle "Last.fm Enabled" to enable +3. Click "Edit" next to Username and enter your Last.fm username +4. Click "Edit" next to Password and enter your Last.fm password +5. Click "Authenticate & Save" to generate a session key +6. Restart the container for changes to take effect + +**ListenBrainz Setup:** +1. Get your user token from [ListenBrainz Settings](https://listenbrainz.org/settings/) +2. Navigate to the **Scrobbling** tab in Allstarr Web UI +3. Toggle "ListenBrainz Enabled" to enable +4. Click "Validate & Save Token" and enter your token +5. Restart the container for changes to take effect + +#### Important Notes + +- **Local Track Scrobbling**: By default, Allstarr does NOT scrobble local library tracks. It's recommended to use native Jellyfin plugins for local track scrobbling: + - [Last.fm Plugin](https://github.com/danielfariati/jellyfin-plugin-lastfm) + - [ListenBrainz Plugin](https://github.com/lyarenei/jellyfin-plugin-listenbrainz) + + This ensures Allstarr only scrobbles external tracks (Spotify, Deezer, Qobuz) that aren't in your local library. + +- **Last.fm**: Scrobbles both local library tracks (if enabled) and external tracks +- **ListenBrainz**: Only scrobbles external tracks (not local library tracks) to maintain data quality +- Tracks shorter than 30 seconds are not scrobbled (per Last.fm rules) +- "Now Playing" status is updated when you start playing a track +- Scrobbles are submitted when you reach the scrobble threshold (50% or 4 minutes) + +#### Troubleshooting + +**Last.fm authentication fails:** +- Verify your username and password are correct +- Check that there are no extra spaces in your credentials +- Try re-authenticating via the Web UI + +**ListenBrainz token invalid:** +- Make sure you copied the entire token from ListenBrainz settings +- Check that there are no extra spaces or newlines +- Try generating a new token by clicking "Reset token" on ListenBrainz + +**Tracks not scrobbling:** +- Verify scrobbling is enabled globally (`SCROBBLING_ENABLED=true`) +- Check that the specific service is enabled (Last.fm or ListenBrainz) +- Ensure you're listening to at least 50% of the track or 4 minutes +- Check container logs: `docker-compose logs -f allstarr | grep -i scrobbl` + +### Getting Credentials + +#### Deezer ARL Token + +See the [Wiki guide](https://github.com/V1ck3s/octo-fiesta/wiki/Getting-Deezer-Credentials-(ARL-Token)) for detailed instructions on obtaining your Deezer ARL token. + +#### Qobuz Credentials + +See the [Wiki guide](https://github.com/V1ck3s/octo-fiesta/wiki/Getting-Qobuz-Credentials-(User-ID-&-Token)) for detailed instructions on obtaining your Qobuz User ID and User Auth Token. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a78661f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,267 @@ +# Contributing to Allstarr + +We welcome contributions! Here's how to get started: + +## Development Setup + +1. **Clone the repository** + ```bash + git clone https://github.com/SoPat712/allstarr.git + cd allstarr + ``` + +2. **Build and run locally** + + Using Docker (recommended for development): + ```bash + # Copy and configure environment + cp .env.example .env + vi .env + + # Build and start with local changes + docker-compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build + + # View logs + docker-compose logs -f + ``` + + Or using .NET directly: + ```bash + # Restore dependencies + dotnet restore + + # Run the application + cd allstarr + dotnet run + ``` + +3. **Run tests** + ```bash + dotnet test + ``` + +## Making Changes + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/amazing-feature`) +3. Make your changes +4. Run tests to ensure everything works +5. Commit your changes (`git commit -m 'Add amazing feature'`) +6. Push to your fork (`git push origin feature/amazing-feature`) +7. Open a Pull Request + +## Code Style + +- Follow existing code patterns and conventions +- Add tests for new features +- Update documentation as needed +- Keep commits feature focused + +## Testing + +All changes should include appropriate tests: +```bash +# Run all tests +dotnet test + +# Run specific test file +dotnet test --filter "FullyQualifiedName~SubsonicProxyServiceTests" + +# Run with coverage +dotnet test --collect:"XPlat Code Coverage" +``` + +## Build + +```bash +dotnet build +``` + +## Run Tests + +```bash +dotnet test +``` + +## Project Structure + +``` +allstarr/ +├── Controllers/ +│ ├── AdminController.cs # Admin health check +│ ├── ConfigController.cs # Configuration management +│ ├── DiagnosticsController.cs # System diagnostics & debugging +│ ├── DownloadsController.cs # Download management +│ ├── JellyfinAdminController.cs # Jellyfin admin operations +│ ├── JellyfinController.cs # Jellyfin API proxy +│ ├── LyricsController.cs # Lyrics management +│ ├── MappingController.cs # Track mapping management +│ ├── PlaylistController.cs # Playlist operations & CRUD +│ ├── ScrobblingAdminController.cs # Scrobbling configuration +│ ├── SpotifyAdminController.cs # Spotify admin operations +│ └── SubSonicController.cs # Subsonic API proxy +├── Filters/ +│ ├── AdminPortFilter.cs # Admin port access control +│ ├── ApiKeyAuthFilter.cs # API key authentication +│ └── JellyfinAuthFilter.cs # Jellyfin authentication +├── Middleware/ +│ ├── AdminStaticFilesMiddleware.cs # Admin UI static file serving +│ ├── GlobalExceptionHandler.cs # Global error handling +│ └── WebSocketProxyMiddleware.cs # WebSocket proxying for Jellyfin +├── Models/ +│ ├── Admin/ # Admin request/response models +│ │ └── AdminDtos.cs +│ ├── Domain/ # Domain entities +│ │ ├── Album.cs +│ │ ├── Artist.cs +│ │ └── Song.cs +│ ├── Download/ # Download-related models +│ │ ├── DownloadInfo.cs +│ │ └── DownloadStatus.cs +│ ├── Lyrics/ +│ │ └── LyricsInfo.cs +│ ├── Scrobbling/ # Scrobbling models +│ │ ├── PlaybackSession.cs +│ │ ├── ScrobbleResult.cs +│ │ └── ScrobbleTrack.cs +│ ├── Search/ +│ │ └── SearchResult.cs +│ ├── Settings/ # Configuration models +│ │ ├── CacheSettings.cs +│ │ ├── DeezerSettings.cs +│ │ ├── JellyfinSettings.cs +│ │ ├── MusicBrainzSettings.cs +│ │ ├── QobuzSettings.cs +│ │ ├── RedisSettings.cs +│ │ ├── ScrobblingSettings.cs +│ │ ├── SpotifyApiSettings.cs +│ │ ├── SpotifyImportSettings.cs +│ │ ├── SquidWTFSettings.cs +│ │ └── SubsonicSettings.cs +│ ├── Spotify/ # Spotify-specific models +│ │ ├── MissingTrack.cs +│ │ ├── SpotifyPlaylistTrack.cs +│ │ └── SpotifyTrackMapping.cs +│ └── Subsonic/ # Subsonic-specific models +│ ├── ExternalPlaylist.cs +│ └── ScanStatus.cs +├── Services/ +│ ├── Admin/ # Admin helper services +│ │ └── AdminHelperService.cs +│ ├── Common/ # Shared utilities +│ │ ├── AuthHeaderHelper.cs # Auth header handling +│ │ ├── BaseDownloadService.cs # Template method base class +│ │ ├── CacheCleanupService.cs # Cache cleanup background service +│ │ ├── CacheExtensions.cs # Cache extension methods +│ │ ├── CacheKeyBuilder.cs # Type-safe cache key generation +│ │ ├── CacheWarmingService.cs # Startup cache warming +│ │ ├── EndpointBenchmarkService.cs # Endpoint performance benchmarking +│ │ ├── EnvMigrationService.cs # Environment migration utilities +│ │ ├── Error.cs # Error types +│ │ ├── ExplicitContentFilter.cs # Explicit content filtering +│ │ ├── 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 +│ │ ├── RedisPersistenceService.cs # Redis persistence monitoring +│ │ ├── Result.cs # Result pattern +│ │ ├── RetryHelper.cs # Retry logic with exponential backoff +│ │ └── RoundRobinFallbackHelper.cs # Load balancing and failover +│ ├── Deezer/ # Deezer provider +│ │ ├── DeezerDownloadService.cs +│ │ ├── DeezerMetadataService.cs +│ │ └── DeezerStartupValidator.cs +│ ├── Jellyfin/ # Jellyfin integration +│ │ ├── JellyfinModelMapper.cs # Model mapping +│ │ ├── JellyfinProxyService.cs # Request proxying +│ │ ├── JellyfinResponseBuilder.cs # Response building +│ │ ├── JellyfinSessionManager.cs # Session management +│ │ └── JellyfinStartupValidator.cs # Startup validation +│ ├── Local/ # Local library +│ │ ├── ILocalLibraryService.cs +│ │ └── LocalLibraryService.cs +│ ├── Lyrics/ # Lyrics services +│ │ ├── LrclibService.cs # LRCLIB lyrics +│ │ ├── LyricsOrchestrator.cs # Lyrics orchestration +│ │ ├── LyricsPlusService.cs # LyricsPlus multi-source +│ │ ├── LyricsPrefetchService.cs # Background lyrics prefetching +│ │ ├── LyricsStartupValidator.cs # Lyrics validation +│ │ └── SpotifyLyricsService.cs # Spotify lyrics +│ ├── MusicBrainz/ +│ │ └── MusicBrainzService.cs # MusicBrainz metadata +│ ├── Qobuz/ # Qobuz provider +│ │ ├── QobuzBundleService.cs +│ │ ├── QobuzDownloadService.cs +│ │ ├── QobuzMetadataService.cs +│ │ └── QobuzStartupValidator.cs +│ ├── Scrobbling/ # Scrobbling services +│ │ ├── IScrobblingService.cs +│ │ ├── LastFmScrobblingService.cs +│ │ ├── ListenBrainzScrobblingService.cs +│ │ ├── ScrobblingHelper.cs +│ │ └── ScrobblingOrchestrator.cs +│ ├── Spotify/ # Spotify integration +│ │ ├── SpotifyApiClient.cs # Spotify API client +│ │ ├── SpotifyMappingMigrationService.cs # Mapping migration +│ │ ├── SpotifyMappingService.cs # Mapping management +│ │ ├── SpotifyMappingValidationService.cs # Mapping validation +│ │ ├── SpotifyMissingTracksFetcher.cs # Missing tracks fetcher +│ │ ├── SpotifyPlaylistFetcher.cs # Playlist fetcher +│ │ └── SpotifyTrackMatchingService.cs # Track matching +│ ├── SquidWTF/ # SquidWTF provider +│ │ ├── SquidWTFDownloadService.cs +│ │ ├── SquidWTFMetadataService.cs +│ │ └── SquidWTFStartupValidator.cs +│ ├── Subsonic/ # Subsonic API logic +│ │ ├── PlaylistSyncService.cs # Playlist synchronization +│ │ ├── SubsonicModelMapper.cs # Model mapping +│ │ ├── SubsonicProxyService.cs # Request proxying +│ │ ├── SubsonicRequestParser.cs # Request parsing +│ │ └── SubsonicResponseBuilder.cs # Response building +│ ├── Validation/ # Startup validation +│ │ ├── BaseStartupValidator.cs +│ │ ├── IStartupValidator.cs +│ │ ├── StartupValidationOrchestrator.cs +│ │ ├── SubsonicStartupValidator.cs +│ │ └── ValidationResult.cs +│ ├── IDownloadService.cs # Download interface +│ ├── IMusicMetadataService.cs # Metadata interface +│ └── StartupValidationService.cs +├── wwwroot/ # Admin UI static files +│ ├── js/ # JavaScript modules +│ ├── app.js # Main application logic +│ ├── index.html # Admin dashboard +│ ├── placeholder.png # Placeholder image +│ ├── spotify-mappings.html # Spotify mappings UI +│ ├── spotify-mappings.js # Spotify mappings logic +│ └── styles.css # Stylesheet +├── Program.cs # Application entry point +└── appsettings.json # Configuration + +allstarr.Tests/ +├── DeezerDownloadServiceTests.cs # Deezer download tests +├── DeezerMetadataServiceTests.cs # Deezer metadata tests +├── JellyfinResponseStructureTests.cs # Jellyfin response tests +├── LocalLibraryServiceTests.cs # Local library tests +├── QobuzDownloadServiceTests.cs # Qobuz download tests +├── SubsonicModelMapperTests.cs # Model mapping tests +├── SubsonicProxyServiceTests.cs # Proxy service tests +├── SubsonicRequestParserTests.cs # Request parser tests +└── SubsonicResponseBuilderTests.cs # Response builder tests +``` + +## Dependencies + +- **BouncyCastle.Cryptography** (v2.6.2) - Blowfish decryption for Deezer streams +- **Cronos** (v0.11.1) - Cron expression parsing for scheduled tasks +- **Microsoft.AspNetCore.OpenApi** (v9.0.4) - OpenAPI support +- **Otp.NET** (v1.4.1) - One-time password generation for Last.fm authentication +- **StackExchange.Redis** (v2.8.16) - Redis client for caching +- **Swashbuckle.AspNetCore** (v9.0.4) - Swagger/OpenAPI documentation +- **TagLibSharp** (v2.3.0) - ID3 tag and cover art embedding +- **xUnit** - Unit testing framework +- **Moq** - Mocking library for tests +- **FluentAssertions** - Fluent assertion library for tests diff --git a/README.md b/README.md index a432515..b74cc65 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,10 @@ vi .env # Edit with your settings # 3. Pull the latest image docker-compose pull -# 3. Start services +# 4. Start services docker-compose up -d -# 4. Check status +# 5. Check status docker-compose ps docker-compose logs -f ``` @@ -46,7 +46,10 @@ Allstarr includes a web UI for easy configuration and playlist management, acces - **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 +- **Lyrics**: Using multiple sources for lyrics - Jellyfin local, Spotify Lyrics API, LyricsPlus (multi-source), and LRCLib +- **Scrobbling**: Track your listening history to Last.fm and ListenBrainz with automatic scrobbling +- **Downloads Management**: View, download, and manage your kept files through the web UI +- **Diagnostics**: Monitor system performance, memory usage, cache statistics, and endpoint usage ### Quick Setup with Web UI @@ -76,7 +79,15 @@ 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). +<<<<<<< HEAD ### Nginx Proxy Setup (Required) +||||||| f68706f + + +### Nginx Proxy Setup (Required) +======= +### Nginx Proxy Setup (Optional) +>>>>>>> beta This service only exposes ports internally. You can use nginx to proxy to it, however PLEASE take significant precautions before exposing this! Everyone decides their own level of risk, but this is currently untested, potentially dangerous software, with almost unfettered access to your Jellyfin server. My recommendation is use Tailscale or something similar! @@ -130,6 +141,8 @@ This project brings together all the music streaming providers into one unified - **Album Enrichment**: Adds missing tracks to local albums from streaming providers - **Cover Art Proxy**: Serves cover art for external content - **Spotify Playlist Injection** (Jellyfin only): Intercepts Spotify Import plugin playlists (Release Radar, Discover Weekly) and fills them with tracks auto-matched from streaming providers +- **Lyrics Support**: Multi-source lyrics fetching from Jellyfin local files, Spotify Lyrics API (synchronized), LyricsPlus (multi-source aggregator), and LRCLib (community database) +- **Scrobbling Support**: Track your listening history to Last.fm and ListenBrainz ## Supported Backends @@ -177,6 +190,8 @@ These clients are **not compatible** with Allstarr due to architectural limitati - [Symfonium](https://symfonium.app/) - Uses offline-first architecture and never queries the server for searches, making streaming provider integration impossible. [See details](https://support.symfonium.app/t/suggestions-on-search-function/1121/) +See [CLIENTS.md](CLIENTS.md) for more detailed client information. + ## Supported Music Providers - **[SquidWTF](https://tidal.squid.wtf/)** - Quality: FLAC (Hi-Res 24-bit/192kHz & CD-Lossless 16-bit/44.1kHz), AAC @@ -190,10 +205,13 @@ Choose your preferred provider via the `MUSIC_SERVICE` environment variable. Add - A running media server: - **Jellyfin**: Any recent version with API access enabled - **Subsonic**: Navidrome or other Subsonic-compatible server +- **Docker and Docker Compose** (recommended) - includes Redis and Spotify Lyrics API sidecars + - Redis is used for caching (search results, playlists, lyrics, etc.) + - Spotify Lyrics API provides synchronized lyrics for Spotify tracks - Credentials for at least one music provider (IF NOT USING SQUIDWTF): - **Deezer**: ARL token from browser cookies - **Qobuz**: User ID + User Auth Token from browser localStorage ([see Wiki guide](https://github.com/V1ck3s/octo-fiesta/wiki/Getting-Qobuz-Credentials-(User-ID-&-Token))) -- Docker and Docker Compose (recommended) **or** [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) for manual installation +- **OR** [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) for manual installation (requires separate Redis setup) ## Configuration @@ -253,6 +271,7 @@ Choose your preferred provider via the `MUSIC_SERVICE` environment variable. Add > **Tip**: Make sure the `DOWNLOAD_PATH` points to a directory that your media server can scan, so downloaded songs appear in your library. +<<<<<<< HEAD ## Advanced Configuration ### Backend Selection @@ -508,6 +527,262 @@ The proxy intercepts requests from your music client and: 4. Serves audio streams transparently **Note**: Only the controller matching your configured `BACKEND_TYPE` is registered at runtime, preventing route conflicts and ensuring clean API separation. +||||||| f68706f +## Advanced Configuration + +### Backend Selection + +| Setting | Description | +|---------|-------------| +| `Backend:Type` | Backend type: `Subsonic` or `Jellyfin` (default: `Subsonic`) | + +### Jellyfin Settings + +| Setting | Description | +|---------|-------------| +| `Jellyfin:Url` | URL of your Jellyfin server | +| `Jellyfin:ApiKey` | API key (get from Jellyfin Dashboard > API Keys) | +| `Jellyfin:UserId` | User ID for library access | +| `Jellyfin:LibraryId` | Music library ID (optional, auto-detected) | +| `Jellyfin:MusicService` | Music provider: `SquidWTF`, `Deezer`, or `Qobuz` | + +### Subsonic Settings + +| Setting | Description | +|---------|-------------| +| `Subsonic:Url` | URL of your Navidrome/Subsonic server | +| `Subsonic:MusicService` | Music provider: `SquidWTF`, `Deezer`, or `Qobuz` (default: `SquidWTF`) | + +### Shared Settings + +| Setting | Description | +|---------|-------------| +| `Library:DownloadPath` | Directory where downloaded songs are stored | +| `*:ExplicitFilter` | Content filter: `All`, `ExplicitOnly`, or `CleanOnly` | +| `*:DownloadMode` | Download mode: `Track` or `Album` | +| `*:StorageMode` | Storage mode: `Permanent` or `Cache` | +| `*:CacheDurationHours` | Cache expiration time in hours | +| `*:EnableExternalPlaylists` | Enable external playlist support | + +### SquidWTF Settings + +| Setting | Description | +|---------|-------------| +| `SquidWTF:Quality` | Preferred audio quality: `FLAC`, `MP3_320`, `MP3_128`. If not specified, the highest available quality for your account will be used | + +**Load Balancing & Reliability:** + +SquidWTF uses a round-robin load balancing strategy across multiple backup API endpoints to distribute requests evenly and prevent overwhelming any single provider. Each request automatically rotates to the next endpoint in the pool, with automatic fallback to other endpoints if one fails. This ensures high availability and prevents rate limiting by distributing load across multiple providers. + +### Deezer Settings + +| Setting | Description | +|---------|-------------| +| `Deezer:Arl` | Your Deezer ARL token (required if using Deezer) | +| `Deezer:ArlFallback` | Backup ARL token if primary fails | +| `Deezer:Quality` | Preferred audio quality: `FLAC`, `MP3_320`, `MP3_128`. If not specified, the highest available quality for your account will be used | + +### Qobuz Settings + +| Setting | Description | +|---------|-------------| +| `Qobuz:UserAuthToken` | Your Qobuz User Auth Token (required if using Qobuz) - [How to get it](https://github.com/V1ck3s/octo-fiesta/wiki/Getting-Qobuz-Credentials-(User-ID-&-Token)) | +| `Qobuz:UserId` | Your Qobuz User ID (required if using Qobuz) | +| `Qobuz:Quality` | Preferred audio quality: `FLAC`, `FLAC_24_HIGH`, `FLAC_24_LOW`, `FLAC_16`, `MP3_320`. If not specified, the highest available quality will be used | + +### External Playlists + +Allstarr supports discovering and downloading playlists from your streaming providers (SquidWTF, Deezer, and Qobuz). + +| Setting | Description | +|---------|-------------| +| `Subsonic:EnableExternalPlaylists` | Enable/disable external playlist support (default: `true`) | +| `Subsonic:PlaylistsDirectory` | Directory name where M3U playlist files are created (default: `playlists`) | + +**How it works:** +1. Search for playlists from an external provider using the global search in your Subsonic client +2. When you "star" (favorite) a playlist, Allstarr automatically downloads all tracks +3. An M3U playlist file is created in `{DownloadPath}/playlists/` with relative paths to downloaded tracks +4. Individual tracks are added to the M3U as they are played or downloaded + +**Environment variable:** +```bash +# To disable playlists +Subsonic__EnableExternalPlaylists=false +``` + +> **Note**: Due to client-side filtering, playlists from streaming providers may not appear in the "Playlists" tab of some clients, but will show up in global search results. + +### Spotify Playlist Injection (Jellyfin Only) + +Allstarr automatically fills your Spotify playlists (like Release Radar and Discover Weekly) with tracks from your configured streaming provider (SquidWTF, Deezer, or Qobuz). This works by intercepting playlists created by the Jellyfin Spotify Import plugin and matching missing tracks with your streaming service. + +#### Prerequisites + +1. **Install the Jellyfin Spotify Import Plugin** + - Navigate to Jellyfin Dashboard → Plugins → Catalog + - Search for "Spotify Import" by Viperinius + - Install and restart Jellyfin + - Plugin repository: [Viperinius/jellyfin-plugin-spotify-import](https://github.com/Viperinius/jellyfin-plugin-spotify-import) + +2. **Configure the Spotify Import Plugin** + - Go to Jellyfin Dashboard → Plugins → Spotify Import + - Connect your Spotify account + - Select which playlists to sync (e.g., Release Radar, Discover Weekly) + - Set a sync schedule (the plugin will create playlists in Jellyfin) + +3. **Configure Allstarr** + - Enable Spotify Import in Allstarr (see configuration below) + - Link your Jellyfin playlists to Spotify playlists via the Web UI + - Uses your existing `JELLYFIN_URL` and `JELLYFIN_API_KEY` settings + +#### Configuration + +| Setting | Description | +|---------|-------------| +| `SpotifyImport:Enabled` | Enable Spotify playlist injection (default: `false`) | +| `SpotifyImport:MatchingIntervalHours` | How often to run track matching in hours (default: 24, set to 0 for startup only) | +| `SpotifyImport:Playlists` | JSON array of playlists (managed via Web UI) | + +**Environment variables example:** +```bash +# Enable the feature +SPOTIFY_IMPORT_ENABLED=true + +# Matching interval (24 hours = once per day) +SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS=24 + +# Playlists (use Web UI to manage instead of editing manually) +SPOTIFY_IMPORT_PLAYLISTS=[["Discover Weekly","37i9dQZEVXcV6s7Dm7RXsU","first"],["Release Radar","37i9dQZEVXbng2vDHnfQlC","first"]] +``` + +#### How It Works + +1. **Spotify Import Plugin Runs** + - Plugin fetches your Spotify playlists + - Creates/updates playlists in Jellyfin with tracks already in your library + - Generates "missing tracks" JSON files for songs not found locally + +2. **Allstarr Matches Tracks** (on startup + every 24 hours by default) + - Reads missing tracks files from the Jellyfin plugin + - For each missing track, searches your streaming provider (SquidWTF, Deezer, or Qobuz) + - Uses fuzzy matching to find the best match (title + artist similarity) + - Rate-limited to avoid overwhelming the service (150ms delay between searches) + - Pre-builds playlist cache for instant loading + +3. **You Open the Playlist in Jellyfin** + - Allstarr intercepts the request + - Returns a merged list: local tracks + matched streaming tracks + - Loads instantly from cache! + +4. **You Play a Track** + - Local tracks stream from Jellyfin normally + - Matched tracks download from streaming provider on-demand + - Downloaded tracks are saved to your library for future use + +#### Manual API Triggers + +You can manually trigger operations via the admin API: + +```bash +# Get API key from your .env file +API_KEY="your-api-key-here" + +# Fetch missing tracks from Jellyfin plugin +curl "http://localhost:5274/spotify/sync?api_key=$API_KEY" + +# Trigger track matching (searches streaming provider) +curl "http://localhost:5274/spotify/match?api_key=$API_KEY" + +# Match all playlists (refresh all matches) +curl "http://localhost:5274/spotify/match-all?api_key=$API_KEY" + +# Clear cache and rebuild +curl "http://localhost:5274/spotify/clear-cache?api_key=$API_KEY" + +# Refresh specific playlist +curl "http://localhost:5274/spotify/refresh-playlist?playlistId=PLAYLIST_ID&api_key=$API_KEY" +``` + +#### Web UI Management + +The easiest way to manage Spotify playlists is through the Web UI at `http://localhost:5275`: + +1. **Link Playlists Tab**: Link Jellyfin playlists to Spotify playlists +2. **Active Playlists Tab**: View status, trigger matching, and manage playlists +3. **Configuration Tab**: Enable/disable Spotify Import and adjust settings + +#### Troubleshooting + +**Playlists are empty:** +- Check that the Spotify Import plugin is running and creating playlists +- Verify playlists are linked in the Web UI +- Check logs: `docker-compose logs -f allstarr | grep -i spotify` + +**Tracks aren't matching:** +- Ensure your streaming provider is configured (`MUSIC_SERVICE`, credentials) +- Manually trigger matching via Web UI or API +- Check that the Jellyfin plugin generated missing tracks files + +**Performance:** +- Matching runs in background with rate limiting (150ms between searches) +- First match may take a few minutes for large playlists +- Subsequent loads are instant (served from cache) + +#### Notes + +- Uses your existing `JELLYFIN_URL` and `JELLYFIN_API_KEY` settings +- Matched tracks cached for fast loading +- Missing tracks cache persists across restarts (Redis + file cache) +- Rate limiting prevents overwhelming your streaming provider +- Only works with Jellyfin backend (not Subsonic/Navidrome) + +### Getting Credentials + +#### Deezer ARL Token + +See the [Wiki guide](https://github.com/V1ck3s/octo-fiesta/wiki/Getting-Deezer-Credentials-(ARL-Token)) for detailed instructions on obtaining your Deezer ARL token. + +#### Qobuz Credentials + +See the [Wiki guide](https://github.com/V1ck3s/octo-fiesta/wiki/Getting-Qobuz-Credentials-(User-ID-&-Token)) for detailed instructions on obtaining your Qobuz User ID and User Auth Token. + +## Limitations + +- **Playlist Search**: Subsonic clients like Aonsoku filter playlists client-side from a cached `getPlaylists` call. Streaming provider playlists appear in global search (`search3`) but not in the Playlists tab filter. +- **Region Restrictions**: Some tracks may be unavailable depending on your region and provider. +- **Token Expiration**: Provider authentication tokens expire and need periodic refresh. + +## Architecture + +``` + ┌─────────────────┐ + ┌───▶│ Jellyfin │ +┌─────────────────┐ ┌──────────────────┐ │ │ Server │ +│ Music Client │────▶│ Allstarr │───┤ └─────────────────┘ +│ (Aonsoku, │◀────│ (Proxy) │◀──┤ +│ Finamp, etc.) │ │ │ │ ┌─────────────────┐ +└─────────────────┘ └────────┬─────────┘ └───▶│ Navidrome │ + │ │ (Subsonic) │ + ▼ └─────────────────┘ + ┌─────────────────┐ + │ Music Providers │ + │ - SquidWTF │ + │ - Deezer │ + │ - Qobuz │ + └─────────────────┘ +``` + +The proxy intercepts requests from your music client and: +1. Forwards library requests to your configured backend (Jellyfin or Subsonic) +2. Merges results with content from your music provider +3. Downloads and caches external tracks on-demand +4. Serves audio streams transparently + +**Note**: Only the controller matching your configured `BACKEND_TYPE` is registered at runtime, preventing route conflicts and ensuring clean API separation. +======= +For detailed configuration options, see [CONFIGURATION.md](CONFIGURATION.md). +>>>>>>> beta ## Manual Installation @@ -574,338 +849,18 @@ If you prefer to run Allstarr without Docker: Point your music client to `http://localhost:5274` instead of your media server directly. -## API Endpoints +## Documentation -### Jellyfin Backend (Primary Focus) +- **[CONFIGURATION.md](CONFIGURATION.md)** - Detailed configuration guide for all settings +- **[ARCHITECTURE.md](ARCHITECTURE.md)** - Technical architecture and API documentation +- **[CLIENTS.md](CLIENTS.md)** - Client compatibility and setup +- **[CONTRIBUTING.md](CONTRIBUTING.md)** - Development setup and contribution guidelines -The proxy provides comprehensive Jellyfin API support with streaming provider integration: +## Limitations -| Endpoint | Description | -|----------|-------------| -| `GET /Items` | Search and browse library items (local + streaming providers) | -| `GET /Artists` | Browse artists with merged results from local + streaming | -| `GET /Artists/AlbumArtists` | Album artists with streaming provider results | -| `GET /Users/{userId}/Items` | User library items with external content | -| `GET /Audio/{id}/stream` | Stream audio, downloading from provider on-demand | -| `GET /Audio/{id}/Lyrics` | Lyrics from Jellyfin, Spotify, or LRCLib | -| `GET /Items/{id}/Images/{type}` | Proxy cover art for external content | -| `GET /Playlists/{id}/Items` | Playlist items (Spotify Import integration) | -| `POST /UserFavoriteItems/{id}` | Favorite items; copies external tracks to kept folder | -| `DELETE /UserFavoriteItems/{id}` | Unfavorite items | -| `POST /Sessions/Playing` | Playback reporting for external tracks | -| `POST /Sessions/Playing/Progress` | Playback progress tracking | -| `POST /Sessions/Playing/Stopped` | Playback stopped reporting | -| `WebSocket /socket` | Real-time session management and remote control | - -**Admin API (Port 5275):** -| Endpoint | Description | -|----------|-------------| -| `GET /api/config` | Get current configuration | -| `POST /api/config` | Update configuration | -| `GET /api/playlists` | List Spotify Import playlists | -| `POST /api/playlists/link` | Link Jellyfin playlist to Spotify | -| `DELETE /api/playlists/{id}` | Unlink playlist | -| `POST /spotify/sync` | Fetch missing tracks from Jellyfin plugin | -| `POST /spotify/match` | Trigger track matching | -| `POST /spotify/match-all` | Match all playlists | -| `POST /spotify/clear-cache` | Clear playlist cache | -| `POST /spotify/refresh-playlist` | Refresh specific playlist | - -All other Jellyfin API endpoints are passed through unchanged. - -### Subsonic Backend - -The proxy implements the Subsonic API with streaming provider integration: - -| Endpoint | Description | -|----------|-------------| -| `GET /rest/search3` | Merged search results from Navidrome + streaming provider | -| `GET /rest/stream` | Streams audio, downloading from provider if needed | -| `GET /rest/getSong` | Returns song details (local or from provider) | -| `GET /rest/getAlbum` | Returns album with tracks from both sources | -| `GET /rest/getArtist` | Returns artist with albums from both sources | -| `GET /rest/getCoverArt` | Proxies cover art for external content | -| `GET /rest/star` | Stars items; triggers automatic playlist download for external playlists | - -All other Subsonic API endpoints are passed through to Navidrome unchanged. - -## External ID Format - -External (streaming provider) content uses typed IDs: - -| Type | Format | Example | -|------|--------|---------| -| Song | `ext-{provider}-song-{id}` | `ext-deezer-song-123456`, `ext-qobuz-song-789012` | -| Album | `ext-{provider}-album-{id}` | `ext-deezer-album-789012`, `ext-qobuz-album-456789` | -| Artist | `ext-{provider}-artist-{id}` | `ext-deezer-artist-259`, `ext-qobuz-artist-123` | - -Legacy format `ext-deezer-{id}` is also supported (assumes song type). - -## Download Folder Structure - -All downloads are organized under a single base directory (default: `./downloads`): - -``` -downloads/ -├── 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 -``` - -**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 - -Downloaded files include: -- **Basic**: Title, Artist, Album, Album Artist -- **Track Info**: Track Number, Total Tracks, Disc Number -- **Dates**: Year, Release Date -- **Audio**: BPM, Duration -- **Identifiers**: ISRC (in comments) -- **Credits**: Contributors/Composers -- **Visual**: Embedded cover art (high resolution) -- **Rights**: Copyright, Label - -## Development - -### Build -```bash -dotnet build -``` - -### Run Tests -```bash -dotnet test -``` - -### Project Structure - -``` -allstarr/ -├── Controllers/ -│ ├── AdminController.cs # Admin dashboard API -│ ├── JellyfinController.cs # Jellyfin API controller -│ └── SubsonicController.cs # Subsonic API controller -├── Filters/ -│ ├── AdminPortFilter.cs # Admin port access control -│ ├── ApiKeyAuthFilter.cs # API key authentication -│ └── JellyfinAuthFilter.cs # Jellyfin authentication -├── Middleware/ -│ ├── AdminStaticFilesMiddleware.cs # Admin UI static file serving -│ ├── GlobalExceptionHandler.cs # Global error handling -│ └── WebSocketProxyMiddleware.cs # WebSocket proxying for Jellyfin -├── Models/ -│ ├── Domain/ # Domain entities -│ │ ├── Song.cs -│ │ ├── Album.cs -│ │ └── Artist.cs -│ ├── Settings/ # Configuration models -│ │ ├── SubsonicSettings.cs -│ │ ├── DeezerSettings.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 pattern -│ │ └── Error.cs # Error types -│ ├── Deezer/ # Deezer provider -│ │ ├── DeezerDownloadService.cs -│ │ ├── DeezerMetadataService.cs -│ │ └── DeezerStartupValidator.cs -│ ├── Qobuz/ # Qobuz provider -│ │ ├── QobuzDownloadService.cs -│ │ ├── QobuzMetadataService.cs -│ │ ├── QobuzBundleService.cs -│ │ └── QobuzStartupValidator.cs -│ ├── SquidWTF/ # SquidWTF provider -│ │ ├── SquidWTFDownloadService.cs -│ │ ├── SquidWTFMetadataService.cs -│ │ └── SquidWTFStartupValidator.cs -│ ├── Jellyfin/ # Jellyfin integration -│ │ ├── JellyfinModelMapper.cs # Model mapping -│ │ ├── JellyfinProxyService.cs # Request proxying -│ │ ├── JellyfinResponseBuilder.cs # Response building -│ │ ├── JellyfinSessionManager.cs # Session management -│ │ └── JellyfinStartupValidator.cs # Startup validation -│ ├── Lyrics/ # Lyrics services -│ │ ├── LrclibService.cs # LRCLIB lyrics -│ │ ├── LyricsPrefetchService.cs # Background lyrics prefetching -│ │ ├── LyricsStartupValidator.cs # Lyrics validation -│ │ └── SpotifyLyricsService.cs # Spotify lyrics -│ ├── MusicBrainz/ -│ │ └── MusicBrainzService.cs # MusicBrainz metadata -│ ├── Spotify/ # Spotify integration -│ │ ├── SpotifyApiClient.cs # Spotify API client -│ │ ├── SpotifyMissingTracksFetcher.cs # Missing tracks fetcher -│ │ ├── SpotifyPlaylistFetcher.cs # Playlist fetcher -│ │ └── SpotifyTrackMatchingService.cs # Track matching -│ ├── Local/ # Local library -│ │ ├── ILocalLibraryService.cs -│ │ └── LocalLibraryService.cs -│ ├── Subsonic/ # Subsonic API logic -│ │ ├── PlaylistSyncService.cs # Playlist synchronization -│ │ ├── SubsonicModelMapper.cs # Model mapping -│ │ ├── SubsonicProxyService.cs # Request proxying -│ │ ├── SubsonicRequestParser.cs # Request parsing -│ │ └── SubsonicResponseBuilder.cs # Response building -│ ├── Validation/ # Startup validation -│ │ ├── IStartupValidator.cs -│ │ ├── BaseStartupValidator.cs -│ │ ├── SubsonicStartupValidator.cs -│ │ ├── StartupValidationOrchestrator.cs -│ │ └── ValidationResult.cs -│ ├── IDownloadService.cs # Download interface -│ ├── IMusicMetadataService.cs # Metadata interface -│ └── StartupValidationService.cs -├── wwwroot/ # Admin UI static files -│ ├── index.html # Admin dashboard -│ └── placeholder.png # Placeholder image -├── Program.cs # Application entry point -└── appsettings.json # Configuration - -allstarr.Tests/ -├── DeezerDownloadServiceTests.cs # Deezer download tests -├── DeezerMetadataServiceTests.cs # Deezer metadata tests -├── JellyfinResponseStructureTests.cs # Jellyfin response tests -├── QobuzDownloadServiceTests.cs # Qobuz download tests -├── LocalLibraryServiceTests.cs # Local library tests -├── SubsonicModelMapperTests.cs # Model mapping tests -├── SubsonicProxyServiceTests.cs # Proxy service tests -├── SubsonicRequestParserTests.cs # Request parser tests -└── SubsonicResponseBuilderTests.cs # Response builder tests -``` - -### Dependencies - -- **BouncyCastle.Cryptography** - Blowfish decryption for Deezer streams -- **TagLibSharp** - ID3 tag and cover art embedding -- **Swashbuckle.AspNetCore** - Swagger/OpenAPI documentation -- **xUnit** - Unit testing framework -- **Moq** - Mocking library for tests -- **FluentAssertions** - Fluent assertion library for tests - -## Contributing - -We welcome contributions! Here's how to get started: - -### Development Setup - -1. **Clone the repository** - ```bash - git clone https://github.com/SoPat712/allstarr.git - cd allstarr - ``` - -2. **Build and run locally** - - Using Docker (recommended for development): - ```bash - # Copy and configure environment - cp .env.example .env - vi .env - - # Build and start with local changes - docker-compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build - - # View logs - docker-compose logs -f - ``` - - Or using .NET directly: - ```bash - # Restore dependencies - dotnet restore - - # Run the application - cd allstarr - dotnet run - ``` - -3. **Run tests** - ```bash - dotnet test - ``` - -### Making Changes - -1. Fork the repository -2. Create a feature branch (`git checkout -b feature/amazing-feature`) -3. Make your changes -4. Run tests to ensure everything works -5. Commit your changes (`git commit -m 'Add amazing feature'`) -6. Push to your fork (`git push origin feature/amazing-feature`) -7. Open a Pull Request - -### Code Style - -- Follow existing code patterns and conventions -- Add tests for new features -- Update documentation as needed -- Keep commits feature focused - -### Testing - -All changes should include appropriate tests: -```bash -# Run all tests -dotnet test - -# Run specific test file -dotnet test --filter "FullyQualifiedName~SubsonicProxyServiceTests" - -# Run with coverage -dotnet test --collect:"XPlat Code Coverage" -``` +- **Playlist Search**: Subsonic clients like Aonsoku filter playlists client-side from a cached `getPlaylists` call. Streaming provider playlists appear in global search (`search3`) but not in the Playlists tab filter. +- **Region Restrictions**: Some tracks may be unavailable depending on your region and provider. +- **Token Expiration**: Provider authentication tokens expire and need periodic refresh. ## License diff --git a/allstarr.Tests/ApiKeyAuthFilterTests.cs b/allstarr.Tests/ApiKeyAuthFilterTests.cs new file mode 100644 index 0000000..3015301 --- /dev/null +++ b/allstarr.Tests/ApiKeyAuthFilterTests.cs @@ -0,0 +1,167 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using System.Collections.Generic; +using allstarr.Filters; +using allstarr.Models.Settings; + +namespace allstarr.Tests; + +public class ApiKeyAuthFilterTests +{ + private readonly Mock> _loggerMock; + private readonly IOptions _options; + + public ApiKeyAuthFilterTests() + { + _loggerMock = new Mock>(); + _options = Options.Create(new JellyfinSettings { ApiKey = "secret-key" }); + } + + private static (ActionExecutingContext ExecContext, ActionContext ActionContext) CreateContext(HttpContext httpContext) + { + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + var execContext = new ActionExecutingContext(actionContext, new List(), new Dictionary(), controller: new object()); + return (execContext, actionContext); + } + + private static ActionExecutionDelegate CreateNext(ActionContext actionContext, Action onInvoke) + { + return () => + { + onInvoke(); + var executedContext = new ActionExecutedContext(actionContext, new List(), controller: new object()); + return Task.FromResult(executedContext); + }; + } + + [Fact] + public async Task OnActionExecutionAsync_WithValidHeader_AllowsRequest() + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["X-Api-Key"] = "secret-key"; + + var (ctx, actionCtx) = CreateContext(httpContext); + var filter = new ApiKeyAuthFilter(_options, _loggerMock.Object); + + var invoked = false; + var next = CreateNext(actionCtx, () => invoked = true); + + // Act + await filter.OnActionExecutionAsync(ctx, next); + + // Assert + Assert.True(invoked, "Next delegate should be invoked for valid API key header"); + Assert.Null(ctx.Result); + } + + [Fact] + public async Task OnActionExecutionAsync_WithValidQuery_AllowsRequest() + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.Request.QueryString = new QueryString("?api_key=secret-key"); + + var (ctx, actionCtx) = CreateContext(httpContext); + var filter = new ApiKeyAuthFilter(_options, _loggerMock.Object); + + var invoked = false; + var next = CreateNext(actionCtx, () => invoked = true); + + // Act + await filter.OnActionExecutionAsync(ctx, next); + + // Assert + Assert.True(invoked, "Next delegate should be invoked for valid API key query"); + Assert.Null(ctx.Result); + } + + [Fact] + public async Task OnActionExecutionAsync_WithXEmbyTokenHeader_AllowsRequest() + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["X-Emby-Token"] = "secret-key"; + + var (ctx, actionCtx) = CreateContext(httpContext); + var filter = new ApiKeyAuthFilter(_options, _loggerMock.Object); + + var invoked = false; + var next = CreateNext(actionCtx, () => invoked = true); + + // Act + await filter.OnActionExecutionAsync(ctx, next); + + // Assert + Assert.True(invoked, "Next delegate should be invoked for valid X-Emby-Token header"); + Assert.Null(ctx.Result); + } + + [Fact] + public async Task OnActionExecutionAsync_WithMissingKey_ReturnsUnauthorized() + { + // Arrange + var httpContext = new DefaultHttpContext(); + var (ctx, actionCtx) = CreateContext(httpContext); + var filter = new ApiKeyAuthFilter(_options, _loggerMock.Object); + + var invoked = false; + var next = CreateNext(actionCtx, () => invoked = true); + + // Act + await filter.OnActionExecutionAsync(ctx, next); + + // Assert + Assert.False(invoked, "Next delegate should not be invoked when API key is missing"); + Assert.IsType(ctx.Result); + } + + [Fact] + public async Task OnActionExecutionAsync_WithWrongKey_ReturnsUnauthorized() + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["X-Api-Key"] = "wrong-key"; + + var (ctx, actionCtx) = CreateContext(httpContext); + var filter = new ApiKeyAuthFilter(_options, _loggerMock.Object); + + var invoked = false; + var next = CreateNext(actionCtx, () => invoked = true); + + // Act + await filter.OnActionExecutionAsync(ctx, next); + + // Assert + Assert.False(invoked, "Next delegate should not be invoked for wrong API key"); + Assert.IsType(ctx.Result); + } + + [Fact] + public async Task OnActionExecutionAsync_ConstantTimeComparison_WorksForDifferentLengths() + { + // Arrange + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["X-Api-Key"] = "short"; + + var (ctx, actionCtx) = CreateContext(httpContext); + var filter = new ApiKeyAuthFilter(Options.Create(new JellyfinSettings { ApiKey = "much-longer-secret-key" }), _loggerMock.Object); + + var invoked = false; + var next = CreateNext(actionCtx, () => invoked = true); + + // Act + await filter.OnActionExecutionAsync(ctx, next); + + // Assert + Assert.False(invoked, "Next should not be invoked for wrong key"); + Assert.IsType(ctx.Result); + } +} diff --git a/allstarr.Tests/DeezerMetadataServiceTests.cs b/allstarr.Tests/DeezerMetadataServiceTests.cs index 222164c..d8a22fd 100644 --- a/allstarr.Tests/DeezerMetadataServiceTests.cs +++ b/allstarr.Tests/DeezerMetadataServiceTests.cs @@ -619,7 +619,7 @@ public class DeezerMetadataServiceTests Assert.Equal(2, result.Count); Assert.Equal("Chill Vibes", result[0].Name); Assert.Equal(50, result[0].TrackCount); - Assert.Equal("pl-deezer-12345", result[0].Id); + Assert.Equal("ext-deezer-playlist-12345", result[0].Id); } [Fact] @@ -691,7 +691,7 @@ public class DeezerMetadataServiceTests Assert.NotNull(result); Assert.Equal("Best Of Jazz", result.Name); Assert.Equal(100, result.TrackCount); - Assert.Equal("pl-deezer-12345", result.Id); + Assert.Equal("ext-deezer-playlist-12345", result.Id); } [Fact] diff --git a/allstarr.Tests/EnvMigrationServiceTests.cs b/allstarr.Tests/EnvMigrationServiceTests.cs new file mode 100644 index 0000000..592db29 --- /dev/null +++ b/allstarr.Tests/EnvMigrationServiceTests.cs @@ -0,0 +1,214 @@ +using Xunit; +using Moq; +using Microsoft.Extensions.Logging; +using allstarr.Services.Common; +using System.IO; + +namespace allstarr.Tests; + +public class EnvMigrationServiceTests +{ + private readonly Mock> _mockLogger; + private readonly string _testEnvPath; + + public EnvMigrationServiceTests() + { + _mockLogger = new Mock>(); + _testEnvPath = Path.Combine(Path.GetTempPath(), $"test-{Guid.NewGuid()}.env"); + } + + [Fact] + public void MigrateEnvFile_RemovesQuotesFromPasswords() + { + // Arrange - passwords with quotes (old incorrect format) + var envContent = @"SCROBBLING_LASTFM_USERNAME=testuser +SCROBBLING_LASTFM_PASSWORD=""test!pass123"" +MUSICBRAINZ_PASSWORD=""fake&Pass*Word$123"" +SOME_OTHER_VAR=value"; + + File.WriteAllText(_testEnvPath, envContent); + + var service = new TestEnvMigrationService(_mockLogger.Object, _testEnvPath); + + // Act + service.MigrateEnvFile(); + + // Assert - quotes should be removed + var result = File.ReadAllText(_testEnvPath); + Assert.Contains("SCROBBLING_LASTFM_PASSWORD=test!pass123", result); + Assert.DoesNotContain("SCROBBLING_LASTFM_PASSWORD=\"test!pass123\"", result); + Assert.Contains("MUSICBRAINZ_PASSWORD=fake&Pass*Word$123", result); + Assert.DoesNotContain("MUSICBRAINZ_PASSWORD=\"fake&Pass*Word$123\"", result); + Assert.Contains("SCROBBLING_LASTFM_USERNAME=testuser", result); + Assert.Contains("SOME_OTHER_VAR=value", result); + + // Cleanup + File.Delete(_testEnvPath); + } + + [Fact] + public void MigrateEnvFile_LeavesUnquotedPasswordsAlone() + { + // Arrange - passwords without quotes (correct format) + var envContent = @"SCROBBLING_LASTFM_PASSWORD=already-unquoted! +MUSICBRAINZ_PASSWORD=also-unquoted&*$"; + + File.WriteAllText(_testEnvPath, envContent); + + var service = new TestEnvMigrationService(_mockLogger.Object, _testEnvPath); + + // Act + service.MigrateEnvFile(); + + // Assert - should remain unchanged + var result = File.ReadAllText(_testEnvPath); + Assert.Contains("SCROBBLING_LASTFM_PASSWORD=already-unquoted!", result); + Assert.Contains("MUSICBRAINZ_PASSWORD=also-unquoted&*$", result); + + // Cleanup + File.Delete(_testEnvPath); + } + + [Fact] + public void MigrateEnvFile_HandlesEmptyPasswords() + { + // Arrange + var envContent = @"SCROBBLING_LASTFM_PASSWORD= +MUSICBRAINZ_PASSWORD="; + + File.WriteAllText(_testEnvPath, envContent); + + var service = new TestEnvMigrationService(_mockLogger.Object, _testEnvPath); + + // Act + service.MigrateEnvFile(); + + // Assert + var result = File.ReadAllText(_testEnvPath); + Assert.Contains("SCROBBLING_LASTFM_PASSWORD=", result); + Assert.DoesNotContain("SCROBBLING_LASTFM_PASSWORD=\"\"", result); + + // Cleanup + File.Delete(_testEnvPath); + } + + [Fact] + public void MigrateEnvFile_PreservesComments() + { + // Arrange + var envContent = @"# This is a comment +SCROBBLING_LASTFM_PASSWORD=fake!test123 +# Another comment +MUSICBRAINZ_PASSWORD=test&pass*word"; + + File.WriteAllText(_testEnvPath, envContent); + + var service = new TestEnvMigrationService(_mockLogger.Object, _testEnvPath); + + // Act + service.MigrateEnvFile(); + + // Assert + var result = File.ReadAllText(_testEnvPath); + Assert.Contains("# This is a comment", result); + Assert.Contains("# Another comment", result); + + // Cleanup + File.Delete(_testEnvPath); + } + + [Theory] + [InlineData("DEEZER_ARL", "\"abc123def456!@#\"")] + [InlineData("QOBUZ_USER_AUTH_TOKEN", "\"token&with*special$chars\"")] + [InlineData("SCROBBLING_LASTFM_SESSION_KEY", "\"session!key@here\"")] + [InlineData("SPOTIFY_API_SESSION_COOKIE", "\"cookie$value&here\"")] + public void MigrateEnvFile_RemovesQuotesFromAllSensitiveKeys(string key, string quotedValue) + { + // Arrange - value with quotes (old incorrect format) + var envContent = $"{key}={quotedValue}"; + File.WriteAllText(_testEnvPath, envContent); + + var service = new TestEnvMigrationService(_mockLogger.Object, _testEnvPath); + + // Act + service.MigrateEnvFile(); + + // Assert - quotes should be removed + var result = File.ReadAllText(_testEnvPath); + var unquotedValue = quotedValue.Substring(1, quotedValue.Length - 2); + Assert.Contains($"{key}={unquotedValue}", result); + Assert.DoesNotContain(quotedValue, result); + + // Cleanup + File.Delete(_testEnvPath); + } + + [Fact] + public void MigrateEnvFile_HandlesMultipleQuotedPasswords() + { + // Arrange - all with quotes (old incorrect format) + var envContent = @"SCROBBLING_LASTFM_PASSWORD=""fakepass1!"" +MUSICBRAINZ_PASSWORD=""testpass2&"" +DEEZER_ARL=""fakearl3*"" +QOBUZ_USER_AUTH_TOKEN=""testtoken4$"""; + + File.WriteAllText(_testEnvPath, envContent); + + var service = new TestEnvMigrationService(_mockLogger.Object, _testEnvPath); + + // Act + service.MigrateEnvFile(); + + // Assert - all quotes should be removed + var result = File.ReadAllText(_testEnvPath); + Assert.Contains("SCROBBLING_LASTFM_PASSWORD=fakepass1!", result); + Assert.DoesNotContain("SCROBBLING_LASTFM_PASSWORD=\"fakepass1!\"", result); + Assert.Contains("MUSICBRAINZ_PASSWORD=testpass2&", result); + Assert.DoesNotContain("MUSICBRAINZ_PASSWORD=\"testpass2&\"", result); + Assert.Contains("DEEZER_ARL=fakearl3*", result); + Assert.DoesNotContain("DEEZER_ARL=\"fakearl3*\"", result); + Assert.Contains("QOBUZ_USER_AUTH_TOKEN=testtoken4$", result); + Assert.DoesNotContain("QOBUZ_USER_AUTH_TOKEN=\"testtoken4$\"", result); + + // Cleanup + File.Delete(_testEnvPath); + } + + [Fact] + public void MigrateEnvFile_NoFileExists_LogsWarning() + { + // Arrange + var nonExistentPath = Path.Combine(Path.GetTempPath(), $"nonexistent-{Guid.NewGuid()}.env"); + var service = new TestEnvMigrationService(_mockLogger.Object, nonExistentPath); + + // Act + service.MigrateEnvFile(); + + // Assert - should not throw, just log warning + _mockLogger.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("No .env file found")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + // Helper class to allow testing with custom path + private class TestEnvMigrationService : EnvMigrationService + { + private readonly string _customPath; + + public TestEnvMigrationService(ILogger logger, string customPath) + : base(logger) + { + _customPath = customPath; + + // Use reflection to set the private field + var field = typeof(EnvMigrationService).GetField("_envFilePath", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + field?.SetValue(this, _customPath); + } + } +} diff --git a/allstarr.Tests/EnvironmentVariableParsingTests.cs b/allstarr.Tests/EnvironmentVariableParsingTests.cs new file mode 100644 index 0000000..9c355e6 --- /dev/null +++ b/allstarr.Tests/EnvironmentVariableParsingTests.cs @@ -0,0 +1,148 @@ +using Xunit; +using allstarr.Services.Admin; + +namespace allstarr.Tests; + +/// +/// Tests for environment variable parsing edge cases +/// Ensures Docker Compose .env file parsing works correctly with special characters +/// +public class EnvironmentVariableParsingTests +{ + [Theory] + [InlineData("password", "password")] + [InlineData("\"password\"", "password")] + [InlineData("'password'", "'password'")] // Single quotes are NOT stripped by our logic + public void ParseEnvValue_QuotedValues_HandlesCorrectly(string envValue, string expected) + { + // Act + var result = AdminHelperService.StripQuotes(envValue); + + // Assert + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("test!pass", "test!pass")] + [InlineData("test&pass", "test&pass")] + [InlineData("test*pass", "test*pass")] + [InlineData("test$pass", "test$pass")] + [InlineData("test@pass", "test@pass")] + [InlineData("test#pass", "test#pass")] + [InlineData("test%pass", "test%pass")] + [InlineData("test^pass", "test^pass")] + [InlineData("test(pass)", "test(pass)")] + [InlineData("test[pass]", "test[pass]")] + [InlineData("test{pass}", "test{pass}")] + [InlineData("test|pass", "test|pass")] + [InlineData("test\\pass", "test\\pass")] + [InlineData("test;pass", "test;pass")] + [InlineData("test'pass", "test'pass")] + [InlineData("test`pass", "test`pass")] + [InlineData("test~pass", "test~pass")] + [InlineData("test", "test")] + [InlineData("test?pass", "test?pass")] + public void ParseEnvValue_ShellSpecialChars_PreservesWhenQuoted(string password, string expected) + { + // When properly quoted in .env file, special chars should be preserved + var quotedValue = $"\"{password}\""; + + // Act + var result = AdminHelperService.StripQuotes(quotedValue); + + // Assert + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("SCROBBLING_LASTFM_PASSWORD=test!pass", "SCROBBLING_LASTFM_PASSWORD", "test!pass")] + [InlineData("SCROBBLING_LASTFM_PASSWORD=\"test!pass\"", "SCROBBLING_LASTFM_PASSWORD", "test!pass")] + [InlineData("MUSICBRAINZ_PASSWORD=test&pass", "MUSICBRAINZ_PASSWORD", "test&pass")] + [InlineData("MUSICBRAINZ_PASSWORD=\"test&pass\"", "MUSICBRAINZ_PASSWORD", "test&pass")] + public void ParseEnvLine_VariousFormats_ParsesCorrectly(string line, string expectedKey, string expectedValue) + { + // Act + var (key, value) = AdminHelperService.ParseEnvLine(line); + + // Assert + Assert.Equal(expectedKey, key); + Assert.Equal(expectedValue, value); + } + + [Theory] + [InlineData("# Comment line")] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + public void ParseEnvLine_CommentsAndEmpty_SkipsCorrectly(string line) + { + // Act + var result = AdminHelperService.ShouldSkipEnvLine(line); + + // Assert + Assert.True(result); + } + + [Theory] + [InlineData("KEY=value")] + [InlineData("KEY=\"value\"")] + public void ParseEnvLine_ValidLines_DoesNotSkip(string line) + { + // Act + var result = AdminHelperService.ShouldSkipEnvLine(line); + + // Assert + Assert.False(result); + } + + [Theory] + [InlineData("KEY=value with spaces", "value with spaces")] + [InlineData("KEY=\"value with spaces\"", "value with spaces")] + [InlineData("KEY= value with leading spaces ", "value with leading spaces")] + public void ParseEnvLine_Whitespace_HandlesCorrectly(string line, string expectedValue) + { + // Act + var (_, value) = AdminHelperService.ParseEnvLine(line); + + // Assert + Assert.Equal(expectedValue, value); + } + + [Theory] + [InlineData("KEY=", "")] + [InlineData("KEY=\"\"", "")] + [InlineData("KEY= ", "")] + public void ParseEnvLine_EmptyValues_HandlesCorrectly(string line, string expectedValue) + { + // Act + var (_, value) = AdminHelperService.ParseEnvLine(line); + + // Assert + Assert.Equal(expectedValue, value); + } + + [Theory] + [InlineData("KEY=value=with=equals", "value=with=equals")] + [InlineData("KEY=\"value=with=equals\"", "value=with=equals")] + public void ParseEnvLine_MultipleEquals_HandlesCorrectly(string line, string expectedValue) + { + // Act + var (_, value) = AdminHelperService.ParseEnvLine(line); + + // Assert + Assert.Equal(expectedValue, value); + } + + [Theory] + [InlineData("KEY=café", "café")] + [InlineData("KEY=\"日本語\"", "日本語")] + [InlineData("KEY=🎵🎶", "🎵🎶")] + public void ParseEnvLine_UnicodeCharacters_HandlesCorrectly(string line, string expectedValue) + { + // Act + var (_, value) = AdminHelperService.ParseEnvLine(line); + + // Assert + Assert.Equal(expectedValue, value); + } +} diff --git a/allstarr.Tests/InputValidationTests.cs b/allstarr.Tests/InputValidationTests.cs new file mode 100644 index 0000000..cc155c0 --- /dev/null +++ b/allstarr.Tests/InputValidationTests.cs @@ -0,0 +1,163 @@ +using Xunit; +using allstarr.Services.Admin; + +namespace allstarr.Tests; + +/// +/// Tests for input validation and sanitization +/// Ensures user inputs are properly validated and don't cause security issues +/// +public class InputValidationTests +{ + [Theory] + [InlineData("VALID_KEY", true)] + [InlineData("VALID_KEY_123", true)] + [InlineData("VALID__KEY", true)] + [InlineData("_VALID_KEY", true)] + [InlineData("VALID-KEY", false)] // Hyphens not allowed + [InlineData("VALID.KEY", false)] // Dots not allowed + [InlineData("VALID KEY", false)] // Spaces not allowed + [InlineData("VALID/KEY", false)] // Slashes not allowed + [InlineData("VALID\\KEY", false)] // Backslashes not allowed + [InlineData("123VALID", false)] // Cannot start with number + [InlineData("", false)] // Empty not allowed + [InlineData("KEY=VALUE", false)] // Equals not allowed + [InlineData("KEY;DROP", false)] // Semicolon not allowed + [InlineData("KEY&VALUE", false)] // Ampersand not allowed + [InlineData("KEY|VALUE", false)] // Pipe not allowed + [InlineData("KEY$VALUE", false)] // Dollar not allowed + [InlineData("KEY`VALUE", false)] // Backtick not allowed + public void IsValidEnvKey_VariousInputs_ValidatesCorrectly(string key, bool expected) + { + // Act + var result = AdminHelperService.IsValidEnvKey(key); + + // Assert + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("username", true)] + [InlineData("user123", true)] + [InlineData("user_name", true)] + [InlineData("user-name", true)] + [InlineData("user.name", true)] + [InlineData("user@domain", true)] // Email format + [InlineData("", false)] + [InlineData(" ", false)] + [InlineData("user\nname", false)] // Newline not allowed + [InlineData("user\tname", false)] // Tab not allowed + [InlineData("user;name", false)] // Semicolon suspicious + [InlineData("user|name", false)] // Pipe suspicious + [InlineData("user&name", false)] // Ampersand suspicious + public void IsValidUsername_VariousInputs_ValidatesCorrectly(string username, bool expected) + { + // Act + var result = AdminHelperService.IsValidUsername(username); + + // Assert + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("password", true)] + [InlineData("pass!@#$%", true)] + [InlineData("pass&word", true)] + [InlineData("pass*word", true)] + [InlineData("", false)] + [InlineData(" ", false)] + [InlineData("pass\nword", false)] // Newline not allowed + [InlineData("pass\0word", false)] // Null byte not allowed + public void IsValidPassword_VariousInputs_ValidatesCorrectly(string password, bool expected) + { + // Act + var result = AdminHelperService.IsValidPassword(password); + + // Assert + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("http://localhost", true)] + [InlineData("https://example.com", true)] + [InlineData("http://192.168.1.1:8080", true)] + [InlineData("https://example.com/path", true)] + [InlineData("", false)] + [InlineData("not-a-url", false)] + [InlineData("javascript:alert(1)", false)] + [InlineData("file:///etc/passwd", false)] + [InlineData("ftp://example.com", false)] + public void IsValidUrl_VariousInputs_ValidatesCorrectly(string url, bool expected) + { + // Act + var result = AdminHelperService.IsValidUrl(url); + + // Assert + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("/path/to/file", true)] + [InlineData("./relative/path", true)] + [InlineData("../parent/path", true)] + [InlineData("/path/with spaces/file", true)] + [InlineData("", false)] + [InlineData("/path/with\nnewline", false)] + [InlineData("/path/with\0null", false)] + [InlineData("/path;rm -rf /", false)] + [InlineData("/path|cat /etc/passwd", false)] + [InlineData("/path&background", false)] + public void IsValidPath_VariousInputs_ValidatesCorrectly(string path, bool expected) + { + // Act + var result = AdminHelperService.IsValidPath(path); + + // Assert + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("", "<script>alert(1)</script>")] + [InlineData("Normal text", "Normal text")] + [InlineData("Text with ", "Text with <tags>")] + [InlineData("Text & more", "Text & more")] + [InlineData("Text \"quoted\"", "Text "quoted"")] + [InlineData("Text 'quoted'", "Text 'quoted'")] + public void SanitizeHtml_VariousInputs_EscapesCorrectly(string input, string expected) + { + // Act + var result = AdminHelperService.SanitizeHtml(input); + + // Assert + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("normal-string", "normal-string")] + [InlineData("string with spaces", "string with spaces")] + [InlineData("string\nwith\nnewlines", "stringwithnewlines")] + [InlineData("string\twith\ttabs", "stringwithtabs")] + [InlineData("string\rwith\rcarriage", "stringwithcarriage")] + public void RemoveControlCharacters_VariousInputs_RemovesCorrectly(string input, string expected) + { + // Act + var result = AdminHelperService.RemoveControlCharacters(input); + + // Assert + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("verylongpassword", 8, "verylong...")] + [InlineData("short", 8, "short")] + [InlineData("exactlen", 8, "exactlen")] + [InlineData("", 8, "")] + public void TruncateForLogging_VariousInputs_TruncatesCorrectly(string input, int maxLength, string expected) + { + // Act + var result = AdminHelperService.TruncateForLogging(input, maxLength); + + // Assert + Assert.Equal(expected, result); + } +} diff --git a/allstarr.Tests/JavaScriptSyntaxTests.cs b/allstarr.Tests/JavaScriptSyntaxTests.cs new file mode 100644 index 0000000..e27916a --- /dev/null +++ b/allstarr.Tests/JavaScriptSyntaxTests.cs @@ -0,0 +1,187 @@ +using System.Diagnostics; +using Xunit; + +namespace allstarr.Tests; + +/// +/// Tests to validate JavaScript syntax in wwwroot files. +/// This prevents broken JavaScript from being committed. +/// +public class JavaScriptSyntaxTests +{ + private readonly string _wwwrootPath; + + public JavaScriptSyntaxTests() + { + // Get the path to the wwwroot directory + var projectRoot = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), "..", "..", "..", "..")); + _wwwrootPath = Path.Combine(projectRoot, "allstarr", "wwwroot"); + } + + [Fact] + public void AppJs_ShouldHaveValidSyntax() + { + var filePath = Path.Combine(_wwwrootPath, "app.js"); + Assert.True(File.Exists(filePath), $"app.js not found at {filePath}"); + + var isValid = ValidateJavaScriptSyntax(filePath, out var error); + Assert.True(isValid, $"app.js has syntax errors:\n{error}"); + } + + [Fact] + public void SpotifyMappingsJs_ShouldHaveValidSyntax() + { + var filePath = Path.Combine(_wwwrootPath, "spotify-mappings.js"); + Assert.True(File.Exists(filePath), $"spotify-mappings.js not found at {filePath}"); + + var isValid = ValidateJavaScriptSyntax(filePath, out var error); + Assert.True(isValid, $"spotify-mappings.js has syntax errors:\n{error}"); + } + + [Fact] + public void ModularJs_UtilsShouldHaveValidSyntax() + { + var filePath = Path.Combine(_wwwrootPath, "js", "utils.js"); + Assert.True(File.Exists(filePath), $"js/utils.js not found at {filePath}"); + + var isValid = ValidateJavaScriptSyntax(filePath, out var error); + Assert.True(isValid, $"js/utils.js has syntax errors:\n{error}"); + } + + [Fact] + public void ModularJs_ApiShouldHaveValidSyntax() + { + var filePath = Path.Combine(_wwwrootPath, "js", "api.js"); + Assert.True(File.Exists(filePath), $"js/api.js not found at {filePath}"); + + var isValid = ValidateJavaScriptSyntax(filePath, out var error); + Assert.True(isValid, $"js/api.js has syntax errors:\n{error}"); + } + + [Fact] + public void ModularJs_MainShouldHaveValidSyntax() + { + var filePath = Path.Combine(_wwwrootPath, "js", "main.js"); + Assert.True(File.Exists(filePath), $"js/main.js not found at {filePath}"); + + var isValid = ValidateJavaScriptSyntax(filePath, out var error); + Assert.True(isValid, $"js/main.js has syntax errors:\n{error}"); + } + + [Fact] + public void AppJs_ShouldBeDeprecated() + { + var filePath = Path.Combine(_wwwrootPath, "app.js"); + var content = File.ReadAllText(filePath); + + // Check that the file is now just a deprecation notice + Assert.Contains("DEPRECATED", content); + Assert.Contains("main.js", content); + } + + [Fact] + public void MainJs_ShouldBeComplete() + { + var filePath = Path.Combine(_wwwrootPath, "js", "main.js"); + var content = File.ReadAllText(filePath); + + // Check that critical window functions exist + Assert.Contains("window.fetchStatus", content); + Assert.Contains("window.fetchPlaylists", content); + Assert.Contains("window.fetchConfig", content); + Assert.Contains("window.fetchEndpointUsage", content); + + // Check that the file has proper initialization + Assert.Contains("DOMContentLoaded", content); + Assert.Contains("window.fetchStatus();", content); + } + + [Fact] + public void AppJs_ShouldHaveBalancedBraces() + { + // app.js is now deprecated and just contains comments + // Skip this test or check main.js instead + var filePath = Path.Combine(_wwwrootPath, "js", "main.js"); + var content = File.ReadAllText(filePath); + + var openBraces = content.Count(c => c == '{'); + var closeBraces = content.Count(c => c == '}'); + + Assert.Equal(openBraces, closeBraces); + } + + [Fact] + public void AppJs_ShouldHaveBalancedParentheses() + { + // app.js is now deprecated and just contains comments + // Skip this test or check main.js instead + var filePath = Path.Combine(_wwwrootPath, "js", "main.js"); + + // Use Node.js to validate syntax instead of counting parentheses + // This is more reliable than regex-based string/comment removal + string error; + var isValid = ValidateJavaScriptSyntax(filePath, out error); + + Assert.True(isValid, $"JavaScript syntax validation failed: {error}"); + } + + private bool ValidateJavaScriptSyntax(string filePath, out string error) + { + error = string.Empty; + + try + { + // Use Node.js to check syntax + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "node", + Arguments = $"--check \"{filePath}\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + + process.Start(); + var stderr = process.StandardError.ReadToEnd(); + process.WaitForExit(); + + if (process.ExitCode != 0) + { + error = stderr; + return false; + } + + return true; + } + catch (Exception ex) + { + error = $"Failed to run Node.js syntax check: {ex.Message}\n" + + "Make sure Node.js is installed and available in PATH."; + return false; + } + } + + private string RemoveStringsAndComments(string content) + { + // Simple removal of strings and comments for brace counting + // This is not perfect but good enough for basic validation + var result = content; + + // Remove single-line comments + result = System.Text.RegularExpressions.Regex.Replace(result, @"//.*$", "", System.Text.RegularExpressions.RegexOptions.Multiline); + + // Remove multi-line comments + result = System.Text.RegularExpressions.Regex.Replace(result, @"/\*.*?\*/", "", System.Text.RegularExpressions.RegexOptions.Singleline); + + // Remove strings (simple approach) + result = System.Text.RegularExpressions.Regex.Replace(result, @"""(?:[^""\\]|\\.)*""", ""); + result = System.Text.RegularExpressions.Regex.Replace(result, @"'(?:[^'\\]|\\.)*'", ""); + result = System.Text.RegularExpressions.Regex.Replace(result, @"`(?:[^`\\]|\\.)*`", ""); + + return result; + } +} diff --git a/allstarr.Tests/JellyfinModelMapperTests.cs b/allstarr.Tests/JellyfinModelMapperTests.cs index 4fdf9c5..d19992d 100644 --- a/allstarr.Tests/JellyfinModelMapperTests.cs +++ b/allstarr.Tests/JellyfinModelMapperTests.cs @@ -277,7 +277,7 @@ public class JellyfinModelMapperTests // Arrange var playlists = new List { - new() { Id = "pl-1", Name = "Summer Mix", Provider = "deezer", ExternalId = "123" } + new() { Id = "ext-deezer-playlist-123", Name = "Summer Mix", Provider = "deezer", ExternalId = "123" } }; var externalResult = new SearchResult @@ -293,7 +293,7 @@ public class JellyfinModelMapperTests // Assert Assert.Single(albums); - Assert.Equal("pl-1", albums[0]["Id"]); + Assert.Equal("ext-deezer-playlist-123", albums[0]["Id"]); } [Fact] diff --git a/allstarr.Tests/JellyfinProxyServiceTests.cs b/allstarr.Tests/JellyfinProxyServiceTests.cs index af9b89c..185c853 100644 --- a/allstarr.Tests/JellyfinProxyServiceTests.cs +++ b/allstarr.Tests/JellyfinProxyServiceTests.cs @@ -41,7 +41,13 @@ public class JellyfinProxyServiceTests ClientName = "TestClient", DeviceName = "TestDevice", DeviceId = "test-device-id", +<<<<<<< HEAD ClientVersion = "1.0.1" +||||||| f68706f + ClientVersion = "1.0.0" +======= + ClientVersion = "1.0.3" +>>>>>>> beta }; var httpContext = new DefaultHttpContext(); diff --git a/allstarr.Tests/JellyfinResponseBuilderTests.cs b/allstarr.Tests/JellyfinResponseBuilderTests.cs index 0fce213..39b1d8b 100644 --- a/allstarr.Tests/JellyfinResponseBuilderTests.cs +++ b/allstarr.Tests/JellyfinResponseBuilderTests.cs @@ -148,8 +148,8 @@ public class JellyfinResponseBuilderTests // Assert Assert.Equal("ext-playlist-deezer-999", result["Id"]); - Assert.Equal("Summer Vibes", result["Name"]); - Assert.Equal("Playlist", result["Type"]); + Assert.Equal("Summer Vibes [S/P]", result["Name"]); + Assert.Equal("MusicAlbum", result["Type"]); Assert.Equal("DJ Cool", result["AlbumArtist"]); Assert.Equal(50, result["ChildCount"]); Assert.Equal(2023, result["ProductionYear"]); @@ -270,7 +270,7 @@ public class JellyfinResponseBuilderTests // Arrange var playlist = new ExternalPlaylist { - Id = "pl-1", + Id = "ext-deezer-playlist-1", Name = "My Playlist", Provider = "deezer", ExternalId = "123" diff --git a/allstarr.Tests/LastFmSignatureTests.cs b/allstarr.Tests/LastFmSignatureTests.cs new file mode 100644 index 0000000..23c6552 --- /dev/null +++ b/allstarr.Tests/LastFmSignatureTests.cs @@ -0,0 +1,282 @@ +using Xunit; +using System.Security.Cryptography; +using System.Text; + +namespace allstarr.Tests; + +/// +/// Tests for Last.fm API signature generation +/// Ensures signatures are generated correctly with uppercase hex format +/// +public class LastFmSignatureTests +{ + // Replicate the signature generation logic from ScrobblingAdminController + private static string GenerateSignature(Dictionary parameters, string sharedSecret) + { + var sorted = parameters.OrderBy(kvp => kvp.Key); + var signatureString = new StringBuilder(); + + foreach (var kvp in sorted) + { + signatureString.Append(kvp.Key); + signatureString.Append(kvp.Value); + } + + signatureString.Append(sharedSecret); + + var bytes = Encoding.UTF8.GetBytes(signatureString.ToString()); + var hash = MD5.HashData(bytes); + + // Convert to UPPERCASE hex string (Last.fm requires uppercase) + var sb = new StringBuilder(); + foreach (byte b in hash) + { + sb.Append(b.ToString("X2")); + } + return sb.ToString(); + } + + [Fact] + public void GenerateSignature_BasicParameters_ReturnsUppercaseHex() + { + // Arrange + var parameters = new Dictionary + { + ["api_key"] = "testkey", + ["method"] = "auth.getMobileSession", + ["username"] = "testuser", + ["password"] = "testpass" + }; + var sharedSecret = "testsecret"; + + // Act + var signature = GenerateSignature(parameters, sharedSecret); + + // Assert + Assert.Matches("^[A-F0-9]{32}$", signature); // 32 uppercase hex chars + Assert.Equal(32, signature.Length); + } + + [Fact] + public void GenerateSignature_PasswordWithSpecialChars_HandlesCorrectly() + { + // Arrange + var parameters = new Dictionary + { + ["api_key"] = "cb3bdcd415fcb40cd572b137b2b255f5", + ["method"] = "auth.getMobileSession", + ["username"] = "testuser123", + ["password"] = "fake!test456" + }; + var sharedSecret = "3a08f9fad6ddc4c35b0dce0062cecb5e"; + + // Act + var signature = GenerateSignature(parameters, sharedSecret); + + // Assert + Assert.Matches("^[A-F0-9]{32}$", signature); + Assert.Equal(32, signature.Length); + } + + [Theory] + [InlineData("password!")] + [InlineData("pass&word")] + [InlineData("pass*word")] + [InlineData("pass$word")] + [InlineData("pass@word")] + [InlineData("pass#word")] + [InlineData("pass%word")] + [InlineData("pass^word")] + public void GenerateSignature_VariousSpecialChars_GeneratesValidSignature(string password) + { + // Arrange + var parameters = new Dictionary + { + ["api_key"] = "testkey", + ["method"] = "auth.getMobileSession", + ["username"] = "testuser", + ["password"] = password + }; + var sharedSecret = "testsecret"; + + // Act + var signature = GenerateSignature(parameters, sharedSecret); + + // Assert + Assert.Matches("^[A-F0-9]{32}$", signature); + Assert.Equal(32, signature.Length); + } + + [Fact] + public void GenerateSignature_ParameterOrder_DoesNotMatter() + { + // Arrange - same parameters, different order + var parameters1 = new Dictionary + { + ["api_key"] = "testkey", + ["method"] = "auth.getMobileSession", + ["username"] = "testuser", + ["password"] = "testpass" + }; + + var parameters2 = new Dictionary + { + ["password"] = "testpass", + ["username"] = "testuser", + ["method"] = "auth.getMobileSession", + ["api_key"] = "testkey" + }; + + var sharedSecret = "testsecret"; + + // Act + var signature1 = GenerateSignature(parameters1, sharedSecret); + var signature2 = GenerateSignature(parameters2, sharedSecret); + + // Assert + Assert.Equal(signature1, signature2); + } + + [Fact] + public void GenerateSignature_EmptyPassword_HandlesCorrectly() + { + // Arrange + var parameters = new Dictionary + { + ["api_key"] = "testkey", + ["method"] = "auth.getMobileSession", + ["username"] = "testuser", + ["password"] = "" + }; + var sharedSecret = "testsecret"; + + // Act + var signature = GenerateSignature(parameters, sharedSecret); + + // Assert + Assert.Matches("^[A-F0-9]{32}$", signature); + } + + [Fact] + public void GenerateSignature_UnicodePassword_HandlesCorrectly() + { + // Arrange + var parameters = new Dictionary + { + ["api_key"] = "testkey", + ["method"] = "auth.getMobileSession", + ["username"] = "testuser", + ["password"] = "pässwörd日本語" + }; + var sharedSecret = "testsecret"; + + // Act + var signature = GenerateSignature(parameters, sharedSecret); + + // Assert + Assert.Matches("^[A-F0-9]{32}$", signature); + Assert.Equal(32, signature.Length); + } + + [Fact] + public void GenerateSignature_LongPassword_HandlesCorrectly() + { + // Arrange + var parameters = new Dictionary + { + ["api_key"] = "testkey", + ["method"] = "auth.getMobileSession", + ["username"] = "testuser", + ["password"] = new string('a', 1000) // Very long password + }; + var sharedSecret = "testsecret"; + + // Act + var signature = GenerateSignature(parameters, sharedSecret); + + // Assert + Assert.Matches("^[A-F0-9]{32}$", signature); + } + + [Fact] + public void GenerateSignature_PasswordWithWhitespace_PreservesWhitespace() + { + // Arrange + var parameters1 = new Dictionary + { + ["api_key"] = "testkey", + ["method"] = "auth.getMobileSession", + ["username"] = "testuser", + ["password"] = "pass word" + }; + + var parameters2 = new Dictionary + { + ["api_key"] = "testkey", + ["method"] = "auth.getMobileSession", + ["username"] = "testuser", + ["password"] = "password" + }; + + var sharedSecret = "testsecret"; + + // Act + var signature1 = GenerateSignature(parameters1, sharedSecret); + var signature2 = GenerateSignature(parameters2, sharedSecret); + + // Assert - should be different because whitespace matters + Assert.NotEqual(signature1, signature2); + } + + [Fact] + public void GenerateSignature_CaseSensitivePassword_GeneratesDifferentSignatures() + { + // Arrange + var parameters1 = new Dictionary + { + ["api_key"] = "testkey", + ["method"] = "auth.getMobileSession", + ["username"] = "testuser", + ["password"] = "Password" + }; + + var parameters2 = new Dictionary + { + ["api_key"] = "testkey", + ["method"] = "auth.getMobileSession", + ["username"] = "testuser", + ["password"] = "password" + }; + + var sharedSecret = "testsecret"; + + // Act + var signature1 = GenerateSignature(parameters1, sharedSecret); + var signature2 = GenerateSignature(parameters2, sharedSecret); + + // Assert - passwords are case-sensitive + Assert.NotEqual(signature1, signature2); + } + + [Fact] + public void GenerateSignature_ConsistentResults_MatchesExpected() + { + // Arrange - Test with known values to ensure consistency + var parameters = new Dictionary + { + ["api_key"] = "testkey123", + ["method"] = "auth.getMobileSession", + ["username"] = "testuser", + ["password"] = "testpass!" + }; + var sharedSecret = "testsecret456"; + + // Act + var signature1 = GenerateSignature(parameters, sharedSecret); + var signature2 = GenerateSignature(parameters, sharedSecret); + + // Assert - should be consistent + Assert.Equal(signature1, signature2); + Assert.Matches("^[A-F0-9]{32}$", signature1); + } +} diff --git a/allstarr.Tests/PathHelperExtraTests.cs b/allstarr.Tests/PathHelperExtraTests.cs new file mode 100644 index 0000000..58a855a --- /dev/null +++ b/allstarr.Tests/PathHelperExtraTests.cs @@ -0,0 +1,82 @@ +using System; +using System.IO; +using allstarr.Services.Common; + +namespace allstarr.Tests; + +public class PathHelperExtraTests : IDisposable +{ + private readonly string _testPath; + + public PathHelperExtraTests() + { + _testPath = Path.Combine(Path.GetTempPath(), "allstarr-pathhelper-extra-" + Guid.NewGuid()); + Directory.CreateDirectory(_testPath); + } + + public void Dispose() + { + if (Directory.Exists(_testPath)) Directory.Delete(_testPath, true); + } + + [Fact] + public void BuildTrackPath_WithProviderAndExternalId_SanitizesSuffix() + { + var downloadPath = _testPath; + var artist = "Artist"; + var album = "Album"; + var title = "Song"; + var provider = "prov/../ider"; // contains slashes and dots + var externalId = "..\evil|id"; // contains traversal and invalid chars + + var path = PathHelper.BuildTrackPath(downloadPath, artist, album, title, 1, ".mp3", provider, externalId); + + // Ensure the path contains sanitized provider/external id and no directory separators in the filename + var fileName = Path.GetFileName(path); + Assert.Contains("[", fileName); + Assert.DoesNotContain("..", fileName); + Assert.DoesNotContain("/", fileName); + Assert.DoesNotContain("\\", fileName); + } + + [Fact] + public void ResolveUniquePath_HandlesNoDirectoryProvided() + { + // Arrange - create files in current directory + var originalCurrent = Directory.GetCurrentDirectory(); + try + { + Directory.SetCurrentDirectory(_testPath); + var baseName = "song.mp3"; + File.WriteAllText(Path.Combine(_testPath, baseName), "x"); + + // Act + var unique = PathHelper.ResolveUniquePath(baseName); + + // Assert + Assert.NotEqual(baseName, unique); + Assert.Contains("song (1).mp3", unique); + } + finally + { + Directory.SetCurrentDirectory(originalCurrent); + } + } + + [Fact] + public void ResolveUniquePath_ThrowsAfterManyAttempts() + { + // Arrange + var basePath = Path.Combine(_testPath, "a.mp3"); + // Create files a.mp3 through a (10010).mp3 to force exhaustion + File.WriteAllText(basePath, "x"); + for (int i = 1; i <= 10005; i++) + { + var p = Path.Combine(_testPath, $"a ({i}).mp3"); + File.WriteAllText(p, "x"); + } + + // Act & Assert + Assert.Throws(() => PathHelper.ResolveUniquePath(basePath)); + } +} diff --git a/allstarr.Tests/PlaylistIdHelperTests.cs b/allstarr.Tests/PlaylistIdHelperTests.cs index 53e1c95..210ec22 100644 --- a/allstarr.Tests/PlaylistIdHelperTests.cs +++ b/allstarr.Tests/PlaylistIdHelperTests.cs @@ -11,7 +11,7 @@ public class PlaylistIdHelperTests public void IsExternalPlaylist_WithValidPlaylistId_ReturnsTrue() { // Arrange - var id = "pl-deezer-123456"; + var id = "ext-deezer-playlist-123456"; // Act var result = PlaylistIdHelper.IsExternalPlaylist(id); @@ -24,7 +24,7 @@ public class PlaylistIdHelperTests public void IsExternalPlaylist_WithValidQobuzPlaylistId_ReturnsTrue() { // Arrange - var id = "pl-qobuz-789012"; + var id = "ext-qobuz-playlist-789012"; // Act var result = PlaylistIdHelper.IsExternalPlaylist(id); @@ -37,7 +37,7 @@ public class PlaylistIdHelperTests public void IsExternalPlaylist_WithUpperCasePrefix_ReturnsTrue() { // Arrange - var id = "PL-deezer-123456"; + var id = "EXT-deezer-PLAYLIST-123456"; // Act var result = PlaylistIdHelper.IsExternalPlaylist(id); @@ -106,7 +106,7 @@ public class PlaylistIdHelperTests public void ParsePlaylistId_WithValidDeezerPlaylistId_ReturnsProviderAndExternalId() { // Arrange - var id = "pl-deezer-123456"; + var id = "ext-deezer-playlist-123456"; // Act var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(id); @@ -120,7 +120,7 @@ public class PlaylistIdHelperTests public void ParsePlaylistId_WithValidQobuzPlaylistId_ReturnsProviderAndExternalId() { // Arrange - var id = "pl-qobuz-789012"; + var id = "ext-qobuz-playlist-789012"; // Act var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(id); @@ -134,7 +134,7 @@ public class PlaylistIdHelperTests public void ParsePlaylistId_WithExternalIdContainingDashes_ParsesCorrectly() { // Arrange - var id = "pl-deezer-abc-def-123"; + var id = "ext-deezer-playlist-abc-def-123"; // Act var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(id); @@ -148,7 +148,7 @@ public class PlaylistIdHelperTests public void ParsePlaylistId_WithInvalidFormatNoProvider_ThrowsArgumentException() { // Arrange - var id = "pl-123456"; + var id = "ext-playlist-123456"; // Act & Assert var exception = Assert.Throws(() => PlaylistIdHelper.ParsePlaylistId(id)); @@ -190,7 +190,7 @@ public class PlaylistIdHelperTests public void ParsePlaylistId_WithOnlyPrefix_ThrowsArgumentException() { // Arrange - var id = "pl-"; + var id = "ext-"; // Act & Assert var exception = Assert.Throws(() => PlaylistIdHelper.ParsePlaylistId(id)); @@ -212,7 +212,7 @@ public class PlaylistIdHelperTests var result = PlaylistIdHelper.CreatePlaylistId(provider, externalId); // Assert - Assert.Equal("pl-deezer-123456", result); + Assert.Equal("ext-deezer-playlist-123456", result); } [Fact] @@ -226,7 +226,7 @@ public class PlaylistIdHelperTests var result = PlaylistIdHelper.CreatePlaylistId(provider, externalId); // Assert - Assert.Equal("pl-qobuz-789012", result); + Assert.Equal("ext-qobuz-playlist-789012", result); } [Fact] @@ -240,7 +240,7 @@ public class PlaylistIdHelperTests var result = PlaylistIdHelper.CreatePlaylistId(provider, externalId); // Assert - Assert.Equal("pl-deezer-123456", result); + Assert.Equal("ext-deezer-playlist-123456", result); } [Fact] @@ -254,7 +254,7 @@ public class PlaylistIdHelperTests var result = PlaylistIdHelper.CreatePlaylistId(provider, externalId); // Assert - Assert.Equal("pl-deezer-123456", result); + Assert.Equal("ext-deezer-playlist-123456", result); } [Fact] @@ -268,7 +268,7 @@ public class PlaylistIdHelperTests var result = PlaylistIdHelper.CreatePlaylistId(provider, externalId); // Assert - Assert.Equal("pl-deezer-abc-def-123", result); + Assert.Equal("ext-deezer-playlist-abc-def-123", result); } [Fact] diff --git a/allstarr.Tests/QobuzDownloadServiceTests.cs b/allstarr.Tests/QobuzDownloadServiceTests.cs index 1cf840f..ba627d7 100644 --- a/allstarr.Tests/QobuzDownloadServiceTests.cs +++ b/allstarr.Tests/QobuzDownloadServiceTests.cs @@ -151,7 +151,13 @@ public class QobuzDownloadServiceTests : IDisposable var mockResponse = new HttpResponseMessage { StatusCode = HttpStatusCode.OK, +<<<<<<< HEAD Content = new StringContent(@"") +||||||| f68706f + Content = new StringContent(@"") +======= + Content = new StringContent(@"") +>>>>>>> beta }; _httpMessageHandlerMock.Protected() diff --git a/allstarr.Tests/QobuzMetadataServiceTests.cs b/allstarr.Tests/QobuzMetadataServiceTests.cs index 1086d38..40189d4 100644 --- a/allstarr.Tests/QobuzMetadataServiceTests.cs +++ b/allstarr.Tests/QobuzMetadataServiceTests.cs @@ -100,7 +100,7 @@ public class QobuzMetadataServiceTests Assert.Equal(12000, result[0].Duration); Assert.Equal("qobuz", result[0].Provider); Assert.Equal("1578664", result[0].ExternalId); - Assert.Equal("pl-qobuz-1578664", result[0].Id); + Assert.Equal("ext-qobuz-playlist-1578664", result[0].Id); Assert.Equal("Qobuz Editorial", result[0].CuratorName); } @@ -198,7 +198,7 @@ public class QobuzMetadataServiceTests Assert.Equal("Top jazz tracks", result.Description); Assert.Equal(100, result.TrackCount); Assert.Equal(24000, result.Duration); - Assert.Equal("pl-qobuz-1578664", result.Id); + Assert.Equal("ext-qobuz-playlist-1578664", result.Id); Assert.Equal("Qobuz Editor", result.CuratorName); Assert.Equal("https://example.com/cover-large.jpg", result.CoverUrl); } diff --git a/allstarr.Tests/ScrobblingAdminControllerTests.cs b/allstarr.Tests/ScrobblingAdminControllerTests.cs new file mode 100644 index 0000000..51592d5 --- /dev/null +++ b/allstarr.Tests/ScrobblingAdminControllerTests.cs @@ -0,0 +1,191 @@ +using Xunit; +using Moq; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Configuration; +using allstarr.Controllers; +using allstarr.Models.Settings; +using allstarr.Services.Admin; +using System.Net; +using System.Net.Http; + +namespace allstarr.Tests; + +public class ScrobblingAdminControllerTests +{ + private readonly Mock> _mockSettings; + private readonly Mock _mockConfiguration; + private readonly Mock> _mockLogger; + private readonly Mock _mockHttpClientFactory; + private readonly ScrobblingAdminController _controller; + + public ScrobblingAdminControllerTests() + { + _mockSettings = new Mock>(); + _mockConfiguration = new Mock(); + _mockLogger = new Mock>(); + _mockHttpClientFactory = new Mock(); + + var settings = new ScrobblingSettings + { + Enabled = true, + LastFm = new LastFmSettings + { + Enabled = true, + ApiKey = "cb3bdcd415fcb40cd572b137b2b255f5", + SharedSecret = "3a08f9fad6ddc4c35b0dce0062cecb5e", + SessionKey = "", + Username = null, + Password = null + } + }; + + _mockSettings.Setup(s => s.Value).Returns(settings); + + _controller = new ScrobblingAdminController( + _mockSettings.Object, + _mockConfiguration.Object, + _mockHttpClientFactory.Object, + _mockLogger.Object, + null! // AdminHelperService not needed for these tests + ); + } + + [Fact] + public void GetStatus_ReturnsCorrectConfiguration() + { + // Act + var result = _controller.GetStatus() as OkObjectResult; + + // Assert + Assert.NotNull(result); + Assert.Equal(200, result.StatusCode); + + dynamic? status = result.Value; + Assert.NotNull(status); + } + + [Theory] + [InlineData("", "password123")] + [InlineData("username", "")] + [InlineData(null, "password123")] + [InlineData("username", null)] + public async Task AuthenticateLastFm_MissingCredentials_ReturnsBadRequest(string? username, string? password) + { + // Arrange - set credentials in settings + var settings = new ScrobblingSettings + { + Enabled = true, + LastFm = new LastFmSettings + { + Enabled = true, + ApiKey = "cb3bdcd415fcb40cd572b137b2b255f5", + SharedSecret = "3a08f9fad6ddc4c35b0dce0062cecb5e", + SessionKey = "", + Username = username, + Password = password + } + }; + _mockSettings.Setup(s => s.Value).Returns(settings); + + var controller = new ScrobblingAdminController( + _mockSettings.Object, + _mockConfiguration.Object, + _mockHttpClientFactory.Object, + _mockLogger.Object, + null! // AdminHelperService not needed for this test + ); + + // Act + var result = await controller.AuthenticateLastFm() as BadRequestObjectResult; + + // Assert + Assert.NotNull(result); + Assert.Equal(400, result.StatusCode); + } + + [Fact] + public void DebugAuth_ValidCredentials_ReturnsDebugInfo() + { + // Arrange + var request = new ScrobblingAdminController.AuthenticateRequest + { + Username = "testuser", + Password = "testpass123" + }; + + // Act + var result = _controller.DebugAuth(request) as OkObjectResult; + + // Assert + Assert.NotNull(result); + Assert.Equal(200, result.StatusCode); + + dynamic? debugInfo = result.Value; + Assert.NotNull(debugInfo); + } + + [Theory] + [InlineData("user!@#$%", "pass!@#$%")] + [InlineData("user with spaces", "pass with spaces")] + [InlineData("user\ttab", "pass\ttab")] + [InlineData("user'quote", "pass\"doublequote")] + [InlineData("user&ersand", "pass&ersand")] + [InlineData("user*asterisk", "pass*asterisk")] + [InlineData("user$dollar", "pass$dollar")] + [InlineData("user(paren)", "pass)paren")] + [InlineData("user[bracket]", "pass{bracket}")] + public void DebugAuth_SpecialCharacters_HandlesCorrectly(string username, string password) + { + // Arrange + var request = new ScrobblingAdminController.AuthenticateRequest + { + Username = username, + Password = password + }; + + // Act + var result = _controller.DebugAuth(request) as OkObjectResult; + + // Assert + Assert.NotNull(result); + Assert.Equal(200, result.StatusCode); + Assert.NotNull(result.Value); + + // Use reflection to access anonymous type properties + var passwordLengthProp = result.Value.GetType().GetProperty("PasswordLength"); + Assert.NotNull(passwordLengthProp); + var passwordLength = (int?)passwordLengthProp.GetValue(result.Value); + Assert.Equal(password.Length, passwordLength); + } + + [Theory] + [InlineData("test!pass456")] + [InlineData("p@ssw0rd!")] + [InlineData("test&test")] + [InlineData("my*password")] + [InlineData("pass$word")] + public void DebugAuth_PasswordsWithShellSpecialChars_CalculatesCorrectLength(string password) + { + // Arrange + var request = new ScrobblingAdminController.AuthenticateRequest + { + Username = "testuser", + Password = password + }; + + // Act + var result = _controller.DebugAuth(request) as OkObjectResult; + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Value); + + // Use reflection to access anonymous type properties + var passwordLengthProp = result.Value.GetType().GetProperty("PasswordLength"); + Assert.NotNull(passwordLengthProp); + var passwordLength = (int?)passwordLengthProp.GetValue(result.Value); + Assert.Equal(password.Length, passwordLength); + } +} diff --git a/allstarr.Tests/ScrobblingHelperTests.cs b/allstarr.Tests/ScrobblingHelperTests.cs new file mode 100644 index 0000000..65cdd31 --- /dev/null +++ b/allstarr.Tests/ScrobblingHelperTests.cs @@ -0,0 +1,189 @@ +using Xunit; +using allstarr.Services.Scrobbling; +using allstarr.Models.Scrobbling; + +namespace allstarr.Tests; + +/// +/// Tests for ScrobblingHelper utility functions +/// +public class ScrobblingHelperTests +{ + [Theory] + [InlineData(0, false)] // 0 seconds - too short + [InlineData(29, false)] // 29 seconds - too short + [InlineData(30, true)] // 30 seconds - minimum + [InlineData(60, true)] // 1 minute + [InlineData(240, true)] // 4 minutes + [InlineData(300, true)] // 5 minutes + [InlineData(3600, true)] // 1 hour + public void IsTrackLongEnoughToScrobble_VariousDurations_ReturnsCorrectly(int durationSeconds, bool expected) + { + // Last.fm rules: tracks must be at least 30 seconds long + // Act + var result = ScrobblingHelper.IsTrackLongEnoughToScrobble(durationSeconds); + + // Assert + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(100, 50, true)] // Listened to 50% of 100s track + [InlineData(100, 51, true)] // Listened to 51% of 100s track + [InlineData(100, 49, false)] // Listened to 49% of 100s track + [InlineData(600, 240, true)] // Listened to 4 minutes of 10 minute track - meets 4min threshold! + [InlineData(600, 239, false)] // Listened to 3:59 of 10 minute track - just under threshold + [InlineData(600, 300, true)] // Listened to 5 minutes of 10 minute track (50%) + [InlineData(120, 60, true)] // Listened to 50% of 2 minute track + [InlineData(30, 15, true)] // Listened to 50% of 30 second track + public void HasListenedEnoughToScrobble_VariousPlaytimes_ReturnsCorrectly( + int trackDurationSeconds, int playedSeconds, bool expected) + { + // Last.fm rules: must listen to at least 50% of track OR 4 minutes (whichever comes first) + // Act + var result = ScrobblingHelper.HasListenedEnoughToScrobble(trackDurationSeconds, playedSeconds); + + // Assert + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(600, 240, true)] // 4 minutes of 10 minute track + [InlineData(600, 239, false)] // 3:59 of 10 minute track + [InlineData(1000, 240, true)] // 4 minutes of 16+ minute track + [InlineData(1000, 500, true)] // 8+ minutes of 16+ minute track + public void FourMinuteRule_LongTracks_AppliesCorrectly( + int trackDurationSeconds, int playedSeconds, bool expected) + { + // For tracks longer than 8 minutes, only need to listen to 4 minutes + // Act + var halfDuration = trackDurationSeconds / 2.0; + var fourMinutes = 240; + var threshold = Math.Min(halfDuration, fourMinutes); + var result = playedSeconds >= threshold; + + // Assert + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("", "", false)] + [InlineData("Track", "", false)] + [InlineData("", "Artist", false)] + [InlineData("Track", "Artist", true)] + public void HasRequiredMetadata_VariousInputs_ValidatesCorrectly( + string trackName, string artistName, bool expected) + { + // Scrobbling requires at minimum: track name and artist name + // Act + var result = ScrobblingHelper.HasRequiredMetadata(trackName, artistName); + + // Assert + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("Track Name", "Artist Name", "Track Name - Artist Name")] + [InlineData("Song", "Band", "Song - Band")] + [InlineData("Title (feat. Guest)", "Main Artist", "Title (feat. Guest) - Main Artist")] + public void FormatScrobbleDisplay_VariousInputs_FormatsCorrectly( + string trackName, string artistName, string expected) + { + // Act + var result = ScrobblingHelper.FormatTrackForDisplay(trackName, artistName); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void ScrobbleTrack_ValidData_CreatesCorrectObject() + { + // Arrange + var track = new ScrobbleTrack + { + Title = "Test Track", + Artist = "Test Artist", + Album = "Test Album", + Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + DurationSeconds = 180 + }; + + // Assert + Assert.NotNull(track.Title); + Assert.NotNull(track.Artist); + Assert.True(track.Timestamp > 0); + Assert.True(track.DurationSeconds > 0); + } + + [Theory] + [InlineData("Track & Artist", "Track & Artist")] + [InlineData("Track (feat. Someone)", "Track (feat. Someone)")] + [InlineData("Track - Remix", "Track - Remix")] + [InlineData("Track [Radio Edit]", "Track [Radio Edit]")] + public void TrackName_SpecialCharacters_PreservesCorrectly(string input, string expected) + { + // Track names with special characters should be preserved as-is + // Act + var track = new ScrobbleTrack + { + Title = input, + Artist = "Artist", + Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + }; + + // Assert + Assert.Equal(expected, track.Title); + } + + [Theory] + [InlineData(1000000000)] // 2001-09-09 + [InlineData(1500000000)] // 2017-07-14 + [InlineData(1700000000)] // 2023-11-14 + public void Timestamp_ValidUnixTimestamps_AcceptsCorrectly(long timestamp) + { + // Act + var track = new ScrobbleTrack + { + Title = "Track", + Artist = "Artist", + Timestamp = timestamp + }; + + // Assert + Assert.Equal(timestamp, track.Timestamp); + Assert.True(timestamp > 0); + } + + [Theory] + [InlineData(-1)] + [InlineData(0)] + public void Timestamp_InvalidValues_ShouldBeRejected(long timestamp) + { + // Timestamps should be positive Unix timestamps + // Act & Assert + Assert.True(timestamp <= 0); + } + + [Theory] + [InlineData(30)] // Minimum valid duration + [InlineData(180)] // 3 minutes + [InlineData(240)] // 4 minutes (scrobble threshold) + [InlineData(300)] // 5 minutes + [InlineData(3600)] // 1 hour + public void Duration_ValidDurations_AcceptsCorrectly(int duration) + { + // Act + var track = new ScrobbleTrack + { + Title = "Track", + Artist = "Artist", + Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + DurationSeconds = duration + }; + + // Assert + Assert.Equal(duration, track.DurationSeconds); + Assert.True(duration >= 30); + } +} diff --git a/allstarr.Tests/SpotifyMappingServiceTests.cs b/allstarr.Tests/SpotifyMappingServiceTests.cs new file mode 100644 index 0000000..b42516f --- /dev/null +++ b/allstarr.Tests/SpotifyMappingServiceTests.cs @@ -0,0 +1,217 @@ +using Xunit; +using Moq; +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using allstarr.Services.Spotify; +using allstarr.Services.Common; +using allstarr.Models.Spotify; +using allstarr.Models.Settings; + +namespace allstarr.Tests; + +public class SpotifyMappingServiceTests +{ + private readonly Mock> _mockCacheLogger; + private readonly Mock> _mockLogger; + private readonly RedisCacheService _cache; + private readonly SpotifyMappingService _service; + + public SpotifyMappingServiceTests() + { + _mockCacheLogger = new Mock>(); + _mockLogger = new Mock>(); + + // Use disabled Redis for tests + var redisSettings = Options.Create(new RedisSettings + { + Enabled = false, + ConnectionString = "localhost:6379" + }); + + _cache = new RedisCacheService(redisSettings, _mockCacheLogger.Object); + _service = new SpotifyMappingService(_cache, _mockLogger.Object); + } + + [Fact] + public void SpotifyTrackMapping_NeedsValidation_LocalMapping_WithinSevenDays() + { + // Arrange + var mapping = new SpotifyTrackMapping + { + SpotifyId = "test", + TargetType = "local", + LocalId = "abc123", + Source = "auto", + CreatedAt = DateTime.UtcNow, + LastValidatedAt = DateTime.UtcNow.AddDays(-3) // 3 days ago + }; + + // Act + var needsValidation = mapping.NeedsValidation(isPlaylistSync: false); + + // Assert + Assert.False(needsValidation); // Should not need validation yet + } + + [Fact] + public void SpotifyTrackMapping_NeedsValidation_LocalMapping_AfterSevenDays() + { + // Arrange + var mapping = new SpotifyTrackMapping + { + SpotifyId = "test", + TargetType = "local", + LocalId = "abc123", + Source = "auto", + CreatedAt = DateTime.UtcNow, + LastValidatedAt = DateTime.UtcNow.AddDays(-8) // 8 days ago + }; + + // Act + var needsValidation = mapping.NeedsValidation(isPlaylistSync: false); + + // Assert + Assert.True(needsValidation); // Should need validation + } + + [Fact] + public void SpotifyTrackMapping_NeedsValidation_ExternalMapping_OnPlaylistSync() + { + // Arrange + var mapping = new SpotifyTrackMapping + { + SpotifyId = "test", + TargetType = "external", + ExternalProvider = "SquidWTF", + ExternalId = "789", + Source = "auto", + CreatedAt = DateTime.UtcNow, + LastValidatedAt = DateTime.UtcNow.AddMinutes(-5) // Just validated + }; + + // Act + var needsValidation = mapping.NeedsValidation(isPlaylistSync: true); + + // Assert + Assert.True(needsValidation); // Should validate on every sync + } + + [Fact] + public void SpotifyTrackMapping_NeedsValidation_ExternalMapping_NotOnPlaylistSync() + { + // Arrange + var mapping = new SpotifyTrackMapping + { + SpotifyId = "test", + TargetType = "external", + ExternalProvider = "SquidWTF", + ExternalId = "789", + Source = "auto", + CreatedAt = DateTime.UtcNow, + LastValidatedAt = DateTime.UtcNow.AddMinutes(-5) + }; + + // Act + var needsValidation = mapping.NeedsValidation(isPlaylistSync: false); + + // Assert + Assert.False(needsValidation); // Should not validate if not playlist sync + } + + [Fact] + public void SpotifyTrackMapping_NeedsValidation_NeverValidated() + { + // Arrange + var mapping = new SpotifyTrackMapping + { + SpotifyId = "test", + TargetType = "local", + LocalId = "abc123", + Source = "auto", + CreatedAt = DateTime.UtcNow, + LastValidatedAt = null // Never validated + }; + + // Act + var needsValidation = mapping.NeedsValidation(isPlaylistSync: false); + + // Assert + Assert.True(needsValidation); // Should always validate if never validated + } + + [Fact] + public async Task SaveMappingAsync_RejectsInvalidLocalMapping() + { + // Arrange + var mapping = new SpotifyTrackMapping + { + SpotifyId = "3n3Ppam7vgaVa1iaRUc9Lp", + TargetType = "local", + LocalId = null, // Invalid - no LocalId + Source = "auto", + CreatedAt = DateTime.UtcNow + }; + + // Act + var result = await _service.SaveMappingAsync(mapping); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task SaveMappingAsync_RejectsInvalidExternalMapping() + { + // Arrange + var mapping = new SpotifyTrackMapping + { + SpotifyId = "3n3Ppam7vgaVa1iaRUc9Lp", + TargetType = "external", + ExternalProvider = "SquidWTF", + ExternalId = null, // Invalid - no ExternalId + Source = "auto", + CreatedAt = DateTime.UtcNow + }; + + // Act + var result = await _service.SaveMappingAsync(mapping); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task SaveMappingAsync_RejectsEmptySpotifyId() + { + // Arrange + var mapping = new SpotifyTrackMapping + { + SpotifyId = "", // Invalid - empty + TargetType = "local", + LocalId = "abc123", + Source = "auto", + CreatedAt = DateTime.UtcNow + }; + + // Act + var result = await _service.SaveMappingAsync(mapping); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task GetMappingAsync_ReturnsNullWhenNotFound() + { + // Arrange + var spotifyId = "nonexistent"; + + // Act + var result = await _service.GetMappingAsync(spotifyId); + + // Assert + Assert.Null(result); // Redis is disabled, so nothing will be found + } +} diff --git a/allstarr.Tests/SpotifyMappingValidationServiceTests.cs b/allstarr.Tests/SpotifyMappingValidationServiceTests.cs new file mode 100644 index 0000000..5dab4fd --- /dev/null +++ b/allstarr.Tests/SpotifyMappingValidationServiceTests.cs @@ -0,0 +1,173 @@ +using Xunit; +using System; +using allstarr.Models.Spotify; + +namespace allstarr.Tests; + +/// +/// Tests for Spotify mapping validation logic. +/// Focuses on the NeedsValidation() method and validation rules. +/// +public class SpotifyMappingValidationTests +{ + + [Fact] + public void SpotifyTrackMapping_NeedsValidation_LocalMapping_WithinSevenDays() + { + // Arrange + var mapping = new SpotifyTrackMapping + { + SpotifyId = "test", + TargetType = "local", + LocalId = "abc123", + Source = "auto", + CreatedAt = DateTime.UtcNow, + LastValidatedAt = DateTime.UtcNow.AddDays(-3) // 3 days ago + }; + + // Act + var needsValidation = mapping.NeedsValidation(isPlaylistSync: false); + + // Assert + Assert.False(needsValidation); // Should not need validation yet + } + + [Fact] + public void SpotifyTrackMapping_NeedsValidation_LocalMapping_AfterSevenDays() + { + // Arrange + var mapping = new SpotifyTrackMapping + { + SpotifyId = "test", + TargetType = "local", + LocalId = "abc123", + Source = "auto", + CreatedAt = DateTime.UtcNow, + LastValidatedAt = DateTime.UtcNow.AddDays(-8) // 8 days ago + }; + + // Act + var needsValidation = mapping.NeedsValidation(isPlaylistSync: false); + + // Assert + Assert.True(needsValidation); // Should need validation + } + + [Fact] + public void SpotifyTrackMapping_NeedsValidation_ExternalMapping_OnPlaylistSync() + { + // Arrange + var mapping = new SpotifyTrackMapping + { + SpotifyId = "test", + TargetType = "external", + ExternalProvider = "SquidWTF", + ExternalId = "789", + Source = "auto", + CreatedAt = DateTime.UtcNow, + LastValidatedAt = DateTime.UtcNow.AddMinutes(-5) // Just validated + }; + + // Act + var needsValidation = mapping.NeedsValidation(isPlaylistSync: true); + + // Assert + Assert.True(needsValidation); // Should validate on every sync + } + + [Fact] + public void SpotifyTrackMapping_NeedsValidation_ExternalMapping_NotOnPlaylistSync() + { + // Arrange + var mapping = new SpotifyTrackMapping + { + SpotifyId = "test", + TargetType = "external", + ExternalProvider = "SquidWTF", + ExternalId = "789", + Source = "auto", + CreatedAt = DateTime.UtcNow, + LastValidatedAt = DateTime.UtcNow.AddMinutes(-5) + }; + + // Act + var needsValidation = mapping.NeedsValidation(isPlaylistSync: false); + + // Assert + Assert.False(needsValidation); // Should not validate if not playlist sync + } + + [Fact] + public void SpotifyTrackMapping_NeedsValidation_NeverValidated() + { + // Arrange + var mapping = new SpotifyTrackMapping + { + SpotifyId = "test", + TargetType = "local", + LocalId = "abc123", + Source = "auto", + CreatedAt = DateTime.UtcNow, + LastValidatedAt = null // Never validated + }; + + // Act + var needsValidation = mapping.NeedsValidation(isPlaylistSync: false); + + // Assert + Assert.True(needsValidation); // Should always validate if never validated + } + + [Fact] + public void SpotifyTrackMapping_NeedsValidation_LocalMapping_ExactlySevenDays() + { + // Arrange + var mapping = new SpotifyTrackMapping + { + SpotifyId = "test", + TargetType = "local", + LocalId = "abc123", + Source = "auto", + CreatedAt = DateTime.UtcNow, + LastValidatedAt = DateTime.UtcNow.AddDays(-7) // Exactly 7 days + }; + + // Act + var needsValidation = mapping.NeedsValidation(isPlaylistSync: false); + + // Assert + Assert.True(needsValidation); // Should validate at 7 days + } + + [Fact] + public void SpotifyTrackMapping_NeedsValidation_ManualMapping_FollowsSameRules() + { + // Arrange - Manual local mapping + var manualLocal = new SpotifyTrackMapping + { + SpotifyId = "test1", + TargetType = "local", + LocalId = "abc123", + Source = "manual", + CreatedAt = DateTime.UtcNow, + LastValidatedAt = DateTime.UtcNow.AddDays(-8) + }; + + // Arrange - Manual external mapping + var manualExternal = new SpotifyTrackMapping + { + SpotifyId = "test2", + TargetType = "external", + ExternalProvider = "SquidWTF", + ExternalId = "789", + Source = "manual", + CreatedAt = DateTime.UtcNow, + LastValidatedAt = DateTime.UtcNow.AddMinutes(-5) + }; + + // Act & Assert + Assert.True(manualLocal.NeedsValidation(false)); // Manual local follows 7-day rule + Assert.True(manualExternal.NeedsValidation(true)); // Manual external validates on sync + Assert.False(manualExternal.NeedsValidation(false)); // But not outside sync + } +} diff --git a/allstarr.Tests/SquidWTFMetadataServiceTests.cs b/allstarr.Tests/SquidWTFMetadataServiceTests.cs index 09a6d4c..de9fc99 100644 --- a/allstarr.Tests/SquidWTFMetadataServiceTests.cs +++ b/allstarr.Tests/SquidWTFMetadataServiceTests.cs @@ -1,3 +1,4 @@ +<<<<<<< HEAD using Xunit; using Moq; using Microsoft.Extensions.Logging; @@ -340,3 +341,348 @@ public class SquidWTFMetadataServiceTests Assert.NotNull(service); } } +||||||| f68706f +======= +using Xunit; +using Moq; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using allstarr.Services.SquidWTF; +using allstarr.Services.Common; +using allstarr.Models.Settings; +using System.Collections.Generic; + +namespace allstarr.Tests; + +public class SquidWTFMetadataServiceTests +{ + private readonly Mock> _mockLogger; + private readonly Mock _mockHttpClientFactory; + private readonly IOptions _subsonicSettings; + private readonly IOptions _squidwtfSettings; + private readonly Mock _mockCache; + private readonly List _apiUrls; + + public SquidWTFMetadataServiceTests() + { + _mockLogger = new Mock>(); + _mockHttpClientFactory = new Mock(); + + _subsonicSettings = Options.Create(new SubsonicSettings + { + ExplicitFilter = ExplicitFilter.All + }); + + _squidwtfSettings = Options.Create(new SquidWTFSettings + { + Quality = "FLAC" + }); + + // Create mock Redis cache + var mockRedisLogger = new Mock>(); + var mockRedisSettings = Options.Create(new RedisSettings { Enabled = false }); + _mockCache = new Mock(mockRedisSettings, mockRedisLogger.Object); + + _apiUrls = new List + { + "https://test1.example.com", + "https://test2.example.com", + "https://test3.example.com" + }; + + var httpClient = new System.Net.Http.HttpClient(); + _mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny())).Returns(httpClient); + } + + [Fact] + public void Constructor_InitializesWithDependencies() + { + // Act + var service = new SquidWTFMetadataService( + _mockHttpClientFactory.Object, + _subsonicSettings, + _squidwtfSettings, + _mockLogger.Object, + _mockCache.Object, + _apiUrls); + + // Assert + Assert.NotNull(service); + } + + [Fact] + public void Constructor_AcceptsOptionalGenreEnrichment() + { + // Arrange - GenreEnrichmentService is optional, just pass null + + // Act + var service = new SquidWTFMetadataService( + _mockHttpClientFactory.Object, + _subsonicSettings, + _squidwtfSettings, + _mockLogger.Object, + _mockCache.Object, + _apiUrls, + null); // GenreEnrichmentService is optional + + // Assert + Assert.NotNull(service); + } + + [Fact] + public void SearchSongsAsync_AcceptsQueryAndLimit() + { + // Arrange + var service = new SquidWTFMetadataService( + _mockHttpClientFactory.Object, + _subsonicSettings, + _squidwtfSettings, + _mockLogger.Object, + _mockCache.Object, + _apiUrls); + + // Act + var result = service.SearchSongsAsync("Mr. Brightside", 20); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public void SearchAlbumsAsync_AcceptsQueryAndLimit() + { + // Arrange + var service = new SquidWTFMetadataService( + _mockHttpClientFactory.Object, + _subsonicSettings, + _squidwtfSettings, + _mockLogger.Object, + _mockCache.Object, + _apiUrls); + + // Act + var result = service.SearchAlbumsAsync("Hot Fuss", 20); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public void SearchArtistsAsync_AcceptsQueryAndLimit() + { + // Arrange + var service = new SquidWTFMetadataService( + _mockHttpClientFactory.Object, + _subsonicSettings, + _squidwtfSettings, + _mockLogger.Object, + _mockCache.Object, + _apiUrls); + + // Act + var result = service.SearchArtistsAsync("The Killers", 20); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public void SearchPlaylistsAsync_AcceptsQueryAndLimit() + { + // Arrange + var service = new SquidWTFMetadataService( + _mockHttpClientFactory.Object, + _subsonicSettings, + _squidwtfSettings, + _mockLogger.Object, + _mockCache.Object, + _apiUrls); + + // Act + var result = service.SearchPlaylistsAsync("Rock Classics", 20); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public void GetSongAsync_RequiresProviderAndId() + { + // Arrange + var service = new SquidWTFMetadataService( + _mockHttpClientFactory.Object, + _subsonicSettings, + _squidwtfSettings, + _mockLogger.Object, + _mockCache.Object, + _apiUrls); + + // Act + var result = service.GetSongAsync("squidwtf", "123456"); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public void GetAlbumAsync_RequiresProviderAndId() + { + // Arrange + var service = new SquidWTFMetadataService( + _mockHttpClientFactory.Object, + _subsonicSettings, + _squidwtfSettings, + _mockLogger.Object, + _mockCache.Object, + _apiUrls); + + // Act + var result = service.GetAlbumAsync("squidwtf", "789012"); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public void GetArtistAsync_RequiresProviderAndId() + { + // Arrange + var service = new SquidWTFMetadataService( + _mockHttpClientFactory.Object, + _subsonicSettings, + _squidwtfSettings, + _mockLogger.Object, + _mockCache.Object, + _apiUrls); + + // Act + var result = service.GetArtistAsync("squidwtf", "345678"); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public void GetArtistAlbumsAsync_RequiresProviderAndId() + { + // Arrange + var service = new SquidWTFMetadataService( + _mockHttpClientFactory.Object, + _subsonicSettings, + _squidwtfSettings, + _mockLogger.Object, + _mockCache.Object, + _apiUrls); + + // Act + var result = service.GetArtistAlbumsAsync("squidwtf", "345678"); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public void GetPlaylistAsync_RequiresProviderAndId() + { + // Arrange + var service = new SquidWTFMetadataService( + _mockHttpClientFactory.Object, + _subsonicSettings, + _squidwtfSettings, + _mockLogger.Object, + _mockCache.Object, + _apiUrls); + + // Act + var result = service.GetPlaylistAsync("squidwtf", "playlist123"); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public void GetPlaylistTracksAsync_RequiresProviderAndId() + { + // Arrange + var service = new SquidWTFMetadataService( + _mockHttpClientFactory.Object, + _subsonicSettings, + _squidwtfSettings, + _mockLogger.Object, + _mockCache.Object, + _apiUrls); + + // Act + var result = service.GetPlaylistTracksAsync("squidwtf", "playlist123"); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public void SearchAllAsync_CombinesAllSearchTypes() + { + // Arrange + var service = new SquidWTFMetadataService( + _mockHttpClientFactory.Object, + _subsonicSettings, + _squidwtfSettings, + _mockLogger.Object, + _mockCache.Object, + _apiUrls); + + // Act + var result = service.SearchAllAsync("The Killers", 20, 20, 20); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public void ExplicitFilter_RespectsSettings() + { + // Arrange - Test with CleanOnly filter + var cleanOnlySettings = Options.Create(new SubsonicSettings + { + ExplicitFilter = ExplicitFilter.CleanOnly + }); + + // Act + var service = new SquidWTFMetadataService( + _mockHttpClientFactory.Object, + cleanOnlySettings, + _squidwtfSettings, + _mockLogger.Object, + _mockCache.Object, + _apiUrls); + + // Assert + Assert.NotNull(service); + } + + [Fact] + public void MultipleApiUrls_EnablesRoundRobinFallback() + { + // Arrange + var multipleUrls = new List + { + "https://test-primary.example.com", + "https://test-backup1.example.com", + "https://test-backup2.example.com", + "https://test-backup3.example.com" + }; + + // Act + var service = new SquidWTFMetadataService( + _mockHttpClientFactory.Object, + _subsonicSettings, + _squidwtfSettings, + _mockLogger.Object, + _mockCache.Object, + multipleUrls); + + // Assert + Assert.NotNull(service); + } +} +>>>>>>> beta diff --git a/allstarr.Tests/WebSocketProxyMiddlewareTests.cs b/allstarr.Tests/WebSocketProxyMiddlewareTests.cs new file mode 100644 index 0000000..79f4734 --- /dev/null +++ b/allstarr.Tests/WebSocketProxyMiddlewareTests.cs @@ -0,0 +1,35 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using allstarr.Middleware; +using allstarr.Models.Settings; + +namespace allstarr.Tests; + +public class WebSocketProxyMiddlewareTests +{ + [Fact] + public void BuildMaskedQuery_RedactsSensitiveParams() + { + var qs = "?api_key=secret&deviceId=abc&token=othertoken"; + var masked = allstarr.Middleware.WebSocketProxyMiddleware.BuildMaskedQuery(qs); + + Assert.Contains("api_key=", masked); + Assert.Contains("deviceId=abc", masked); + Assert.Contains("token=", masked); + Assert.DoesNotContain("secret", masked); + Assert.DoesNotContain("othertoken", masked); + } + + [Fact] + public void BuildMaskedQuery_EmptyOrNull_ReturnsEmpty() + { + Assert.Equal(string.Empty, allstarr.Middleware.WebSocketProxyMiddleware.BuildMaskedQuery(null)); + Assert.Equal(string.Empty, allstarr.Middleware.WebSocketProxyMiddleware.BuildMaskedQuery(string.Empty)); + } +} diff --git a/allstarr/AppVersion.cs b/allstarr/AppVersion.cs new file mode 100644 index 0000000..32de807 --- /dev/null +++ b/allstarr/AppVersion.cs @@ -0,0 +1,13 @@ +namespace allstarr; + +/// +/// Single source of truth for application version. +/// Update this value when releasing a new version. +/// +public static class AppVersion +{ + /// + /// Current application version. + /// + public const string Version = "1.1.1"; +} diff --git a/allstarr/Controllers/AdminController.cs b/allstarr/Controllers/AdminController.cs index b879d94..d109917 100644 --- a/allstarr/Controllers/AdminController.cs +++ b/allstarr/Controllers/AdminController.cs @@ -1,22 +1,18 @@ using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Options; -using allstarr.Models.Settings; -using allstarr.Models.Spotify; -using allstarr.Services.Spotify; -using allstarr.Services.Jellyfin; -using allstarr.Services.Common; -using allstarr.Services; using allstarr.Filters; -using System.Text.Json; -using System.Text.RegularExpressions; -using System.Runtime; namespace allstarr.Controllers; /// -/// Admin API controller for the web dashboard. -/// Provides endpoints for viewing status, playlists, and modifying configuration. -/// Only accessible on internal admin port (5275) - not exposed through reverse proxy. +/// Legacy AdminController - All functionality has been split into specialized controllers: +/// - ConfigController: Configuration management +/// - DiagnosticsController: System diagnostics and debugging +/// - DownloadsController: Download management +/// - PlaylistController: Playlist operations +/// - JellyfinAdminController: Jellyfin-specific operations +/// - SpotifyAdminController: Spotify-specific operations +/// - LyricsController: Lyrics management +/// - MappingController: Track mapping management /// [ApiController] [Route("api/admin")] @@ -24,48 +20,11 @@ namespace allstarr.Controllers; public class AdminController : ControllerBase { private readonly ILogger _logger; - private readonly IConfiguration _configuration; - private readonly SpotifyApiSettings _spotifyApiSettings; - private readonly SpotifyImportSettings _spotifyImportSettings; - private readonly JellyfinSettings _jellyfinSettings; - private readonly SubsonicSettings _subsonicSettings; - private readonly DeezerSettings _deezerSettings; - private readonly QobuzSettings _qobuzSettings; - private readonly SquidWTFSettings _squidWtfSettings; - private readonly MusicBrainzSettings _musicBrainzSettings; - private readonly SpotifyApiClient _spotifyClient; - private readonly SpotifyPlaylistFetcher _playlistFetcher; - private readonly SpotifyTrackMatchingService? _matchingService; - private readonly RedisCacheService _cache; - private readonly HttpClient _jellyfinHttpClient; - private readonly IWebHostEnvironment _environment; - private readonly IServiceProvider _serviceProvider; - private readonly string _envFilePath; - private readonly List _squidWtfApiUrls; - private static int _urlIndex = 0; - private static readonly object _urlIndexLock = new(); - private const string CacheDirectory = "/app/cache/spotify"; - - public AdminController( - ILogger logger, - IConfiguration configuration, - IWebHostEnvironment environment, - IOptions spotifyApiSettings, - IOptions spotifyImportSettings, - IOptions jellyfinSettings, - IOptions subsonicSettings, - IOptions deezerSettings, - IOptions qobuzSettings, - IOptions squidWtfSettings, - IOptions musicBrainzSettings, - SpotifyApiClient spotifyClient, - SpotifyPlaylistFetcher playlistFetcher, - RedisCacheService cache, - IHttpClientFactory httpClientFactory, - IServiceProvider serviceProvider, - SpotifyTrackMatchingService? matchingService = null) + + public AdminController(ILogger logger) { _logger = logger; +<<<<<<< HEAD _configuration = configuration; _environment = environment; _spotifyApiSettings = spotifyApiSettings.Value; @@ -2601,43 +2560,2369 @@ public class AdminController : ControllerBase { return BadRequest(new { error = ex.Message }); } +||||||| f68706f + _configuration = configuration; + _environment = environment; + _spotifyApiSettings = spotifyApiSettings.Value; + _spotifyImportSettings = spotifyImportSettings.Value; + _jellyfinSettings = jellyfinSettings.Value; + _subsonicSettings = subsonicSettings.Value; + _deezerSettings = deezerSettings.Value; + _qobuzSettings = qobuzSettings.Value; + _squidWtfSettings = squidWtfSettings.Value; + _musicBrainzSettings = musicBrainzSettings.Value; + _spotifyClient = spotifyClient; + _playlistFetcher = playlistFetcher; + _matchingService = matchingService; + _cache = cache; + _jellyfinHttpClient = httpClientFactory.CreateClient(); + _serviceProvider = serviceProvider; + + // Decode SquidWTF base URLs + _squidWtfApiUrls = DecodeSquidWtfUrls(); + + // .env file path is always /app/.env in Docker (mounted from host) + // In development, it's in the parent directory of ContentRootPath + _envFilePath = _environment.IsDevelopment() + ? Path.Combine(_environment.ContentRootPath, "..", ".env") + : "/app/.env"; } - + + private static List 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(); + } + /// - /// Forces garbage collection to free up memory (emergency use only). + /// Helper method to safely check if a dynamic cache result has a value + /// Handles the case where JsonElement cannot be compared to null directly /// - [HttpPost("force-gc")] - public IActionResult ForceGarbageCollection() + 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; + } + + /// + /// Get current system status and configuration + /// + [HttpGet("status")] + public IActionResult GetStatus() + { + // Determine Spotify auth status based on configuration only + // DO NOT call Spotify API here - this endpoint is polled frequently + var spotifyAuthStatus = "not_configured"; + string? spotifyUser = null; + + if (_spotifyApiSettings.Enabled && !string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie)) + { + // If cookie is set, assume it's working until proven otherwise + // Actual validation happens when playlists are fetched + spotifyAuthStatus = "configured"; + spotifyUser = "(cookie set)"; + } + else if (_spotifyApiSettings.Enabled) + { + spotifyAuthStatus = "missing_cookie"; + } + + return Ok(new + { + version = "1.0.0", + backendType = _configuration.GetValue("Backend:Type") ?? "Jellyfin", + jellyfinUrl = _jellyfinSettings.Url, + spotify = new + { + apiEnabled = _spotifyApiSettings.Enabled, + authStatus = spotifyAuthStatus, + user = spotifyUser, + hasCookie = !string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie), + cookieSetDate = _spotifyApiSettings.SessionCookieSetDate, + cacheDurationMinutes = _spotifyApiSettings.CacheDurationMinutes, + preferIsrcMatching = _spotifyApiSettings.PreferIsrcMatching + }, + spotifyImport = new + { + enabled = _spotifyImportSettings.Enabled, + matchingIntervalHours = _spotifyImportSettings.MatchingIntervalHours, + playlistCount = _spotifyImportSettings.Playlists.Count + }, + deezer = new + { + hasArl = !string.IsNullOrEmpty(_deezerSettings.Arl), + quality = _deezerSettings.Quality ?? "FLAC" + }, + qobuz = new + { + hasToken = !string.IsNullOrEmpty(_qobuzSettings.UserAuthToken), + quality = _qobuzSettings.Quality ?? "FLAC" + }, + squidWtf = new + { + quality = _squidWtfSettings.Quality ?? "LOSSLESS" + } + }); + } + + /// + /// Get a random SquidWTF base URL for searching (round-robin) + /// + [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 }); + } + + /// + /// Get list of configured playlists with their current data + /// + [HttpGet("playlists")] + public async Task 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>(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(); + + // 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 + { + ["name"] = config.Name, + ["id"] = config.Id, + ["jellyfinId"] = config.JellyfinId, + ["localTracksPosition"] = config.LocalTracksPosition.ToString(), + ["trackCount"] = 0, + ["localTracks"] = 0, + ["externalTracks"] = 0, + ["lastFetched"] = null as DateTime?, + ["cacheAge"] = null as string + }; + + // Get Spotify playlist track count from cache + var cacheFilePath = Path.Combine(CacheDirectory, $"{SanitizeFileName(config.Name)}_spotify.json"); + int spotifyTrackCount = 0; + + if (System.IO.File.Exists(cacheFilePath)) + { + try + { + var json = await System.IO.File.ReadAllTextAsync(cacheFilePath); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + if (root.TryGetProperty("tracks", out var tracks)) + { + spotifyTrackCount = tracks.GetArrayLength(); + playlistInfo["trackCount"] = spotifyTrackCount; + } + + if (root.TryGetProperty("fetchedAt", out var fetchedAt)) + { + var fetchedTime = fetchedAt.GetDateTime(); + playlistInfo["lastFetched"] = fetchedTime; + var age = DateTime.UtcNow - fetchedTime; + playlistInfo["cacheAge"] = age.TotalHours < 1 + ? $"{age.TotalMinutes:F0}m" + : $"{age.TotalHours:F1}h"; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to read cache for playlist {Name}", config.Name); + } + } + + // Get current Jellyfin playlist track count + if (!string.IsNullOrEmpty(config.JellyfinId)) + { + try + { + // Jellyfin requires UserId parameter to fetch playlist items + var userId = _jellyfinSettings.UserId; + + // If no user configured, try to get the first user + if (string.IsNullOrEmpty(userId)) + { + var usersResponse = await _jellyfinHttpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get, $"{_jellyfinSettings.Url}/Users") + { + Headers = { { "X-Emby-Authorization", GetJellyfinAuthHeader() } } + }); + + if (usersResponse.IsSuccessStatusCode) + { + var usersJson = await usersResponse.Content.ReadAsStringAsync(); + using var usersDoc = JsonDocument.Parse(usersJson); + if (usersDoc.RootElement.GetArrayLength() > 0) + { + userId = usersDoc.RootElement[0].GetProperty("Id").GetString(); + } + } + } + + if (string.IsNullOrEmpty(userId)) + { + _logger.LogWarning("No user ID available to fetch playlist items for {Name}", config.Name); + } + else + { + var url = $"{_jellyfinSettings.Url}/Playlists/{config.JellyfinId}/Items?UserId={userId}&Fields=Path"; + var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader()); + + _logger.LogDebug("Fetching Jellyfin playlist items for {Name} from {Url}", config.Name, url); + + var response = await _jellyfinHttpClient.SendAsync(request); + if (response.IsSuccessStatusCode) + { + var jellyfinJson = await response.Content.ReadAsStringAsync(); + using var jellyfinDoc = JsonDocument.Parse(jellyfinJson); + + if (jellyfinDoc.RootElement.TryGetProperty("Items", out var items)) + { + // Get Spotify tracks to match against + var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name); + + // Try to use the pre-built playlist cache first (includes manual mappings!) + var playlistItemsCacheKey = $"spotify:playlist:items:{config.Name}"; + + List>? cachedPlaylistItems = null; + try + { + cachedPlaylistItems = await _cache.GetAsync>>(playlistItemsCacheKey); + } + catch (Exception cacheEx) + { + _logger.LogWarning(cacheEx, "Failed to deserialize playlist cache for {Playlist}", config.Name); + } + + _logger.LogInformation("Checking cache for {Playlist}: {CacheKey}, Found: {Found}, Count: {Count}", + config.Name, playlistItemsCacheKey, cachedPlaylistItems != null, cachedPlaylistItems?.Count ?? 0); + + if (cachedPlaylistItems != null && cachedPlaylistItems.Count > 0) + { + // Use the pre-built cache which respects manual mappings + var localCount = 0; + var externalCount = 0; + + foreach (var item in cachedPlaylistItems) + { + // Check if it's external by looking for external provider in ProviderIds + // External providers: SquidWTF, Deezer, Qobuz, Tidal + var isExternal = false; + + if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null) + { + // Handle both Dictionary and JsonElement + Dictionary? providerIds = null; + + if (providerIdsObj is Dictionary dict) + { + providerIds = dict; + } + else if (providerIdsObj is JsonElement jsonEl && jsonEl.ValueKind == JsonValueKind.Object) + { + providerIds = new Dictionary(); + foreach (var prop in jsonEl.EnumerateObject()) + { + providerIds[prop.Name] = prop.Value.GetString() ?? ""; + } + } + + if (providerIds != null) + { + // Check for external provider keys (not MusicBrainz, ISRC, Spotify, etc) + isExternal = providerIds.Keys.Any(k => + k.Equals("SquidWTF", StringComparison.OrdinalIgnoreCase) || + k.Equals("Deezer", StringComparison.OrdinalIgnoreCase) || + k.Equals("Qobuz", StringComparison.OrdinalIgnoreCase) || + k.Equals("Tidal", StringComparison.OrdinalIgnoreCase)); + } + } + + if (isExternal) + { + externalCount++; + } + else + { + localCount++; + } + } + + var externalMissingCount = spotifyTracks.Count - cachedPlaylistItems.Count; + if (externalMissingCount < 0) externalMissingCount = 0; + + playlistInfo["localTracks"] = localCount; + playlistInfo["externalMatched"] = externalCount; + playlistInfo["externalMissing"] = externalMissingCount; + playlistInfo["externalTotal"] = externalCount + externalMissingCount; + playlistInfo["totalInJellyfin"] = cachedPlaylistItems.Count; + playlistInfo["totalPlayable"] = localCount + externalCount; // Total tracks that will be served + + _logger.LogInformation("Playlist {Name} (from cache): {Total} Spotify tracks, {Local} local, {ExtMatched} external matched, {ExtMissing} external missing, {Playable} total playable", + config.Name, spotifyTracks.Count, localCount, externalCount, externalMissingCount, localCount + externalCount); + } + else + { + // Fallback: Build list of local tracks from Jellyfin (match by name only) + var localTracks = new List<(string Title, string Artist)>(); + foreach (var item in items.EnumerateArray()) + { + var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : ""; + var artist = ""; + + if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0) + { + artist = artistsEl[0].GetString() ?? ""; + } + else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl)) + { + artist = albumArtistEl.GetString() ?? ""; + } + + if (!string.IsNullOrEmpty(title)) + { + localTracks.Add((title, artist)); + } + } + + // Get matched external tracks cache once + var matchedTracksKey = $"spotify:matched:ordered:{config.Name}"; + var matchedTracks = await _cache.GetAsync>(matchedTracksKey); + var matchedSpotifyIds = new HashSet( + matchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty() + ); + + var localCount = 0; + var externalMatchedCount = 0; + var externalMissingCount = 0; + + // Match each Spotify track to determine if it's local, external, or missing + foreach (var track in spotifyTracks) + { + var isLocal = false; + var hasExternalMapping = false; + + // FIRST: Check for manual Jellyfin mapping + var manualMappingKey = $"spotify:manual-map:{config.Name}:{track.SpotifyId}"; + var manualJellyfinId = await _cache.GetAsync(manualMappingKey); + + if (!string.IsNullOrEmpty(manualJellyfinId)) + { + // Manual Jellyfin mapping exists - this track is definitely local + isLocal = true; + } + else + { + // Check for external manual mapping + var externalMappingKey = $"spotify:external-map:{config.Name}:{track.SpotifyId}"; + var externalMappingJson = await _cache.GetStringAsync(externalMappingKey); + + if (!string.IsNullOrEmpty(externalMappingJson)) + { + // External manual mapping exists + hasExternalMapping = true; + } + else if (localTracks.Count > 0) + { + // SECOND: No manual mapping, try fuzzy matching with local tracks + var bestMatch = localTracks + .Select(local => new + { + Local = local, + TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, local.Title), + ArtistScore = FuzzyMatcher.CalculateSimilarity(track.PrimaryArtist, local.Artist) + }) + .Select(x => new + { + x.Local, + x.TitleScore, + x.ArtistScore, + TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3) + }) + .OrderByDescending(x => x.TotalScore) + .FirstOrDefault(); + + // Use 70% threshold (same as playback matching) + if (bestMatch != null && bestMatch.TotalScore >= 70) + { + isLocal = true; + } + } + } + + if (isLocal) + { + localCount++; + } + else + { + // Check if external track is matched (either manual mapping or auto-matched) + if (hasExternalMapping || matchedSpotifyIds.Contains(track.SpotifyId)) + { + externalMatchedCount++; + } + else + { + externalMissingCount++; + } + } + } + + playlistInfo["localTracks"] = localCount; + playlistInfo["externalMatched"] = externalMatchedCount; + playlistInfo["externalMissing"] = externalMissingCount; + playlistInfo["externalTotal"] = externalMatchedCount + externalMissingCount; + playlistInfo["totalInJellyfin"] = localCount + externalMatchedCount; + playlistInfo["totalPlayable"] = localCount + externalMatchedCount; // Total tracks that will be served + + _logger.LogDebug("Playlist {Name} (fallback): {Total} Spotify tracks, {Local} local, {ExtMatched} external matched, {ExtMissing} external missing, {Playable} total playable", + config.Name, spotifyTracks.Count, localCount, externalMatchedCount, externalMissingCount, localCount + externalMatchedCount); + } + } + else + { + _logger.LogWarning("No Items property in Jellyfin response for {Name}", config.Name); + } + } + else + { + _logger.LogWarning("Failed to get Jellyfin playlist {Name}: {StatusCode}", + config.Name, response.StatusCode); + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to get Jellyfin playlist tracks for {Name}", config.Name); + } + } + else + { + _logger.LogWarning("Playlist {Name} has no JellyfinId configured", config.Name); + } + + playlists.Add(playlistInfo); + } + + // Save to file cache + try + { + var cacheDir = "/app/cache"; + Directory.CreateDirectory(cacheDir); + var cacheFile = Path.Combine(cacheDir, "admin_playlists_summary.json"); + + var response = new { playlists }; + var json = JsonSerializer.Serialize(response, new JsonSerializerOptions { WriteIndented = false }); + await System.IO.File.WriteAllTextAsync(cacheFile, json); + + _logger.LogDebug("💾 Saved playlist summary to cache"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to save playlist summary cache"); + } + + return Ok(new { playlists }); + } + + /// + /// Get tracks for a specific playlist with local/external status + /// + [HttpGet("playlists/{name}/tracks")] + public async Task GetPlaylistTracks(string name) + { + var decodedName = Uri.UnescapeDataString(name); + + // Get Spotify tracks + var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(decodedName); + + var tracksWithStatus = new List(); + + // 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>? cachedPlaylistItems = null; + try + { + cachedPlaylistItems = await _cache.GetAsync>>(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>(); + + foreach (var item in cachedPlaylistItems) + { + if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null) + { + Dictionary? providerIds = null; + + if (providerIdsObj is Dictionary dict) + { + providerIds = dict; + } + else if (providerIdsObj is JsonElement jsonEl && jsonEl.ValueKind == JsonValueKind.Object) + { + providerIds = new Dictionary(); + 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? providerIds = null; + + if (providerIdsObj is Dictionary dict) + { + providerIds = dict; + } + else if (providerIdsObj is JsonElement jsonEl && jsonEl.ValueKind == JsonValueKind.Object) + { + providerIds = new Dictionary(); + 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(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>(fallbackMatchedTracksKey); + var fallbackMatchedSpotifyIds = new HashSet( + fallbackMatchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty() + ); + + 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(manualMappingKey); + + if (!string.IsNullOrEmpty(manualJellyfinId)) + { + isLocal = true; + } + else + { + // Check for external manual mapping + var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}"; + var externalMappingJson = await _cache.GetStringAsync(externalMappingKey); + + if (!string.IsNullOrEmpty(externalMappingJson)) + { + try + { + using var extDoc = JsonDocument.Parse(externalMappingJson); + var extRoot = extDoc.RootElement; + + string? provider = null; + + if (extRoot.TryGetProperty("provider", out var providerEl)) + { + provider = providerEl.GetString(); + } + + if (!string.IsNullOrEmpty(provider)) + { + isLocal = false; + externalProvider = provider; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", track.Title); + } + } + else if (fallbackMatchedSpotifyIds.Contains(track.SpotifyId)) + { + isLocal = false; + externalProvider = "SquidWTF"; + } + else + { + isLocal = null; + externalProvider = null; + } + } + + tracksWithStatus.Add(new + { + position = track.Position, + title = track.Title, + artists = track.Artists, + album = track.Album, + isrc = track.Isrc, + spotifyId = track.SpotifyId, + durationMs = track.DurationMs, + albumArtUrl = track.AlbumArtUrl, + isLocal = isLocal, + externalProvider = externalProvider, + searchQuery = isLocal != true ? $"{track.Title} {track.PrimaryArtist}" : null + }); + } + + return Ok(new + { + name = decodedName, + trackCount = spotifyTracks.Count, + tracks = tracksWithStatus + }); + } + + /// + /// Trigger a manual refresh of all playlists + /// + [HttpPost("playlists/refresh")] + public async Task RefreshPlaylists() + { + _logger.LogInformation("Manual playlist refresh triggered from admin UI"); + await _playlistFetcher.TriggerFetchAsync(); + + // Invalidate playlist summary cache + InvalidatePlaylistSummaryCache(); + + return Ok(new { message = "Playlist refresh triggered", timestamp = DateTime.UtcNow }); + } + + /// + /// Trigger track matching for a specific playlist + /// + [HttpPost("playlists/{name}/match")] + public async Task MatchPlaylistTracks(string name) + { + var decodedName = Uri.UnescapeDataString(name); + _logger.LogInformation("Manual track matching triggered for playlist: {Name}", decodedName); + + if (_matchingService == null) + { + return BadRequest(new { error = "Track matching service is not available" }); + } + + try + { + await _matchingService.TriggerMatchingForPlaylistAsync(decodedName); + + // Invalidate playlist summary cache + InvalidatePlaylistSummaryCache(); + + return Ok(new { message = $"Track matching triggered for {decodedName}", timestamp = DateTime.UtcNow }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to trigger track matching for {Name}", decodedName); + return StatusCode(500, new { error = "Failed to trigger track matching", details = ex.Message }); + } + } + + /// + /// Clear cache and rebuild for a specific playlist + /// + [HttpPost("playlists/{name}/clear-cache")] + public async Task 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 }); + } + } + + /// + /// Search Jellyfin library for tracks (for manual mapping) + /// + [HttpGet("jellyfin/search")] + public async Task SearchJellyfinTracks([FromQuery] string query) + { + if (string.IsNullOrWhiteSpace(query)) + { + return BadRequest(new { error = "Query is required" }); + } + + try + { + var userId = _jellyfinSettings.UserId; + + // Build URL with UserId if available + var url = $"{_jellyfinSettings.Url}/Items?searchTerm={Uri.EscapeDataString(query)}&includeItemTypes=Audio&recursive=true&limit=20"; + if (!string.IsNullOrEmpty(userId)) + { + url += $"&UserId={userId}"; + } + + var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader()); + + _logger.LogDebug("Searching Jellyfin: {Url}", url); + + var response = await _jellyfinHttpClient.SendAsync(request); + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(); + _logger.LogWarning("Jellyfin search failed: {StatusCode} - {Error}", response.StatusCode, errorBody); + return StatusCode((int)response.StatusCode, new { error = "Failed to search Jellyfin" }); + } + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + + var tracks = new List(); + if (doc.RootElement.TryGetProperty("Items", out var items)) + { + foreach (var item in items.EnumerateArray()) + { + // Verify it's actually an Audio item + var type = item.TryGetProperty("Type", out var typeEl) ? typeEl.GetString() : ""; + if (type != "Audio") + { + _logger.LogDebug("Skipping non-audio item: {Type}", type); + continue; + } + + var id = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() : ""; + var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : ""; + var album = item.TryGetProperty("Album", out var albumEl) ? albumEl.GetString() : ""; + var artist = ""; + + if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0) + { + artist = artistsEl[0].GetString() ?? ""; + } + else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl)) + { + artist = albumArtistEl.GetString() ?? ""; + } + + tracks.Add(new { id, title, artist, album }); + } + } + + return Ok(new { tracks }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to search Jellyfin tracks"); + return StatusCode(500, new { error = "Search failed" }); + } + } + + /// + /// Get track details by Jellyfin ID (for URL-based mapping) + /// + [HttpGet("jellyfin/track/{id}")] + public async Task GetJellyfinTrack(string id) + { + if (string.IsNullOrWhiteSpace(id)) + { + return BadRequest(new { error = "Track ID is required" }); + } + + try + { + var userId = _jellyfinSettings.UserId; + + var url = $"{_jellyfinSettings.Url}/Items/{id}"; + if (!string.IsNullOrEmpty(userId)) + { + url += $"?UserId={userId}"; + } + + var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader()); + + _logger.LogDebug("Fetching Jellyfin track {Id} from {Url}", id, url); + + var response = await _jellyfinHttpClient.SendAsync(request); + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(); + _logger.LogWarning("Failed to fetch Jellyfin track {Id}: {StatusCode} - {Error}", + id, response.StatusCode, errorBody); + return StatusCode((int)response.StatusCode, new { error = "Track not found in Jellyfin" }); + } + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + + var item = doc.RootElement; + + // Verify it's an Audio item + var type = item.TryGetProperty("Type", out var typeEl) ? typeEl.GetString() : ""; + if (type != "Audio") + { + _logger.LogWarning("Item {Id} is not an Audio track, it's a {Type}", id, type); + return BadRequest(new { error = $"Item is not an audio track (it's a {type})" }); + } + + var trackId = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() : ""; + var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : ""; + var album = item.TryGetProperty("Album", out var albumEl) ? albumEl.GetString() : ""; + var artist = ""; + + if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0) + { + artist = artistsEl[0].GetString() ?? ""; + } + else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl)) + { + artist = albumArtistEl.GetString() ?? ""; + } + + _logger.LogInformation("Found Jellyfin track: {Title} by {Artist}", title, artist); + + return Ok(new { id = trackId, title, artist, album }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get Jellyfin track {Id}", id); + return StatusCode(500, new { error = "Failed to get track details" }); + } + } + + /// + /// Save manual track mapping (local Jellyfin or external provider) + /// + [HttpPost("playlists/{name}/map")] + public async Task SaveManualMapping(string name, [FromBody] ManualMappingRequest request) + { + var decodedName = Uri.UnescapeDataString(name); + + if (string.IsNullOrWhiteSpace(request.SpotifyId)) + { + return BadRequest(new { error = "SpotifyId is required" }); + } + + // Validate that either Jellyfin mapping or external mapping is provided + var hasJellyfinMapping = !string.IsNullOrWhiteSpace(request.JellyfinId); + var hasExternalMapping = !string.IsNullOrWhiteSpace(request.ExternalProvider) && !string.IsNullOrWhiteSpace(request.ExternalId); + + if (!hasJellyfinMapping && !hasExternalMapping) + { + return BadRequest(new { error = "Either JellyfinId or (ExternalProvider + ExternalId) is required" }); + } + + if (hasJellyfinMapping && hasExternalMapping) + { + return BadRequest(new { error = "Cannot specify both Jellyfin and external mapping for the same track" }); + } + + try + { + string? normalizedProvider = null; + + if (hasJellyfinMapping) + { + // Store Jellyfin mapping in cache (NO EXPIRATION - manual mappings are permanent) + var mappingKey = $"spotify:manual-map:{decodedName}:{request.SpotifyId}"; + await _cache.SetAsync(mappingKey, request.JellyfinId!); + + // Also save to file for persistence across restarts + await SaveManualMappingToFileAsync(decodedName, request.SpotifyId, request.JellyfinId!, null, null); + + _logger.LogInformation("Manual Jellyfin mapping saved: {Playlist} - Spotify {SpotifyId} → Jellyfin {JellyfinId}", + decodedName, request.SpotifyId, request.JellyfinId); + } + else + { + // Store external mapping in cache (NO EXPIRATION - manual mappings are permanent) + var externalMappingKey = $"spotify:external-map:{decodedName}:{request.SpotifyId}"; + normalizedProvider = request.ExternalProvider!.ToLowerInvariant(); // Normalize to lowercase + var externalMapping = new { provider = normalizedProvider, id = request.ExternalId }; + await _cache.SetAsync(externalMappingKey, externalMapping); + + // Also save to file for persistence across restarts + await SaveManualMappingToFileAsync(decodedName, request.SpotifyId, null, normalizedProvider, request.ExternalId!); + + _logger.LogInformation("Manual external mapping saved: {Playlist} - Spotify {SpotifyId} → {Provider} {ExternalId}", + decodedName, request.SpotifyId, normalizedProvider, request.ExternalId); + } + + // Clear all related caches to force rebuild + var matchedCacheKey = $"spotify:matched:{decodedName}"; + var orderedCacheKey = $"spotify:matched:ordered:{decodedName}"; + var playlistItemsKey = $"spotify:playlist:items:{decodedName}"; + + await _cache.DeleteAsync(matchedCacheKey); + await _cache.DeleteAsync(orderedCacheKey); + await _cache.DeleteAsync(playlistItemsKey); + + // Also delete file caches to force rebuild + try + { + var cacheDir = "/app/cache/spotify"; + var safeName = string.Join("_", decodedName.Split(Path.GetInvalidFileNameChars())); + var matchedFile = Path.Combine(cacheDir, $"{safeName}_matched.json"); + var itemsFile = Path.Combine(cacheDir, $"{safeName}_items.json"); + + if (System.IO.File.Exists(matchedFile)) + { + System.IO.File.Delete(matchedFile); + _logger.LogDebug("Deleted matched tracks file cache for {Playlist}", decodedName); + } + + if (System.IO.File.Exists(itemsFile)) + { + System.IO.File.Delete(itemsFile); + _logger.LogDebug("Deleted playlist items file cache for {Playlist}", decodedName); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to delete file caches for {Playlist}", decodedName); + } + + _logger.LogInformation("Cleared playlist caches for {Playlist} to force rebuild", decodedName); + + // Fetch external provider track details to return to the UI (only for external mappings) + string? trackTitle = null; + string? trackArtist = null; + string? trackAlbum = null; + + if (hasExternalMapping && normalizedProvider != null) + { + try + { + var metadataService = HttpContext.RequestServices.GetRequiredService(); + var externalSong = await metadataService.GetSongAsync(normalizedProvider, request.ExternalId!); + + if (externalSong != null) + { + trackTitle = externalSong.Title; + trackArtist = externalSong.Artist; + trackAlbum = externalSong.Album; + _logger.LogInformation("✓ Fetched external track metadata: {Title} by {Artist}", trackTitle, trackArtist); + } + else + { + _logger.LogWarning("Failed to fetch external track metadata for {Provider} ID {Id}", + normalizedProvider, request.ExternalId); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to fetch external track metadata, but mapping was saved"); + } + } + + // Trigger immediate playlist rebuild with the new mapping + if (_matchingService != null) + { + _logger.LogInformation("Triggering immediate playlist rebuild for {Playlist} with new manual mapping", decodedName); + + // Run rebuild in background with timeout to avoid blocking the response + _ = Task.Run(async () => + { + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2)); // 2 minute timeout + await _matchingService.TriggerMatchingForPlaylistAsync(decodedName); + _logger.LogInformation("✓ Playlist {Playlist} rebuilt successfully with manual mapping", decodedName); + } + catch (OperationCanceledException) + { + _logger.LogWarning("Playlist rebuild for {Playlist} timed out after 2 minutes", decodedName); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to rebuild playlist {Playlist} after manual mapping", decodedName); + } + }); + } + else + { + _logger.LogWarning("Matching service not available - playlist will rebuild on next scheduled run"); + } + + // Return success with track details if available + var mappedTrack = new + { + id = request.ExternalId, + title = trackTitle ?? "Unknown", + artist = trackArtist ?? "Unknown", + album = trackAlbum ?? "Unknown", + isLocal = false, + externalProvider = request.ExternalProvider!.ToLowerInvariant() + }; + + return Ok(new + { + message = "Mapping saved and playlist rebuild triggered", + track = mappedTrack, + rebuildTriggered = _matchingService != null + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save manual mapping"); + return StatusCode(500, new { error = "Failed to save mapping" }); + } + } + + /// + /// Trigger track matching for all playlists + /// + [HttpPost("playlists/match-all")] + public async Task MatchAllPlaylistTracks() + { + _logger.LogInformation("Manual track matching triggered for all playlists"); + + if (_matchingService == null) + { + return BadRequest(new { error = "Track matching service is not available" }); + } + + try + { + await _matchingService.TriggerMatchingAsync(); + return Ok(new { message = "Track matching triggered for all playlists", timestamp = DateTime.UtcNow }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to trigger track matching for all playlists"); + return StatusCode(500, new { error = "Failed to trigger track matching", details = ex.Message }); + } + } + + /// + /// Get current configuration (safe values only) + /// + [HttpGet("config")] + public IActionResult GetConfig() + { + return Ok(new + { + spotifyApi = new + { + enabled = _spotifyApiSettings.Enabled, + sessionCookie = MaskValue(_spotifyApiSettings.SessionCookie, showLast: 8), + sessionCookieSetDate = _spotifyApiSettings.SessionCookieSetDate, + cacheDurationMinutes = _spotifyApiSettings.CacheDurationMinutes, + rateLimitDelayMs = _spotifyApiSettings.RateLimitDelayMs, + preferIsrcMatching = _spotifyApiSettings.PreferIsrcMatching + }, + spotifyImport = new + { + enabled = _spotifyImportSettings.Enabled, + matchingIntervalHours = _spotifyImportSettings.MatchingIntervalHours, + playlists = _spotifyImportSettings.Playlists.Select(p => new + { + name = p.Name, + id = p.Id, + localTracksPosition = p.LocalTracksPosition.ToString() + }) + }, + jellyfin = new + { + url = _jellyfinSettings.Url, + apiKey = MaskValue(_jellyfinSettings.ApiKey), + userId = _jellyfinSettings.UserId ?? "(not set)", + libraryId = _jellyfinSettings.LibraryId + }, + library = new + { + downloadPath = _subsonicSettings.StorageMode == StorageMode.Cache + ? Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "cache") + : Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "permanent"), + keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept"), + storageMode = _subsonicSettings.StorageMode.ToString(), + cacheDurationHours = _subsonicSettings.CacheDurationHours, + downloadMode = _subsonicSettings.DownloadMode.ToString() + }, + deezer = new + { + arl = MaskValue(_deezerSettings.Arl, showLast: 8), + arlFallback = MaskValue(_deezerSettings.ArlFallback, showLast: 8), + quality = _deezerSettings.Quality ?? "FLAC" + }, + qobuz = new + { + userAuthToken = MaskValue(_qobuzSettings.UserAuthToken, showLast: 8), + userId = _qobuzSettings.UserId, + quality = _qobuzSettings.Quality ?? "FLAC" + }, + squidWtf = new + { + quality = _squidWtfSettings.Quality ?? "LOSSLESS" + }, + musicBrainz = new + { + enabled = _musicBrainzSettings.Enabled, + username = _musicBrainzSettings.Username ?? "(not set)", + password = MaskValue(_musicBrainzSettings.Password), + baseUrl = _musicBrainzSettings.BaseUrl, + rateLimitMs = _musicBrainzSettings.RateLimitMs + } + }); + } + + /// + /// Update configuration by modifying .env file + /// + [HttpPost("config")] + public async Task UpdateConfig([FromBody] ConfigUpdateRequest request) + { + if (request == null || request.Updates == null || request.Updates.Count == 0) + { + return BadRequest(new { error = "No updates provided" }); + } + + _logger.LogInformation("Config update requested: {Count} changes", request.Updates.Count); + + try + { + // Check if .env file exists + if (!System.IO.File.Exists(_envFilePath)) + { + _logger.LogWarning(".env file not found at {Path}, creating new file", _envFilePath); + } + + // Read current .env file or create new one + var envContent = new Dictionary(); + + if (System.IO.File.Exists(_envFilePath)) + { + var lines = await System.IO.File.ReadAllLinesAsync(_envFilePath); + foreach (var line in lines) + { + if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith('#')) + continue; + + var eqIndex = line.IndexOf('='); + if (eqIndex > 0) + { + var key = line[..eqIndex].Trim(); + var value = line[(eqIndex + 1)..].Trim(); + envContent[key] = value; + } + } + _logger.LogInformation("Loaded {Count} existing env vars from {Path}", envContent.Count, _envFilePath); + } + + // Apply updates with validation + var appliedUpdates = new List(); + foreach (var (key, value) in request.Updates) + { + // Validate key format + if (!IsValidEnvKey(key)) + { + _logger.LogWarning("Invalid env key rejected: {Key}", key); + return BadRequest(new { error = $"Invalid environment variable key: {key}" }); + } + + envContent[key] = value; + appliedUpdates.Add(key); + _logger.LogInformation(" Setting {Key} = {Value}", key, + key.Contains("COOKIE") || key.Contains("TOKEN") || key.Contains("KEY") || key.Contains("ARL") + ? "***" + (value.Length > 8 ? value[^8..] : "") + : value); + + // Auto-set cookie date when Spotify session cookie is updated + if (key == "SPOTIFY_API_SESSION_COOKIE" && !string.IsNullOrEmpty(value)) + { + var dateKey = "SPOTIFY_API_SESSION_COOKIE_SET_DATE"; + var dateValue = DateTime.UtcNow.ToString("o"); // ISO 8601 format + envContent[dateKey] = dateValue; + appliedUpdates.Add(dateKey); + _logger.LogInformation(" Auto-setting {Key} to {Value}", dateKey, dateValue); + } + } + + // Write back to .env file + var newContent = string.Join("\n", envContent.Select(kv => $"{kv.Key}={kv.Value}")); + await System.IO.File.WriteAllTextAsync(_envFilePath, newContent + "\n"); + + _logger.LogInformation("Config file updated successfully at {Path}", _envFilePath); + + return Ok(new + { + message = "Configuration updated. Restart container to apply changes.", + updatedKeys = appliedUpdates, + requiresRestart = true, + envFilePath = _envFilePath + }); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogError(ex, "Permission denied writing to .env file at {Path}", _envFilePath); + return StatusCode(500, new { + error = "Permission denied", + details = "Cannot write to .env file. Check file permissions and volume mount.", + path = _envFilePath + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update configuration at {Path}", _envFilePath); + return StatusCode(500, new { + error = "Failed to update configuration", + details = ex.Message, + path = _envFilePath + }); + } + } + + /// + /// Add a new playlist to the configuration + /// + [HttpPost("playlists")] + public async Task AddPlaylist([FromBody] AddPlaylistRequest request) + { + if (string.IsNullOrEmpty(request.Name) || string.IsNullOrEmpty(request.SpotifyId)) + { + return BadRequest(new { error = "Name and SpotifyId are required" }); + } + + _logger.LogInformation("Adding playlist: {Name} ({SpotifyId})", request.Name, request.SpotifyId); + + // Get current playlists + var currentPlaylists = _spotifyImportSettings.Playlists.ToList(); + + // Check for duplicates + if (currentPlaylists.Any(p => p.Id == request.SpotifyId || p.Name == request.Name)) + { + return BadRequest(new { error = "Playlist with this name or ID already exists" }); + } + + // Add new playlist + currentPlaylists.Add(new SpotifyPlaylistConfig + { + Name = request.Name, + Id = request.SpotifyId, + LocalTracksPosition = request.LocalTracksPosition == "last" + ? LocalTracksPosition.Last + : LocalTracksPosition.First + }); + + // Convert to JSON format for env var + var playlistsJson = JsonSerializer.Serialize( + currentPlaylists.Select(p => new[] { p.Name, p.Id, p.LocalTracksPosition.ToString().ToLower() }).ToArray() + ); + + // Update .env file + var updateRequest = new ConfigUpdateRequest + { + Updates = new Dictionary + { + ["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson + } + }; + + return await UpdateConfig(updateRequest); + } + + /// + /// Remove a playlist from the configuration + /// + [HttpDelete("playlists/{name}")] + public async Task RemovePlaylist(string name) + { + var decodedName = Uri.UnescapeDataString(name); + _logger.LogInformation("Removing playlist: {Name}", decodedName); + + // Read current playlists from .env file (not stale in-memory config) + var currentPlaylists = await ReadPlaylistsFromEnvFile(); + var playlist = currentPlaylists.FirstOrDefault(p => p.Name == decodedName); + + if (playlist == null) + { + return NotFound(new { error = "Playlist not found" }); + } + + currentPlaylists.Remove(playlist); + + // Convert to JSON format for env var: [["Name","SpotifyId","JellyfinId","first|last"],...] + var playlistsJson = JsonSerializer.Serialize( + currentPlaylists.Select(p => new[] { p.Name, p.Id, p.JellyfinId, p.LocalTracksPosition.ToString().ToLower() }).ToArray() + ); + + // Update .env file + var updateRequest = new ConfigUpdateRequest + { + Updates = new Dictionary + { + ["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson + } + }; + + return await UpdateConfig(updateRequest); + } + + /// + /// Clear all cached data + /// + [HttpPost("cache/clear")] + public async Task ClearCache() + { + _logger.LogInformation("Cache clear requested from admin UI"); + + var clearedFiles = 0; + var clearedRedisKeys = 0; + + // Clear file cache + if (Directory.Exists(CacheDirectory)) + { + foreach (var file in Directory.GetFiles(CacheDirectory, "*.json")) + { + try + { + System.IO.File.Delete(file); + clearedFiles++; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to delete cache file {File}", file); + } + } + } + + // Clear ALL Redis cache keys for Spotify playlists + // This includes matched tracks, ordered tracks, missing tracks, playlist items, etc. + foreach (var playlist in _spotifyImportSettings.Playlists) + { + var keysToDelete = new[] + { + $"spotify:playlist:{playlist.Name}", + $"spotify:missing:{playlist.Name}", + $"spotify:matched:{playlist.Name}", + $"spotify:matched:ordered:{playlist.Name}", + $"spotify:playlist:items:{playlist.Name}" // NEW: Clear file-backed playlist items cache + }; + + foreach (var key in keysToDelete) + { + if (await _cache.DeleteAsync(key)) + { + clearedRedisKeys++; + _logger.LogInformation("Cleared Redis cache key: {Key}", key); + } + } + } + + // Clear all search cache keys (pattern-based deletion) + var searchKeysDeleted = await _cache.DeleteByPatternAsync("search:*"); + clearedRedisKeys += searchKeysDeleted; + + // Clear all image cache keys (pattern-based deletion) + var imageKeysDeleted = await _cache.DeleteByPatternAsync("image:*"); + clearedRedisKeys += imageKeysDeleted; + + _logger.LogInformation("Cache cleared: {Files} files, {RedisKeys} Redis keys (including {SearchKeys} search keys, {ImageKeys} image keys)", + clearedFiles, clearedRedisKeys, searchKeysDeleted, imageKeysDeleted); + + return Ok(new { + message = "Cache cleared successfully", + filesDeleted = clearedFiles, + redisKeysDeleted = clearedRedisKeys + }); + } + + /// + /// Restart the allstarr container to apply configuration changes + /// + [HttpPost("restart")] + public async Task RestartContainer() + { + _logger.LogInformation("Container restart requested from admin UI"); + + try + { + // Use Docker socket to restart the container + var socketPath = "/var/run/docker.sock"; + + if (!System.IO.File.Exists(socketPath)) + { + _logger.LogWarning("Docker socket not available at {Path}", socketPath); + return StatusCode(503, new { + error = "Docker socket not available", + message = "Please restart manually: docker-compose restart allstarr" + }); + } + + // Get container ID from hostname (Docker sets hostname to container ID by default) + // Or use the well-known container name + var containerId = Environment.MachineName; + var containerName = "allstarr"; + + _logger.LogInformation("Attempting to restart container {ContainerId} / {ContainerName}", containerId, containerName); + + // Create Unix socket HTTP client + var handler = new SocketsHttpHandler + { + ConnectCallback = async (context, cancellationToken) => + { + var socket = new System.Net.Sockets.Socket( + System.Net.Sockets.AddressFamily.Unix, + System.Net.Sockets.SocketType.Stream, + System.Net.Sockets.ProtocolType.Unspecified); + + var endpoint = new System.Net.Sockets.UnixDomainSocketEndPoint(socketPath); + await socket.ConnectAsync(endpoint, cancellationToken); + + return new System.Net.Sockets.NetworkStream(socket, ownsSocket: true); + } + }; + + using var dockerClient = new HttpClient(handler) + { + BaseAddress = new Uri("http://localhost") + }; + + // Try to restart by container name first, then by ID + var response = await dockerClient.PostAsync($"/containers/{containerName}/restart?t=5", null); + + if (!response.IsSuccessStatusCode) + { + // Try by container ID + response = await dockerClient.PostAsync($"/containers/{containerId}/restart?t=5", null); + } + + if (response.IsSuccessStatusCode) + { + _logger.LogInformation("Container restart initiated successfully"); + return Ok(new { message = "Restarting container...", success = true }); + } + else + { + var errorBody = await response.Content.ReadAsStringAsync(); + _logger.LogError("Failed to restart container: {StatusCode} - {Body}", response.StatusCode, errorBody); + return StatusCode((int)response.StatusCode, new { + error = "Failed to restart container", + message = "Please restart manually: docker-compose restart allstarr" + }); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error restarting container"); + return StatusCode(500, new { + error = "Failed to restart container", + details = ex.Message, + message = "Please restart manually: docker-compose restart allstarr" + }); + } + } + + /// + /// Initialize cookie date to current date if cookie exists but date is not set + /// + [HttpPost("config/init-cookie-date")] + public async Task InitCookieDate() + { + // Only init if cookie exists but date is not set + if (string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie)) + { + return BadRequest(new { error = "No cookie set" }); + } + + if (!string.IsNullOrEmpty(_spotifyApiSettings.SessionCookieSetDate)) + { + return Ok(new { message = "Cookie date already set", date = _spotifyApiSettings.SessionCookieSetDate }); + } + + _logger.LogInformation("Initializing cookie date to current date (cookie existed without date tracking)"); + + var updateRequest = new ConfigUpdateRequest + { + Updates = new Dictionary + { + ["SPOTIFY_API_SESSION_COOKIE_SET_DATE"] = DateTime.UtcNow.ToString("o") + } + }; + + return await UpdateConfig(updateRequest); + } + + /// + /// Get all Jellyfin users + /// + [HttpGet("jellyfin/users")] + public async Task GetJellyfinUsers() + { + if (string.IsNullOrEmpty(_jellyfinSettings.Url) || string.IsNullOrEmpty(_jellyfinSettings.ApiKey)) + { + return BadRequest(new { error = "Jellyfin URL or API key not configured" }); + } + + try + { + var url = $"{_jellyfinSettings.Url}/Users"; + + var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader()); + + var response = await _jellyfinHttpClient.SendAsync(request); + + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(); + _logger.LogError("Failed to fetch Jellyfin users: {StatusCode} - {Body}", response.StatusCode, errorBody); + return StatusCode((int)response.StatusCode, new { error = "Failed to fetch users from Jellyfin" }); + } + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + + var users = new List(); + + foreach (var user in doc.RootElement.EnumerateArray()) + { + var id = user.GetProperty("Id").GetString(); + var name = user.GetProperty("Name").GetString(); + + users.Add(new { id, name }); + } + + return Ok(new { users }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching Jellyfin users"); + return StatusCode(500, new { error = "Failed to fetch users", details = ex.Message }); + } + } + + /// + /// Get all Jellyfin libraries (virtual folders) + /// + [HttpGet("jellyfin/libraries")] + public async Task GetJellyfinLibraries() + { + if (string.IsNullOrEmpty(_jellyfinSettings.Url) || string.IsNullOrEmpty(_jellyfinSettings.ApiKey)) + { + return BadRequest(new { error = "Jellyfin URL or API key not configured" }); + } + + try + { + var url = $"{_jellyfinSettings.Url}/Library/VirtualFolders"; + + var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader()); + + var response = await _jellyfinHttpClient.SendAsync(request); + + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(); + _logger.LogError("Failed to fetch Jellyfin libraries: {StatusCode} - {Body}", response.StatusCode, errorBody); + return StatusCode((int)response.StatusCode, new { error = "Failed to fetch libraries from Jellyfin" }); + } + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + + var libraries = new List(); + + foreach (var lib in doc.RootElement.EnumerateArray()) + { + var name = lib.GetProperty("Name").GetString(); + var itemId = lib.TryGetProperty("ItemId", out var id) ? id.GetString() : null; + var collectionType = lib.TryGetProperty("CollectionType", out var ct) ? ct.GetString() : null; + + libraries.Add(new { id = itemId, name, collectionType }); + } + + return Ok(new { libraries }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching Jellyfin libraries"); + return StatusCode(500, new { error = "Failed to fetch libraries", details = ex.Message }); + } + } + + /// + /// Get all playlists from Jellyfin + /// + [HttpGet("jellyfin/playlists")] + public async Task GetJellyfinPlaylists([FromQuery] string? userId = null) + { + if (string.IsNullOrEmpty(_jellyfinSettings.Url) || string.IsNullOrEmpty(_jellyfinSettings.ApiKey)) + { + return BadRequest(new { error = "Jellyfin URL or API key not configured" }); + } + + try + { + // Build URL with optional userId filter + var url = $"{_jellyfinSettings.Url}/Items?IncludeItemTypes=Playlist&Recursive=true&Fields=ProviderIds,ChildCount,RecursiveItemCount,SongCount"; + + if (!string.IsNullOrEmpty(userId)) + { + url += $"&UserId={userId}"; + } + + var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader()); + + var response = await _jellyfinHttpClient.SendAsync(request); + + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(); + _logger.LogError("Failed to fetch Jellyfin playlists: {StatusCode} - {Body}", response.StatusCode, errorBody); + return StatusCode((int)response.StatusCode, new { error = "Failed to fetch playlists from Jellyfin" }); + } + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + + var playlists = new List(); + + // Read current playlists from .env file for accurate linked status + var configuredPlaylists = await ReadPlaylistsFromEnvFile(); + + if (doc.RootElement.TryGetProperty("Items", out var items)) + { + foreach (var item in items.EnumerateArray()) + { + var id = item.GetProperty("Id").GetString(); + var name = item.GetProperty("Name").GetString(); + + // Try multiple fields for track count - Jellyfin may use different fields + var childCount = 0; + if (item.TryGetProperty("ChildCount", out var cc) && cc.ValueKind == JsonValueKind.Number) + childCount = cc.GetInt32(); + else if (item.TryGetProperty("SongCount", out var sc) && sc.ValueKind == JsonValueKind.Number) + childCount = sc.GetInt32(); + else if (item.TryGetProperty("RecursiveItemCount", out var ric) && ric.ValueKind == JsonValueKind.Number) + childCount = ric.GetInt32(); + + // Check if this playlist is configured in allstarr by Jellyfin ID + var configuredPlaylist = configuredPlaylists + .FirstOrDefault(p => p.JellyfinId.Equals(id, StringComparison.OrdinalIgnoreCase)); + var isConfigured = configuredPlaylist != null; + var linkedSpotifyId = configuredPlaylist?.Id; + + // Only fetch detailed track stats for configured Spotify playlists + // This avoids expensive queries for large non-Spotify playlists + var trackStats = (LocalTracks: 0, ExternalTracks: 0, ExternalAvailable: 0); + if (isConfigured) + { + trackStats = await GetPlaylistTrackStats(id!); + } + + playlists.Add(new + { + id, + name, + trackCount = childCount, + linkedSpotifyId, + isConfigured, + localTracks = trackStats.LocalTracks, + externalTracks = trackStats.ExternalTracks, + externalAvailable = trackStats.ExternalAvailable + }); + } + } + + return Ok(new { playlists }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching Jellyfin playlists"); + return StatusCode(500, new { error = "Failed to fetch playlists", details = ex.Message }); + } + } + + /// + /// Get track statistics for a playlist (local vs external) + /// + private async Task<(int LocalTracks, int ExternalTracks, int ExternalAvailable)> GetPlaylistTrackStats(string playlistId) { try { - var memoryBefore = GC.GetTotalMemory(false); - var processBefore = System.Diagnostics.Process.GetCurrentProcess().WorkingSet64; + // Jellyfin requires a UserId to fetch playlist items + // We'll use the first available user if not specified + var userId = _jellyfinSettings.UserId; - // Force full garbage collection - GC.Collect(2, GCCollectionMode.Forced); + // If no user configured, try to get the first user + if (string.IsNullOrEmpty(userId)) + { + var usersResponse = await _jellyfinHttpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get, $"{_jellyfinSettings.Url}/Users") + { + Headers = { { "X-Emby-Authorization", GetJellyfinAuthHeader() } } + }); + + if (usersResponse.IsSuccessStatusCode) + { + var usersJson = await usersResponse.Content.ReadAsStringAsync(); + using var usersDoc = JsonDocument.Parse(usersJson); + if (usersDoc.RootElement.GetArrayLength() > 0) + { + userId = usersDoc.RootElement[0].GetProperty("Id").GetString(); + } + } + } + + if (string.IsNullOrEmpty(userId)) + { + _logger.LogWarning("No user ID available to fetch playlist items for {PlaylistId}", playlistId); + return (0, 0, 0); + } + + var url = $"{_jellyfinSettings.Url}/Playlists/{playlistId}/Items?UserId={userId}&Fields=Path"; + var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader()); + + var response = await _jellyfinHttpClient.SendAsync(request); + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("Failed to fetch playlist items for {PlaylistId}: {StatusCode}", playlistId, response.StatusCode); + return (0, 0, 0); + } + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + + var localTracks = 0; + var externalTracks = 0; + var externalAvailable = 0; + + if (doc.RootElement.TryGetProperty("Items", out var items)) + { + foreach (var item in items.EnumerateArray()) + { + // Simpler detection: Check if Path exists and is not empty + // External tracks from allstarr won't have a Path property + var hasPath = item.TryGetProperty("Path", out var pathProp) && + pathProp.ValueKind == JsonValueKind.String && + !string.IsNullOrEmpty(pathProp.GetString()); + + if (hasPath) + { + var pathStr = pathProp.GetString()!; + // Check if it's a real file path (not a URL) + if (pathStr.StartsWith("/") || pathStr.Contains(":\\")) + { + localTracks++; + } + else + { + // It's a URL or external source + externalTracks++; + externalAvailable++; + } + } + else + { + // No path means it's external + externalTracks++; + externalAvailable++; + } + } + + _logger.LogDebug("Playlist {PlaylistId} stats: {Local} local, {External} external", + playlistId, localTracks, externalTracks); + } + else + { + _logger.LogWarning("No Items property in playlist response for {PlaylistId}", playlistId); + } + + return (localTracks, externalTracks, externalAvailable); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get track stats for playlist {PlaylistId}", playlistId); + return (0, 0, 0); + } + } + + /// + /// Link a Jellyfin playlist to a Spotify playlist + /// + [HttpPost("jellyfin/playlists/{jellyfinPlaylistId}/link")] + public async Task LinkPlaylist(string jellyfinPlaylistId, [FromBody] LinkPlaylistRequest request) + { + if (string.IsNullOrEmpty(request.SpotifyPlaylistId)) + { + return BadRequest(new { error = "SpotifyPlaylistId is required" }); + } + + if (string.IsNullOrEmpty(request.Name)) + { + return BadRequest(new { error = "Name is required" }); + } + + _logger.LogInformation("Linking Jellyfin playlist {JellyfinId} to Spotify playlist {SpotifyId} with name {Name}", + jellyfinPlaylistId, request.SpotifyPlaylistId, request.Name); + + // Read current playlists from .env file (not in-memory config which is stale) + var currentPlaylists = await ReadPlaylistsFromEnvFile(); + + // Check if already configured by Jellyfin ID + var existingByJellyfinId = currentPlaylists + .FirstOrDefault(p => p.JellyfinId.Equals(jellyfinPlaylistId, StringComparison.OrdinalIgnoreCase)); + + if (existingByJellyfinId != null) + { + return BadRequest(new { error = $"This Jellyfin playlist is already linked to '{existingByJellyfinId.Name}'" }); + } + + // Check if already configured by name + var existingByName = currentPlaylists + .FirstOrDefault(p => p.Name.Equals(request.Name, StringComparison.OrdinalIgnoreCase)); + + if (existingByName != null) + { + return BadRequest(new { error = $"Playlist name '{request.Name}' is already configured" }); + } + + // Add the playlist to configuration + currentPlaylists.Add(new SpotifyPlaylistConfig + { + Name = request.Name, + Id = request.SpotifyPlaylistId, + JellyfinId = jellyfinPlaylistId, + LocalTracksPosition = LocalTracksPosition.First // Use Spotify order + }); + + // Convert to JSON format for env var: [["Name","SpotifyId","JellyfinId","first|last"],...] + var playlistsJson = JsonSerializer.Serialize( + currentPlaylists.Select(p => new[] { p.Name, p.Id, p.JellyfinId, p.LocalTracksPosition.ToString().ToLower() }).ToArray() + ); + + // Update .env file + var updateRequest = new ConfigUpdateRequest + { + Updates = new Dictionary + { + ["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson + } + }; + + return await UpdateConfig(updateRequest); + } + + /// + /// Unlink a playlist (remove from configuration) + /// + [HttpDelete("jellyfin/playlists/{name}/unlink")] + public async Task UnlinkPlaylist(string name) + { + var decodedName = Uri.UnescapeDataString(name); + return await RemovePlaylist(decodedName); + } + + private string GetJellyfinAuthHeader() + { + return $"MediaBrowser Client=\"Allstarr\", Device=\"Server\", DeviceId=\"allstarr-admin\", Version=\"1.0.0\", Token=\"{_jellyfinSettings.ApiKey}\""; + } + + /// + /// Read current playlists from .env file (not stale in-memory config) + /// + private async Task> ReadPlaylistsFromEnvFile() + { + var playlists = new List(); + + if (!System.IO.File.Exists(_envFilePath)) + { + return playlists; + } + + try + { + var lines = await System.IO.File.ReadAllLinesAsync(_envFilePath); + foreach (var line in lines) + { + if (line.TrimStart().StartsWith("SPOTIFY_IMPORT_PLAYLISTS=")) + { + var value = line.Substring(line.IndexOf('=') + 1).Trim(); + + if (string.IsNullOrWhiteSpace(value) || value == "[]") + { + return playlists; + } + + // Parse JSON array format: [["Name","SpotifyId","JellyfinId","first|last"],...] + var playlistArrays = JsonSerializer.Deserialize(value); + if (playlistArrays != null) + { + foreach (var arr in playlistArrays) + { + if (arr.Length >= 2) + { + playlists.Add(new SpotifyPlaylistConfig + { + Name = arr[0].Trim(), + Id = arr[1].Trim(), + JellyfinId = arr.Length >= 3 ? arr[2].Trim() : "", + LocalTracksPosition = arr.Length >= 4 && + arr[3].Trim().Equals("last", StringComparison.OrdinalIgnoreCase) + ? LocalTracksPosition.Last + : LocalTracksPosition.First + }); + } + } + } + break; + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to read playlists from .env file"); + } + + return playlists; + } + + private static string MaskValue(string? value, int showLast = 0) + { + if (string.IsNullOrEmpty(value)) return "(not set)"; + if (value.Length <= showLast) return "***"; + return showLast > 0 ? "***" + value[^showLast..] : value[..8] + "..."; + } + + private static string SanitizeFileName(string name) + { + return string.Join("_", name.Split(Path.GetInvalidFileNameChars())); + } + + private static bool IsValidEnvKey(string key) + { + // Only allow alphanumeric, underscore, and must start with letter/underscore + return Regex.IsMatch(key, @"^[A-Z_][A-Z0-9_]*$", RegexOptions.IgnoreCase); + } + + /// + /// Export .env file for backup/transfer + /// + [HttpGet("export-env")] + public IActionResult ExportEnv() + { + try + { + if (!System.IO.File.Exists(_envFilePath)) + { + return NotFound(new { error = ".env file not found" }); + } + + var envContent = System.IO.File.ReadAllText(_envFilePath); + var bytes = System.Text.Encoding.UTF8.GetBytes(envContent); + + return File(bytes, "text/plain", ".env"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to export .env file"); + return StatusCode(500, new { error = "Failed to export .env file", details = ex.Message }); + } + } + + /// + /// Import .env file from upload + /// + [HttpPost("import-env")] + public async Task ImportEnv([FromForm] IFormFile file) + { + if (file == null || file.Length == 0) + { + return BadRequest(new { error = "No file provided" }); + } + + if (!file.FileName.EndsWith(".env")) + { + return BadRequest(new { error = "File must be a .env file" }); + } + + try + { + // Read uploaded file + using var reader = new StreamReader(file.OpenReadStream()); + var content = await reader.ReadToEndAsync(); + + // Validate it's a valid .env file (basic check) + if (string.IsNullOrWhiteSpace(content)) + { + return BadRequest(new { error = ".env file is empty" }); + } + + // Backup existing .env + if (System.IO.File.Exists(_envFilePath)) + { + var backupPath = $"{_envFilePath}.backup.{DateTime.UtcNow:yyyyMMddHHmmss}"; + System.IO.File.Copy(_envFilePath, backupPath, true); + _logger.LogInformation("Backed up existing .env to {BackupPath}", backupPath); + } + + // Write new .env file + await System.IO.File.WriteAllTextAsync(_envFilePath, content); + + _logger.LogInformation(".env file imported successfully"); + + return Ok(new + { + success = true, + message = ".env file imported successfully. Restart the application for changes to take effect." + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to import .env file"); + return StatusCode(500, new { error = "Failed to import .env file", details = ex.Message }); + } + } + + /// + /// Gets detailed memory usage statistics for debugging. + /// + [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(2, GCCollectionMode.Forced); + GC.Collect(); + + var memoryAfterGC = GC.GetTotalMemory(false); + var gen0After = GC.CollectionCount(0); + var gen1After = GC.CollectionCount(1); + var gen2After = GC.CollectionCount(2); - var memoryAfter = GC.GetTotalMemory(false); - var processAfter = System.Diagnostics.Process.GetCurrentProcess().WorkingSet64; + // Get process memory info + var process = System.Diagnostics.Process.GetCurrentProcess(); 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) + 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 }); } +======= +>>>>>>> beta } + [HttpGet("health")] + public IActionResult Health() + { + return Ok(new { status = "healthy", message = "Admin API is running" }); + } +<<<<<<< HEAD + /// /// Gets current active sessions for debugging. /// @@ -3433,7 +5718,807 @@ public class ManualMappingRequest public string? JellyfinId { get; set; } public string? ExternalProvider { get; set; } public string? ExternalId { get; set; } +||||||| f68706f + + /// + /// Gets current active sessions for debugging. + /// + [HttpGet("sessions")] + public IActionResult GetActiveSessions() + { + try + { + var sessionManager = HttpContext.RequestServices.GetService(); + 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 }); + } + } + + /// + /// Helper method to trigger GC after large file operations to prevent memory leaks. + /// + 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 + + /// + /// Manual trigger endpoint to force fetch Spotify missing tracks. + /// + [HttpGet("spotify/sync")] + public async Task TriggerSpotifySync([FromServices] IEnumerable 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() + .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" }); + } + } + + /// + /// Manual trigger endpoint to force Spotify track matching. + /// + [HttpGet("spotify/match")] + public async Task TriggerSpotifyMatch([FromServices] IEnumerable 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() + .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" }); + } + } + + /// + /// Clear Spotify playlist cache to force re-matching. + /// + [HttpPost("spotify/clear-cache")] + public async Task ClearSpotifyCache() + { + try + { + var clearedKeys = new List(); + + // 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 + + /// + /// Gets endpoint usage statistics from the log file. + /// + [HttpGet("debug/endpoint-usage")] + public async Task 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(); + 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" }); + } + } + + /// + /// Clears the endpoint usage log file. + /// + [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 + + /// + /// Saves a manual mapping to file for persistence across restarts. + /// Manual mappings NEVER expire - they are permanent user decisions. + /// + 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(); + if (System.IO.File.Exists(filePath)) + { + var json = await System.IO.File.ReadAllTextAsync(filePath); + mappings = JsonSerializer.Deserialize>(json) + ?? new Dictionary(); + } + + // 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); + } + } + + /// + /// Save lyrics mapping to file for persistence across restarts. + /// Lyrics mappings NEVER expire - they are permanent user decisions. + /// + 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(); + if (System.IO.File.Exists(mappingsFile)) + { + var json = await System.IO.File.ReadAllTextAsync(mappingsFile); + mappings = JsonSerializer.Deserialize>(json) + ?? new List(); + } + + // 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); + } + } + + /// + /// Save manual lyrics ID mapping for a track + /// + [HttpPost("lyrics/map")] + public async Task 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(); + 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" }); + } + } + + /// + /// Get manual lyrics mappings + /// + [HttpGet("lyrics/mappings")] + public async Task GetLyricsMappings() + { + try + { + var mappingsFile = "/app/cache/lyrics_mappings.json"; + + if (!System.IO.File.Exists(mappingsFile)) + { + return Ok(new { mappings = new List() }); + } + + var json = await System.IO.File.ReadAllTextAsync(mappingsFile); + var mappings = JsonSerializer.Deserialize>(json) ?? new List(); + + 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" }); + } + } + + /// + /// Get all manual track mappings (both Jellyfin and external) for all playlists + /// + [HttpGet("mappings/tracks")] + public async Task GetAllTrackMappings() + { + try + { + var mappingsDir = "/app/cache/mappings"; + var allMappings = new List(); + + 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>(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" }); + } + } + + /// + /// Delete a manual track mapping + /// + [HttpDelete("mappings/tracks")] + public async Task 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>(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" }); + } + } + + /// + /// Test Spotify lyrics API by fetching lyrics for a specific Spotify track ID + /// Example: GET /api/admin/lyrics/spotify/test?trackId=3yII7UwgLF6K5zW3xad3MP + /// + [HttpGet("lyrics/spotify/test")] + public async Task TestSpotifyLyrics([FromQuery] string trackId) + { + if (string.IsNullOrEmpty(trackId)) + { + return BadRequest(new { error = "trackId parameter is required" }); + } + + try + { + var spotifyLyricsService = _serviceProvider.GetService(); + + 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}" }); + } + } + + /// + /// Prefetch lyrics for a specific playlist + /// + [HttpPost("playlists/{name}/prefetch-lyrics")] + public async Task PrefetchPlaylistLyrics(string name) + { + var decodedName = Uri.UnescapeDataString(name); + + try + { + var lyricsPrefetchService = _serviceProvider.GetService(); + + 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 + + /// + /// Invalidates the cached playlist summary so it will be regenerated on next request + /// + 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; } +======= +>>>>>>> beta } +<<<<<<< HEAD public class LyricsMappingRequest { @@ -3693,3 +6778,251 @@ public class ConfigUpdateRequest { public Dictionary Updates { get; set; } = new(); } +||||||| f68706f + +public class LyricsMappingRequest +{ + public string Artist { get; set; } = ""; + public string Title { get; set; } = ""; + public string? Album { get; set; } + public int DurationSeconds { get; set; } + public int LyricsId { get; set; } +} + +public class ManualMappingEntry +{ + public string SpotifyId { get; set; } = ""; + public string? JellyfinId { get; set; } + public string? ExternalProvider { get; set; } + public string? ExternalId { get; set; } + public DateTime CreatedAt { get; set; } +} + +public class LyricsMappingEntry +{ + public string Artist { get; set; } = ""; + public string Title { get; set; } = ""; + public string? Album { get; set; } + public int DurationSeconds { get; set; } + public int LyricsId { get; set; } + public DateTime CreatedAt { get; set; } +} + +public class ConfigUpdateRequest +{ + public Dictionary Updates { get; set; } = new(); +} + +public class AddPlaylistRequest +{ + public string Name { get; set; } = string.Empty; + public string SpotifyId { get; set; } = string.Empty; + public string LocalTracksPosition { get; set; } = "first"; +} + +public class LinkPlaylistRequest +{ + public string Name { get; set; } = string.Empty; + public string SpotifyPlaylistId { get; set; } = string.Empty; +} + + /// + /// GET /api/admin/downloads + /// Lists all downloaded files in the KEPT folder only (favorited tracks) + /// + [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(), totalSize = 0, count = 0 }); + } + + var files = new List(); + 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" }); + } + } + + /// + /// DELETE /api/admin/downloads + /// Deletes a specific kept file and cleans up empty folders + /// + [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" }); + } + } + + /// + /// GET /api/admin/downloads/file + /// Downloads a specific file from the kept folder + /// + [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]}"; + } +} +======= +>>>>>>> beta diff --git a/allstarr/Controllers/ConfigController.cs b/allstarr/Controllers/ConfigController.cs new file mode 100644 index 0000000..be39fe7 --- /dev/null +++ b/allstarr/Controllers/ConfigController.cs @@ -0,0 +1,640 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using allstarr.Models.Settings; +using allstarr.Models.Admin; +using allstarr.Filters; +using allstarr.Services.Admin; +using allstarr.Services.Common; +using System.Text.Json; +using System.Net.Sockets; + +namespace allstarr.Controllers; + +[ApiController] +[Route("api/admin")] +[ServiceFilter(typeof(AdminPortFilter))] +public class ConfigController : ControllerBase +{ + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + private readonly SpotifyApiSettings _spotifyApiSettings; + private readonly JellyfinSettings _jellyfinSettings; + private readonly SubsonicSettings _subsonicSettings; + private readonly DeezerSettings _deezerSettings; + private readonly QobuzSettings _qobuzSettings; + private readonly SquidWTFSettings _squidWtfSettings; + private readonly MusicBrainzSettings _musicBrainzSettings; + private readonly SpotifyImportSettings _spotifyImportSettings; + private readonly ScrobblingSettings _scrobblingSettings; + private readonly AdminHelperService _helperService; + private readonly RedisCacheService _cache; + private const string CacheDirectory = "/app/cache/spotify"; + + public ConfigController( + ILogger logger, + IConfiguration configuration, + IOptions spotifyApiSettings, + IOptions jellyfinSettings, + IOptions subsonicSettings, + IOptions deezerSettings, + IOptions qobuzSettings, + IOptions squidWtfSettings, + IOptions musicBrainzSettings, + IOptions spotifyImportSettings, + IOptions scrobblingSettings, + AdminHelperService helperService, + RedisCacheService cache) + { + _logger = logger; + _configuration = configuration; + _spotifyApiSettings = spotifyApiSettings.Value; + _jellyfinSettings = jellyfinSettings.Value; + _subsonicSettings = subsonicSettings.Value; + _deezerSettings = deezerSettings.Value; + _qobuzSettings = qobuzSettings.Value; + _squidWtfSettings = squidWtfSettings.Value; + _musicBrainzSettings = musicBrainzSettings.Value; + _spotifyImportSettings = spotifyImportSettings.Value; + _scrobblingSettings = scrobblingSettings.Value; + _helperService = helperService; + _cache = cache; + } + + [HttpGet("config")] + public async Task GetConfig() + { + return Ok(new + { + backendType = _configuration.GetValue("Backend:Type") ?? "Jellyfin", + musicService = _configuration.GetValue("MusicService") ?? "SquidWTF", + explicitFilter = _configuration.GetValue("ExplicitFilter") ?? "All", + enableExternalPlaylists = _configuration.GetValue("EnableExternalPlaylists", false), + playlistsDirectory = _configuration.GetValue("PlaylistsDirectory") ?? "(not set)", + redisEnabled = _configuration.GetValue("Redis:Enabled", false), + debug = new + { + logAllRequests = _configuration.GetValue("Debug:LogAllRequests", false) + }, + spotifyApi = new + { + enabled = _spotifyApiSettings.Enabled, + sessionCookie = AdminHelperService.MaskValue(_spotifyApiSettings.SessionCookie, showLast: 8), + sessionCookieSetDate = _spotifyApiSettings.SessionCookieSetDate, + cacheDurationMinutes = _spotifyApiSettings.CacheDurationMinutes, + rateLimitDelayMs = _spotifyApiSettings.RateLimitDelayMs, + preferIsrcMatching = _spotifyApiSettings.PreferIsrcMatching + }, + spotifyImport = new + { + enabled = _spotifyImportSettings.Enabled, + matchingIntervalHours = _spotifyImportSettings.MatchingIntervalHours, + playlists = _spotifyImportSettings.Playlists.Select(p => new + { + name = p.Name, + id = p.Id, + localTracksPosition = p.LocalTracksPosition.ToString() + }) + }, + jellyfin = new + { + url = _jellyfinSettings.Url, + apiKey = AdminHelperService.MaskValue(_jellyfinSettings.ApiKey), + userId = _jellyfinSettings.UserId ?? "(not set)", + libraryId = _jellyfinSettings.LibraryId + }, + library = new + { + downloadPath = _subsonicSettings.StorageMode == StorageMode.Cache + ? Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "cache") + : Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "permanent"), + keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept"), + storageMode = _subsonicSettings.StorageMode.ToString(), + cacheDurationHours = _subsonicSettings.CacheDurationHours, + downloadMode = _subsonicSettings.DownloadMode.ToString() + }, + deezer = new + { + arl = AdminHelperService.MaskValue(_deezerSettings.Arl, showLast: 8), + arlFallback = AdminHelperService.MaskValue(_deezerSettings.ArlFallback, showLast: 8), + quality = _deezerSettings.Quality ?? "FLAC" + }, + qobuz = new + { + userAuthToken = AdminHelperService.MaskValue(_qobuzSettings.UserAuthToken, showLast: 8), + userId = _qobuzSettings.UserId, + quality = _qobuzSettings.Quality ?? "FLAC" + }, + squidWtf = new + { + quality = _squidWtfSettings.Quality ?? "LOSSLESS" + }, + musicBrainz = new + { + enabled = _musicBrainzSettings.Enabled, + username = _musicBrainzSettings.Username ?? "(not set)", + password = AdminHelperService.MaskValue(_musicBrainzSettings.Password), + baseUrl = _musicBrainzSettings.BaseUrl, + rateLimitMs = _musicBrainzSettings.RateLimitMs + }, + scrobbling = await GetScrobblingSettingsFromEnvAsync() + }); + } + + /// + /// Read scrobbling settings directly from .env file for real-time updates + /// + private async Task GetScrobblingSettingsFromEnvAsync() + { + try + { + var envPath = _helperService.GetEnvFilePath(); + if (!System.IO.File.Exists(envPath)) + { + // Fallback to IOptions if .env doesn't exist + return new + { + enabled = _scrobblingSettings.Enabled, + lastFm = new + { + enabled = _scrobblingSettings.LastFm.Enabled, + apiKey = AdminHelperService.MaskValue(_scrobblingSettings.LastFm.ApiKey, showLast: 8), + sharedSecret = AdminHelperService.MaskValue(_scrobblingSettings.LastFm.SharedSecret, showLast: 8), + sessionKey = AdminHelperService.MaskValue(_scrobblingSettings.LastFm.SessionKey, showLast: 8), + username = _scrobblingSettings.LastFm.Username ?? "(not set)", + password = AdminHelperService.MaskValue(_scrobblingSettings.LastFm.Password, showLast: 0) + }, + listenBrainz = new + { + enabled = _scrobblingSettings.ListenBrainz.Enabled, + userToken = AdminHelperService.MaskValue(_scrobblingSettings.ListenBrainz.UserToken, showLast: 8) + } + }; + } + + var lines = await System.IO.File.ReadAllLinesAsync(envPath); + var envVars = new Dictionary(); + + foreach (var line in lines) + { + if (AdminHelperService.ShouldSkipEnvLine(line)) + continue; + + var (key, value) = AdminHelperService.ParseEnvLine(line); + if (!string.IsNullOrEmpty(key)) + { + envVars[key] = value; + } + } + + return new + { + enabled = envVars.TryGetValue("SCROBBLING_ENABLED", out var scrobblingEnabled) + ? scrobblingEnabled.Equals("true", StringComparison.OrdinalIgnoreCase) + : _scrobblingSettings.Enabled, + lastFm = new + { + enabled = envVars.TryGetValue("SCROBBLING_LASTFM_ENABLED", out var lastFmEnabled) + ? lastFmEnabled.Equals("true", StringComparison.OrdinalIgnoreCase) + : _scrobblingSettings.LastFm.Enabled, + apiKey = envVars.TryGetValue("SCROBBLING_LASTFM_API_KEY", out var apiKey) + ? AdminHelperService.MaskValue(apiKey, showLast: 8) + : AdminHelperService.MaskValue(_scrobblingSettings.LastFm.ApiKey, showLast: 8), + sharedSecret = envVars.TryGetValue("SCROBBLING_LASTFM_SHARED_SECRET", out var sharedSecret) + ? AdminHelperService.MaskValue(sharedSecret, showLast: 8) + : AdminHelperService.MaskValue(_scrobblingSettings.LastFm.SharedSecret, showLast: 8), + sessionKey = envVars.TryGetValue("SCROBBLING_LASTFM_SESSION_KEY", out var sessionKey) + ? AdminHelperService.MaskValue(sessionKey, showLast: 8) + : AdminHelperService.MaskValue(_scrobblingSettings.LastFm.SessionKey, showLast: 8), + username = envVars.TryGetValue("SCROBBLING_LASTFM_USERNAME", out var username) + ? (string.IsNullOrEmpty(username) ? "(not set)" : username) + : (_scrobblingSettings.LastFm.Username ?? "(not set)"), + password = envVars.TryGetValue("SCROBBLING_LASTFM_PASSWORD", out var password) + ? AdminHelperService.MaskValue(password, showLast: 0) + : AdminHelperService.MaskValue(_scrobblingSettings.LastFm.Password, showLast: 0) + }, + listenBrainz = new + { + enabled = envVars.TryGetValue("SCROBBLING_LISTENBRAINZ_ENABLED", out var lbEnabled) + ? lbEnabled.Equals("true", StringComparison.OrdinalIgnoreCase) + : _scrobblingSettings.ListenBrainz.Enabled, + userToken = envVars.TryGetValue("SCROBBLING_LISTENBRAINZ_USER_TOKEN", out var userToken) + ? AdminHelperService.MaskValue(userToken, showLast: 8) + : AdminHelperService.MaskValue(_scrobblingSettings.ListenBrainz.UserToken, showLast: 8) + } + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to read scrobbling settings from .env, falling back to IOptions"); + // Fallback to IOptions + return new + { + enabled = _scrobblingSettings.Enabled, + lastFm = new + { + enabled = _scrobblingSettings.LastFm.Enabled, + apiKey = AdminHelperService.MaskValue(_scrobblingSettings.LastFm.ApiKey, showLast: 8), + sharedSecret = AdminHelperService.MaskValue(_scrobblingSettings.LastFm.SharedSecret, showLast: 8), + sessionKey = AdminHelperService.MaskValue(_scrobblingSettings.LastFm.SessionKey, showLast: 8), + username = _scrobblingSettings.LastFm.Username ?? "(not set)", + password = AdminHelperService.MaskValue(_scrobblingSettings.LastFm.Password, showLast: 0) + }, + listenBrainz = new + { + enabled = _scrobblingSettings.ListenBrainz.Enabled, + userToken = AdminHelperService.MaskValue(_scrobblingSettings.ListenBrainz.UserToken, showLast: 8) + } + }; + } + } + + /// + /// Update configuration by modifying .env file + /// + [HttpPost("config")] + public async Task UpdateConfig([FromBody] ConfigUpdateRequest request) + { + if (request == null || request.Updates == null || request.Updates.Count == 0) + { + return BadRequest(new { error = "No updates provided" }); + } + + _logger.LogDebug("Config update requested: {Count} changes", request.Updates.Count); + + try + { + // Check if .env file exists + if (!System.IO.File.Exists(_helperService.GetEnvFilePath())) + { + _logger.LogWarning(".env file not found at {Path}, creating new file", _helperService.GetEnvFilePath()); + } + + // Read current .env file or create new one + var envContent = new Dictionary(); + + if (System.IO.File.Exists(_helperService.GetEnvFilePath())) + { + var lines = await System.IO.File.ReadAllLinesAsync(_helperService.GetEnvFilePath()); + foreach (var line in lines) + { + if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith('#')) + continue; + + var eqIndex = line.IndexOf('='); + if (eqIndex > 0) + { + var key = line[..eqIndex].Trim(); + var value = line[(eqIndex + 1)..].Trim(); + + // Remove surrounding quotes if present (for proper re-quoting) + if (value.StartsWith("\"") && value.EndsWith("\"") && value.Length >= 2) + { + value = value[1..^1]; + } + + envContent[key] = value; + } + } + _logger.LogDebug("Loaded {Count} existing env vars from {Path}", envContent.Count, _helperService.GetEnvFilePath()); + } + + // Apply updates with validation + var appliedUpdates = new List(); + foreach (var (key, value) in request.Updates) + { + // Validate key format + if (!AdminHelperService.IsValidEnvKey(key)) + { + _logger.LogWarning("Invalid env key rejected: {Key}", key); + return BadRequest(new { error = $"Invalid environment variable key: {key}" }); + } + + // IMPORTANT: Docker Compose does NOT need quotes in .env files + // It handles special characters correctly without them + // When quotes are used, they become part of the value itself + envContent[key] = value; + appliedUpdates.Add(key); + _logger.LogInformation(" Setting {Key} = {Value}", key, + key.Contains("COOKIE") || key.Contains("TOKEN") || key.Contains("KEY") || key.Contains("ARL") || key.Contains("PASSWORD") + ? "***" + (value.Length > 8 ? value[^8..] : "") + : value); + + // Auto-set cookie date when Spotify session cookie is updated + if (key == "SPOTIFY_API_SESSION_COOKIE" && !string.IsNullOrEmpty(value)) + { + var dateKey = "SPOTIFY_API_SESSION_COOKIE_SET_DATE"; + var dateValue = DateTime.UtcNow.ToString("o"); // ISO 8601 format + envContent[dateKey] = dateValue; + appliedUpdates.Add(dateKey); + _logger.LogInformation(" Auto-setting {Key} to {Value}", dateKey, dateValue); + } + } + + // Write back to .env file (no quoting needed - Docker Compose handles special chars) + var newContent = string.Join("\n", envContent.Select(kv => $"{kv.Key}={kv.Value}")); + await System.IO.File.WriteAllTextAsync(_helperService.GetEnvFilePath(), newContent + "\n"); + + _logger.LogDebug("Config file updated successfully at {Path}", _helperService.GetEnvFilePath()); + + // Invalidate playlist summary cache if playlists were updated + if (appliedUpdates.Contains("SPOTIFY_IMPORT_PLAYLISTS")) + { + _helperService.InvalidatePlaylistSummaryCache(); + } + + return Ok(new + { + message = "Configuration updated. Restart container to apply changes.", + updatedKeys = appliedUpdates, + requiresRestart = true, + envFilePath = _helperService.GetEnvFilePath() + }); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogError(ex, "Permission denied writing to .env file at {Path}", _helperService.GetEnvFilePath()); + return StatusCode(500, new { + error = "Permission denied", + details = "Cannot write to .env file. Check file permissions and volume mount.", + path = _helperService.GetEnvFilePath() + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update configuration at {Path}", _helperService.GetEnvFilePath()); + return StatusCode(500, new { + error = "Failed to update configuration", + details = ex.Message, + path = _helperService.GetEnvFilePath() + }); + } + } + + /// + /// Add a new playlist to the configuration + /// + [HttpPost("cache/clear")] + public async Task ClearCache() + { + _logger.LogDebug("Cache clear requested from admin UI"); + + var clearedFiles = 0; + var clearedRedisKeys = 0; + + // Clear file cache + if (Directory.Exists(CacheDirectory)) + { + foreach (var file in Directory.GetFiles(CacheDirectory, "*.json")) + { + try + { + System.IO.File.Delete(file); + clearedFiles++; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete cache file {File}", file); + } + } + } + + // Clear ALL Redis cache keys for Spotify playlists + // This includes matched tracks, ordered tracks, missing tracks, playlist items, etc. + foreach (var playlist in _spotifyImportSettings.Playlists) + { + var keysToDelete = new[] + { + CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name), + CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlist.Name), + $"spotify:matched:{playlist.Name}", // Legacy key + CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name), + CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name) + }; + + foreach (var key in keysToDelete) + { + if (await _cache.DeleteAsync(key)) + { + clearedRedisKeys++; + _logger.LogInformation("Cleared Redis cache key: {Key}", key); + } + } + } + + // Clear all search cache keys (pattern-based deletion) + var searchKeysDeleted = await _cache.DeleteByPatternAsync("search:*"); + clearedRedisKeys += searchKeysDeleted; + + // Clear all image cache keys (pattern-based deletion) + var imageKeysDeleted = await _cache.DeleteByPatternAsync("image:*"); + clearedRedisKeys += imageKeysDeleted; + + _logger.LogInformation("Cache cleared: {Files} files, {RedisKeys} Redis keys (including {SearchKeys} search keys, {ImageKeys} image keys)", + clearedFiles, clearedRedisKeys, searchKeysDeleted, imageKeysDeleted); + + return Ok(new { + message = "Cache cleared successfully", + filesDeleted = clearedFiles, + redisKeysDeleted = clearedRedisKeys + }); + } + + /// + /// Restart the allstarr container to apply configuration changes + /// + [HttpPost("restart")] + public async Task RestartContainer() + { + _logger.LogDebug("Container restart requested from admin UI"); + + try + { + // Use Docker socket to restart the container + var socketPath = "/var/run/docker.sock"; + + if (!System.IO.File.Exists(socketPath)) + { + _logger.LogWarning("Docker socket not available at {Path}", socketPath); + return StatusCode(503, new { + error = "Docker socket not available", + message = "Please restart manually: docker-compose restart allstarr" + }); + } + + // Get container ID from hostname (Docker sets hostname to container ID by default) + // Or use the well-known container name + var containerId = Environment.MachineName; + var containerName = "allstarr"; + + _logger.LogDebug("Attempting to restart container {ContainerId} / {ContainerName}", containerId, containerName); + + // Create Unix socket HTTP client + var handler = new SocketsHttpHandler + { + ConnectCallback = async (context, cancellationToken) => + { + var socket = new System.Net.Sockets.Socket( + System.Net.Sockets.AddressFamily.Unix, + System.Net.Sockets.SocketType.Stream, + System.Net.Sockets.ProtocolType.Unspecified); + + var endpoint = new System.Net.Sockets.UnixDomainSocketEndPoint(socketPath); + await socket.ConnectAsync(endpoint, cancellationToken); + + return new System.Net.Sockets.NetworkStream(socket, ownsSocket: true); + } + }; + + using var dockerClient = new HttpClient(handler) + { + BaseAddress = new Uri("http://localhost") + }; + + // Try to restart by container name first, then by ID + var response = await dockerClient.PostAsync($"/containers/{containerName}/restart?t=5", null); + + if (!response.IsSuccessStatusCode) + { + // Try by container ID + response = await dockerClient.PostAsync($"/containers/{containerId}/restart?t=5", null); + } + + if (response.IsSuccessStatusCode) + { + _logger.LogInformation("Container restart initiated successfully"); + return Ok(new { message = "Restarting container...", success = true }); + } + else + { + var errorBody = await response.Content.ReadAsStringAsync(); + _logger.LogError("Failed to restart container: {StatusCode} - {Body}", response.StatusCode, errorBody); + return StatusCode((int)response.StatusCode, new { + error = "Failed to restart container", + message = "Please restart manually: docker-compose restart allstarr" + }); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error restarting container"); + return StatusCode(500, new { + error = "Failed to restart container", + details = ex.Message, + message = "Please restart manually: docker-compose restart allstarr" + }); + } + } + + /// + /// Initialize cookie date to current date if cookie exists but date is not set + /// + [HttpPost("config/init-cookie-date")] + public async Task InitCookieDate() + { + // Only init if cookie exists but date is not set + if (string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie)) + { + return BadRequest(new { error = "No cookie set" }); + } + + if (!string.IsNullOrEmpty(_spotifyApiSettings.SessionCookieSetDate)) + { + return Ok(new { message = "Cookie date already set", date = _spotifyApiSettings.SessionCookieSetDate }); + } + + _logger.LogInformation("Initializing cookie date to current date (cookie existed without date tracking)"); + + var updateRequest = new ConfigUpdateRequest + { + Updates = new Dictionary + { + ["SPOTIFY_API_SESSION_COOKIE_SET_DATE"] = DateTime.UtcNow.ToString("o") + } + }; + + return await UpdateConfig(updateRequest); + } + + /// + /// Get all Jellyfin users + /// + [HttpGet("export-env")] + public IActionResult ExportEnv() + { + try + { + if (!System.IO.File.Exists(_helperService.GetEnvFilePath())) + { + return NotFound(new { error = ".env file not found" }); + } + + var envContent = System.IO.File.ReadAllText(_helperService.GetEnvFilePath()); + var bytes = System.Text.Encoding.UTF8.GetBytes(envContent); + + return File(bytes, "text/plain", ".env"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to export .env file"); + return StatusCode(500, new { error = "Failed to export .env file", details = ex.Message }); + } + } + + /// + /// Import .env file from upload + /// + [HttpPost("import-env")] + public async Task ImportEnv([FromForm] IFormFile file) + { + if (file == null || file.Length == 0) + { + return BadRequest(new { error = "No file provided" }); + } + + if (!file.FileName.EndsWith(".env")) + { + return BadRequest(new { error = "File must be a .env file" }); + } + + try + { + // Read uploaded file + using var reader = new StreamReader(file.OpenReadStream()); + var content = await reader.ReadToEndAsync(); + + // Validate it's a valid .env file (basic check) + if (string.IsNullOrWhiteSpace(content)) + { + return BadRequest(new { error = ".env file is empty" }); + } + + // Backup existing .env + if (System.IO.File.Exists(_helperService.GetEnvFilePath())) + { + var backupPath = $"{_helperService.GetEnvFilePath()}.backup.{DateTime.UtcNow:yyyyMMddHHmmss}"; + System.IO.File.Copy(_helperService.GetEnvFilePath(), backupPath, true); + _logger.LogDebug("Backed up existing .env to {BackupPath}", backupPath); + } + + // Write new .env file + await System.IO.File.WriteAllTextAsync(_helperService.GetEnvFilePath(), content); + + _logger.LogInformation(".env file imported successfully"); + + return Ok(new + { + success = true, + message = ".env file imported successfully. Restart the application for changes to take effect." + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to import .env file"); + return StatusCode(500, new { error = "Failed to import .env file", details = ex.Message }); + } + } + + /// + /// Gets detailed memory usage statistics for debugging. + /// +} diff --git a/allstarr/Controllers/DiagnosticsController.cs b/allstarr/Controllers/DiagnosticsController.cs new file mode 100644 index 0000000..7872a4c --- /dev/null +++ b/allstarr/Controllers/DiagnosticsController.cs @@ -0,0 +1,394 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using allstarr.Models.Settings; +using allstarr.Filters; +using allstarr.Services.Jellyfin; +using allstarr.Services.Common; +using System.Runtime; + +namespace allstarr.Controllers; + +[ApiController] +[Route("api/admin")] +[ServiceFilter(typeof(AdminPortFilter))] +public class DiagnosticsController : ControllerBase +{ + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + private readonly SpotifyApiSettings _spotifyApiSettings; + private readonly SpotifyImportSettings _spotifyImportSettings; + private readonly JellyfinSettings _jellyfinSettings; + private readonly DeezerSettings _deezerSettings; + private readonly QobuzSettings _qobuzSettings; + private readonly SquidWTFSettings _squidWtfSettings; + private readonly RedisCacheService _cache; + private readonly List _squidWtfApiUrls; + private static int _urlIndex = 0; + private static readonly object _urlIndexLock = new(); + + public DiagnosticsController( + ILogger logger, + IConfiguration configuration, + IOptions spotifyApiSettings, + IOptions spotifyImportSettings, + IOptions jellyfinSettings, + IOptions deezerSettings, + IOptions qobuzSettings, + IOptions squidWtfSettings, + RedisCacheService cache) + { + _logger = logger; + _configuration = configuration; + _spotifyApiSettings = spotifyApiSettings.Value; + _spotifyImportSettings = spotifyImportSettings.Value; + _jellyfinSettings = jellyfinSettings.Value; + _deezerSettings = deezerSettings.Value; + _qobuzSettings = qobuzSettings.Value; + _squidWtfSettings = squidWtfSettings.Value; + _cache = cache; + _squidWtfApiUrls = DecodeSquidWtfUrls(); + } + + private static List DecodeSquidWtfUrls() + { + var encodedUrls = new[] + { + "aHR0cHM6Ly90cml0b24uc3F1aWQud3Rm", + "aHR0cHM6Ly90aWRhbC5raW5vcGx1cy5vbmxpbmU=", + "aHR0cHM6Ly9oaWZpLXR3by5zcG90aXNhdmVyLm5ldA==", + "aHR0cHM6Ly9oaWZpLW9uZS5zcG90aXNhdmVyLm5ldA==", + "aHR0cHM6Ly93b2xmLnFxZGwuc2l0ZQ==", + "aHR0cDovL2h1bmQucXFkbC5zaXRl", + "aHR0cHM6Ly9rYXR6ZS5xcWRsLnNpdGU=", + "aHR0cHM6Ly92b2dlbC5xcWRsLnNpdGU=", + "aHR0cHM6Ly9tYXVzLnFxZGwuc2l0ZQ==", + "aHR0cHM6Ly9ldS1jZW50cmFsLm1vbm9jaHJvbWUudGY=", + "aHR0cHM6Ly91cy13ZXN0Lm1vbm9jaHJvbWUudGY=", + "aHR0cHM6Ly9hcnJhbi5tb25vY2hyb21lLnRm", + "aHR0cHM6Ly9hcGkubW9ub2Nocm9tZS50Zg==", + "aHR0cHM6Ly9odW5kLnFxZGwuc2l0ZQ==" + }; + return encodedUrls.Select(encoded => System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(encoded))).ToList(); + } + + [HttpGet("status")] + public IActionResult GetStatus() + { + // Determine Spotify auth status based on configuration only + // DO NOT call Spotify API here - this endpoint is polled frequently + var spotifyAuthStatus = "not_configured"; + string? spotifyUser = null; + + if (_spotifyApiSettings.Enabled && !string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie)) + { + // If cookie is set, assume it's working until proven otherwise + // Actual validation happens when playlists are fetched + spotifyAuthStatus = "configured"; + spotifyUser = "(cookie set)"; + } + else if (_spotifyApiSettings.Enabled) + { + spotifyAuthStatus = "missing_cookie"; + } + + return Ok(new + { + version = AppVersion.Version, + backendType = _configuration.GetValue("Backend:Type") ?? "Jellyfin", + jellyfinUrl = _jellyfinSettings.Url, + spotify = new + { + apiEnabled = _spotifyApiSettings.Enabled, + authStatus = spotifyAuthStatus, + user = spotifyUser, + hasCookie = !string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie), + cookieSetDate = _spotifyApiSettings.SessionCookieSetDate, + cacheDurationMinutes = _spotifyApiSettings.CacheDurationMinutes, + preferIsrcMatching = _spotifyApiSettings.PreferIsrcMatching + }, + spotifyImport = new + { + enabled = _spotifyImportSettings.Enabled, + matchingIntervalHours = _spotifyImportSettings.MatchingIntervalHours, + playlistCount = _spotifyImportSettings.Playlists.Count + }, + deezer = new + { + hasArl = !string.IsNullOrEmpty(_deezerSettings.Arl), + quality = _deezerSettings.Quality ?? "FLAC" + }, + qobuz = new + { + hasToken = !string.IsNullOrEmpty(_qobuzSettings.UserAuthToken), + quality = _qobuzSettings.Quality ?? "FLAC" + }, + squidWtf = new + { + quality = _squidWtfSettings.Quality ?? "LOSSLESS" + } + }); + } + + /// + /// Get a random SquidWTF base URL for searching (round-robin) + /// + [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 }); + } + + /// + /// Get current configuration including cache settings + /// + + /// + /// Get list of configured playlists with their current data + /// + [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 }); + } + } + + /// + /// Forces garbage collection to free up memory (emergency use only). + /// + [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 }); + } + } + + /// + /// Gets current active sessions for debugging. + /// + [HttpGet("sessions")] + public IActionResult GetActiveSessions() + { + try + { + var sessionManager = HttpContext.RequestServices.GetService(); + 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 }); + } + } + + /// + /// Helper method to trigger GC after large file operations to prevent memory leaks. + /// + [HttpGet("debug/endpoint-usage")] + public async Task 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(); + 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" }); + } + } + + /// + /// Clears the endpoint usage log file. + /// + [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.LogDebug("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" }); + } + } + + + + /// + /// Saves a manual mapping to file for persistence across restarts. + /// Manual mappings NEVER expire - they are permanent user decisions. + /// +} diff --git a/allstarr/Controllers/DownloadsController.cs b/allstarr/Controllers/DownloadsController.cs new file mode 100644 index 0000000..62e872a --- /dev/null +++ b/allstarr/Controllers/DownloadsController.cs @@ -0,0 +1,245 @@ +using Microsoft.AspNetCore.Mvc; +using allstarr.Filters; +using allstarr.Services.Admin; + +namespace allstarr.Controllers; + +[ApiController] +[Route("api/admin")] +[ServiceFilter(typeof(AdminPortFilter))] +public class DownloadsController : ControllerBase +{ + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + + public DownloadsController( + ILogger logger, + IConfiguration configuration) + { + _logger = logger; + _configuration = configuration; + } + + [HttpGet("downloads")] + public IActionResult GetDownloads() + { + try + { + var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept"); + + if (!Directory.Exists(keptPath)) + { + return Ok(new { files = new List(), totalSize = 0, count = 0 }); + } + + var files = new List(); + 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(); + + foreach (var filePath in allFiles) + { + + 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 = AdminHelperService.FormatFileSize(fileInfo.Length), + lastModified = fileInfo.LastWriteTimeUtc, + extension = fileInfo.Extension + }); + + totalSize += fileInfo.Length; + } + + return Ok(new + { + files = files.OrderBy(f => ((dynamic)f).artist).ThenBy(f => ((dynamic)f).album).ThenBy(f => ((dynamic)f).fileName), + totalSize, + totalSizeFormatted = AdminHelperService.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" }); + } + } + + /// + /// DELETE /api/admin/downloads + /// Deletes a specific kept file and cleans up empty folders + /// + [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); + + // 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" }); + } + + System.IO.File.Delete(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); + directory = Path.GetDirectoryName(directory); + } + else + { + 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" }); + } + } + + /// + /// GET /api/admin/downloads/file + /// Downloads a specific file from the kept folder + /// + [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" }); + } + } + + /// + /// GET /api/admin/downloads/all + /// Downloads all kept files as a zip archive + /// + [HttpGet("downloads/all")] + public IActionResult DownloadAllFiles() + { + try + { + var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept"); + + if (!Directory.Exists(keptPath)) + { + return NotFound(new { error = "No kept files found" }); + } + + var audioExtensions = new[] { ".flac", ".mp3", ".m4a", ".opus" }; + var allFiles = Directory.GetFiles(keptPath, "*.*", SearchOption.AllDirectories) + .Where(f => audioExtensions.Contains(Path.GetExtension(f).ToLowerInvariant())) + .ToList(); + + if (allFiles.Count == 0) + { + return NotFound(new { error = "No audio files found in kept folder" }); + } + + _logger.LogInformation("📦 Creating zip archive with {Count} files", allFiles.Count); + + // Create zip in memory + var memoryStream = new MemoryStream(); + using (var archive = new System.IO.Compression.ZipArchive(memoryStream, System.IO.Compression.ZipArchiveMode.Create, true)) + { + foreach (var filePath in allFiles) + { + var relativePath = Path.GetRelativePath(keptPath, filePath); + var entry = archive.CreateEntry(relativePath, System.IO.Compression.CompressionLevel.NoCompression); + + using var entryStream = entry.Open(); + using var fileStream = System.IO.File.OpenRead(filePath); + fileStream.CopyTo(entryStream); + } + } + + memoryStream.Position = 0; + var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); + return File(memoryStream, "application/zip", $"allstarr_kept_{timestamp}.zip"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create zip archive"); + return StatusCode(500, new { error = "Failed to create zip archive" }); + } + } + + /// + /// Gets all Spotify track mappings (paginated) + /// +} diff --git a/allstarr/Controllers/Helpers.cs b/allstarr/Controllers/Helpers.cs new file mode 100644 index 0000000..c5869cc --- /dev/null +++ b/allstarr/Controllers/Helpers.cs @@ -0,0 +1,356 @@ +using System.Text.Json; +using allstarr.Models.Domain; +using allstarr.Models.Spotify; +using allstarr.Services.Common; +using Microsoft.AspNetCore.Mvc; + +namespace allstarr.Controllers; + +public partial class JellyfinController +{ + #region Helpers + + /// + /// Helper to handle proxy responses with proper status code handling. + /// + private IActionResult HandleProxyResponse(JsonDocument? result, int statusCode, object? fallbackValue = null) + { + if (result != null) + { + return new JsonResult(JsonSerializer.Deserialize(result.RootElement.GetRawText())); + } + + // Handle error status codes + if (statusCode == 401) + { + return Unauthorized(); + } + else if (statusCode == 403) + { + return Forbid(); + } + else if (statusCode == 404) + { + return NotFound(); + } + else if (statusCode >= 400) + { + return StatusCode(statusCode); + } + + // Success with no body - return fallback or empty + if (fallbackValue != null) + { + return new JsonResult(fallbackValue); + } + + return NoContent(); + } + + /// + /// Updates ChildCount for Spotify playlists in the response to show total tracks (local + matched). + /// + private async Task UpdateSpotifyPlaylistCounts(JsonDocument response) + { + try + { + if (!response.RootElement.TryGetProperty("Items", out var items)) + { + return response; + } + + var itemsArray = items.EnumerateArray().ToList(); + var modified = false; + var updatedItems = new List>(); + + _logger.LogDebug("Checking {Count} items for Spotify playlists", itemsArray.Count); + + foreach (var item in itemsArray) + { + var itemDict = JsonSerializer.Deserialize>(item.GetRawText()); + if (itemDict == null) + { + continue; + } + + // Check if this is a Spotify playlist + if (item.TryGetProperty("Id", out var idProp)) + { + var playlistId = idProp.GetString(); + _logger.LogDebug("Checking item with ID: {Id}", playlistId); + + if (!string.IsNullOrEmpty(playlistId) && _spotifySettings.IsSpotifyPlaylist(playlistId)) + { + _logger.LogInformation("Found Spotify playlist: {Id}", playlistId); + + // This is a Spotify playlist - get the actual track count + var playlistConfig = _spotifySettings.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) + var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlistName); + var matchedTracks = await _cache.GetAsync>(matchedTracksKey); + + _logger.LogInformation("Cache lookup for {Key}: {Count} matched tracks", + matchedTracksKey, matchedTracks?.Count ?? 0); + + // Fallback to legacy cache format + if (matchedTracks == null || matchedTracks.Count == 0) + { + var legacyKey = $"spotify:matched:{playlistName}"; + var legacySongs = await _cache.GetAsync>(legacyKey); + if (legacySongs != null && legacySongs.Count > 0) + { + matchedTracks = legacySongs.Select((s, i) => new MatchedTrack + { + Position = i, + MatchedSong = s + }).ToList(); + _logger.LogDebug("Loaded {Count} tracks from legacy cache", matchedTracks.Count); + } + } + + // Try loading from file cache if Redis is empty + if (matchedTracks == null || matchedTracks.Count == 0) + { + var fileItems = await LoadPlaylistItemsFromFile(playlistName); + if (fileItems != null && fileItems.Count > 0) + { + _logger.LogDebug( + "💿 Loaded {Count} playlist items from file cache for count update", + fileItems.Count); + // Use file cache count directly + itemDict["ChildCount"] = fileItems.Count; + modified = true; + } + } + + // Only fetch from Jellyfin if we didn't get count from file cache + if (!itemDict.ContainsKey("ChildCount") || + (itemDict["ChildCount"] is JsonElement childCountElement && + childCountElement.GetInt32() == 0) || + (itemDict["ChildCount"] is int childCountInt && childCountInt == 0)) + { + // Get local tracks count from Jellyfin + var localTracksCount = 0; + try + { + // Include UserId parameter to avoid 401 Unauthorized + var userId = _settings.UserId; + var playlistItemsUrl = $"Playlists/{playlistId}/Items"; + var queryParams = new Dictionary(); + if (!string.IsNullOrEmpty(userId)) + { + queryParams["UserId"] = userId; + } + + var (localTracksResponse, _) = await _proxyService.GetJsonAsyncInternal( + playlistItemsUrl, + queryParams); + + if (localTracksResponse != null && + localTracksResponse.RootElement.TryGetProperty("Items", out var localItems)) + { + localTracksCount = localItems.GetArrayLength(); + _logger.LogDebug("Found {Count} total items in Jellyfin playlist {Name}", + localTracksCount, playlistName); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get local tracks count for {Name}", playlistName); + } + + // Count external matched tracks (not local) + var externalMatchedCount = 0; + if (matchedTracks != null) + { + externalMatchedCount = matchedTracks.Count(t => + t.MatchedSong != null && !t.MatchedSong.IsLocal); + } + + // Total available tracks = local tracks in Jellyfin + external matched tracks + // This represents what users will actually hear when playing the playlist + var totalAvailableCount = localTracksCount + externalMatchedCount; + + if (totalAvailableCount > 0) + { + // Update ChildCount to show actual available tracks + itemDict["ChildCount"] = totalAvailableCount; + modified = true; + _logger.LogDebug( + "✓ Updated ChildCount for Spotify playlist {Name} to {Total} ({Local} local + {External} external)", + playlistName, totalAvailableCount, localTracksCount, externalMatchedCount); + } + else + { + _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); + } + } + } + + updatedItems.Add(itemDict); + } + + if (!modified) + { + _logger.LogInformation("No Spotify playlists found to update"); + return response; + } + + _logger.LogDebug("Modified {Count} Spotify playlists, rebuilding response", + updatedItems.Count(i => i.ContainsKey("ChildCount"))); + + // Rebuild the response with updated items + var responseDict = + JsonSerializer.Deserialize>(response.RootElement.GetRawText()); + if (responseDict != null) + { + responseDict["Items"] = updatedItems; + var updatedJson = JsonSerializer.Serialize(responseDict); + return JsonDocument.Parse(updatedJson); + } + + return response; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update Spotify playlist counts"); + return response; + } + } + + /// + /// Logs endpoint usage to a file for analysis. + /// Creates a CSV file with timestamp, method, path, and query string. + /// + private async Task LogEndpointUsageAsync(string path, string method) + { + try + { + var logDir = "/app/cache/endpoint-usage"; + Directory.CreateDirectory(logDir); + + var logFile = Path.Combine(logDir, "endpoints.csv"); + var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss"); + var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : ""; + + // Sanitize path and query for CSV (remove commas, quotes, newlines) + var sanitizedPath = path.Replace(",", ";").Replace("\"", "'").Replace("\n", " ").Replace("\r", " "); + var sanitizedQuery = queryString.Replace(",", ";").Replace("\"", "'").Replace("\n", " ").Replace("\r", " "); + + var logLine = $"{timestamp},{method},{sanitizedPath},{sanitizedQuery}\n"; + + // Append to file (thread-safe) + await System.IO.File.AppendAllTextAsync(logFile, logLine); + } + catch (Exception ex) + { + // Don't let logging failures break the request + _logger.LogError(ex, "Failed to log endpoint usage"); + } + } + + private static string[]? ParseItemTypes(string? includeItemTypes) + { + if (string.IsNullOrWhiteSpace(includeItemTypes)) + { + return null; + } + + return includeItemTypes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } + + private static string GetContentType(string filePath) + { + var extension = Path.GetExtension(filePath).ToLowerInvariant(); + return extension switch + { + ".mp3" => "audio/mpeg", + ".flac" => "audio/flac", + ".ogg" => "audio/ogg", + ".m4a" => "audio/mp4", + ".wav" => "audio/wav", + ".aac" => "audio/aac", + _ => "audio/mpeg" + }; + } + + /// + /// Scores search results based on fuzzy matching against the query. + /// Returns items with their relevance scores. + /// External results get a small boost to prioritize the larger catalog. + /// + private static List<(T Item, int Score)> ScoreSearchResults( + string query, + List items, + Func titleField, + Func artistField, + Func albumField, + bool isExternal = false) + { + return items.Select(item => + { + var title = titleField(item) ?? ""; + var artist = artistField(item) ?? ""; + var album = albumField(item) ?? ""; + + // Token-based fuzzy matching: split query and fields into words + var queryTokens = query.ToLower() + .Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries) + .ToList(); + + var fieldText = $"{title} {artist} {album}".ToLower(); + var fieldTokens = fieldText + .Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries) + .ToList(); + + if (queryTokens.Count == 0) return (item, 0); + + // Count how many query tokens match field tokens (with fuzzy tolerance) + var matchedTokens = 0; + foreach (var queryToken in queryTokens) + { + // Check if any field token matches this query token + var hasMatch = fieldTokens.Any(fieldToken => + { + // Exact match or substring match + if (fieldToken.Contains(queryToken) || queryToken.Contains(fieldToken)) + return true; + + // Fuzzy match with Levenshtein distance + var similarity = FuzzyMatcher.CalculateSimilarity(queryToken, fieldToken); + return similarity >= 70; // 70% similarity threshold for individual words + }); + + if (hasMatch) matchedTokens++; + } + + // Score = percentage of query tokens that matched + var baseScore = (matchedTokens * 100) / queryTokens.Count; + + // Give external results a small boost (+5 points) to prioritize the larger catalog + var finalScore = isExternal ? Math.Min(100, baseScore + 5) : baseScore; + + return (item, finalScore); + }).ToList(); + } + + #endregion +} \ No newline at end of file diff --git a/allstarr/Controllers/JellyfinAdminController.cs b/allstarr/Controllers/JellyfinAdminController.cs new file mode 100644 index 0000000..bf49a46 --- /dev/null +++ b/allstarr/Controllers/JellyfinAdminController.cs @@ -0,0 +1,473 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using allstarr.Models.Settings; +using allstarr.Models.Admin; +using allstarr.Services.Admin; +using allstarr.Services.Common; +using allstarr.Filters; +using System.Text.Json; + +namespace allstarr.Controllers; + +[ApiController] +[Route("api/admin")] +[ServiceFilter(typeof(AdminPortFilter))] +public class JellyfinAdminController : ControllerBase +{ + private readonly ILogger _logger; + private readonly JellyfinSettings _jellyfinSettings; + private readonly HttpClient _jellyfinHttpClient; + private readonly AdminHelperService _helperService; + private readonly RedisCacheService _cache; + private readonly IConfiguration _configuration; + private readonly SpotifyImportSettings _spotifyImportSettings; + + public JellyfinAdminController( + ILogger logger, + IOptions jellyfinSettings, + IHttpClientFactory httpClientFactory, + AdminHelperService helperService, + RedisCacheService cache, + IConfiguration configuration, + IOptions spotifyImportSettings) + { + _logger = logger; + _jellyfinSettings = jellyfinSettings.Value; + _jellyfinHttpClient = httpClientFactory.CreateClient(); + _helperService = helperService; + _cache = cache; + _configuration = configuration; + _spotifyImportSettings = spotifyImportSettings.Value; + } + + [HttpGet("jellyfin/users")] + public async Task GetJellyfinUsers() + { + if (string.IsNullOrEmpty(_jellyfinSettings.Url) || string.IsNullOrEmpty(_jellyfinSettings.ApiKey)) + { + return BadRequest(new { error = "Jellyfin URL or API key not configured" }); + } + + try + { + var url = $"{_jellyfinSettings.Url}/Users"; + + var request = _helperService.CreateJellyfinRequest(HttpMethod.Get, url); + + var response = await _jellyfinHttpClient.SendAsync(request); + + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(); + _logger.LogError("Failed to fetch Jellyfin users: {StatusCode} - {Body}", response.StatusCode, errorBody); + return StatusCode((int)response.StatusCode, new { error = "Failed to fetch users from Jellyfin" }); + } + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + + var users = new List(); + + foreach (var user in doc.RootElement.EnumerateArray()) + { + var id = user.GetProperty("Id").GetString(); + var name = user.GetProperty("Name").GetString(); + + users.Add(new { id, name }); + } + + return Ok(new { users }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching Jellyfin users"); + return StatusCode(500, new { error = "Failed to fetch users", details = ex.Message }); + } + } + + /// + /// Get all Jellyfin libraries (virtual folders) + /// + [HttpGet("jellyfin/libraries")] + public async Task GetJellyfinLibraries() + { + if (string.IsNullOrEmpty(_jellyfinSettings.Url) || string.IsNullOrEmpty(_jellyfinSettings.ApiKey)) + { + return BadRequest(new { error = "Jellyfin URL or API key not configured" }); + } + + try + { + var url = $"{_jellyfinSettings.Url}/Library/VirtualFolders"; + + var request = _helperService.CreateJellyfinRequest(HttpMethod.Get, url); + + var response = await _jellyfinHttpClient.SendAsync(request); + + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(); + _logger.LogError("Failed to fetch Jellyfin libraries: {StatusCode} - {Body}", response.StatusCode, errorBody); + return StatusCode((int)response.StatusCode, new { error = "Failed to fetch libraries from Jellyfin" }); + } + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + + var libraries = new List(); + + foreach (var lib in doc.RootElement.EnumerateArray()) + { + var name = lib.GetProperty("Name").GetString(); + var itemId = lib.TryGetProperty("ItemId", out var id) ? id.GetString() : null; + var collectionType = lib.TryGetProperty("CollectionType", out var ct) ? ct.GetString() : null; + + libraries.Add(new { id = itemId, name, collectionType }); + } + + return Ok(new { libraries }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching Jellyfin libraries"); + return StatusCode(500, new { error = "Failed to fetch libraries", details = ex.Message }); + } + } + + /// + /// Get all playlists from the user's Spotify account + /// + [HttpGet("jellyfin/playlists")] + public async Task GetJellyfinPlaylists([FromQuery] string? userId = null) + { + if (string.IsNullOrEmpty(_jellyfinSettings.Url) || string.IsNullOrEmpty(_jellyfinSettings.ApiKey)) + { + return BadRequest(new { error = "Jellyfin URL or API key not configured" }); + } + + try + { + // Build URL with optional userId filter + var url = $"{_jellyfinSettings.Url}/Items?IncludeItemTypes=Playlist&Recursive=true&Fields=ProviderIds,ChildCount,RecursiveItemCount,SongCount"; + + if (!string.IsNullOrEmpty(userId)) + { + url += $"&UserId={userId}"; + } + + var request = _helperService.CreateJellyfinRequest(HttpMethod.Get, url); + + var response = await _jellyfinHttpClient.SendAsync(request); + + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(); + _logger.LogError("Failed to fetch Jellyfin playlists: {StatusCode} - {Body}", response.StatusCode, errorBody); + return StatusCode((int)response.StatusCode, new { error = "Failed to fetch playlists from Jellyfin" }); + } + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + + var playlists = new List(); + + // Read current playlists from .env file for accurate linked status + var configuredPlaylists = await _helperService.ReadPlaylistsFromEnvFileAsync(); + + if (doc.RootElement.TryGetProperty("Items", out var items)) + { + foreach (var item in items.EnumerateArray()) + { + var id = item.GetProperty("Id").GetString(); + var name = item.GetProperty("Name").GetString(); + + // Try multiple fields for track count - Jellyfin may use different fields + var childCount = 0; + if (item.TryGetProperty("ChildCount", out var cc) && cc.ValueKind == JsonValueKind.Number) + childCount = cc.GetInt32(); + else if (item.TryGetProperty("SongCount", out var sc) && sc.ValueKind == JsonValueKind.Number) + childCount = sc.GetInt32(); + else if (item.TryGetProperty("RecursiveItemCount", out var ric) && ric.ValueKind == JsonValueKind.Number) + childCount = ric.GetInt32(); + + // Check if this playlist is configured in allstarr by Jellyfin ID + var configuredPlaylist = configuredPlaylists + .FirstOrDefault(p => p.JellyfinId.Equals(id, StringComparison.OrdinalIgnoreCase)); + var isConfigured = configuredPlaylist != null; + var linkedSpotifyId = configuredPlaylist?.Id; + + // Only fetch detailed track stats for configured Spotify playlists + // This avoids expensive queries for large non-Spotify playlists + var trackStats = (LocalTracks: 0, ExternalTracks: 0, ExternalAvailable: 0); + if (isConfigured) + { + trackStats = await GetPlaylistTrackStats(id!); + } + + // Use actual track stats for configured playlists, otherwise use Jellyfin's count + var actualTrackCount = isConfigured + ? trackStats.LocalTracks + trackStats.ExternalTracks + : childCount; + + playlists.Add(new + { + id, + name, + trackCount = actualTrackCount, + linkedSpotifyId, + isConfigured, + localTracks = trackStats.LocalTracks, + externalTracks = trackStats.ExternalTracks, + externalAvailable = trackStats.ExternalAvailable + }); + } + } + + return Ok(new { playlists }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching Jellyfin playlists"); + return StatusCode(500, new { error = "Failed to fetch playlists", details = ex.Message }); + } + } + + /// + /// Get track statistics for a playlist (local vs external) + /// + private async Task<(int LocalTracks, int ExternalTracks, int ExternalAvailable)> GetPlaylistTrackStats(string playlistId) + { + try + { + // Jellyfin requires a UserId to fetch playlist items + // We'll use the first available user if not specified + var userId = _jellyfinSettings.UserId; + + // If no user configured, try to get the first user + if (string.IsNullOrEmpty(userId)) + { + var usersRequest = _helperService.CreateJellyfinRequest(HttpMethod.Get, $"{_jellyfinSettings.Url}/Users"); + var usersResponse = await _jellyfinHttpClient.SendAsync(usersRequest); + + if (usersResponse.IsSuccessStatusCode) + { + var usersJson = await usersResponse.Content.ReadAsStringAsync(); + using var usersDoc = JsonDocument.Parse(usersJson); + if (usersDoc.RootElement.GetArrayLength() > 0) + { + userId = usersDoc.RootElement[0].GetProperty("Id").GetString(); + } + } + } + + if (string.IsNullOrEmpty(userId)) + { + _logger.LogWarning("No user ID available to fetch playlist items for {PlaylistId}", playlistId); + return (0, 0, 0); + } + + var url = $"{_jellyfinSettings.Url}/Playlists/{playlistId}/Items?UserId={userId}&Fields=Path"; + var request = _helperService.CreateJellyfinRequest(HttpMethod.Get, url); + + var response = await _jellyfinHttpClient.SendAsync(request); + if (!response.IsSuccessStatusCode) + { + _logger.LogError("Failed to fetch playlist items for {PlaylistId}: {StatusCode}", playlistId, response.StatusCode); + return (0, 0, 0); + } + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + + var localTracks = 0; + var externalTracks = 0; + var externalAvailable = 0; + + if (doc.RootElement.TryGetProperty("Items", out var items)) + { + foreach (var item in items.EnumerateArray()) + { + // Simpler detection: Check if Path exists and is not empty + // External tracks from allstarr won't have a Path property + var hasPath = item.TryGetProperty("Path", out var pathProp) && + pathProp.ValueKind == JsonValueKind.String && + !string.IsNullOrEmpty(pathProp.GetString()); + + if (hasPath) + { + var pathStr = pathProp.GetString()!; + // Check if it's a real file path (not a URL) + if (pathStr.StartsWith("/") || pathStr.Contains(":\\")) + { + localTracks++; + } + else + { + // It's a URL or external source + externalTracks++; + externalAvailable++; + } + } + else + { + // No path means it's external + externalTracks++; + externalAvailable++; + } + } + + _logger.LogDebug("Playlist {PlaylistId} stats: {Local} local, {External} external", + playlistId, localTracks, externalTracks); + } + else + { + _logger.LogWarning("No Items property in playlist response for {PlaylistId}", playlistId); + } + + return (localTracks, externalTracks, externalAvailable); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get track stats for playlist {PlaylistId}", playlistId); + return (0, 0, 0); + } + } + + /// + /// Link a Jellyfin playlist to a Spotify playlist + /// + [HttpPost("jellyfin/playlists/{jellyfinPlaylistId}/link")] + public async Task LinkPlaylist(string jellyfinPlaylistId, [FromBody] LinkPlaylistRequest request) + { + if (string.IsNullOrEmpty(request.SpotifyPlaylistId)) + { + return BadRequest(new { error = "SpotifyPlaylistId is required" }); + } + + if (string.IsNullOrEmpty(request.Name)) + { + return BadRequest(new { error = "Name is required" }); + } + + _logger.LogInformation("Linking Jellyfin playlist {JellyfinId} to Spotify playlist {SpotifyId} with name {Name}", + jellyfinPlaylistId, request.SpotifyPlaylistId, request.Name); + + // Read current playlists from .env file (not in-memory config which is stale) + var currentPlaylists = await _helperService.ReadPlaylistsFromEnvFileAsync(); + + // Check if already configured by Jellyfin ID + var existingByJellyfinId = currentPlaylists + .FirstOrDefault(p => p.JellyfinId.Equals(jellyfinPlaylistId, StringComparison.OrdinalIgnoreCase)); + + if (existingByJellyfinId != null) + { + return BadRequest(new { error = $"This Jellyfin playlist is already linked to '{existingByJellyfinId.Name}'" }); + } + + // Check if already configured by name + var existingByName = currentPlaylists + .FirstOrDefault(p => p.Name.Equals(request.Name, StringComparison.OrdinalIgnoreCase)); + + if (existingByName != null) + { + return BadRequest(new { error = $"Playlist name '{request.Name}' is already configured" }); + } + + // Add the playlist to configuration + currentPlaylists.Add(new SpotifyPlaylistConfig + { + Name = request.Name, + Id = request.SpotifyPlaylistId, + JellyfinId = jellyfinPlaylistId, + LocalTracksPosition = LocalTracksPosition.First, // Use Spotify order + SyncSchedule = request.SyncSchedule ?? "0 8 * * *" // Default to daily 8 AM + }); + + // Convert to JSON format for env var: [["Name","SpotifyId","JellyfinId","first|last","cronSchedule"],...] + var playlistsJson = JsonSerializer.Serialize( + currentPlaylists.Select(p => new[] { + p.Name, + p.Id, + p.JellyfinId, + p.LocalTracksPosition.ToString().ToLower(), + p.SyncSchedule ?? "0 8 * * *" + }).ToArray() + ); + + // Update .env file + var updateRequest = new ConfigUpdateRequest + { + Updates = new Dictionary + { + ["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson + } + }; + + return await _helperService.UpdateEnvConfigAsync(updateRequest.Updates); + } + + /// + /// Unlink a playlist (remove from configuration) + /// + [HttpDelete("jellyfin/playlists/{name}/unlink")] + public async Task UnlinkPlaylist(string name) + { + var decodedName = Uri.UnescapeDataString(name); + return await _helperService.RemovePlaylistFromConfigAsync(decodedName); + } + + /// + /// Update playlist sync schedule + /// + [HttpPut("playlists/{name}/schedule")] + public async Task 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 _helperService.ReadPlaylistsFromEnvFileAsync(); + 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 * * *" + }).ToArray() + ); + + var updateRequest = new ConfigUpdateRequest + { + Updates = new Dictionary + { + ["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson + } + }; + + return await _helperService.UpdateEnvConfigAsync(updateRequest.Updates); + } + +} diff --git a/allstarr/Controllers/JellyfinController.Audio.cs b/allstarr/Controllers/JellyfinController.Audio.cs new file mode 100644 index 0000000..1ae57b5 --- /dev/null +++ b/allstarr/Controllers/JellyfinController.Audio.cs @@ -0,0 +1,224 @@ +using allstarr.Services.Common; +using Microsoft.AspNetCore.Mvc; + +namespace allstarr.Controllers; + +public partial class JellyfinController +{ + #region Audio Streaming + + /// + /// Downloads/streams audio. Works with local and external content. + /// + [HttpGet("Items/{itemId}/Download")] + [HttpGet("Items/{itemId}/File")] + public async Task DownloadAudio(string itemId) + { + if (string.IsNullOrWhiteSpace(itemId)) + { + return BadRequest(new { error = "Missing item ID" }); + } + + var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId); + + if (!isExternal) + { + // Build path for Jellyfin download/file endpoint + var endpoint = Request.Path.Value?.Contains("/File", StringComparison.OrdinalIgnoreCase) == true + ? "File" + : "Download"; + var fullPath = $"Items/{itemId}/{endpoint}"; + if (Request.QueryString.HasValue) + { + fullPath = $"{fullPath}{Request.QueryString.Value}"; + } + + return await ProxyJellyfinStream(fullPath, itemId); + } + + // Handle external content + return await StreamExternalContent(provider!, externalId!); + } + + /// + /// Streams audio for a given item. Downloads on-demand for external content. + /// + [HttpGet("Audio/{itemId}/stream")] + [HttpGet("Audio/{itemId}/stream.{container}")] + public async Task StreamAudio(string itemId, string? container = null) + { + if (string.IsNullOrWhiteSpace(itemId)) + { + return BadRequest(new { error = "Missing item ID" }); + } + + var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId); + + if (!isExternal) + { + // Build path for Jellyfin stream + var fullPath = string.IsNullOrEmpty(container) + ? $"Audio/{itemId}/stream" + : $"Audio/{itemId}/stream.{container}"; + + if (Request.QueryString.HasValue) + { + fullPath = $"{fullPath}{Request.QueryString.Value}"; + } + + return await ProxyJellyfinStream(fullPath, itemId); + } + + // Handle external content + return await StreamExternalContent(provider!, externalId!); + } + + /// + /// Proxies a stream from Jellyfin with proper header forwarding. + /// + private async Task ProxyJellyfinStream(string path, string itemId) + { + var jellyfinUrl = $"{_settings.Url?.TrimEnd('/')}/{path}"; + + try + { + var request = new HttpRequestMessage(HttpMethod.Get, jellyfinUrl); + + // Forward auth headers + AuthHeaderHelper.ForwardAuthHeaders(Request.Headers, request); + + // Forward Range header for seeking + if (Request.Headers.TryGetValue("Range", out var range)) + { + request.Headers.TryAddWithoutValidation("Range", range.ToString()); + } + + var response = await _proxyService.HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + + if (!response.IsSuccessStatusCode) + { + _logger.LogError("Jellyfin stream failed: {StatusCode} for {ItemId}", response.StatusCode, itemId); + return StatusCode((int)response.StatusCode); + } + + // Set response status and headers + Response.StatusCode = (int)response.StatusCode; + + var contentType = response.Content.Headers.ContentType?.ToString() ?? "audio/mpeg"; + + // Forward caching headers for client-side caching + if (response.Headers.ETag != null) + { + Response.Headers["ETag"] = response.Headers.ETag.ToString(); + } + + if (response.Content.Headers.LastModified.HasValue) + { + Response.Headers["Last-Modified"] = response.Content.Headers.LastModified.Value.ToString("R"); + } + + if (response.Headers.CacheControl != null) + { + Response.Headers["Cache-Control"] = response.Headers.CacheControl.ToString(); + } + + // Forward range headers for seeking + if (response.Content.Headers.ContentRange != null) + { + Response.Headers["Content-Range"] = response.Content.Headers.ContentRange.ToString(); + } + + if (response.Headers.AcceptRanges != null) + { + Response.Headers["Accept-Ranges"] = string.Join(", ", response.Headers.AcceptRanges); + } + + if (response.Content.Headers.ContentLength.HasValue) + { + Response.Headers["Content-Length"] = response.Content.Headers.ContentLength.Value.ToString(); + } + + var stream = await response.Content.ReadAsStreamAsync(); + return File(stream, contentType); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to proxy stream from Jellyfin for {ItemId}", itemId); + return StatusCode(500, new { error = $"Streaming failed: {ex.Message}" }); + } + } + + /// + /// Streams external content, using cache if available or downloading on-demand. + /// + private async Task StreamExternalContent(string provider, string externalId) + { + // Check for locally cached file + var localPath = await _localLibraryService.GetLocalPathForExternalSongAsync(provider, externalId); + + if (localPath != null && System.IO.File.Exists(localPath)) + { + // Update last write time for cache cleanup (extends cache lifetime) + try + { + System.IO.File.SetLastWriteTimeUtc(localPath, DateTime.UtcNow); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update last write time for {Path}", localPath); + } + + var stream = System.IO.File.OpenRead(localPath); + return File(stream, GetContentType(localPath), enableRangeProcessing: true); + } + + // Download and stream on-demand + try + { + var downloadStream = await _downloadService.DownloadAndStreamAsync( + provider, + externalId, + HttpContext.RequestAborted); + + return File(downloadStream, "audio/mpeg", enableRangeProcessing: true); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to stream external song {Provider}:{ExternalId}", provider, externalId); + return StatusCode(500, new { error = $"Streaming failed: {ex.Message}" }); + } + } + + /// + /// Universal audio endpoint - handles transcoding, format negotiation, and adaptive streaming. + /// This is the primary endpoint used by Jellyfin Web and most clients. + /// + [HttpGet("Audio/{itemId}/universal")] + [HttpHead("Audio/{itemId}/universal")] + public async Task UniversalAudio(string itemId) + { + if (string.IsNullOrWhiteSpace(itemId)) + { + return BadRequest(new { error = "Missing item ID" }); + } + + var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId); + + if (!isExternal) + { + // For local content, proxy the universal endpoint with all query parameters + var fullPath = $"Audio/{itemId}/universal"; + if (Request.QueryString.HasValue) + { + fullPath = $"{fullPath}{Request.QueryString.Value}"; + } + + return await ProxyJellyfinStream(fullPath, itemId); + } + + // For external content, use simple streaming (no transcoding support yet) + return await StreamExternalContent(provider!, externalId!); + } + + #endregion +} \ No newline at end of file diff --git a/allstarr/Controllers/JellyfinController.Authentication.cs b/allstarr/Controllers/JellyfinController.Authentication.cs new file mode 100644 index 0000000..36e2392 --- /dev/null +++ b/allstarr/Controllers/JellyfinController.Authentication.cs @@ -0,0 +1,123 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; + +namespace allstarr.Controllers; + +public partial class JellyfinController +{ + #region Authentication + + /// + /// Authenticates a user by username and password. + /// This is the primary login endpoint for Jellyfin clients. + /// + [HttpPost("Users/AuthenticateByName")] + public async Task AuthenticateByName() + { + try + { + // Enable buffering to allow multiple reads of the request body + Request.EnableBuffering(); + + // Read the request body + using var reader = new StreamReader(Request.Body, leaveOpen: true); + var body = await reader.ReadToEndAsync(); + + // Reset stream position + Request.Body.Position = 0; + + _logger.LogDebug("Authentication request received"); + // DO NOT log request body or detailed headers - contains password + + // Forward to Jellyfin server with client headers - completely transparent proxy + var (result, statusCode) = + await _proxyService.PostJsonAsync("Users/AuthenticateByName", body, Request.Headers); + + // Pass through Jellyfin's response exactly as-is (transparent proxy) + if (result != null) + { + var responseJson = result.RootElement.GetRawText(); + + // On successful auth, extract access token and post session capabilities in background + if (statusCode == 200) + { + _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 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"); + + // Build auth header with the new token + var authHeaders = new HeaderDictionary + { + ["X-Emby-Token"] = token + }; + + var capabilities = new + { + PlayableMediaTypes = new[] { "Audio" }, + SupportedCommands = Array.Empty(), + 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.LogError(ex, "Failed to post session capabilities after auth"); + } + }); + } + } + else + { + _logger.LogError("Authentication failed - status {StatusCode}", statusCode); + } + + // Return Jellyfin's exact response + return Content(responseJson, "application/json"); + } + + // No response body from Jellyfin - return status code only + _logger.LogWarning("Authentication request returned {StatusCode} with no response body", statusCode); + return StatusCode(statusCode); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during authentication"); + return StatusCode(500, new { error = $"Authentication error: {ex.Message}" }); + } + } + + #endregion +} \ No newline at end of file diff --git a/allstarr/Controllers/JellyfinController.Lyrics.cs b/allstarr/Controllers/JellyfinController.Lyrics.cs new file mode 100644 index 0000000..6c7f587 --- /dev/null +++ b/allstarr/Controllers/JellyfinController.Lyrics.cs @@ -0,0 +1,471 @@ +using System.Text.Json; +using allstarr.Models.Domain; +using allstarr.Models.Lyrics; +using Microsoft.AspNetCore.Mvc; + +namespace allstarr.Controllers; + +public partial class JellyfinController +{ + #region Lyrics + + /// + /// Gets lyrics for an item. + /// Priority: 1. Jellyfin embedded lyrics, 2. Spotify synced lyrics, 3. LRCLIB + /// + [HttpGet("Audio/{itemId}/Lyrics")] + [HttpGet("Items/{itemId}/Lyrics")] + public async Task GetLyrics(string itemId) + { + _logger.LogDebug("🎵 GetLyrics called for itemId: {ItemId}", itemId); + + if (string.IsNullOrWhiteSpace(itemId)) + { + return NotFound(); + } + + var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId); + + _logger.LogDebug( + "🎵 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) + { + _logger.LogDebug("Checking Jellyfin for embedded lyrics for local track: {ItemId}", itemId); + + // Try to get lyrics from Jellyfin first (it reads embedded lyrics from files) + var (jellyfinLyrics, statusCode) = + await _proxyService.GetJsonAsync($"Audio/{itemId}/Lyrics", null, Request.Headers); + + _logger.LogDebug("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(jellyfinLyrics.RootElement.GetRawText())); + } + + _logger.LogWarning("No embedded lyrics found in Jellyfin (status: {StatusCode}), trying Spotify/LRCLIB", + statusCode); + } + + // Get song metadata for lyrics search + Song? song = null; + string? spotifyTrackId = null; + + if (isExternal) + { + song = await _metadataService.GetSongAsync(provider!, externalId!); + + // Use Spotify ID from song metadata if available (populated during GetSongAsync) + if (song != null && !string.IsNullOrEmpty(song.SpotifyId)) + { + spotifyTrackId = song.SpotifyId; + _logger.LogInformation("Using Spotify ID {SpotifyId} from song metadata for {Provider}/{ExternalId}", + spotifyTrackId, provider, externalId); + } + // Fallback: Try to find Spotify ID from matched tracks cache + else if (song != null) + { + spotifyTrackId = await FindSpotifyIdForExternalTrackAsync(song); + if (!string.IsNullOrEmpty(spotifyTrackId)) + { + _logger.LogDebug( + "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.LogDebug("Converted {Provider}/{ExternalId} to Spotify ID {SpotifyId} via Odesli", + provider, externalId, spotifyTrackId); + } + } + } + } + else + { + // For local songs, get 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) + { + return NotFound(new { error = "Song not found" }); + } + + // Strip [S] suffix from title, artist, and album for lyrics search + // The [S] tag is added to external tracks but shouldn't be used in lyrics queries + var searchTitle = song.Title.Replace(" [S]", "").Trim(); + var searchArtist = song.Artist?.Replace(" [S]", "").Trim() ?? ""; + var searchAlbum = song.Album?.Replace(" [S]", "").Trim() ?? ""; + var searchArtists = song.Artists.Select(a => a.Replace(" [S]", "").Trim()).ToList(); + + if (searchArtists.Count == 0 && !string.IsNullOrEmpty(searchArtist)) + { + searchArtists.Add(searchArtist); + } + + // Use orchestrator for clean, modular lyrics fetching + LyricsInfo? lyrics = null; + + if (_lyricsOrchestrator != null) + { + lyrics = await _lyricsOrchestrator.GetLyricsAsync( + trackName: searchTitle, + artistNames: searchArtists.ToArray(), + albumName: searchAlbum, + durationSeconds: song.Duration ?? 0, + spotifyTrackId: spotifyTrackId); + } + else + { + // Fallback to manual fetching if orchestrator not available + _logger.LogWarning("LyricsOrchestrator not available, using fallback method"); + + // Try Spotify lyrics ONLY 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) + { + lyrics = _spotifyLyricsService.ToLyricsInfo(spotifyLyrics); + } + } + } + + // Fall back to LyricsPlus + if (lyrics == null && _lyricsPlusService != null) + { + lyrics = await _lyricsPlusService.GetLyricsAsync( + searchTitle, + searchArtists.ToArray(), + searchAlbum, + song.Duration ?? 0); + } + + // Fall back to LRCLIB + if (lyrics == null && _lrclibService != null) + { + lyrics = await _lrclibService.GetLyricsAsync( + searchTitle, + searchArtists.ToArray(), + searchAlbum, + song.Duration ?? 0); + } + } + + if (lyrics == null) + { + return NotFound(new { error = "Lyrics not found" }); + } + + // Prefer synced lyrics, fall back to plain + var lyricsText = lyrics.SyncedLyrics ?? lyrics.PlainLyrics ?? ""; + var isSynced = !string.IsNullOrEmpty(lyrics.SyncedLyrics); + + _logger.LogInformation( + "Lyrics for {Artist} - {Track}: synced={HasSynced}, plainLength={PlainLen}, syncedLength={SyncLen}", + song.Artist, song.Title, isSynced, lyrics.PlainLyrics?.Length ?? 0, lyrics.SyncedLyrics?.Length ?? 0); + + // Parse LRC format into individual lines for Jellyfin + var lyricLines = new List>(); + + if (isSynced && !string.IsNullOrEmpty(lyrics.SyncedLyrics)) + { + _logger.LogDebug("Parsing synced lyrics (LRC format)"); + // Parse LRC format: [mm:ss.xx] text + // Skip ID tags like [ar:Artist], [ti:Title], etc. + var lines = lyrics.SyncedLyrics.Split('\n', StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + // Match timestamp format [mm:ss.xx] or [mm:ss.xxx] + var match = System.Text.RegularExpressions.Regex.Match(line, @"^\[(\d+):(\d+)\.(\d+)\]\s*(.*)$"); + if (match.Success) + { + var minutes = int.Parse(match.Groups[1].Value); + var seconds = int.Parse(match.Groups[2].Value); + var centiseconds = int.Parse(match.Groups[3].Value); + var text = match.Groups[4].Value; + + // Convert to ticks (100 nanoseconds) + var totalMilliseconds = (minutes * 60 + seconds) * 1000 + centiseconds * 10; + var ticks = totalMilliseconds * 10000L; + + // For synced lyrics, include Start timestamp + lyricLines.Add(new Dictionary + { + ["Text"] = text, + ["Start"] = ticks + }); + } + // Skip ID tags like [ar:Artist], [ti:Title], [length:2:23], etc. + } + + _logger.LogDebug("Parsed {Count} synced lyric lines (skipped ID tags)", lyricLines.Count); + } + else if (!string.IsNullOrEmpty(lyricsText)) + { + _logger.LogInformation("Splitting plain lyrics into lines (no timestamps)"); + // Plain lyrics - split by newlines and return each line separately + // IMPORTANT: Do NOT include "Start" field at all for unsynced lyrics + // Including it (even as null) causes clients to treat it as synced with timestamp 0:00 + var lines = lyricsText.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + lyricLines.Add(new Dictionary + { + ["Text"] = line.Trim() + }); + } + + _logger.LogDebug("Split into {Count} plain lyric lines", lyricLines.Count); + } + else + { + _logger.LogWarning("No lyrics text available"); + // No lyrics at all + lyricLines.Add(new Dictionary + { + ["Text"] = "" + }); + } + + var response = new + { + Metadata = new + { + Artist = lyrics.ArtistName, + Album = lyrics.AlbumName, + Title = lyrics.TrackName, + Length = lyrics.Duration, + IsSynced = isSynced + }, + Lyrics = lyricLines + }; + + _logger.LogDebug("Returning lyrics response: {LineCount} lines, synced={IsSynced}", lyricLines.Count, isSynced); + + // Log a sample of the response for debugging + if (lyricLines.Count > 0) + { + var sampleLine = lyricLines[0]; + var hasStart = sampleLine.ContainsKey("Start"); + _logger.LogDebug("Sample line: Text='{Text}', HasStart={HasStart}", + sampleLine.GetValueOrDefault("Text"), hasStart); + } + + return Ok(response); + } + + /// + /// Proactively fetches and caches lyrics for a track in the background. + /// Called when playback starts to ensure lyrics are ready when requested. + /// + 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); + + // Use orchestrator for prefetching + if (_lyricsOrchestrator != null) + { + await _lyricsOrchestrator.PrefetchLyricsAsync( + trackName: searchTitle, + artistNames: searchArtists.ToArray(), + albumName: searchAlbum, + durationSeconds: song.Duration ?? 0, + spotifyTrackId: spotifyTrackId); + return; + } + + // Fallback to manual prefetching if orchestrator not available + _logger.LogWarning("LyricsOrchestrator not available for prefetch, using fallback method"); + + // 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 LyricsPlus + if (_lyricsPlusService != null) + { + var lyrics = await _lyricsPlusService.GetLyricsAsync( + searchTitle, + searchArtists.ToArray(), + searchAlbum, + song.Duration ?? 0); + + if (lyrics != null) + { + _logger.LogDebug("✓ Prefetched LyricsPlus lyrics for {Artist} - {Title}", searchArtist, + searchTitle); + 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.LogError(ex, "Error prefetching lyrics for track {ItemId}", itemId); + } + } + + #endregion +} \ No newline at end of file diff --git a/allstarr/Controllers/JellyfinController.PlaybackSessions.cs b/allstarr/Controllers/JellyfinController.PlaybackSessions.cs new file mode 100644 index 0000000..5bf3e67 --- /dev/null +++ b/allstarr/Controllers/JellyfinController.PlaybackSessions.cs @@ -0,0 +1,915 @@ +using System.Text.Json; +using allstarr.Models.Scrobbling; +using Microsoft.AspNetCore.Mvc; + +namespace allstarr.Controllers; + +public partial class JellyfinController +{ + #region Playback Session Reporting + + #region Session Management + + /// + /// Reports session capabilities. Required for Jellyfin to track active sessions. + /// Handles both POST (with body) and GET (query params only) methods. + /// + [HttpPost("Sessions/Capabilities")] + [HttpPost("Sessions/Capabilities/Full")] + [HttpGet("Sessions/Capabilities")] + [HttpGet("Sessions/Capabilities/Full")] + public async Task ReportCapabilities() + { + try + { + var method = Request.Method; + var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : ""; + + _logger.LogDebug("📡 Session capabilities reported - Method: {Method}, Query: {Query}", method, + queryString); + _logger.LogInformation("Headers: {Headers}", + string.Join(", ", Request.Headers.Where(h => + h.Key.Contains("Auth", StringComparison.OrdinalIgnoreCase) || + h.Key.Contains("Device", StringComparison.OrdinalIgnoreCase) || + h.Key.Contains("Client", StringComparison.OrdinalIgnoreCase)) + .Select(h => $"{h.Key}={h.Value}"))); + + // Forward to Jellyfin with query string and headers + var endpoint = $"Sessions/Capabilities{queryString}"; + + // Read body if present (POST requests) + string body = "{}"; + if (method == "POST" && Request.ContentLength > 0) + { + Request.EnableBuffering(); + using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8, + detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true)) + { + body = await reader.ReadToEndAsync(); + } + + Request.Body.Position = 0; + _logger.LogInformation("Capabilities body: {Body}", body); + } + + var (result, statusCode) = await _proxyService.PostJsonAsync(endpoint, body, Request.Headers); + + if (statusCode == 204 || statusCode == 200) + { + _logger.LogDebug("✓ Session capabilities forwarded to Jellyfin ({StatusCode})", statusCode); + } + else if (statusCode == 401) + { + _logger.LogWarning("⚠ Jellyfin returned 401 for capabilities (token expired)"); + } + else + { + _logger.LogWarning("⚠ Jellyfin returned {StatusCode} for capabilities", statusCode); + } + + return NoContent(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to report session capabilities"); + return StatusCode(500); + } + } + + /// + /// Reports playback start. Handles both local and external tracks. + /// For local tracks, forwards to Jellyfin. For external tracks, logs locally. + /// Also ensures session is initialized if this is the first report from a device. + /// + [HttpPost("Sessions/Playing")] + public async Task ReportPlaybackStart() + { + try + { + Request.EnableBuffering(); + string body; + using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8, + detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true)) + { + body = await reader.ReadToEndAsync(); + } + + Request.Body.Position = 0; + + _logger.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)) + { + itemId = itemIdProp.GetString(); + } + + if (doc.RootElement.TryGetProperty("ItemName", out var itemNameProp)) + { + itemName = itemNameProp.GetString(); + } + + if (doc.RootElement.TryGetProperty("PositionTicks", out var posProp)) + { + positionTicks = posProp.GetInt64(); + } + + // Track the playing item for scrobbling on session cleanup (local tracks only) + var (deviceId, client, device, version) = ExtractDeviceInfo(Request.Headers); + + // Only update session for local tracks - external tracks don't need session tracking + if (!string.IsNullOrEmpty(deviceId) && !string.IsNullOrEmpty(itemId)) + { + var (isExt, _, _) = _localLibraryService.ParseSongId(itemId); + if (!isExt) + { + _sessionManager.UpdatePlayingItem(deviceId, itemId, positionTicks); + } + } + + if (!string.IsNullOrEmpty(itemId)) + { + var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId); + + if (isExternal) + { + // Fetch metadata early so we can log the correct track name + var song = await _metadataService.GetSongAsync(provider!, externalId!); + var trackName = song != null ? $"{song.Artist} - {song.Title}" : "Unknown"; + + _logger.LogInformation("🎵 External track playback started: {TrackName} ({Provider}/{ExternalId})", + trackName, provider, externalId); + + // Proactively fetch lyrics in background for external tracks + _ = Task.Run(async () => + { + try + { + await PrefetchLyricsForTrackAsync(itemId, isExternal: true, provider, externalId); + } + catch (Exception ex) + { + _logger.LogError(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); + } + + // Scrobble external track playback start + _logger.LogInformation( + "🎵 Checking scrobbling: orchestrator={HasOrchestrator}, helper={HasHelper}, deviceId={DeviceId}", + _scrobblingOrchestrator != null, _scrobblingHelper != null, deviceId ?? "null"); + + if (_scrobblingOrchestrator != null && _scrobblingHelper != null && + !string.IsNullOrEmpty(deviceId) && song != null) + { + _logger.LogInformation("🎵 Starting scrobble task for external track"); + _ = Task.Run(async () => + { + try + { + var track = _scrobblingHelper.CreateScrobbleTrackFromExternal( + title: song.Title, + artist: song.Artist, + album: song.Album, + albumArtist: song.AlbumArtist, + durationSeconds: song.Duration + ); + + if (track != null) + { + await _scrobblingOrchestrator.OnPlaybackStartAsync(deviceId, track); + } + else + { + _logger.LogWarning("Failed to create scrobble track from metadata"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to scrobble external track playback start"); + } + }); + } + + return NoContent(); + } + + // Proactively fetch lyrics in background for local tracks + _ = Task.Run(async () => + { + try + { + await PrefetchLyricsForTrackAsync(itemId, isExternal: false, null, null); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to prefetch lyrics for local track {ItemId}", itemId); + } + }); + } + + // For local tracks, forward playback start to Jellyfin FIRST + _logger.LogDebug("Forwarding playback start to Jellyfin..."); + + // Fetch full item details to include in playback report + try + { + var (itemResult, itemStatus) = + await _proxyService.GetJsonAsync($"Items/{itemId}", null, Request.Headers); + if (itemResult != null && itemStatus == 200) + { + var item = itemResult.RootElement; + + // Extract track name from item details for logging + string? trackName = null; + if (item.TryGetProperty("Name", out var nameElement)) + { + trackName = nameElement.GetString(); + } + + _logger.LogInformation("🎵 Local track playback started: {Name} (ID: {ItemId})", + trackName ?? "Unknown", itemId); + + // Build playback start info - Jellyfin will fetch item details itself + var playbackStart = new + { + ItemId = itemId, + PositionTicks = positionTicks ?? 0, + // Let Jellyfin fetch the item details - don't include NowPlayingItem + }; + + var playbackJson = JsonSerializer.Serialize(playbackStart); + _logger.LogInformation("📤 Sending playback start: {Json}", playbackJson); + + var (result, statusCode) = + await _proxyService.PostJsonAsync("Sessions/Playing", playbackJson, Request.Headers); + + if (statusCode == 204 || statusCode == 200) + { + _logger.LogDebug("✓ Playback start forwarded to Jellyfin ({StatusCode})", statusCode); + + // Scrobble local track playback start (only if enabled) + if (_scrobblingSettings.LocalTracksEnabled && _scrobblingOrchestrator != null && + _scrobblingHelper != null && !string.IsNullOrEmpty(deviceId) && + !string.IsNullOrEmpty(itemId)) + { + _ = Task.Run(async () => + { + try + { + var track = await _scrobblingHelper.GetScrobbleTrackFromItemIdAsync(itemId, + Request.Headers); + if (track != null) + { + await _scrobblingOrchestrator.OnPlaybackStartAsync(deviceId, track); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to scrobble local track playback start"); + } + }); + } + + // NOW ensure session exists with capabilities (after playback is reported) + if (!string.IsNullOrEmpty(deviceId)) + { + var sessionCreated = await _sessionManager.EnsureSessionAsync(deviceId, client ?? "Unknown", + device ?? "Unknown", version ?? "1.0", Request.Headers); + if (sessionCreated) + { + _logger.LogDebug( + "✓ SESSION: Session ensured for device {DeviceId} after playback start", deviceId); + } + else + { + _logger.LogError("⚠️ SESSION: Failed to ensure session for device {DeviceId}", + deviceId); + } + } + else + { + _logger.LogWarning("⚠️ SESSION: No device ID found in headers for playback start"); + } + } + else + { + _logger.LogWarning("⚠️ Playback start returned status {StatusCode}", statusCode); + } + } + else + { + _logger.LogWarning("⚠️ Could not fetch item details ({StatusCode}), sending basic playback start", + itemStatus); + // Fall back to basic playback start + var (result, statusCode) = + await _proxyService.PostJsonAsync("Sessions/Playing", body, Request.Headers); + if (statusCode == 204 || statusCode == 200) + { + _logger.LogDebug("✓ Basic playback start forwarded to Jellyfin ({StatusCode})", statusCode); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send playback start, trying basic"); + // Fall back to basic playback start + var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", body, Request.Headers); + if (statusCode == 204 || statusCode == 200) + { + _logger.LogInformation("✓ Basic playback start forwarded to Jellyfin ({StatusCode})", statusCode); + } + } + + return NoContent(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to report playback start"); + return NoContent(); // Return success anyway to not break playback + } + } + + /// + /// Reports playback progress. Handles both local and external tracks. + /// + [HttpPost("Sessions/Playing/Progress")] + public async Task ReportPlaybackProgress() + { + try + { + Request.EnableBuffering(); + string body; + using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8, + detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true)) + { + body = await reader.ReadToEndAsync(); + } + + Request.Body.Position = 0; + + // Update session activity (local tracks only) + var (deviceId, _, _, _) = ExtractDeviceInfo(Request.Headers); + + // Parse the body to check if it's an external track + var doc = JsonDocument.Parse(body); + string? itemId = null; + long? positionTicks = null; + + if (doc.RootElement.TryGetProperty("ItemId", out var itemIdProp)) + { + itemId = itemIdProp.GetString(); + } + + if (doc.RootElement.TryGetProperty("PositionTicks", out var posProp)) + { + positionTicks = posProp.GetInt64(); + } + + // Only update session for local tracks + if (!string.IsNullOrEmpty(deviceId) && !string.IsNullOrEmpty(itemId)) + { + var (isExt, _, _) = _localLibraryService.ParseSongId(itemId); + if (!isExt) + { + _sessionManager.UpdateActivity(deviceId); + _sessionManager.UpdatePlayingItem(deviceId, itemId, positionTicks); + } + + // Scrobble progress check (both local and external) + if (_scrobblingOrchestrator != null && _scrobblingHelper != null && positionTicks.HasValue) + { + _ = Task.Run(async () => + { + try + { + var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId); + + // Skip local tracks if local scrobbling is disabled + if (!isExternal && !_scrobblingSettings.LocalTracksEnabled) + { + return; + } + + ScrobbleTrack? track = null; + + if (isExternal) + { + // For external tracks, fetch metadata from provider + var song = await _metadataService.GetSongAsync(provider!, externalId!); + if (song != null) + { + track = _scrobblingHelper.CreateScrobbleTrackFromExternal( + title: song.Title, + artist: song.Artist, + album: song.Album, + albumArtist: song.AlbumArtist, + durationSeconds: song.Duration + ); + } + } + else + { + // For local tracks, fetch from Jellyfin + track = await _scrobblingHelper.GetScrobbleTrackFromItemIdAsync(itemId, + Request.Headers); + } + + if (track != null) + { + var positionSeconds = (int)(positionTicks.Value / TimeSpan.TicksPerSecond); + await _scrobblingOrchestrator.OnPlaybackProgressAsync(deviceId, track.Artist, + track.Title, positionSeconds); + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to scrobble playback progress"); + } + }); + } + } + + if (!string.IsNullOrEmpty(itemId)) + { + var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId); + + if (isExternal) + { + // For external tracks, report progress with ghost UUID to Jellyfin + var ghostUuid = GenerateUuidFromString(itemId); + + // Build progress report with ghost UUID + var progressReport = new + { + ItemId = ghostUuid, + PositionTicks = positionTicks ?? 0, + IsPaused = false, + IsMuted = false, + CanSeek = true, + PlayMethod = "DirectPlay" + }; + + var progressJson = JsonSerializer.Serialize(progressReport); + + // Forward to Jellyfin with ghost UUID + var (progressResult, progressStatusCode) = + await _proxyService.PostJsonAsync("Sessions/Playing/Progress", progressJson, Request.Headers); + + // Log progress occasionally for debugging (every ~30 seconds) + if (positionTicks.HasValue) + { + var position = TimeSpan.FromTicks(positionTicks.Value); + if (position.Seconds % 30 == 0 && position.Milliseconds < 500) + { + _logger.LogDebug( + "▶️ External track progress: {Position:mm\\:ss} ({Provider}/{ExternalId}) - Status: {StatusCode}", + position, provider, externalId, progressStatusCode); + } + } + + return NoContent(); + } + + // Log progress for local tracks (only every ~10 seconds to avoid spam) + if (positionTicks.HasValue) + { + var position = TimeSpan.FromTicks(positionTicks.Value); + // Only log at 10-second intervals + if (position.Seconds % 10 == 0 && position.Milliseconds < 500) + { + _logger.LogDebug("▶️ Progress: {Position:mm\\:ss} for item {ItemId}", position, itemId); + } + } + } + + // For local tracks, forward to Jellyfin + _logger.LogDebug("📤 Sending playback progress body: {Body}", body); + + var (result, statusCode) = + await _proxyService.PostJsonAsync("Sessions/Playing/Progress", body, Request.Headers); + + if (statusCode != 204 && statusCode != 200) + { + _logger.LogWarning("⚠️ Progress report returned {StatusCode} for item {ItemId}", statusCode, itemId); + } + + return NoContent(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to report playback progress"); + return NoContent(); + } + } + + /// + /// Reports playback stopped. Handles both local and external tracks. + /// + [HttpPost("Sessions/Playing/Stopped")] + public async Task ReportPlaybackStopped() + { + try + { + Request.EnableBuffering(); + string body; + using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8, + detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true)) + { + body = await reader.ReadToEndAsync(); + } + + Request.Body.Position = 0; + + _logger.LogInformation("⏹️ Playback STOPPED reported"); + _logger.LogDebug("📤 Sending playback stop body: {Body}", body); + + // 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)) + { + itemId = itemIdProp.GetString(); + } + + if (doc.RootElement.TryGetProperty("ItemName", out var itemNameProp)) + { + itemName = itemNameProp.GetString(); + } + + if (doc.RootElement.TryGetProperty("PositionTicks", out var posProp)) + { + positionTicks = posProp.GetInt64(); + } + + // 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); + + if (isExternal) + { + var position = positionTicks.HasValue + ? TimeSpan.FromTicks(positionTicks.Value).ToString(@"mm\:ss") + : "unknown"; + + // Try to get track metadata from provider if not in stop event + if (string.IsNullOrEmpty(itemName)) + { + try + { + var song = await _metadataService.GetSongAsync(provider!, externalId!); + if (song != null) + { + itemName = $"{song.Artist} - {song.Title}"; + // Update position with actual track duration if available + if (positionTicks.HasValue && song.Duration > 0) + { + var actualPosition = TimeSpan.FromTicks(positionTicks.Value); + var duration = TimeSpan.FromSeconds((double)song.Duration); + position = $"{actualPosition:mm\\:ss}/{duration:mm\\:ss}"; + } + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Could not fetch track name for external track on stop"); + } + } + + _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 externalStopInfo = new + { + ItemId = ghostUuid, + PositionTicks = positionTicks ?? 0 + }; + + var stopJson = JsonSerializer.Serialize(externalStopInfo); + _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); + } + + // Scrobble external track playback stop + if (_scrobblingOrchestrator != null && _scrobblingHelper != null && + !string.IsNullOrEmpty(deviceId) && positionTicks.HasValue) + { + _ = Task.Run(async () => + { + try + { + // Fetch full metadata from the provider for scrobbling + var song = await _metadataService.GetSongAsync(provider!, externalId!); + + if (song != null) + { + var track = _scrobblingHelper.CreateScrobbleTrackFromExternal( + title: song.Title, + artist: song.Artist, + album: song.Album, + albumArtist: song.AlbumArtist, + durationSeconds: song.Duration + ); + + if (track != null) + { + var positionSeconds = (int)(positionTicks.Value / TimeSpan.TicksPerSecond); + await _scrobblingOrchestrator.OnPlaybackStopAsync(deviceId, track.Artist, + track.Title, positionSeconds); + } + } + else + { + _logger.LogWarning( + "Could not fetch metadata for external track scrobbling on stop: {Provider}/{ExternalId}", + provider, externalId); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to scrobble external track playback stop"); + } + }); + } + + return NoContent(); + } + + // For local tracks, fetch item details to get track name + string? trackName = itemName; + if (string.IsNullOrEmpty(trackName)) + { + try + { + var (itemResult, itemStatus) = + await _proxyService.GetJsonAsync($"Items/{itemId}", null, Request.Headers); + if (itemResult != null && itemStatus == 200) + { + var item = itemResult.RootElement; + if (item.TryGetProperty("Name", out var nameElement)) + { + trackName = nameElement.GetString(); + } + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Could not fetch track name for local track on stop"); + } + } + + _logger.LogInformation("🎵 Local track playback stopped: {Name} (ID: {ItemId})", + trackName ?? "Unknown", itemId); + + // Scrobble local track playback stop (only if enabled) + if (_scrobblingSettings.LocalTracksEnabled && _scrobblingOrchestrator != null && + _scrobblingHelper != null && !string.IsNullOrEmpty(deviceId) && !string.IsNullOrEmpty(itemId) && + positionTicks.HasValue) + { + _ = Task.Run(async () => + { + try + { + var track = await _scrobblingHelper.GetScrobbleTrackFromItemIdAsync(itemId, + Request.Headers); + if (track != null) + { + var positionSeconds = (int)(positionTicks.Value / TimeSpan.TicksPerSecond); + await _scrobblingOrchestrator.OnPlaybackStopAsync(deviceId, track.Artist, track.Title, + positionSeconds); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to scrobble local track playback stop"); + } + }); + } + } + + // For local tracks, forward to Jellyfin + _logger.LogDebug("Forwarding playback stop to Jellyfin..."); + + // Log the body being sent for debugging + _logger.LogDebug("📤 Original playback stop body: {Body}", body); + + // Parse and fix the body - ensure IsPaused is false for a proper stop + var stopDoc = JsonDocument.Parse(body); + var stopInfo = new Dictionary(); + + foreach (var prop in stopDoc.RootElement.EnumerateObject()) + { + if (prop.Name == "IsPaused") + { + // Force IsPaused to false for a proper stop + stopInfo[prop.Name] = false; + } + else if (prop.Value.ValueKind == JsonValueKind.String) + { + stopInfo[prop.Name] = prop.Value.GetString(); + } + else if (prop.Value.ValueKind == JsonValueKind.Number) + { + stopInfo[prop.Name] = prop.Value.GetInt64(); + } + else if (prop.Value.ValueKind == JsonValueKind.True || prop.Value.ValueKind == JsonValueKind.False) + { + stopInfo[prop.Name] = prop.Value.GetBoolean(); + } + else + { + stopInfo[prop.Name] = prop.Value.GetRawText(); + } + } + + // Ensure required fields are present + if (!stopInfo.ContainsKey("ItemId") && !string.IsNullOrEmpty(itemId)) + { + stopInfo["ItemId"] = itemId; + } + + if (!stopInfo.ContainsKey("PositionTicks") && positionTicks.HasValue) + { + stopInfo["PositionTicks"] = positionTicks.Value; + } + + body = JsonSerializer.Serialize(stopInfo); + _logger.LogInformation("📤 Sending playback stop body (IsPaused=false): {Body}", body); + + var (result, statusCode) = + await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", body, Request.Headers); + + if (statusCode == 204 || statusCode == 200) + { + _logger.LogDebug("✓ Playback stop forwarded to Jellyfin ({StatusCode})", statusCode); + } + else if (statusCode == 401) + { + _logger.LogWarning("Playback stop returned 401 (token expired)"); + } + else + { + _logger.LogWarning("Playback stop forward failed with status {StatusCode}", statusCode); + } + + return NoContent(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to report playback stopped"); + return NoContent(); + } + } + + /// + /// Pings a playback session to keep it alive. + /// + [HttpPost("Sessions/Playing/Ping")] + public async Task PingPlaybackSession([FromQuery] string playSessionId) + { + try + { + _logger.LogDebug("Playback session ping: {SessionId}", playSessionId); + + // Forward to Jellyfin + var endpoint = $"Sessions/Playing/Ping?playSessionId={Uri.EscapeDataString(playSessionId)}"; + var (result, statusCode) = await _proxyService.PostJsonAsync(endpoint, "{}", Request.Headers); + return NoContent(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to ping playback session"); + return NoContent(); + } + } + + /// + /// Catch-all for any other session-related requests. + /// + /// Catch-all proxy for any other session-related endpoints we haven't explicitly implemented. + /// This ensures all session management calls get proxied to Jellyfin. + /// Examples: GET /Sessions, POST /Sessions/Logout, etc. + /// + [HttpGet("Sessions")] + [HttpPost("Sessions")] + [HttpGet("Sessions/{**path}")] + [HttpPost("Sessions/{**path}")] + [HttpPut("Sessions/{**path}")] + [HttpDelete("Sessions/{**path}")] + public async Task ProxySessionRequest(string? path = null) + { + try + { + var method = Request.Method; + var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : ""; + var endpoint = string.IsNullOrEmpty(path) ? $"Sessions{queryString}" : $"Sessions/{path}{queryString}"; + + _logger.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}"))); + + // Read body if present + string body = "{}"; + if ((method == "POST" || method == "PUT") && Request.ContentLength > 0) + { + Request.EnableBuffering(); + using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8, + detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true)) + { + body = await reader.ReadToEndAsync(); + } + + Request.Body.Position = 0; + _logger.LogDebug("Session proxy body: {Body}", body); + } + + // Forward to Jellyfin + var (result, statusCode) = method switch + { + "GET" => await _proxyService.GetJsonAsync(endpoint, null, Request.Headers), + "POST" => await _proxyService.PostJsonAsync(endpoint, body, Request.Headers), + "PUT" => await _proxyService.PostJsonAsync(endpoint, body, Request.Headers), // Use POST for PUT + "DELETE" => await _proxyService.PostJsonAsync(endpoint, body, Request.Headers), // Use POST for DELETE + _ => (null, 405) + }; + + if (result != null) + { + _logger.LogDebug("✓ Session request proxied successfully ({StatusCode})", statusCode); + return new JsonResult(result.RootElement.Clone()); + } + + _logger.LogDebug("✓ Session request proxied ({StatusCode}, no body)", statusCode); + return StatusCode(statusCode); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to proxy session request: {Path}", path); + return StatusCode(500); + } + } + + #endregion // Session Management + + #endregion // Playback Session Reporting +} \ No newline at end of file diff --git a/allstarr/Controllers/JellyfinController.PlaylistHandler.cs b/allstarr/Controllers/JellyfinController.PlaylistHandler.cs new file mode 100644 index 0000000..5a32429 --- /dev/null +++ b/allstarr/Controllers/JellyfinController.PlaylistHandler.cs @@ -0,0 +1,180 @@ +using allstarr.Services.Common; +using Microsoft.AspNetCore.Mvc; + +namespace allstarr.Controllers; + +public partial class JellyfinController +{ + #region Playlists + + /// + /// Gets playlist tracks displayed as an album. + /// + private async Task GetPlaylistAsAlbum(string playlistId) + { + try + { + var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(playlistId); + + var playlist = await _metadataService.GetPlaylistAsync(provider, externalId); + if (playlist == null) + { + return _responseBuilder.CreateError(404, "Playlist not found"); + } + + var tracks = await _metadataService.GetPlaylistTracksAsync(provider, externalId); + + // Cache tracks for playlist sync + if (_playlistSyncService != null) + { + foreach (var track in tracks) + { + if (!string.IsNullOrEmpty(track.ExternalId)) + { + var trackId = $"ext-{provider}-{track.ExternalId}"; + _playlistSyncService.AddTrackToPlaylistCache(trackId, playlistId); + } + } + + _logger.LogDebug("Cached {Count} tracks for playlist {PlaylistId}", tracks.Count, playlistId); + } + + return _responseBuilder.CreatePlaylistAsAlbumResponse(playlist, tracks); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting playlist {PlaylistId}", playlistId); + return _responseBuilder.CreateError(500, "Failed to get playlist"); + } + } + + /// + /// Gets playlist tracks as child items. + /// + private async Task GetPlaylistTracks(string playlistId) + { + try + { + _logger.LogDebug("=== GetPlaylistTracks called === PlaylistId: {PlaylistId}", playlistId); + + // Check if this is an external playlist (Deezer/Qobuz) first + if (PlaylistIdHelper.IsExternalPlaylist(playlistId)) + { + var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(playlistId); + var tracks = await _metadataService.GetPlaylistTracksAsync(provider, externalId); + + // Convert tracks to Jellyfin items and override ParentId/AlbumId to be the playlist + var items = tracks.Select(track => + { + var item = _responseBuilder.ConvertSongToJellyfinItem(track); + // Override ParentId and AlbumId to be the playlist ID + // This makes all tracks appear to be from the same "album" (the playlist) + item["ParentId"] = playlistId; + item["AlbumId"] = playlistId; + item["AlbumPrimaryImageTag"] = playlistId; + item["ParentLogoItemId"] = playlistId; + item["ParentLogoImageTag"] = playlistId; + item["ParentBackdropItemId"] = playlistId; + return item; + }).ToList(); + + return new JsonResult(new + { + Items = items, + TotalRecordCount = items.Count, + StartIndex = 0 + }); + } + + // Check if this is a Spotify playlist (by ID) + _logger.LogInformation("Spotify Import Enabled: {Enabled}, Configured Playlists: {Count}", + _spotifySettings.Enabled, _spotifySettings.Playlists.Count); + + if (_spotifySettings.Enabled && _spotifySettings.IsSpotifyPlaylist(playlistId)) + { + // Get playlist info from Jellyfin to get the name for matching missing tracks + _logger.LogInformation("Fetching playlist info from Jellyfin for ID: {PlaylistId}", playlistId); + var (playlistInfo, _) = await _proxyService.GetJsonAsync($"Items/{playlistId}", null, Request.Headers); + + if (playlistInfo != null && playlistInfo.RootElement.TryGetProperty("Name", out var nameElement)) + { + var playlistName = nameElement.GetString() ?? ""; + _logger.LogInformation( + "✓ MATCHED! Intercepting Spotify playlist: {PlaylistName} (ID: {PlaylistId})", + playlistName, playlistId); + return await GetSpotifyPlaylistTracksAsync(playlistName, playlistId); + } + else + { + _logger.LogWarning("Could not get playlist name from Jellyfin for ID: {PlaylistId}", playlistId); + } + } + + // Regular Jellyfin playlist - proxy through + var endpoint = $"Playlists/{playlistId}/Items"; + if (Request.QueryString.HasValue) + { + endpoint = $"{endpoint}{Request.QueryString.Value}"; + } + + _logger.LogDebug("Proxying to Jellyfin: {Endpoint}", endpoint); + var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers); + + return HandleProxyResponse(result, statusCode); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting playlist tracks {PlaylistId}", playlistId); + return _responseBuilder.CreateError(500, "Failed to get playlist tracks"); + } + } + + /// + /// Gets a playlist cover image. + /// + private async Task GetPlaylistImage(string playlistId) + { + try + { + // Check cache first (1 hour TTL for playlist images since they can change) + var cacheKey = $"playlist:image:{playlistId}"; + var cachedImage = await _cache.GetAsync(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); + + if (playlist == null || string.IsNullOrEmpty(playlist.CoverUrl)) + { + return NotFound(); + } + + var response = await _proxyService.HttpClient.GetAsync(playlist.CoverUrl); + if (!response.IsSuccessStatusCode) + { + return NotFound(); + } + + var imageBytes = await response.Content.ReadAsByteArrayAsync(); + var contentType = response.Content.Headers.ContentType?.ToString() ?? "image/jpeg"; + + // Cache for configurable duration (playlists can change) + await _cache.SetAsync(cacheKey, imageBytes, CacheExtensions.PlaylistImagesTTL); + _logger.LogDebug("Cached playlist image for {PlaylistId}", playlistId); + + return File(imageBytes, contentType); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get playlist image {PlaylistId}", playlistId); + return NotFound(); + } + } + + #endregion +} \ No newline at end of file diff --git a/allstarr/Controllers/JellyfinController.Search.cs b/allstarr/Controllers/JellyfinController.Search.cs new file mode 100644 index 0000000..4556f5e --- /dev/null +++ b/allstarr/Controllers/JellyfinController.Search.cs @@ -0,0 +1,568 @@ +using System.Text.Json; +using allstarr.Models.Subsonic; +using allstarr.Services.Common; +using Microsoft.AspNetCore.Mvc; + +namespace allstarr.Controllers; + +public partial class JellyfinController +{ + #region Search + + /// + /// Searches local Jellyfin library and external providers. + /// Combines songs/albums/artists. Works with /Items and /Users/{userId}/Items. + /// + [HttpGet("Items", Order = 1)] + [HttpGet("Users/{userId}/Items", Order = 1)] + public async Task SearchItems( + [FromQuery] string? searchTerm, + [FromQuery] string? includeItemTypes, + [FromQuery] int limit = 20, + [FromQuery] int startIndex = 0, + [FromQuery] string? parentId = null, + [FromQuery] string? artistIds = null, + [FromQuery] string? albumArtistIds = null, + [FromQuery] string? albumIds = null, + [FromQuery] string? sortBy = null, + [FromQuery] bool recursive = true, + string? userId = null) + { + // AlbumArtistIds takes precedence over ArtistIds if both are provided + var effectiveArtistIds = albumArtistIds ?? artistIds; + + _logger.LogDebug( + "=== SEARCHITEMS V2 CALLED === searchTerm={SearchTerm}, includeItemTypes={ItemTypes}, parentId={ParentId}, artistIds={ArtistIds}, albumArtistIds={AlbumArtistIds}, albumIds={AlbumIds}, userId={UserId}", + searchTerm, includeItemTypes, parentId, artistIds, albumArtistIds, albumIds, userId); + + // ============================================================================ + // REQUEST ROUTING LOGIC (Priority Order) + // ============================================================================ + // 1. ArtistIds present (external) → Handle external artists (even with ParentId) + // 2. AlbumIds present (external) → Handle external albums (even with ParentId) + // 3. ParentId present → GetChildItems (handles external playlists/albums/artists OR proxies library items) + // 4. ArtistIds present (library) → Proxy to Jellyfin with artist filter + // 5. SearchTerm present → Integrated search (Jellyfin + external sources) + // 6. Otherwise → Proxy browse request transparently to Jellyfin + // ============================================================================ + + // PRIORITY 1: External artist filter - takes precedence over everything (including ParentId) + if (!string.IsNullOrWhiteSpace(effectiveArtistIds)) + { + var artistId = effectiveArtistIds.Split(',')[0]; // Take first artist if multiple + var (isExternal, provider, type, externalId) = _localLibraryService.ParseExternalId(artistId); + + if (isExternal) + { + // Check if this is a curator ID (format: ext-{provider}-curator-{name}) + if (artistId.Contains("-curator-", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogInformation("Fetching playlists for curator: {ArtistId}", artistId); + return await GetCuratorPlaylists(provider!, externalId!, includeItemTypes); + } + + _logger.LogInformation("Fetching content for external artist: {Provider}/{ExternalId}, type={Type}, parentId={ParentId}", + provider, externalId, type, parentId); + return await GetExternalChildItems(provider!, type!, externalId!, includeItemTypes); + } + // If library artist, fall through to handle with ParentId or proxy + } + + // PRIORITY 2: External album filter + if (!string.IsNullOrWhiteSpace(albumIds)) + { + var albumId = albumIds.Split(',')[0]; // Take first album if multiple + var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(albumId); + + if (isExternal) + { + _logger.LogInformation("Fetching songs for external album: {Provider}/{ExternalId}", provider, + externalId); + + var album = await _metadataService.GetAlbumAsync(provider!, externalId!); + if (album == null) + { + return new JsonResult(new + { Items = Array.Empty(), TotalRecordCount = 0, StartIndex = startIndex }); + } + + var albumItems = album.Songs.Select(song => _responseBuilder.ConvertSongToJellyfinItem(song)).ToList(); + + return new JsonResult(new + { + Items = albumItems, + TotalRecordCount = albumItems.Count, + StartIndex = startIndex + }); + } + // If library album, fall through to handle with ParentId or proxy + } + + // PRIORITY 3: ParentId present - handles both external and library items + if (!string.IsNullOrWhiteSpace(parentId)) + { + // Check if this is the music library root with a search term - if so, do integrated search + var isMusicLibrary = parentId == _settings.LibraryId; + + if (isMusicLibrary && !string.IsNullOrWhiteSpace(searchTerm)) + { + _logger.LogInformation("Searching within music library {ParentId}, including external sources", + parentId); + // Fall through to integrated search below + } + else + { + // Browse parent item (external playlist/album/artist OR library item) + _logger.LogDebug("Browsing parent: {ParentId}", parentId); + return await GetChildItems(parentId, includeItemTypes, limit, startIndex, sortBy); + } + } + + // PRIORITY 4: Library artist filter (already checked for external above) + if (!string.IsNullOrWhiteSpace(effectiveArtistIds)) + { + // Library artist - proxy transparently with full query string + _logger.LogDebug("Library artist filter requested, proxying to Jellyfin"); + var endpoint = userId != null + ? $"Users/{userId}/Items{Request.QueryString}" + : $"Items{Request.QueryString}"; + var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers); + return HandleProxyResponse(result, statusCode); + } + + // PRIORITY 5: Search term present - do integrated search (Jellyfin + external) + if (!string.IsNullOrWhiteSpace(searchTerm)) + { + // Check cache for search results (only cache pure searches, not filtered searches) + if (string.IsNullOrWhiteSpace(effectiveArtistIds) && string.IsNullOrWhiteSpace(albumIds)) + { + var cacheKey = CacheKeyBuilder.BuildSearchKey(searchTerm, includeItemTypes, limit, startIndex); + var cachedResult = await _cache.GetAsync(cacheKey); + + if (cachedResult != null) + { + _logger.LogDebug("✅ Returning cached search results for '{SearchTerm}'", searchTerm); + return new JsonResult(cachedResult); + } + } + + // Fall through to integrated search below + } + // PRIORITY 6: No filters, no search - proxy browse request transparently + else + { + _logger.LogDebug("Browse request with no filters, proxying to Jellyfin with full query string"); + + var endpoint = userId != null ? $"Users/{userId}/Items" : "Items"; + + // Ensure MediaSources is included in Fields parameter for bitrate info + var queryString = Request.QueryString.Value ?? ""; + + if (!string.IsNullOrEmpty(queryString)) + { + // Parse query string to modify Fields parameter + var queryParams = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString); + + if (queryParams.ContainsKey("Fields")) + { + var fieldsValue = queryParams["Fields"].ToString(); + if (!fieldsValue.Contains("MediaSources", StringComparison.OrdinalIgnoreCase)) + { + // Append MediaSources to existing Fields + var newFields = string.IsNullOrEmpty(fieldsValue) + ? "MediaSources" + : $"{fieldsValue},MediaSources"; + + // Rebuild query string with updated Fields + var newQueryParams = new Dictionary(); + foreach (var kvp in queryParams) + { + if (kvp.Key == "Fields") + { + newQueryParams[kvp.Key] = newFields; + } + else + { + newQueryParams[kvp.Key] = kvp.Value.ToString(); + } + } + + queryString = "?" + string.Join("&", newQueryParams.Select(kvp => + $"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value)}")); + } + } + else + { + // No Fields parameter, add it + queryString = $"{queryString}&Fields=MediaSources"; + } + } + else + { + // No query string at all + queryString = "?Fields=MediaSources"; + } + + endpoint = $"{endpoint}{queryString}"; + + var (browseResult, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers); + + if (browseResult == null) + { + if (statusCode == 401) + { + _logger.LogInformation("Jellyfin returned 401 Unauthorized, returning 401 to client"); + return Unauthorized(new { error = "Authentication required" }); + } + + _logger.LogDebug("Jellyfin returned {StatusCode}, returning empty result", statusCode); + return new JsonResult(new + { Items = Array.Empty(), TotalRecordCount = 0, StartIndex = startIndex }); + } + + // Update Spotify playlist counts if enabled and response contains playlists + if (_spotifySettings.Enabled && browseResult.RootElement.TryGetProperty("Items", out var _)) + { + _logger.LogDebug("Browse result has Items, checking for Spotify playlists to update counts"); + browseResult = await UpdateSpotifyPlaylistCounts(browseResult); + } + + var result = JsonSerializer.Deserialize(browseResult.RootElement.GetRawText()); + if (_logger.IsEnabled(LogLevel.Debug)) + { + var rawText = browseResult.RootElement.GetRawText(); + var preview = rawText.Length > 200 ? rawText[..200] : rawText; + _logger.LogDebug("Jellyfin browse result preview: {Result}", preview); + } + + return new JsonResult(result); + } + + // ============================================================================ + // INTEGRATED SEARCH: Search both Jellyfin library and external sources + // ============================================================================ + + var cleanQuery = searchTerm?.Trim().Trim('"') ?? ""; + _logger.LogDebug("Performing integrated search for: {Query}", cleanQuery); + + // Run local and external searches in parallel + var itemTypes = ParseItemTypes(includeItemTypes); + var jellyfinTask = _proxyService.SearchAsync(cleanQuery, itemTypes, limit, recursive, Request.Headers); + + // Use parallel metadata service if available (races providers), otherwise use primary + var externalTask = _parallelMetadataService != null + ? _parallelMetadataService.SearchAllAsync(cleanQuery, limit, limit, limit) + : _metadataService.SearchAllAsync(cleanQuery, limit, limit, limit); + + var playlistTask = _settings.EnableExternalPlaylists + ? _metadataService.SearchPlaylistsAsync(cleanQuery, limit) + : Task.FromResult(new List()); + + _logger.LogDebug("Playlist search enabled: {Enabled}, searching for: '{Query}'", + _settings.EnableExternalPlaylists, cleanQuery); + + await Task.WhenAll(jellyfinTask, externalTask, playlistTask); + + var (jellyfinResult, _) = await jellyfinTask; + var externalResult = await externalTask; + var playlistResult = await playlistTask; + + _logger.LogInformation( + "Search results for '{Query}': Jellyfin={JellyfinCount}, External Songs={ExtSongs}, Albums={ExtAlbums}, Artists={ExtArtists}, Playlists={Playlists}", + cleanQuery, + jellyfinResult != null ? "found" : "null", + externalResult.Songs.Count, + externalResult.Albums.Count, + externalResult.Artists.Count, + playlistResult.Count); + + // Parse Jellyfin results into domain models + var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult); + + // Sort all results by match score (local tracks get +10 boost) + // This ensures best matches appear first regardless of source + var allSongs = localSongs.Concat(externalResult.Songs) + .Select(s => new + { Song = s, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title) + (s.IsLocal ? 10.0 : 0.0) }) + .OrderByDescending(x => x.Score) + .Select(x => x.Song) + .ToList(); + + var allAlbums = localAlbums.Concat(externalResult.Albums) + .Select(a => new + { Album = a, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title) + (a.IsLocal ? 10.0 : 0.0) }) + .OrderByDescending(x => x.Score) + .Select(x => x.Album) + .ToList(); + + var allArtists = localArtists.Concat(externalResult.Artists) + .Select(a => new + { Artist = a, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name) + (a.IsLocal ? 10.0 : 0.0) }) + .OrderByDescending(x => x.Score) + .Select(x => x.Artist) + .ToList(); + + // Log top results for debugging + if (_logger.IsEnabled(LogLevel.Debug)) + { + if (allSongs.Any()) + { + var topSong = allSongs.First(); + var topScore = FuzzyMatcher.CalculateSimilarity(cleanQuery, topSong.Title) + + (topSong.IsLocal ? 10.0 : 0.0); + _logger.LogDebug("🎵 Top song: '{Title}' (local={IsLocal}, score={Score:F2})", + topSong.Title, topSong.IsLocal, topScore); + } + + if (allAlbums.Any()) + { + var topAlbum = allAlbums.First(); + var topScore = FuzzyMatcher.CalculateSimilarity(cleanQuery, topAlbum.Title) + + (topAlbum.IsLocal ? 10.0 : 0.0); + _logger.LogDebug("💿 Top album: '{Title}' (local={IsLocal}, score={Score:F2})", + topAlbum.Title, topAlbum.IsLocal, topScore); + } + + if (allArtists.Any()) + { + var topArtist = allArtists.First(); + var topScore = FuzzyMatcher.CalculateSimilarity(cleanQuery, topArtist.Name) + + (topArtist.IsLocal ? 10.0 : 0.0); + _logger.LogDebug("🎤 Top artist: '{Name}' (local={IsLocal}, score={Score:F2})", + topArtist.Name, topArtist.IsLocal, topScore); + } + } + + // Convert to Jellyfin format + var mergedSongs = allSongs.Select(s => _responseBuilder.ConvertSongToJellyfinItem(s)).ToList(); + var mergedAlbums = allAlbums.Select(a => _responseBuilder.ConvertAlbumToJellyfinItem(a)).ToList(); + var mergedArtists = allArtists.Select(a => _responseBuilder.ConvertArtistToJellyfinItem(a)).ToList(); + + // Add playlists with scoring (albums get +10 boost over playlists) + // Playlists are mixed with albums due to Jellyfin API limitations (no dedicated playlist search) + var mergedPlaylistsWithScore = new List<(Dictionary Item, double Score)>(); + if (playlistResult.Count > 0) + { + _logger.LogInformation("Processing {Count} playlists for merging with albums", playlistResult.Count); + foreach (var playlist in playlistResult) + { + var playlistItem = _responseBuilder.ConvertPlaylistToAlbumItem(playlist); + var score = FuzzyMatcher.CalculateSimilarity(cleanQuery, playlist.Name); + mergedPlaylistsWithScore.Add((playlistItem, score)); + _logger.LogDebug("Playlist '{Name}' score: {Score:F2}", playlist.Name, score); + } + + _logger.LogInformation("Found {Count} playlists, merging with albums (albums get +10 score boost)", + playlistResult.Count); + } + else + { + _logger.LogDebug("No playlists found to merge with albums"); + } + + // Merge albums and playlists, sorted by score (albums get +10 boost) + var albumsWithScore = mergedAlbums.Select(a => + { + var title = a.TryGetValue("Name", out var nameObj) && nameObj is JsonElement nameEl + ? nameEl.GetString() ?? "" + : ""; + var score = FuzzyMatcher.CalculateSimilarity(cleanQuery, title) + 10.0; // Albums get +10 boost + return (Item: a, Score: score); + }); + + var mergedAlbumsAndPlaylists = albumsWithScore + .Concat(mergedPlaylistsWithScore) + .OrderByDescending(x => x.Score) + .Select(x => x.Item) + .ToList(); + + _logger.LogDebug( + "Merged and sorted results by score: Songs={Songs}, Albums+Playlists={AlbumsPlaylists}, Artists={Artists}", + mergedSongs.Count, mergedAlbumsAndPlaylists.Count, mergedArtists.Count); + + // Pre-fetch lyrics for top 3 songs in background (don't await) + if (_lrclibService != null && mergedSongs.Count > 0) + { + _ = Task.Run(async () => + { + try + { + var top3 = mergedSongs.Take(3).ToList(); + _logger.LogDebug("🎵 Pre-fetching lyrics for top {Count} search results", top3.Count); + + foreach (var songItem in top3) + { + if (songItem.TryGetValue("Name", out var nameObj) && nameObj is JsonElement nameEl && + songItem.TryGetValue("Artists", out var artistsObj) && + artistsObj is JsonElement artistsEl && + artistsEl.GetArrayLength() > 0) + { + var title = nameEl.GetString() ?? ""; + var artist = artistsEl[0].GetString() ?? ""; + + if (!string.IsNullOrEmpty(title) && !string.IsNullOrEmpty(artist)) + { + await _lrclibService.GetLyricsAsync(title, artist, "", 0); + } + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to pre-fetch lyrics for search results"); + } + }); + } + + // Filter by item types if specified + var items = new List>(); + + _logger.LogDebug("Filtering by item types: {ItemTypes}", + itemTypes == null ? "null" : string.Join(",", itemTypes)); + + if (itemTypes == null || itemTypes.Length == 0 || itemTypes.Contains("MusicArtist")) + { + _logger.LogDebug("Adding {Count} artists to results", mergedArtists.Count); + items.AddRange(mergedArtists); + } + + if (itemTypes == null || itemTypes.Length == 0 || itemTypes.Contains("MusicAlbum") || + itemTypes.Contains("Playlist")) + { + _logger.LogDebug("Adding {Count} albums+playlists to results", mergedAlbumsAndPlaylists.Count); + items.AddRange(mergedAlbumsAndPlaylists); + } + + if (itemTypes == null || itemTypes.Length == 0 || itemTypes.Contains("Audio")) + { + _logger.LogDebug("Adding {Count} songs to results", mergedSongs.Count); + items.AddRange(mergedSongs); + } + + // Apply pagination + var pagedItems = items.Skip(startIndex).Take(limit).ToList(); + + _logger.LogDebug("Returning {Count} items (total: {Total})", pagedItems.Count, items.Count); + + try + { + // Return with PascalCase - use ContentResult to bypass JSON serialization issues + var response = new + { + Items = pagedItems, + TotalRecordCount = items.Count, + StartIndex = startIndex + }; + + // Cache search results in Redis (15 min TTL, no file persistence) + if (!string.IsNullOrWhiteSpace(searchTerm) && string.IsNullOrWhiteSpace(effectiveArtistIds)) + { + var cacheKey = CacheKeyBuilder.BuildSearchKey(searchTerm, includeItemTypes, limit, startIndex); + await _cache.SetAsync(cacheKey, response, CacheExtensions.SearchResultsTTL); + _logger.LogDebug("💾 Cached search results for '{SearchTerm}' ({Minutes} min TTL)", searchTerm, + CacheExtensions.SearchResultsTTL.TotalMinutes); + } + + _logger.LogDebug("About to serialize response..."); + + var json = System.Text.Json.JsonSerializer.Serialize(response, new System.Text.Json.JsonSerializerOptions + { + PropertyNamingPolicy = null, + DictionaryKeyPolicy = null + }); + + if (_logger.IsEnabled(LogLevel.Debug)) + { + var preview = json.Length > 200 ? json[..200] : json; + _logger.LogDebug("JSON response preview: {Json}", preview); + } + + return Content(json, "application/json"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error serializing search response"); + throw; + } + } + + /// + /// Gets child items of a parent (tracks in album, albums for artist). + /// + private async Task GetChildItems( + string parentId, + string? includeItemTypes, + int limit, + int startIndex, + string? sortBy) + { + // Check if this is an external playlist + if (PlaylistIdHelper.IsExternalPlaylist(parentId)) + { + return await GetPlaylistTracks(parentId); + } + + var (isExternal, provider, type, externalId) = _localLibraryService.ParseExternalId(parentId); + + if (isExternal) + { + // Get external album or artist content + return await GetExternalChildItems(provider!, type!, externalId!, includeItemTypes); + } + + // For library items, proxy transparently with full query string + _logger.LogDebug("Proxying library item request to Jellyfin: ParentId={ParentId}", parentId); + + var endpoint = $"Users/{Request.RouteValues["userId"]}/Items{Request.QueryString}"; + var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers); + + return HandleProxyResponse(result, statusCode); + } + + /// + /// Quick search endpoint. Works with /Search/Hints and /Users/{userId}/Search/Hints. + /// + [HttpGet("Search/Hints", Order = 1)] + [HttpGet("Users/{userId}/Search/Hints", Order = 1)] + public async Task SearchHints( + [FromQuery] string searchTerm, + [FromQuery] int limit = 20, + [FromQuery] string? includeItemTypes = null, + string? userId = null) + { + if (string.IsNullOrWhiteSpace(searchTerm)) + { + return _responseBuilder.CreateJsonResponse(new + { + SearchHints = Array.Empty(), + TotalRecordCount = 0 + }); + } + + var cleanQuery = searchTerm.Trim().Trim('"'); + var itemTypes = ParseItemTypes(includeItemTypes); + + // Run searches in parallel + var jellyfinTask = _proxyService.SearchAsync(cleanQuery, itemTypes, limit, true, Request.Headers); + var externalTask = _metadataService.SearchAllAsync(cleanQuery, limit, limit, limit); + + await Task.WhenAll(jellyfinTask, externalTask); + + var (jellyfinResult, _) = await jellyfinTask; + var externalResult = await externalTask; + + var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult); + + // 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(); + var allArtists = localArtists.Concat(externalResult.Artists).Take(limit).ToList(); + + return _responseBuilder.CreateSearchHintsResponse( + allSongs.Take(limit).ToList(), + allAlbums.Take(limit).ToList(), + allArtists.Take(limit).ToList()); + } + + #endregion +} \ No newline at end of file diff --git a/allstarr/Controllers/JellyfinController.Spotify.cs b/allstarr/Controllers/JellyfinController.Spotify.cs new file mode 100644 index 0000000..429bb45 --- /dev/null +++ b/allstarr/Controllers/JellyfinController.Spotify.cs @@ -0,0 +1,946 @@ +using System.Text.Json; +using allstarr.Models.Domain; +using allstarr.Models.Spotify; +using allstarr.Services.Admin; +using allstarr.Services.Common; +using Microsoft.AspNetCore.Mvc; + +namespace allstarr.Controllers; + +public partial class JellyfinController +{ + #region Spotify Playlist Injection + + /// + /// Gets tracks for a Spotify playlist by matching missing tracks against external providers + /// and merging with existing local tracks from Jellyfin. + /// + /// Supports two modes: + /// 1. Direct Spotify API (new): Uses SpotifyPlaylistFetcher for ordered tracks with ISRC matching + /// 2. Jellyfin Plugin (legacy): Uses MissingTrack data from Jellyfin Spotify Import plugin + /// + private async Task GetSpotifyPlaylistTracksAsync(string spotifyPlaylistName, string playlistId) + { + try + { + // Only inject tracks if Spotify API is enabled + if (_spotifyApiSettings.Enabled && _spotifyPlaylistFetcher != null) + { + var orderedResult = await GetSpotifyPlaylistTracksOrderedAsync(spotifyPlaylistName, playlistId); + if (orderedResult != null) return orderedResult; + } + + // Spotify API not enabled or no ordered tracks - proxy through without modification + _logger.LogInformation( + "Spotify API not enabled or no tracks found, proxying playlist {PlaylistName} without modification", + spotifyPlaylistName); + + var endpoint = $"Playlists/{playlistId}/Items"; + if (Request.QueryString.HasValue) + { + endpoint = $"{endpoint}{Request.QueryString.Value}"; + } + + var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers); + return HandleProxyResponse(result, statusCode); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting Spotify playlist tracks {PlaylistName}", spotifyPlaylistName); + return _responseBuilder.CreateError(500, "Failed to get Spotify playlist tracks"); + } + } + + /// + /// New mode: Gets playlist tracks with correct ordering using direct Spotify API data. + /// Optimized to only re-match when Jellyfin playlist changes (cheap check). + /// + private async Task GetSpotifyPlaylistTracksOrderedAsync(string spotifyPlaylistName, + string playlistId) + { + // Check if Jellyfin playlist has changed (cheap API call) + var jellyfinSignatureCacheKey = $"spotify:playlist:jellyfin-signature:{spotifyPlaylistName}"; + var currentJellyfinSignature = await GetJellyfinPlaylistSignatureAsync(playlistId); + var cachedJellyfinSignature = await _cache.GetAsync(jellyfinSignatureCacheKey); + + var jellyfinPlaylistChanged = cachedJellyfinSignature != currentJellyfinSignature; + + // Check Redis cache first for fast serving (only if Jellyfin playlist hasn't changed) + var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(spotifyPlaylistName); + var cachedItems = await _cache.GetAsync>>(cacheKey); + + if (cachedItems != null && cachedItems.Count > 0 && !jellyfinPlaylistChanged) + { + _logger.LogDebug("✅ Loaded {Count} playlist items from Redis cache for {Playlist} (Jellyfin unchanged)", + cachedItems.Count, spotifyPlaylistName); + + return new JsonResult(new + { + Items = cachedItems, + TotalRecordCount = cachedItems.Count, + StartIndex = 0 + }); + } + + if (jellyfinPlaylistChanged) + { + _logger.LogInformation("🔄 Jellyfin playlist changed for {Playlist} - re-matching tracks", + spotifyPlaylistName); + } + + // Check file cache as fallback + var fileItems = await LoadPlaylistItemsFromFile(spotifyPlaylistName); + if (fileItems != null && fileItems.Count > 0) + { + _logger.LogDebug("✅ Loaded {Count} playlist items from file cache for {Playlist}", + fileItems.Count, spotifyPlaylistName); + + // Restore to Redis cache + await _cache.SetAsync(cacheKey, fileItems, CacheExtensions.SpotifyPlaylistItemsTTL); + + return new JsonResult(new + { + Items = fileItems, + TotalRecordCount = fileItems.Count, + StartIndex = 0 + }); + } + + // Check for ordered matched tracks from SpotifyTrackMatchingService + var orderedCacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(spotifyPlaylistName); + var orderedTracks = await _cache.GetAsync>(orderedCacheKey); + + if (orderedTracks == null || orderedTracks.Count == 0) + { + _logger.LogInformation("No ordered matched tracks in cache for {Playlist}, checking if we can fetch", + spotifyPlaylistName); + return null; // Fall back to legacy mode + } + + _logger.LogInformation("Using {Count} ordered matched tracks for {Playlist}", + orderedTracks.Count, spotifyPlaylistName); + + // Get existing Jellyfin playlist items (RAW - don't convert!) + // CRITICAL: Must include UserId parameter or Jellyfin returns empty results + var userId = _settings.UserId; + if (string.IsNullOrEmpty(userId)) + { + _logger.LogError( + "❌ JELLYFIN_USER_ID is NOT configured! Cannot fetch playlist tracks. Set it in .env or admin UI."); + return null; // Fall back to legacy mode + } + + // Pass through all requested fields from the original request + var queryString = Request.QueryString.Value ?? ""; + var playlistItemsUrl = $"Playlists/{playlistId}/Items?UserId={userId}"; + + // Append the original query string (which includes Fields parameter) + if (!string.IsNullOrEmpty(queryString)) + { + // Remove the leading ? if present + queryString = queryString.TrimStart('?'); + playlistItemsUrl = $"{playlistItemsUrl}&{queryString}"; + } + + _logger.LogInformation("🔍 Fetching existing tracks from Jellyfin playlist {PlaylistId} with UserId {UserId}", + playlistId, userId); + + var (existingTracksResponse, statusCode) = await _proxyService.GetJsonAsync( + playlistItemsUrl, + null, + Request.Headers); + + if (statusCode != 200) + { + _logger.LogError( + "❌ Failed to fetch Jellyfin playlist items: HTTP {StatusCode}. Check JELLYFIN_USER_ID is correct.", + statusCode); + return null; + } + + // Keep raw Jellyfin items - don't convert to Song objects! + var jellyfinItems = new List(); + var jellyfinItemsByName = new Dictionary(); + + if (existingTracksResponse != null && + existingTracksResponse.RootElement.TryGetProperty("Items", out var items)) + { + foreach (var item in items.EnumerateArray()) + { + jellyfinItems.Add(item); + + // Index by title+artist for matching + var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : ""; + var artist = ""; + if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0) + { + artist = artistsEl[0].GetString() ?? ""; + } + else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl)) + { + artist = albumArtistEl.GetString() ?? ""; + } + + var key = $"{title}|{artist}".ToLowerInvariant(); + if (!jellyfinItemsByName.ContainsKey(key)) + { + jellyfinItemsByName[key] = item; + } + } + + _logger.LogInformation("✅ Found {Count} existing LOCAL tracks in Jellyfin playlist", jellyfinItems.Count); + } + else + { + _logger.LogWarning("⚠️ No existing tracks found in Jellyfin playlist {PlaylistId} - playlist may be empty", + playlistId); + } + + // Get the full playlist from Spotify to know the correct order + var spotifyTracks = await _spotifyPlaylistFetcher!.GetPlaylistTracksAsync(spotifyPlaylistName); + if (spotifyTracks.Count == 0) + { + _logger.LogWarning("Could not get Spotify playlist tracks for {Playlist}", spotifyPlaylistName); + return null; // Fall back to legacy + } + + // Build the final track list in correct Spotify order + var finalItems = new List>(); + var usedJellyfinItems = new HashSet(); + var localUsedCount = 0; + var externalUsedCount = 0; + + _logger.LogDebug("🔍 Building playlist in Spotify order with {SpotifyCount} positions...", spotifyTracks.Count); + + foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position)) + { + // Try to find matching Jellyfin item by fuzzy matching + JsonElement? matchedJellyfinItem = null; + string? matchedKey = null; + double bestScore = 0; + + foreach (var kvp in jellyfinItemsByName) + { + if (usedJellyfinItems.Contains(kvp.Key)) continue; + + var item = kvp.Value; + var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : ""; + var artist = ""; + if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0) + { + artist = artistsEl[0].GetString() ?? ""; + } + + var titleScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.Title, title); + var artistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, artist); + var totalScore = (titleScore * 0.7) + (artistScore * 0.3); + + if (totalScore > bestScore && totalScore >= 70) + { + bestScore = totalScore; + matchedJellyfinItem = item; + matchedKey = kvp.Key; + } + } + + if (matchedJellyfinItem.HasValue && matchedKey != null) + { + // Use the raw Jellyfin item (preserves ALL metadata including MediaSources!) + var itemDict = JsonElementToDictionary(matchedJellyfinItem.Value); + finalItems.Add(itemDict); + usedJellyfinItems.Add(matchedKey); + localUsedCount++; + _logger.LogDebug("✅ Position #{Pos}: '{Title}' → LOCAL (score: {Score:F1}%)", + spotifyTrack.Position, spotifyTrack.Title, bestScore); + } + else + { + // No local match via fuzzy matching - try to find in orderedTracks cache + var matched = orderedTracks?.FirstOrDefault(t => t.SpotifyId == spotifyTrack.SpotifyId); + if (matched != null && matched.MatchedSong != null) + { + // Check if this is a LOCAL track that we should fetch from Jellyfin + if (matched.MatchedSong.IsLocal && !string.IsNullOrEmpty(matched.MatchedSong.Id)) + { + // Try to find the full Jellyfin item by ID + var jellyfinItem = jellyfinItems.FirstOrDefault(item => + item.TryGetProperty("Id", out var idProp) && + idProp.GetString() == matched.MatchedSong.Id); + + if (jellyfinItem.ValueKind != JsonValueKind.Undefined) + { + // Found the full Jellyfin item - use it! + var itemDict = JsonElementToDictionary(jellyfinItem); + finalItems.Add(itemDict); + localUsedCount++; + _logger.LogDebug("✅ Position #{Pos}: '{Title}' → LOCAL from cache (ID: {Id})", + spotifyTrack.Position, spotifyTrack.Title, matched.MatchedSong.Id); + continue; + } + else + { + _logger.LogWarning( + "⚠️ Position #{Pos}: '{Title}' marked as LOCAL but not found in Jellyfin items (ID: {Id})", + spotifyTrack.Position, spotifyTrack.Title, matched.MatchedSong.Id); + } + } + + // External track or local track not found - convert 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(); + } + + var providerIds = externalItem["ProviderIds"] as Dictionary; + if (providerIds != null && !providerIds.ContainsKey("Spotify")) + { + providerIds["Spotify"] = spotifyTrack.SpotifyId; + } + } + + finalItems.Add(externalItem); + externalUsedCount++; + _logger.LogDebug( + "📥 Position #{Pos}: '{Title}' → EXTERNAL: {Provider}/{Id} (Spotify ID: {SpotifyId})", + spotifyTrack.Position, spotifyTrack.Title, + matched.MatchedSong.ExternalProvider, matched.MatchedSong.ExternalId, spotifyTrack.SpotifyId); + } + else + { + _logger.LogDebug("❌ Position #{Pos}: '{Title}' → NO MATCH", + spotifyTrack.Position, spotifyTrack.Title); + } + } + } + + _logger.LogDebug("🎵 Final playlist '{Playlist}': {Total} tracks ({Local} LOCAL + {External} EXTERNAL)", + spotifyPlaylistName, finalItems.Count, localUsedCount, externalUsedCount); + + // Save to file cache for persistence across restarts + await SavePlaylistItemsToFile(spotifyPlaylistName, finalItems); + + // Also cache in Redis for fast serving (reuse the same cache key from top of method) + await _cache.SetAsync(cacheKey, finalItems, CacheExtensions.SpotifyPlaylistItemsTTL); + + // Cache the Jellyfin playlist signature to detect future changes + await _cache.SetAsync(jellyfinSignatureCacheKey, currentJellyfinSignature, + CacheExtensions.SpotifyPlaylistItemsTTL); + + // Return raw Jellyfin response format + return new JsonResult(new + { + Items = finalItems, + TotalRecordCount = finalItems.Count, + StartIndex = 0 + }); + } + + /// + /// + /// Copies an external track to the kept folder when favorited. + /// + private async Task CopyExternalTrackToKeptAsync(string itemId, string provider, string externalId) + { + try + { + // Check if already favorited (persistent tracking) + if (await IsTrackFavoritedAsync(itemId)) + { + _logger.LogInformation("Track already favorited (persistent): {ItemId}", itemId); + return; + } + + // Get the song metadata first to build paths + var song = await _metadataService.GetSongAsync(provider, externalId); + if (song == null) + { + _logger.LogWarning("Could not find song metadata for {ItemId}", itemId); + return; + } + + // Build kept folder path: Artist/Album/ + var keptBasePath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept"); + var keptArtistPath = Path.Combine(keptBasePath, AdminHelperService.SanitizeFileName(song.Artist)); + var keptAlbumPath = Path.Combine(keptArtistPath, AdminHelperService.SanitizeFileName(song.Album)); + + // Check if track already exists in kept folder + if (Directory.Exists(keptAlbumPath)) + { + var sanitizedTitle = AdminHelperService.SanitizeFileName(song.Title); + var existingFiles = Directory.GetFiles(keptAlbumPath, $"*{sanitizedTitle}*"); + if (existingFiles.Length > 0) + { + _logger.LogInformation("Track already exists in kept folder: {Path}", existingFiles[0]); + // Mark as favorited even if we didn't download it + await MarkTrackAsFavoritedAsync(itemId, song); + return; + } + } + + // Look for the track in cache folder first + var cacheBasePath = "/tmp/allstarr-cache"; + var cacheArtistPath = Path.Combine(cacheBasePath, AdminHelperService.SanitizeFileName(song.Artist)); + var cacheAlbumPath = Path.Combine(cacheArtistPath, AdminHelperService.SanitizeFileName(song.Album)); + + string? sourceFilePath = null; + + if (Directory.Exists(cacheAlbumPath)) + { + var sanitizedTitle = AdminHelperService.SanitizeFileName(song.Title); + var cacheFiles = Directory.GetFiles(cacheAlbumPath, $"*{sanitizedTitle}*"); + if (cacheFiles.Length > 0) + { + sourceFilePath = cacheFiles[0]; + _logger.LogDebug("Found track in cache folder: {Path}", sourceFilePath); + } + } + + // If not in cache, download it first + if (sourceFilePath == null) + { + _logger.LogInformation("Track not in cache, downloading: {ItemId}", itemId); + try + { + // Use CancellationToken.None to ensure download completes even if user navigates away + sourceFilePath = + await _downloadService.DownloadSongAsync(provider, externalId, CancellationToken.None); + } + catch (Exception ex) + { + _logger.LogError(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(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; + } + + // Create hard link instead of copying to save space + // Both locations will point to the same file data on disk + try + { + // Use ln command on Unix systems for hard links + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + { + var process = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = "ln", + Arguments = $"\"{sourceFilePath}\" \"{keptFilePath}\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }); + + if (process != null) + { + await process.WaitForExitAsync(); + _logger.LogDebug("✓ Created hard link to kept folder: {Path}", keptFilePath); + } + } + else + { + // Fall back to copy on Windows + System.IO.File.Copy(sourceFilePath, keptFilePath, overwrite: false); + _logger.LogDebug("✓ Copied track to kept folder: {Path}", keptFilePath); + } + } + catch (Exception ex) + { + // Fall back to copy if hard link fails (e.g., different filesystems) + _logger.LogWarning(ex, "Failed to create hard link, falling back to copy"); + System.IO.File.Copy(sourceFilePath, keptFilePath, overwrite: false); + _logger.LogDebug("✓ Copied track to kept folder: {Path}", keptFilePath); + } + + // Also create hard link for cover art if it exists + 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)) + { + try + { + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + { + var process = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = "ln", + Arguments = $"\"{sourceCoverPath}\" \"{keptCoverPath}\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }); + + if (process != null) + { + await process.WaitForExitAsync(); + _logger.LogDebug("Created hard link for cover art"); + } + } + else + { + System.IO.File.Copy(sourceCoverPath, keptCoverPath, overwrite: false); + _logger.LogDebug("Copied cover art to kept folder"); + } + } + catch + { + // Fall back to copy if hard link fails + 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) + { + _logger.LogError(ex, "Error copying external track {ItemId} to kept folder", itemId); + } + } + + /// + /// Copies an external album (all tracks) to the kept folder when favorited. + /// + private async Task CopyExternalAlbumToKeptAsync(string itemId, string provider, string externalId) + { + try + { + // Get the album metadata with all tracks + var album = await _metadataService.GetAlbumAsync(provider, externalId); + if (album == null) + { + _logger.LogWarning("Could not find album metadata for {ItemId}", itemId); + return; + } + + _logger.LogInformation("Downloading {Count} tracks from album: {Artist} - {Album}", + album.Songs.Count, album.Artist, album.Title); + + // Download all tracks in the album + var downloadTasks = album.Songs.Select(async song => + { + try + { + var songItemId = song.Id; // Already in format: ext-provider-song-id + var (_, songProvider, songExternalId) = _localLibraryService.ParseSongId(songItemId); + + if (songProvider != null && songExternalId != null) + { + await CopyExternalTrackToKeptAsync(songItemId, songProvider, songExternalId); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to download track: {Title}", song.Title); + } + }); + + await Task.WhenAll(downloadTasks); + + _logger.LogInformation("✓ Finished downloading album: {Artist} - {Album}", album.Artist, album.Title); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error copying external album {ItemId} to kept folder", itemId); + } + } + + /// + /// Removes an external track from the kept folder when unfavorited. + /// + 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"; + + /// + /// Checks if a track is already favorited (persistent across restarts). + /// + private async Task IsTrackFavoritedAsync(string itemId) + { + try + { + if (!System.IO.File.Exists(_favoritesFilePath)) + return false; + + var json = await System.IO.File.ReadAllTextAsync(_favoritesFilePath); + var favorites = JsonSerializer.Deserialize>(json) ?? new(); + + return favorites.ContainsKey(itemId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to check favorite status for {ItemId}", itemId); + return false; + } + } + + /// + /// Marks a track as favorited in persistent storage. + /// + private async Task MarkTrackAsFavoritedAsync(string itemId, Song song) + { + try + { + var favorites = new Dictionary(); + + if (System.IO.File.Exists(_favoritesFilePath)) + { + var json = await System.IO.File.ReadAllTextAsync(_favoritesFilePath); + favorites = JsonSerializer.Deserialize>(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.LogError(ex, "Failed to mark track as favorited: {ItemId}", itemId); + } + } + + /// + /// Removes a track from persistent favorites storage. + /// + 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>(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.LogError(ex, "Failed to remove track from favorites: {ItemId}", itemId); + } + } + + /// + /// Marks a track for deletion (delayed deletion for safety). + /// + private async Task MarkTrackForDeletionAsync(string itemId) + { + try + { + var deletionFilePath = "/app/cache/pending_deletions.json"; + var pendingDeletions = new Dictionary(); + + if (System.IO.File.Exists(deletionFilePath)) + { + var json = await System.IO.File.ReadAllTextAsync(deletionFilePath); + pendingDeletions = JsonSerializer.Deserialize>(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.LogError(ex, "Failed to mark track for deletion: {ItemId}", itemId); + } + } + + /// + /// Information about a favorited track for persistent storage. + /// + 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; } + } + + /// + /// Processes pending deletions (called by cleanup service). + /// + 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>(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.LogDebug("Processed {Count} pending deletions", toDelete.Count); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing pending deletions"); + } + } + + /// + /// Actually deletes a track from the kept folder. + /// + 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, AdminHelperService.SanitizeFileName(song.Artist)); + var keptAlbumPath = Path.Combine(keptArtistPath, AdminHelperService.SanitizeFileName(song.Album)); + + if (!Directory.Exists(keptAlbumPath)) return; + + var sanitizedTitle = AdminHelperService.SanitizeFileName(song.Title); + var trackFiles = Directory.GetFiles(keptAlbumPath, $"*{sanitizedTitle}*"); + + foreach (var trackFile in trackFiles) + { + System.IO.File.Delete(trackFile); + _logger.LogDebug("✓ 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.LogError(ex, "Failed to delete track {ItemId}", itemId); + } + } + + #endregion + + /// + /// Loads missing tracks from file cache as fallback when Redis is empty. + /// + /// Gets a signature (hash) of the Jellyfin playlist to detect changes. + /// This is a cheap operation compared to re-matching all tracks. + /// Signature includes: track count + concatenated track IDs. + /// + private async Task GetJellyfinPlaylistSignatureAsync(string playlistId) + { + try + { + var userId = _settings.UserId; + var playlistItemsUrl = $"Playlists/{playlistId}/Items?Fields=Id"; + if (!string.IsNullOrEmpty(userId)) + { + playlistItemsUrl += $"&UserId={userId}"; + } + + var (response, _) = await _proxyService.GetJsonAsync(playlistItemsUrl, null, Request.Headers); + + if (response != null && response.RootElement.TryGetProperty("Items", out var items)) + { + var trackIds = new List(); + foreach (var item in items.EnumerateArray()) + { + if (item.TryGetProperty("Id", out var idEl)) + { + trackIds.Add(idEl.GetString() ?? ""); + } + } + + // Create signature: count + sorted IDs (sorted for consistency) + trackIds.Sort(); + var signature = $"{trackIds.Count}:{string.Join(",", trackIds)}"; + + // Hash it to keep it compact + using var sha256 = System.Security.Cryptography.SHA256.Create(); + var hashBytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(signature)); + return Convert.ToHexString(hashBytes); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to get Jellyfin playlist signature for {PlaylistId}", playlistId); + } + + // Return empty string if failed (will trigger re-match) + return string.Empty; + } + + /// + /// Saves playlist items (raw Jellyfin JSON) to file cache for persistence across restarts. + /// + private async Task SavePlaylistItemsToFile(string playlistName, List> items) + { + try + { + var cacheDir = "/app/cache/spotify"; + Directory.CreateDirectory(cacheDir); + + var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars())); + var filePath = Path.Combine(cacheDir, $"{safeName}_items.json"); + + var json = JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true }); + await System.IO.File.WriteAllTextAsync(filePath, json); + + _logger.LogDebug("💾 Saved {Count} playlist items to file cache for {Playlist}", + items.Count, playlistName); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save playlist items to file for {Playlist}", playlistName); + } + } + + /// + /// Loads playlist items (raw Jellyfin JSON) from file cache. + /// + private async Task>?> LoadPlaylistItemsFromFile(string playlistName) + { + try + { + var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars())); + var filePath = Path.Combine("/app/cache/spotify", $"{safeName}_items.json"); + + if (!System.IO.File.Exists(filePath)) + { + _logger.LogDebug("No playlist items file cache found for {Playlist} at {Path}", playlistName, filePath); + return null; + } + + var fileAge = DateTime.UtcNow - System.IO.File.GetLastWriteTimeUtc(filePath); + + // Check if cache is too old (more than 24 hours) + if (fileAge.TotalHours > 24) + { + _logger.LogDebug("Playlist items file cache for {Playlist} is too old ({Age:F1}h), will rebuild", + playlistName, fileAge.TotalHours); + return null; + } + + _logger.LogDebug("Playlist items file cache for {Playlist} age: {Age:F1}h", playlistName, + fileAge.TotalHours); + + var json = await System.IO.File.ReadAllTextAsync(filePath); + + // Parse as JsonDocument first to preserve nested structures + using var doc = JsonDocument.Parse(json); + var items = new List>(); + + if (doc.RootElement.ValueKind == JsonValueKind.Array) + { + foreach (var item in doc.RootElement.EnumerateArray()) + { + items.Add(JsonElementToDictionary(item)); + } + } + + _logger.LogDebug("💿 Loaded {Count} playlist items from file cache for {Playlist} (age: {Age:F1}h)", + items.Count, playlistName, fileAge.TotalHours); + + return items; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load playlist items from file for {Playlist}", playlistName); + return null; + } + } + + #endregion +} \ No newline at end of file diff --git a/allstarr/Controllers/JellyfinController.cs b/allstarr/Controllers/JellyfinController.cs index 0cb91f2..47f2473 100644 --- a/allstarr/Controllers/JellyfinController.cs +++ b/allstarr/Controllers/JellyfinController.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Options; using System.Text.Json; using allstarr.Models.Domain; using allstarr.Models.Lyrics; +using allstarr.Models.Scrobbling; using allstarr.Models.Settings; using allstarr.Models.Subsonic; using allstarr.Models.Spotify; @@ -13,6 +14,8 @@ using allstarr.Services.Jellyfin; using allstarr.Services.Subsonic; using allstarr.Services.Lyrics; using allstarr.Services.Spotify; +using allstarr.Services.Scrobbling; +using allstarr.Services.Admin; using allstarr.Filters; namespace allstarr.Controllers; @@ -23,11 +26,12 @@ namespace allstarr.Controllers; /// [ApiController] [Route("")] -public class JellyfinController : ControllerBase +public partial class JellyfinController : ControllerBase { private readonly JellyfinSettings _settings; private readonly SpotifyImportSettings _spotifySettings; private readonly SpotifyApiSettings _spotifyApiSettings; + private readonly ScrobblingSettings _scrobblingSettings; private readonly IMusicMetadataService _metadataService; private readonly ParallelMetadataService? _parallelMetadataService; private readonly ILocalLibraryService _localLibraryService; @@ -41,7 +45,14 @@ public class JellyfinController : ControllerBase private readonly SpotifyLyricsService? _spotifyLyricsService; private readonly LyricsPlusService? _lyricsPlusService; private readonly LrclibService? _lrclibService; +<<<<<<< HEAD private readonly LyricsOrchestrator? _lyricsOrchestrator; +||||||| f68706f +======= + private readonly LyricsOrchestrator? _lyricsOrchestrator; + private readonly ScrobblingOrchestrator? _scrobblingOrchestrator; + private readonly ScrobblingHelper? _scrobblingHelper; +>>>>>>> beta private readonly OdesliService _odesliService; private readonly RedisCacheService _cache; private readonly IConfiguration _configuration; @@ -51,6 +62,7 @@ public class JellyfinController : ControllerBase IOptions settings, IOptions spotifySettings, IOptions spotifyApiSettings, + IOptions scrobblingSettings, IMusicMetadataService metadataService, ILocalLibraryService localLibraryService, IDownloadService downloadService, @@ -66,13 +78,24 @@ public class JellyfinController : ControllerBase PlaylistSyncService? playlistSyncService = null, SpotifyPlaylistFetcher? spotifyPlaylistFetcher = null, SpotifyLyricsService? spotifyLyricsService = null, +<<<<<<< HEAD LyricsPlusService? lyricsPlusService = null, LrclibService? lrclibService = null, LyricsOrchestrator? lyricsOrchestrator = null) +||||||| f68706f + LrclibService? lrclibService = null) +======= + LyricsPlusService? lyricsPlusService = null, + LrclibService? lrclibService = null, + LyricsOrchestrator? lyricsOrchestrator = null, + ScrobblingOrchestrator? scrobblingOrchestrator = null, + ScrobblingHelper? scrobblingHelper = null) +>>>>>>> beta { _settings = settings.Value; _spotifySettings = spotifySettings.Value; _spotifyApiSettings = spotifyApiSettings.Value; + _scrobblingSettings = scrobblingSettings.Value; _metadataService = metadataService; _parallelMetadataService = parallelMetadataService; _localLibraryService = localLibraryService; @@ -86,7 +109,14 @@ public class JellyfinController : ControllerBase _spotifyLyricsService = spotifyLyricsService; _lyricsPlusService = lyricsPlusService; _lrclibService = lrclibService; +<<<<<<< HEAD _lyricsOrchestrator = lyricsOrchestrator; +||||||| f68706f +======= + _lyricsOrchestrator = lyricsOrchestrator; + _scrobblingOrchestrator = scrobblingOrchestrator; + _scrobblingHelper = scrobblingHelper; +>>>>>>> beta _odesliService = odesliService; _cache = cache; _configuration = configuration; @@ -98,6 +128,7 @@ public class JellyfinController : ControllerBase } } +<<<<<<< HEAD #region Search /// @@ -532,6 +563,446 @@ public class JellyfinController : ControllerBase #endregion +||||||| f68706f + #region Search + + /// + /// Searches local Jellyfin library and external providers. + /// Combines songs/albums/artists. Works with /Items and /Users/{userId}/Items. + /// + [HttpGet("Items", Order = 1)] + [HttpGet("Users/{userId}/Items", Order = 1)] + public async Task SearchItems( + [FromQuery] string? searchTerm, + [FromQuery] string? includeItemTypes, + [FromQuery] int limit = 20, + [FromQuery] int startIndex = 0, + [FromQuery] string? parentId = null, + [FromQuery] string? artistIds = null, + [FromQuery] string? sortBy = null, + [FromQuery] bool recursive = true, + string? userId = null) + { + _logger.LogInformation("=== SEARCHITEMS V2 CALLED === searchTerm={SearchTerm}, includeItemTypes={ItemTypes}, parentId={ParentId}, artistIds={ArtistIds}, userId={UserId}", + searchTerm, includeItemTypes, parentId, artistIds, userId); + + // Cache search results in Redis only (no file persistence, 15 min TTL) + // Only cache actual searches, not browse operations + if (!string.IsNullOrWhiteSpace(searchTerm) && string.IsNullOrWhiteSpace(artistIds)) + { + var cacheKey = $"search:{searchTerm?.ToLowerInvariant()}:{includeItemTypes}:{limit}:{startIndex}"; + var cachedResult = await _cache.GetAsync(cacheKey); + + if (cachedResult != null) + { + _logger.LogDebug("✅ Returning cached search results for '{SearchTerm}'", searchTerm); + return new JsonResult(cachedResult); + } + } + + // If filtering by artist, handle external artists + if (!string.IsNullOrWhiteSpace(artistIds)) + { + var artistId = artistIds.Split(',')[0]; // Take first artist if multiple + var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(artistId); + + if (isExternal) + { + _logger.LogInformation("Fetching albums for external artist: {Provider}/{ExternalId}", provider, externalId); + return await GetExternalChildItems(provider!, externalId!, includeItemTypes); + } + } + + // If no search term, proxy to Jellyfin for browsing + // If Jellyfin returns empty results, we'll just return empty (not mixing browse with external) + if (string.IsNullOrWhiteSpace(searchTerm) && string.IsNullOrWhiteSpace(parentId)) + { + _logger.LogDebug("No search term or parentId, proxying to Jellyfin with full query string"); + + // Build the full endpoint path with query string + var endpoint = userId != null ? $"Users/{userId}/Items" : "Items"; + + // Ensure MediaSources is included in Fields parameter for bitrate info + var queryString = Request.QueryString.Value ?? ""; + + if (!string.IsNullOrEmpty(queryString)) + { + // Parse query string to modify Fields parameter + var queryParams = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString); + + if (queryParams.ContainsKey("Fields")) + { + var fieldsValue = queryParams["Fields"].ToString(); + if (!fieldsValue.Contains("MediaSources", StringComparison.OrdinalIgnoreCase)) + { + // Append MediaSources to existing Fields + var newFields = string.IsNullOrEmpty(fieldsValue) + ? "MediaSources" + : $"{fieldsValue},MediaSources"; + + // Rebuild query string with updated Fields + var newQueryParams = new Dictionary(); + foreach (var kvp in queryParams) + { + if (kvp.Key == "Fields") + { + newQueryParams[kvp.Key] = newFields; + } + else + { + newQueryParams[kvp.Key] = kvp.Value.ToString(); + } + } + + queryString = "?" + string.Join("&", newQueryParams.Select(kvp => + $"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value)}")); + } + } + else + { + // No Fields parameter, add it + queryString = $"{queryString}&Fields=MediaSources"; + } + } + else + { + // No query string at all + queryString = "?Fields=MediaSources"; + } + + endpoint = $"{endpoint}{queryString}"; + + var (browseResult, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers); + + if (browseResult == null) + { + if (statusCode == 401) + { + _logger.LogInformation("Jellyfin returned 401 Unauthorized, returning 401 to client"); + return Unauthorized(new { error = "Authentication required" }); + } + + _logger.LogInformation("Jellyfin returned {StatusCode}, returning empty result", statusCode); + return new JsonResult(new { Items = Array.Empty(), TotalRecordCount = 0, StartIndex = startIndex }); + } + + // Update Spotify playlist counts if enabled and response contains playlists + if (_spotifySettings.Enabled && browseResult.RootElement.TryGetProperty("Items", out var _)) + { + _logger.LogInformation("Browse result has Items, checking for Spotify playlists to update counts"); + browseResult = await UpdateSpotifyPlaylistCounts(browseResult); + } + + var result = JsonSerializer.Deserialize(browseResult.RootElement.GetRawText()); + if (_logger.IsEnabled(LogLevel.Debug)) + { + var rawText = browseResult.RootElement.GetRawText(); + var preview = rawText.Length > 200 ? rawText[..200] : rawText; + _logger.LogDebug("Jellyfin browse result preview: {Result}", preview); + } + return new JsonResult(result); + } + + // If browsing a specific parent (album, artist, playlist) + if (!string.IsNullOrWhiteSpace(parentId)) + { + // Check if this is the music library root - if so, treat as a search + var isMusicLibrary = parentId == _settings.LibraryId; + + if (!isMusicLibrary || string.IsNullOrWhiteSpace(searchTerm)) + { + _logger.LogDebug("Browsing parent: {ParentId}", parentId); + return await GetChildItems(parentId, includeItemTypes, limit, startIndex, sortBy); + } + + // If searching within music library root, continue to integrated search below + _logger.LogInformation("Searching within music library {ParentId}, including external sources", parentId); + } + + var cleanQuery = searchTerm?.Trim().Trim('"') ?? ""; + _logger.LogInformation("Performing integrated search for: {Query}", cleanQuery); + + // Run local and external searches in parallel + var itemTypes = ParseItemTypes(includeItemTypes); + var jellyfinTask = _proxyService.SearchAsync(cleanQuery, itemTypes, limit, recursive, Request.Headers); + + // Use parallel metadata service if available (races providers), otherwise use primary + var externalTask = _parallelMetadataService != null + ? _parallelMetadataService.SearchAllAsync(cleanQuery, limit, limit, limit) + : _metadataService.SearchAllAsync(cleanQuery, limit, limit, limit); + + var playlistTask = _settings.EnableExternalPlaylists + ? _metadataService.SearchPlaylistsAsync(cleanQuery, limit) + : Task.FromResult(new List()); + + await Task.WhenAll(jellyfinTask, externalTask, playlistTask); + + var (jellyfinResult, _) = await jellyfinTask; + var externalResult = await externalTask; + var playlistResult = await playlistTask; + + _logger.LogInformation("Search results: Jellyfin={JellyfinCount}, External Songs={ExtSongs}, Albums={ExtAlbums}, Artists={ExtArtists}, Playlists={Playlists}", + jellyfinResult != null ? "found" : "null", + externalResult.Songs.Count, + externalResult.Albums.Count, + externalResult.Artists.Count, + playlistResult.Count); + + // Parse Jellyfin results into domain models + var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult); + + // Respect source ordering (SquidWTF/Tidal has better search ranking than our fuzzy matching) + // Just interleave local and external results based on which source has better overall match + + // 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; + + 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 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; + + // 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 = allArtists.Select(a => _responseBuilder.ConvertArtistToJellyfinItem(a)).ToList(); + + // Add playlists (preserve their order too) + if (playlistResult.Count > 0) + { + var playlistItems = playlistResult + .Select(p => _responseBuilder.ConvertPlaylistToJellyfinItem(p)) + .ToList(); + + mergedAlbums.AddRange(playlistItems); + } + + _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) + if (_lrclibService != null && mergedSongs.Count > 0) + { + _ = Task.Run(async () => + { + try + { + var top3 = mergedSongs.Take(3).ToList(); + _logger.LogDebug("🎵 Pre-fetching lyrics for top {Count} search results", top3.Count); + + foreach (var songItem in top3) + { + if (songItem.TryGetValue("Name", out var nameObj) && nameObj is JsonElement nameEl && + songItem.TryGetValue("Artists", out var artistsObj) && artistsObj is JsonElement artistsEl && + artistsEl.GetArrayLength() > 0) + { + var title = nameEl.GetString() ?? ""; + var artist = artistsEl[0].GetString() ?? ""; + + if (!string.IsNullOrEmpty(title) && !string.IsNullOrEmpty(artist)) + { + await _lrclibService.GetLyricsAsync(title, artist, "", 0); + } + } + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to pre-fetch lyrics for search results"); + } + }); + } + + // Filter by item types if specified + var items = new List>(); + + _logger.LogInformation("Filtering by item types: {ItemTypes}", itemTypes == null ? "null" : string.Join(",", itemTypes)); + + if (itemTypes == null || itemTypes.Length == 0 || itemTypes.Contains("MusicArtist")) + { + _logger.LogInformation("Adding {Count} artists to results", mergedArtists.Count); + items.AddRange(mergedArtists); + } + if (itemTypes == null || itemTypes.Length == 0 || itemTypes.Contains("MusicAlbum") || itemTypes.Contains("Playlist")) + { + _logger.LogInformation("Adding {Count} albums to results", mergedAlbums.Count); + items.AddRange(mergedAlbums); + } + if (itemTypes == null || itemTypes.Length == 0 || itemTypes.Contains("Audio")) + { + _logger.LogInformation("Adding {Count} songs to results", mergedSongs.Count); + items.AddRange(mergedSongs); + } + + // Apply pagination + var pagedItems = items.Skip(startIndex).Take(limit).ToList(); + + _logger.LogInformation("Returning {Count} items (total: {Total})", pagedItems.Count, items.Count); + + try + { + // Return with PascalCase - use ContentResult to bypass JSON serialization issues + var response = new + { + Items = pagedItems, + TotalRecordCount = items.Count, + StartIndex = startIndex + }; + + // Cache search results in Redis (15 min TTL, no file persistence) + if (!string.IsNullOrWhiteSpace(searchTerm) && string.IsNullOrWhiteSpace(artistIds)) + { + var cacheKey = $"search:{searchTerm?.ToLowerInvariant()}:{includeItemTypes}:{limit}:{startIndex}"; + await _cache.SetAsync(cacheKey, response, TimeSpan.FromMinutes(15)); + _logger.LogDebug("💾 Cached search results for '{SearchTerm}' (15 min TTL)", searchTerm); + } + + _logger.LogInformation("About to serialize response..."); + + var json = System.Text.Json.JsonSerializer.Serialize(response, new System.Text.Json.JsonSerializerOptions + { + PropertyNamingPolicy = null, + DictionaryKeyPolicy = null + }); + + if (_logger.IsEnabled(LogLevel.Debug)) + { + var preview = json.Length > 200 ? json[..200] : json; + _logger.LogDebug("JSON response preview: {Json}", preview); + } + + return Content(json, "application/json"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error serializing search response"); + throw; + } + } + + /// + /// Gets child items of a parent (tracks in album, albums for artist). + /// + private async Task GetChildItems( + string parentId, + string? includeItemTypes, + int limit, + int startIndex, + string? sortBy) + { + // Check if this is an external playlist + if (PlaylistIdHelper.IsExternalPlaylist(parentId)) + { + return await GetPlaylistTracks(parentId); + } + + var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(parentId); + + if (isExternal) + { + // Get external album or artist content + return await GetExternalChildItems(provider!, externalId!, includeItemTypes); + } + + // Proxy to Jellyfin for local content + var (result, statusCode) = await _proxyService.GetItemsAsync( + parentId: parentId, + includeItemTypes: ParseItemTypes(includeItemTypes), + sortBy: sortBy, + limit: limit, + startIndex: startIndex, + clientHeaders: Request.Headers); + + return HandleProxyResponse(result, statusCode); + } + + /// + /// Quick search endpoint. Works with /Search/Hints and /Users/{userId}/Search/Hints. + /// + [HttpGet("Search/Hints", Order = 1)] + [HttpGet("Users/{userId}/Search/Hints", Order = 1)] + public async Task SearchHints( + [FromQuery] string searchTerm, + [FromQuery] int limit = 20, + [FromQuery] string? includeItemTypes = null, + string? userId = null) + { + if (string.IsNullOrWhiteSpace(searchTerm)) + { + return _responseBuilder.CreateJsonResponse(new + { + SearchHints = Array.Empty(), + TotalRecordCount = 0 + }); + } + + var cleanQuery = searchTerm.Trim().Trim('"'); + var itemTypes = ParseItemTypes(includeItemTypes); + + // Run searches in parallel + var jellyfinTask = _proxyService.SearchAsync(cleanQuery, itemTypes, limit, true, Request.Headers); + var externalTask = _metadataService.SearchAllAsync(cleanQuery, limit, limit, limit); + + await Task.WhenAll(jellyfinTask, externalTask); + + var (jellyfinResult, _) = await jellyfinTask; + var externalResult = await externalTask; + + var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult); + + // 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(); + var allArtists = localArtists.Concat(externalResult.Artists).Take(limit).ToList(); + + return _responseBuilder.CreateSearchHintsResponse( + allSongs.Take(limit).ToList(), + allAlbums.Take(limit).ToList(), + allArtists.Take(limit).ToList()); + } + + #endregion + +======= +>>>>>>> beta #region Items /// @@ -611,26 +1082,46 @@ public class JellyfinController : ControllerBase /// /// Gets child items for an external parent (album tracks or artist albums). /// - private async Task GetExternalChildItems(string provider, string externalId, string? includeItemTypes) + private async Task GetExternalChildItems(string provider, string type, string externalId, string? includeItemTypes) { var itemTypes = ParseItemTypes(includeItemTypes); +<<<<<<< HEAD _logger.LogDebug("GetExternalChildItems: provider={Provider}, externalId={ExternalId}, itemTypes={ItemTypes}", provider, externalId, string.Join(",", itemTypes ?? Array.Empty())); +||||||| f68706f + _logger.LogInformation("GetExternalChildItems: provider={Provider}, externalId={ExternalId}, itemTypes={ItemTypes}", + provider, externalId, string.Join(",", itemTypes ?? Array.Empty())); +======= + _logger.LogDebug("GetExternalChildItems: provider={Provider}, type={Type}, externalId={ExternalId}, itemTypes={ItemTypes}", + provider, type, externalId, string.Join(",", itemTypes ?? Array.Empty())); +>>>>>>> beta - // Check if asking for audio (album tracks) + // Check if asking for audio (album tracks or artist songs) if (itemTypes?.Contains("Audio") == true) { - _logger.LogDebug("Fetching album tracks for {Provider}/{ExternalId}", provider, externalId); - var album = await _metadataService.GetAlbumAsync(provider, externalId); - if (album == null) + if (type == "album") { - return _responseBuilder.CreateError(404, "Album not found"); - } + _logger.LogDebug("Fetching album tracks for {Provider}/{ExternalId}", provider, externalId); + var album = await _metadataService.GetAlbumAsync(provider, externalId); + if (album == null) + { + return _responseBuilder.CreateError(404, "Album not found"); + } - return _responseBuilder.CreateItemsResponse(album.Songs); + return _responseBuilder.CreateItemsResponse(album.Songs); + } + else if (type == "artist") + { + // For artist + Audio, fetch top tracks from the artist endpoint + _logger.LogDebug("Fetching artist tracks for {Provider}/{ExternalId}", provider, externalId); + var tracks = await _metadataService.GetArtistTracksAsync(provider, externalId); + _logger.LogDebug("Found {Count} tracks for artist", tracks.Count); + return _responseBuilder.CreateItemsResponse(tracks); + } } +<<<<<<< HEAD // Otherwise assume it's artist albums _logger.LogDebug("Fetching artist albums for {Provider}/{ExternalId}", provider, externalId); var albums = await _metadataService.GetArtistAlbumsAsync(provider, externalId); @@ -640,16 +1131,87 @@ public class JellyfinController : ControllerBase // Fill artist info if (artist != null) +||||||| f68706f + // Otherwise assume it's artist albums + _logger.LogDebug("Fetching artist albums for {Provider}/{ExternalId}", provider, externalId); + var albums = await _metadataService.GetArtistAlbumsAsync(provider, externalId); + var artist = await _metadataService.GetArtistAsync(provider, externalId); + + _logger.LogInformation("Found {Count} albums for artist {ArtistName}", albums.Count, artist?.Name ?? "unknown"); + + // Fill artist info + if (artist != null) +======= + // Check if asking for albums (artist albums) + if (itemTypes?.Contains("MusicAlbum") == true || itemTypes == null) +>>>>>>> beta { - foreach (var a in albums) + if (type == "artist") { - if (string.IsNullOrEmpty(a.Artist)) a.Artist = artist.Name; - if (string.IsNullOrEmpty(a.ArtistId)) a.ArtistId = artist.Id; + _logger.LogDebug("Fetching artist albums for {Provider}/{ExternalId}", provider, externalId); + var albums = await _metadataService.GetArtistAlbumsAsync(provider, externalId); + var artist = await _metadataService.GetArtistAsync(provider, externalId); + + _logger.LogDebug("Found {Count} albums for artist {ArtistName}", albums.Count, artist?.Name ?? "unknown"); + + // Fill artist info + if (artist != null) + { + foreach (var a in albums) + { + if (string.IsNullOrEmpty(a.Artist)) a.Artist = artist.Name; + if (string.IsNullOrEmpty(a.ArtistId)) a.ArtistId = artist.Id; + } + } + + return _responseBuilder.CreateAlbumsResponse(albums); } } - return _responseBuilder.CreateAlbumsResponse(albums); + // Fallback: return empty result + _logger.LogWarning("Unhandled GetExternalChildItems request: provider={Provider}, type={Type}, externalId={ExternalId}, itemTypes={ItemTypes}", + provider, type, externalId, string.Join(",", itemTypes ?? Array.Empty())); + return _responseBuilder.CreateItemsResponse(new List()); } + private async Task GetCuratorPlaylists(string provider, string externalId, string? includeItemTypes) + { + var itemTypes = ParseItemTypes(includeItemTypes); + + _logger.LogDebug("GetCuratorPlaylists: provider={Provider}, curatorId={CuratorId}, itemTypes={ItemTypes}", + provider, externalId, string.Join(",", itemTypes ?? Array.Empty())); + + // Extract curator name from externalId (format: "curator-{name}") + var curatorName = externalId.Replace("curator-", "", StringComparison.OrdinalIgnoreCase); + + // Search for playlists by this curator + // Since we don't have a direct "get playlists by curator" method, we'll search for the curator name + // and filter the results + var playlists = await _metadataService.SearchPlaylistsAsync(curatorName, 50); + + // Filter to only playlists from this curator (case-insensitive match) + var curatorPlaylists = playlists + .Where(p => !string.IsNullOrEmpty(p.CuratorName) && + p.CuratorName.Equals(curatorName, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + _logger.LogInformation("Found {Count} playlists for curator '{CuratorName}'", curatorPlaylists.Count, curatorName); + + // Convert playlists to album items + var albumItems = curatorPlaylists + .Select(p => _responseBuilder.ConvertPlaylistToAlbumItem(p)) + .ToList(); + + var response = new Dictionary + { + ["Items"] = albumItems, + ["TotalRecordCount"] = albumItems.Count, + ["StartIndex"] = 0 + }; + + return new JsonResult(response); + } + + #endregion @@ -808,6 +1370,7 @@ public class JellyfinController : ControllerBase #endregion +<<<<<<< HEAD #region Audio Streaming /// @@ -1030,6 +1593,231 @@ public class JellyfinController : ControllerBase #endregion +||||||| f68706f + #region Audio Streaming + + /// + /// Downloads/streams audio. Works with local and external content. + /// + [HttpGet("Items/{itemId}/Download")] + [HttpGet("Items/{itemId}/File")] + public async Task DownloadAudio(string itemId) + { + if (string.IsNullOrWhiteSpace(itemId)) + { + return BadRequest(new { error = "Missing item ID" }); + } + + var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId); + + if (!isExternal) + { + // Build path for Jellyfin download/file endpoint + var endpoint = Request.Path.Value?.Contains("/File", StringComparison.OrdinalIgnoreCase) == true ? "File" : "Download"; + var fullPath = $"Items/{itemId}/{endpoint}"; + if (Request.QueryString.HasValue) + { + fullPath = $"{fullPath}{Request.QueryString.Value}"; + } + + return await ProxyJellyfinStream(fullPath, itemId); + } + + // Handle external content + return await StreamExternalContent(provider!, externalId!); + } + + /// + /// Streams audio for a given item. Downloads on-demand for external content. + /// + [HttpGet("Audio/{itemId}/stream")] + [HttpGet("Audio/{itemId}/stream.{container}")] + public async Task StreamAudio(string itemId, string? container = null) + { + if (string.IsNullOrWhiteSpace(itemId)) + { + return BadRequest(new { error = "Missing item ID" }); + } + + var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId); + + if (!isExternal) + { + // Build path for Jellyfin stream + var fullPath = string.IsNullOrEmpty(container) + ? $"Audio/{itemId}/stream" + : $"Audio/{itemId}/stream.{container}"; + + if (Request.QueryString.HasValue) + { + fullPath = $"{fullPath}{Request.QueryString.Value}"; + } + + return await ProxyJellyfinStream(fullPath, itemId); + } + + // Handle external content + return await StreamExternalContent(provider!, externalId!); + } + + /// + /// Proxies a stream from Jellyfin with proper header forwarding. + /// + private async Task ProxyJellyfinStream(string path, string itemId) + { + var jellyfinUrl = $"{_settings.Url?.TrimEnd('/')}/{path}"; + + try + { + var request = new HttpRequestMessage(HttpMethod.Get, jellyfinUrl); + + // Forward auth headers + if (Request.Headers.TryGetValue("X-Emby-Authorization", out var embyAuth)) + { + request.Headers.TryAddWithoutValidation("X-Emby-Authorization", embyAuth.ToString()); + } + else if (Request.Headers.TryGetValue("Authorization", out var auth)) + { + request.Headers.TryAddWithoutValidation("Authorization", auth.ToString()); + } + + // Forward Range header for seeking + if (Request.Headers.TryGetValue("Range", out var range)) + { + request.Headers.TryAddWithoutValidation("Range", range.ToString()); + } + + var response = await _proxyService.HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + + if (!response.IsSuccessStatusCode) + { + _logger.LogError("Jellyfin stream failed: {StatusCode} for {ItemId}", response.StatusCode, itemId); + return StatusCode((int)response.StatusCode); + } + + // Set response status and headers + Response.StatusCode = (int)response.StatusCode; + + var contentType = response.Content.Headers.ContentType?.ToString() ?? "audio/mpeg"; + + // Forward caching headers for client-side caching + if (response.Headers.ETag != null) + { + Response.Headers["ETag"] = response.Headers.ETag.ToString(); + } + + if (response.Content.Headers.LastModified.HasValue) + { + Response.Headers["Last-Modified"] = response.Content.Headers.LastModified.Value.ToString("R"); + } + + if (response.Headers.CacheControl != null) + { + Response.Headers["Cache-Control"] = response.Headers.CacheControl.ToString(); + } + + // Forward range headers for seeking + if (response.Content.Headers.ContentRange != null) + { + Response.Headers["Content-Range"] = response.Content.Headers.ContentRange.ToString(); + } + + if (response.Headers.AcceptRanges != null) + { + Response.Headers["Accept-Ranges"] = string.Join(", ", response.Headers.AcceptRanges); + } + + if (response.Content.Headers.ContentLength.HasValue) + { + Response.Headers["Content-Length"] = response.Content.Headers.ContentLength.Value.ToString(); + } + + var stream = await response.Content.ReadAsStreamAsync(); + return File(stream, contentType); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to proxy stream from Jellyfin for {ItemId}", itemId); + return StatusCode(500, new { error = $"Streaming failed: {ex.Message}" }); + } + } + + /// + /// Streams external content, using cache if available or downloading on-demand. + /// + private async Task StreamExternalContent(string provider, string externalId) + { + // Check for locally cached file + var localPath = await _localLibraryService.GetLocalPathForExternalSongAsync(provider, externalId); + + if (localPath != null && System.IO.File.Exists(localPath)) + { + // Update last access time for cache cleanup + try + { + System.IO.File.SetLastAccessTimeUtc(localPath, DateTime.UtcNow); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to update last access time for {Path}", localPath); + } + + var stream = System.IO.File.OpenRead(localPath); + return File(stream, GetContentType(localPath), enableRangeProcessing: true); + } + + // Download and stream on-demand + try + { + var downloadStream = await _downloadService.DownloadAndStreamAsync( + provider, + externalId, + HttpContext.RequestAborted); + + return File(downloadStream, "audio/mpeg", enableRangeProcessing: true); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to stream external song {Provider}:{ExternalId}", provider, externalId); + return StatusCode(500, new { error = $"Streaming failed: {ex.Message}" }); + } + } + + /// + /// Universal audio endpoint - handles transcoding, format negotiation, and adaptive streaming. + /// This is the primary endpoint used by Jellyfin Web and most clients. + /// + [HttpGet("Audio/{itemId}/universal")] + [HttpHead("Audio/{itemId}/universal")] + public async Task UniversalAudio(string itemId) + { + if (string.IsNullOrWhiteSpace(itemId)) + { + return BadRequest(new { error = "Missing item ID" }); + } + + var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId); + + if (!isExternal) + { + // For local content, proxy the universal endpoint with all query parameters + var fullPath = $"Audio/{itemId}/universal"; + if (Request.QueryString.HasValue) + { + fullPath = $"{fullPath}{Request.QueryString.Value}"; + } + + return await ProxyJellyfinStream(fullPath, itemId); + } + + // For external content, use simple streaming (no transcoding support yet) + return await StreamExternalContent(provider!, externalId!); + } + + #endregion + +======= +>>>>>>> beta #region Images /// @@ -1068,7 +1856,45 @@ public class JellyfinController : ControllerBase if (imageBytes == null || contentType == null) { - // Return placeholder if Jellyfin doesn't have image + // Try to get the item details to find fallback image (album/parent) + var (itemResult, itemStatus) = await _proxyService.GetJsonAsync($"Items/{itemId}", null, Request.Headers); + + if (itemResult != null && itemStatus == 200) + { + var item = itemResult.RootElement; + string? fallbackItemId = null; + + // Check for album image fallback (for songs) + if (item.TryGetProperty("AlbumId", out var albumIdProp)) + { + fallbackItemId = albumIdProp.GetString(); + } + // Check for parent primary image fallback + else if (item.TryGetProperty("ParentPrimaryImageItemId", out var parentIdProp)) + { + fallbackItemId = parentIdProp.GetString(); + } + + // Try to fetch the fallback image + if (!string.IsNullOrEmpty(fallbackItemId)) + { + _logger.LogDebug("Item {ItemId} has no {ImageType} image, trying fallback from {FallbackId}", + itemId, imageType, fallbackItemId); + + var (fallbackBytes, fallbackContentType) = await _proxyService.GetImageAsync( + fallbackItemId, + imageType, + maxWidth, + maxHeight); + + if (fallbackBytes != null && fallbackContentType != null) + { + return File(fallbackBytes, fallbackContentType); + } + } + } + + // Return placeholder if no fallback found return await GetPlaceholderImageAsync(); } @@ -1084,8 +1910,11 @@ public class JellyfinController : ControllerBase _ => null }; + _logger.LogDebug("External {Type} {Provider}/{ExternalId} coverUrl: {CoverUrl}", type, provider, externalId, coverUrl ?? "NULL"); + if (string.IsNullOrEmpty(coverUrl)) { + _logger.LogDebug("No cover URL for external {Type}, returning placeholder", type); // Return placeholder "no image available" image return await GetPlaceholderImageAsync(); } @@ -1093,16 +1922,34 @@ public class JellyfinController : ControllerBase // Fetch and return the image using the proxy service's HttpClient try { - var response = await _proxyService.HttpClient.GetAsync(coverUrl); - if (!response.IsSuccessStatusCode) + _logger.LogDebug("Fetching external image from {Url}", coverUrl); + + var imageBytes = await RetryHelper.RetryWithBackoffAsync(async () => + { + var response = await _proxyService.HttpClient.GetAsync(coverUrl); + + if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests || + response.StatusCode == System.Net.HttpStatusCode.ServiceUnavailable) + { + throw new HttpRequestException($"Transient error: {response.StatusCode}", null, response.StatusCode); + } + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("Failed to fetch external image from {Url}: {StatusCode}", coverUrl, response.StatusCode); + return null; + } + + return await response.Content.ReadAsByteArrayAsync(); + }, _logger, maxRetries: 3, initialDelayMs: 500); + + if (imageBytes == null) { - // Return placeholder on fetch failure return await GetPlaceholderImageAsync(); } - - var imageBytes = await response.Content.ReadAsByteArrayAsync(); - var contentType = response.Content.Headers.ContentType?.ToString() ?? "image/jpeg"; - return File(imageBytes, contentType); + + _logger.LogDebug("Successfully fetched external image from {Url}, size: {Size} bytes", coverUrl, imageBytes.Length); + return File(imageBytes, "image/jpeg"); } catch (Exception ex) { @@ -1137,6 +1984,7 @@ public class JellyfinController : ControllerBase #endregion +<<<<<<< HEAD #region Lyrics /// @@ -1580,6 +2428,418 @@ public class JellyfinController : ControllerBase #endregion +||||||| f68706f + #region Lyrics + + /// + /// Gets lyrics for an item. + /// Priority: 1. Jellyfin embedded lyrics, 2. Spotify synced lyrics, 3. LRCLIB + /// + [HttpGet("Audio/{itemId}/Lyrics")] + [HttpGet("Items/{itemId}/Lyrics")] + public async Task GetLyrics(string itemId) + { + _logger.LogInformation("🎵 GetLyrics called for itemId: {ItemId}", itemId); + + if (string.IsNullOrWhiteSpace(itemId)) + { + return NotFound(); + } + + 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) + { + _logger.LogInformation("Checking Jellyfin for embedded lyrics for local track: {ItemId}", itemId); + + // Try to get lyrics from Jellyfin first (it reads embedded lyrics from files) + var (jellyfinLyrics, statusCode) = await _proxyService.GetJsonAsync($"Audio/{itemId}/Lyrics", null, Request.Headers); + + _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(jellyfinLyrics.RootElement.GetRawText())); + } + + _logger.LogInformation("No embedded lyrics found in Jellyfin (status: {StatusCode}), trying Spotify/LRCLIB", statusCode); + } + + // Get song metadata for lyrics search + Song? song = null; + string? spotifyTrackId = null; + + if (isExternal) + { + song = await _metadataService.GetSongAsync(provider!, externalId!); + + // Use Spotify ID from song metadata if available (populated during GetSongAsync) + if (song != null && !string.IsNullOrEmpty(song.SpotifyId)) + { + spotifyTrackId = song.SpotifyId; + _logger.LogInformation("Using Spotify ID {SpotifyId} from song metadata for {Provider}/{ExternalId}", + spotifyTrackId, provider, externalId); + } + // Fallback: Try to find Spotify ID from matched tracks cache + else if (song != null) + { + spotifyTrackId = await FindSpotifyIdForExternalTrackAsync(song); + if (!string.IsNullOrEmpty(spotifyTrackId)) + { + _logger.LogInformation("Found Spotify ID {SpotifyId} for external track {Provider}/{ExternalId} from cache", + spotifyTrackId, provider, externalId); + } + else + { + // Last resort: Try to convert via Odesli/song.link + if (provider == "squidwtf") + { + spotifyTrackId = await _odesliService.ConvertTidalToSpotifyIdAsync(externalId!, HttpContext.RequestAborted); + } + else + { + // For other providers, build the URL and convert + var sourceUrl = provider?.ToLowerInvariant() switch + { + "deezer" => $"https://www.deezer.com/track/{externalId}", + "qobuz" => $"https://www.qobuz.com/us-en/album/-/-/{externalId}", + _ => null + }; + + if (!string.IsNullOrEmpty(sourceUrl)) + { + spotifyTrackId = await _odesliService.ConvertUrlToSpotifyIdAsync(sourceUrl, HttpContext.RequestAborted); + } + } + + if (!string.IsNullOrEmpty(spotifyTrackId)) + { + _logger.LogInformation("Converted {Provider}/{ExternalId} to Spotify ID {SpotifyId} via Odesli", + provider, externalId, spotifyTrackId); + } + } + } + } + else + { + // For local songs, get 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) + { + return NotFound(new { error = "Song not found" }); + } + + // Strip [S] suffix from title, artist, and album for lyrics search + // The [S] tag is added to external tracks but shouldn't be used in lyrics queries + var searchTitle = song.Title.Replace(" [S]", "").Trim(); + var searchArtist = song.Artist?.Replace(" [S]", "").Trim() ?? ""; + var searchAlbum = song.Album?.Replace(" [S]", "").Trim() ?? ""; + var searchArtists = song.Artists.Select(a => a.Replace(" [S]", "").Trim()).ToList(); + + if (searchArtists.Count == 0 && !string.IsNullOrEmpty(searchArtist)) + { + searchArtists.Add(searchArtist); + } + + LyricsInfo? lyrics = null; + + // Try Spotify lyrics ONLY if we have a valid Spotify track ID + // Spotify lyrics only work for tracks from injected playlists that have been matched + if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled && !string.IsNullOrEmpty(spotifyTrackId)) + { + // Validate that this is a real Spotify ID (not spotify:local or other invalid formats) + var cleanSpotifyId = spotifyTrackId.Replace("spotify:track:", "").Trim(); + + // Spotify track IDs are 22 characters, base62 encoded + if (cleanSpotifyId.Length == 22 && !cleanSpotifyId.Contains(":") && !cleanSpotifyId.Contains("local")) + { + _logger.LogInformation("Trying Spotify lyrics for track ID: {SpotifyId} ({Artist} - {Title})", + cleanSpotifyId, searchArtist, searchTitle); + + var spotifyLyrics = await _spotifyLyricsService.GetLyricsByTrackIdAsync(cleanSpotifyId); + + if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0) + { + _logger.LogInformation("Found Spotify lyrics for {Artist} - {Title} ({LineCount} lines, type: {SyncType})", + searchArtist, searchTitle, spotifyLyrics.Lines.Count, spotifyLyrics.SyncType); + lyrics = _spotifyLyricsService.ToLyricsInfo(spotifyLyrics); + } + else + { + _logger.LogDebug("No Spotify lyrics found for track ID {SpotifyId}", cleanSpotifyId); + } + } + else + { + _logger.LogDebug("Invalid Spotify ID format: {SpotifyId}, skipping Spotify lyrics", spotifyTrackId); + } + } + + // Fall back to LRCLIB if no Spotify lyrics + if (lyrics == null) + { + _logger.LogInformation("Searching LRCLIB for lyrics: {Artists} - {Title}", + string.Join(", ", searchArtists), + searchTitle); + var lrclibService = HttpContext.RequestServices.GetService(); + if (lrclibService != null) + { + lyrics = await lrclibService.GetLyricsAsync( + searchTitle, + searchArtists.ToArray(), + searchAlbum, + song.Duration ?? 0); + } + } + + if (lyrics == null) + { + return NotFound(new { error = "Lyrics not found" }); + } + + // Prefer synced lyrics, fall back to plain + var lyricsText = lyrics.SyncedLyrics ?? lyrics.PlainLyrics ?? ""; + var isSynced = !string.IsNullOrEmpty(lyrics.SyncedLyrics); + + _logger.LogInformation("Lyrics for {Artist} - {Track}: synced={HasSynced}, plainLength={PlainLen}, syncedLength={SyncLen}", + song.Artist, song.Title, isSynced, lyrics.PlainLyrics?.Length ?? 0, lyrics.SyncedLyrics?.Length ?? 0); + + // Parse LRC format into individual lines for Jellyfin + var lyricLines = new List>(); + + if (isSynced && !string.IsNullOrEmpty(lyrics.SyncedLyrics)) + { + _logger.LogInformation("Parsing synced lyrics (LRC format)"); + // Parse LRC format: [mm:ss.xx] text + // Skip ID tags like [ar:Artist], [ti:Title], etc. + var lines = lyrics.SyncedLyrics.Split('\n', StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + // Match timestamp format [mm:ss.xx] or [mm:ss.xxx] + var match = System.Text.RegularExpressions.Regex.Match(line, @"^\[(\d+):(\d+)\.(\d+)\]\s*(.*)$"); + if (match.Success) + { + var minutes = int.Parse(match.Groups[1].Value); + var seconds = int.Parse(match.Groups[2].Value); + var centiseconds = int.Parse(match.Groups[3].Value); + var text = match.Groups[4].Value; + + // Convert to ticks (100 nanoseconds) + var totalMilliseconds = (minutes * 60 + seconds) * 1000 + centiseconds * 10; + var ticks = totalMilliseconds * 10000L; + + // For synced lyrics, include Start timestamp + lyricLines.Add(new Dictionary + { + ["Text"] = text, + ["Start"] = ticks + }); + } + // Skip ID tags like [ar:Artist], [ti:Title], [length:2:23], etc. + } + _logger.LogInformation("Parsed {Count} synced lyric lines (skipped ID tags)", lyricLines.Count); + } + else if (!string.IsNullOrEmpty(lyricsText)) + { + _logger.LogInformation("Splitting plain lyrics into lines (no timestamps)"); + // Plain lyrics - split by newlines and return each line separately + // IMPORTANT: Do NOT include "Start" field at all for unsynced lyrics + // Including it (even as null) causes clients to treat it as synced with timestamp 0:00 + var lines = lyricsText.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + lyricLines.Add(new Dictionary + { + ["Text"] = line.Trim() + }); + } + _logger.LogInformation("Split into {Count} plain lyric lines", lyricLines.Count); + } + else + { + _logger.LogWarning("No lyrics text available"); + // No lyrics at all + lyricLines.Add(new Dictionary + { + ["Text"] = "" + }); + } + + var response = new + { + Metadata = new + { + Artist = lyrics.ArtistName, + Album = lyrics.AlbumName, + Title = lyrics.TrackName, + Length = lyrics.Duration, + IsSynced = isSynced + }, + Lyrics = lyricLines + }; + + _logger.LogInformation("Returning lyrics response: {LineCount} lines, synced={IsSynced}", lyricLines.Count, isSynced); + + // Log a sample of the response for debugging + if (lyricLines.Count > 0) + { + var sampleLine = lyricLines[0]; + var hasStart = sampleLine.ContainsKey("Start"); + _logger.LogInformation("Sample line: Text='{Text}', HasStart={HasStart}", + sampleLine.GetValueOrDefault("Text"), hasStart); + } + + return Ok(response); + } + + /// + /// Proactively fetches and caches lyrics for a track in the background. + /// Called when playback starts to ensure lyrics are ready when requested. + /// + 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 + +======= +>>>>>>> beta #region Favorites /// @@ -1634,20 +2894,43 @@ public class JellyfinController : ControllerBase var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId); if (isExternal) { - _logger.LogInformation("Favoriting external item {ItemId}, copying to kept folder", itemId); + // Check if it's an album by parsing the full ID with type + var (_, _, type, _) = _localLibraryService.ParseExternalId(itemId); - // Copy the track to kept folder in background - _ = Task.Run(async () => + if (type == "album") { - try + _logger.LogInformation("Favoriting external album {ItemId}, downloading all tracks to kept folder", itemId); + + // Download entire album to kept folder in background + _ = Task.Run(async () => { - await CopyExternalTrackToKeptAsync(itemId, provider!, externalId!); - } - catch (Exception ex) + try + { + await CopyExternalAlbumToKeptAsync(itemId, provider!, externalId!); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to copy external album {ItemId} to kept folder", itemId); + } + }); + } + else + { + _logger.LogInformation("Favoriting external track {ItemId}, copying to kept folder", itemId); + + // Copy the track to kept folder in background + _ = Task.Run(async () => { - _logger.LogError(ex, "Failed to copy external track {ItemId} to kept folder", itemId); - } - }); + try + { + await CopyExternalTrackToKeptAsync(itemId, provider!, externalId!); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to copy external track {ItemId} to kept folder", itemId); + } + }); + } // Return a minimal UserItemDataDto response return Ok(new @@ -1736,6 +3019,7 @@ public class JellyfinController : ControllerBase #endregion +<<<<<<< HEAD #region Playlists /// @@ -1997,6 +3281,270 @@ public class JellyfinController : ControllerBase #endregion +||||||| f68706f + #region Playlists + + /// + /// Gets playlist tracks displayed as an album. + /// + private async Task GetPlaylistAsAlbum(string playlistId) + { + try + { + var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(playlistId); + + var playlist = await _metadataService.GetPlaylistAsync(provider, externalId); + if (playlist == null) + { + return _responseBuilder.CreateError(404, "Playlist not found"); + } + + var tracks = await _metadataService.GetPlaylistTracksAsync(provider, externalId); + + // Cache tracks for playlist sync + if (_playlistSyncService != null) + { + foreach (var track in tracks) + { + if (!string.IsNullOrEmpty(track.ExternalId)) + { + var trackId = $"ext-{provider}-{track.ExternalId}"; + _playlistSyncService.AddTrackToPlaylistCache(trackId, playlistId); + } + } + _logger.LogDebug("Cached {Count} tracks for playlist {PlaylistId}", tracks.Count, playlistId); + } + + return _responseBuilder.CreatePlaylistAsAlbumResponse(playlist, tracks); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting playlist {PlaylistId}", playlistId); + return _responseBuilder.CreateError(500, "Failed to get playlist"); + } + } + + /// + /// Gets playlist tracks as child items. + /// + private async Task GetPlaylistTracks(string playlistId) + { + try + { + _logger.LogInformation("=== GetPlaylistTracks called === PlaylistId: {PlaylistId}", playlistId); + + // Check if this is an external playlist (Deezer/Qobuz) first + if (PlaylistIdHelper.IsExternalPlaylist(playlistId)) + { + var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(playlistId); + var tracks = await _metadataService.GetPlaylistTracksAsync(provider, externalId); + return _responseBuilder.CreateItemsResponse(tracks); + } + + // Check if this is a Spotify playlist (by ID) + _logger.LogInformation("Spotify Import Enabled: {Enabled}, Configured Playlists: {Count}", + _spotifySettings.Enabled, _spotifySettings.Playlists.Count); + + if (_spotifySettings.Enabled && _spotifySettings.IsSpotifyPlaylist(playlistId)) + { + // Get playlist info from Jellyfin to get the name for matching missing tracks + _logger.LogInformation("Fetching playlist info from Jellyfin for ID: {PlaylistId}", playlistId); + var (playlistInfo, _) = await _proxyService.GetJsonAsync($"Items/{playlistId}", null, Request.Headers); + + if (playlistInfo != null && playlistInfo.RootElement.TryGetProperty("Name", out var nameElement)) + { + var playlistName = nameElement.GetString() ?? ""; + _logger.LogInformation("✓ MATCHED! Intercepting Spotify playlist: {PlaylistName} (ID: {PlaylistId})", + playlistName, playlistId); + return await GetSpotifyPlaylistTracksAsync(playlistName, playlistId); + } + else + { + _logger.LogWarning("Could not get playlist name from Jellyfin for ID: {PlaylistId}", playlistId); + } + } + + // Regular Jellyfin playlist - proxy through + var endpoint = $"Playlists/{playlistId}/Items"; + if (Request.QueryString.HasValue) + { + endpoint = $"{endpoint}{Request.QueryString.Value}"; + } + + _logger.LogInformation("Proxying to Jellyfin: {Endpoint}", endpoint); + var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers); + + return HandleProxyResponse(result, statusCode); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting playlist tracks {PlaylistId}", playlistId); + return _responseBuilder.CreateError(500, "Failed to get playlist tracks"); + } + } + + /// + /// Gets a playlist cover image. + /// + private async Task GetPlaylistImage(string playlistId) + { + try + { + // Check cache first (1 hour TTL for playlist images since they can change) + var cacheKey = $"playlist:image:{playlistId}"; + var cachedImage = await _cache.GetAsync(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); + + if (playlist == null || string.IsNullOrEmpty(playlist.CoverUrl)) + { + return NotFound(); + } + + var response = await _proxyService.HttpClient.GetAsync(playlist.CoverUrl); + if (!response.IsSuccessStatusCode) + { + return NotFound(); + } + + 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) + { + _logger.LogWarning(ex, "Failed to get playlist image {PlaylistId}", playlistId); + return NotFound(); + } + } + + #endregion + + #region Authentication + + /// + /// Authenticates a user by username and password. + /// This is the primary login endpoint for Jellyfin clients. + /// + [HttpPost("Users/AuthenticateByName")] + public async Task AuthenticateByName() + { + try + { + // Enable buffering to allow multiple reads of the request body + Request.EnableBuffering(); + + // Read the request body + using var reader = new StreamReader(Request.Body, leaveOpen: true); + var body = await reader.ReadToEndAsync(); + + // Reset stream position + Request.Body.Position = 0; + + _logger.LogInformation("Authentication request received"); + // DO NOT log request body or detailed headers - contains password + + // Forward to Jellyfin server with client headers - completely transparent proxy + var (result, statusCode) = await _proxyService.PostJsonAsync("Users/AuthenticateByName", body, Request.Headers); + + // Pass through Jellyfin's response exactly as-is (transparent proxy) + if (result != null) + { + var responseJson = result.RootElement.GetRawText(); + + // On successful auth, extract access token and post session capabilities in background + if (statusCode == 200) + { + _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 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"); + + // Build auth header with the new token + var authHeaders = new HeaderDictionary + { + ["X-Emby-Token"] = token + }; + + var capabilities = new + { + PlayableMediaTypes = new[] { "Audio" }, + SupportedCommands = Array.Empty(), + 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("Authentication failed - status {StatusCode}", statusCode); + } + + // Return Jellyfin's exact response + return Content(responseJson, "application/json"); + } + + // No response body from Jellyfin - return status code only + _logger.LogWarning("Authentication request returned {StatusCode} with no response body", statusCode); + return StatusCode(statusCode); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during authentication"); + return StatusCode(500, new { error = $"Authentication error: {ex.Message}" }); + } + } + + #endregion + +======= +>>>>>>> beta #region Recommendations & Instant Mix /// @@ -2201,6 +3749,7 @@ public class JellyfinController : ControllerBase #endregion +<<<<<<< HEAD #region Playback Session Reporting #region Session Management @@ -2801,6 +4350,609 @@ public class JellyfinController : ControllerBase #endregion // Playback Session Reporting +||||||| f68706f + #region Playback Session Reporting + + #region Session Management + + /// + /// Reports session capabilities. Required for Jellyfin to track active sessions. + /// Handles both POST (with body) and GET (query params only) methods. + /// + [HttpPost("Sessions/Capabilities")] + [HttpPost("Sessions/Capabilities/Full")] + [HttpGet("Sessions/Capabilities")] + [HttpGet("Sessions/Capabilities/Full")] + public async Task ReportCapabilities() + { + try + { + var method = Request.Method; + var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : ""; + + _logger.LogDebug("📡 Session capabilities reported - Method: {Method}, Query: {Query}", method, queryString); + _logger.LogInformation("Headers: {Headers}", + string.Join(", ", Request.Headers.Where(h => h.Key.Contains("Auth", StringComparison.OrdinalIgnoreCase) || h.Key.Contains("Device", StringComparison.OrdinalIgnoreCase) || h.Key.Contains("Client", StringComparison.OrdinalIgnoreCase)) + .Select(h => $"{h.Key}={h.Value}"))); + + // Forward to Jellyfin with query string and headers + var endpoint = $"Sessions/Capabilities{queryString}"; + + // Read body if present (POST requests) + string body = "{}"; + if (method == "POST" && Request.ContentLength > 0) + { + Request.EnableBuffering(); + using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true)) + { + body = await reader.ReadToEndAsync(); + } + Request.Body.Position = 0; + _logger.LogInformation("Capabilities body: {Body}", body); + } + + var (result, statusCode) = await _proxyService.PostJsonAsync(endpoint, body, Request.Headers); + + if (statusCode == 204 || statusCode == 200) + { + _logger.LogDebug("✓ Session capabilities forwarded to Jellyfin ({StatusCode})", statusCode); + } + else if (statusCode == 401) + { + _logger.LogDebug("⚠ Jellyfin returned 401 for capabilities (token expired)"); + } + else + { + _logger.LogWarning("⚠ Jellyfin returned {StatusCode} for capabilities", statusCode); + } + + return NoContent(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to report session capabilities"); + return StatusCode(500); + } + } + + /// + /// Reports playback start. Handles both local and external tracks. + /// For local tracks, forwards to Jellyfin. For external tracks, logs locally. + /// Also ensures session is initialized if this is the first report from a device. + /// + [HttpPost("Sessions/Playing")] + public async Task ReportPlaybackStart() + { + try + { + Request.EnableBuffering(); + string body; + using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true)) + { + body = await reader.ReadToEndAsync(); + } + Request.Body.Position = 0; + + _logger.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)) + { + itemId = itemIdProp.GetString(); + } + + if (doc.RootElement.TryGetProperty("ItemName", out var itemNameProp)) + { + itemName = itemNameProp.GetString(); + } + + if (doc.RootElement.TryGetProperty("PositionTicks", out var posProp)) + { + positionTicks = posProp.GetInt64(); + } + + // 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); + + if (isExternal) + { + _logger.LogInformation("🎵 External track playback started: {Name} ({Provider}/{ExternalId})", + itemName ?? "Unknown", provider, externalId); + + // Proactively fetch lyrics in background for external tracks + _ = Task.Run(async () => + { + try + { + await PrefetchLyricsForTrackAsync(itemId, isExternal: true, provider, externalId); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to prefetch lyrics for external track {ItemId}", itemId); + } + }); + + // Create a ghost/fake item to report to Jellyfin so "Now Playing" shows up + // Generate a deterministic UUID from the external ID + var ghostUuid = GenerateUuidFromString(itemId); + + // Build minimal playback start with just the ghost UUID + // Don't include the Item object - Jellyfin will just track the session without item details + var playbackStart = new + { + ItemId = ghostUuid, + PositionTicks = positionTicks ?? 0, + CanSeek = true, + IsPaused = false, + IsMuted = false, + PlayMethod = "DirectPlay" + }; + + var playbackJson = JsonSerializer.Serialize(playbackStart); + _logger.LogDebug("📤 Sending ghost playback start for external track: {Json}", playbackJson); + + // Forward to Jellyfin with ghost UUID + var (ghostResult, ghostStatusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", playbackJson, Request.Headers); + + if (ghostStatusCode == 204 || ghostStatusCode == 200) + { + _logger.LogDebug("✓ Ghost playback start forwarded to Jellyfin for external track ({StatusCode})", ghostStatusCode); + } + else + { + _logger.LogWarning("⚠️ Ghost playback start returned status {StatusCode} for external track", ghostStatusCode); + } + + return NoContent(); + } + + _logger.LogInformation("🎵 Local track playback started: {Name} (ID: {ItemId})", + itemName ?? "Unknown", itemId); + + // Proactively fetch lyrics in background for local tracks + _ = Task.Run(async () => + { + try + { + await PrefetchLyricsForTrackAsync(itemId, isExternal: false, null, null); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to prefetch lyrics for local track {ItemId}", itemId); + } + }); + } + + // For local tracks, forward playback start to Jellyfin FIRST + _logger.LogDebug("Forwarding playback start to Jellyfin..."); + + // Fetch full item details to include in playback report + try + { + var (itemResult, itemStatus) = await _proxyService.GetJsonAsync($"Items/{itemId}", null, Request.Headers); + if (itemResult != null && itemStatus == 200) + { + var item = itemResult.RootElement; + _logger.LogInformation("📦 Fetched item details for playback report"); + + // Build playback start info - Jellyfin will fetch item details itself + var playbackStart = new + { + ItemId = itemId, + PositionTicks = positionTicks ?? 0, + // Let Jellyfin fetch the item details - don't include NowPlayingItem + }; + + var playbackJson = JsonSerializer.Serialize(playbackStart); + _logger.LogInformation("📤 Sending playback start: {Json}", playbackJson); + + var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", playbackJson, Request.Headers); + + if (statusCode == 204 || statusCode == 200) + { + _logger.LogDebug("✓ Playback start forwarded to Jellyfin ({StatusCode})", statusCode); + + // NOW ensure session exists with capabilities (after playback is reported) + if (!string.IsNullOrEmpty(deviceId)) + { + var sessionCreated = await _sessionManager.EnsureSessionAsync(deviceId, client ?? "Unknown", device ?? "Unknown", version ?? "1.0", Request.Headers); + if (sessionCreated) + { + _logger.LogDebug("✓ SESSION: Session ensured for device {DeviceId} after playback start", deviceId); + } + else + { + _logger.LogWarning("⚠️ SESSION: Failed to ensure session for device {DeviceId}", deviceId); + } + } + else + { + _logger.LogWarning("⚠️ SESSION: No device ID found in headers for playback start"); + } + } + else + { + _logger.LogWarning("⚠️ Playback start returned status {StatusCode}", statusCode); + } + } + else + { + _logger.LogWarning("⚠️ Could not fetch item details ({StatusCode}), sending basic playback start", itemStatus); + // Fall back to basic playback start + var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", body, Request.Headers); + if (statusCode == 204 || statusCode == 200) + { + _logger.LogDebug("✓ Basic playback start forwarded to Jellyfin ({StatusCode})", statusCode); + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to send playback start, trying basic"); + // Fall back to basic playback start + var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", body, Request.Headers); + if (statusCode == 204 || statusCode == 200) + { + _logger.LogInformation("✓ Basic playback start forwarded to Jellyfin ({StatusCode})", statusCode); + } + } + + return NoContent(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to report playback start"); + return NoContent(); // Return success anyway to not break playback + } + } + + /// + /// Reports playback progress. Handles both local and external tracks. + /// + [HttpPost("Sessions/Playing/Progress")] + public async Task ReportPlaybackProgress() + { + try + { + Request.EnableBuffering(); + string body; + using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true)) + { + body = await reader.ReadToEndAsync(); + } + Request.Body.Position = 0; + + // Update session activity + var (deviceId, _, _, _) = ExtractDeviceInfo(Request.Headers); + if (!string.IsNullOrEmpty(deviceId)) + { + _sessionManager.UpdateActivity(deviceId); + } + + // Parse the body to check if it's an external track + var doc = JsonDocument.Parse(body); + string? itemId = null; + long? positionTicks = null; + + if (doc.RootElement.TryGetProperty("ItemId", out var itemIdProp)) + { + itemId = itemIdProp.GetString(); + } + if (doc.RootElement.TryGetProperty("PositionTicks", out var posProp)) + { + positionTicks = posProp.GetInt64(); + } + + // 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, 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(); + } + + // Log progress for local tracks (only every ~10 seconds to avoid spam) + if (positionTicks.HasValue) + { + var position = TimeSpan.FromTicks(positionTicks.Value); + // Only log at 10-second intervals + if (position.Seconds % 10 == 0 && position.Milliseconds < 500) + { + _logger.LogDebug("▶️ Progress: {Position:mm\\:ss} for item {ItemId}", position, itemId); + } + } + } + + // For local tracks, forward to Jellyfin + _logger.LogDebug("📤 Sending playback progress body: {Body}", body); + + var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing/Progress", body, Request.Headers); + + if (statusCode != 204 && statusCode != 200) + { + _logger.LogWarning("⚠️ Progress report returned {StatusCode} for item {ItemId}", statusCode, itemId); + } + + return NoContent(); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to report playback progress"); + return NoContent(); + } + } + + /// + /// Reports playback stopped. Handles both local and external tracks. + /// + [HttpPost("Sessions/Playing/Stopped")] + public async Task ReportPlaybackStopped() + { + try + { + Request.EnableBuffering(); + string body; + using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true)) + { + body = await reader.ReadToEndAsync(); + } + Request.Body.Position = 0; + + _logger.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)) + { + itemId = itemIdProp.GetString(); + } + + if (doc.RootElement.TryGetProperty("ItemName", out var itemNameProp)) + { + itemName = itemNameProp.GetString(); + } + + if (doc.RootElement.TryGetProperty("PositionTicks", out var posProp)) + { + positionTicks = posProp.GetInt64(); + } + + // 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); + + if (isExternal) + { + var position = positionTicks.HasValue + ? TimeSpan.FromTicks(positionTicks.Value).ToString(@"mm\:ss") + : "unknown"; + _logger.LogInformation("🎵 External track playback stopped: {Name} at {Position} ({Provider}/{ExternalId})", + itemName ?? "Unknown", position, provider, externalId); + + // 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(); + } + + _logger.LogInformation("🎵 Local track playback stopped: {Name} (ID: {ItemId})", + itemName ?? "Unknown", itemId); + } + + // For local tracks, forward to Jellyfin + _logger.LogDebug("Forwarding playback stop to Jellyfin..."); + + // Log the body being sent for debugging + _logger.LogInformation("📤 Sending playback stop body: {Body}", body); + + // Validate that body is not empty + if (string.IsNullOrWhiteSpace(body) || body == "{}") + { + _logger.LogWarning("⚠️ Playback stop body is empty, building minimal valid payload"); + // Build a minimal valid PlaybackStopInfo + var stopInfo = new + { + ItemId = itemId, + PositionTicks = positionTicks ?? 0 + }; + body = JsonSerializer.Serialize(stopInfo); + _logger.LogInformation("📤 Built playback stop body: {Body}", body); + } + + var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", body, Request.Headers); + + if (statusCode == 204 || statusCode == 200) + { + _logger.LogDebug("✓ Playback stop forwarded to Jellyfin ({StatusCode})", statusCode); + } + else if (statusCode == 401) + { + _logger.LogDebug("Playback stop returned 401 (token expired)"); + } + else + { + _logger.LogWarning("Playback stop forward failed with status {StatusCode}", statusCode); + } + + return NoContent(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to report playback stopped"); + return NoContent(); + } + } + + /// + /// Pings a playback session to keep it alive. + /// + [HttpPost("Sessions/Playing/Ping")] + public async Task PingPlaybackSession([FromQuery] string playSessionId) + { + try + { + _logger.LogDebug("Playback session ping: {SessionId}", playSessionId); + + // Forward to Jellyfin + var endpoint = $"Sessions/Playing/Ping?playSessionId={Uri.EscapeDataString(playSessionId)}"; + var (result, statusCode) = await _proxyService.PostJsonAsync(endpoint, "{}", Request.Headers); + return NoContent(); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to ping playback session"); + return NoContent(); + } + } + + /// + /// Catch-all for any other session-related requests. + /// + /// Catch-all proxy for any other session-related endpoints we haven't explicitly implemented. + /// This ensures all session management calls get proxied to Jellyfin. + /// Examples: GET /Sessions, POST /Sessions/Logout, etc. + /// + [HttpGet("Sessions")] + [HttpPost("Sessions")] + [HttpGet("Sessions/{**path}")] + [HttpPost("Sessions/{**path}")] + [HttpPut("Sessions/{**path}")] + [HttpDelete("Sessions/{**path}")] + public async Task ProxySessionRequest(string? path = null) + { + try + { + var method = Request.Method; + var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : ""; + var endpoint = string.IsNullOrEmpty(path) ? $"Sessions{queryString}" : $"Sessions/{path}{queryString}"; + + _logger.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}"))); + + // Read body if present + string body = "{}"; + if ((method == "POST" || method == "PUT") && Request.ContentLength > 0) + { + Request.EnableBuffering(); + using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true)) + { + body = await reader.ReadToEndAsync(); + } + Request.Body.Position = 0; + _logger.LogDebug("Session proxy body: {Body}", body); + } + + // Forward to Jellyfin + var (result, statusCode) = method switch + { + "GET" => await _proxyService.GetJsonAsync(endpoint, null, Request.Headers), + "POST" => await _proxyService.PostJsonAsync(endpoint, body, Request.Headers), + "PUT" => await _proxyService.PostJsonAsync(endpoint, body, Request.Headers), // Use POST for PUT + "DELETE" => await _proxyService.PostJsonAsync(endpoint, body, Request.Headers), // Use POST for DELETE + _ => (null, 405) + }; + + if (result != null) + { + _logger.LogDebug("✓ Session request proxied successfully ({StatusCode})", statusCode); + return new JsonResult(result.RootElement.Clone()); + } + + _logger.LogDebug("✓ Session request proxied ({StatusCode}, no body)", statusCode); + return StatusCode(statusCode); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to proxy session request: {Path}", path); + return StatusCode(500); + } + } + + #endregion // Session Management + + #endregion // Playback Session Reporting + +======= +>>>>>>> beta #region System & Proxy /// @@ -2815,7 +4967,13 @@ public class JellyfinController : ControllerBase { LocalAddress = Request.Host.ToString(), ServerName = serverName ?? "Allstarr", +<<<<<<< HEAD Version = version ?? "1.0.1", +||||||| f68706f + Version = version ?? "1.0.0", +======= + Version = version ?? AppVersion.Version, +>>>>>>> beta ProductName = "Allstarr (Jellyfin Proxy)", OperatingSystem = Environment.OSVersion.Platform.ToString(), Id = _settings.DeviceId, @@ -2841,6 +4999,13 @@ public class JellyfinController : ControllerBase [HttpPost("{**path}", Order = 100)] public async Task ProxyRequest(string path) { + // Block admin API routes - these should be handled by admin controllers, not proxied to Jellyfin + if (path.StartsWith("api/admin", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning("Admin route {Path} reached ProxyRequest - this should be handled by admin controllers", path); + return NotFound(new { error = "Admin endpoint not found" }); + } + // Log session-related requests prominently to debug missing capabilities call if (path.Contains("session", StringComparison.OrdinalIgnoreCase) || path.Contains("capabilit", StringComparison.OrdinalIgnoreCase)) @@ -2944,23 +5109,7 @@ public class JellyfinController : ControllerBase using var request = new HttpRequestMessage(HttpMethod.Get, url); // Forward auth headers from client - if (Request.Headers.TryGetValue("X-Emby-Authorization", out var embyAuth)) - { - request.Headers.TryAddWithoutValidation("X-Emby-Authorization", embyAuth.ToString()); - } - else if (Request.Headers.TryGetValue("Authorization", out var auth)) - { - var authValue = auth.ToString(); - if (authValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase) || - authValue.Contains("Token=", StringComparison.OrdinalIgnoreCase)) - { - request.Headers.TryAddWithoutValidation("X-Emby-Authorization", authValue); - } - else - { - request.Headers.TryAddWithoutValidation("Authorization", authValue); - } - } + AuthHeaderHelper.ForwardAuthHeaders(Request.Headers, request); var response = await _proxyService.HttpClient.SendAsync(request); @@ -3128,52 +5277,30 @@ public class JellyfinController : ControllerBase #endregion - #region Helpers - /// - /// Helper to handle proxy responses with proper status code handling. + /// Converts a JsonElement to a Dictionary while properly preserving nested objects and arrays. + /// This prevents metadata from being stripped when deserializing Jellyfin responses. /// - private IActionResult HandleProxyResponse(JsonDocument? result, int statusCode, object? fallbackValue = null) + private Dictionary JsonElementToDictionary(JsonElement element) { - if (result != null) + var dict = new Dictionary(); + + foreach (var property in element.EnumerateObject()) { - return new JsonResult(JsonSerializer.Deserialize(result.RootElement.GetRawText())); + dict[property.Name] = ConvertJsonElement(property.Value); } - // Handle error status codes - if (statusCode == 401) - { - return Unauthorized(); - } - else if (statusCode == 403) - { - return Forbid(); - } - else if (statusCode == 404) - { - return NotFound(); - } - else if (statusCode >= 400) - { - return StatusCode(statusCode); - } - - // Success with no body - return fallback or empty - if (fallbackValue != null) - { - return new JsonResult(fallbackValue); - } - - return NoContent(); + return dict; } /// - /// Updates ChildCount for Spotify playlists in the response to show total tracks (local + matched). + /// Recursively converts JsonElement values to proper C# types (Dictionary, List, primitives). /// - private async Task UpdateSpotifyPlaylistCounts(JsonDocument response) + private object? ConvertJsonElement(JsonElement element) { - try + switch (element.ValueKind) { +<<<<<<< HEAD if (!response.RootElement.TryGetProperty("Items", out var items)) { return response; @@ -3189,9 +5316,31 @@ public class JellyfinController : ControllerBase { var itemDict = JsonSerializer.Deserialize>(item.GetRawText()); if (itemDict == null) +||||||| f68706f + if (!response.RootElement.TryGetProperty("Items", out var items)) + { + return response; + } + + var itemsArray = items.EnumerateArray().ToList(); + var modified = false; + var updatedItems = new List>(); + + _logger.LogInformation("Checking {Count} items for Spotify playlists", itemsArray.Count); + + foreach (var item in itemsArray) + { + var itemDict = JsonSerializer.Deserialize>(item.GetRawText()); + if (itemDict == null) +======= + case JsonValueKind.Object: + var dict = new Dictionary(); + foreach (var property in element.EnumerateObject()) +>>>>>>> beta { - continue; + dict[property.Name] = ConvertJsonElement(property.Value); } +<<<<<<< HEAD // Check if this is a Spotify playlist if (item.TryGetProperty("Id", out var idProp)) @@ -3607,19 +5756,421 @@ public class JellyfinController : ControllerBase foreach (var item in items.EnumerateArray()) { jellyfinItems.Add(item); - - // Index by title+artist for matching - var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : ""; - var artist = ""; - if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0) +||||||| f68706f + + // Check if this is a Spotify playlist + if (item.TryGetProperty("Id", out var idProp)) { - artist = artistsEl[0].GetString() ?? ""; + var playlistId = idProp.GetString(); + _logger.LogDebug("Checking item with ID: {Id}", playlistId); + + if (!string.IsNullOrEmpty(playlistId) && _spotifySettings.IsSpotifyPlaylist(playlistId)) + { + _logger.LogInformation("Found Spotify playlist: {Id}", playlistId); + + // This is a Spotify playlist - get the actual track count + var playlistConfig = _spotifySettings.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) + var matchedTracksKey = $"spotify:matched:ordered:{playlistName}"; + var matchedTracks = await _cache.GetAsync>(matchedTracksKey); + + _logger.LogDebug("Cache lookup for {Key}: {Count} matched tracks", + matchedTracksKey, matchedTracks?.Count ?? 0); + + // Fallback to legacy cache format + if (matchedTracks == null || matchedTracks.Count == 0) + { + var legacyKey = $"spotify:matched:{playlistName}"; + var legacySongs = await _cache.GetAsync>(legacyKey); + if (legacySongs != null && legacySongs.Count > 0) + { + matchedTracks = legacySongs.Select((s, i) => new MatchedTrack + { + Position = i, + MatchedSong = s + }).ToList(); + _logger.LogDebug("Loaded {Count} tracks from legacy cache", matchedTracks.Count); + } + } + + // Try loading from file cache if Redis is empty + if (matchedTracks == null || matchedTracks.Count == 0) + { + var fileItems = await LoadPlaylistItemsFromFile(playlistName); + if (fileItems != null && fileItems.Count > 0) + { + _logger.LogInformation("💿 Loaded {Count} playlist items from file cache for count update", fileItems.Count); + // Use file cache count directly + itemDict["ChildCount"] = fileItems.Count; + modified = true; + } + } + + // Only fetch from Jellyfin if we didn't get count from file cache + if (!itemDict.ContainsKey("ChildCount") || + (itemDict["ChildCount"] is JsonElement childCountElement && childCountElement.GetInt32() == 0) || + (itemDict["ChildCount"] is int childCountInt && childCountInt == 0)) + { + // Get local tracks count from Jellyfin + var localTracksCount = 0; + try + { + // Include UserId parameter to avoid 401 Unauthorized + var userId = _settings.UserId; + var playlistItemsUrl = $"Playlists/{playlistId}/Items"; + var queryParams = new Dictionary(); + if (!string.IsNullOrEmpty(userId)) + { + queryParams["UserId"] = userId; + } + + var (localTracksResponse, _) = await _proxyService.GetJsonAsyncInternal( + playlistItemsUrl, + queryParams); + + if (localTracksResponse != null && + localTracksResponse.RootElement.TryGetProperty("Items", out var localItems)) + { + localTracksCount = localItems.GetArrayLength(); + _logger.LogInformation("Found {Count} total items in Jellyfin playlist {Name}", + localTracksCount, playlistName); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to get local tracks count for {Name}", playlistName); + } + + // Count external matched tracks (not local) + var externalMatchedCount = 0; + if (matchedTracks != null) + { + externalMatchedCount = matchedTracks.Count(t => t.MatchedSong != null && !t.MatchedSong.IsLocal); + } + + // Total available tracks = local tracks in Jellyfin + external matched tracks + // This represents what users will actually hear when playing the playlist + var totalAvailableCount = localTracksCount + externalMatchedCount; + + if (totalAvailableCount > 0) + { + // Update ChildCount to show actual available tracks + itemDict["ChildCount"] = totalAvailableCount; + modified = true; + _logger.LogInformation("✓ Updated ChildCount for Spotify playlist {Name} to {Total} ({Local} local + {External} external)", + playlistName, totalAvailableCount, localTracksCount, externalMatchedCount); + } + else + { + _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); + } + } } - else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl)) + + updatedItems.Add(itemDict); + } + + if (!modified) + { + _logger.LogInformation("No Spotify playlists found to update"); + return response; + } + + _logger.LogInformation("Modified {Count} Spotify playlists, rebuilding response", + updatedItems.Count(i => i.ContainsKey("ChildCount"))); + + // Rebuild the response with updated items + var responseDict = JsonSerializer.Deserialize>(response.RootElement.GetRawText()); + if (responseDict != null) + { + responseDict["Items"] = updatedItems; + var updatedJson = JsonSerializer.Serialize(responseDict); + return JsonDocument.Parse(updatedJson); + } + + return response; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to update Spotify playlist counts"); + return response; + } + } + + /// + /// Logs endpoint usage to a file for analysis. + /// Creates a CSV file with timestamp, method, path, and query string. + /// + private async Task LogEndpointUsageAsync(string path, string method) + { + try + { + var logDir = "/app/cache/endpoint-usage"; + Directory.CreateDirectory(logDir); + + var logFile = Path.Combine(logDir, "endpoints.csv"); + var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss"); + var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : ""; + + // Sanitize path and query for CSV (remove commas, quotes, newlines) + var sanitizedPath = path.Replace(",", ";").Replace("\"", "'").Replace("\n", " ").Replace("\r", " "); + var sanitizedQuery = queryString.Replace(",", ";").Replace("\"", "'").Replace("\n", " ").Replace("\r", " "); + + var logLine = $"{timestamp},{method},{sanitizedPath},{sanitizedQuery}\n"; + + // Append to file (thread-safe) + await System.IO.File.AppendAllTextAsync(logFile, logLine); + } + catch (Exception ex) + { + // Don't let logging failures break the request + _logger.LogDebug(ex, "Failed to log endpoint usage"); + } + } + + private static string[]? ParseItemTypes(string? includeItemTypes) + { + if (string.IsNullOrWhiteSpace(includeItemTypes)) + { + return null; + } + + return includeItemTypes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } + + private static string GetContentType(string filePath) + { + var extension = Path.GetExtension(filePath).ToLowerInvariant(); + return extension switch + { + ".mp3" => "audio/mpeg", + ".flac" => "audio/flac", + ".ogg" => "audio/ogg", + ".m4a" => "audio/mp4", + ".wav" => "audio/wav", + ".aac" => "audio/aac", + _ => "audio/mpeg" + }; + } + + /// + /// Scores search results based on fuzzy matching against the query. + /// Returns items with their relevance scores. + /// External results get a small boost to prioritize the larger catalog. + /// + private static List<(T Item, int Score)> ScoreSearchResults( + string query, + List items, + Func titleField, + Func artistField, + Func albumField, + bool isExternal = false) + { + return items.Select(item => + { + var title = titleField(item) ?? ""; + var artist = artistField(item) ?? ""; + var album = albumField(item) ?? ""; + + // Token-based fuzzy matching: split query and fields into words + var queryTokens = query.ToLower() + .Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries) + .ToList(); + + var fieldText = $"{title} {artist} {album}".ToLower(); + var fieldTokens = fieldText + .Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries) + .ToList(); + + if (queryTokens.Count == 0) return (item, 0); + + // Count how many query tokens match field tokens (with fuzzy tolerance) + var matchedTokens = 0; + foreach (var queryToken in queryTokens) + { + // Check if any field token matches this query token + var hasMatch = fieldTokens.Any(fieldToken => { - artist = albumArtistEl.GetString() ?? ""; - } + // Exact match or substring match + if (fieldToken.Contains(queryToken) || queryToken.Contains(fieldToken)) + return true; + + // Fuzzy match with Levenshtein distance + var similarity = FuzzyMatcher.CalculateSimilarity(queryToken, fieldToken); + return similarity >= 70; // 70% similarity threshold for individual words + }); + + if (hasMatch) matchedTokens++; + } + + // Score = percentage of query tokens that matched + var baseScore = (matchedTokens * 100) / queryTokens.Count; + + // Give external results a small boost (+5 points) to prioritize the larger catalog + var finalScore = isExternal ? Math.Min(100, baseScore + 5) : baseScore; + + return (item, finalScore); + }).ToList(); + } + + #endregion + + #region Spotify Playlist Injection + + /// + /// Gets tracks for a Spotify playlist by matching missing tracks against external providers + /// and merging with existing local tracks from Jellyfin. + /// + /// Supports two modes: + /// 1. Direct Spotify API (new): Uses SpotifyPlaylistFetcher for ordered tracks with ISRC matching + /// 2. Jellyfin Plugin (legacy): Uses MissingTrack data from Jellyfin Spotify Import plugin + /// + private async Task GetSpotifyPlaylistTracksAsync(string spotifyPlaylistName, string playlistId) + { + try + { + // Try ordered cache first (from direct Spotify API mode) + if (_spotifyApiSettings.Enabled && _spotifyPlaylistFetcher != null) + { + var orderedResult = await GetSpotifyPlaylistTracksOrderedAsync(spotifyPlaylistName, playlistId); + if (orderedResult != null) return orderedResult; + } + + // Fall back to legacy unordered mode + return await GetSpotifyPlaylistTracksLegacyAsync(spotifyPlaylistName, playlistId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting Spotify playlist tracks {PlaylistName}", spotifyPlaylistName); + return _responseBuilder.CreateError(500, "Failed to get Spotify playlist tracks"); + } + } + + /// + /// New mode: Gets playlist tracks with correct ordering using direct Spotify API data. + /// + private async Task GetSpotifyPlaylistTracksOrderedAsync(string spotifyPlaylistName, string playlistId) + { + // Check Redis cache first for fast serving + var cacheKey = $"spotify:playlist:items:{spotifyPlaylistName}"; + var cachedItems = await _cache.GetAsync>>(cacheKey); + + if (cachedItems != null && cachedItems.Count > 0) + { + _logger.LogInformation("✅ Loaded {Count} playlist items from Redis cache for {Playlist}", + cachedItems.Count, spotifyPlaylistName); + + // Log sample item to verify Spotify IDs are present + if (cachedItems.Count > 0 && cachedItems[0].ContainsKey("ProviderIds")) + { + var providerIds = cachedItems[0]["ProviderIds"] as Dictionary; + var hasSpotifyId = providerIds?.ContainsKey("Spotify") ?? false; + _logger.LogDebug("Sample cached item has Spotify ID: {HasSpotifyId}", hasSpotifyId); + } + + return new JsonResult(new + { + Items = cachedItems, + TotalRecordCount = cachedItems.Count, + StartIndex = 0 + }); + } + + // Check file cache as fallback + var fileItems = await LoadPlaylistItemsFromFile(spotifyPlaylistName); + if (fileItems != null && fileItems.Count > 0) + { + _logger.LogInformation("✅ Loaded {Count} playlist items from file cache for {Playlist}", + fileItems.Count, spotifyPlaylistName); + + // Restore to Redis cache + await _cache.SetAsync(cacheKey, fileItems, TimeSpan.FromHours(24)); + + return new JsonResult(new + { + Items = fileItems, + TotalRecordCount = fileItems.Count, + StartIndex = 0 + }); + } + + // Check for ordered matched tracks from SpotifyTrackMatchingService + var orderedCacheKey = $"spotify:matched:ordered:{spotifyPlaylistName}"; + var orderedTracks = await _cache.GetAsync>(orderedCacheKey); + + if (orderedTracks == null || orderedTracks.Count == 0) + { + _logger.LogDebug("No ordered matched tracks in cache for {Playlist}, checking if we can fetch", + spotifyPlaylistName); + return null; // Fall back to legacy mode + } + + _logger.LogDebug("Using {Count} ordered matched tracks for {Playlist}", + orderedTracks.Count, spotifyPlaylistName); + + // Get existing Jellyfin playlist items (RAW - don't convert!) + // CRITICAL: Must include UserId parameter or Jellyfin returns empty results + var userId = _settings.UserId; + if (string.IsNullOrEmpty(userId)) + { + _logger.LogError("❌ JELLYFIN_USER_ID is NOT configured! Cannot fetch playlist tracks. Set it in .env or admin UI."); + return null; // Fall back to legacy mode + } + + // Request MediaSources field to get bitrate info + var playlistItemsUrl = $"Playlists/{playlistId}/Items?UserId={userId}&Fields=MediaSources"; + + _logger.LogInformation("🔍 Fetching existing tracks from Jellyfin playlist {PlaylistId} with UserId {UserId}", + playlistId, userId); + + var (existingTracksResponse, statusCode) = await _proxyService.GetJsonAsync( + playlistItemsUrl, + null, + Request.Headers); + + if (statusCode != 200) + { + _logger.LogError("❌ Failed to fetch Jellyfin playlist items: HTTP {StatusCode}. Check JELLYFIN_USER_ID is correct.", statusCode); + return null; + } + + // Keep raw Jellyfin items - don't convert to Song objects! + var jellyfinItems = new List(); + var jellyfinItemsByName = new Dictionary(); + + if (existingTracksResponse != null && + existingTracksResponse.RootElement.TryGetProperty("Items", out var items)) + { + foreach (var item in items.EnumerateArray()) + { + jellyfinItems.Add(item); +======= + return dict; +>>>>>>> beta + case JsonValueKind.Array: + var list = new List(); + foreach (var item in element.EnumerateArray()) + { + list.Add(ConvertJsonElement(item)); + } + return list; + +<<<<<<< HEAD var key = $"{title}|{artist}".ToLowerInvariant(); if (!jellyfinItemsByName.ContainsKey(key)) { @@ -3660,19 +6211,65 @@ public class JellyfinController : ControllerBase foreach (var kvp in jellyfinItemsByName) { if (usedJellyfinItems.Contains(kvp.Key)) continue; - - var item = kvp.Value; - var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : ""; - var artist = ""; - if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0) +||||||| f68706f + var key = $"{title}|{artist}".ToLowerInvariant(); + if (!jellyfinItemsByName.ContainsKey(key)) { - artist = artistsEl[0].GetString() ?? ""; + jellyfinItemsByName[key] = item; } + } + + _logger.LogInformation("✅ Found {Count} existing LOCAL tracks in Jellyfin playlist", jellyfinItems.Count); + } + else + { + _logger.LogWarning("⚠️ No existing tracks found in Jellyfin playlist {PlaylistId} - playlist may be empty", playlistId); + } + + // Get the full playlist from Spotify to know the correct order + var spotifyTracks = await _spotifyPlaylistFetcher!.GetPlaylistTracksAsync(spotifyPlaylistName); + if (spotifyTracks.Count == 0) + { + _logger.LogWarning("Could not get Spotify playlist tracks for {Playlist}", spotifyPlaylistName); + return null; // Fall back to legacy + } + + // Build the final track list in correct Spotify order + var finalItems = new List>(); + var usedJellyfinItems = new HashSet(); + var localUsedCount = 0; + var externalUsedCount = 0; + + _logger.LogInformation("🔍 Building playlist in Spotify order with {SpotifyCount} positions...", spotifyTracks.Count); + + foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position)) + { + // Try to find matching Jellyfin item by fuzzy matching + JsonElement? matchedJellyfinItem = null; + string? matchedKey = null; + double bestScore = 0; + + foreach (var kvp in jellyfinItemsByName) + { + if (usedJellyfinItems.Contains(kvp.Key)) continue; +======= + case JsonValueKind.String: + return element.GetString(); +>>>>>>> beta - var titleScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.Title, title); - var artistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, artist); - var totalScore = (titleScore * 0.7) + (artistScore * 0.3); + case JsonValueKind.Number: + if (element.TryGetInt32(out var intValue)) + return intValue; + if (element.TryGetInt64(out var longValue)) + return longValue; + if (element.TryGetDouble(out var doubleValue)) + return doubleValue; + return element.GetDecimal(); + case JsonValueKind.True: + return true; + +<<<<<<< HEAD if (totalScore > bestScore && totalScore >= 70) { bestScore = totalScore; @@ -4088,7 +6685,425 @@ public class JellyfinController : ControllerBase try { if (!System.IO.File.Exists(_favoritesFilePath)) +||||||| f68706f + if (totalScore > bestScore && totalScore >= 70) + { + bestScore = totalScore; + matchedJellyfinItem = item; + matchedKey = kvp.Key; + } + } + + if (matchedJellyfinItem.HasValue && matchedKey != null) + { + // Use the raw Jellyfin item (preserves ALL metadata including MediaSources!) + var itemDict = JsonSerializer.Deserialize>(matchedJellyfinItem.Value.GetRawText()); + if (itemDict != null) + { + finalItems.Add(itemDict); + usedJellyfinItems.Add(matchedKey); + localUsedCount++; + _logger.LogDebug("✅ Position #{Pos}: '{Title}' → LOCAL (score: {Score:F1}%)", + spotifyTrack.Position, spotifyTrack.Title, bestScore); + } + } + else + { + // No local match - try to find external track + var matched = orderedTracks?.FirstOrDefault(t => t.SpotifyId == spotifyTrack.SpotifyId); + if (matched != null && matched.MatchedSong != null) + { + // Convert external song to Jellyfin item format + var externalItem = _responseBuilder.ConvertSongToJellyfinItem(matched.MatchedSong); + + // Add Spotify ID to ProviderIds so lyrics can work + if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId)) + { + if (!externalItem.ContainsKey("ProviderIds")) + { + externalItem["ProviderIds"] = new Dictionary(); + } + + var providerIds = externalItem["ProviderIds"] as Dictionary; + if (providerIds != null && !providerIds.ContainsKey("Spotify")) + { + providerIds["Spotify"] = spotifyTrack.SpotifyId; + } + } + + finalItems.Add(externalItem); + externalUsedCount++; + _logger.LogDebug("📥 Position #{Pos}: '{Title}' → EXTERNAL: {Provider}/{Id} (Spotify ID: {SpotifyId})", + spotifyTrack.Position, spotifyTrack.Title, + matched.MatchedSong.ExternalProvider, matched.MatchedSong.ExternalId, spotifyTrack.SpotifyId); + } + else + { + _logger.LogDebug("❌ Position #{Pos}: '{Title}' → NO MATCH", + spotifyTrack.Position, spotifyTrack.Title); + } + } + } + + _logger.LogInformation( + "🎵 Final playlist '{Playlist}': {Total} tracks ({Local} LOCAL + {External} EXTERNAL)", + spotifyPlaylistName, finalItems.Count, localUsedCount, externalUsedCount); + + // Save to file cache for persistence across restarts + await SavePlaylistItemsToFile(spotifyPlaylistName, finalItems); + + // Also cache in Redis for fast serving (reuse the same cache key from top of method) + await _cache.SetAsync(cacheKey, finalItems, TimeSpan.FromHours(24)); + + // Return raw Jellyfin response format + return new JsonResult(new + { + Items = finalItems, + TotalRecordCount = finalItems.Count, + StartIndex = 0 + }); + } + + /// + /// Legacy mode: Gets playlist tracks without ordering (from Jellyfin Spotify Import plugin). + /// + private async Task GetSpotifyPlaylistTracksLegacyAsync(string spotifyPlaylistName, string playlistId) + { + var cacheKey = $"spotify:matched:{spotifyPlaylistName}"; + var cachedTracks = await _cache.GetAsync>(cacheKey); + + if (cachedTracks != null && cachedTracks.Count > 0) + { + _logger.LogDebug("Returning {Count} cached matched tracks from Redis for {Playlist}", + cachedTracks.Count, spotifyPlaylistName); + return _responseBuilder.CreateItemsResponse(cachedTracks); + } + + // Try file cache if Redis is empty + if (cachedTracks == null || cachedTracks.Count == 0) + { + cachedTracks = await LoadMatchedTracksFromFile(spotifyPlaylistName); + if (cachedTracks != null && cachedTracks.Count > 0) + { + // Restore to Redis with 1 hour TTL + await _cache.SetAsync(cacheKey, cachedTracks, TimeSpan.FromHours(1)); + _logger.LogInformation("Loaded {Count} matched tracks from file cache for {Playlist}", + cachedTracks.Count, spotifyPlaylistName); + return _responseBuilder.CreateItemsResponse(cachedTracks); + } + } + + // Get existing Jellyfin playlist items (tracks the plugin already found) + // CRITICAL: Must include UserId parameter or Jellyfin returns empty results + var userId = _settings.UserId; + var playlistItemsUrl = $"Playlists/{playlistId}/Items"; + if (!string.IsNullOrEmpty(userId)) + { + playlistItemsUrl += $"?UserId={userId}"; + } + else + { + _logger.LogWarning("No UserId configured - may not be able to fetch existing playlist tracks"); + } + + var (existingTracksResponse, _) = await _proxyService.GetJsonAsync( + playlistItemsUrl, + null, + Request.Headers); + + var existingTracks = new List(); + var existingSpotifyIds = new HashSet(); + + if (existingTracksResponse != null && + existingTracksResponse.RootElement.TryGetProperty("Items", out var items)) + { + foreach (var item in items.EnumerateArray()) + { + var song = _modelMapper.ParseSong(item); + existingTracks.Add(song); + + // Track Spotify IDs to avoid duplicates + if (item.TryGetProperty("ProviderIds", out var providerIds) && + providerIds.TryGetProperty("Spotify", out var spotifyId)) + { + existingSpotifyIds.Add(spotifyId.GetString() ?? ""); + } + } + _logger.LogInformation("Found {Count} existing tracks in Jellyfin playlist", existingTracks.Count); + } + else + { + _logger.LogWarning("No existing tracks found in Jellyfin playlist - may need UserId parameter"); + } + + var missingTracksKey = $"spotify:missing:{spotifyPlaylistName}"; + var missingTracks = await _cache.GetAsync>(missingTracksKey); + + // Fallback to file cache if Redis is empty + if (missingTracks == null || missingTracks.Count == 0) + { + missingTracks = await LoadMissingTracksFromFile(spotifyPlaylistName); + + // If we loaded from file, restore to Redis with no expiration + if (missingTracks != null && missingTracks.Count > 0) + { + await _cache.SetAsync(missingTracksKey, missingTracks, TimeSpan.FromDays(365)); + _logger.LogInformation("Restored {Count} missing tracks from file cache for {Playlist} (no expiration)", + missingTracks.Count, spotifyPlaylistName); + } + } + + if (missingTracks == null || missingTracks.Count == 0) + { + _logger.LogInformation("No missing tracks found for {Playlist}, returning {Count} existing tracks", + spotifyPlaylistName, existingTracks.Count); + return _responseBuilder.CreateItemsResponse(existingTracks); + } + + _logger.LogInformation("Matching {Count} missing tracks for {Playlist}", + missingTracks.Count, spotifyPlaylistName); + + // Match missing tracks sequentially with rate limiting (excluding ones we already have locally) + var matchedBySpotifyId = new Dictionary(); + var tracksToMatch = missingTracks + .Where(track => !existingSpotifyIds.Contains(track.SpotifyId)) + .ToList(); + + foreach (var track in tracksToMatch) + { + try + { + // Search with just title and artist for better matching + var query = $"{track.Title} {track.PrimaryArtist}"; + var results = await _metadataService.SearchSongsAsync(query, limit: 5); + + if (results.Count > 0) + { + // Fuzzy match to find best result + // Check that ALL artists match (not just some) + var bestMatch = results + .Select(song => new + { + Song = song, + TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, song.Title), + ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors) + }) + .Select(x => new + { + x.Song, + x.TitleScore, + x.ArtistScore, + TotalScore = (x.TitleScore * 0.6) + (x.ArtistScore * 0.4) // Weight title more + }) + .OrderByDescending(x => x.TotalScore) + .FirstOrDefault(); + + // Only add if match is good enough (>60% combined score) + if (bestMatch != null && bestMatch.TotalScore >= 60) + { + _logger.LogDebug("Matched '{Title}' by {Artist} -> '{MatchTitle}' by {MatchArtist} (score: {Score:F1})", + track.Title, track.PrimaryArtist, + bestMatch.Song.Title, bestMatch.Song.Artist, + bestMatch.TotalScore); + matchedBySpotifyId[track.SpotifyId] = bestMatch.Song; + } + else + { + _logger.LogDebug("No good match for '{Title}' by {Artist} (best score: {Score:F1})", + track.Title, track.PrimaryArtist, bestMatch?.TotalScore ?? 0); + } + } + + // Rate limiting: small delay between searches to avoid overwhelming the service + await Task.Delay(100); // 100ms delay = max 10 searches/second + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}", + track.Title, track.PrimaryArtist); + } + } + + // Build final track list based on playlist configuration + // Local tracks position is configurable per-playlist + var playlistConfig = _spotifySettings.GetPlaylistByJellyfinId(playlistId); + var localTracksPosition = playlistConfig?.LocalTracksPosition ?? LocalTracksPosition.First; + + var finalTracks = new List(); + if (localTracksPosition == LocalTracksPosition.First) + { + // Local tracks first, external tracks at the end + finalTracks.AddRange(existingTracks); + finalTracks.AddRange(matchedBySpotifyId.Values); + } + else + { + // External tracks first, local tracks at the end + finalTracks.AddRange(matchedBySpotifyId.Values); + finalTracks.AddRange(existingTracks); + } + + await _cache.SetAsync(cacheKey, finalTracks, TimeSpan.FromHours(1)); + + // Also save to file cache for persistence across restarts + await SaveMatchedTracksToFile(spotifyPlaylistName, finalTracks); + + _logger.LogInformation("Final playlist: {Total} tracks ({Existing} local + {Matched} external, LocalTracksPosition: {Position})", + finalTracks.Count, + existingTracks.Count, + matchedBySpotifyId.Count, + localTracksPosition); + + return _responseBuilder.CreateItemsResponse(finalTracks); + } + + /// + /// Copies an external track to the kept folder when favorited. + /// + private async Task CopyExternalTrackToKeptAsync(string itemId, string provider, string externalId) + { + try + { + // Check if already favorited (persistent tracking) + if (await IsTrackFavoritedAsync(itemId)) + { + _logger.LogInformation("Track already favorited (persistent): {ItemId}", itemId); + return; + } + + // Get the song metadata first to build paths + var song = await _metadataService.GetSongAsync(provider, externalId); + if (song == null) + { + _logger.LogWarning("Could not find song metadata for {ItemId}", itemId); + return; + } + + // Build kept folder path: 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 + if (Directory.Exists(keptAlbumPath)) + { + var sanitizedTitle = PathHelper.SanitizeFileName(song.Title); + var existingFiles = Directory.GetFiles(keptAlbumPath, $"*{sanitizedTitle}*"); + if (existingFiles.Length > 0) + { + _logger.LogInformation("Track already exists in kept folder: {Path}", existingFiles[0]); + // Mark as favorited even if we didn't download it + await MarkTrackAsFavoritedAsync(itemId, song); + return; + } + } + + // Look for the track in cache folder first + var cacheBasePath = "/tmp/allstarr-cache"; + var cacheArtistPath = Path.Combine(cacheBasePath, PathHelper.SanitizeFileName(song.Artist)); + var cacheAlbumPath = Path.Combine(cacheArtistPath, PathHelper.SanitizeFileName(song.Album)); + + string? sourceFilePath = null; + + if (Directory.Exists(cacheAlbumPath)) + { + var sanitizedTitle = PathHelper.SanitizeFileName(song.Title); + var cacheFiles = Directory.GetFiles(cacheAlbumPath, $"*{sanitizedTitle}*"); + if (cacheFiles.Length > 0) + { + sourceFilePath = cacheFiles[0]; + _logger.LogInformation("Found track in cache folder: {Path}", sourceFilePath); + } + } + + // If not in cache, download it first + if (sourceFilePath == null) + { + _logger.LogInformation("Track not in cache, downloading: {ItemId}", itemId); + try + { + sourceFilePath = await _downloadService.DownloadSongAsync(provider, externalId); + } + 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(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(sourceFilePath, keptFilePath, overwrite: false); + _logger.LogInformation("✓ Copied track to kept folder: {Path}", keptFilePath); + + // Also copy cover art if it exists + var sourceCoverPath = Path.Combine(Path.GetDirectoryName(sourceFilePath)!, "cover.jpg"); + if (System.IO.File.Exists(sourceCoverPath)) + { + var keptCoverPath = Path.Combine(keptAlbumPath, "cover.jpg"); + if (!System.IO.File.Exists(keptCoverPath)) + { + 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) + { + _logger.LogError(ex, "Error copying external track {ItemId} to kept folder", itemId); + } + } + + /// + /// Removes an external track from the kept folder when unfavorited. + /// + 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"; + + /// + /// Checks if a track is already favorited (persistent across restarts). + /// + private async Task IsTrackFavoritedAsync(string itemId) + { + try + { + if (!System.IO.File.Exists(_favoritesFilePath)) +======= + case JsonValueKind.False: +>>>>>>> beta return false; +<<<<<<< HEAD var json = await System.IO.File.ReadAllTextAsync(_favoritesFilePath); var favorites = JsonSerializer.Deserialize>(json) ?? new(); @@ -4242,7 +7257,164 @@ public class JellyfinController : ControllerBase // Update pending deletions file var updatedJson = JsonSerializer.Serialize(remaining, new JsonSerializerOptions { WriteIndented = true }); await System.IO.File.WriteAllTextAsync(deletionFilePath, updatedJson); +||||||| f68706f + + var json = await System.IO.File.ReadAllTextAsync(_favoritesFilePath); + var favorites = JsonSerializer.Deserialize>(json) ?? new(); + + return favorites.ContainsKey(itemId); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to check favorite status for {ItemId}", itemId); + return false; + } + } + + /// + /// Marks a track as favorited in persistent storage. + /// + private async Task MarkTrackAsFavoritedAsync(string itemId, Song song) + { + try + { + var favorites = new Dictionary(); + + if (System.IO.File.Exists(_favoritesFilePath)) + { + var json = await System.IO.File.ReadAllTextAsync(_favoritesFilePath); + favorites = JsonSerializer.Deserialize>(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); + } + } + + /// + /// Removes a track from persistent favorites storage. + /// + 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>(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); + } + } + + /// + /// Marks a track for deletion (delayed deletion for safety). + /// + private async Task MarkTrackForDeletionAsync(string itemId) + { + try + { + var deletionFilePath = "/app/cache/pending_deletions.json"; + var pendingDeletions = new Dictionary(); + + if (System.IO.File.Exists(deletionFilePath)) + { + var json = await System.IO.File.ReadAllTextAsync(deletionFilePath); + pendingDeletions = JsonSerializer.Deserialize>(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); + } + } + + /// + /// Information about a favorited track for persistent storage. + /// + 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; } + } + + /// + /// Processes pending deletions (called by cleanup service). + /// + 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>(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); +======= +>>>>>>> beta +<<<<<<< HEAD _logger.LogDebug("Processed {Count} pending deletions", toDelete.Count); } } @@ -4284,7 +7456,54 @@ public class JellyfinController : ControllerBase if (Directory.GetFiles(keptAlbumPath).Length == 0 && Directory.GetDirectories(keptAlbumPath).Length == 0) { Directory.Delete(keptAlbumPath); +||||||| f68706f + _logger.LogInformation("Processed {Count} pending deletions", toDelete.Count); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing pending deletions"); + } + } + + /// + /// Actually deletes a track from the kept folder. + /// + 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); +======= + case JsonValueKind.Null: + return null; +>>>>>>> beta +<<<<<<< HEAD if (Directory.Exists(keptArtistPath) && Directory.GetFiles(keptArtistPath).Length == 0 && Directory.GetDirectories(keptArtistPath).Length == 0) @@ -4314,7 +7533,41 @@ public class JellyfinController : ControllerBase if (!System.IO.File.Exists(filePath)) { _logger.LogDebug("No file cache found for {Playlist} at {Path}", playlistName, filePath); +||||||| f68706f + 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 + + /// + /// Loads missing tracks from file cache as fallback when Redis is empty. + /// + private async Task?> LoadMissingTracksFromFile(string playlistName) + { + try + { + var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars())); + var filePath = Path.Combine("/app/cache/spotify", $"{safeName}_missing.json"); + + if (!System.IO.File.Exists(filePath)) + { + _logger.LogDebug("No file cache found for {Playlist} at {Path}", playlistName, filePath); +======= + default: +>>>>>>> beta return null; +<<<<<<< HEAD } // No expiration check - cache persists until next Jellyfin job generates new file @@ -4328,7 +7581,24 @@ public class JellyfinController : ControllerBase tracks?.Count ?? 0, playlistName, fileAge.TotalHours); return tracks; +||||||| f68706f + } + + // No expiration check - cache persists until next Jellyfin job generates new file + var fileAge = DateTime.UtcNow - System.IO.File.GetLastWriteTimeUtc(filePath); + _logger.LogDebug("File cache for {Playlist} age: {Age:F1}h (no expiration)", playlistName, fileAge.TotalHours); + + var json = await System.IO.File.ReadAllTextAsync(filePath); + var tracks = JsonSerializer.Deserialize>(json); + + _logger.LogInformation("Loaded {Count} missing tracks from file cache for {Playlist} (age: {Age:F1}h)", + tracks?.Count ?? 0, playlistName, fileAge.TotalHours); + + return tracks; +======= +>>>>>>> beta } +<<<<<<< HEAD catch (Exception ex) { _logger.LogError(ex, "Failed to load missing tracks from file for {Playlist}", playlistName); @@ -4568,6 +7838,200 @@ public class JellyfinController : ControllerBase avgScore *= 0.7; // 30% penalty for poor individual match return avgScore; +||||||| f68706f + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load missing tracks from file for {Playlist}", playlistName); + return null; + } + } + + /// + /// Loads matched/combined tracks from file cache as fallback when Redis is empty. + /// + private async Task?> LoadMatchedTracksFromFile(string playlistName) + { + try + { + var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars())); + var filePath = Path.Combine("/app/cache/spotify", $"{safeName}_matched.json"); + + if (!System.IO.File.Exists(filePath)) + { + _logger.LogDebug("No matched tracks file cache found for {Playlist} at {Path}", playlistName, filePath); + return null; + } + + var fileAge = DateTime.UtcNow - System.IO.File.GetLastWriteTimeUtc(filePath); + + // Check if cache is too old (more than 24 hours) + if (fileAge.TotalHours > 24) + { + _logger.LogInformation("Matched tracks file cache for {Playlist} is too old ({Age:F1}h), will rebuild", + playlistName, fileAge.TotalHours); + return null; + } + + _logger.LogDebug("Matched tracks file cache for {Playlist} age: {Age:F1}h", playlistName, fileAge.TotalHours); + + var json = await System.IO.File.ReadAllTextAsync(filePath); + var tracks = JsonSerializer.Deserialize>(json); + + _logger.LogInformation("Loaded {Count} matched tracks from file cache for {Playlist} (age: {Age:F1}h)", + tracks?.Count ?? 0, playlistName, fileAge.TotalHours); + + return tracks; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load matched tracks from file for {Playlist}", playlistName); + return null; + } + } + + /// + /// Saves matched/combined tracks to file cache for persistence across restarts. + /// + private async Task SaveMatchedTracksToFile(string playlistName, List tracks) + { + try + { + var cacheDir = "/app/cache/spotify"; + Directory.CreateDirectory(cacheDir); + + var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars())); + var filePath = Path.Combine(cacheDir, $"{safeName}_matched.json"); + + var json = JsonSerializer.Serialize(tracks, new JsonSerializerOptions { WriteIndented = true }); + await System.IO.File.WriteAllTextAsync(filePath, json); + + _logger.LogInformation("Saved {Count} matched tracks to file cache for {Playlist}", + tracks.Count, playlistName); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save matched tracks to file for {Playlist}", playlistName); + } + } + + /// + /// Saves playlist items (raw Jellyfin JSON) to file cache for persistence across restarts. + /// + private async Task SavePlaylistItemsToFile(string playlistName, List> items) + { + try + { + var cacheDir = "/app/cache/spotify"; + Directory.CreateDirectory(cacheDir); + + var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars())); + var filePath = Path.Combine(cacheDir, $"{safeName}_items.json"); + + var json = JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true }); + await System.IO.File.WriteAllTextAsync(filePath, json); + + _logger.LogInformation("💾 Saved {Count} playlist items to file cache for {Playlist}", + items.Count, playlistName); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save playlist items to file for {Playlist}", playlistName); + } + } + + /// + /// Loads playlist items (raw Jellyfin JSON) from file cache. + /// + private async Task>?> LoadPlaylistItemsFromFile(string playlistName) + { + try + { + var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars())); + var filePath = Path.Combine("/app/cache/spotify", $"{safeName}_items.json"); + + if (!System.IO.File.Exists(filePath)) + { + _logger.LogDebug("No playlist items file cache found for {Playlist} at {Path}", playlistName, filePath); + return null; + } + + var fileAge = DateTime.UtcNow - System.IO.File.GetLastWriteTimeUtc(filePath); + + // Check if cache is too old (more than 24 hours) + if (fileAge.TotalHours > 24) + { + _logger.LogInformation("Playlist items file cache for {Playlist} is too old ({Age:F1}h), will rebuild", + playlistName, fileAge.TotalHours); + return null; + } + + _logger.LogDebug("Playlist items file cache for {Playlist} age: {Age:F1}h", playlistName, fileAge.TotalHours); + + var json = await System.IO.File.ReadAllTextAsync(filePath); + var items = JsonSerializer.Deserialize>>(json); + + _logger.LogInformation("💿 Loaded {Count} playlist items from file cache for {Playlist} (age: {Age:F1}h)", + items?.Count ?? 0, playlistName, fileAge.TotalHours); + + return items; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load playlist items from file for {Playlist}", playlistName); + return null; + } + } + + #endregion + + /// + /// Calculates artist match score ensuring ALL artists are present. + /// Penalizes if artist counts don't match or if any artist is missing. + /// + private static double CalculateArtistMatchScore(List spotifyArtists, string songMainArtist, List songContributors) + { + if (spotifyArtists.Count == 0 || string.IsNullOrEmpty(songMainArtist)) + return 0; + + // Build list of all song artists (main + contributors) + var allSongArtists = new List { 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(); + foreach (var spotifyArtist in spotifyArtists) + { + var bestMatch = allSongArtists.Max(songArtist => + FuzzyMatcher.CalculateSimilarity(spotifyArtist, songArtist)); + spotifyScores.Add(bestMatch); + } + + // Check that each song artist has a good match in Spotify artists + var songScores = new List(); + foreach (var songArtist in allSongArtists) + { + var bestMatch = spotifyArtists.Max(spotifyArtist => + FuzzyMatcher.CalculateSimilarity(songArtist, spotifyArtist)); + songScores.Add(bestMatch); + } + + // Average all scores - this ensures ALL artists must match well + var allScores = spotifyScores.Concat(songScores); + var avgScore = allScores.Average(); + + // Penalize if any individual artist match is poor (< 70) + var minScore = allScores.Min(); + if (minScore < 70) + avgScore *= 0.7; // 30% penalty for poor individual match + + return avgScore; +======= +>>>>>>> beta } /// @@ -4646,7 +8110,7 @@ public class JellyfinController : ControllerBase // Search through each playlist's matched tracks cache foreach (var playlist in playlists) { - var cacheKey = $"spotify:matched:ordered:{playlist.Name}"; + var cacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name); var matchedTracks = await _cache.GetAsync>(cacheKey); if (matchedTracks == null || matchedTracks.Count == 0) diff --git a/allstarr/Controllers/LyricsController.cs b/allstarr/Controllers/LyricsController.cs new file mode 100644 index 0000000..f8bfb03 --- /dev/null +++ b/allstarr/Controllers/LyricsController.cs @@ -0,0 +1,258 @@ +using Microsoft.AspNetCore.Mvc; +using allstarr.Models.Admin; +using allstarr.Services.Common; +using allstarr.Services.Admin; +using allstarr.Services.Spotify; +using allstarr.Filters; +using System.Text.Json; + +namespace allstarr.Controllers; + +[ApiController] +[Route("api/admin")] +[ServiceFilter(typeof(AdminPortFilter))] +public class LyricsController : ControllerBase +{ + private readonly ILogger _logger; + private readonly RedisCacheService _cache; + private readonly AdminHelperService _adminHelper; + private readonly SpotifyPlaylistFetcher _playlistFetcher; + private readonly IServiceProvider _serviceProvider; + + public LyricsController( + ILogger logger, + RedisCacheService cache, + AdminHelperService adminHelper, + SpotifyPlaylistFetcher playlistFetcher, + IServiceProvider serviceProvider) + { + _logger = logger; + _cache = cache; + _adminHelper = adminHelper; + _playlistFetcher = playlistFetcher; + _serviceProvider = serviceProvider; + } + + + /// + /// Save manual lyrics ID mapping for a track + /// + [HttpPost("lyrics/map")] + public async Task 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 _adminHelper.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(); + 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.LogDebug("✓ 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.LogError(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" }); + } + } + + /// + /// Get manual lyrics mappings + /// + [HttpGet("lyrics/mappings")] + public async Task GetLyricsMappings() + { + try + { + var mappingsFile = "/app/cache/lyrics_mappings.json"; + + if (!System.IO.File.Exists(mappingsFile)) + { + return Ok(new { mappings = new List() }); + } + + var json = await System.IO.File.ReadAllTextAsync(mappingsFile); + var mappings = JsonSerializer.Deserialize>(json) ?? new List(); + + 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" }); + } + } + + + + /// + /// Test Spotify lyrics API by fetching lyrics for a specific Spotify track ID + /// Example: GET /api/admin/lyrics/spotify/test?trackId=3yII7UwgLF6K5zW3xad3MP + /// + [HttpGet("lyrics/spotify/test")] + public async Task TestSpotifyLyrics([FromQuery] string trackId) + { + if (string.IsNullOrEmpty(trackId)) + { + return BadRequest(new { error = "trackId parameter is required" }); + } + + try + { + var spotifyLyricsService = _serviceProvider.GetService(); + + 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}" }); + } + } + + /// + /// Prefetch lyrics for a specific playlist + /// + [HttpPost("playlists/{name}/prefetch-lyrics")] + public async Task PrefetchPlaylistLyrics(string name) + { + var decodedName = Uri.UnescapeDataString(name); + + try + { + var lyricsPrefetchService = _serviceProvider.GetService(); + + 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}" }); + } + } + + + + /// + /// Invalidates the cached playlist summary so it will be regenerated on next request + /// +} diff --git a/allstarr/Controllers/MappingController.cs b/allstarr/Controllers/MappingController.cs new file mode 100644 index 0000000..b03feea --- /dev/null +++ b/allstarr/Controllers/MappingController.cs @@ -0,0 +1,161 @@ +using Microsoft.AspNetCore.Mvc; +using allstarr.Models.Admin; +using allstarr.Services.Common; +using allstarr.Services.Admin; +using allstarr.Filters; +using System.Text.Json; + +namespace allstarr.Controllers; + +[ApiController] +[Route("api/admin")] +[ServiceFilter(typeof(AdminPortFilter))] +public class MappingController : ControllerBase +{ + private readonly ILogger _logger; + private readonly RedisCacheService _cache; + private readonly AdminHelperService _adminHelper; + + public MappingController( + ILogger logger, + RedisCacheService cache, + AdminHelperService adminHelper) + { + _logger = logger; + _cache = cache; + _adminHelper = adminHelper; + } + + + /// + /// Save lyrics mapping to file for persistence across restarts. + /// Lyrics mappings NEVER expire - they are permanent user decisions. + /// + [HttpGet("mappings/tracks")] + public async Task GetAllTrackMappings() + { + try + { + var mappingsDir = "/app/cache/mappings"; + var allMappings = new List(); + + 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>(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.LogError(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" }); + } + } + + /// + /// Delete a manual track mapping + /// + [HttpDelete("mappings/tracks")] + public async Task 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 = AdminHelperService.SanitizeFileName(playlist); + 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>(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" }); + } + } + + /// + /// Test Spotify lyrics API by fetching lyrics for a specific Spotify track ID + /// Example: GET /api/admin/lyrics/spotify/test?trackId=3yII7UwgLF6K5zW3xad3MP + /// +} diff --git a/allstarr/Controllers/PlaylistController.cs b/allstarr/Controllers/PlaylistController.cs new file mode 100644 index 0000000..5828757 --- /dev/null +++ b/allstarr/Controllers/PlaylistController.cs @@ -0,0 +1,1612 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using allstarr.Models.Settings; +using allstarr.Models.Spotify; +using allstarr.Models.Admin; +using allstarr.Services.Spotify; +using allstarr.Services.Common; +using allstarr.Services.Admin; +using allstarr.Services; +using allstarr.Filters; +using System.Text.Json; + +namespace allstarr.Controllers; + +[ApiController] +[Route("api/admin")] +[ServiceFilter(typeof(AdminPortFilter))] +public class PlaylistController : ControllerBase +{ + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + private readonly JellyfinSettings _jellyfinSettings; + private readonly SpotifyImportSettings _spotifyImportSettings; + private readonly SpotifyPlaylistFetcher _playlistFetcher; + private readonly SpotifyTrackMatchingService? _matchingService; + private readonly SpotifyMappingService _mappingService; + private readonly RedisCacheService _cache; + private readonly HttpClient _jellyfinHttpClient; + private readonly AdminHelperService _helperService; + private readonly IServiceProvider _serviceProvider; + private const string CacheDirectory = "/app/cache/spotify"; + + public PlaylistController( + ILogger logger, + IConfiguration configuration, + IOptions jellyfinSettings, + IOptions spotifyImportSettings, + SpotifyPlaylistFetcher playlistFetcher, + SpotifyMappingService mappingService, + RedisCacheService cache, + IHttpClientFactory httpClientFactory, + AdminHelperService helperService, + IServiceProvider serviceProvider, + SpotifyTrackMatchingService? matchingService = null) + { + _logger = logger; + _configuration = configuration; + _jellyfinSettings = jellyfinSettings.Value; + _spotifyImportSettings = spotifyImportSettings.Value; + _playlistFetcher = playlistFetcher; + _matchingService = matchingService; + _mappingService = mappingService; + _cache = cache; + _jellyfinHttpClient = httpClientFactory.CreateClient(); + _helperService = helperService; + _serviceProvider = serviceProvider; + } + + [HttpGet("playlists")] + public async Task 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>(cachedJson); + _logger.LogDebug("📦 Returning cached playlist summary (age: {Age:F1}m)", age.TotalMinutes); + return Ok(cachedData); + } + else + { + _logger.LogWarning("🔄 Cache expired (age: {Age:F1}m), refreshing...", age.TotalMinutes); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to read cached playlist summary"); + } + } + else if (refresh) + { + _logger.LogDebug("🔄 Force refresh requested for playlist summary"); + } + + var playlists = new List(); + + // Read playlists directly from .env file to get the latest configuration + // (IOptions is cached and doesn't reload after .env changes) + var configuredPlaylists = await _helperService.ReadPlaylistsFromEnvFileAsync(); + + foreach (var config in configuredPlaylists) + { + var playlistInfo = new Dictionary + { + ["name"] = config.Name, + ["id"] = config.Id, + ["jellyfinId"] = config.JellyfinId, + ["localTracksPosition"] = config.LocalTracksPosition.ToString(), + ["syncSchedule"] = config.SyncSchedule ?? "0 8 * * *", + ["trackCount"] = 0, + ["localTracks"] = 0, + ["externalTracks"] = 0, + ["lastFetched"] = null as DateTime?, + ["cacheAge"] = null as string + }; + + // Get Spotify playlist track count from cache OR fetch it fresh + var cacheFilePath = Path.Combine(CacheDirectory, $"{AdminHelperService.SanitizeFileName(config.Name)}_spotify.json"); + int spotifyTrackCount = 0; + + if (System.IO.File.Exists(cacheFilePath)) + { + try + { + var json = await System.IO.File.ReadAllTextAsync(cacheFilePath); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + if (root.TryGetProperty("tracks", out var tracks)) + { + spotifyTrackCount = tracks.GetArrayLength(); + playlistInfo["trackCount"] = spotifyTrackCount; + } + + if (root.TryGetProperty("fetchedAt", out var fetchedAt)) + { + var fetchedTime = fetchedAt.GetDateTime(); + playlistInfo["lastFetched"] = fetchedTime; + var age = DateTime.UtcNow - fetchedTime; + playlistInfo["cacheAge"] = age.TotalHours < 1 + ? $"{age.TotalMinutes:F0}m" + : $"{age.TotalHours:F1}h"; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to read cache for playlist {Name}", config.Name); + } + } + + // If cache doesn't exist or failed to read, fetch track count from Spotify API + if (spotifyTrackCount == 0) + { + try + { + var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name); + spotifyTrackCount = spotifyTracks.Count; + playlistInfo["trackCount"] = spotifyTrackCount; + _logger.LogDebug("Fetched {Count} tracks from Spotify for playlist {Name}", spotifyTrackCount, config.Name); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to fetch Spotify track count for playlist {Name}", config.Name); + } + } + + // Calculate stats from playlist items cache (source of truth) + // This is fast and always accurate + if (spotifyTrackCount > 0) + { + try + { + // Try to use the pre-built playlist cache + var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(config.Name); + + List>? cachedPlaylistItems = null; + try + { + cachedPlaylistItems = await _cache.GetAsync>>(playlistItemsCacheKey); + } + catch (Exception cacheEx) + { + _logger.LogWarning(cacheEx, "Failed to deserialize playlist cache for {Playlist}", config.Name); + } + + if (cachedPlaylistItems != null && cachedPlaylistItems.Count > 0) + { + // Calculate stats from the actual playlist cache + var localCount = 0; + var externalCount = 0; + + foreach (var item in cachedPlaylistItems) + { + if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null) + { + Dictionary? providerIds = null; + + if (providerIdsObj is Dictionary dict) + { + providerIds = dict; + } + else if (providerIdsObj is JsonElement jsonEl && jsonEl.ValueKind == JsonValueKind.Object) + { + providerIds = new Dictionary(); + foreach (var prop in jsonEl.EnumerateObject()) + { + providerIds[prop.Name] = prop.Value.GetString() ?? ""; + } + } + + if (providerIds != null) + { + // Check if it's external (has squidwtf, deezer, qobuz, or tidal key) + var isExternal = providerIds.ContainsKey("squidwtf") || + providerIds.ContainsKey("deezer") || + providerIds.ContainsKey("qobuz") || + providerIds.ContainsKey("tidal"); + + if (isExternal) + { + externalCount++; + } + else + { + localCount++; + } + } + } + } + + var missingCount = spotifyTrackCount - (localCount + externalCount); + + playlistInfo["localTracks"] = localCount; + playlistInfo["externalMatched"] = externalCount; + playlistInfo["externalMissing"] = missingCount; + playlistInfo["externalTotal"] = externalCount + missingCount; + playlistInfo["totalInJellyfin"] = localCount + externalCount; + playlistInfo["totalPlayable"] = localCount + externalCount; + + _logger.LogDebug("📊 Calculated stats from playlist cache for {Name}: {Local} local, {External} external, {Missing} missing", + config.Name, localCount, externalCount, missingCount); + } + else + { + // No playlist cache - calculate from global mappings as fallback + var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name); + var localCount = 0; + var externalCount = 0; + var missingCount = 0; + + foreach (var track in spotifyTracks) + { + var mapping = await _mappingService.GetMappingAsync(track.SpotifyId); + + if (mapping != null) + { + if (mapping.TargetType == "local") + { + localCount++; + } + else if (mapping.TargetType == "external") + { + externalCount++; + } + } + else + { + missingCount++; + } + } + + playlistInfo["localTracks"] = localCount; + playlistInfo["externalMatched"] = externalCount; + playlistInfo["externalMissing"] = missingCount; + playlistInfo["externalTotal"] = externalCount + missingCount; + playlistInfo["totalInJellyfin"] = localCount + externalCount; + playlistInfo["totalPlayable"] = localCount + externalCount; + + _logger.LogDebug("📊 Calculated stats from global mappings for {Name}: {Local} local, {External} external, {Missing} missing", + config.Name, localCount, externalCount, missingCount); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to calculate playlist stats for {Name}", config.Name); + } + } + + // LEGACY FALLBACK: Only used if global mappings fail + // This is the old slow path - kept for backwards compatibility + if (!string.IsNullOrEmpty(config.JellyfinId) && + (int)(playlistInfo["totalPlayable"] ?? 0) == 0 && + spotifyTrackCount > 0) + { + try + { + // Jellyfin requires UserId parameter to fetch playlist items + var userId = _jellyfinSettings.UserId; + + // If no user configured, try to get the first user + if (string.IsNullOrEmpty(userId)) + { + var usersRequest = _helperService.CreateJellyfinRequest(HttpMethod.Get, $"{_jellyfinSettings.Url}/Users"); + var usersResponse = await _jellyfinHttpClient.SendAsync(usersRequest); + + if (usersResponse.IsSuccessStatusCode) + { + var usersJson = await usersResponse.Content.ReadAsStringAsync(); + using var usersDoc = JsonDocument.Parse(usersJson); + if (usersDoc.RootElement.GetArrayLength() > 0) + { + userId = usersDoc.RootElement[0].GetProperty("Id").GetString(); + } + } + } + + if (string.IsNullOrEmpty(userId)) + { + _logger.LogWarning("No user ID available to fetch playlist items for {Name}", config.Name); + } + else + { + var url = $"{_jellyfinSettings.Url}/Playlists/{config.JellyfinId}/Items?UserId={userId}&Fields=Path"; + var request = _helperService.CreateJellyfinRequest(HttpMethod.Get, url); + + _logger.LogDebug("Fetching Jellyfin playlist items for {Name} from {Url}", config.Name, url); + + var response = await _jellyfinHttpClient.SendAsync(request); + if (response.IsSuccessStatusCode) + { + var jellyfinJson = await response.Content.ReadAsStringAsync(); + using var jellyfinDoc = JsonDocument.Parse(jellyfinJson); + + if (jellyfinDoc.RootElement.TryGetProperty("Items", out var items)) + { + // Get Spotify tracks to match against + var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name); + + // Try to use the pre-built playlist cache first (includes manual mappings!) + var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(config.Name); + + List>? cachedPlaylistItems = null; + try + { + cachedPlaylistItems = await _cache.GetAsync>>(playlistItemsCacheKey); + } + catch (Exception cacheEx) + { + _logger.LogWarning(cacheEx, "Failed to deserialize playlist cache for {Playlist}", config.Name); + } + + _logger.LogDebug("Checking cache for {Playlist}: {CacheKey}, Found: {Found}, Count: {Count}", + config.Name, playlistItemsCacheKey, cachedPlaylistItems != null, cachedPlaylistItems?.Count ?? 0); + + if (cachedPlaylistItems != null && cachedPlaylistItems.Count > 0) + { + // Use the pre-built cache which respects manual mappings + // spotifyTracks already fetched above - reuse it + var localCount = 0; + var externalCount = 0; + var missingCount = 0; + + // Count tracks by checking provider keys + foreach (var item in cachedPlaylistItems) + { + if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null) + { + Dictionary? providerIds = null; + + if (providerIdsObj is Dictionary dict) + { + providerIds = dict; + } + else if (providerIdsObj is JsonElement jsonEl && jsonEl.ValueKind == JsonValueKind.Object) + { + providerIds = new Dictionary(); + foreach (var prop in jsonEl.EnumerateObject()) + { + providerIds[prop.Name] = prop.Value.GetString() ?? ""; + } + } + + if (providerIds != null) + { + // Check if it's external (has squidwtf, deezer, qobuz, or tidal key) + var hasSquidWTF = providerIds.ContainsKey("squidwtf"); + var hasDeezer = providerIds.ContainsKey("deezer"); + var hasQobuz = providerIds.ContainsKey("qobuz"); + var hasTidal = providerIds.ContainsKey("tidal"); + var isExternal = hasSquidWTF || hasDeezer || hasQobuz || hasTidal; + + if (isExternal) + { + externalCount++; + } + else + { + // Local track (has Jellyfin, MusicBrainz, or other metadata keys) + localCount++; + } + } + } + } + + // Calculate missing tracks: total Spotify tracks minus matched tracks + // The playlist cache only contains successfully matched tracks (local + external) + // So missing = total - (local + external) + missingCount = spotifyTracks.Count - (localCount + externalCount); + + playlistInfo["localTracks"] = localCount; + playlistInfo["externalMatched"] = externalCount; + playlistInfo["externalMissing"] = missingCount; + playlistInfo["externalTotal"] = externalCount + missingCount; + playlistInfo["totalInJellyfin"] = localCount + externalCount; // Tracks actually in the Jellyfin playlist + playlistInfo["totalPlayable"] = localCount + externalCount; // Total tracks that will be served + + _logger.LogDebug("Playlist {Name} (from cache): {Total} Spotify tracks, {Local} local, {ExtMatched} external matched, {ExtMissing} external missing, {Playable} total playable", + config.Name, spotifyTracks.Count, localCount, externalCount, missingCount, localCount + externalCount); + } + else + { + // Fallback: Build list of local tracks from Jellyfin (match by name only) + var localTracks = new List<(string Title, string Artist)>(); + foreach (var item in items.EnumerateArray()) + { + var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : ""; + var artist = ""; + + if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0) + { + artist = artistsEl[0].GetString() ?? ""; + } + else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl)) + { + artist = albumArtistEl.GetString() ?? ""; + } + + if (!string.IsNullOrEmpty(title)) + { + localTracks.Add((title, artist)); + } + } + + // Get matched external tracks cache once + var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(config.Name); + var matchedTracks = await _cache.GetAsync>(matchedTracksKey); + var matchedSpotifyIds = new HashSet( + matchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty() + ); + + var localCount = 0; + var externalMatchedCount = 0; + var externalMissingCount = 0; + + // Match each Spotify track to determine if it's local, external, or missing + foreach (var track in spotifyTracks) + { + var isLocal = false; + var hasExternalMapping = false; + + // FIRST: Check for manual Jellyfin mapping + var manualMappingKey = $"spotify:manual-map:{config.Name}:{track.SpotifyId}"; + var manualJellyfinId = await _cache.GetAsync(manualMappingKey); + + if (!string.IsNullOrEmpty(manualJellyfinId)) + { + // Manual Jellyfin mapping exists - this track is definitely local + isLocal = true; + } + else + { + // Check for external manual mapping + var externalMappingKey = $"spotify:external-map:{config.Name}:{track.SpotifyId}"; + var externalMappingJson = await _cache.GetStringAsync(externalMappingKey); + + if (!string.IsNullOrEmpty(externalMappingJson)) + { + // External manual mapping exists + hasExternalMapping = true; + } + else if (localTracks.Count > 0) + { + // SECOND: No manual mapping, try fuzzy matching with local tracks + var bestMatch = localTracks + .Select(local => new + { + Local = local, + TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, local.Title), + ArtistScore = FuzzyMatcher.CalculateSimilarity(track.PrimaryArtist, local.Artist) + }) + .Select(x => new + { + x.Local, + x.TitleScore, + x.ArtistScore, + TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3) + }) + .OrderByDescending(x => x.TotalScore) + .FirstOrDefault(); + + // Use 70% threshold (same as playback matching) + if (bestMatch != null && bestMatch.TotalScore >= 70) + { + isLocal = true; + } + } + } + + if (isLocal) + { + localCount++; + } + else + { + // Check if external track is matched (either manual mapping or auto-matched) + if (hasExternalMapping || matchedSpotifyIds.Contains(track.SpotifyId)) + { + externalMatchedCount++; + } + else + { + externalMissingCount++; + } + } + } + + playlistInfo["localTracks"] = localCount; + playlistInfo["externalMatched"] = externalMatchedCount; + playlistInfo["externalMissing"] = externalMissingCount; + playlistInfo["externalTotal"] = externalMatchedCount + externalMissingCount; + playlistInfo["totalInJellyfin"] = localCount + externalMatchedCount; + playlistInfo["totalPlayable"] = localCount + externalMatchedCount; // Total tracks that will be served + + _logger.LogWarning("Playlist {Name} (fallback): {Total} Spotify tracks, {Local} local, {ExtMatched} external matched, {ExtMissing} external missing, {Playable} total playable", + config.Name, spotifyTracks.Count, localCount, externalMatchedCount, externalMissingCount, localCount + externalMatchedCount); + } + } + else + { + _logger.LogWarning("No Items property in Jellyfin response for {Name}", config.Name); + } + } + else + { + _logger.LogError("Failed to get Jellyfin playlist {Name}: {StatusCode}", + config.Name, response.StatusCode); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get Jellyfin playlist tracks for {Name}", config.Name); + } + } + else + { + // This else block is reached when: + // 1. JellyfinId is empty, OR + // 2. totalPlayable > 0 (modern path already worked), OR + // 3. spotifyTrackCount == 0 + // Only log if JellyfinId is actually missing + if (string.IsNullOrEmpty(config.JellyfinId)) + { + _logger.LogInformation("Playlist {Name} has no JellyfinId configured", config.Name); + } + } + + playlists.Add(playlistInfo); + } + + // Save to file cache + try + { + var cacheDir = "/app/cache"; + Directory.CreateDirectory(cacheDir); + var cacheFile = Path.Combine(cacheDir, "admin_playlists_summary.json"); + + var response = new { playlists }; + var json = JsonSerializer.Serialize(response, new JsonSerializerOptions { WriteIndented = false }); + await System.IO.File.WriteAllTextAsync(cacheFile, json); + + _logger.LogDebug("💾 Saved playlist summary to cache"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save playlist summary cache"); + } + + return Ok(new { playlists }); + } + + /// + /// Get tracks for a specific playlist with local/external status + /// + [HttpGet("playlists/{name}/tracks")] + public async Task GetPlaylistTracks(string name) + { + var decodedName = Uri.UnescapeDataString(name); + + // Get Spotify tracks + var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(decodedName); + + var tracksWithStatus = new List(); + + // Use the pre-built playlist cache (same as GetPlaylists endpoint) + // This cache includes all matched tracks with proper provider IDs + var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(decodedName); + + List>? cachedPlaylistItems = null; + try + { + cachedPlaylistItems = await _cache.GetAsync>>(playlistItemsCacheKey); + } + catch (Exception cacheEx) + { + _logger.LogWarning(cacheEx, "Failed to deserialize playlist cache for {Playlist}", decodedName); + } + + _logger.LogDebug("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>(); + + foreach (var item in cachedPlaylistItems) + { + // Try to get Spotify ID from ProviderIds (works for both local and external) + if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null) + { + Dictionary? providerIds = null; + + if (providerIdsObj is Dictionary dict) + { + providerIds = dict; + } + else if (providerIdsObj is JsonElement jsonEl && jsonEl.ValueKind == JsonValueKind.Object) + { + providerIds = new Dictionary(); + 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; + + Dictionary? cachedItem = null; + + // Try to match by Spotify ID only (no position-based fallback!) + if (spotifyIdToItem.TryGetValue(track.SpotifyId, out cachedItem)) + { + _logger.LogDebug("Matched track {Title} by Spotify ID", track.Title); + } + + // Check if track is in the playlist cache first + if (cachedItem != null) + { + // First check ServerId - if it's "allstarr", it's an external track + if (cachedItem.TryGetValue("ServerId", out var serverIdObj) && serverIdObj != null) + { + string? serverId = null; + if (serverIdObj is string str) + { + serverId = str; + } + else if (serverIdObj is JsonElement jsonEl && jsonEl.ValueKind == JsonValueKind.String) + { + serverId = jsonEl.GetString(); + } + + if (serverId == "allstarr") + { + // This is an external track stub + isLocal = false; + + // Try to determine the provider from ProviderIds + if (cachedItem.TryGetValue("ProviderIds", out var providerIdsObjExt) && providerIdsObjExt != null) + { + Dictionary? providerIdsExt = null; + + if (providerIdsObjExt is Dictionary dictExt) + { + providerIdsExt = dictExt; + } + else if (providerIdsObjExt is JsonElement jsonElExt && jsonElExt.ValueKind == JsonValueKind.Object) + { + providerIdsExt = new Dictionary(); + foreach (var prop in jsonElExt.EnumerateObject()) + { + providerIdsExt[prop.Name] = prop.Value.GetString() ?? ""; + } + } + + if (providerIdsExt != null) + { + // Check for external provider keys + if (providerIdsExt.ContainsKey("squidwtf")) + externalProvider = "squidwtf"; + else if (providerIdsExt.ContainsKey("deezer")) + externalProvider = "deezer"; + else if (providerIdsExt.ContainsKey("qobuz")) + externalProvider = "qobuz"; + else if (providerIdsExt.ContainsKey("tidal")) + externalProvider = "tidal"; + } + } + + _logger.LogDebug("✓ Track {Title} identified as EXTERNAL from ServerId=allstarr (provider: {Provider})", + track.Title, externalProvider ?? "unknown"); + + // Check if this is a manual mapping + var globalMappingExt = await _mappingService.GetMappingAsync(track.SpotifyId); + if (globalMappingExt != null && globalMappingExt.Source == "manual") + { + isManualMapping = true; + manualMappingType = "external"; + manualMappingId = globalMappingExt.ExternalId; + } + + // Skip the rest of the ProviderIds logic + goto AddTrack; + } + } + + // Track is in the playlist cache with real Jellyfin ServerId - determine type from ProviderIds + if (cachedItem.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null) + { + Dictionary? providerIds = null; + + if (providerIdsObj is Dictionary dict) + { + providerIds = dict; + } + else if (providerIdsObj is JsonElement jsonEl && jsonEl.ValueKind == JsonValueKind.Object) + { + providerIds = new Dictionary(); + 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 + var hasSquidWTF = providerIds.Keys.Any(k => k.Equals("squidwtf", StringComparison.OrdinalIgnoreCase)); + var hasDeezer = providerIds.Keys.Any(k => k.Equals("deezer", StringComparison.OrdinalIgnoreCase)); + var hasQobuz = providerIds.Keys.Any(k => k.Equals("qobuz", StringComparison.OrdinalIgnoreCase)); + var hasTidal = providerIds.Keys.Any(k => k.Equals("tidal", StringComparison.OrdinalIgnoreCase)); + + if (hasSquidWTF) + { + isLocal = false; + externalProvider = "squidwtf"; + _logger.LogDebug("✓ Track {Title} identified as SquidWTF from cache", track.Title); + } + else if (hasDeezer) + { + isLocal = false; + externalProvider = "deezer"; + _logger.LogDebug("✓ Track {Title} identified as Deezer from cache", track.Title); + } + else if (hasQobuz) + { + isLocal = false; + externalProvider = "qobuz"; + _logger.LogDebug("✓ Track {Title} identified as Qobuz from cache", track.Title); + } + else if (hasTidal) + { + isLocal = false; + externalProvider = "tidal"; + _logger.LogDebug("✓ Track {Title} identified as Tidal from cache", track.Title); + } + else + { + // No external provider key found - it's a local Jellyfin track + isLocal = true; + _logger.LogDebug("✓ Track {Title} identified as LOCAL from cache", track.Title); + } + } + else + { + isLocal = true; + _logger.LogDebug("✓ Track {Title} identified as LOCAL (ProviderIds null)", track.Title); + } + } + else + { + // Track is in cache but has NO ProviderIds - treat as local + isLocal = true; + _logger.LogDebug("✓ Track {Title} identified as LOCAL (in cache, no ProviderIds)", track.Title); + } + + // Check if this is a manual mapping (for display purposes) + var globalMapping = await _mappingService.GetMappingAsync(track.SpotifyId); + if (globalMapping != null && globalMapping.Source == "manual") + { + isManualMapping = true; + manualMappingType = globalMapping.TargetType == "local" ? "jellyfin" : "external"; + manualMappingId = globalMapping.TargetType == "local" ? globalMapping.LocalId : globalMapping.ExternalId; + } + } + else + { + // Track NOT in playlist cache - check if there's a MANUAL global mapping + var globalMapping = await _mappingService.GetMappingAsync(track.SpotifyId); + + if (globalMapping != null && globalMapping.Source == "manual") + { + // Manual mapping exists - trust it even if not in cache yet + _logger.LogDebug("✓ Track {Title} has MANUAL global mapping: {Type}", track.Title, globalMapping.TargetType); + + if (globalMapping.TargetType == "local") + { + isLocal = true; + isManualMapping = true; + manualMappingType = "jellyfin"; + manualMappingId = globalMapping.LocalId; + } + else if (globalMapping.TargetType == "external") + { + isLocal = false; + externalProvider = globalMapping.ExternalProvider; + isManualMapping = true; + manualMappingType = "external"; + manualMappingId = globalMapping.ExternalId; + } + } + else + { + // No manual mapping and not in cache - it's missing + // (Auto mappings don't count if track isn't in the playlist cache) + isLocal = null; + externalProvider = null; + _logger.LogDebug("✗ Track {Title} ({SpotifyId}) is MISSING (not in cache, no manual mapping)", track.Title, track.SpotifyId); + } + } + + AddTrack: + // 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 = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(decodedName); + var fallbackMatchedTracks = await _cache.GetAsync>(fallbackMatchedTracksKey); + var fallbackMatchedSpotifyIds = new HashSet( + fallbackMatchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty() + ); + + 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(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.LogError(ex, "Failed to process external manual mapping for {Title}", track.Title); + } + } + else if (fallbackMatchedSpotifyIds.Contains(track.SpotifyId)) + { + isLocal = false; + externalProvider = "SquidWTF"; + } + else + { + isLocal = null; + externalProvider = null; + } + } + + tracksWithStatus.Add(new + { + position = track.Position, + title = track.Title, + artists = track.Artists, + album = track.Album, + isrc = track.Isrc, + spotifyId = track.SpotifyId, + durationMs = track.DurationMs, + albumArtUrl = track.AlbumArtUrl, + isLocal = isLocal, + externalProvider = externalProvider, + searchQuery = isLocal != true ? $"{track.Title} {track.PrimaryArtist}" : null + }); + } + + return Ok(new + { + name = decodedName, + trackCount = spotifyTracks.Count, + tracks = tracksWithStatus + }); + } + + /// + /// Trigger a manual refresh of all playlists + /// + [HttpPost("playlists/refresh")] + public async Task RefreshPlaylists() + { + _logger.LogInformation("Manual playlist refresh triggered from admin UI"); + await _playlistFetcher.TriggerFetchAsync(); + + // Invalidate playlist summary cache + _helperService.InvalidatePlaylistSummaryCache(); + + // Clear ALL playlist stats caches + var configuredPlaylists = await _helperService.ReadPlaylistsFromEnvFileAsync(); + foreach (var playlist in configuredPlaylists) + { + var statsCacheKey = $"spotify:playlist:stats:{playlist.Name}"; + await _cache.DeleteAsync(statsCacheKey); + } + _logger.LogInformation("Cleared stats cache for all {Count} playlists", configuredPlaylists.Count); + + return Ok(new { message = "Playlist refresh triggered", timestamp = DateTime.UtcNow }); + } + + /// + /// Refresh a single playlist from Spotify (fetch latest data without re-matching). + /// + [HttpPost("playlists/{name}/refresh")] + public async Task RefreshPlaylist(string name) + { + var decodedName = Uri.UnescapeDataString(name); + _logger.LogInformation("Manual refresh triggered for playlist: {Name}", decodedName); + + if (_playlistFetcher == null) + { + return BadRequest(new { error = "Playlist fetcher is not available" }); + } + + try + { + await _playlistFetcher.RefreshPlaylistAsync(decodedName); + + // Clear playlist stats cache first (so it gets recalculated with fresh data) + var statsCacheKey = $"spotify:playlist:stats:{decodedName}"; + await _cache.DeleteAsync(statsCacheKey); + + // Then invalidate playlist summary cache (will rebuild with fresh stats) + _helperService.InvalidatePlaylistSummaryCache(); + + return Ok(new { + message = $"Refreshed {decodedName} from Spotify (no re-matching)", + timestamp = DateTime.UtcNow + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to refresh playlist {Name}", decodedName); + return StatusCode(500, new { error = "Failed to refresh playlist", details = ex.Message }); + } + } + + /// + /// Re-match tracks when LOCAL library has changed (checks if Jellyfin playlist changed). + /// This is a lightweight operation that reuses cached Spotify data. + /// + [HttpPost("playlists/{name}/match")] + public async Task MatchPlaylistTracks(string name) + { + var decodedName = Uri.UnescapeDataString(name); + _logger.LogInformation("Re-match tracks triggered for playlist: {Name} (checking for local changes)", decodedName); + + if (_matchingService == null) + { + return BadRequest(new { error = "Track matching service is not available" }); + } + + try + { + // Clear the Jellyfin playlist signature cache to force re-checking if local tracks changed + var jellyfinSignatureCacheKey = $"spotify:playlist:jellyfin-signature:{decodedName}"; + await _cache.DeleteAsync(jellyfinSignatureCacheKey); + _logger.LogDebug("Cleared Jellyfin signature cache to force change detection"); + + // Clear the matched results cache to force re-matching + var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(decodedName); + await _cache.DeleteAsync(matchedTracksKey); + _logger.LogDebug("Cleared matched tracks cache"); + + // Clear the playlist items cache + var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(decodedName); + await _cache.DeleteAsync(playlistItemsCacheKey); + _logger.LogDebug("Cleared playlist items cache"); + + // Trigger matching (will use cached Spotify data if still valid) + await _matchingService.TriggerMatchingForPlaylistAsync(decodedName); + + // Invalidate playlist summary cache + _helperService.InvalidatePlaylistSummaryCache(); + + // Clear playlist stats cache to force recalculation from new mappings + var statsCacheKey = $"spotify:playlist:stats:{decodedName}"; + await _cache.DeleteAsync(statsCacheKey); + _logger.LogDebug("Cleared stats cache for {Name}", decodedName); + + return Ok(new { + message = $"Re-matching tracks for {decodedName} (checking local changes)", + timestamp = DateTime.UtcNow + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to trigger track matching for {Name}", decodedName); + return StatusCode(500, new { error = "Failed to trigger track matching", details = ex.Message }); + } + } + + /// + /// Rebuild playlist from scratch when REMOTE (Spotify) playlist has changed. + /// Clears all caches including Spotify data and forces fresh fetch. + /// + [HttpPost("playlists/{name}/clear-cache")] + public async Task ClearPlaylistCache(string name) + { + var decodedName = Uri.UnescapeDataString(name); + _logger.LogInformation("Rebuild from scratch triggered for playlist: {Name} (same as cron job)", decodedName); + + if (_matchingService == null) + { + return BadRequest(new { error = "Track matching service is not available" }); + } + + try + { + // Use the unified rebuild method (same as cron job and "Rebuild All Remote") + await _matchingService.TriggerRebuildForPlaylistAsync(decodedName); + + // Invalidate playlist summary cache + _helperService.InvalidatePlaylistSummaryCache(); + + return Ok(new + { + message = $"Rebuilding {decodedName} from scratch (same as cron job)", + timestamp = DateTime.UtcNow + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to rebuild playlist {Name}", decodedName); + return StatusCode(500, new { error = "Failed to rebuild playlist", details = ex.Message }); + } + } + + /// + /// Search Jellyfin library for tracks (for manual mapping) + /// + [HttpGet("jellyfin/search")] + public async Task SearchJellyfinTracks([FromQuery] string query) + { + if (string.IsNullOrWhiteSpace(query)) + { + return BadRequest(new { error = "Query is required" }); + } + + try + { + var userId = _jellyfinSettings.UserId; + + // Build URL with UserId if available + var url = $"{_jellyfinSettings.Url}/Items?searchTerm={Uri.EscapeDataString(query)}&includeItemTypes=Audio&recursive=true&limit=20"; + if (!string.IsNullOrEmpty(userId)) + { + url += $"&UserId={userId}"; + } + + var request = _helperService.CreateJellyfinRequest(HttpMethod.Get, url); + + _logger.LogDebug("Searching Jellyfin: {Url}", url); + + var response = await _jellyfinHttpClient.SendAsync(request); + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(); + _logger.LogError("Jellyfin search failed: {StatusCode} - {Error}", response.StatusCode, errorBody); + return StatusCode((int)response.StatusCode, new { error = "Failed to search Jellyfin" }); + } + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + + var tracks = new List(); + if (doc.RootElement.TryGetProperty("Items", out var items)) + { + foreach (var item in items.EnumerateArray()) + { + // Verify it's actually an Audio item + var type = item.TryGetProperty("Type", out var typeEl) ? typeEl.GetString() : ""; + if (type != "Audio") + { + _logger.LogWarning("Skipping non-audio item: {Type}", type); + continue; + } + + var id = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() : ""; + var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : ""; + var album = item.TryGetProperty("Album", out var albumEl) ? albumEl.GetString() : ""; + var artist = ""; + + if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0) + { + artist = artistsEl[0].GetString() ?? ""; + } + else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl)) + { + artist = albumArtistEl.GetString() ?? ""; + } + + tracks.Add(new { id, title, artist, album }); + } + } + + return Ok(new { tracks }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to search Jellyfin tracks"); + return StatusCode(500, new { error = "Search failed" }); + } + } + + /// + /// Get track details by Jellyfin ID (for URL-based mapping) + /// + [HttpGet("jellyfin/track/{id}")] + public async Task GetJellyfinTrack(string id) + { + if (string.IsNullOrWhiteSpace(id)) + { + return BadRequest(new { error = "Track ID is required" }); + } + + try + { + var userId = _jellyfinSettings.UserId; + + var url = $"{_jellyfinSettings.Url}/Items/{id}"; + if (!string.IsNullOrEmpty(userId)) + { + url += $"?UserId={userId}"; + } + + var request = _helperService.CreateJellyfinRequest(HttpMethod.Get, url); + + _logger.LogDebug("Fetching Jellyfin track {Id} from {Url}", id, url); + + var response = await _jellyfinHttpClient.SendAsync(request); + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(); + _logger.LogError("Failed to fetch Jellyfin track {Id}: {StatusCode} - {Error}", + id, response.StatusCode, errorBody); + return StatusCode((int)response.StatusCode, new { error = "Track not found in Jellyfin" }); + } + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + + var item = doc.RootElement; + + // Verify it's an Audio item + var type = item.TryGetProperty("Type", out var typeEl) ? typeEl.GetString() : ""; + if (type != "Audio") + { + _logger.LogWarning("Item {Id} is not an Audio track, it's a {Type}", id, type); + return BadRequest(new { error = $"Item is not an audio track (it's a {type})" }); + } + + var trackId = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() : ""; + var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : ""; + var album = item.TryGetProperty("Album", out var albumEl) ? albumEl.GetString() : ""; + var artist = ""; + + if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0) + { + artist = artistsEl[0].GetString() ?? ""; + } + else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl)) + { + artist = albumArtistEl.GetString() ?? ""; + } + + _logger.LogInformation("Found Jellyfin track: {Title} by {Artist}", title, artist); + + return Ok(new { id = trackId, title, artist, album }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get Jellyfin track {Id}", id); + return StatusCode(500, new { error = "Failed to get track details" }); + } + } + + /// + /// Save manual track mapping (local Jellyfin or external provider) + /// + [HttpPost("playlists/{name}/map")] + public async Task SaveManualMapping(string name, [FromBody] ManualMappingRequest request) + { + var decodedName = Uri.UnescapeDataString(name); + + if (string.IsNullOrWhiteSpace(request.SpotifyId)) + { + return BadRequest(new { error = "SpotifyId is required" }); + } + + // Validate that either Jellyfin mapping or external mapping is provided + var hasJellyfinMapping = !string.IsNullOrWhiteSpace(request.JellyfinId); + var hasExternalMapping = !string.IsNullOrWhiteSpace(request.ExternalProvider) && !string.IsNullOrWhiteSpace(request.ExternalId); + + if (!hasJellyfinMapping && !hasExternalMapping) + { + return BadRequest(new { error = "Either JellyfinId or (ExternalProvider + ExternalId) is required" }); + } + + if (hasJellyfinMapping && hasExternalMapping) + { + return BadRequest(new { error = "Cannot specify both Jellyfin and external mapping for the same track" }); + } + + try + { + string? normalizedProvider = null; + + if (hasJellyfinMapping) + { + // Store Jellyfin mapping in cache (NO EXPIRATION - manual mappings are permanent) + var mappingKey = $"spotify:manual-map:{decodedName}:{request.SpotifyId}"; + await _cache.SetAsync(mappingKey, request.JellyfinId!); + + // Also save to file for persistence across restarts + await _helperService.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 _helperService.SaveManualMappingToFileAsync(decodedName, request.SpotifyId, null, normalizedProvider, request.ExternalId!); + + _logger.LogInformation("Manual external mapping saved: {Playlist} - Spotify {SpotifyId} → {Provider} {ExternalId}", + decodedName, request.SpotifyId, normalizedProvider, request.ExternalId); + } + + // Clear all related caches to force rebuild + var matchedCacheKey = $"spotify:matched:{decodedName}"; + var orderedCacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(decodedName); + var playlistItemsKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(decodedName); + var statsCacheKey = $"spotify:playlist:stats:{decodedName}"; + + await _cache.DeleteAsync(matchedCacheKey); + await _cache.DeleteAsync(orderedCacheKey); + await _cache.DeleteAsync(playlistItemsKey); + await _cache.DeleteAsync(statsCacheKey); + + // Also delete file caches to force rebuild + try + { + var cacheDir = "/app/cache/spotify"; + var safeName = AdminHelperService.SanitizeFileName(decodedName); + var matchedFile = Path.Combine(cacheDir, $"{safeName}_matched.json"); + var itemsFile = Path.Combine(cacheDir, $"{safeName}_items.json"); + var statsFile = Path.Combine(cacheDir, $"{safeName}_stats.json"); + + if (System.IO.File.Exists(matchedFile)) + { + System.IO.File.Delete(matchedFile); + _logger.LogInformation("Deleted matched tracks file cache for {Playlist}", decodedName); + } + + if (System.IO.File.Exists(itemsFile)) + { + System.IO.File.Delete(itemsFile); + _logger.LogDebug("Deleted playlist items file cache for {Playlist}", decodedName); + } + + if (System.IO.File.Exists(statsFile)) + { + System.IO.File.Delete(statsFile); + _logger.LogDebug("Deleted stats file cache for {Playlist}", decodedName); + } + } + catch (Exception ex) + { + _logger.LogError(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(); + 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.LogError("Failed to fetch external track metadata for {Provider} ID {Id}", + normalizedProvider, request.ExternalId); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to fetch external track metadata, but mapping was saved"); + } + } + + // Trigger immediate playlist rebuild with the new mapping + if (_matchingService != null) + { + _logger.LogInformation("Triggering immediate playlist rebuild for {Playlist} with new manual mapping", decodedName); + + // Run rebuild in background with timeout to avoid blocking the response + _ = Task.Run(async () => + { + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2)); // 2 minute timeout + await _matchingService.TriggerMatchingForPlaylistAsync(decodedName); + _logger.LogInformation("✓ Playlist {Playlist} rebuilt successfully with manual mapping", decodedName); + } + catch (OperationCanceledException) + { + _logger.LogWarning("Playlist rebuild for {Playlist} timed out after 2 minutes", decodedName); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to rebuild playlist {Playlist} after manual mapping", decodedName); + } + }); + } + else + { + _logger.LogWarning("Matching service not available - playlist will rebuild on next scheduled run"); + } + + // Return success with track details if available + var mappedTrack = new + { + id = request.ExternalId, + title = trackTitle ?? "Unknown", + artist = trackArtist ?? "Unknown", + album = trackAlbum ?? "Unknown", + isLocal = false, + externalProvider = request.ExternalProvider!.ToLowerInvariant() + }; + + return Ok(new + { + message = "Mapping saved and playlist rebuild triggered", + track = mappedTrack, + rebuildTriggered = _matchingService != null + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save manual mapping"); + return StatusCode(500, new { error = "Failed to save mapping" }); + } + } + + /// + /// Trigger track matching for all playlists + /// + [HttpPost("playlists/match-all")] + public async Task MatchAllPlaylistTracks() + { + _logger.LogInformation("Manual track matching triggered for all playlists"); + + if (_matchingService == null) + { + return BadRequest(new { error = "Track matching service is not available" }); + } + + try + { + await _matchingService.TriggerMatchingAsync(); + return Ok(new { message = "Track matching triggered for all playlists", timestamp = DateTime.UtcNow }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to trigger track matching for all playlists"); + return StatusCode(500, new { error = "Failed to trigger track matching", details = ex.Message }); + } + } + + /// + /// Rebuild all playlists from scratch (clear cache, fetch fresh data, re-match). + /// This is the same process as the scheduled cron job - used by "Rebuild All Remote" button. + /// + [HttpPost("playlists/rebuild-all")] + public async Task RebuildAllPlaylists() + { + _logger.LogInformation("Manual full rebuild triggered for all playlists (same as cron job)"); + + if (_matchingService == null) + { + return BadRequest(new { error = "Track matching service is not available" }); + } + + try + { + await _matchingService.TriggerRebuildAllAsync(); + return Ok(new { message = "Full rebuild triggered for all playlists (same as cron job)", timestamp = DateTime.UtcNow }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to trigger full rebuild for all playlists"); + return StatusCode(500, new { error = "Failed to trigger full rebuild", details = ex.Message }); + } + } + + /// + /// Get current configuration (safe values only) + /// + [HttpPost("playlists")] + public async Task AddPlaylist([FromBody] AddPlaylistRequest request) + { + if (string.IsNullOrEmpty(request.Name) || string.IsNullOrEmpty(request.SpotifyId)) + { + return BadRequest(new { error = "Name and SpotifyId are required" }); + } + + _logger.LogInformation("Adding playlist: {Name} ({SpotifyId})", request.Name, request.SpotifyId); + + // Get current playlists + var currentPlaylists = _spotifyImportSettings.Playlists.ToList(); + + // Check for duplicates + if (currentPlaylists.Any(p => p.Id == request.SpotifyId || p.Name == request.Name)) + { + return BadRequest(new { error = "Playlist with this name or ID already exists" }); + } + + // Add new playlist + currentPlaylists.Add(new SpotifyPlaylistConfig + { + Name = request.Name, + Id = request.SpotifyId, + LocalTracksPosition = request.LocalTracksPosition == "last" + ? LocalTracksPosition.Last + : LocalTracksPosition.First + }); + + // Convert to JSON format for env var + var playlistsJson = JsonSerializer.Serialize( + currentPlaylists.Select(p => new[] { p.Name, p.Id, p.LocalTracksPosition.ToString().ToLower() }).ToArray() + ); + + // Update .env file + var updateRequest = new ConfigUpdateRequest + { + Updates = new Dictionary + { + ["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson + } + }; + + return await _helperService.UpdateEnvConfigAsync(updateRequest.Updates); + } + + /// + /// Remove a playlist from the configuration + /// + [HttpDelete("playlists/{name}")] + public async Task RemovePlaylist(string name) + { + var decodedName = Uri.UnescapeDataString(name); + _logger.LogInformation("Removing playlist: {Name}", decodedName); + + // Read current playlists from .env file (not stale in-memory config) + var currentPlaylists = await _helperService.ReadPlaylistsFromEnvFileAsync(); + var playlist = currentPlaylists.FirstOrDefault(p => p.Name == decodedName); + + if (playlist == null) + { + return NotFound(new { error = "Playlist not found" }); + } + + currentPlaylists.Remove(playlist); + + // Convert to JSON format for env var: [["Name","SpotifyId","JellyfinId","first|last"],...] + var playlistsJson = JsonSerializer.Serialize( + currentPlaylists.Select(p => new[] { p.Name, p.Id, p.JellyfinId, p.LocalTracksPosition.ToString().ToLower() }).ToArray() + ); + + // Update .env file + var updateRequest = new ConfigUpdateRequest + { + Updates = new Dictionary + { + ["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson + } + }; + + return await _helperService.UpdateEnvConfigAsync(updateRequest.Updates); + } + + + /// + /// Save lyrics mapping to file for persistence across restarts. + /// Lyrics mappings NEVER expire - they are permanent user decisions. + /// +} diff --git a/allstarr/Controllers/ScrobblingAdminController.cs b/allstarr/Controllers/ScrobblingAdminController.cs new file mode 100644 index 0000000..3f7f791 --- /dev/null +++ b/allstarr/Controllers/ScrobblingAdminController.cs @@ -0,0 +1,539 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using System.Security.Cryptography; +using System.Text; +using System.Xml.Linq; +using allstarr.Filters; +using allstarr.Models.Settings; +using allstarr.Services.Admin; + +namespace allstarr.Controllers; + +/// +/// Admin controller for scrobbling configuration and authentication. +/// Note: Does not require API key auth - users authenticate with Last.fm directly. +/// +[ApiController] +[Route("api/admin/scrobbling")] +[ServiceFilter(typeof(AdminPortFilter))] +public class ScrobblingAdminController : ControllerBase +{ + private readonly ScrobblingSettings _settings; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + private readonly HttpClient _httpClient; + private readonly AdminHelperService _adminHelper; + + public ScrobblingAdminController( + IOptions settings, + IConfiguration configuration, + IHttpClientFactory httpClientFactory, + ILogger logger, + AdminHelperService adminHelper) + { + _settings = settings.Value; + _configuration = configuration; + _logger = logger; + _httpClient = httpClientFactory.CreateClient("LastFm"); + _adminHelper = adminHelper; + } + + /// + /// Gets current scrobbling configuration status. + /// + [HttpGet("status")] + public IActionResult GetStatus() + { + var hasApiCredentials = !string.IsNullOrEmpty(_settings.LastFm.ApiKey) && + !string.IsNullOrEmpty(_settings.LastFm.SharedSecret); + + return Ok(new + { + Enabled = _settings.Enabled, + LocalTracksEnabled = _settings.LocalTracksEnabled, + LastFm = new + { + Enabled = _settings.LastFm.Enabled, + Configured = hasApiCredentials && !string.IsNullOrEmpty(_settings.LastFm.SessionKey), + HasApiKey = hasApiCredentials, + HasSessionKey = !string.IsNullOrEmpty(_settings.LastFm.SessionKey), + Username = _settings.LastFm.Username, + UsingHardcodedCredentials = hasApiCredentials && + _settings.LastFm.ApiKey == "cb3bdcd415fcb40cd572b137b2b255f5" + }, + ListenBrainz = new + { + Enabled = _settings.ListenBrainz.Enabled, + Configured = !string.IsNullOrEmpty(_settings.ListenBrainz.UserToken), + HasUserToken = !string.IsNullOrEmpty(_settings.ListenBrainz.UserToken) + } + }); + } + + /// + /// Authenticate with Last.fm using credentials from .env file. + /// Uses hardcoded API credentials from Jellyfin Last.fm plugin for convenience. + /// + [HttpPost("lastfm/authenticate")] + public async Task AuthenticateLastFm() + { + // Get username and password from settings (loaded from .env) + var username = _settings.LastFm.Username; + var password = _settings.LastFm.Password; + + if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password)) + { + return BadRequest(new { error = "Username and password must be set in .env file (SCROBBLING_LASTFM_USERNAME and SCROBBLING_LASTFM_PASSWORD)" }); + } + + // Check if API credentials are available + if (string.IsNullOrEmpty(_settings.LastFm.ApiKey) || string.IsNullOrEmpty(_settings.LastFm.SharedSecret)) + { + return BadRequest(new { error = "Last.fm API credentials not configured. This should not happen - please report this bug." }); + } + + _logger.LogInformation("🔍 DEBUG: Password from settings: '{Password}' (length: {Length})", + password, password.Length); + _logger.LogInformation("🔍 DEBUG: Password bytes: {Bytes}", + string.Join(" ", System.Text.Encoding.UTF8.GetBytes(password).Select(b => b.ToString("X2")))); + + try + { + // Build parameters for auth.getMobileSession + var parameters = new Dictionary + { + ["api_key"] = _settings.LastFm.ApiKey, + ["method"] = "auth.getMobileSession", + ["username"] = username, + ["password"] = password + }; + + // Generate signature + var signature = GenerateSignature(parameters, _settings.LastFm.SharedSecret); + parameters["api_sig"] = signature; + + _logger.LogInformation("🔍 DEBUG: Signature: {Signature}", signature); + + // Send POST request over HTTPS + var content = new FormUrlEncodedContent(parameters); + var response = await _httpClient.PostAsync("https://ws.audioscrobbler.com/2.0/", content); + var responseBody = await response.Content.ReadAsStringAsync(); + + _logger.LogInformation("🔍 DEBUG: Last.fm response: {Status} - {Body}", + response.StatusCode, responseBody); + + // Parse response + var doc = XDocument.Parse(responseBody); + var root = doc.Root; + + if (root?.Attribute("status")?.Value == "failed") + { + var errorElement = root.Element("error"); + var errorCode = errorElement?.Attribute("code")?.Value; + var errorMessage = errorElement?.Value ?? "Unknown error"; + + if (errorCode == "4") + { + return BadRequest(new { error = "Invalid username or password" }); + } + + return BadRequest(new { error = $"Last.fm error: {errorMessage}" }); + } + + // Extract session info + var sessionElement = root?.Element("session"); + var sessionKey = sessionElement?.Element("key")?.Value; + var authenticatedUsername = sessionElement?.Element("name")?.Value; + + if (string.IsNullOrEmpty(sessionKey)) + { + return BadRequest(new { error = "Failed to get session key from Last.fm response" }); + } + + _logger.LogInformation("Successfully authenticated Last.fm user: {Username}", authenticatedUsername); + + // Save session key to .env file + try + { + var updates = new Dictionary + { + ["SCROBBLING_LASTFM_SESSION_KEY"] = sessionKey + }; + + await _adminHelper.UpdateEnvConfigAsync(updates); + _logger.LogInformation("Session key saved to .env file"); + } + catch (Exception saveEx) + { + _logger.LogError(saveEx, "Failed to save session key to .env file"); + return StatusCode(500, new { + error = "Authentication successful but failed to save session key", + sessionKey = sessionKey, + details = saveEx.Message + }); + } + + return Ok(new + { + Success = true, + SessionKey = sessionKey, + Username = authenticatedUsername, + Message = "Authentication successful! Session key saved. Please restart the container for changes to take effect." + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error authenticating with Last.fm"); + return StatusCode(500, new { error = $"Error: {ex.Message}" }); + } + } + + /// + /// DEPRECATED: OAuth method - use /authenticate instead for simpler username/password auth. + /// Step 1: Get Last.fm authentication URL for user to authorize the app. + /// + [HttpGet("lastfm/auth-url")] + public IActionResult GetLastFmAuthUrl() + { + return BadRequest(new { + error = "OAuth authentication is deprecated. Use POST /lastfm/authenticate with username and password instead.", + hint = "This is simpler and doesn't require a callback URL." + }); + } + + /// + /// DEPRECATED: OAuth method - use /authenticate instead. + /// Step 2: Exchange Last.fm auth token for session key. + /// + [HttpPost("lastfm/get-session")] + public IActionResult GetLastFmSession([FromBody] GetSessionRequest request) + { + return BadRequest(new { + error = "OAuth authentication is deprecated. Use POST /lastfm/authenticate with username and password instead.", + hint = "This is simpler and doesn't require a callback URL." + }); + } + + /// + /// Test Last.fm connection with current configuration. + /// + [HttpPost("lastfm/test")] + public async Task TestLastFmConnection() + { + if (!_settings.LastFm.Enabled) + { + return BadRequest(new { error = "Last.fm scrobbling is not enabled" }); + } + + if (string.IsNullOrEmpty(_settings.LastFm.ApiKey) || + string.IsNullOrEmpty(_settings.LastFm.SharedSecret) || + string.IsNullOrEmpty(_settings.LastFm.SessionKey)) + { + return BadRequest(new { error = "Last.fm is not fully configured (missing API key, shared secret, or session key)" }); + } + + try + { + // Try to get user info to test the session key + var parameters = new Dictionary + { + ["api_key"] = _settings.LastFm.ApiKey, + ["method"] = "user.getInfo", + ["sk"] = _settings.LastFm.SessionKey + }; + + var signature = GenerateSignature(parameters, _settings.LastFm.SharedSecret); + parameters["api_sig"] = signature; + + var content = new FormUrlEncodedContent(parameters); + var response = await _httpClient.PostAsync("https://ws.audioscrobbler.com/2.0/", content); + var responseBody = await response.Content.ReadAsStringAsync(); + + var doc = XDocument.Parse(responseBody); + var root = doc.Root; + + if (root?.Attribute("status")?.Value == "failed") + { + var errorElement = root.Element("error"); + var errorCode = errorElement?.Attribute("code")?.Value; + var errorMessage = errorElement?.Value ?? "Unknown error"; + + if (errorCode == "9") + { + return BadRequest(new { error = "Session key is invalid. Please re-authenticate." }); + } + + return BadRequest(new { error = $"Last.fm error: {errorMessage}" }); + } + + var userElement = root?.Element("user"); + var username = userElement?.Element("name")?.Value; + var playcount = userElement?.Element("playcount")?.Value; + + return Ok(new + { + Success = true, + Message = "Last.fm connection successful!", + Username = username, + Playcount = playcount + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error testing Last.fm connection"); + return StatusCode(500, new { error = $"Error: {ex.Message}" }); + } + } + + /// + /// Update local tracks scrobbling setting. + /// + [HttpPost("local-tracks/update")] + public async Task UpdateLocalTracksEnabled([FromBody] UpdateLocalTracksRequest request) + { + try + { + var updates = new Dictionary + { + ["SCROBBLING_LOCAL_TRACKS_ENABLED"] = request.Enabled.ToString().ToLower() + }; + + await _adminHelper.UpdateEnvConfigAsync(updates); + _logger.LogInformation("Local tracks scrobbling setting updated to: {Enabled}", request.Enabled); + + return Ok(new + { + Success = true, + LocalTracksEnabled = request.Enabled, + Message = "Setting saved! Please restart the container for changes to take effect." + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update local tracks scrobbling setting"); + return StatusCode(500, new { error = $"Error: {ex.Message}" }); + } + } + + /// + /// Validate ListenBrainz user token. + /// + [HttpPost("listenbrainz/validate")] + public async Task ValidateListenBrainzToken([FromBody] ValidateTokenRequest request) + { + if (string.IsNullOrEmpty(request.UserToken)) + { + return BadRequest(new { error = "User token is required" }); + } + + try + { + var httpRequest = new HttpRequestMessage(HttpMethod.Get, "https://api.listenbrainz.org/1/validate-token"); + httpRequest.Headers.Add("Authorization", $"Token {request.UserToken}"); + + var response = await _httpClient.SendAsync(httpRequest); + var responseBody = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + return BadRequest(new { error = "Invalid user token" }); + } + + var jsonDoc = System.Text.Json.JsonDocument.Parse(responseBody); + var valid = jsonDoc.RootElement.GetProperty("valid").GetBoolean(); + + if (!valid) + { + return BadRequest(new { error = "Invalid user token" }); + } + + var username = jsonDoc.RootElement.GetProperty("user_name").GetString(); + + // Save token to .env file + try + { + var updates = new Dictionary + { + ["SCROBBLING_LISTENBRAINZ_USER_TOKEN"] = request.UserToken + }; + + await _adminHelper.UpdateEnvConfigAsync(updates); + _logger.LogInformation("ListenBrainz token saved to .env file"); + } + catch (Exception saveEx) + { + _logger.LogError(saveEx, "Failed to save token to .env file"); + return StatusCode(500, new { + error = "Token validation successful but failed to save", + userToken = request.UserToken, + username = username, + details = saveEx.Message + }); + } + + return Ok(new + { + Success = true, + Valid = true, + Username = username, + Message = "Token validated and saved! Please restart the container for changes to take effect." + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error validating ListenBrainz token"); + return StatusCode(500, new { error = $"Error: {ex.Message}" }); + } + } + + /// + /// Test ListenBrainz connection with current configuration. + /// + [HttpPost("listenbrainz/test")] + public async Task TestListenBrainzConnection() + { + if (!_settings.ListenBrainz.Enabled) + { + return BadRequest(new { error = "ListenBrainz scrobbling is not enabled" }); + } + + if (string.IsNullOrEmpty(_settings.ListenBrainz.UserToken)) + { + return BadRequest(new { error = "ListenBrainz user token is not configured" }); + } + + try + { + var httpRequest = new HttpRequestMessage(HttpMethod.Get, "https://api.listenbrainz.org/1/validate-token"); + httpRequest.Headers.Add("Authorization", $"Token {_settings.ListenBrainz.UserToken}"); + + var response = await _httpClient.SendAsync(httpRequest); + var responseBody = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + return BadRequest(new { error = "Invalid user token" }); + } + + var jsonDoc = System.Text.Json.JsonDocument.Parse(responseBody); + var valid = jsonDoc.RootElement.GetProperty("valid").GetBoolean(); + + if (!valid) + { + return BadRequest(new { error = "Invalid user token" }); + } + + var username = jsonDoc.RootElement.GetProperty("user_name").GetString(); + + return Ok(new + { + Success = true, + Message = "ListenBrainz connection successful!", + Username = username + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error testing ListenBrainz connection"); + return StatusCode(500, new { error = $"Error: {ex.Message}" }); + } + } + + /// + /// Debug endpoint to test authentication parameters without actually calling Last.fm. + /// Shows what would be sent to Last.fm for debugging. + /// + [HttpPost("lastfm/debug-auth")] + public IActionResult DebugAuth([FromBody] AuthenticateRequest request) + { + if (string.IsNullOrEmpty(request.Username) || string.IsNullOrEmpty(request.Password)) + { + return BadRequest(new { error = "Username and password are required" }); + } + + // Build parameters for auth.getMobileSession + var parameters = new Dictionary + { + ["api_key"] = _settings.LastFm.ApiKey, + ["method"] = "auth.getMobileSession", + ["username"] = request.Username, + ["password"] = request.Password + }; + + // Generate signature + var signature = GenerateSignature(parameters, _settings.LastFm.SharedSecret); + + // Build signature string for debugging + var sorted = parameters.OrderBy(kvp => kvp.Key); + var signatureString = new StringBuilder(); + foreach (var kvp in sorted) + { + signatureString.Append(kvp.Key); + signatureString.Append(kvp.Value); + } + signatureString.Append(_settings.LastFm.SharedSecret); + + return Ok(new + { + ApiKey = _settings.LastFm.ApiKey, + SharedSecret = _settings.LastFm.SharedSecret.Substring(0, 8) + "...", + Username = request.Username, + PasswordLength = request.Password.Length, + SignatureString = signatureString.ToString(), + Signature = signature, + CurlCommand = $"curl -X POST \"https://ws.audioscrobbler.com/2.0/\" " + + $"-d \"method=auth.getMobileSession\" " + + $"-d \"username={request.Username}\" " + + $"-d \"password={request.Password}\" " + + $"-d \"api_key={_settings.LastFm.ApiKey}\" " + + $"-d \"api_sig={signature}\" " + + $"-d \"format=json\"" + }); + } + + private string GenerateSignature(Dictionary parameters, string sharedSecret) + { + var sorted = parameters.OrderBy(kvp => kvp.Key); + var signatureString = new StringBuilder(); + + foreach (var kvp in sorted) + { + signatureString.Append(kvp.Key); + signatureString.Append(kvp.Value); + } + + signatureString.Append(sharedSecret); + + var bytes = Encoding.UTF8.GetBytes(signatureString.ToString()); + var hash = MD5.HashData(bytes); + + // Convert to UPPERCASE hex string (Last.fm requires uppercase) + var sb = new StringBuilder(); + foreach (byte b in hash) + { + sb.Append(b.ToString("X2")); + } + return sb.ToString(); + } + + public class AuthenticateRequest + { + public required string Username { get; set; } + public required string Password { get; set; } + } + + public class GetSessionRequest + { + public required string Token { get; set; } + } + + public class ValidateTokenRequest + { + public required string UserToken { get; set; } + } + + public class UpdateLocalTracksRequest + { + public required bool Enabled { get; set; } + } +} diff --git a/allstarr/Controllers/SpotifyAdminController.cs b/allstarr/Controllers/SpotifyAdminController.cs new file mode 100644 index 0000000..ec04c9b --- /dev/null +++ b/allstarr/Controllers/SpotifyAdminController.cs @@ -0,0 +1,537 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using allstarr.Models.Settings; +using allstarr.Models.Spotify; +using allstarr.Models.Admin; +using allstarr.Services.Spotify; +using allstarr.Services.Common; +using allstarr.Services; +using allstarr.Services.Admin; +using allstarr.Filters; +using System.Text.Json; + +namespace allstarr.Controllers; + +[ApiController] +[Route("api/admin")] +[ServiceFilter(typeof(AdminPortFilter))] +public class SpotifyAdminController : ControllerBase +{ + private readonly ILogger _logger; + private readonly SpotifyApiClient _spotifyClient; + private readonly SpotifyMappingService _mappingService; + private readonly RedisCacheService _cache; + private readonly IServiceProvider _serviceProvider; + private readonly SpotifyApiSettings _spotifyApiSettings; + private readonly SpotifyImportSettings _spotifyImportSettings; + private readonly AdminHelperService _helperService; + + public SpotifyAdminController( + ILogger logger, + SpotifyApiClient spotifyClient, + SpotifyMappingService mappingService, + RedisCacheService cache, + IServiceProvider serviceProvider, + IOptions spotifyApiSettings, + IOptions spotifyImportSettings, + AdminHelperService helperService) + { + _logger = logger; + _spotifyClient = spotifyClient; + _mappingService = mappingService; + _cache = cache; + _serviceProvider = serviceProvider; + _spotifyApiSettings = spotifyApiSettings.Value; + _spotifyImportSettings = spotifyImportSettings.Value; + _helperService = helperService; + } + + [HttpGet("spotify/user-playlists")] + public async Task 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 _helperService.ReadPlaylistsFromEnvFileAsync(); + var linkedSpotifyIds = new HashSet( + 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() }); + } + + 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 }); + } + } + + /// + /// Get all playlists from Jellyfin + /// + [HttpGet("spotify/sync")] + public async Task TriggerSpotifySync([FromServices] IEnumerable 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() + .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" }); + } + } + + /// + /// Manual trigger endpoint to force Spotify track matching. + /// + [HttpGet("spotify/match")] + public async Task TriggerSpotifyMatch([FromServices] IEnumerable 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() + .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" }); + } + } + + /// + /// Clear Spotify playlist cache to force re-matching. + /// + [HttpPost("spotify/clear-cache")] + public async Task ClearSpotifyCache() + { + try + { + var clearedKeys = new List(); + + // Clear Redis cache for all configured playlists + foreach (var playlist in _spotifyImportSettings.Playlists) + { + var keys = new[] + { + CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name), + CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name), + CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name) + }; + + foreach (var key in keys) + { + await _cache.DeleteAsync(key); + clearedKeys.Add(key); + } + } + + _logger.LogDebug("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" }); + } + } + + + + /// + /// Gets endpoint usage statistics from the log file. + /// + [HttpGet("spotify/mappings")] + public async Task GetSpotifyMappings( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 50, + [FromQuery] bool enrichMetadata = true, + [FromQuery] string? targetType = null, + [FromQuery] string? source = null, + [FromQuery] string? search = null, + [FromQuery] string? sortBy = null, + [FromQuery] string? sortOrder = "asc") + { + try + { + // Get all mappings (we'll filter and sort in memory for now) + var allMappings = await _mappingService.GetAllMappingsAsync(0, int.MaxValue); + var stats = await _mappingService.GetStatsAsync(); + + // Enrich metadata for external tracks that are missing it + if (enrichMetadata) + { + await EnrichExternalMappingsMetadataAsync(allMappings); + } + + // Apply filters + var filteredMappings = allMappings.AsEnumerable(); + + if (!string.IsNullOrEmpty(targetType) && targetType != "all") + { + filteredMappings = filteredMappings.Where(m => + m.TargetType.Equals(targetType, StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrEmpty(source) && source != "all") + { + filteredMappings = filteredMappings.Where(m => + m.Source.Equals(source, StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrEmpty(search)) + { + var searchLower = search.ToLower(); + filteredMappings = filteredMappings.Where(m => + m.SpotifyId.ToLower().Contains(searchLower) || + (m.Metadata?.Title?.ToLower().Contains(searchLower) ?? false) || + (m.Metadata?.Artist?.ToLower().Contains(searchLower) ?? false)); + } + + // Apply sorting + if (!string.IsNullOrEmpty(sortBy)) + { + var isDescending = sortOrder?.ToLower() == "desc"; + + filteredMappings = sortBy.ToLower() switch + { + "title" => isDescending + ? filteredMappings.OrderByDescending(m => m.Metadata?.Title ?? "") + : filteredMappings.OrderBy(m => m.Metadata?.Title ?? ""), + "artist" => isDescending + ? filteredMappings.OrderByDescending(m => m.Metadata?.Artist ?? "") + : filteredMappings.OrderBy(m => m.Metadata?.Artist ?? ""), + "spotifyid" => isDescending + ? filteredMappings.OrderByDescending(m => m.SpotifyId) + : filteredMappings.OrderBy(m => m.SpotifyId), + "type" => isDescending + ? filteredMappings.OrderByDescending(m => m.TargetType) + : filteredMappings.OrderBy(m => m.TargetType), + "source" => isDescending + ? filteredMappings.OrderByDescending(m => m.Source) + : filteredMappings.OrderBy(m => m.Source), + "created" => isDescending + ? filteredMappings.OrderByDescending(m => m.CreatedAt) + : filteredMappings.OrderBy(m => m.CreatedAt), + _ => filteredMappings + }; + } + + var filteredList = filteredMappings.ToList(); + var totalCount = filteredList.Count; + + // Apply pagination + var skip = (page - 1) * pageSize; + var pagedMappings = filteredList.Skip(skip).Take(pageSize).ToList(); + + return Ok(new + { + mappings = pagedMappings, + pagination = new + { + page, + pageSize, + totalCount, + totalPages = (int)Math.Ceiling((double)totalCount / pageSize) + }, + stats + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get Spotify mappings"); + return StatusCode(500, new { error = "Failed to get mappings" }); + } + } + + /// + /// Gets a specific Spotify track mapping + /// + [HttpGet("spotify/mappings/{spotifyId}")] + public async Task GetSpotifyMapping(string spotifyId) + { + try + { + var mapping = await _mappingService.GetMappingAsync(spotifyId); + if (mapping == null) + { + return NotFound(new { error = "Mapping not found" }); + } + + return Ok(mapping); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get Spotify mapping for {SpotifyId}", spotifyId); + return StatusCode(500, new { error = "Failed to get mapping" }); + } + } + + /// + /// Creates or updates a Spotify track mapping (manual override) + /// + [HttpPost("spotify/mappings")] + public async Task SaveSpotifyMapping([FromBody] SpotifyMappingRequest request) + { + try + { + var metadata = request.Metadata != null ? new TrackMetadata + { + Title = request.Metadata.Title, + Artist = request.Metadata.Artist, + Album = request.Metadata.Album, + ArtworkUrl = request.Metadata.ArtworkUrl, + DurationMs = request.Metadata.DurationMs + } : null; + + var success = await _mappingService.SaveManualMappingAsync( + request.SpotifyId, + request.TargetType, + request.LocalId, + request.ExternalProvider, + request.ExternalId, + metadata); + + if (success) + { + _logger.LogInformation("Saved manual mapping: {SpotifyId} → {TargetType}", + request.SpotifyId, request.TargetType); + return Ok(new { success = true }); + } + + return StatusCode(500, new { error = "Failed to save mapping" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save Spotify mapping"); + return StatusCode(500, new { error = ex.Message }); + } + } + + /// + /// Deletes a Spotify track mapping + /// + [HttpDelete("spotify/mappings/{spotifyId}")] + public async Task DeleteSpotifyMapping(string spotifyId) + { + try + { + var success = await _mappingService.DeleteMappingAsync(spotifyId); + if (success) + { + _logger.LogInformation("Deleted mapping for {SpotifyId}", spotifyId); + return Ok(new { success = true }); + } + + return NotFound(new { error = "Mapping not found" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete Spotify mapping for {SpotifyId}", spotifyId); + return StatusCode(500, new { error = "Failed to delete mapping" }); + } + } + + /// + /// Gets statistics about Spotify track mappings + /// + [HttpGet("spotify/mappings/stats")] + public async Task GetSpotifyMappingStats() + { + try + { + var stats = await _mappingService.GetStatsAsync(); + return Ok(stats); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get Spotify mapping stats"); + return StatusCode(500, new { error = "Failed to get stats" }); + } + } + + /// + /// Enriches metadata for external mappings that are missing title/artist/artwork + /// + private async Task EnrichExternalMappingsMetadataAsync(List mappings) + { + var metadataService = _serviceProvider.GetService(); + if (metadataService == null) + { + _logger.LogWarning("No metadata service available for enrichment"); + return; + } + + foreach (var mapping in mappings) + { + // Skip if not external or already has metadata + if (mapping.TargetType != "external" || + string.IsNullOrEmpty(mapping.ExternalProvider) || + string.IsNullOrEmpty(mapping.ExternalId)) + { + continue; + } + + // Skip if already has complete metadata + if (mapping.Metadata != null && + !string.IsNullOrEmpty(mapping.Metadata.Title) && + !string.IsNullOrEmpty(mapping.Metadata.Artist)) + { + continue; + } + + try + { + // Fetch track details from external provider + var song = await metadataService.GetSongAsync(mapping.ExternalProvider.ToLowerInvariant(), mapping.ExternalId); + + if (song != null) + { + // Update metadata + if (mapping.Metadata == null) + { + mapping.Metadata = new TrackMetadata(); + } + + mapping.Metadata.Title = song.Title; + mapping.Metadata.Artist = song.Artist; + mapping.Metadata.Album = song.Album; + mapping.Metadata.ArtworkUrl = song.CoverArtUrl; + mapping.Metadata.DurationMs = song.Duration.HasValue ? song.Duration.Value * 1000 : null; + + // Save enriched metadata back to cache + await _mappingService.SaveMappingAsync(mapping); + + _logger.LogDebug("Enriched metadata for {SpotifyId} from {Provider}: {Title} by {Artist}", + mapping.SpotifyId, mapping.ExternalProvider, song.Title, song.Artist); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to enrich metadata for {SpotifyId} from {Provider}:{ExternalId}", + mapping.SpotifyId, mapping.ExternalProvider, mapping.ExternalId); + } + } + } + +} diff --git a/allstarr/Filters/AdminPortFilter.cs b/allstarr/Filters/AdminPortFilter.cs index b69cb8c..2ac4903 100644 --- a/allstarr/Filters/AdminPortFilter.cs +++ b/allstarr/Filters/AdminPortFilter.cs @@ -10,13 +10,24 @@ namespace allstarr.Filters; public class AdminPortFilter : IActionFilter { private const int AdminPort = 5275; + private readonly ILogger _logger; + + public AdminPortFilter(ILogger logger) + { + _logger = logger; + } public void OnActionExecuting(ActionExecutingContext context) { var requestPort = context.HttpContext.Connection.LocalPort; + _logger.LogDebug("AdminPortFilter: Request to {Path} on port {Port} (admin port is {AdminPort})", + context.HttpContext.Request.Path, requestPort, AdminPort); + if (requestPort != AdminPort) { + _logger.LogWarning("Admin endpoint {Path} accessed on wrong port {Port}, rejecting", + context.HttpContext.Request.Path, requestPort); context.Result = new NotFoundResult(); } } diff --git a/allstarr/Filters/ApiKeyAuthFilter.cs b/allstarr/Filters/ApiKeyAuthFilter.cs index 345ad47..6e5f804 100644 --- a/allstarr/Filters/ApiKeyAuthFilter.cs +++ b/allstarr/Filters/ApiKeyAuthFilter.cs @@ -32,7 +32,7 @@ public class ApiKeyAuthFilter : IAsyncActionFilter ?? request.Headers["X-Emby-Token"].FirstOrDefault(); // Validate API key - if (string.IsNullOrEmpty(apiKey) || !string.Equals(apiKey, _settings.ApiKey, StringComparison.Ordinal)) + if (string.IsNullOrEmpty(apiKey) || string.IsNullOrEmpty(_settings.ApiKey) || !FixedTimeEquals(apiKey, _settings.ApiKey)) { _logger.LogWarning("Unauthorized access attempt to {Path} from {IP}", request.Path, @@ -49,4 +49,18 @@ public class ApiKeyAuthFilter : IAsyncActionFilter _logger.LogInformation("API key authentication successful for {Path}", request.Path); await next(); } + + // Use a robust constant-time comparison by comparing fixed-length hashes of the inputs. + // This avoids leaking lengths and uses the platform's fixed-time compare helper. + private static bool FixedTimeEquals(string a, string b) + { + if (a == null || b == null) return false; + + // Compute SHA-256 hashes and compare them in constant time + using var sha = System.Security.Cryptography.SHA256.Create(); + var aHash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(a)); + var bHash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(b)); + + return System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(aHash, bHash); + } } diff --git a/allstarr/Middleware/RequestLoggingMiddleware.cs b/allstarr/Middleware/RequestLoggingMiddleware.cs new file mode 100644 index 0000000..5c28fd1 --- /dev/null +++ b/allstarr/Middleware/RequestLoggingMiddleware.cs @@ -0,0 +1,156 @@ +using System.Diagnostics; +using System.Text; + +namespace allstarr.Middleware; + +/// +/// Middleware that logs all incoming HTTP requests when debug logging is enabled. +/// Useful for debugging client issues and seeing what requests are being made. +/// +public class RequestLoggingMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + + public RequestLoggingMiddleware( + RequestDelegate next, + ILogger logger, + IConfiguration configuration) + { + _next = next; + _logger = logger; + _configuration = configuration; + + // Log initialization status + var initialValue = _configuration.GetValue("Debug:LogAllRequests"); + _logger.LogWarning("🔍 RequestLoggingMiddleware initialized - LogAllRequests={LogAllRequests}", initialValue); + + if (initialValue) + { + _logger.LogWarning("🔍 Request logging ENABLED - all HTTP requests will be logged"); + } + else + { + _logger.LogInformation("Request logging disabled (set DEBUG_LOG_ALL_REQUESTS=true to enable)"); + } + } + + public async Task InvokeAsync(HttpContext context) + { + // Check configuration on every request to allow dynamic toggling + var logAllRequests = _configuration.GetValue("Debug:LogAllRequests"); + + if (!logAllRequests) + { + await _next(context); + return; + } + + var stopwatch = Stopwatch.StartNew(); + var request = context.Request; + + // Log request details + var requestLog = new StringBuilder(); + requestLog.AppendLine($"📥 HTTP {request.Method} {request.Path}{request.QueryString}"); + requestLog.AppendLine($" Host: {request.Host}"); + requestLog.AppendLine($" Content-Type: {request.ContentType ?? "(none)"}"); + requestLog.AppendLine($" Content-Length: {request.ContentLength?.ToString() ?? "(none)"}"); + + // Log important headers + if (request.Headers.ContainsKey("User-Agent")) + { + requestLog.AppendLine($" User-Agent: {request.Headers["User-Agent"]}"); + } + if (request.Headers.ContainsKey("X-Emby-Authorization")) + { + requestLog.AppendLine($" X-Emby-Authorization: {MaskAuthHeader(request.Headers["X-Emby-Authorization"]!)}"); + } + if (request.Headers.ContainsKey("Authorization")) + { + requestLog.AppendLine($" Authorization: {MaskAuthHeader(request.Headers["Authorization"]!)}"); + } + if (request.Headers.ContainsKey("X-Emby-Token")) + { + requestLog.AppendLine($" X-Emby-Token: ***"); + } + if (request.Headers.ContainsKey("X-Emby-Device-Id")) + { + requestLog.AppendLine($" X-Emby-Device-Id: {request.Headers["X-Emby-Device-Id"]}"); + } + if (request.Headers.ContainsKey("X-Emby-Client")) + { + requestLog.AppendLine($" X-Emby-Client: {request.Headers["X-Emby-Client"]}"); + } + + _logger.LogInformation(requestLog.ToString().TrimEnd()); + + // Capture response status + var originalBodyStream = context.Response.Body; + + try + { + await _next(context); + + stopwatch.Stop(); + + // Log response + _logger.LogInformation( + "📤 HTTP {Method} {Path} → {StatusCode} ({ElapsedMs}ms)", + request.Method, + request.Path, + context.Response.StatusCode, + stopwatch.ElapsedMilliseconds); + } + catch (Exception ex) + { + stopwatch.Stop(); + _logger.LogError(ex, + "❌ HTTP {Method} {Path} → EXCEPTION ({ElapsedMs}ms)", + request.Method, + request.Path, + stopwatch.ElapsedMilliseconds); + throw; + } + } + + private static string MaskAuthHeader(string authHeader) + { + // Mask tokens in auth headers for security + if (string.IsNullOrEmpty(authHeader)) + return "(empty)"; + + // For MediaBrowser format: MediaBrowser Client="...", Token="..." + if (authHeader.Contains("Token=", StringComparison.OrdinalIgnoreCase)) + { + var parts = authHeader.Split(','); + var masked = new List(); + foreach (var part in parts) + { + if (part.Contains("Token=", StringComparison.OrdinalIgnoreCase)) + { + masked.Add("Token=\"***\""); + } + else + { + masked.Add(part.Trim()); + } + } + return string.Join(", ", masked); + } + + // For Bearer tokens + if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + return "Bearer ***"; + } + + // For other formats, just mask everything after first 10 chars + if (authHeader.Length > 10) + { + return authHeader.Substring(0, 10) + "***"; + } + + return "***"; + } +} diff --git a/allstarr/Middleware/WebSocketProxyMiddleware.cs b/allstarr/Middleware/WebSocketProxyMiddleware.cs index b4a9482..1491f6f 100644 --- a/allstarr/Middleware/WebSocketProxyMiddleware.cs +++ b/allstarr/Middleware/WebSocketProxyMiddleware.cs @@ -110,7 +110,9 @@ public class WebSocketProxyMiddleware jellyfinWsUrl += context.Request.QueryString.Value; } - _logger.LogDebug("🔗 WEBSOCKET: Connecting to Jellyfin WebSocket: {Url}", jellyfinWsUrl); + // Build masked query string for safe logging + var maskedQuery = BuildMaskedQuery(context.Request.QueryString.Value); + _logger.LogDebug("🔗 WEBSOCKET: Connecting to Jellyfin WebSocket: {BaseUrl}{MaskedQuery}", jellyfinWsUrl.Split('?')[0], maskedQuery); // Connect to Jellyfin WebSocket serverWebSocket = new ClientWebSocket(); @@ -139,7 +141,13 @@ public class WebSocketProxyMiddleware } // Set user agent +<<<<<<< HEAD serverWebSocket.Options.SetRequestHeader("User-Agent", "Allstarr/1.0.1"); +||||||| f68706f + serverWebSocket.Options.SetRequestHeader("User-Agent", "Allstarr/1.0"); +======= + serverWebSocket.Options.SetRequestHeader("User-Agent", "Allstarr/1.0.3"); +>>>>>>> beta await serverWebSocket.ConnectAsync(new Uri(jellyfinWsUrl), context.RequestAborted); _logger.LogInformation("✓ WEBSOCKET: Connected to Jellyfin WebSocket"); @@ -210,6 +218,32 @@ public class WebSocketProxyMiddleware } } + // Helper for building a masked query string for logging. Redacts sensitive keys. + public static string BuildMaskedQuery(string? queryString) + { + if (string.IsNullOrEmpty(queryString)) return string.Empty; + + var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString); + var parts = new List(); + foreach (var kv in query) + { + var key = kv.Key; + var value = kv.Value.ToString(); + if (string.Equals(key, "api_key", StringComparison.OrdinalIgnoreCase) || + string.Equals(key, "token", StringComparison.OrdinalIgnoreCase) || + string.Equals(key, "auth", StringComparison.OrdinalIgnoreCase)) + { + parts.Add($"{key}="); + } + else + { + parts.Add($"{key}={value}"); + } + } + + return parts.Count > 0 ? "?" + string.Join("&", parts) : string.Empty; + } + private async Task ProxyMessagesAsync( WebSocket source, WebSocket destination, diff --git a/allstarr/Models/Admin/AdminDtos.cs b/allstarr/Models/Admin/AdminDtos.cs new file mode 100644 index 0000000..ac61644 --- /dev/null +++ b/allstarr/Models/Admin/AdminDtos.cs @@ -0,0 +1,83 @@ +namespace allstarr.Models.Admin; + +public class ManualMappingRequest +{ + public string SpotifyId { get; set; } = ""; + public string? JellyfinId { get; set; } + public string? ExternalProvider { get; set; } + public string? ExternalId { get; set; } +} + +public class LyricsMappingRequest +{ + public string Artist { get; set; } = ""; + public string Title { get; set; } = ""; + public string? Album { get; set; } + public int DurationSeconds { get; set; } + public int LyricsId { get; set; } +} + +public class ManualMappingEntry +{ + public string SpotifyId { get; set; } = ""; + public string? JellyfinId { get; set; } + public string? ExternalProvider { get; set; } + public string? ExternalId { get; set; } + public DateTime CreatedAt { get; set; } +} + +public class LyricsMappingEntry +{ + public string Artist { get; set; } = ""; + public string Title { get; set; } = ""; + public string? Album { get; set; } + public int DurationSeconds { get; set; } + public int LyricsId { get; set; } + public DateTime CreatedAt { get; set; } +} + +public class ConfigUpdateRequest +{ + public Dictionary Updates { get; set; } = new(); +} + +public class AddPlaylistRequest +{ + public string Name { get; set; } = string.Empty; + public string SpotifyId { get; set; } = string.Empty; + public string LocalTracksPosition { get; set; } = "first"; +} + +public class LinkPlaylistRequest +{ + public string Name { get; set; } = string.Empty; + public string SpotifyPlaylistId { get; set; } = string.Empty; + public string SyncSchedule { get; set; } = "0 8 * * *"; +} + +public class UpdateScheduleRequest +{ + public string SyncSchedule { get; set; } = string.Empty; +} +public class SpotifyMappingRequest +{ + public required string SpotifyId { get; set; } + public required string TargetType { get; set; } // "local" or "external" + public string? LocalId { get; set; } + public string? ExternalProvider { get; set; } + public string? ExternalId { get; set; } + public TrackMetadataRequest? Metadata { get; set; } +} + +public class TrackMetadataRequest +{ + public string? Title { get; set; } + public string? Artist { get; set; } + public string? Album { get; set; } + public string? ArtworkUrl { get; set; } + public int? DurationMs { get; set; } +} + +/// +/// Request model for updating configuration +/// diff --git a/allstarr/Models/Scrobbling/PlaybackSession.cs b/allstarr/Models/Scrobbling/PlaybackSession.cs new file mode 100644 index 0000000..d285155 --- /dev/null +++ b/allstarr/Models/Scrobbling/PlaybackSession.cs @@ -0,0 +1,66 @@ +namespace allstarr.Models.Scrobbling; + +/// +/// Tracks playback state for scrobbling decisions. +/// +public class PlaybackSession +{ + /// + /// Unique identifier for this playback session. + /// + public required string SessionId { get; init; } + + /// + /// Device ID of the client. + /// + public required string DeviceId { get; init; } + + /// + /// Track being played. + /// + public required ScrobbleTrack Track { get; init; } + + /// + /// When playback started (UTC). + /// + public DateTime StartTime { get; init; } + + /// + /// Last reported playback position in seconds. + /// + public int LastPositionSeconds { get; set; } + + /// + /// Whether "Now Playing" has been sent for this session. + /// + public bool NowPlayingSent { get; set; } + + /// + /// Whether the track has been scrobbled. + /// + public bool Scrobbled { get; set; } + + /// + /// Last activity timestamp (for cleanup). + /// + public DateTime LastActivity { get; set; } + + /// + /// Checks if the track should be scrobbled based on Last.fm rules: + /// - Track must be longer than 30 seconds + /// - Track has been played for at least half its duration, or for 4 minutes (whichever occurs earlier) + /// + public bool ShouldScrobble() + { + if (Scrobbled) + return false; // Already scrobbled + + if (Track.DurationSeconds == null || Track.DurationSeconds <= 30) + return false; // Track too short or duration unknown + + var halfDuration = Track.DurationSeconds.Value / 2; + var scrobbleThreshold = Math.Min(halfDuration, 240); // 4 minutes = 240 seconds + + return LastPositionSeconds >= scrobbleThreshold; + } +} diff --git a/allstarr/Models/Scrobbling/ScrobbleResult.cs b/allstarr/Models/Scrobbling/ScrobbleResult.cs new file mode 100644 index 0000000..bd8cc77 --- /dev/null +++ b/allstarr/Models/Scrobbling/ScrobbleResult.cs @@ -0,0 +1,90 @@ +namespace allstarr.Models.Scrobbling; + +/// +/// Result of a scrobble or now playing request. +/// +public record ScrobbleResult +{ + /// + /// Whether the request was successful. + /// + public bool Success { get; init; } + + /// + /// Error message if the request failed. + /// + public string? ErrorMessage { get; init; } + + /// + /// Error code from the service (e.g., Last.fm error code). + /// + public int? ErrorCode { get; init; } + + /// + /// Whether the scrobble was ignored by the service (filtered). + /// + public bool Ignored { get; init; } + + /// + /// Reason the scrobble was ignored (if applicable). + /// + public string? IgnoredReason { get; init; } + + /// + /// Ignored message code (e.g., Last.fm ignored code). + /// + public int? IgnoredCode { get; init; } + + /// + /// Whether the artist was corrected by the service. + /// + public bool ArtistCorrected { get; init; } + + /// + /// Corrected artist name (if applicable). + /// + public string? CorrectedArtist { get; init; } + + /// + /// Whether the track was corrected by the service. + /// + public bool TrackCorrected { get; init; } + + /// + /// Corrected track name (if applicable). + /// + public string? CorrectedTrack { get; init; } + + /// + /// Whether the album was corrected by the service. + /// + public bool AlbumCorrected { get; init; } + + /// + /// Corrected album name (if applicable). + /// + public string? CorrectedAlbum { get; init; } + + /// + /// Whether the request should be retried (e.g., service offline, temporary error). + /// + public bool ShouldRetry { get; init; } + + public static ScrobbleResult CreateSuccess() => new() { Success = true }; + + public static ScrobbleResult CreateError(string message, int? errorCode = null, bool shouldRetry = false) => new() + { + Success = false, + ErrorMessage = message, + ErrorCode = errorCode, + ShouldRetry = shouldRetry + }; + + public static ScrobbleResult CreateIgnored(string reason, int ignoredCode) => new() + { + Success = true, + Ignored = true, + IgnoredReason = reason, + IgnoredCode = ignoredCode + }; +} diff --git a/allstarr/Models/Scrobbling/ScrobbleTrack.cs b/allstarr/Models/Scrobbling/ScrobbleTrack.cs new file mode 100644 index 0000000..06ae9ca --- /dev/null +++ b/allstarr/Models/Scrobbling/ScrobbleTrack.cs @@ -0,0 +1,55 @@ +namespace allstarr.Models.Scrobbling; + +/// +/// Represents a track to be scrobbled. +/// +public record ScrobbleTrack +{ + /// + /// Track title (required). + /// + public required string Title { get; init; } + + /// + /// Artist name (required). + /// + public required string Artist { get; init; } + + /// + /// Album name (optional). + /// + public string? Album { get; init; } + + /// + /// Album artist (optional). + /// + public string? AlbumArtist { get; init; } + + /// + /// Track duration in seconds (optional but recommended). + /// + public int? DurationSeconds { get; init; } + + /// + /// MusicBrainz Track ID (optional). + /// + public string? MusicBrainzId { get; init; } + + /// + /// Unix timestamp when the track started playing (required for scrobbles). + /// + public long? Timestamp { get; init; } + + /// + /// Whether the track was chosen by the user (true) or by an algorithm/radio (false). + /// Default is true. Set to false for Last.fm radio, recommendation services, etc. + /// + public bool ChosenByUser { get; init; } = true; + + /// + /// Whether the track is from an external source (Spotify, Deezer, etc.) or local library. + /// Default is false (local library). Set to true for external tracks. + /// ListenBrainz only scrobbles external tracks. + /// + public bool IsExternal { get; init; } = false; +} diff --git a/allstarr/Models/Settings/JellyfinSettings.cs b/allstarr/Models/Settings/JellyfinSettings.cs index b7c62b3..25e8cb8 100644 --- a/allstarr/Models/Settings/JellyfinSettings.cs +++ b/allstarr/Models/Settings/JellyfinSettings.cs @@ -43,7 +43,13 @@ public class JellyfinSettings /// /// Client version reported to Jellyfin /// +<<<<<<< HEAD public string ClientVersion { get; set; } = "1.0.1"; +||||||| f68706f + public string ClientVersion { get; set; } = "1.0.0"; +======= + public string ClientVersion { get; set; } = AppVersion.Version; +>>>>>>> beta /// /// Device ID reported to Jellyfin diff --git a/allstarr/Models/Settings/ScrobblingSettings.cs b/allstarr/Models/Settings/ScrobblingSettings.cs new file mode 100644 index 0000000..c9826c3 --- /dev/null +++ b/allstarr/Models/Settings/ScrobblingSettings.cs @@ -0,0 +1,87 @@ +namespace allstarr.Models.Settings; + +/// +/// Settings for scrobbling services (Last.fm, ListenBrainz, etc.). +/// +public class ScrobblingSettings +{ + /// + /// Whether scrobbling is enabled globally. + /// + public bool Enabled { get; set; } + + /// + /// Whether to scrobble local library tracks. + /// Recommended: Keep disabled and use native Jellyfin plugins instead. + /// + public bool LocalTracksEnabled { get; set; } + + /// + /// Last.fm settings. + /// + public LastFmSettings LastFm { get; set; } = new(); + + /// + /// ListenBrainz settings (future). + /// + public ListenBrainzSettings ListenBrainz { get; set; } = new(); +} + +/// +/// Last.fm scrobbling settings. +/// +public class LastFmSettings +{ + /// + /// Whether Last.fm scrobbling is enabled. + /// + public bool Enabled { get; set; } + + /// + /// Last.fm API key (32-character hex string). + /// Uses hardcoded credentials from Jellyfin Last.fm plugin for convenience. + /// Users can override by setting SCROBBLING_LASTFM_API_KEY in .env + /// + public string ApiKey { get; set; } = "cb3bdcd415fcb40cd572b137b2b255f5"; + + /// + /// Last.fm shared secret (32-character hex string). + /// Uses hardcoded credentials from Jellyfin Last.fm plugin for convenience. + /// Users can override by setting SCROBBLING_LASTFM_SHARED_SECRET in .env + /// + public string SharedSecret { get; set; } = "3a08f9fad6ddc4c35b0dce0062cecb5e"; + + /// + /// Last.fm session key (obtained via Mobile Authentication). + /// This is user-specific and has infinite lifetime (unless revoked by user). + /// + public string SessionKey { get; set; } = string.Empty; + + /// + /// Last.fm username. + /// + public string? Username { get; set; } + + /// + /// Last.fm password (stored for automatic re-authentication if needed). + /// Only used for authentication, not stored in plaintext in production. + /// + public string? Password { get; set; } +} + +/// +/// ListenBrainz scrobbling settings (future implementation). +/// +public class ListenBrainzSettings +{ + /// + /// Whether ListenBrainz scrobbling is enabled. + /// + public bool Enabled { get; set; } + + /// + /// ListenBrainz user token. + /// Get from: https://listenbrainz.org/profile/ + /// + public string UserToken { get; set; } = string.Empty; +} diff --git a/allstarr/Models/Settings/SpotifyImportSettings.cs b/allstarr/Models/Settings/SpotifyImportSettings.cs index 48956b9..ba6fae8 100644 --- a/allstarr/Models/Settings/SpotifyImportSettings.cs +++ b/allstarr/Models/Settings/SpotifyImportSettings.cs @@ -45,6 +45,7 @@ public class SpotifyPlaylistConfig /// Where to position local tracks: "first" or "last" /// public LocalTracksPosition LocalTracksPosition { get; set; } = LocalTracksPosition.First; +<<<<<<< HEAD /// /// Cron schedule for syncing this playlist with Spotify @@ -53,6 +54,17 @@ public class SpotifyPlaylistConfig /// Default: "0 8 * * 1" (weekly on Monday at 8 AM) /// public string SyncSchedule { get; set; } = "0 8 * * 1"; +||||||| f68706f +======= + + /// + /// Cron schedule for syncing this playlist with Spotify + /// Format: minute hour day month dayofweek + /// Example: "0 8 * * *" = 8 AM every day + /// Default: "0 8 * * *" (daily at 8 AM) + /// + public string SyncSchedule { get; set; } = "0 8 * * *"; +>>>>>>> beta } /// diff --git a/allstarr/Models/Spotify/SpotifyTrackMapping.cs b/allstarr/Models/Spotify/SpotifyTrackMapping.cs new file mode 100644 index 0000000..6cf0b56 --- /dev/null +++ b/allstarr/Models/Spotify/SpotifyTrackMapping.cs @@ -0,0 +1,94 @@ +namespace allstarr.Models.Spotify; + +/// +/// Represents a global mapping from a Spotify track ID to either a local Jellyfin track or an external provider track. +/// This is a permanent mapping that speeds up playlist matching. +/// +public class SpotifyTrackMapping +{ + /// + /// Spotify track ID (e.g., "3n3Ppam7vgaVa1iaRUc9Lp") + /// + public required string SpotifyId { get; set; } + + /// + /// Target type: "local" or "external" + /// + public required string TargetType { get; set; } + + /// + /// Jellyfin item ID (if TargetType is "local") + /// + public string? LocalId { get; set; } + + /// + /// External provider name (if TargetType is "external") + /// + public string? ExternalProvider { get; set; } + + /// + /// External provider track ID (if TargetType is "external") + /// + public string? ExternalId { get; set; } + + /// + /// Track metadata for display purposes + /// + public TrackMetadata? Metadata { get; set; } + + /// + /// How this mapping was created: "auto" or "manual" + /// + public required string Source { get; set; } + + /// + /// When this mapping was created + /// + public DateTime CreatedAt { get; set; } + + /// + /// When this mapping was last updated (for manual overrides) + /// + public DateTime? UpdatedAt { get; set; } + + /// + /// When this mapping was last validated (checked if target still exists) + /// + public DateTime? LastValidatedAt { get; set; } + + /// + /// Whether this mapping needs validation + /// Local: every 7 days, External: every playlist sync + /// + public bool NeedsValidation(bool isPlaylistSync = false) + { + if (!LastValidatedAt.HasValue) return true; + + var timeSinceValidation = DateTime.UtcNow - LastValidatedAt.Value; + + if (TargetType == "local") + { + // Local mappings: validate every 7 days + return timeSinceValidation.TotalDays >= 7; + } + else if (TargetType == "external") + { + // External mappings: validate on every playlist sync + return isPlaylistSync; + } + + return false; + } +} + +/// +/// Track metadata for display in Admin UI +/// +public class TrackMetadata +{ + public string? Title { get; set; } + public string? Artist { get; set; } + public string? Album { get; set; } + public string? ArtworkUrl { get; set; } + public int? DurationMs { get; set; } +} diff --git a/allstarr/Models/Subsonic/ExternalPlaylist.cs b/allstarr/Models/Subsonic/ExternalPlaylist.cs index d1db309..faffa09 100644 --- a/allstarr/Models/Subsonic/ExternalPlaylist.cs +++ b/allstarr/Models/Subsonic/ExternalPlaylist.cs @@ -6,8 +6,9 @@ namespace allstarr.Models.Subsonic; public class ExternalPlaylist { /// - /// Unique identifier in the format "pl-{provider}-{externalId}" - /// Example: "pl-deezer-123456" or "pl-qobuz-789" + /// Unique identifier in the format "ext-{provider}-playlist-{externalId}" + /// Example: "ext-deezer-playlist-123456" or "ext-qobuz-playlist-789" + /// This matches the format used for albums and songs for consistency. /// public string Id { get; set; } = string.Empty; @@ -32,7 +33,7 @@ public class ExternalPlaylist public string Provider { get; set; } = string.Empty; /// - /// External ID from the provider (without "pl-" prefix) + /// External ID from the provider (without "ext-{provider}-playlist-" prefix) /// public string ExternalId { get; set; } = string.Empty; diff --git a/allstarr/Program.cs b/allstarr/Program.cs index 41aaf06..ee16339 100644 --- a/allstarr/Program.cs +++ b/allstarr/Program.cs @@ -9,6 +9,7 @@ using allstarr.Services.Subsonic; using allstarr.Services.Jellyfin; using allstarr.Services.Common; using allstarr.Services.Lyrics; +using allstarr.Services.Scrobbling; using allstarr.Middleware; using allstarr.Filters; using Microsoft.Extensions.Http; @@ -41,21 +42,20 @@ static List DecodeSquidWtfUrls() { var encodedUrls = new[] { - "aHR0cHM6Ly90cml0b24uc3F1aWQud3Rm", // triton.squid.wtf - "aHR0cHM6Ly90aWRhbC1hcGkuYmluaW11bS5vcmc=", // tidal-api.binimum.org - "aHR0cHM6Ly90aWRhbC5raW5vcGx1cy5vbmxpbmU=", // tidal.kinoplus.online - "aHR0cHM6Ly9oaWZpLXR3by5zcG90aXNhdmVyLm5ldA==", // hifi-two.spotisaver.net - "aHR0cHM6Ly9oaWZpLW9uZS5zcG90aXNhdmVyLm5ldA==", // hifi-one.spotisaver.net - "aHR0cHM6Ly93b2xmLnFxZGwuc2l0ZQ==", // wolf.qqdl.site - "aHR0cDovL2h1bmQucXFkbC5zaXRl", // hund.qqdl.site (http) - "aHR0cHM6Ly9rYXR6ZS5xcWRsLnNpdGU=", // katze.qqdl.site - "aHR0cHM6Ly92b2dlbC5xcWRsLnNpdGU=", // vogel.qqdl.site - "aHR0cHM6Ly9tYXVzLnFxZGwuc2l0ZQ==", // maus.qqdl.site - "aHR0cHM6Ly9ldS1jZW50cmFsLm1vbm9jaHJvbWUudGY=", // eu-central.monochrome.tf - "aHR0cHM6Ly91cy13ZXN0Lm1vbm9jaHJvbWUudGY=", // us-west.monochrome.tf - "aHR0cHM6Ly9hcnJhbi5tb25vY2hyb21lLnRm", // arran.monochrome.tf - "aHR0cHM6Ly9hcGkubW9ub2Nocm9tZS50Zg==", // api.monochrome.tf - "aHR0cHM6Ly9odW5kLnFxZGwuc2l0ZQ==" // hund.qqdl.site (https) + "aHR0cHM6Ly90cml0b24uc3F1aWQud3Rm", // triton + "aHR0cHM6Ly90aWRhbC5raW5vcGx1cy5vbmxpbmU=", // kinoplus + "aHR0cHM6Ly9oaWZpLXR3by5zcG90aXNhdmVyLm5ldA==", // spotisaver-two + "aHR0cHM6Ly9oaWZpLW9uZS5zcG90aXNhdmVyLm5ldA==", // spotisaver-one + "aHR0cHM6Ly93b2xmLnFxZGwuc2l0ZQ==", // wolf + "aHR0cDovL2h1bmQucXFkbC5zaXRl", // hund-http + "aHR0cHM6Ly9rYXR6ZS5xcWRsLnNpdGU=", // katze + "aHR0cHM6Ly92b2dlbC5xcWRsLnNpdGU=", // vogel + "aHR0cHM6Ly9tYXVzLnFxZGwuc2l0ZQ==", // maus + "aHR0cHM6Ly9ldS1jZW50cmFsLm1vbm9jaHJvbWUudGY=", // eu-central + "aHR0cHM6Ly91cy13ZXN0Lm1vbm9jaHJvbWUudGY=", // us-west + "aHR0cHM6Ly9hcnJhbi5tb25vY2hyb21lLnRm", // arran + "aHR0cHM6Ly9hcGkubW9ub2Nocm9tZS50Zg==", // api + "aHR0cHM6Ly9odW5kLnFxZGwuc2l0ZQ==" // hund }; return encodedUrls @@ -137,6 +137,9 @@ builder.Services.AddProblemDetails(); // Admin port filter (restricts admin API to port 5275) builder.Services.AddScoped(); +// Admin helper service (shared utilities for admin controllers) +builder.Services.AddSingleton(); + // Configuration - register both settings, active one determined by backend type builder.Services.Configure( builder.Configuration.GetSection("Subsonic")); @@ -165,7 +168,7 @@ builder.Services.Configure(options => #pragma warning restore CS0618 // Parse SPOTIFY_IMPORT_PLAYLISTS env var (JSON array format) - // Format: [["Name","SpotifyId","JellyfinId","first|last"],["Name2","SpotifyId2","JellyfinId2","first|last"]] + // Format: [["Name","SpotifyId","JellyfinId","first|last","cronSchedule"],["Name2","SpotifyId2","JellyfinId2","first|last","cronSchedule"]] var playlistsEnv = builder.Configuration.GetValue("SpotifyImport:Playlists"); if (!string.IsNullOrWhiteSpace(playlistsEnv)) { @@ -192,10 +195,11 @@ builder.Services.Configure(options => 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 * * *" }; options.Playlists.Add(config); - Console.WriteLine($" Added: {config.Name} (Spotify: {config.Id}, Jellyfin: {config.JellyfinId}, Position: {config.LocalTracksPosition})"); + Console.WriteLine($" Added: {config.Name} (Spotify: {config.Id}, Jellyfin: {config.JellyfinId}, Position: {config.LocalTracksPosition}, Schedule: {config.SyncSchedule})"); } } } @@ -207,7 +211,7 @@ builder.Services.Configure(options => catch (System.Text.Json.JsonException ex) { Console.WriteLine($"Warning: Failed to parse SPOTIFY_IMPORT_PLAYLISTS: {ex.Message}"); - Console.WriteLine("Expected format: [[\"Name\",\"SpotifyId\",\"JellyfinId\",\"first|last\"],[\"Name2\",\"SpotifyId2\",\"JellyfinId2\",\"first|last\"]]"); + Console.WriteLine("Expected format: [[\"Name\",\"SpotifyId\",\"JellyfinId\",\"first|last\",\"cronSchedule\"],[\"Name2\",\"SpotifyId2\",\"JellyfinId2\",\"first|last\",\"cronSchedule\"]]"); Console.WriteLine("Will try legacy format instead"); } } @@ -580,12 +584,31 @@ builder.Services.AddSingleton(); // Register Spotify lyrics service (uses Spotify's color-lyrics API) builder.Services.AddSingleton(); +<<<<<<< HEAD // Register LyricsPlus service (multi-source lyrics API) builder.Services.AddSingleton(); // Register Lyrics Orchestrator (manages priority-based lyrics fetching) builder.Services.AddSingleton(); +||||||| f68706f +======= +// Register LyricsPlus service (multi-source lyrics API) +builder.Services.AddSingleton(); + +// Register Lyrics Orchestrator (manages priority-based lyrics fetching) +builder.Services.AddSingleton(); + +// Register Spotify mapping service (global Spotify ID → Local/External mappings) +builder.Services.AddSingleton(); + +// Register Spotify mapping validation service (validates and upgrades mappings) +builder.Services.AddSingleton(); + +// Register Spotify mapping migration service (migrates legacy per-playlist mappings to global format) +builder.Services.AddHostedService(); + +>>>>>>> beta // Register Spotify playlist fetcher (uses direct Spotify API when SpotifyApi is enabled) builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); @@ -602,6 +625,70 @@ builder.Services.AddHostedService(sp => sp.GetRequiredService(); // builder.Services.AddHostedService(sp => sp.GetRequiredService()); +// Register scrobbling services (Last.fm, ListenBrainz, etc.) +builder.Services.Configure(options => +{ + // Last.fm settings + var lastFmEnabled = builder.Configuration.GetValue("Scrobbling:LastFm:Enabled"); + var lastFmApiKey = builder.Configuration.GetValue("Scrobbling:LastFm:ApiKey"); + var lastFmSharedSecret = builder.Configuration.GetValue("Scrobbling:LastFm:SharedSecret"); + var lastFmSessionKey = builder.Configuration.GetValue("Scrobbling:LastFm:SessionKey"); + var lastFmUsername = builder.Configuration.GetValue("Scrobbling:LastFm:Username"); + var lastFmPassword = builder.Configuration.GetValue("Scrobbling:LastFm:Password"); + + options.Enabled = builder.Configuration.GetValue("Scrobbling:Enabled"); + options.LocalTracksEnabled = builder.Configuration.GetValue("Scrobbling:LocalTracksEnabled"); + options.LastFm.Enabled = lastFmEnabled; + + // Only override hardcoded API credentials if explicitly set in config + if (!string.IsNullOrEmpty(lastFmApiKey)) + options.LastFm.ApiKey = lastFmApiKey; + if (!string.IsNullOrEmpty(lastFmSharedSecret)) + options.LastFm.SharedSecret = lastFmSharedSecret; + + // These don't have defaults, so set them normally + options.LastFm.SessionKey = lastFmSessionKey ?? string.Empty; + options.LastFm.Username = lastFmUsername; + options.LastFm.Password = lastFmPassword; + + // ListenBrainz settings + var listenBrainzEnabled = builder.Configuration.GetValue("Scrobbling:ListenBrainz:Enabled"); + var listenBrainzUserToken = builder.Configuration.GetValue("Scrobbling:ListenBrainz:UserToken") ?? string.Empty; + + options.ListenBrainz.Enabled = listenBrainzEnabled; + options.ListenBrainz.UserToken = listenBrainzUserToken; + + // Debug logging + Console.WriteLine($"Scrobbling Configuration:"); + Console.WriteLine($" Enabled: {options.Enabled}"); + Console.WriteLine($" Local Tracks Enabled: {options.LocalTracksEnabled}"); + Console.WriteLine($" Last.fm Enabled: {options.LastFm.Enabled}"); + Console.WriteLine($" Last.fm Username: {options.LastFm.Username ?? "(not set)"}"); + Console.WriteLine($" Last.fm Session Key: {(string.IsNullOrEmpty(options.LastFm.SessionKey) ? "(not set)" : "***" + options.LastFm.SessionKey[^8..])}"); + Console.WriteLine($" ListenBrainz Enabled: {options.ListenBrainz.Enabled}"); + Console.WriteLine($" ListenBrainz Token: {(string.IsNullOrEmpty(options.ListenBrainz.UserToken) ? "(not set)" : "***" + options.ListenBrainz.UserToken[^8..])}"); +}); + +// Register Last.fm HTTP client with proper User-Agent +builder.Services.AddHttpClient("LastFm", client => +{ + client.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0 (https://github.com/sopat712/allstarr)"); + client.Timeout = TimeSpan.FromSeconds(30); +}); + +// Register ListenBrainz HTTP client with proper User-Agent +builder.Services.AddHttpClient("ListenBrainz", client => +{ + client.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0 (https://github.com/sopat712/allstarr)"); + client.Timeout = TimeSpan.FromSeconds(30); +}); + +// Register scrobbling services +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + // Register MusicBrainz service for metadata enrichment builder.Services.Configure(options => { @@ -659,11 +746,23 @@ catch (Exception ex) } // Configure the HTTP request pipeline. +<<<<<<< HEAD // IMPORTANT: UseForwardedHeaders must be called BEFORE other middleware // This processes X-Forwarded-For, X-Real-IP, etc. from nginx app.UseForwardedHeaders(); +||||||| f68706f +======= + +// IMPORTANT: UseForwardedHeaders must be called BEFORE other middleware +// This processes X-Forwarded-For, X-Real-IP, etc. from nginx +app.UseForwardedHeaders(); + +// Request logging middleware (when DEBUG_LOG_ALL_REQUESTS=true) +app.UseMiddleware(); + +>>>>>>> beta app.UseExceptionHandler(_ => { }); // Global exception handler // Enable response compression EARLY in the pipeline @@ -718,8 +817,22 @@ class BackendControllerFeatureProvider : Microsoft.AspNetCore.Mvc.Controllers.Co var isController = base.IsController(typeInfo); if (!isController) return false; - // AdminController should always be registered (for web UI) - if (typeInfo.Name == "AdminController") return true; + // All admin controllers should always be registered (for admin UI) + // This includes: AdminController, ConfigController, DiagnosticsController, DownloadsController, + // PlaylistController, JellyfinAdminController, SpotifyAdminController, LyricsController, MappingController, ScrobblingAdminController + if (typeInfo.Name == "AdminController" || + typeInfo.Name == "ConfigController" || + typeInfo.Name == "DiagnosticsController" || + typeInfo.Name == "DownloadsController" || + typeInfo.Name == "PlaylistController" || + typeInfo.Name == "JellyfinAdminController" || + typeInfo.Name == "SpotifyAdminController" || + typeInfo.Name == "LyricsController" || + typeInfo.Name == "MappingController" || + typeInfo.Name == "ScrobblingAdminController") + { + return true; + } // Only register the controller matching the configured backend type return _backendType switch diff --git a/allstarr/Services/Admin/AdminHelperService.cs b/allstarr/Services/Admin/AdminHelperService.cs new file mode 100644 index 0000000..362e315 --- /dev/null +++ b/allstarr/Services/Admin/AdminHelperService.cs @@ -0,0 +1,550 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using allstarr.Models.Settings; +using allstarr.Models.Spotify; + +namespace allstarr.Services.Admin; + +public class AdminHelperService +{ + private readonly ILogger _logger; + private readonly JellyfinSettings _jellyfinSettings; + private readonly string _envFilePath; + + public AdminHelperService( + ILogger logger, + IOptions jellyfinSettings, + IWebHostEnvironment environment) + { + _logger = logger; + _jellyfinSettings = jellyfinSettings.Value; + _envFilePath = environment.IsDevelopment() + ? Path.Combine(environment.ContentRootPath, "..", ".env") + : "/app/.env"; + } + + public string GetJellyfinAuthHeader() + { + return $"MediaBrowser Client=\"Allstarr\", Device=\"Server\", DeviceId=\"allstarr-admin\", Version=\"{AppVersion.Version}\", Token=\"{_jellyfinSettings.ApiKey}\""; + } + + public async Task> ReadPlaylistsFromEnvFileAsync() + { + var playlists = new List(); + + if (!File.Exists(_envFilePath)) + { + return playlists; + } + + try + { + var lines = await File.ReadAllLinesAsync(_envFilePath); + foreach (var line in lines) + { + if (line.TrimStart().StartsWith("SPOTIFY_IMPORT_PLAYLISTS=")) + { + var value = line.Substring(line.IndexOf('=') + 1).Trim(); + + if (string.IsNullOrWhiteSpace(value) || value == "[]") + { + return playlists; + } + + var playlistArrays = JsonSerializer.Deserialize(value); + if (playlistArrays != null) + { + foreach (var arr in playlistArrays) + { + if (arr.Length >= 2) + { + playlists.Add(new SpotifyPlaylistConfig + { + Name = arr[0].Trim(), + Id = arr[1].Trim(), + JellyfinId = arr.Length >= 3 ? arr[2].Trim() : "", + LocalTracksPosition = arr.Length >= 4 && + arr[3].Trim().Equals("last", StringComparison.OrdinalIgnoreCase) + ? LocalTracksPosition.Last + : LocalTracksPosition.First, + SyncSchedule = arr.Length >= 5 ? arr[4].Trim() : "0 8 * * *" + }); + } + } + } + break; + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to read playlists from .env file"); + } + + return playlists; + } + + public static string MaskValue(string? value, int showLast = 0) + { + if (string.IsNullOrEmpty(value)) return "(not set)"; + if (value.Length <= showLast) return "***"; + return showLast > 0 ? "***" + value[^showLast..] : value[..8] + "..."; + } + + public static string SanitizeFileName(string name) + { + return string.Join("_", name.Split(Path.GetInvalidFileNameChars())); + } + + public static bool IsValidEnvKey(string key) + { + return Regex.IsMatch(key, @"^[A-Z_][A-Z0-9_]*$", RegexOptions.IgnoreCase); + } + + /// + /// Truncates a string for safe logging, adding ellipsis if truncated. + /// + public static string TruncateForLogging(string? str, int maxLength) + { + if (string.IsNullOrEmpty(str)) + return str ?? string.Empty; + + if (str.Length <= maxLength) + return str; + + return str[..maxLength] + "..."; + } + + /// + /// Validates if a username is safe (no control characters or shell metacharacters). + /// + public static bool IsValidUsername(string? username) + { + if (string.IsNullOrWhiteSpace(username)) + return false; + + // Reject control characters and dangerous shell metacharacters + var dangerousChars = new[] { '\n', '\r', '\t', ';', '|', '&', '`', '$', '(', ')' }; + return !username.Any(c => char.IsControl(c) || dangerousChars.Contains(c)); + } + + /// + /// Validates if a password is safe (no control characters). + /// + public static bool IsValidPassword(string? password) + { + if (string.IsNullOrWhiteSpace(password)) + return false; + + // Reject control characters (except space which is allowed) + return !password.Any(c => char.IsControl(c)); + } + + /// + /// Validates if a URL is safe (http or https only). + /// + public static bool IsValidUrl(string? urlString) + { + if (string.IsNullOrWhiteSpace(urlString)) + return false; + + if (!Uri.TryCreate(urlString, UriKind.Absolute, out var uri)) + return false; + + // Only allow http and https + return uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps; + } + + /// + /// Validates if a file path is safe (no shell metacharacters or control characters). + /// + public static bool IsValidPath(string? pathString) + { + if (string.IsNullOrWhiteSpace(pathString)) + return false; + + // Reject control characters and dangerous shell metacharacters + var dangerousChars = new[] { '\n', '\r', '\0', ';', '|', '&', '`', '$' }; + return !pathString.Any(c => char.IsControl(c) || dangerousChars.Contains(c)); + } + + /// + /// Sanitizes HTML by escaping special characters to prevent XSS. + /// + public static string SanitizeHtml(string? html) + { + if (string.IsNullOrEmpty(html)) + return html ?? string.Empty; + + return html + .Replace("&", "&") + .Replace("<", "<") + .Replace(">", ">") + .Replace("\"", """) + .Replace("'", "'"); + } + + /// + /// Removes control characters from a string for safe logging/display. + /// + public static string RemoveControlCharacters(string? str) + { + if (string.IsNullOrEmpty(str)) + return str ?? string.Empty; + + return new string(str.Where(c => !char.IsControl(c)).ToArray()); + } + + /// + /// Quotes a value if it's not already quoted (for .env file values). + /// + public static string QuoteIfNeeded(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return value ?? string.Empty; + + if (value.StartsWith("\"") && value.EndsWith("\"")) + return value; + + return $"\"{value}\""; + } + + /// + /// Strips surrounding quotes from a value (for reading .env file values). + /// + public static string StripQuotes(string? value) + { + if (string.IsNullOrEmpty(value)) + return value ?? string.Empty; + + if (value.StartsWith("\"") && value.EndsWith("\"") && value.Length >= 2) + return value[1..^1]; + + return value; + } + + /// + /// Parses a line from .env file and returns key-value pair. + /// + public static (string key, string value) ParseEnvLine(string line) + { + var eqIndex = line.IndexOf('='); + if (eqIndex <= 0) + return (string.Empty, string.Empty); + + var key = line[..eqIndex].Trim(); + var value = line[(eqIndex + 1)..].Trim(); + + // Strip quotes from value + value = StripQuotes(value); + + return (key, value); + } + + /// + /// Checks if an .env line should be skipped (comment or empty). + /// + public static bool ShouldSkipEnvLine(string line) + { + return string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith('#'); + } + + public 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]}"; + } + + public void InvalidatePlaylistSummaryCache() + { + try + { + var cacheFile = "/app/cache/admin_playlists_summary.json"; + if (File.Exists(cacheFile)) + { + File.Delete(cacheFile); + _logger.LogDebug("🗑️ Invalidated playlist summary cache"); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to invalidate playlist summary cache"); + } + } + + public 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; + } + + public string GetEnvFilePath() => _envFilePath; + + public async Task UpdateEnvConfigAsync(Dictionary updates) + { + if (updates == null || updates.Count == 0) + { + return new BadRequestObjectResult(new { error = "No updates provided" }); + } + + _logger.LogInformation("Config update requested: {Count} changes", updates.Count); + + try + { + if (!File.Exists(_envFilePath)) + { + _logger.LogWarning(".env file not found at {Path}, creating new file", _envFilePath); + } + + var envContent = new Dictionary(); + + if (File.Exists(_envFilePath)) + { + var lines = await File.ReadAllLinesAsync(_envFilePath); + foreach (var line in lines) + { + if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith('#')) + continue; + + var eqIndex = line.IndexOf('='); + if (eqIndex > 0) + { + var key = line[..eqIndex].Trim(); + var value = line[(eqIndex + 1)..].Trim(); + envContent[key] = value; + } + } + } + + var appliedUpdates = new List(); + foreach (var (key, value) in updates) + { + if (!IsValidEnvKey(key)) + { + _logger.LogWarning("Invalid env key rejected: {Key}", key); + return new BadRequestObjectResult(new { error = $"Invalid environment variable key: {key}" }); + } + + envContent[key] = value; + appliedUpdates.Add(key); + + if (key == "SPOTIFY_API_SESSION_COOKIE" && !string.IsNullOrEmpty(value)) + { + var dateKey = "SPOTIFY_API_SESSION_COOKIE_SET_DATE"; + var dateValue = DateTime.UtcNow.ToString("o"); + envContent[dateKey] = dateValue; + appliedUpdates.Add(dateKey); + } + } + + var newContent = string.Join("\n", envContent.Select(kv => $"{kv.Key}={kv.Value}")); + await File.WriteAllTextAsync(_envFilePath, newContent + "\n"); + + _logger.LogInformation("Config file updated successfully at {Path}", _envFilePath); + + return new OkObjectResult(new + { + message = "Configuration updated. Restart container to apply changes.", + updatedKeys = appliedUpdates, + requiresRestart = true, + envFilePath = _envFilePath + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update configuration at {Path}", _envFilePath); + return new ObjectResult(new { error = "Failed to update configuration", details = ex.Message }) + { + StatusCode = 500 + }; + } + } + + public async Task RemovePlaylistFromConfigAsync(string playlistName) + { + try + { + var currentPlaylists = await ReadPlaylistsFromEnvFileAsync(); + var playlist = currentPlaylists.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase)); + + if (playlist == null) + { + return new NotFoundObjectResult(new { error = $"Playlist '{playlistName}' not found" }); + } + + currentPlaylists.Remove(playlist); + + var playlistsJson = JsonSerializer.Serialize( + currentPlaylists.Select(p => new[] { + p.Name, + p.Id, + p.JellyfinId, + p.LocalTracksPosition.ToString().ToLower(), + p.SyncSchedule ?? "0 8 * * *" + }).ToArray() + ); + + var updates = new Dictionary + { + ["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson + }; + + return await UpdateEnvConfigAsync(updates); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to remove playlist {Name}", playlistName); + return new ObjectResult(new { error = "Failed to remove playlist", details = ex.Message }) + { + StatusCode = 500 + }; + } + } + + public async Task SaveManualMappingToFileAsync( + string playlistName, + string spotifyId, + string? jellyfinId, + string? externalProvider, + string? externalId) + { + try + { + var mappingsDir = "/app/cache/mappings"; + Directory.CreateDirectory(mappingsDir); + + var safeName = SanitizeFileName(playlistName); + var filePath = Path.Combine(mappingsDir, $"{safeName}_mappings.json"); + + // Load existing mappings + var mappings = new Dictionary(); + if (File.Exists(filePath)) + { + var json = await File.ReadAllTextAsync(filePath); + mappings = JsonSerializer.Deserialize>(json) + ?? new Dictionary(); + } + + // Add or update mapping + mappings[spotifyId] = new Models.Admin.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 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); + } + } + + public async Task SaveLyricsMappingToFileAsync( + string artist, + string title, + string album, + int durationSeconds, + int lyricsId) + { + try + { + var mappingsDir = "/app/cache/lyrics_mappings"; + Directory.CreateDirectory(mappingsDir); + + var safeName = SanitizeFileName($"{artist}_{title}"); + var filePath = Path.Combine(mappingsDir, $"{safeName}.json"); + + var mapping = new + { + artist, + title, + album, + durationSeconds, + lyricsId, + createdAt = DateTime.UtcNow + }; + + var json = JsonSerializer.Serialize(mapping, new JsonSerializerOptions { WriteIndented = true }); + await File.WriteAllTextAsync(filePath, json); + + _logger.LogDebug("💾 Saved lyrics mapping to file: {Artist} - {Title} → {LyricsId}", artist, title, lyricsId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save lyrics mapping to file for {Artist} - {Title}", artist, title); + } + } + + /// + /// Create an authenticated HTTP request to Jellyfin API + /// + public HttpRequestMessage CreateJellyfinRequest(HttpMethod method, string url) + { + var request = new HttpRequestMessage(method, url); + request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader()); + return request; + } + + /// + /// Read and deserialize a JSON file + /// + public async Task ReadJsonFileAsync(string filePath) where T : class + { + try + { + if (!File.Exists(filePath)) + return null; + + var json = await File.ReadAllTextAsync(filePath); + return JsonSerializer.Deserialize(json); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to read JSON file: {Path}", filePath); + return null; + } + } + + /// + /// Write object to JSON file + /// + public async Task WriteJsonFileAsync(string filePath, T data, bool createDirectory = true) + { + try + { + if (createDirectory) + { + var directory = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + } + + var json = JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true }); + await File.WriteAllTextAsync(filePath, json); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to write JSON file: {Path}", filePath); + return false; + } + } +} diff --git a/allstarr/Services/Common/AuthHeaderHelper.cs b/allstarr/Services/Common/AuthHeaderHelper.cs new file mode 100644 index 0000000..610e9e2 --- /dev/null +++ b/allstarr/Services/Common/AuthHeaderHelper.cs @@ -0,0 +1,168 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; + +namespace allstarr.Services.Common; + +/// +/// Utility class for handling Jellyfin/Emby authentication headers. +/// Centralizes logic for extracting and forwarding authentication headers. +/// +public static class AuthHeaderHelper +{ + /// + /// Forwards authentication headers from HTTP request to HttpRequestMessage. + /// Handles both X-Emby-Authorization and Authorization headers. + /// + /// Source headers (from HttpRequest or IHeaderDictionary) + /// Target HttpRequestMessage + /// True if auth header was added, false otherwise + public static bool ForwardAuthHeaders(IHeaderDictionary sourceHeaders, HttpRequestMessage targetRequest) + { + // Try X-Emby-Authorization first (case-insensitive) + foreach (var header in sourceHeaders) + { + if (header.Key.Equals("X-Emby-Authorization", StringComparison.OrdinalIgnoreCase)) + { + var headerValue = header.Value.ToString(); + targetRequest.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue); + return true; + } + } + + // If no X-Emby-Authorization, check if Authorization header contains MediaBrowser format + foreach (var header in sourceHeaders) + { + if (header.Key.Equals("Authorization", StringComparison.OrdinalIgnoreCase)) + { + var headerValue = header.Value.ToString(); + + // Check if it's a MediaBrowser/Jellyfin auth header + if (headerValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase) || + headerValue.Contains("Client=", StringComparison.OrdinalIgnoreCase) || + headerValue.Contains("Token=", StringComparison.OrdinalIgnoreCase)) + { + // Forward as X-Emby-Authorization (Jellyfin's expected header) + targetRequest.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue); + return true; + } + else + { + // Standard Bearer token + targetRequest.Headers.TryAddWithoutValidation("Authorization", headerValue); + return true; + } + } + } + + return false; + } + + /// + /// Extracts device ID from X-Emby-Authorization header. + /// + /// Request headers + /// Device ID if found, null otherwise + public static string? ExtractDeviceId(IHeaderDictionary headers) + { + if (headers.TryGetValue("X-Emby-Authorization", out var authHeader)) + { + var authValue = authHeader.ToString(); + return ExtractDeviceIdFromAuthString(authValue); + } + + if (headers.TryGetValue("Authorization", out var authHeader2)) + { + var authValue = authHeader2.ToString(); + if (authValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase)) + { + return ExtractDeviceIdFromAuthString(authValue); + } + } + + return null; + } + + /// + /// Extracts device ID from MediaBrowser auth string. + /// Format: MediaBrowser Client="...", Device="...", DeviceId="...", Version="...", Token="..." + /// + private static string? ExtractDeviceIdFromAuthString(string authValue) + { + var deviceIdMatch = System.Text.RegularExpressions.Regex.Match( + authValue, + @"DeviceId=""([^""]+)""", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + if (deviceIdMatch.Success) + { + return deviceIdMatch.Groups[1].Value; + } + + return null; + } + + /// + /// Extracts client name from MediaBrowser auth string. + /// + public static string? ExtractClientName(IHeaderDictionary headers) + { + if (headers.TryGetValue("X-Emby-Authorization", out var authHeader)) + { + var authValue = authHeader.ToString(); + return ExtractClientNameFromAuthString(authValue); + } + + if (headers.TryGetValue("Authorization", out var authHeader2)) + { + var authValue = authHeader2.ToString(); + if (authValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase)) + { + return ExtractClientNameFromAuthString(authValue); + } + } + + return null; + } + + /// + /// Extracts client name from MediaBrowser auth string. + /// + private static string? ExtractClientNameFromAuthString(string authValue) + { + var clientMatch = System.Text.RegularExpressions.Regex.Match( + authValue, + @"Client=""([^""]+)""", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + if (clientMatch.Success) + { + return clientMatch.Groups[1].Value; + } + + return null; + } + + /// + /// Creates a MediaBrowser auth header string. + /// + public static string CreateAuthHeader(string token, string? client = null, string? device = null, string? deviceId = null, string? version = null) + { + var parts = new List(); + + if (!string.IsNullOrEmpty(client)) + parts.Add($"Client=\"{client}\""); + + if (!string.IsNullOrEmpty(device)) + parts.Add($"Device=\"{device}\""); + + if (!string.IsNullOrEmpty(deviceId)) + parts.Add($"DeviceId=\"{deviceId}\""); + + if (!string.IsNullOrEmpty(version)) + parts.Add($"Version=\"{version}\""); + + parts.Add($"Token=\"{token}\""); + + return $"MediaBrowser {string.Join(", ", parts)}"; + } +} diff --git a/allstarr/Services/Common/BaseDownloadService.cs b/allstarr/Services/Common/BaseDownloadService.cs index dce837b..c4f7abe 100644 --- a/allstarr/Services/Common/BaseDownloadService.cs +++ b/allstarr/Services/Common/BaseDownloadService.cs @@ -88,65 +88,95 @@ public abstract class BaseDownloadService : IDownloadService #region IDownloadService Implementation + /// + /// Downloads a song and returns the local file path. + /// This method respects the cancellation token for user-initiated downloads (e.g., playlist downloads). + /// For streaming downloads, use DownloadAndStreamAsync which ensures downloads complete server-side. + /// public async Task DownloadSongAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { return await DownloadSongInternalAsync(externalProvider, externalId, triggerAlbumDownload: true, cancellationToken); } + public async Task 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)) { +<<<<<<< HEAD var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds; Logger.LogInformation("Streaming from local cache ({ElapsedMs}ms): {Path}", elapsed, localPath); // Update write time for cache cleanup (extends cache lifetime) if (SubsonicSettings.StorageMode == StorageMode.Cache) +||||||| f68706f + 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) +======= + var startTime = DateTime.UtcNow; + + // Check if already downloaded locally + var localPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId); + if (localPath != null && IOFile.Exists(localPath)) +>>>>>>> beta { +<<<<<<< HEAD IOFile.SetLastWriteTime(localPath, DateTime.UtcNow); +||||||| f68706f + IOFile.SetLastAccessTime(localPath, DateTime.UtcNow); +======= + var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds; + Logger.LogInformation("Streaming from local cache ({ElapsedMs}ms): {Path}", elapsed, localPath); + + // Update write time for cache cleanup (extends cache lifetime) + if (SubsonicSettings.StorageMode == StorageMode.Cache) + { + IOFile.SetLastWriteTime(localPath, DateTime.UtcNow); + } + + // Start background Odesli conversion for lyrics (if not already cached) + StartBackgroundOdesliConversion(externalProvider, externalId); + + return IOFile.OpenRead(localPath); + } + + // 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); + + try + { + // IMPORTANT: Use CancellationToken.None for the actual download + // This ensures downloads complete server-side even if the client cancels the request + // The client can request the file again later once it's ready + localPath = await DownloadSongInternalAsync(externalProvider, externalId, triggerAlbumDownload: true, CancellationToken.None); + 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; +>>>>>>> beta } - - // Start background Odesli conversion for lyrics (if not already cached) - StartBackgroundOdesliConversion(externalProvider, externalId); - - return IOFile.OpenRead(localPath); } - // 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); - - 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; - } - } /// /// Starts background Odesli conversion for lyrics support. @@ -242,8 +272,18 @@ public abstract class BaseDownloadService : IDownloadService /// /// Extracts the external album ID from the internal album ID format. /// Example: "ext-deezer-album-123456" -> "123456" + /// Default implementation handles standard format: "ext-{provider}-album-{id}" + /// Override if your provider uses a different format. /// - protected abstract string? ExtractExternalIdFromAlbumId(string albumId); + protected virtual string? ExtractExternalIdFromAlbumId(string albumId) + { + var prefix = $"ext-{ProviderName}-album-"; + if (albumId.StartsWith(prefix)) + { + return albumId[prefix.Length..]; + } + return null; + } #endregion @@ -291,12 +331,18 @@ public abstract class BaseDownloadService : IDownloadService 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 + // Wait for download to complete, checking every 100ms + // Note: We check cancellation but don't cancel the actual download + // The download continues server-side even if this client gives up waiting while (ActiveDownloads.TryGetValue(songId, out activeDownload) && activeDownload.Status == DownloadStatus.InProgress) { - cancellationToken.ThrowIfCancellationRequested(); - await Task.Delay(100, cancellationToken); + // If client cancels, throw but let the download continue in background + if (cancellationToken.IsCancellationRequested) + { + Logger.LogInformation("Client cancelled while waiting for download {SongId}, but download continues server-side", songId); + throw new OperationCanceledException("Client cancelled request, but download continues server-side"); + } + await Task.Delay(100, CancellationToken.None); } if (activeDownload?.Status == DownloadStatus.Completed && activeDownload.LocalPath != null) diff --git a/allstarr/Services/Common/CacheKeyBuilder.cs b/allstarr/Services/Common/CacheKeyBuilder.cs new file mode 100644 index 0000000..33a80d5 --- /dev/null +++ b/allstarr/Services/Common/CacheKeyBuilder.cs @@ -0,0 +1,107 @@ +namespace allstarr.Services.Common; + +/// +/// Utility class for building consistent cache keys across the application. +/// Centralizes cache key generation to ensure consistency and prevent typos. +/// +public static class CacheKeyBuilder +{ + #region Search Keys + + public static string BuildSearchKey(string? searchTerm, string? itemTypes, int? limit, int? startIndex) + { + return $"search:{searchTerm?.ToLowerInvariant()}:{itemTypes}:{limit}:{startIndex}"; + } + + #endregion + + #region Metadata Keys + + public static string BuildAlbumKey(string provider, string externalId) + { + return $"{provider}:album:{externalId}"; + } + + public static string BuildArtistKey(string provider, string externalId) + { + return $"{provider}:artist:{externalId}"; + } + + public static string BuildSongKey(string provider, string externalId) + { + return $"{provider}:song:{externalId}"; + } + + #endregion + + #region Spotify Keys + + public static string BuildSpotifyPlaylistKey(string playlistName) + { + return $"spotify:playlist:{playlistName}"; + } + + public static string BuildSpotifyPlaylistItemsKey(string playlistName) + { + return $"spotify:playlist:items:{playlistName}"; + } + + public static string BuildSpotifyMatchedTracksKey(string playlistName) + { + return $"spotify:matched:ordered:{playlistName}"; + } + + public static string BuildSpotifyMissingTracksKey(string playlistName) + { + return $"spotify:missing:{playlistName}"; + } + + public static string BuildSpotifyManualMappingKey(string playlist, string spotifyId) + { + return $"spotify:manual-map:{playlist}:{spotifyId}"; + } + + public static string BuildSpotifyExternalMappingKey(string playlist, string spotifyId) + { + return $"spotify:external-map:{playlist}:{spotifyId}"; + } + + #endregion + + #region Lyrics Keys + + public static string BuildLyricsKey(string artist, string title, string? album, int? durationSeconds) + { + return $"lyrics:{artist}:{title}:{album}:{durationSeconds}"; + } + + public static string BuildLyricsPlusKey(string artist, string title, string? album, int? durationSeconds) + { + return $"lyricsplus:{artist}:{title}:{album}:{durationSeconds}"; + } + + public static string BuildLyricsManualMappingKey(string artist, string title) + { + return $"lyrics:manual-map:{artist}:{title}"; + } + + #endregion + + #region Playlist Keys + + public static string BuildPlaylistImageKey(string playlistId) + { + return $"playlist:image:{playlistId}"; + } + + #endregion + + #region Genre Keys + + public static string BuildGenreKey(string genre) + { + return $"genre:{genre.ToLowerInvariant()}"; + } + + #endregion +} diff --git a/allstarr/Services/Common/CacheWarmingService.cs b/allstarr/Services/Common/CacheWarmingService.cs index f2be362..1d77e0d 100644 --- a/allstarr/Services/Common/CacheWarmingService.cs +++ b/allstarr/Services/Common/CacheWarmingService.cs @@ -161,8 +161,16 @@ public class CacheWarmingService : IHostedService var fileName = Path.GetFileNameWithoutExtension(file); var playlistName = fileName.Replace("_items", ""); +<<<<<<< HEAD var redisKey = $"spotify:playlist:items:{playlistName}"; await _cache.SetAsync(redisKey, items, CacheExtensions.SpotifyPlaylistItemsTTL); +||||||| f68706f + var redisKey = $"spotify:playlist:items:{playlistName}"; + await _cache.SetAsync(redisKey, items, TimeSpan.FromHours(24)); +======= + var redisKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlistName); + await _cache.SetAsync(redisKey, items, CacheExtensions.SpotifyPlaylistItemsTTL); +>>>>>>> beta warmedCount++; _logger.LogDebug("🔥 Warmed playlist items cache for {Playlist} ({Count} items)", @@ -199,8 +207,16 @@ public class CacheWarmingService : IHostedService var fileName = Path.GetFileNameWithoutExtension(file); var playlistName = fileName.Replace("_matched", ""); +<<<<<<< HEAD var redisKey = $"spotify:matched:ordered:{playlistName}"; await _cache.SetAsync(redisKey, matchedTracks, CacheExtensions.SpotifyMatchedTracksTTL); +||||||| f68706f + var redisKey = $"spotify:matched:ordered:{playlistName}"; + await _cache.SetAsync(redisKey, matchedTracks, TimeSpan.FromHours(1)); +======= + var redisKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlistName); + await _cache.SetAsync(redisKey, matchedTracks, CacheExtensions.SpotifyMatchedTracksTTL); +>>>>>>> beta warmedCount++; _logger.LogInformation("🔥 Warmed matched tracks cache for {Playlist} ({Count} tracks)", diff --git a/allstarr/Services/Common/EnvMigrationService.cs b/allstarr/Services/Common/EnvMigrationService.cs index 6a39cce..f0a1dc4 100644 --- a/allstarr/Services/Common/EnvMigrationService.cs +++ b/allstarr/Services/Common/EnvMigrationService.cs @@ -1,3 +1,4 @@ +<<<<<<< HEAD namespace allstarr.Services.Common; /// @@ -78,3 +79,119 @@ public class EnvMigrationService } } } +||||||| f68706f +======= +namespace allstarr.Services.Common; + +/// +/// Service that runs on startup to migrate old .env file format to new format +/// +public class EnvMigrationService +{ + private readonly ILogger _logger; + private readonly string _envFilePath; + + public EnvMigrationService(ILogger logger) + { + _logger = logger; + _envFilePath = Path.Combine(Directory.GetCurrentDirectory(), ".env"); + } + + public void MigrateEnvFile() + { + if (!File.Exists(_envFilePath)) + { + _logger.LogWarning("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.LogDebug("Migrated DOWNLOAD_PATH to Library__DownloadPath in .env file"); + } + + // Migrate old SquidWTF quality values to new format + if (line.StartsWith("SQUIDWTF_QUALITY=")) + { + var value = line.Substring("SQUIDWTF_QUALITY=".Length).Trim(); + var newValue = value.ToUpperInvariant() switch + { + "FLAC" => "LOSSLESS", + "HI_RES" => "HI_RES_LOSSLESS", + "MP3_320" => "HIGH", + "MP3_128" => "LOW", + _ => null // Keep as-is if already correct + }; + + if (newValue != null) + { + lines[i] = $"SQUIDWTF_QUALITY={newValue}"; + modified = true; + _logger.LogInformation("Migrated SQUIDWTF_QUALITY from {Old} to {New} in .env file", value, newValue); + } + } + + // CRITICAL FIX: Remove quotes from password/token values + // Docker Compose does NOT need quotes in .env files - it handles special characters correctly + // When quotes are used, they become part of the value itself + var keysToUnquote = new[] + { + "SCROBBLING_LASTFM_PASSWORD", + "MUSICBRAINZ_PASSWORD", + "DEEZER_ARL", + "DEEZER_ARL_FALLBACK", + "QOBUZ_USER_AUTH_TOKEN", + "SCROBBLING_LASTFM_SESSION_KEY", + "SCROBBLING_LISTENBRAINZ_USER_TOKEN", + "SPOTIFY_API_SESSION_COOKIE" + }; + + foreach (var key in keysToUnquote) + { + if (line.StartsWith($"{key}=")) + { + var value = line.Substring($"{key}=".Length); + + // Remove surrounding quotes if present + if (value.StartsWith("\"") && value.EndsWith("\"") && value.Length >= 2) + { + var unquoted = value.Substring(1, value.Length - 2); + lines[i] = $"{key}={unquoted}"; + modified = true; + _logger.LogInformation("Removed quotes from {Key} (Docker Compose doesn't need them)", key); + } + break; + } + } + } + + if (modified) + { + File.WriteAllLines(_envFilePath, lines); + _logger.LogInformation("✅ .env file migration completed successfully"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to migrate .env file"); + } + } +} +>>>>>>> beta diff --git a/allstarr/Services/Common/ExplicitContentFilter.cs b/allstarr/Services/Common/ExplicitContentFilter.cs new file mode 100644 index 0000000..b54df99 --- /dev/null +++ b/allstarr/Services/Common/ExplicitContentFilter.cs @@ -0,0 +1,41 @@ +using allstarr.Models.Domain; +using allstarr.Models.Settings; + +namespace allstarr.Services.Common; + +/// +/// Utility class for filtering songs based on explicit content settings. +/// Centralizes explicit content filtering logic used across metadata services. +/// +public static class ExplicitContentFilter +{ + /// + /// Determines if a song should be included based on explicit content filter settings. + /// + /// The song to check + /// The explicit content filter setting + /// True if the song should be included, false otherwise + public static bool ShouldIncludeSong(Song song, ExplicitFilter filter) + { + // If no explicit content info, include the song + if (song.ExplicitContentLyrics == null) + return true; + + return filter switch + { + // All: No filtering, include everything + ExplicitFilter.All => true, + + // ExplicitOnly: Exclude clean/edited versions (value 3) + // Include: 0 (naturally clean), 1 (explicit), 2 (not applicable), 6/7 (unknown) + ExplicitFilter.ExplicitOnly => song.ExplicitContentLyrics != 3, + + // CleanOnly: Only show clean content + // Include: 0 (naturally clean), 3 (clean/edited version) + // Exclude: 1 (explicit) + ExplicitFilter.CleanOnly => song.ExplicitContentLyrics != 1, + + _ => true + }; + } +} diff --git a/allstarr/Services/Common/GenreEnrichmentService.cs b/allstarr/Services/Common/GenreEnrichmentService.cs index decae59..b8492cd 100644 --- a/allstarr/Services/Common/GenreEnrichmentService.cs +++ b/allstarr/Services/Common/GenreEnrichmentService.cs @@ -112,31 +112,14 @@ public class GenreEnrichmentService /// /// Aggregates genres from a list of songs to determine playlist genres. - /// Returns the top 5 most common genres. + /// Returns all unique genres from the songs. /// public List AggregatePlaylistGenres(List songs) { - var genreCounts = new Dictionary(StringComparer.OrdinalIgnoreCase); - - foreach (var song in songs) - { - if (!string.IsNullOrEmpty(song.Genre)) - { - if (genreCounts.ContainsKey(song.Genre)) - { - genreCounts[song.Genre]++; - } - else - { - genreCounts[song.Genre] = 1; - } - } - } - - return genreCounts - .OrderByDescending(kvp => kvp.Value) - .Take(5) - .Select(kvp => kvp.Key) + return songs + .Where(s => !string.IsNullOrEmpty(s.Genre)) + .Select(s => s.Genre!) + .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); } diff --git a/allstarr/Services/Common/PathHelper.cs b/allstarr/Services/Common/PathHelper.cs index 4e15f8d..7b71537 100644 --- a/allstarr/Services/Common/PathHelper.cs +++ b/allstarr/Services/Common/PathHelper.cs @@ -41,10 +41,33 @@ public static class PathHelper var albumFolder = Path.Combine(artistFolder, safeAlbum); var trackPrefix = trackNumber.HasValue ? $"{trackNumber:D2} - " : ""; +<<<<<<< HEAD var idSuffix = !string.IsNullOrEmpty(provider) && !string.IsNullOrEmpty(externalId) ? $" [{provider}-{externalId}]" : ""; var fileName = $"{trackPrefix}{safeTitle}{idSuffix}{extension}"; +||||||| f68706f + var fileName = $"{trackPrefix}{safeTitle}{extension}"; +======= + // Sanitize provider and external id to avoid path traversal or invalid filename segments + string? safeProvider = null; + string? safeExternalId = null; + if (!string.IsNullOrEmpty(provider)) + { + safeProvider = SanitizeFileName(provider); + } + + if (!string.IsNullOrEmpty(externalId)) + { + safeExternalId = SanitizeFileName(externalId); + } + + // If both provider and external id are present, append a sanitized id suffix + var idSuffix = (!string.IsNullOrEmpty(safeProvider) && !string.IsNullOrEmpty(safeExternalId)) + ? $" [{safeProvider}-{safeExternalId}]" + : ""; + var fileName = $"{trackPrefix}{safeTitle}{idSuffix}{extension}"; +>>>>>>> beta return Path.Combine(albumFolder, fileName); } @@ -66,12 +89,24 @@ public static class PathHelper .Select(c => invalidChars.Contains(c) ? '_' : c) .ToArray()); + // Collapse sequences of two or more dots to a single underscore to avoid + // creating ".." which can be interpreted as parent directory tokens. + sanitized = System.Text.RegularExpressions.Regex.Replace(sanitized, "\\.{2,}", "_"); + + // Remove any remaining path separators just in case + sanitized = sanitized.Replace('/', '_').Replace('\\', '_'); + + // Trim whitespace and trailing/leading dots + sanitized = sanitized.Trim().TrimEnd('.').TrimStart('.'); + if (sanitized.Length > 100) { - sanitized = sanitized[..100]; + sanitized = sanitized[..100].TrimEnd('.'); } - - return sanitized.Trim(); + + if (string.IsNullOrWhiteSpace(sanitized)) return "Unknown"; + + return sanitized; } /// @@ -119,21 +154,38 @@ public static class PathHelper /// Unique file path that does not exist yet. public static string ResolveUniquePath(string basePath) { + if (string.IsNullOrEmpty(basePath)) + { + throw new ArgumentException("basePath must be provided", nameof(basePath)); + } + if (!IOFile.Exists(basePath)) { return basePath; } - var directory = Path.GetDirectoryName(basePath)!; + var directory = Path.GetDirectoryName(basePath); + if (string.IsNullOrEmpty(directory)) + { + // If no directory part is present, use current directory + directory = Directory.GetCurrentDirectory(); + } var extension = Path.GetExtension(basePath); var fileNameWithoutExt = Path.GetFileNameWithoutExtension(basePath); var counter = 1; string uniquePath; + // Limit attempts to avoid infinite loop in pathological cases + const int maxAttempts = 10000; do { uniquePath = Path.Combine(directory, $"{fileNameWithoutExt} ({counter}){extension}"); counter++; + + if (counter > maxAttempts) + { + throw new IOException("Unable to determine unique file path after many attempts"); + } } while (IOFile.Exists(uniquePath)); return uniquePath; diff --git a/allstarr/Services/Common/PlaylistIdHelper.cs b/allstarr/Services/Common/PlaylistIdHelper.cs index 2e155bb..6fff99e 100644 --- a/allstarr/Services/Common/PlaylistIdHelper.cs +++ b/allstarr/Services/Common/PlaylistIdHelper.cs @@ -2,48 +2,55 @@ namespace allstarr.Services.Common; /// /// Helper class for handling external playlist IDs. -/// Playlist IDs use the format: "pl-{provider}-{externalId}" -/// Example: "pl-deezer-123456", "pl-qobuz-789" +/// Playlist IDs use the format: "ext-{provider}-playlist-{externalId}" +/// Example: "ext-deezer-playlist-123456", "ext-qobuz-playlist-789" +/// This matches the format used for albums and songs for consistency. /// public static class PlaylistIdHelper { - private const string PlaylistPrefix = "pl-"; + private const string PlaylistType = "playlist"; /// /// Checks if an ID represents an external playlist. + /// Only supports new format: ext-{provider}-playlist-{id} /// /// The ID to check - /// True if the ID starts with "pl-", false otherwise + /// True if the ID is a playlist ID in the new format, false otherwise public static bool IsExternalPlaylist(string? id) { - return !string.IsNullOrEmpty(id) && id.StartsWith(PlaylistPrefix, StringComparison.OrdinalIgnoreCase); + if (string.IsNullOrEmpty(id)) return false; + + // New format only: ext-{provider}-playlist-{id} + return id.StartsWith("ext-", StringComparison.OrdinalIgnoreCase) && + id.Contains("-playlist-", StringComparison.OrdinalIgnoreCase); } /// /// Parses a playlist ID to extract provider and external ID. + /// Only supports new format: ext-{provider}-playlist-{id} /// - /// The playlist ID in format "pl-{provider}-{externalId}" + /// The playlist ID in format "ext-{provider}-playlist-{externalId}" /// A tuple containing (provider, externalId) /// Thrown if the ID format is invalid public static (string provider, string externalId) ParsePlaylistId(string id) { if (!IsExternalPlaylist(id)) { - throw new ArgumentException($"Invalid playlist ID format. Expected 'pl-{{provider}}-{{externalId}}', got '{id}'", nameof(id)); + throw new ArgumentException($"Invalid playlist ID format. Expected 'ext-{{provider}}-playlist-{{externalId}}', got '{id}'", nameof(id)); } - // Remove "pl-" prefix - var withoutPrefix = id.Substring(PlaylistPrefix.Length); + // Format: ext-{provider}-playlist-{externalId} + var withoutPrefix = id.Substring(4); // Remove "ext-" - // Split by first dash to get provider and externalId - var dashIndex = withoutPrefix.IndexOf('-'); - if (dashIndex == -1) + // Find "-playlist-" separator + var playlistIndex = withoutPrefix.IndexOf("-playlist-", StringComparison.OrdinalIgnoreCase); + if (playlistIndex == -1) { - throw new ArgumentException($"Invalid playlist ID format. Expected 'pl-{{provider}}-{{externalId}}', got '{id}'", nameof(id)); + throw new ArgumentException($"Invalid playlist ID format. Expected 'ext-{{provider}}-playlist-{{externalId}}', got '{id}'", nameof(id)); } - var provider = withoutPrefix.Substring(0, dashIndex); - var externalId = withoutPrefix.Substring(dashIndex + 1); + var provider = withoutPrefix.Substring(0, playlistIndex); + var externalId = withoutPrefix.Substring(playlistIndex + 10); // 10 = length of "-playlist-" if (string.IsNullOrEmpty(provider) || string.IsNullOrEmpty(externalId)) { @@ -58,7 +65,7 @@ public static class PlaylistIdHelper /// /// The provider name (e.g., "deezer", "qobuz") /// The external ID from the provider - /// A playlist ID in format "pl-{provider}-{externalId}" + /// A playlist ID in format "ext-{provider}-playlist-{externalId}" public static string CreatePlaylistId(string provider, string externalId) { if (string.IsNullOrEmpty(provider)) @@ -71,6 +78,6 @@ public static class PlaylistIdHelper throw new ArgumentException("External ID cannot be null or empty", nameof(externalId)); } - return $"{PlaylistPrefix}{provider.ToLowerInvariant()}-{externalId}"; + return $"ext-{provider.ToLowerInvariant()}-{PlaylistType}-{externalId}"; } } diff --git a/allstarr/Services/Common/RetryHelper.cs b/allstarr/Services/Common/RetryHelper.cs new file mode 100644 index 0000000..7c597f2 --- /dev/null +++ b/allstarr/Services/Common/RetryHelper.cs @@ -0,0 +1,73 @@ +using Microsoft.Extensions.Logging; + +namespace allstarr.Services.Common; + +/// +/// Utility class for handling retry logic with exponential backoff. +/// Centralizes retry patterns used across download and metadata services. +/// +public static class RetryHelper +{ + /// + /// Executes an async action with exponential backoff retry logic. + /// Retries on HTTP 503 (Service Unavailable) and 429 (Too Many Requests). + /// + /// Return type of the action + /// The async action to execute + /// Logger for retry attempts + /// Maximum number of retry attempts (default: 3) + /// Initial delay in milliseconds (default: 1000) + /// Result of the action + public static async Task RetryWithBackoffAsync( + Func> action, + ILogger logger, + int maxRetries = 3, + int initialDelayMs = 1000) + { + Exception? lastException = null; + + for (int attempt = 0; attempt < maxRetries; attempt++) + { + try + { + return await action(); + } + catch (HttpRequestException ex) when ( + ex.StatusCode == System.Net.HttpStatusCode.ServiceUnavailable || + ex.StatusCode == System.Net.HttpStatusCode.TooManyRequests) + { + lastException = ex; + if (attempt < maxRetries - 1) + { + var delay = initialDelayMs * (int)Math.Pow(2, attempt); + logger.LogWarning( + "Retry attempt {Attempt}/{MaxRetries} after {Delay}ms ({Message})", + attempt + 1, maxRetries, delay, ex.Message); + await Task.Delay(delay); + } + } + catch + { + throw; + } + } + + throw lastException!; + } + + /// + /// Executes an async action with exponential backoff retry logic (void return). + /// + public static async Task RetryWithBackoffAsync( + Func action, + ILogger logger, + int maxRetries = 3, + int initialDelayMs = 1000) + { + await RetryWithBackoffAsync(async () => + { + await action(); + return true; + }, logger, maxRetries, initialDelayMs); + } +} diff --git a/allstarr/Services/Common/RoundRobinFallbackHelper.cs b/allstarr/Services/Common/RoundRobinFallbackHelper.cs index faed7d1..4074085 100644 --- a/allstarr/Services/Common/RoundRobinFallbackHelper.cs +++ b/allstarr/Services/Common/RoundRobinFallbackHelper.cs @@ -76,9 +76,42 @@ public class RoundRobinFallbackHelper return isHealthy; } + catch (TaskCanceledException) + { + // Timeouts are expected when checking multiple mirrors - log at debug level + _logger.LogDebug("{Service} endpoint {Endpoint} health check timed out", _serviceName, baseUrl); + + // Cache as unhealthy + lock (_healthCacheLock) + { + _healthCache[baseUrl] = (false, DateTime.UtcNow); + } + + return false; + } + catch (HttpRequestException ex) + { + // Connection errors (refused, DNS failures, etc.) - log at debug level + _logger.LogDebug("{Service} endpoint {Endpoint} health check failed: {Message}", _serviceName, baseUrl, ex.Message); + + // Cache as unhealthy + lock (_healthCacheLock) + { + _healthCache[baseUrl] = (false, DateTime.UtcNow); + } + + return false; + } catch (Exception ex) { +<<<<<<< HEAD _logger.LogError(ex, "{Service} endpoint {Endpoint} health check failed", _serviceName, baseUrl); +||||||| f68706f + _logger.LogDebug(ex, "{Service} endpoint {Endpoint} health check failed", _serviceName, baseUrl); +======= + // Unexpected errors - still log at debug level for health checks + _logger.LogDebug(ex, "{Service} endpoint {Endpoint} health check failed", _serviceName, baseUrl); +>>>>>>> beta // Cache as unhealthy lock (_healthCacheLock) @@ -203,22 +236,15 @@ public class RoundRobinFallbackHelper /// Races all endpoints in parallel and returns the first successful result. /// Cancels remaining requests once one succeeds. Great for latency-sensitive operations. /// - public async Task RaceAllEndpointsAsync(Func> action, CancellationToken cancellationToken = default) - { - if (_apiUrls.Count == 1) + /// + /// Races the top N fastest endpoints in parallel and returns the first successful result. + /// Cancels remaining requests once one succeeds. Used for latency-sensitive operations like search. + /// + public async Task RaceTopEndpointsAsync(int topN, Func> action, CancellationToken cancellationToken = default) { - // No point racing with one endpoint - return await action(_apiUrls[0], cancellationToken); - } - - using var raceCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - var tasks = new List>(); - - // Start all requests in parallel - foreach (var baseUrl in _apiUrls) - { - var task = Task.Run(async () => + if (_apiUrls.Count == 1 || topN <= 1) { +<<<<<<< HEAD try { _logger.LogDebug("Racing {Service} endpoint {Endpoint}", _serviceName, baseUrl); @@ -246,13 +272,90 @@ public class RoundRobinFallbackHelper _logger.LogDebug("🏁 {Service} race won by {Endpoint}, canceling others", _serviceName, endpoint); raceCts.Cancel(); // Cancel all other requests return result; - } +||||||| f68706f + 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.Remove(completedTask); + tasks.Add(task); } - throw new Exception($"All {_serviceName} endpoints failed in race"); - } + // 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; +======= + // No point racing with one endpoint - use fallback instead + return await TryWithFallbackAsync(baseUrl => action(baseUrl, cancellationToken)); +>>>>>>> beta + } + + // Get top N fastest healthy endpoints + var endpointsToRace = _apiUrls.Take(Math.Min(topN, _apiUrls.Count)).ToList(); + + if (endpointsToRace.Count == 1) + { + return await action(endpointsToRace[0], cancellationToken); + } + + using var raceCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var tasks = new List>(); + + // Start racing the top N endpoints + foreach (var baseUrl in endpointsToRace) + { + 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("{Service} race failed for endpoint {Endpoint}: {Message}", _serviceName, baseUrl, ex.Message); + 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.LogDebug("🏆 {Service} race won by {Endpoint}, canceling others", _serviceName, endpoint); + raceCts.Cancel(); // Cancel all other requests + return result; + } + + tasks.Remove(completedTask); + } + + throw new Exception($"All {topN} {_serviceName} endpoints failed in race"); + } /// /// Tries the request with the next provider in round-robin, then falls back to others on failure. @@ -310,4 +413,93 @@ public class RoundRobinFallbackHelper } return defaultValue; } + + /// + /// Processes multiple items in parallel across all available endpoints. + /// Each endpoint processes items sequentially. Failed endpoints are blacklisted. + /// + public async Task> ProcessInParallelAsync( + List items, + Func> action, + CancellationToken cancellationToken = default) + { + if (!items.Any()) + { + return new List(); + } + + var results = new List(); + var resultsLock = new object(); + var itemQueue = new Queue(items); + var queueLock = new object(); + var blacklistedEndpoints = new HashSet(); + var blacklistLock = new object(); + + // Start one task per endpoint + var tasks = _apiUrls.Select(async endpoint => + { + while (true) + { + // Check if endpoint is blacklisted + lock (blacklistLock) + { + if (blacklistedEndpoints.Contains(endpoint)) + { + return; + } + } + + // Get next item from queue + TItem? item; + lock (queueLock) + { + if (itemQueue.Count == 0) + { + return; // No more items to process + } + item = itemQueue.Dequeue(); + } + + // Process the item + try + { + var result = await action(endpoint, item, cancellationToken); + + lock (resultsLock) + { + results.Add(result); + } + + _logger.LogDebug("✓ {Service} endpoint {Endpoint} processed item ({Completed}/{Total})", + _serviceName, endpoint, results.Count, items.Count); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "✗ {Service} endpoint {Endpoint} failed, blacklisting", + _serviceName, endpoint); + + // Blacklist this endpoint + lock (blacklistLock) + { + blacklistedEndpoints.Add(endpoint); + } + + // Put item back in queue for another endpoint to try + lock (queueLock) + { + itemQueue.Enqueue(item); + } + + return; // Exit this endpoint's task + } + } + }).ToList(); + + await Task.WhenAll(tasks); + + _logger.LogInformation("🏁 {Service} parallel processing complete: {Completed}/{Total} items, {Blacklisted} endpoints blacklisted", + _serviceName, results.Count, items.Count, blacklistedEndpoints.Count); + + return results; + } } diff --git a/allstarr/Services/Deezer/DeezerDownloadService.cs b/allstarr/Services/Deezer/DeezerDownloadService.cs index b90adbf..7e7bad4 100644 --- a/allstarr/Services/Deezer/DeezerDownloadService.cs +++ b/allstarr/Services/Deezer/DeezerDownloadService.cs @@ -81,15 +81,6 @@ public class DeezerDownloadService : BaseDownloadService } } - protected override string? ExtractExternalIdFromAlbumId(string albumId) - { - const string prefix = "ext-deezer-album-"; - if (albumId.StartsWith(prefix)) - { - return albumId[prefix.Length..]; - } - return null; - } protected override async Task DownloadTrackAsync(string trackId, Song song, CancellationToken cancellationToken) { @@ -121,14 +112,14 @@ public class DeezerDownloadService : BaseDownloadService outputPath = PathHelper.ResolveUniquePath(outputPath); // Download the encrypted file - var response = await RetryWithBackoffAsync(async () => + var response = await RetryHelper.RetryWithBackoffAsync(async () => { using var request = new HttpRequestMessage(HttpMethod.Get, downloadInfo.DownloadUrl); request.Headers.Add("User-Agent", "Mozilla/5.0"); request.Headers.Add("Accept", "*/*"); return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); - }); + }, Logger); response.EnsureSuccessStatusCode(); @@ -159,7 +150,7 @@ public class DeezerDownloadService : BaseDownloadService throw new Exception("ARL token required for Deezer downloads"); } - await RetryWithBackoffAsync(async () => + await RetryHelper.RetryWithBackoffAsync(async () => { using var request = new HttpRequestMessage(HttpMethod.Post, "https://www.deezer.com/ajax/gw-light.php?method=deezer.getUserData&input=3&api_version=1.0&api_token=null"); @@ -186,11 +177,12 @@ public class DeezerDownloadService : BaseDownloadService } Logger.LogInformation("Deezer token refreshed successfully"); - return true; } - - throw new Exception("Invalid ARL token"); - }); + else + { + throw new Exception("Invalid ARL token"); + } + }, Logger); } private async Task GetTrackDownloadInfoAsync(string trackId, CancellationToken cancellationToken) @@ -457,43 +449,6 @@ public class DeezerDownloadService : BaseDownloadService }; } - private async Task RetryWithBackoffAsync(Func> action, int maxRetries = 3, int initialDelayMs = 1000) - { - Exception? lastException = null; - - for (int attempt = 0; attempt < maxRetries; attempt++) - { - try - { - return await action(); - } - catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.ServiceUnavailable || - ex.StatusCode == System.Net.HttpStatusCode.TooManyRequests) - { - lastException = ex; - if (attempt < maxRetries - 1) - { - var delay = initialDelayMs * (int)Math.Pow(2, attempt); - Logger.LogWarning("Retry attempt {Attempt}/{MaxRetries} after {Delay}ms ({Message})", - attempt + 1, maxRetries, delay, ex.Message); - await Task.Delay(delay); - } - } - catch - { - throw; - } - } - - throw lastException!; - } - - private async Task RetryWithBackoffAsync(Func> action, int maxRetries = 3, int initialDelayMs = 1000) - { - await RetryWithBackoffAsync(action, maxRetries, initialDelayMs); - } - - #endregion private class DownloadResult diff --git a/allstarr/Services/Deezer/DeezerMetadataService.cs b/allstarr/Services/Deezer/DeezerMetadataService.cs index 366eb8a..7d7f82d 100644 --- a/allstarr/Services/Deezer/DeezerMetadataService.cs +++ b/allstarr/Services/Deezer/DeezerMetadataService.cs @@ -47,7 +47,7 @@ public class DeezerMetadataService : IMusicMetadataService foreach (var track in data.EnumerateArray()) { var song = ParseDeezerTrack(track); - if (ShouldIncludeSong(song)) + if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter)) { songs.Add(song); } @@ -260,7 +260,7 @@ public class DeezerMetadataService : IMusicMetadataService song.AlbumId = album.Id; song.AlbumArtist = album.Artist; - if (ShouldIncludeSong(song)) + if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter)) { album.Songs.Add(song); } @@ -312,6 +312,30 @@ public class DeezerMetadataService : IMusicMetadataService return albums; } + public async Task> GetArtistTracksAsync(string externalProvider, string externalId) + { + if (externalProvider != "deezer") return new List(); + + var url = $"{BaseUrl}/artist/{externalId}/top?limit=50"; + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) return new List(); + + var json = await response.Content.ReadAsStringAsync(); + var result = JsonDocument.Parse(json); + + var tracks = new List(); + if (result.RootElement.TryGetProperty("data", out var data)) + { + foreach (var track in data.EnumerateArray()) + { + tracks.Add(ParseDeezerTrack(track)); + } + } + + return tracks; + } + private Song ParseDeezerTrack(JsonElement track, int? fallbackTrackNumber = null, string? albumArtist = null) { var externalId = track.GetProperty("id").GetInt64().ToString(); @@ -636,7 +660,11 @@ public class DeezerMetadataService : IMusicMetadataService // Override album name to be the playlist name song.Album = playlistName; - if (ShouldIncludeSong(song)) + // Playlists should not have disc numbers - always set to null + // This prevents Jellyfin from splitting the playlist into multiple "discs" + song.DiscNumber = null; + + if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter)) { songs.Add(song); } @@ -704,33 +732,4 @@ public class DeezerMetadataService : IMusicMetadataService CreatedDate = createdDate }; } - - /// - /// Determines whether a song should be included based on the explicit content filter setting - /// - /// The song to check - /// True if the song should be included, false otherwise - private bool ShouldIncludeSong(Song song) - { - // If no explicit content info, include the song - if (song.ExplicitContentLyrics == null) - return true; - - return _settings.ExplicitFilter switch - { - // All: No filtering, include everything - ExplicitFilter.All => true, - - // ExplicitOnly: Exclude clean/edited versions (value 3) - // Include: 0 (naturally clean), 1 (explicit), 2 (not applicable), 6/7 (unknown) - ExplicitFilter.ExplicitOnly => song.ExplicitContentLyrics != 3, - - // CleanOnly: Only show clean content - // Include: 0 (naturally clean), 3 (clean/edited version) - // Exclude: 1 (explicit) - ExplicitFilter.CleanOnly => song.ExplicitContentLyrics != 1, - - _ => true - }; - } } diff --git a/allstarr/Services/IMusicMetadataService.cs b/allstarr/Services/IMusicMetadataService.cs index d32f715..25db101 100644 --- a/allstarr/Services/IMusicMetadataService.cs +++ b/allstarr/Services/IMusicMetadataService.cs @@ -55,6 +55,11 @@ public interface IMusicMetadataService /// Task> GetArtistAlbumsAsync(string externalProvider, string externalId); + /// + /// Gets an artist's top tracks (not all songs, just popular tracks from the artist endpoint) + /// + Task> GetArtistTracksAsync(string externalProvider, string externalId); + /// /// Searches for playlists on external providers /// diff --git a/allstarr/Services/Jellyfin/JellyfinProxyService.cs b/allstarr/Services/Jellyfin/JellyfinProxyService.cs index 459189e..05989c9 100644 --- a/allstarr/Services/Jellyfin/JellyfinProxyService.cs +++ b/allstarr/Services/Jellyfin/JellyfinProxyService.cs @@ -176,22 +176,11 @@ public class JellyfinProxyService // Forward authentication headers from client if provided if (clientHeaders != null && clientHeaders.Count > 0) { - // Try X-Emby-Authorization first (case-insensitive) - foreach (var header in clientHeaders) - { - if (header.Key.Equals("X-Emby-Authorization", StringComparison.OrdinalIgnoreCase)) - { - var headerValue = header.Value.ToString(); - request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue); - authHeaderAdded = true; - _logger.LogTrace("Forwarded X-Emby-Authorization header"); - break; - } - } + authHeaderAdded = AuthHeaderHelper.ForwardAuthHeaders(clientHeaders, request); - // Try X-Emby-Token (simpler format used by some clients) - if (!authHeaderAdded) + if (authHeaderAdded) { +<<<<<<< HEAD foreach (var header in clientHeaders) { if (header.Key.Equals("X-Emby-Token", StringComparison.OrdinalIgnoreCase)) @@ -234,6 +223,52 @@ public class JellyfinProxyService break; } } +||||||| f68706f + 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) + { + foreach (var header in clientHeaders) + { + if (header.Key.Equals("Authorization", StringComparison.OrdinalIgnoreCase)) + { + var headerValue = header.Value.ToString(); + + // Check if it's MediaBrowser/Jellyfin format (contains "MediaBrowser" or "Token=") + if (headerValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase) || + headerValue.Contains("Token=", StringComparison.OrdinalIgnoreCase)) + { + // Forward as X-Emby-Authorization (Jellyfin's expected header) + request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue); + authHeaderAdded = true; + _logger.LogTrace("Converted Authorization to X-Emby-Authorization"); + } + else + { + // Standard Bearer token - forward as-is + request.Headers.TryAddWithoutValidation("Authorization", headerValue); + authHeaderAdded = true; + _logger.LogTrace("Forwarded Authorization header"); + } + break; + } + } +======= + _logger.LogTrace("Forwarded authentication headers"); +>>>>>>> beta } // Check for api_key query parameter (some clients use this) @@ -329,23 +364,12 @@ public class JellyfinProxyService 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)) - { - var headerValue = header.Value.ToString(); - request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue); - authHeaderAdded = true; - _logger.LogTrace("Forwarded X-Emby-Authorization header"); - break; - } - } + // Forward authentication headers from client + authHeaderAdded = AuthHeaderHelper.ForwardAuthHeaders(clientHeaders, request); - // Try X-Emby-Token - if (!authHeaderAdded) + if (authHeaderAdded) { +<<<<<<< HEAD foreach (var header in clientHeaders) { if (header.Key.Equals("X-Emby-Token", StringComparison.OrdinalIgnoreCase)) @@ -386,6 +410,50 @@ public class JellyfinProxyService break; } } +||||||| f68706f + 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) + { + if (header.Key.Equals("Authorization", StringComparison.OrdinalIgnoreCase)) + { + var headerValue = header.Value.ToString(); + + // Check if it's MediaBrowser/Jellyfin format + if (headerValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase) || + headerValue.Contains("Client=", StringComparison.OrdinalIgnoreCase)) + { + // Forward as X-Emby-Authorization + request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue); + _logger.LogTrace("Converted Authorization to X-Emby-Authorization"); + } + else + { + // Standard Bearer token + request.Headers.TryAddWithoutValidation("Authorization", headerValue); + _logger.LogTrace("Forwarded Authorization header"); + } + authHeaderAdded = true; + break; + } + } +======= + _logger.LogTrace("Forwarded authentication headers"); +>>>>>>> beta } // For authentication endpoints, credentials are in the body, not headers @@ -536,51 +604,23 @@ public class JellyfinProxyService bool authHeaderAdded = false; - // Forward authentication headers from client (case-insensitive) - foreach (var header in clientHeaders) - { - if (header.Key.Equals("X-Emby-Authorization", StringComparison.OrdinalIgnoreCase)) - { - var headerValue = header.Value.ToString(); - request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue); - authHeaderAdded = true; - _logger.LogDebug("Forwarded X-Emby-Authorization from client"); - break; - } - } - - if (!authHeaderAdded) - { - foreach (var header in clientHeaders) - { - if (header.Key.Equals("Authorization", StringComparison.OrdinalIgnoreCase)) - { - var headerValue = header.Value.ToString(); - - // Check if it's MediaBrowser/Jellyfin format - if (headerValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase) || - headerValue.Contains("Client=", StringComparison.OrdinalIgnoreCase)) - { - // Forward as X-Emby-Authorization - request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue); - _logger.LogDebug("Converted Authorization to X-Emby-Authorization"); - } - else - { - // Standard Bearer token - request.Headers.TryAddWithoutValidation("Authorization", headerValue); - _logger.LogDebug("Forwarded Authorization header"); - } - authHeaderAdded = true; - break; - } - } - } + // Forward authentication headers from client + authHeaderAdded = AuthHeaderHelper.ForwardAuthHeaders(clientHeaders, request); if (!authHeaderAdded) { _logger.LogDebug("No client auth provided for DELETE {Url} - forwarding without auth", url); } + else + { +<<<<<<< HEAD + _logger.LogDebug("No client auth provided for DELETE {Url} - forwarding without auth", url); +||||||| f68706f + _logger.LogInformation("No client auth provided for DELETE {Url} - forwarding without auth", url); +======= + _logger.LogTrace("Forwarded authentication headers"); +>>>>>>> beta + } request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); @@ -627,16 +667,29 @@ public class JellyfinProxyService var result = await GetBytesAsync(endpoint, queryParams); return (result.Body, result.ContentType, true); } + catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) + { + // 404s are expected for missing images - log at debug level + _logger.LogDebug("Image not available for {Endpoint}", endpoint); + return (null, null, false); + } catch (Exception ex) { +<<<<<<< HEAD _logger.LogError(ex, "Failed to get bytes from {Endpoint}", endpoint); +||||||| f68706f + _logger.LogWarning(ex, "Failed to get bytes from {Endpoint}", endpoint); +======= + // Actual errors should still be logged + _logger.LogError(ex, "Failed to get bytes from {Endpoint}", endpoint); +>>>>>>> beta return (null, null, false); } } /// /// Searches for items in Jellyfin. - /// Uses configured or auto-detected LibraryId to filter search to music library only. + /// Does not force any library filtering - clients can specify parentId if they want. /// public async Task<(JsonDocument? Body, int StatusCode)> SearchAsync( string searchTerm, @@ -658,12 +711,24 @@ public class JellyfinProxyService queryParams["userId"] = _settings.UserId; } +<<<<<<< HEAD // Only filter search to music library if explicitly configured if (!string.IsNullOrEmpty(_settings.LibraryId)) { queryParams["parentId"] = _settings.LibraryId; _logger.LogInformation("Searching within configured LibraryId {LibraryId}", _settings.LibraryId); } +||||||| f68706f + // Only filter search to music library if explicitly configured + if (!string.IsNullOrEmpty(_settings.LibraryId)) + { + queryParams["parentId"] = _settings.LibraryId; + _logger.LogDebug("Searching within configured LibraryId {LibraryId}", _settings.LibraryId); + } +======= + // Note: We don't force parentId here - let clients specify which library to search + // The controller will detect music library searches and add external results +>>>>>>> beta if (includeItemTypes != null && includeItemTypes.Length > 0) { diff --git a/allstarr/Services/Jellyfin/JellyfinResponseBuilder.cs b/allstarr/Services/Jellyfin/JellyfinResponseBuilder.cs index fc793d9..ed73d49 100644 --- a/allstarr/Services/Jellyfin/JellyfinResponseBuilder.cs +++ b/allstarr/Services/Jellyfin/JellyfinResponseBuilder.cs @@ -94,41 +94,104 @@ public class JellyfinResponseBuilder /// Creates a response for a playlist represented as an album. /// public IActionResult CreatePlaylistAsAlbumResponse(ExternalPlaylist playlist, List tracks) - { - var totalDuration = tracks.Sum(s => s.Duration ?? 0); - - var curatorName = !string.IsNullOrEmpty(playlist.CuratorName) - ? playlist.CuratorName - : playlist.Provider; - - var albumItem = new Dictionary { - ["Id"] = playlist.Id, - ["Name"] = playlist.Name, - ["Type"] = "Playlist", - ["AlbumArtist"] = curatorName, - ["Genres"] = new[] { "Playlist" }, - ["ChildCount"] = tracks.Count, - ["RunTimeTicks"] = totalDuration * TimeSpan.TicksPerSecond, - ["ImageTags"] = new Dictionary + var totalDuration = tracks.Sum(s => s.Duration ?? 0); + + var curatorName = !string.IsNullOrEmpty(playlist.CuratorName) + ? playlist.CuratorName + : playlist.Provider; + + // Create artist items for the curator + var artistId = $"ext-{playlist.Provider}-curator-{curatorName.ToLowerInvariant().Replace(" ", "-")}"; + var artistItems = new[] { - ["Primary"] = playlist.Id - }, - ["ProviderIds"] = new Dictionary + new Dictionary + { + ["Name"] = curatorName, + ["Id"] = artistId + } + }; + + // Aggregate unique genres from all tracks + var genres = tracks + .Where(s => !string.IsNullOrEmpty(s.Genre)) + .Select(s => s.Genre!) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + // If no genres found, fallback to "Playlist" + if (genres.Count == 0) { - [playlist.Provider] = playlist.ExternalId - }, - ["Children"] = tracks.Select(ConvertSongToJellyfinItem).ToList() - }; - - if (playlist.CreatedDate.HasValue) - { - albumItem["PremiereDate"] = playlist.CreatedDate.Value.ToString("o"); - albumItem["ProductionYear"] = playlist.CreatedDate.Value.Year; + genres.Add("Playlist"); + } + + var genreItems = genres.Select(g => new Dictionary + { + ["Name"] = g, + ["Id"] = $"genre-{g.ToLowerInvariant()}" + }).ToArray(); + + var albumItem = new Dictionary + { + ["Id"] = playlist.Id, + ["Name"] = $"{playlist.Name} [S/P]", // Label as playlist + ["Type"] = "MusicAlbum", // Must be MusicAlbum for Jellyfin clients + ["ServerId"] = "allstarr", + ["ChannelId"] = null, + ["IsFolder"] = true, + ["PremiereDate"] = playlist.CreatedDate?.ToString("o"), + ["ProductionYear"] = playlist.CreatedDate?.Year, + ["Genres"] = genres.ToArray(), + ["GenreItems"] = genreItems, + ["Artists"] = new[] { curatorName }, + ["ArtistItems"] = artistItems, + ["AlbumArtist"] = curatorName, + ["AlbumArtists"] = artistItems, + ["ParentLogoItemId"] = artistId, + ["ParentBackdropItemId"] = artistId, + ["ParentBackdropImageTags"] = new string[0], + ["ChildCount"] = tracks.Count, + ["RunTimeTicks"] = totalDuration * TimeSpan.TicksPerSecond, + ["ImageTags"] = new Dictionary + { + ["Primary"] = playlist.Id + }, + ["BackdropImageTags"] = new string[0], + ["ParentLogoImageTag"] = artistId, + ["ImageBlurHashes"] = new Dictionary(), + ["LocationType"] = "FileSystem", // Must be FileSystem for Jellyfin to show artist albums + ["MediaType"] = "Unknown", + ["UserData"] = new Dictionary + { + ["PlaybackPositionTicks"] = 0, + ["PlayCount"] = 0, + ["IsFavorite"] = false, + ["Played"] = false, + ["Key"] = $"{curatorName}-{playlist.Name}", + ["ItemId"] = playlist.Id + }, + ["ProviderIds"] = new Dictionary + { + [playlist.Provider] = playlist.ExternalId + }, + ["Children"] = tracks.Select(song => + { + var item = ConvertSongToJellyfinItem(song); + // Override ParentId and AlbumId to be the playlist ID + // This makes all tracks appear to be from the same "album" (the playlist) + item["ParentId"] = playlist.Id; + item["AlbumId"] = playlist.Id; + item["AlbumPrimaryImageTag"] = playlist.Id; + item["ParentLogoItemId"] = playlist.Id; + item["ParentLogoImageTag"] = playlist.Id; + item["ParentBackdropItemId"] = playlist.Id; + return item; + }).ToList() + }; + + // Return album object directly (not wrapped) - same as CreateAlbumResponse + return CreateJsonResponse(albumItem); } - - return CreateJsonResponse(albumItem); - } /// /// Creates a search hints response (Jellyfin search format). @@ -663,18 +726,51 @@ public class JellyfinResponseBuilder var item = new Dictionary { + ["Name"] = $"{playlist.Name} [S/P]", + ["ServerId"] = "allstarr", ["Id"] = playlist.Id, - ["Name"] = playlist.Name, - ["Type"] = "Playlist", - ["IsFolder"] = true, - ["AlbumArtist"] = curatorName, - ["ChildCount"] = playlist.TrackCount, + ["ChannelId"] = (object?)null, + ["Genres"] = new string[0], ["RunTimeTicks"] = playlist.Duration * TimeSpan.TicksPerSecond, - ["Genres"] = new[] { "Playlist" }, + ["IsFolder"] = true, + ["Type"] = "MusicAlbum", + ["GenreItems"] = new Dictionary[0], + ["UserData"] = new Dictionary + { + ["PlaybackPositionTicks"] = 0, + ["PlayCount"] = 0, + ["IsFavorite"] = false, + ["Played"] = false, + ["Key"] = playlist.Id, + ["ItemId"] = playlist.Id + }, + ["ChildCount"] = playlist.TrackCount, + ["Artists"] = new[] { curatorName }, + ["ArtistItems"] = new[] + { + new Dictionary + { + ["Name"] = curatorName, + ["Id"] = $"ext-{playlist.Provider}-curator-{curatorName.ToLowerInvariant().Replace(" ", "-")}" + } + }, + ["AlbumArtist"] = curatorName, + ["AlbumArtists"] = new[] + { + new Dictionary + { + ["Name"] = curatorName, + ["Id"] = $"ext-{playlist.Provider}-curator-{curatorName.ToLowerInvariant().Replace(" ", "-")}" + } + }, ["ImageTags"] = new Dictionary { ["Primary"] = playlist.Id }, + ["BackdropImageTags"] = new string[0], + ["ImageBlurHashes"] = new Dictionary(), + ["LocationType"] = "FileSystem", + ["MediaType"] = "Unknown", ["ProviderIds"] = new Dictionary { [playlist.Provider] = playlist.ExternalId diff --git a/allstarr/Services/Lyrics/LrclibService.cs b/allstarr/Services/Lyrics/LrclibService.cs index f5db5e9..a323f52 100644 --- a/allstarr/Services/Lyrics/LrclibService.cs +++ b/allstarr/Services/Lyrics/LrclibService.cs @@ -18,7 +18,13 @@ public class LrclibService ILogger logger) { _httpClient = httpClientFactory.CreateClient(); +<<<<<<< HEAD _httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0.1 (https://github.com/SoPat712/allstarr)"); +||||||| f68706f + _httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0.0 (https://github.com/SoPat712/allstarr)"); +======= + _httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0.3 (https://github.com/SoPat712/allstarr)"); +>>>>>>> beta _cache = cache; _logger = logger; } diff --git a/allstarr/Services/Lyrics/LyricsOrchestrator.cs b/allstarr/Services/Lyrics/LyricsOrchestrator.cs index 4987349..1c7470e 100644 --- a/allstarr/Services/Lyrics/LyricsOrchestrator.cs +++ b/allstarr/Services/Lyrics/LyricsOrchestrator.cs @@ -1,3 +1,4 @@ +<<<<<<< HEAD using allstarr.Models.Lyrics; using allstarr.Models.Settings; using Microsoft.Extensions.Options; @@ -226,3 +227,240 @@ public class LyricsOrchestrator #endregion } +||||||| f68706f +======= +using allstarr.Models.Lyrics; +using allstarr.Models.Settings; +using Microsoft.Extensions.Options; + +namespace allstarr.Services.Lyrics; + +/// +/// Orchestrates lyrics fetching from multiple sources with priority-based fallback. +/// Priority order: Spotify → LyricsPlus → LRCLib +/// Note: Jellyfin local lyrics are handled by the controller before calling this orchestrator. +/// +public class LyricsOrchestrator +{ + private readonly SpotifyLyricsService _spotifyLyrics; + private readonly LyricsPlusService _lyricsPlus; + private readonly LrclibService _lrclib; + private readonly SpotifyApiSettings _spotifySettings; + private readonly ILogger _logger; + + public LyricsOrchestrator( + SpotifyLyricsService spotifyLyrics, + LyricsPlusService lyricsPlus, + LrclibService lrclib, + IOptions spotifySettings, + ILogger logger) + { + _spotifyLyrics = spotifyLyrics; + _lyricsPlus = lyricsPlus; + _lrclib = lrclib; + _spotifySettings = spotifySettings.Value; + _logger = logger; + } + + /// + /// Fetches lyrics with automatic fallback through all available sources. + /// Note: Jellyfin local lyrics are handled by the controller before calling this. + /// + /// Track title + /// Artist names (can be multiple) + /// Album name + /// Track duration in seconds + /// Spotify track ID (if available) + /// Lyrics info or null if not found + public async Task GetLyricsAsync( + string trackName, + string[] artistNames, + string? albumName, + int durationSeconds, + string? spotifyTrackId = null) + { + var artistName = string.Join(", ", artistNames); + + _logger.LogInformation("🎵 Fetching lyrics for: {Artist} - {Track}", artistName, trackName); + + // 1. Try Spotify lyrics (if Spotify ID provided) + if (!string.IsNullOrEmpty(spotifyTrackId)) + { + var spotifyLyrics = await TrySpotifyLyrics(spotifyTrackId, artistName, trackName); + if (spotifyLyrics != null) + { + return spotifyLyrics; + } + } + + // 2. Try LyricsPlus + var lyricsPlusLyrics = await TryLyricsPlusLyrics(trackName, artistNames, albumName, durationSeconds, artistName); + if (lyricsPlusLyrics != null) + { + return lyricsPlusLyrics; + } + + // 3. Try LRCLib + var lrclibLyrics = await TryLrclibLyrics(trackName, artistNames, albumName, durationSeconds, artistName); + if (lrclibLyrics != null) + { + return lrclibLyrics; + } + + _logger.LogInformation("❌ No lyrics found for: {Artist} - {Track}", artistName, trackName); + return null; + } + + /// + /// Prefetches lyrics in the background (for cache warming). + /// Skips Jellyfin local since we don't have an itemId. + /// + public async Task PrefetchLyricsAsync( + string trackName, + string[] artistNames, + string? albumName, + int durationSeconds, + string? spotifyTrackId = null) + { + var artistName = string.Join(", ", artistNames); + + _logger.LogDebug("🎵 Prefetching lyrics for: {Artist} - {Track} (Spotify ID: {SpotifyId})", + artistName, trackName, spotifyTrackId ?? "none"); + + // 1. Try Spotify lyrics (if Spotify ID provided) + if (!string.IsNullOrEmpty(spotifyTrackId)) + { + var spotifyLyrics = await TrySpotifyLyrics(spotifyTrackId, artistName, trackName); + if (spotifyLyrics != null) + { + return true; + } + } + else + { + _logger.LogDebug("No Spotify ID available for prefetch, skipping Spotify lyrics"); + } + + // 2. Try LyricsPlus + var lyricsPlusLyrics = await TryLyricsPlusLyrics(trackName, artistNames, albumName, durationSeconds, artistName); + if (lyricsPlusLyrics != null) + { + return true; + } + + // 3. Try LRCLib + var lrclibLyrics = await TryLrclibLyrics(trackName, artistNames, albumName, durationSeconds, artistName); + if (lrclibLyrics != null) + { + return true; + } + + _logger.LogDebug("No lyrics found for prefetch: {Artist} - {Track}", artistName, trackName); + return false; + } + + #region Private Helper Methods + + private async Task TrySpotifyLyrics(string spotifyTrackId, string artistName, string trackName) + { + if (!_spotifySettings.Enabled) + { + _logger.LogWarning("Spotify API not enabled, skipping Spotify lyrics"); + return null; + } + + try + { + // Validate Spotify ID format + var cleanSpotifyId = spotifyTrackId.Replace("spotify:track:", "").Trim(); + + if (cleanSpotifyId.Length != 22 || cleanSpotifyId.Contains(":") || cleanSpotifyId.Contains("local")) + { + _logger.LogWarning("Invalid Spotify ID format: {SpotifyId}, skipping", spotifyTrackId); + return null; + } + + _logger.LogDebug("→ Trying Spotify lyrics for track ID: {SpotifyId}", cleanSpotifyId); + + var spotifyLyrics = await _spotifyLyrics.GetLyricsByTrackIdAsync(cleanSpotifyId); + + if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0) + { + _logger.LogDebug("✓ Found Spotify lyrics for {Artist} - {Track} ({LineCount} lines, type: {SyncType})", + artistName, trackName, spotifyLyrics.Lines.Count, spotifyLyrics.SyncType); + + return _spotifyLyrics.ToLyricsInfo(spotifyLyrics); + } + + _logger.LogDebug("No Spotify lyrics found for track ID {SpotifyId}", cleanSpotifyId); + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching Spotify lyrics for track ID {SpotifyId}", spotifyTrackId); + return null; + } + } + + private async Task TryLyricsPlusLyrics( + string trackName, + string[] artistNames, + string? albumName, + int durationSeconds, + string artistName) + { + try + { + _logger.LogDebug("→ Trying LyricsPlus for: {Artist} - {Track}", artistName, trackName); + + var lyrics = await _lyricsPlus.GetLyricsAsync(trackName, artistNames, albumName, durationSeconds); + + if (lyrics != null) + { + // LyricsPlus already logs with source info, so we just confirm success + _logger.LogDebug("✓ LyricsOrchestrator: Using LyricsPlus lyrics for {Artist} - {Track}", artistName, trackName); + return lyrics; + } + + _logger.LogDebug("No LyricsPlus lyrics found for {Artist} - {Track}", artistName, trackName); + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching LyricsPlus lyrics for {Artist} - {Track}", artistName, trackName); + return null; + } + } + + private async Task TryLrclibLyrics( + string trackName, + string[] artistNames, + string? albumName, + int durationSeconds, + string artistName) + { + try + { + _logger.LogDebug("→ Trying LRCLib for: {Artist} - {Track}", artistName, trackName); + + var lyrics = await _lrclib.GetLyricsAsync(trackName, artistNames, albumName ?? string.Empty, durationSeconds); + + if (lyrics != null) + { + _logger.LogInformation("✓ LyricsOrchestrator: Using LRCLib lyrics for {Artist} - {Track}", artistName, trackName); + return lyrics; + } + + _logger.LogDebug("No LRCLib lyrics found for {Artist} - {Track}", artistName, trackName); + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching LRCLib lyrics for {Artist} - {Track}", artistName, trackName); + return null; + } + } + + #endregion +} +>>>>>>> beta diff --git a/allstarr/Services/Lyrics/LyricsPlusService.cs b/allstarr/Services/Lyrics/LyricsPlusService.cs index 987483e..f3b5f06 100644 --- a/allstarr/Services/Lyrics/LyricsPlusService.cs +++ b/allstarr/Services/Lyrics/LyricsPlusService.cs @@ -1,3 +1,4 @@ +<<<<<<< HEAD using System.Text.Json; using System.Text.Json.Serialization; using allstarr.Models.Lyrics; @@ -252,3 +253,260 @@ public class LyricsPlusService public string Text { get; set; } = string.Empty; } } +||||||| f68706f +======= +using System.Text.Json; +using System.Text.Json.Serialization; +using allstarr.Models.Lyrics; +using allstarr.Services.Common; + +namespace allstarr.Services.Lyrics; + +/// +/// Service for fetching lyrics from LyricsPlus API (https://lyricsplus.prjktla.workers.dev) +/// Supports multiple sources: Apple Music, Spotify, Musixmatch, and more +/// +public class LyricsPlusService +{ + private readonly HttpClient _httpClient; + private readonly RedisCacheService _cache; + private readonly ILogger _logger; + private const string BaseUrl = "https://lyricsplus.prjktla.workers.dev/v2/lyrics/get"; + + public LyricsPlusService( + IHttpClientFactory httpClientFactory, + RedisCacheService cache, + ILogger logger) + { + _httpClient = httpClientFactory.CreateClient(); + _httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0.3 (https://github.com/SoPat712/allstarr)"); + _cache = cache; + _logger = logger; + } + + public async Task GetLyricsAsync(string trackName, string artistName, string? albumName, int durationSeconds) + { + return await GetLyricsAsync(trackName, new[] { artistName }, albumName, durationSeconds); + } + + public async Task 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 LyricsPlus search: trackName={TrackName}, artistCount={ArtistCount}", + trackName, artistNames?.Length ?? 0); + return null; + } + + var artistName = string.Join(", ", artistNames); + var cacheKey = $"lyricsplus:{artistName}:{trackName}:{albumName}:{durationSeconds}"; + + // Check cache + var cached = await _cache.GetStringAsync(cacheKey); + if (!string.IsNullOrEmpty(cached)) + { + try + { + return JsonSerializer.Deserialize(cached, JsonOptions); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to deserialize cached LyricsPlus lyrics"); + } + } + + try + { + // Build URL with query parameters + var url = $"{BaseUrl}?title={Uri.EscapeDataString(trackName)}&artist={Uri.EscapeDataString(artistName)}"; + + if (!string.IsNullOrEmpty(albumName)) + { + url += $"&album={Uri.EscapeDataString(albumName)}"; + } + + if (durationSeconds > 0) + { + url += $"&duration={durationSeconds}"; + } + + // Add sources: apple, lyricsplus, musixmatch, spotify, musixmatch-word + url += "&source=apple,lyricsplus,musixmatch,spotify,musixmatch-word"; + + _logger.LogDebug("Fetching lyrics from LyricsPlus: {Url}", url); + + var response = await _httpClient.GetAsync(url); + + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + _logger.LogDebug("Lyrics not found on LyricsPlus for {Artist} - {Track}", artistName, trackName); + return null; + } + + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + var lyricsResponse = JsonSerializer.Deserialize(json, JsonOptions); + + if (lyricsResponse == null || lyricsResponse.Lyrics == null || lyricsResponse.Lyrics.Count == 0) + { + _logger.LogDebug("Empty lyrics response from LyricsPlus for {Artist} - {Track}", artistName, trackName); + return null; + } + + // Convert to LyricsInfo format + var result = ConvertToLyricsInfo(lyricsResponse, trackName, artistName, albumName, durationSeconds); + + if (result != null) + { + await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(result, JsonOptions), CacheExtensions.LyricsTTL); + _logger.LogInformation("✓ Retrieved lyrics from LyricsPlus for {Artist} - {Track} (type: {Type}, source: {Source})", + artistName, trackName, lyricsResponse.Type, lyricsResponse.Metadata?.Source); + } + + return result; + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "Failed to fetch lyrics from LyricsPlus for {Artist} - {Track}", artistName, trackName); + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching lyrics from LyricsPlus for {Artist} - {Track}", artistName, trackName); + return null; + } + } + + private LyricsInfo? ConvertToLyricsInfo(LyricsPlusResponse response, string trackName, string artistName, string? albumName, int durationSeconds) + { + if (response.Lyrics == null || response.Lyrics.Count == 0) + { + return null; + } + + string? syncedLyrics = null; + string? plainLyrics = null; + + // Convert based on type + if (response.Type == "Word") + { + // Word-level timing - convert to line-level LRC + syncedLyrics = ConvertWordTimingToLrc(response.Lyrics); + plainLyrics = string.Join("\n", response.Lyrics.Select(l => l.Text)); + } + else if (response.Type == "Line") + { + // Line-level timing - convert to LRC + syncedLyrics = ConvertLineTimingToLrc(response.Lyrics); + plainLyrics = string.Join("\n", response.Lyrics.Select(l => l.Text)); + } + else + { + // Static or unknown type - just plain text + plainLyrics = string.Join("\n", response.Lyrics.Select(l => l.Text)); + } + + return new LyricsInfo + { + TrackName = trackName, + ArtistName = artistName, + AlbumName = albumName ?? string.Empty, + Duration = durationSeconds, + Instrumental = false, + PlainLyrics = plainLyrics, + SyncedLyrics = syncedLyrics + }; + } + + private string ConvertLineTimingToLrc(List lines) + { + var lrcLines = new List(); + + foreach (var line in lines) + { + if (line.Time.HasValue) + { + var timestamp = TimeSpan.FromMilliseconds(line.Time.Value); + var mm = (int)timestamp.TotalMinutes; + var ss = timestamp.Seconds; + var cs = timestamp.Milliseconds / 10; // Convert to centiseconds + + lrcLines.Add($"[{mm:D2}:{ss:D2}.{cs:D2}]{line.Text}"); + } + else + { + // No timing, just add the text + lrcLines.Add(line.Text); + } + } + + return string.Join("\n", lrcLines); + } + + private string ConvertWordTimingToLrc(List lines) + { + // For word-level timing, we use the line start time + // (word-level detail is in syllabus array but we simplify to line-level for LRC) + return ConvertLineTimingToLrc(lines); + } + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private class LyricsPlusResponse + { + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; // "Word", "Line", or "Static" + + [JsonPropertyName("metadata")] + public LyricsPlusMetadata? Metadata { get; set; } + + [JsonPropertyName("lyrics")] + public List Lyrics { get; set; } = new(); + } + + private class LyricsPlusMetadata + { + [JsonPropertyName("source")] + public string? Source { get; set; } + + [JsonPropertyName("title")] + public string? Title { get; set; } + + [JsonPropertyName("language")] + public string? Language { get; set; } + } + + private class LyricsPlusLine + { + [JsonPropertyName("time")] + public long? Time { get; set; } // Milliseconds + + [JsonPropertyName("duration")] + public long? Duration { get; set; } + + [JsonPropertyName("text")] + public string Text { get; set; } = string.Empty; + + [JsonPropertyName("syllabus")] + public List? Syllabus { get; set; } + } + + private class LyricsPlusSyllable + { + [JsonPropertyName("time")] + public long Time { get; set; } + + [JsonPropertyName("duration")] + public long Duration { get; set; } + + [JsonPropertyName("text")] + public string Text { get; set; } = string.Empty; + } +} +>>>>>>> beta diff --git a/allstarr/Services/Lyrics/LyricsPrefetchService.cs b/allstarr/Services/Lyrics/LyricsPrefetchService.cs index 81831eb..d0a36e5 100644 --- a/allstarr/Services/Lyrics/LyricsPrefetchService.cs +++ b/allstarr/Services/Lyrics/LyricsPrefetchService.cs @@ -125,7 +125,7 @@ public class LyricsPrefetchService : BackgroundService } // Get the pre-built playlist items cache which includes Jellyfin item IDs for local tracks - var playlistItemsKey = $"spotify:playlist:items:{playlistName}"; + var playlistItemsKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlistName); var playlistItems = await _cache.GetAsync>>(playlistItemsKey); // Build a map of Spotify ID -> Jellyfin Item ID for quick lookup diff --git a/allstarr/Services/MusicBrainz/MusicBrainzService.cs b/allstarr/Services/MusicBrainz/MusicBrainzService.cs index 1b0d326..2999525 100644 --- a/allstarr/Services/MusicBrainz/MusicBrainzService.cs +++ b/allstarr/Services/MusicBrainz/MusicBrainzService.cs @@ -4,6 +4,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using allstarr.Models.Domain; using allstarr.Models.Settings; +using allstarr.Services.Common; using Microsoft.Extensions.Options; namespace allstarr.Services.MusicBrainz; @@ -15,6 +16,8 @@ public class MusicBrainzService { private readonly HttpClient _httpClient; private readonly MusicBrainzSettings _settings; + private readonly CacheSettings _cacheSettings; + private readonly RedisCacheService _cache; private readonly ILogger _logger; private DateTime _lastRequestTime = DateTime.MinValue; private readonly SemaphoreSlim _rateLimitSemaphore = new(1, 1); @@ -22,13 +25,23 @@ public class MusicBrainzService public MusicBrainzService( IHttpClientFactory httpClientFactory, IOptions settings, + IOptions cacheSettings, + RedisCacheService cache, ILogger logger) { _httpClient = httpClientFactory.CreateClient(); +<<<<<<< HEAD _httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0.1 (https://github.com/SoPat712/allstarr)"); +||||||| f68706f + _httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0.0 (https://github.com/SoPat712/allstarr)"); +======= + _httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0.3 (https://github.com/SoPat712/allstarr)"); +>>>>>>> beta _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); _settings = settings.Value; + _cacheSettings = cacheSettings.Value; + _cache = cache; _logger = logger; // Set up digest authentication if credentials provided @@ -51,6 +64,15 @@ public class MusicBrainzService return null; } + // Check cache first + var cacheKey = $"musicbrainz:isrc:{isrc}"; + var cached = await _cache.GetAsync(cacheKey); + if (cached != null) + { + _logger.LogDebug("MusicBrainz ISRC cache hit: {Isrc}", isrc); + return cached; + } + await RateLimitAsync(); try @@ -81,6 +103,9 @@ public class MusicBrainzService _logger.LogInformation("✓ Found MusicBrainz recording for ISRC {Isrc}: {Title} by {Artist} (Genres: {Genres})", isrc, recording.Title, recording.ArtistCredit?[0]?.Name ?? "Unknown", string.Join(", ", genres)); + // Cache the result + await _cache.SetAsync(cacheKey, recording, _cacheSettings.GenreTTL); + return recording; } catch (Exception ex) @@ -101,6 +126,15 @@ public class MusicBrainzService return new List(); } + // Check cache first + var cacheKey = $"musicbrainz:search:{title.ToLowerInvariant()}:{artist.ToLowerInvariant()}:{limit}"; + var cached = await _cache.GetAsync>(cacheKey); + if (cached != null) + { + _logger.LogDebug("MusicBrainz search cache hit: {Title} - {Artist}", title, artist); + return cached; + } + await RateLimitAsync(); try @@ -133,6 +167,9 @@ public class MusicBrainzService _logger.LogDebug("Found {Count} MusicBrainz recordings for: {Title} - {Artist}", result.Recordings.Count, title, artist); + // Cache the result + await _cache.SetAsync(cacheKey, result.Recordings, _cacheSettings.GenreTTL); + return result.Recordings; } catch (Exception ex) @@ -143,6 +180,7 @@ public class MusicBrainzService } /// +<<<<<<< HEAD /// Looks up a recording by MBID to get full details including genres. /// public async Task LookupByMbidAsync(string mbid) @@ -190,6 +228,68 @@ public class MusicBrainzService } /// +||||||| f68706f +======= + /// Looks up a recording by MBID to get full details including genres. + /// + public async Task LookupByMbidAsync(string mbid) + { + if (!_settings.Enabled) + { + return null; + } + + // Check cache first + var cacheKey = $"musicbrainz:mbid:{mbid}"; + var cached = await _cache.GetAsync(cacheKey); + if (cached != null) + { + _logger.LogDebug("MusicBrainz MBID cache hit: {Mbid}", mbid); + return cached; + } + + await RateLimitAsync(); + + try + { + var url = $"{_settings.BaseUrl}/recording/{mbid}?fmt=json&inc=artists+releases+release-groups+genres+tags"; + _logger.LogDebug("MusicBrainz MBID lookup: {Url}", url); + + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("MusicBrainz MBID lookup failed: {StatusCode}", response.StatusCode); + return null; + } + + var json = await response.Content.ReadAsStringAsync(); + var recording = JsonSerializer.Deserialize(json, JsonOptions); + + if (recording == null) + { + _logger.LogDebug("No MusicBrainz recording found for MBID: {Mbid}", mbid); + return null; + } + + var genres = recording.Genres?.Select(g => g.Name).Where(n => !string.IsNullOrEmpty(n)).ToList() ?? new List(); + _logger.LogInformation("✓ Found MusicBrainz recording for MBID {Mbid}: {Title} by {Artist} (Genres: {Genres})", + mbid, recording.Title, recording.ArtistCredit?[0]?.Name ?? "Unknown", string.Join(", ", genres)); + + // Cache the result + await _cache.SetAsync(cacheKey, recording, _cacheSettings.GenreTTL); + + return recording; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error looking up MBID {Mbid} in MusicBrainz", mbid); + return null; + } + } + + /// +>>>>>>> beta /// Enriches a song with genre information from MusicBrainz. /// First tries ISRC lookup, then falls back to title/artist search + MBID lookup. /// diff --git a/allstarr/Services/Qobuz/QobuzDownloadService.cs b/allstarr/Services/Qobuz/QobuzDownloadService.cs index 9d91150..63ba0b5 100644 --- a/allstarr/Services/Qobuz/QobuzDownloadService.cs +++ b/allstarr/Services/Qobuz/QobuzDownloadService.cs @@ -80,15 +80,6 @@ public class QobuzDownloadService : BaseDownloadService } } - protected override string? ExtractExternalIdFromAlbumId(string albumId) - { - const string prefix = "ext-qobuz-album-"; - if (albumId.StartsWith(prefix)) - { - return albumId[prefix.Length..]; - } - return null; - } protected override async Task DownloadTrackAsync(string trackId, Song song, CancellationToken cancellationToken) { diff --git a/allstarr/Services/Qobuz/QobuzMetadataService.cs b/allstarr/Services/Qobuz/QobuzMetadataService.cs index d5c4350..4a17e2e 100644 --- a/allstarr/Services/Qobuz/QobuzMetadataService.cs +++ b/allstarr/Services/Qobuz/QobuzMetadataService.cs @@ -68,10 +68,7 @@ public class QobuzMetadataService : IMusicMetadataService foreach (var track in items.EnumerateArray()) { var song = ParseQobuzTrack(track); - if (ShouldIncludeSong(song)) - { - songs.Add(song); - } + songs.Add(song); } } @@ -241,10 +238,7 @@ public class QobuzMetadataService : IMusicMetadataService song.AlbumId = album.Id; song.AlbumArtist = album.Artist; - if (ShouldIncludeSong(song)) - { - album.Songs.Add(song); - } + album.Songs.Add(song); } } @@ -334,6 +328,14 @@ public class QobuzMetadataService : IMusicMetadataService } } + public async Task> GetArtistTracksAsync(string externalProvider, string externalId) + { + // Qobuz doesn't have a dedicated "artist top tracks" endpoint + // Return empty list - clients will need to browse albums instead + if (externalProvider != "qobuz") return new List(); + return new List(); + } + public async Task> SearchPlaylistsAsync(string query, int limit = 20) { try @@ -429,10 +431,11 @@ public class QobuzMetadataService : IMusicMetadataService song.Album = playlistName; song.Track = trackIndex; - if (ShouldIncludeSong(song)) - { - songs.Add(song); - } + // Playlists should not have disc numbers - always set to null + // This prevents Jellyfin from splitting the playlist into multiple "discs" + song.DiscNumber = null; + + songs.Add(song); trackIndex++; } } @@ -835,14 +838,4 @@ public class QobuzMetadataService : IMusicMetadataService .Replace("(C)", "©"); } - /// - /// Determines whether a song should be included based on the explicit content filter setting - /// Note: Qobuz doesn't have the same explicit content tagging as Deezer, so this is a no-op for now - /// - private bool ShouldIncludeSong(Song song) - { - // Qobuz API doesn't expose explicit content flags in the same way as Deezer - // We could implement this in the future if needed - return true; - } } diff --git a/allstarr/Services/Scrobbling/IScrobblingService.cs b/allstarr/Services/Scrobbling/IScrobblingService.cs new file mode 100644 index 0000000..be8224d --- /dev/null +++ b/allstarr/Services/Scrobbling/IScrobblingService.cs @@ -0,0 +1,46 @@ +using allstarr.Models.Scrobbling; + +namespace allstarr.Services.Scrobbling; + +/// +/// Interface for scrobbling services (Last.fm, ListenBrainz, etc.). +/// +public interface IScrobblingService +{ + /// + /// Service name (e.g., "Last.fm", "ListenBrainz"). + /// + string ServiceName { get; } + + /// + /// Whether this service is enabled and configured. + /// + bool IsEnabled { get; } + + /// + /// Updates "Now Playing" status for a track. + /// This is optional but recommended - shows what the user is currently listening to. + /// + /// Track being played + /// Cancellation token + /// Result of the request + Task UpdateNowPlayingAsync(ScrobbleTrack track, CancellationToken cancellationToken = default); + + /// + /// Scrobbles a track (adds to listening history). + /// Should only be called when scrobble conditions are met (see Last.fm rules). + /// + /// Track to scrobble + /// Cancellation token + /// Result of the request + Task ScrobbleAsync(ScrobbleTrack track, CancellationToken cancellationToken = default); + + /// + /// Scrobbles multiple tracks in a batch (up to 50 for Last.fm). + /// Useful for retrying cached scrobbles. + /// + /// Tracks to scrobble + /// Cancellation token + /// Results for each track + Task> ScrobbleBatchAsync(List tracks, CancellationToken cancellationToken = default); +} diff --git a/allstarr/Services/Scrobbling/LastFmScrobblingService.cs b/allstarr/Services/Scrobbling/LastFmScrobblingService.cs new file mode 100644 index 0000000..3c0199c --- /dev/null +++ b/allstarr/Services/Scrobbling/LastFmScrobblingService.cs @@ -0,0 +1,458 @@ +using System.Security.Cryptography; +using System.Text; +using System.Xml.Linq; +using Microsoft.Extensions.Options; +using allstarr.Models.Scrobbling; +using allstarr.Models.Settings; + +namespace allstarr.Services.Scrobbling; + +/// +/// Last.fm scrobbling service implementation. +/// Follows the Scrobbling 2.0 API specification. +/// +public class LastFmScrobblingService : IScrobblingService +{ + private const string ApiRoot = "https://ws.audioscrobbler.com/2.0/"; + private const int MaxBatchSize = 50; + + private readonly LastFmSettings _settings; + private readonly ScrobblingSettings _globalSettings; + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + public string ServiceName => "Last.fm"; + public bool IsEnabled => _settings.Enabled && + !string.IsNullOrEmpty(_settings.ApiKey) && + !string.IsNullOrEmpty(_settings.SharedSecret) && + !string.IsNullOrEmpty(_settings.SessionKey); + + public LastFmScrobblingService( + IOptions settings, + IHttpClientFactory httpClientFactory, + ILogger logger) + { + _globalSettings = settings.Value; + _settings = settings.Value.LastFm; + _httpClient = httpClientFactory.CreateClient("LastFm"); + _logger = logger; + + if (IsEnabled) + { + _logger.LogInformation("🎵 Last.fm scrobbling enabled for user: {Username}", + _settings.Username ?? "Unknown"); + } + } + + public async Task UpdateNowPlayingAsync(ScrobbleTrack track, CancellationToken cancellationToken = default) + { + if (!IsEnabled) + { + return ScrobbleResult.CreateError("Last.fm scrobbling not enabled or configured"); + } + + // Only scrobble external tracks (unless local tracks are enabled) + if (!track.IsExternal && !_globalSettings.LocalTracksEnabled) + { + return ScrobbleResult.CreateIgnored("Local library tracks are not scrobbled (LocalTracksEnabled=false)", 0); + } + + _logger.LogDebug("→ Updating Now Playing on Last.fm: {Artist} - {Track}", track.Artist, track.Title); + + try + { + var parameters = BuildBaseParameters("track.updateNowPlaying"); + AddTrackParameters(parameters, track, includeTimestamp: false); + + var response = await SendRequestAsync(parameters, cancellationToken); + var result = ParseResponse(response, isScrobble: false); + + if (result.Success && !result.Ignored) + { + _logger.LogDebug("✓ Now Playing updated on Last.fm: {Artist} - {Track}", + track.Artist, track.Title); + } + else if (result.Ignored) + { + _logger.LogWarning("⚠️ Now Playing ignored by Last.fm: {Reason}", result.IgnoredReason); + } + + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "❌ Failed to update Now Playing on Last.fm"); + return ScrobbleResult.CreateError($"Exception: {ex.Message}"); + } + } + + public async Task ScrobbleAsync(ScrobbleTrack track, CancellationToken cancellationToken = default) + { + if (!IsEnabled) + { + return ScrobbleResult.CreateError("Last.fm scrobbling not enabled or configured"); + } + + // Only scrobble external tracks (unless local tracks are enabled) + if (!track.IsExternal && !_globalSettings.LocalTracksEnabled) + { + return ScrobbleResult.CreateIgnored("Local library tracks are not scrobbled (LocalTracksEnabled=false)", 0); + } + + if (track.Timestamp == null) + { + return ScrobbleResult.CreateError("Timestamp is required for scrobbling"); + } + + _logger.LogDebug("→ Scrobbling to Last.fm: {Artist} - {Track}", track.Artist, track.Title); + + try + { + var parameters = BuildBaseParameters("track.scrobble"); + AddTrackParameters(parameters, track, includeTimestamp: true); + + var response = await SendRequestAsync(parameters, cancellationToken); + var result = ParseResponse(response, isScrobble: true); + + if (result.Success && !result.Ignored) + { + _logger.LogDebug("✓ Scrobbled to Last.fm: {Artist} - {Track}", + track.Artist, track.Title); + + if (result.ArtistCorrected || result.TrackCorrected || result.AlbumCorrected) + { + _logger.LogDebug("📝 Last.fm corrections: Artist={Artist}, Track={Track}, Album={Album}", + result.CorrectedArtist ?? track.Artist, + result.CorrectedTrack ?? track.Title, + result.CorrectedAlbum ?? track.Album); + } + } + else if (result.Ignored) + { + _logger.LogWarning("⚠️ Scrobble ignored by Last.fm: {Reason} (code: {Code})", + result.IgnoredReason, result.IgnoredCode); + } + + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "❌ Failed to scrobble to Last.fm"); + return ScrobbleResult.CreateError($"Exception: {ex.Message}"); + } + } + + public async Task> ScrobbleBatchAsync(List tracks, CancellationToken cancellationToken = default) + { + if (!IsEnabled) + { + return tracks.Select(_ => ScrobbleResult.CreateError("Last.fm scrobbling not enabled or configured")).ToList(); + } + + if (tracks.Count == 0) + { + return new List(); + } + + // Filter out local tracks (unless local tracks are enabled) + var allowedTracks = tracks.Where(t => t.IsExternal || _globalSettings.LocalTracksEnabled).ToList(); + var filteredTracks = tracks.Where(t => !t.IsExternal && !_globalSettings.LocalTracksEnabled).ToList(); + + var results = new List(); + + // Add ignored results for filtered local tracks + results.AddRange(filteredTracks.Select(_ => + ScrobbleResult.CreateIgnored("Local library tracks are not scrobbled (LocalTracksEnabled=false)", 0))); + + if (allowedTracks.Count == 0) + { + return results; + } + + if (allowedTracks.Count > MaxBatchSize) + { + _logger.LogWarning("Batch size {Count} exceeds maximum {Max}, splitting into multiple requests", + allowedTracks.Count, MaxBatchSize); + + for (int i = 0; i < allowedTracks.Count; i += MaxBatchSize) + { + var batch = allowedTracks.Skip(i).Take(MaxBatchSize).ToList(); + var batchResults = await ScrobbleBatchAsync(batch, cancellationToken); + results.AddRange(batchResults); + } + return results; + } + + _logger.LogDebug("→ Scrobbling batch of {Count} tracks to Last.fm", allowedTracks.Count); + + try + { + var parameters = BuildBaseParameters("track.scrobble"); + + // Add parameters for each track with index suffix + for (int i = 0; i < allowedTracks.Count; i++) + { + AddTrackParameters(parameters, allowedTracks[i], includeTimestamp: true, index: i); + } + + var response = await SendRequestAsync(parameters, cancellationToken); + var batchResults = ParseBatchResponse(response, allowedTracks.Count); + + var accepted = batchResults.Count(r => r.Success && !r.Ignored); + var ignored = batchResults.Count(r => r.Ignored); + var failed = batchResults.Count(r => !r.Success); + + _logger.LogDebug("✓ Batch scrobble complete: {Accepted} accepted, {Ignored} ignored, {Failed} failed", + accepted, ignored, failed); + + results.AddRange(batchResults); + return results; + } + catch (Exception ex) + { + _logger.LogError(ex, "❌ Failed to scrobble batch to Last.fm"); + results.AddRange(allowedTracks.Select(_ => ScrobbleResult.CreateError($"Exception: {ex.Message}"))); + return results; + } + } + + #region Helper Methods + + /// + /// Builds base parameters for all API requests (api_key, method, sk). + /// + private Dictionary BuildBaseParameters(string method) + { + return new Dictionary + { + ["api_key"] = _settings.ApiKey, + ["method"] = method, + ["sk"] = _settings.SessionKey + }; + } + + /// + /// Adds track-specific parameters to the request. + /// + private void AddTrackParameters(Dictionary parameters, ScrobbleTrack track, bool includeTimestamp, int? index = null) + { + var suffix = index.HasValue ? $"[{index}]" : ""; + + parameters[$"artist{suffix}"] = track.Artist; + parameters[$"track{suffix}"] = track.Title; + + if (!string.IsNullOrEmpty(track.Album)) + parameters[$"album{suffix}"] = track.Album; + + if (!string.IsNullOrEmpty(track.AlbumArtist)) + parameters[$"albumArtist{suffix}"] = track.AlbumArtist; + + if (track.DurationSeconds.HasValue) + parameters[$"duration{suffix}"] = track.DurationSeconds.Value.ToString(); + + if (!string.IsNullOrEmpty(track.MusicBrainzId)) + parameters[$"mbid{suffix}"] = track.MusicBrainzId; + + if (includeTimestamp && track.Timestamp.HasValue) + parameters[$"timestamp{suffix}"] = track.Timestamp.Value.ToString(); + + // Only include chosenByUser if it's false (default is true) + if (!track.ChosenByUser) + parameters[$"chosenByUser{suffix}"] = "0"; + } + + /// + /// Generates MD5 signature for API request. + /// Format: api_key{value}method{value}...{shared_secret} + /// + private string GenerateSignature(Dictionary parameters) + { + // Sort parameters alphabetically by key + var sorted = parameters.OrderBy(kvp => kvp.Key); + + // Build signature string: key1value1key2value2...secret + var signatureString = new StringBuilder(); + foreach (var kvp in sorted) + { + signatureString.Append(kvp.Key); + signatureString.Append(kvp.Value); + } + signatureString.Append(_settings.SharedSecret); + + // Generate MD5 hash + var bytes = Encoding.UTF8.GetBytes(signatureString.ToString()); + var hash = MD5.HashData(bytes); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + + /// + /// Sends HTTP POST request to Last.fm API. + /// + private async Task SendRequestAsync(Dictionary parameters, CancellationToken cancellationToken) + { + // Add signature + parameters["api_sig"] = GenerateSignature(parameters); + + // Create form content + var content = new FormUrlEncodedContent(parameters); + + // Send request + var response = await _httpClient.PostAsync(ApiRoot, content, cancellationToken); + var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); + + // Log request/response for debugging + _logger.LogTrace("Last.fm request: {Method}, Response: {StatusCode}", + parameters["method"], response.StatusCode); + + // Always inspect response body, even if HTTP status is not 200 + return responseBody; + } + + /// + /// Parses Last.fm XML response for single scrobble/now playing. + /// + private ScrobbleResult ParseResponse(string xml, bool isScrobble) + { + try + { + var doc = XDocument.Parse(xml); + var root = doc.Root; + + if (root == null) + { + return ScrobbleResult.CreateError("Invalid XML response"); + } + + var status = root.Attribute("status")?.Value; + + // Check for error + if (status == "failed") + { + var errorElement = root.Element("error"); + var errorCode = int.Parse(errorElement?.Attribute("code")?.Value ?? "0"); + var errorMessage = errorElement?.Value ?? "Unknown error"; + + // Determine if should retry based on error code + var shouldRetry = errorCode == 11 || errorCode == 16; // Service offline or temporarily unavailable + + // Error code 9 means session key is invalid - log prominently + if (errorCode == 9) + { + _logger.LogError("❌ Last.fm session key is invalid - please re-authenticate"); + } + + return ScrobbleResult.CreateError(errorMessage, errorCode, shouldRetry); + } + + // Success - check for ignored message + if (isScrobble) + { + var scrobbleElement = root.Descendants("scrobble").FirstOrDefault(); + if (scrobbleElement != null) + { + return ParseScrobbleElement(scrobbleElement); + } + } + + return ScrobbleResult.CreateSuccess(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to parse Last.fm response: {Xml}", xml); + return ScrobbleResult.CreateError($"Parse error: {ex.Message}"); + } + } + + /// + /// Parses Last.fm XML response for batch scrobble. + /// + private List ParseBatchResponse(string xml, int expectedCount) + { + try + { + var doc = XDocument.Parse(xml); + var root = doc.Root; + + if (root == null) + { + return Enumerable.Repeat(ScrobbleResult.CreateError("Invalid XML response"), expectedCount).ToList(); + } + + var status = root.Attribute("status")?.Value; + + // Check for error + if (status == "failed") + { + var errorElement = root.Element("error"); + var errorCode = int.Parse(errorElement?.Attribute("code")?.Value ?? "0"); + var errorMessage = errorElement?.Value ?? "Unknown error"; + var shouldRetry = errorCode == 11 || errorCode == 16; + + return Enumerable.Repeat(ScrobbleResult.CreateError(errorMessage, errorCode, shouldRetry), expectedCount).ToList(); + } + + // Parse individual scrobble results + var scrobbleElements = root.Descendants("scrobble").ToList(); + return scrobbleElements.Select(ParseScrobbleElement).ToList(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to parse Last.fm batch response: {Xml}", xml); + return Enumerable.Repeat(ScrobbleResult.CreateError($"Parse error: {ex.Message}"), expectedCount).ToList(); + } + } + + /// + /// Parses a single scrobble element from XML response. + /// + private ScrobbleResult ParseScrobbleElement(XElement scrobbleElement) + { + var result = new ScrobbleResult { Success = true }; + + // Check for ignored message + var ignoredElement = scrobbleElement.Element("ignoredmessage"); + if (ignoredElement != null) + { + var ignoredCode = int.Parse(ignoredElement.Attribute("code")?.Value ?? "0"); + if (ignoredCode > 0) + { + return ScrobbleResult.CreateIgnored(ignoredElement.Value.Trim(), ignoredCode); + } + } + + // Check for corrections + var artistElement = scrobbleElement.Element("artist"); + if (artistElement != null && artistElement.Attribute("corrected")?.Value == "1") + { + result = result with + { + ArtistCorrected = true, + CorrectedArtist = artistElement.Value + }; + } + + var trackElement = scrobbleElement.Element("track"); + if (trackElement != null && trackElement.Attribute("corrected")?.Value == "1") + { + result = result with + { + TrackCorrected = true, + CorrectedTrack = trackElement.Value + }; + } + + var albumElement = scrobbleElement.Element("album"); + if (albumElement != null && albumElement.Attribute("corrected")?.Value == "1") + { + result = result with + { + AlbumCorrected = true, + CorrectedAlbum = albumElement.Value + }; + } + + return result; + } + + #endregion +} diff --git a/allstarr/Services/Scrobbling/ListenBrainzScrobblingService.cs b/allstarr/Services/Scrobbling/ListenBrainzScrobblingService.cs new file mode 100644 index 0000000..8babd8f --- /dev/null +++ b/allstarr/Services/Scrobbling/ListenBrainzScrobblingService.cs @@ -0,0 +1,320 @@ +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Options; +using allstarr.Models.Scrobbling; +using allstarr.Models.Settings; + +namespace allstarr.Services.Scrobbling; + +/// +/// ListenBrainz scrobbling service implementation. +/// Follows the ListenBrainz API specification. +/// Only scrobbles external tracks (not local library tracks). +/// +public class ListenBrainzScrobblingService : IScrobblingService +{ + private const string ApiRoot = "https://api.listenbrainz.org/1"; + private const int MaxBatchSize = 1000; // ListenBrainz supports up to 1000 listens per request + + private readonly ListenBrainzSettings _settings; + private readonly ScrobblingSettings _globalSettings; + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + public string ServiceName => "ListenBrainz"; + public bool IsEnabled => _settings.Enabled && !string.IsNullOrEmpty(_settings.UserToken); + + public ListenBrainzScrobblingService( + IOptions settings, + IHttpClientFactory httpClientFactory, + ILogger logger) + { + _globalSettings = settings.Value; + _settings = settings.Value.ListenBrainz; + _httpClient = httpClientFactory.CreateClient("ListenBrainz"); + _logger = logger; + + // Debug logging + _logger.LogInformation("ListenBrainz Service Configuration:"); + _logger.LogInformation(" Enabled: {Enabled}", _settings.Enabled); + _logger.LogInformation(" UserToken: {Token}", string.IsNullOrEmpty(_settings.UserToken) ? "(empty)" : "***" + _settings.UserToken[^Math.Min(8, _settings.UserToken.Length)..]); + _logger.LogInformation(" IsEnabled: {IsEnabled}", IsEnabled); + + if (IsEnabled) + { + _logger.LogInformation("🎵 ListenBrainz scrobbling enabled"); + } + else + { + _logger.LogWarning("⚠️ ListenBrainz scrobbling NOT enabled (Enabled={Enabled}, HasToken={HasToken})", + _settings.Enabled, !string.IsNullOrEmpty(_settings.UserToken)); + } + } + + public async Task UpdateNowPlayingAsync(ScrobbleTrack track, CancellationToken cancellationToken = default) + { + if (!IsEnabled) + { + return ScrobbleResult.CreateError("ListenBrainz scrobbling not enabled or configured"); + } + + // Only scrobble external tracks (unless local tracks are enabled) + if (!track.IsExternal && !_globalSettings.LocalTracksEnabled) + { + return ScrobbleResult.CreateIgnored("Local library tracks are not scrobbled (LocalTracksEnabled=false)", 0); + } + + _logger.LogDebug("→ Updating Now Playing on ListenBrainz: {Artist} - {Track}", track.Artist, track.Title); + + try + { + var payload = BuildListenPayload("playing_now", new[] { track }); + var response = await SendRequestAsync("/submit-listens", payload, cancellationToken); + + if (response.Success) + { + _logger.LogDebug("✓ Now Playing updated on ListenBrainz: {Artist} - {Track}", + track.Artist, track.Title); + } + + return response; + } + catch (Exception ex) + { + _logger.LogError(ex, "❌ Failed to update Now Playing on ListenBrainz"); + return ScrobbleResult.CreateError($"Exception: {ex.Message}"); + } + } + + public async Task ScrobbleAsync(ScrobbleTrack track, CancellationToken cancellationToken = default) + { + if (!IsEnabled) + { + return ScrobbleResult.CreateError("ListenBrainz scrobbling not enabled or configured"); + } + + // Only scrobble external tracks (unless local tracks are enabled) + if (!track.IsExternal && !_globalSettings.LocalTracksEnabled) + { + return ScrobbleResult.CreateIgnored("Local library tracks are not scrobbled (LocalTracksEnabled=false)", 0); + } + + if (track.Timestamp == null) + { + return ScrobbleResult.CreateError("Timestamp is required for scrobbling"); + } + + _logger.LogDebug("→ Scrobbling to ListenBrainz: {Artist} - {Track}", track.Artist, track.Title); + + try + { + var payload = BuildListenPayload("single", new[] { track }); + var response = await SendRequestAsync("/submit-listens", payload, cancellationToken); + + if (response.Success) + { + _logger.LogDebug("✓ Scrobbled to ListenBrainz: {Artist} - {Track}", + track.Artist, track.Title); + } + + return response; + } + catch (Exception ex) + { + _logger.LogError(ex, "❌ Failed to scrobble to ListenBrainz"); + return ScrobbleResult.CreateError($"Exception: {ex.Message}"); + } + } + + public async Task> ScrobbleBatchAsync(List tracks, CancellationToken cancellationToken = default) + { + if (!IsEnabled) + { + return tracks.Select(_ => ScrobbleResult.CreateError("ListenBrainz scrobbling not enabled or configured")).ToList(); + } + + if (tracks.Count == 0) + { + return new List(); + } + + // Filter out local tracks (unless local tracks are enabled) + var externalTracks = tracks.Where(t => t.IsExternal || _globalSettings.LocalTracksEnabled).ToList(); + var localTracks = tracks.Where(t => !t.IsExternal && !_globalSettings.LocalTracksEnabled).ToList(); + + var results = new List(); + + // Add ignored results for local tracks + results.AddRange(localTracks.Select(_ => + ScrobbleResult.CreateIgnored("Local library tracks are not scrobbled", 0))); + + if (externalTracks.Count == 0) + { + return results; + } + + if (externalTracks.Count > MaxBatchSize) + { + _logger.LogWarning("Batch size {Count} exceeds maximum {Max}, splitting into multiple requests", + externalTracks.Count, MaxBatchSize); + + for (int i = 0; i < externalTracks.Count; i += MaxBatchSize) + { + var batch = externalTracks.Skip(i).Take(MaxBatchSize).ToList(); + var batchResults = await ScrobbleBatchAsync(batch, cancellationToken); + results.AddRange(batchResults); + } + return results; + } + + _logger.LogDebug("→ Scrobbling batch of {Count} tracks to ListenBrainz", externalTracks.Count); + + try + { + var payload = BuildListenPayload("import", externalTracks); + var response = await SendRequestAsync("/submit-listens", payload, cancellationToken); + + if (response.Success) + { + _logger.LogDebug("✓ Batch scrobble complete: {Count} tracks submitted to ListenBrainz", + externalTracks.Count); + + // ListenBrainz doesn't provide per-track results, so return success for all + results.AddRange(externalTracks.Select(_ => ScrobbleResult.CreateSuccess())); + } + else + { + // If batch fails, all tracks fail + results.AddRange(externalTracks.Select(_ => response)); + } + + return results; + } + catch (Exception ex) + { + _logger.LogError(ex, "❌ Failed to scrobble batch to ListenBrainz"); + results.AddRange(externalTracks.Select(_ => ScrobbleResult.CreateError($"Exception: {ex.Message}"))); + return results; + } + } + + #region Helper Methods + + /// + /// Builds the JSON payload for ListenBrainz API. + /// + private string BuildListenPayload(string listenType, IEnumerable tracks) + { + var listens = tracks.Select(track => + { + var additionalInfo = new Dictionary(); + + // Only add MusicBrainz recording ID if available (must be valid UUID format) + if (!string.IsNullOrEmpty(track.MusicBrainzId)) + { + additionalInfo["recording_mbid"] = track.MusicBrainzId; + } + + if (track.DurationSeconds.HasValue) + { + additionalInfo["duration_ms"] = track.DurationSeconds.Value * 1000; + } + + // For single and import, include timestamp + if (listenType != "playing_now" && track.Timestamp.HasValue) + { + return (object)new + { + listened_at = track.Timestamp.Value, + track_metadata = new + { + artist_name = track.Artist, + track_name = track.Title, + release_name = track.Album, + additional_info = additionalInfo + } + }; + } + + return (object)new + { + track_metadata = new + { + artist_name = track.Artist, + track_name = track.Title, + release_name = track.Album, + additional_info = additionalInfo + } + }; + }).ToList(); + + var payload = new + { + listen_type = listenType, + payload = listens + }; + + return JsonSerializer.Serialize(payload, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }); + } + + /// + /// Sends HTTP POST request to ListenBrainz API. + /// + private async Task SendRequestAsync(string endpoint, string jsonPayload, CancellationToken cancellationToken) + { + try + { + var request = new HttpRequestMessage(HttpMethod.Post, $"{ApiRoot}{endpoint}") + { + Content = new StringContent(jsonPayload, Encoding.UTF8, "application/json") + }; + + request.Headers.Add("Authorization", $"Token {_settings.UserToken}"); + + var response = await _httpClient.SendAsync(request, cancellationToken); + var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); + + _logger.LogTrace("ListenBrainz request: {Endpoint}, Response: {StatusCode}", + endpoint, response.StatusCode); + + if (response.IsSuccessStatusCode) + { + return ScrobbleResult.CreateSuccess(); + } + + // Parse error response + try + { + var errorDoc = JsonDocument.Parse(responseBody); + var errorMessage = errorDoc.RootElement.GetProperty("error").GetString() ?? "Unknown error"; + var errorCode = (int)response.StatusCode; + + // Determine if should retry based on status code + var shouldRetry = errorCode == 429 || errorCode >= 500; + + if (errorCode == 401) + { + _logger.LogError("❌ ListenBrainz user token is invalid - please check your token"); + } + + return ScrobbleResult.CreateError(errorMessage, errorCode, shouldRetry); + } + catch + { + return ScrobbleResult.CreateError($"HTTP {response.StatusCode}: {responseBody}", + (int)response.StatusCode, (int)response.StatusCode >= 500); + } + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "HTTP request failed"); + return ScrobbleResult.CreateError($"HTTP error: {ex.Message}", shouldRetry: true); + } + } + + #endregion +} diff --git a/allstarr/Services/Scrobbling/ScrobblingHelper.cs b/allstarr/Services/Scrobbling/ScrobblingHelper.cs new file mode 100644 index 0000000..d971adf --- /dev/null +++ b/allstarr/Services/Scrobbling/ScrobblingHelper.cs @@ -0,0 +1,261 @@ +using System.Text.Json; +using allstarr.Models.Scrobbling; +using allstarr.Services.Jellyfin; +using allstarr.Services.Local; + +namespace allstarr.Services.Scrobbling; + +/// +/// Helper methods for extracting scrobble track information from Jellyfin items. +/// +public class ScrobblingHelper +{ + private readonly JellyfinProxyService _proxyService; + private readonly ILocalLibraryService _localLibraryService; + private readonly ILogger _logger; + + public ScrobblingHelper( + JellyfinProxyService proxyService, + ILocalLibraryService localLibraryService, + ILogger logger) + { + _proxyService = proxyService; + _localLibraryService = localLibraryService; + _logger = logger; + } + + /// + /// Extracts scrobble track information from a Jellyfin item. + /// Fetches item details from Jellyfin if needed. + /// + public async Task GetScrobbleTrackFromItemIdAsync( + string itemId, + Microsoft.AspNetCore.Http.IHeaderDictionary headers, + CancellationToken cancellationToken = default) + { + try + { + // Fetch item details from Jellyfin + var (itemResult, statusCode) = await _proxyService.GetJsonAsync($"Items/{itemId}", null, headers); + + if (itemResult == null || statusCode != 200) + { + _logger.LogWarning("Failed to fetch item details for scrobbling: {ItemId} (status: {StatusCode})", + itemId, statusCode); + return null; + } + + return ExtractScrobbleTrackFromJson(itemResult); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching item for scrobbling: {ItemId}", itemId); + return null; + } + } + + /// + /// Extracts scrobble track information from a Jellyfin JSON item. + /// + public ScrobbleTrack? ExtractScrobbleTrackFromJson(JsonDocument itemJson) + { + try + { + var item = itemJson.RootElement; + + // Extract required fields + var title = item.TryGetProperty("Name", out var nameProp) ? nameProp.GetString() : null; + var artist = ExtractArtist(item); + + if (string.IsNullOrEmpty(title) || string.IsNullOrEmpty(artist)) + { + _logger.LogDebug("Cannot create scrobble track - missing title or artist"); + return null; + } + + // Extract optional fields + var album = item.TryGetProperty("Album", out var albumProp) ? albumProp.GetString() : null; + var albumArtist = ExtractAlbumArtist(item); + var durationSeconds = ExtractDurationSeconds(item); + var musicBrainzId = ExtractMusicBrainzId(item); + + // Determine if track is external by checking the Path + var isExternal = false; + if (item.TryGetProperty("Path", out var pathProp)) + { + var path = pathProp.GetString(); + if (!string.IsNullOrEmpty(path)) + { + // External tracks have paths like: ext-deezer-song-123456 + // or are in the "kept" directory for favorited external tracks + isExternal = path.StartsWith("ext-") || path.Contains("/kept/") || path.Contains("\\kept\\"); + } + } + + return new ScrobbleTrack + { + Title = title, + Artist = artist, + Album = album, + AlbumArtist = albumArtist, + DurationSeconds = durationSeconds, + MusicBrainzId = musicBrainzId, + Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + IsExternal = isExternal + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error extracting scrobble track from JSON"); + return null; + } + } + + /// + /// Creates a scrobble track from external track metadata. + /// + public ScrobbleTrack? CreateScrobbleTrackFromExternal( + string title, + string artist, + string? album = null, + string? albumArtist = null, + int? durationSeconds = null) + { + if (string.IsNullOrEmpty(title) || string.IsNullOrEmpty(artist)) + { + return null; + } + + return new ScrobbleTrack + { + Title = title, + Artist = artist, + Album = album, + AlbumArtist = albumArtist, + DurationSeconds = durationSeconds, + Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + IsExternal = true // Explicitly mark as external + }; + } + + #region Private Helper Methods + + /// + /// Checks if a track is long enough to be scrobbled according to Last.fm rules. + /// Tracks must be at least 30 seconds long. + /// + public static bool IsTrackLongEnoughToScrobble(int durationSeconds) + { + return durationSeconds >= 30; + } + + /// + /// Checks if enough of the track has been listened to for scrobbling. + /// Last.fm rules: Must listen to at least 50% of track OR 4 minutes (whichever comes first). + /// + public static bool HasListenedEnoughToScrobble(int trackDurationSeconds, int playedSeconds) + { + var halfDuration = trackDurationSeconds / 2.0; + var fourMinutes = 240; + var threshold = Math.Min(halfDuration, fourMinutes); + return playedSeconds >= threshold; + } + + /// + /// Checks if a track has the minimum required metadata for scrobbling. + /// Requires at minimum: track title and artist name. + /// + public static bool HasRequiredMetadata(string? title, string? artist) + { + return !string.IsNullOrWhiteSpace(title) && !string.IsNullOrWhiteSpace(artist); + } + + /// + /// Formats a track for display in logs. + /// + public static string FormatTrackForDisplay(string title, string artist) + { + return $"{title} - {artist}"; + } + + /// + /// Extracts artist name from Jellyfin item. + /// Tries Artists array first, then AlbumArtist, then ArtistItems. + /// + private string? ExtractArtist(JsonElement item) + { + // Try Artists array (most common) + if (item.TryGetProperty("Artists", out var artistsProp) && artistsProp.ValueKind == JsonValueKind.Array) + { + var firstArtist = artistsProp.EnumerateArray().FirstOrDefault(); + if (firstArtist.ValueKind == JsonValueKind.String) + { + return firstArtist.GetString(); + } + } + + // Try AlbumArtist + if (item.TryGetProperty("AlbumArtist", out var albumArtistProp)) + { + return albumArtistProp.GetString(); + } + + // Try ArtistItems array + if (item.TryGetProperty("ArtistItems", out var artistItemsProp) && artistItemsProp.ValueKind == JsonValueKind.Array) + { + var firstArtistItem = artistItemsProp.EnumerateArray().FirstOrDefault(); + if (firstArtistItem.TryGetProperty("Name", out var artistNameProp)) + { + return artistNameProp.GetString(); + } + } + + return null; + } + + /// + /// Extracts album artist from Jellyfin item. + /// + private string? ExtractAlbumArtist(JsonElement item) + { + if (item.TryGetProperty("AlbumArtist", out var albumArtistProp)) + { + return albumArtistProp.GetString(); + } + + // Fall back to first artist + return ExtractArtist(item); + } + + /// + /// Extracts track duration in seconds from Jellyfin item. + /// + private int? ExtractDurationSeconds(JsonElement item) + { + if (item.TryGetProperty("RunTimeTicks", out var ticksProp)) + { + var ticks = ticksProp.GetInt64(); + return (int)(ticks / TimeSpan.TicksPerSecond); + } + + return null; + } + + /// + /// Extracts MusicBrainz Track ID from Jellyfin item. + /// + private string? ExtractMusicBrainzId(JsonElement item) + { + if (item.TryGetProperty("ProviderIds", out var providerIdsProp)) + { + if (providerIdsProp.TryGetProperty("MusicBrainzTrack", out var mbidProp)) + { + return mbidProp.GetString(); + } + } + + return null; + } + + #endregion +} diff --git a/allstarr/Services/Scrobbling/ScrobblingOrchestrator.cs b/allstarr/Services/Scrobbling/ScrobblingOrchestrator.cs new file mode 100644 index 0000000..9300b07 --- /dev/null +++ b/allstarr/Services/Scrobbling/ScrobblingOrchestrator.cs @@ -0,0 +1,338 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Options; +using allstarr.Models.Scrobbling; +using allstarr.Models.Settings; + +namespace allstarr.Services.Scrobbling; + +/// +/// Orchestrates scrobbling across multiple services (Last.fm, ListenBrainz, etc.). +/// Manages playback sessions and determines when to scrobble based on listening rules. +/// +public class ScrobblingOrchestrator +{ + private readonly IEnumerable _scrobblingServices; + private readonly ScrobblingSettings _settings; + private readonly ILogger _logger; + private readonly ConcurrentDictionary _sessions = new(); + private readonly Timer _cleanupTimer; + + public ScrobblingOrchestrator( + IEnumerable scrobblingServices, + IOptions settings, + ILogger logger) + { + _scrobblingServices = scrobblingServices; + _settings = settings.Value; + _logger = logger; + + // Clean up stale sessions every 5 minutes + _cleanupTimer = new Timer(CleanupStaleSessions, null, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5)); + + var enabledServices = _scrobblingServices.Where(s => s.IsEnabled).Select(s => s.ServiceName).ToList(); + if (enabledServices.Any()) + { + _logger.LogInformation("🎵 Scrobbling orchestrator initialized with services: {Services}", + string.Join(", ", enabledServices)); + } + else + { + _logger.LogInformation("Scrobbling orchestrator initialized (no services enabled)"); + } + } + + /// + /// Handles playback start - sends "Now Playing" to all enabled services. + /// + public async Task OnPlaybackStartAsync(string deviceId, ScrobbleTrack track) + { + if (!_settings.Enabled) + return; + + var sessionId = $"{deviceId}:{track.Artist}:{track.Title}:{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}"; + + var session = new PlaybackSession + { + SessionId = sessionId, + DeviceId = deviceId, + Track = track with { Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds() }, + StartTime = DateTime.UtcNow, + LastPositionSeconds = 0, + LastActivity = DateTime.UtcNow + }; + + _sessions[sessionId] = session; + + _logger.LogDebug("🎵 Playback started: {Artist} - {Track} (session: {SessionId})", + track.Artist, track.Title, sessionId); + + // Send "Now Playing" to all enabled services + await SendNowPlayingAsync(session); + } + + /// + /// Handles playback progress - checks if track should be scrobbled. + /// + public async Task OnPlaybackProgressAsync(string deviceId, string artist, string title, int positionSeconds) + { + if (!_settings.Enabled) + return; + + // Find the session for this track + var session = _sessions.Values.FirstOrDefault(s => + s.DeviceId == deviceId && + s.Track.Artist == artist && + s.Track.Title == title); + + if (session == null) + { + _logger.LogDebug("No active session found for progress update: {Artist} - {Track}", artist, title); + return; + } + + session.LastPositionSeconds = positionSeconds; + session.LastActivity = DateTime.UtcNow; + + // Check if we should scrobble (and haven't already) + if (!session.Scrobbled && session.ShouldScrobble()) + { + _logger.LogDebug("✓ Scrobble threshold reached for: {Artist} - {Track} (position: {Position}s)", + artist, title, positionSeconds); + await ScrobbleAsync(session); + } + } + + /// + /// Handles playback stop - final chance to scrobble if threshold was met. + /// + public async Task OnPlaybackStopAsync(string deviceId, string artist, string title, int positionSeconds) + { + if (!_settings.Enabled) + return; + + // Find and remove the session + var session = _sessions.Values.FirstOrDefault(s => + s.DeviceId == deviceId && + s.Track.Artist == artist && + s.Track.Title == title); + + if (session == null) + { + _logger.LogDebug("No active session found for stop: {Artist} - {Track}", artist, title); + return; + } + + session.LastPositionSeconds = positionSeconds; + + // Final check if we should scrobble (and haven't already) + if (!session.Scrobbled && session.ShouldScrobble()) + { + _logger.LogDebug("✓ Scrobbling on stop: {Artist} - {Track} (position: {Position}s)", + artist, title, positionSeconds); + await ScrobbleAsync(session); + } + else if (session.Scrobbled) + { + _logger.LogDebug("Track already scrobbled during playback: {Artist} - {Track}", artist, title); + } + else + { + _logger.LogDebug("Track not scrobbled (threshold not met): {Artist} - {Track} (position: {Position}s, duration: {Duration}s)", + artist, title, positionSeconds, session.Track.DurationSeconds); + } + + // Remove session + _sessions.TryRemove(session.SessionId, out _); + } + + /// + /// Sends "Now Playing" to all enabled services with retry logic. + /// + private async Task SendNowPlayingAsync(PlaybackSession session) + { + if (session.NowPlayingSent) + return; + + var tasks = _scrobblingServices + .Where(s => s.IsEnabled) + .Select(async service => + { + const int maxRetries = 3; + var retryDelays = new[] { TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(5) }; + + for (int attempt = 0; attempt < maxRetries; attempt++) + { + try + { + var result = await service.UpdateNowPlayingAsync(session.Track); + if (result.Success) + { + _logger.LogInformation("✓ Now Playing sent to {Service}: {Artist} - {Track}", + service.ServiceName, session.Track.Artist, session.Track.Title); + return; // Success, exit retry loop + } + else if (result.Ignored) + { + return; // Ignored, don't retry + } + else if (result.ShouldRetry && attempt < maxRetries - 1) + { + _logger.LogWarning("⚠️ Now Playing failed for {Service}: {Error} - Retrying in {Delay}s (attempt {Attempt}/{Max})", + service.ServiceName, result.ErrorMessage, retryDelays[attempt].TotalSeconds, attempt + 1, maxRetries); + await Task.Delay(retryDelays[attempt]); + } + else + { + _logger.LogWarning("⚠️ Now Playing failed for {Service}: {Error}", + service.ServiceName, result.ErrorMessage); + return; // Don't retry or max retries reached + } + } + catch (Exception ex) + { + if (attempt < maxRetries - 1) + { + _logger.LogWarning(ex, "❌ Error sending Now Playing to {Service} - Retrying in {Delay}s (attempt {Attempt}/{Max})", + service.ServiceName, retryDelays[attempt].TotalSeconds, attempt + 1, maxRetries); + await Task.Delay(retryDelays[attempt]); + } + else + { + _logger.LogError(ex, "❌ Error sending Now Playing to {Service} after {Max} attempts", + service.ServiceName, maxRetries); + } + } + } + }); + + await Task.WhenAll(tasks); + session.NowPlayingSent = true; + } + + /// + /// Scrobbles a track to all enabled services with retry logic. + /// Only retries on failure - prevents double scrobbling. + /// + private async Task ScrobbleAsync(PlaybackSession session) + { + if (session.Scrobbled) + { + _logger.LogDebug("Track already scrobbled, skipping: {Artist} - {Track}", + session.Track.Artist, session.Track.Title); + return; + } + + _logger.LogDebug("Scrobbling track to {Count} enabled services: {Artist} - {Track}", + _scrobblingServices.Count(s => s.IsEnabled), session.Track.Artist, session.Track.Title); + + var tasks = _scrobblingServices + .Where(s => s.IsEnabled) + .Select(async service => + { + const int maxRetries = 3; + var retryDelays = new[] { TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10) }; + + for (int attempt = 0; attempt < maxRetries; attempt++) + { + try + { + var result = await service.ScrobbleAsync(session.Track); + if (result.Success && !result.Ignored) + { + _logger.LogInformation("✓ Scrobbled to {Service}: {Artist} - {Track}", + service.ServiceName, session.Track.Artist, session.Track.Title); + return; // Success, exit retry loop - prevents double scrobbling + } + else if (result.Ignored) + { + _logger.LogDebug("⊘ Scrobble skipped by {Service}: {Reason}", + service.ServiceName, result.IgnoredReason); + return; // Ignored, don't retry + } + else if (result.ShouldRetry && attempt < maxRetries - 1) + { + _logger.LogWarning("❌ Scrobble failed for {Service}: {Error} - Retrying in {Delay}s (attempt {Attempt}/{Max})", + service.ServiceName, result.ErrorMessage, retryDelays[attempt].TotalSeconds, attempt + 1, maxRetries); + await Task.Delay(retryDelays[attempt]); + } + else + { + _logger.LogError("❌ Scrobble failed for {Service}: {Error} - No more retries", + service.ServiceName, result.ErrorMessage); + return; // Don't retry or max retries reached + } + } + catch (Exception ex) + { + if (attempt < maxRetries - 1) + { + _logger.LogWarning(ex, "❌ Error scrobbling to {Service} - Retrying in {Delay}s (attempt {Attempt}/{Max})", + service.ServiceName, retryDelays[attempt].TotalSeconds, attempt + 1, maxRetries); + await Task.Delay(retryDelays[attempt]); + } + else + { + _logger.LogError(ex, "❌ Error scrobbling to {Service} after {Max} attempts", + service.ServiceName, maxRetries); + } + } + } + }); + + await Task.WhenAll(tasks); + session.Scrobbled = true; + _logger.LogDebug("Marked session as scrobbled: {SessionId}", session.SessionId); + } + + /// + /// Cleans up stale sessions (inactive for more than 10 minutes). + /// + private void CleanupStaleSessions(object? state) + { + var now = DateTime.UtcNow; + var staleThreshold = TimeSpan.FromMinutes(10); + + var staleSessions = _sessions.Where(kvp => now - kvp.Value.LastActivity > staleThreshold).ToList(); + + foreach (var stale in staleSessions) + { + _logger.LogDebug("🧹 Removing stale scrobbling session: {SessionId}", stale.Key); + _sessions.TryRemove(stale.Key, out _); + } + + if (staleSessions.Any()) + { + _logger.LogDebug("Cleaned up {Count} stale scrobbling sessions", staleSessions.Count); + } + } + + /// + /// Gets information about active scrobbling sessions (for debugging). + /// + public object GetSessionsInfo() + { + var now = DateTime.UtcNow; + var sessions = _sessions.Values.Select(s => new + { + SessionId = s.SessionId, + DeviceId = s.DeviceId, + Artist = s.Track.Artist, + Track = s.Track.Title, + Duration = s.Track.DurationSeconds, + Position = s.LastPositionSeconds, + StartTime = s.StartTime, + ElapsedMinutes = Math.Round((now - s.StartTime).TotalMinutes, 1), + NowPlayingSent = s.NowPlayingSent, + Scrobbled = s.Scrobbled, + ShouldScrobble = s.ShouldScrobble() + }).ToList(); + + return new + { + TotalSessions = sessions.Count, + ScrobbledSessions = sessions.Count(s => s.Scrobbled), + PendingSessions = sessions.Count(s => !s.Scrobbled), + Sessions = sessions.OrderByDescending(s => s.StartTime) + }; + } +} diff --git a/allstarr/Services/Spotify/SpotifyMappingMigrationService.cs b/allstarr/Services/Spotify/SpotifyMappingMigrationService.cs new file mode 100644 index 0000000..53f444d --- /dev/null +++ b/allstarr/Services/Spotify/SpotifyMappingMigrationService.cs @@ -0,0 +1,167 @@ +using System.Text.Json; +using allstarr.Models.Spotify; +using allstarr.Services.Common; + +namespace allstarr.Services.Spotify; + +/// +/// Migrates legacy per-playlist manual mappings to global mappings. +/// Runs once on startup to convert old format to new format. +/// +public class SpotifyMappingMigrationService : IHostedService +{ + private readonly SpotifyMappingService _mappingService; + private readonly RedisCacheService _cache; + private readonly ILogger _logger; + private const string MappingsCacheDirectory = "/app/cache/mappings"; + private const string MigrationFlagKey = "spotify:mappings:migrated"; + + public SpotifyMappingMigrationService( + SpotifyMappingService mappingService, + RedisCacheService cache, + ILogger logger) + { + _mappingService = mappingService; + _cache = cache; + _logger = logger; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + // Check if migration already completed + var migrated = await _cache.GetStringAsync(MigrationFlagKey); + if (migrated == "true") + { + _logger.LogDebug("Mapping migration already completed, skipping"); + return; + } + + _logger.LogInformation("🔄 Starting migration of legacy per-playlist mappings to global mappings..."); + + try + { + var migratedCount = await MigrateLegacyMappingsAsync(cancellationToken); + + if (migratedCount > 0) + { + _logger.LogInformation("✅ Migrated {Count} legacy mappings to global format", migratedCount); + } + else + { + _logger.LogInformation("✅ No legacy mappings found to migrate"); + } + + // Set migration flag (permanent) + await _cache.SetStringAsync(MigrationFlagKey, "true", expiry: null); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to migrate legacy mappings"); + } + } + + public Task StopAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + private async Task MigrateLegacyMappingsAsync(CancellationToken cancellationToken) + { + if (!Directory.Exists(MappingsCacheDirectory)) + { + return 0; + } + + var files = Directory.GetFiles(MappingsCacheDirectory, "*_mappings.json"); + var migratedCount = 0; + + foreach (var file in files) + { + if (cancellationToken.IsCancellationRequested) + break; + + try + { + var json = await File.ReadAllTextAsync(file, cancellationToken); + var legacyMappings = JsonSerializer.Deserialize>(json); + + if (legacyMappings == null || legacyMappings.Count == 0) + continue; + + var playlistName = Path.GetFileNameWithoutExtension(file).Replace("_mappings", ""); + _logger.LogInformation("Migrating {Count} mappings from playlist: {Playlist}", + legacyMappings.Count, playlistName); + + foreach (var (spotifyId, legacyMapping) in legacyMappings) + { + // Check if global mapping already exists + var existingMapping = await _mappingService.GetMappingAsync(spotifyId); + if (existingMapping != null) + { + _logger.LogDebug("Skipping {SpotifyId} - global mapping already exists", spotifyId); + continue; + } + + // Convert legacy mapping to global mapping + var metadata = new TrackMetadata + { + Title = legacyMapping.Title, + Artist = legacyMapping.Artist, + Album = legacyMapping.Album + }; + + bool success; + if (!string.IsNullOrEmpty(legacyMapping.JellyfinId)) + { + // Local mapping + success = await _mappingService.SaveManualMappingAsync( + spotifyId, + "local", + localId: legacyMapping.JellyfinId, + metadata: metadata); + } + else if (!string.IsNullOrEmpty(legacyMapping.ExternalProvider) && + !string.IsNullOrEmpty(legacyMapping.ExternalId)) + { + // External mapping + success = await _mappingService.SaveManualMappingAsync( + spotifyId, + "external", + externalProvider: legacyMapping.ExternalProvider, + externalId: legacyMapping.ExternalId, + metadata: metadata); + } + else + { + _logger.LogWarning("Invalid legacy mapping for {SpotifyId}, skipping", spotifyId); + continue; + } + + if (success) + { + migratedCount++; + _logger.LogDebug("Migrated {SpotifyId} → {TargetType}", + spotifyId, + !string.IsNullOrEmpty(legacyMapping.JellyfinId) ? "local" : "external"); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to migrate mappings from file: {File}", file); + } + } + + return migratedCount; + } + + private class LegacyMappingEntry + { + public string? Title { get; set; } + public string? Artist { get; set; } + public string? Album { get; set; } + public string? JellyfinId { get; set; } + public string? ExternalProvider { get; set; } + public string? ExternalId { get; set; } + } +} diff --git a/allstarr/Services/Spotify/SpotifyMappingService.cs b/allstarr/Services/Spotify/SpotifyMappingService.cs new file mode 100644 index 0000000..9de7757 --- /dev/null +++ b/allstarr/Services/Spotify/SpotifyMappingService.cs @@ -0,0 +1,394 @@ +using System.Text.Json; +using allstarr.Models.Spotify; +using allstarr.Services.Common; + +namespace allstarr.Services.Spotify; + +/// +/// Manages global Spotify ID → Local/External track mappings. +/// Provides fast lookups and persistence via Redis. +/// +public class SpotifyMappingService +{ + private readonly RedisCacheService _cache; + private readonly ILogger _logger; + private const string MappingKeyPrefix = "spotify:global-map:"; + private const string AllMappingsKey = "spotify:global-map:all-ids"; + + public SpotifyMappingService( + RedisCacheService cache, + ILogger logger) + { + _cache = cache; + _logger = logger; + } + + /// + /// Gets a mapping for a Spotify track ID. + /// + public async Task GetMappingAsync(string spotifyId) + { + var key = $"{MappingKeyPrefix}{spotifyId}"; + var mapping = await _cache.GetAsync(key); + + if (mapping != null) + { + _logger.LogDebug("Found mapping for Spotify ID {SpotifyId}: {TargetType}", spotifyId, mapping.TargetType); + } + + return mapping; + } + + /// + /// Saves a mapping for a Spotify track ID. + /// Local mappings are always preferred over external. + /// Manual mappings are preserved unless explicitly overwritten. + /// + public async Task SaveMappingAsync(SpotifyTrackMapping mapping) + { + // Validate mapping + if (string.IsNullOrEmpty(mapping.SpotifyId)) + { + _logger.LogWarning("Cannot save mapping: SpotifyId is required"); + return false; + } + + if (mapping.TargetType == "local" && string.IsNullOrEmpty(mapping.LocalId)) + { + _logger.LogWarning("Cannot save local mapping: LocalId is required"); + return false; + } + + if (mapping.TargetType == "external" && + (string.IsNullOrEmpty(mapping.ExternalProvider) || string.IsNullOrEmpty(mapping.ExternalId))) + { + _logger.LogWarning("Cannot save external mapping: ExternalProvider and ExternalId are required"); + return false; + } + + var key = $"{MappingKeyPrefix}{mapping.SpotifyId}"; + + // Check if mapping already exists + var existingMapping = await GetMappingAsync(mapping.SpotifyId); + + // RULE 1: Never overwrite manual mappings with auto mappings + if (existingMapping != null && + existingMapping.Source == "manual" && + mapping.Source == "auto") + { + _logger.LogDebug("Skipping auto mapping for {SpotifyId} - manual mapping exists", mapping.SpotifyId); + return false; + } + + // RULE 2: Local always wins over external (even if existing is manual external) + if (existingMapping != null && + existingMapping.TargetType == "external" && + mapping.TargetType == "local") + { + _logger.LogInformation("🎉 UPGRADING: External → Local for {SpotifyId}", mapping.SpotifyId); + // Allow the upgrade to proceed + } + + // RULE 3: Don't downgrade local to external + if (existingMapping != null && + existingMapping.TargetType == "local" && + mapping.TargetType == "external") + { + _logger.LogDebug("Skipping external mapping for {SpotifyId} - local mapping exists", mapping.SpotifyId); + return false; + } + + // Set timestamps + if (mapping.CreatedAt == default) + { + mapping.CreatedAt = DateTime.UtcNow; + } + + // Preserve CreatedAt from existing mapping + if (existingMapping != null) + { + mapping.CreatedAt = existingMapping.CreatedAt; + } + + // Save mapping (permanent - no TTL) + var success = await _cache.SetAsync(key, mapping, expiry: null); + + if (success) + { + // Add to set of all mapping IDs for enumeration + await AddToAllMappingsSetAsync(mapping.SpotifyId); + + // Invalidate ALL playlist stats caches since this mapping could affect any playlist + // This ensures the stats are recalculated on next request + await InvalidateAllPlaylistStatsCachesAsync(); + + _logger.LogInformation( + "Saved {Source} mapping: Spotify {SpotifyId} → {TargetType} {TargetId}", + mapping.Source, + mapping.SpotifyId, + mapping.TargetType, + mapping.TargetType == "local" ? mapping.LocalId : $"{mapping.ExternalProvider}:{mapping.ExternalId}" + ); + } + + return success; + } + + /// + /// Saves a local mapping (auto-populated during matching). + /// + public async Task SaveLocalMappingAsync( + string spotifyId, + string localId, + TrackMetadata? metadata = null) + { + var mapping = new SpotifyTrackMapping + { + SpotifyId = spotifyId, + TargetType = "local", + LocalId = localId, + Metadata = metadata, + Source = "auto", + CreatedAt = DateTime.UtcNow + }; + + return await SaveMappingAsync(mapping); + } + + /// + /// Saves an external mapping (auto-populated during matching). + /// + public async Task SaveExternalMappingAsync( + string spotifyId, + string externalProvider, + string externalId, + TrackMetadata? metadata = null) + { + var mapping = new SpotifyTrackMapping + { + SpotifyId = spotifyId, + TargetType = "external", + ExternalProvider = externalProvider, + ExternalId = externalId, + Metadata = metadata, + Source = "auto", + CreatedAt = DateTime.UtcNow + }; + + return await SaveMappingAsync(mapping); + } + + /// + /// Saves a manual mapping (user override via Admin UI). + /// + public async Task SaveManualMappingAsync( + string spotifyId, + string targetType, + string? localId = null, + string? externalProvider = null, + string? externalId = null, + TrackMetadata? metadata = null) + { + var mapping = new SpotifyTrackMapping + { + SpotifyId = spotifyId, + TargetType = targetType, + LocalId = localId, + ExternalProvider = externalProvider, + ExternalId = externalId, + Metadata = metadata, + Source = "manual", + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + return await SaveMappingAsync(mapping); + } + + /// + /// Deletes a mapping for a Spotify track ID. + /// + public async Task DeleteMappingAsync(string spotifyId) + { + var key = $"{MappingKeyPrefix}{spotifyId}"; + var success = await _cache.DeleteAsync(key); + + if (success) + { + await RemoveFromAllMappingsSetAsync(spotifyId); + _logger.LogInformation("Deleted mapping for Spotify ID {SpotifyId}", spotifyId); + } + + return success; + } + + /// + /// Gets all Spotify IDs that have mappings. + /// + public async Task> GetAllMappingIdsAsync() + { + var json = await _cache.GetStringAsync(AllMappingsKey); + if (string.IsNullOrEmpty(json)) + { + return new List(); + } + + try + { + return JsonSerializer.Deserialize>(json) ?? new List(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to deserialize all mapping IDs"); + return new List(); + } + } + + /// + /// Gets all mappings (paginated). + /// + public async Task> GetAllMappingsAsync(int skip = 0, int take = 100) + { + var allIds = await GetAllMappingIdsAsync(); + var pagedIds = allIds.Skip(skip).Take(take).ToList(); + + var mappings = new List(); + + foreach (var spotifyId in pagedIds) + { + var mapping = await GetMappingAsync(spotifyId); + if (mapping != null) + { + mappings.Add(mapping); + } + } + + return mappings; + } + + /// + /// Gets count of all mappings. + /// + public async Task GetMappingCountAsync() + { + var allIds = await GetAllMappingIdsAsync(); + return allIds.Count; + } + + /// + /// Gets statistics about mappings. + /// + public async Task GetStatsAsync() + { + var allIds = await GetAllMappingIdsAsync(); + var stats = new MappingStats + { + TotalMappings = allIds.Count + }; + + // Sample first 1000 to get stats (avoid loading all mappings) + var sampleIds = allIds.Take(1000).ToList(); + + foreach (var spotifyId in sampleIds) + { + var mapping = await GetMappingAsync(spotifyId); + if (mapping != null) + { + if (mapping.TargetType == "local") + { + stats.LocalMappings++; + } + else if (mapping.TargetType == "external") + { + stats.ExternalMappings++; + } + + if (mapping.Source == "manual") + { + stats.ManualMappings++; + } + else if (mapping.Source == "auto") + { + stats.AutoMappings++; + } + } + } + + // Extrapolate if we sampled + if (allIds.Count > 1000) + { + var ratio = (double)allIds.Count / sampleIds.Count; + stats.LocalMappings = (int)(stats.LocalMappings * ratio); + stats.ExternalMappings = (int)(stats.ExternalMappings * ratio); + stats.ManualMappings = (int)(stats.ManualMappings * ratio); + stats.AutoMappings = (int)(stats.AutoMappings * ratio); + } + + return stats; + } + + private async Task AddToAllMappingsSetAsync(string spotifyId) + { + var allIds = await GetAllMappingIdsAsync(); + + if (!allIds.Contains(spotifyId)) + { + allIds.Add(spotifyId); + var json = JsonSerializer.Serialize(allIds); + await _cache.SetStringAsync(AllMappingsKey, json, expiry: null); + } + } + + private async Task RemoveFromAllMappingsSetAsync(string spotifyId) + { + var allIds = await GetAllMappingIdsAsync(); + + if (allIds.Remove(spotifyId)) + { + var json = JsonSerializer.Serialize(allIds); + await _cache.SetStringAsync(AllMappingsKey, json, expiry: null); + } + } + + /// + /// Invalidates all playlist stats caches. + /// Called when a mapping is saved/deleted to ensure stats are recalculated. + /// + private async Task InvalidateAllPlaylistStatsCachesAsync() + { + try + { + // Delete all keys matching the pattern "spotify:playlist:stats:*" + // Note: This is a simple implementation that deletes known patterns + // In production, you might want to track playlist names or use Redis SCAN + + // For now, we'll just log that stats should be recalculated + // The stats will be recalculated on next request since they check global mappings + _logger.LogDebug("Mapping changed - playlist stats will be recalculated on next request"); + + // Optionally: Delete the admin playlist summary cache to force immediate refresh + var summaryFile = "/app/cache/admin_playlists_summary.json"; + if (File.Exists(summaryFile)) + { + File.Delete(summaryFile); + _logger.LogDebug("Deleted admin playlist summary cache"); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to invalidate playlist stats caches"); + } + } +} + +/// +/// Statistics about Spotify track mappings. +/// +public class MappingStats +{ + public int TotalMappings { get; set; } + public int LocalMappings { get; set; } + public int ExternalMappings { get; set; } + public int ManualMappings { get; set; } + public int AutoMappings { get; set; } +} diff --git a/allstarr/Services/Spotify/SpotifyMappingValidationService.cs b/allstarr/Services/Spotify/SpotifyMappingValidationService.cs new file mode 100644 index 0000000..ca95e48 --- /dev/null +++ b/allstarr/Services/Spotify/SpotifyMappingValidationService.cs @@ -0,0 +1,332 @@ +using allstarr.Models.Domain; +using allstarr.Models.Spotify; +using allstarr.Services.Common; +using allstarr.Services.Jellyfin; +using Microsoft.Extensions.Options; +using allstarr.Models.Settings; + +namespace allstarr.Services.Spotify; + +/// +/// Validates Spotify track mappings to ensure they're still accurate. +/// - Local mappings: Checks if Jellyfin track still exists (every 7 days) +/// - External mappings: Searches for local match to upgrade (every playlist sync) +/// +public class SpotifyMappingValidationService +{ + private readonly SpotifyMappingService _mappingService; + private readonly JellyfinProxyService _jellyfinProxy; + private readonly JellyfinSettings _jellyfinSettings; + private readonly ILogger _logger; + + public SpotifyMappingValidationService( + SpotifyMappingService mappingService, + JellyfinProxyService jellyfinProxy, + IOptions jellyfinSettings, + ILogger logger) + { + _mappingService = mappingService; + _jellyfinProxy = jellyfinProxy; + _jellyfinSettings = jellyfinSettings.Value; + _logger = logger; + } + + /// + /// Validates a single mapping. Returns updated mapping or null if should be deleted. + /// + public async Task ValidateMappingAsync(SpotifyTrackMapping mapping, bool isPlaylistSync = false) + { + if (!mapping.NeedsValidation(isPlaylistSync)) + { + _logger.LogDebug("Mapping {SpotifyId} doesn't need validation yet", mapping.SpotifyId); + return mapping; + } + + _logger.LogInformation("🔍 Validating mapping: {SpotifyId} → {TargetType} {TargetId}", + mapping.SpotifyId, + mapping.TargetType, + mapping.TargetType == "local" ? mapping.LocalId : $"{mapping.ExternalProvider}:{mapping.ExternalId}"); + + if (mapping.TargetType == "local") + { + return await ValidateLocalMappingAsync(mapping); + } + else if (mapping.TargetType == "external") + { + return await ValidateExternalMappingAsync(mapping); + } + + return mapping; + } + + /// + /// Validates a local mapping - checks if Jellyfin track still exists. + /// If deleted: clear mapping and search for new local match, fallback to external. + /// + private async Task ValidateLocalMappingAsync(SpotifyTrackMapping mapping) + { + if (string.IsNullOrEmpty(mapping.LocalId)) + { + _logger.LogWarning("Local mapping has no LocalId, deleting: {SpotifyId}", mapping.SpotifyId); + await _mappingService.DeleteMappingAsync(mapping.SpotifyId); + return null; + } + + // Check if Jellyfin track still exists + try + { + var (response, _) = await _jellyfinProxy.GetJsonAsyncInternal( + $"Items/{mapping.LocalId}", + new Dictionary()); + + if (response != null && response.RootElement.TryGetProperty("Name", out _)) + { + // Track still exists, update validation timestamp + mapping.LastValidatedAt = DateTime.UtcNow; + await _mappingService.SaveMappingAsync(mapping); + + _logger.LogInformation("✓ Local track still exists: {LocalId}", mapping.LocalId); + return mapping; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Local track not found or error checking: {LocalId}", mapping.LocalId); + } + + // Track doesn't exist anymore - clear mapping and re-search + _logger.LogWarning("❌ Local track deleted: {LocalId}, re-searching for {Title} - {Artist}", + mapping.LocalId, + mapping.Metadata?.Title ?? "Unknown", + mapping.Metadata?.Artist ?? "Unknown"); + + await _mappingService.DeleteMappingAsync(mapping.SpotifyId); + + // Try to find new local match + if (mapping.Metadata != null) + { + var newLocalMatch = await SearchJellyfinForTrackAsync( + mapping.Metadata.Title ?? "", + mapping.Metadata.Artist ?? ""); + + if (newLocalMatch != null) + { + _logger.LogInformation("✓ Found new local match: {Title} → {NewLocalId}", + mapping.Metadata.Title, + newLocalMatch.Id); + + // Create new local mapping + await _mappingService.SaveLocalMappingAsync( + mapping.SpotifyId, + newLocalMatch.Id, + mapping.Metadata); + + mapping.LocalId = newLocalMatch.Id; + mapping.LastValidatedAt = DateTime.UtcNow; + return mapping; + } + else + { + _logger.LogInformation("❌ No local match found, will fallback to external on next match"); + } + } + + return null; // Mapping deleted, will re-match from scratch + } + + /// + /// Validates an external mapping - searches Jellyfin to see if track is now local. + /// If found locally: upgrade mapping from external → local. + /// + private async Task ValidateExternalMappingAsync(SpotifyTrackMapping mapping) + { + if (mapping.Metadata == null) + { + _logger.LogWarning("⚠️ External mapping has NO METADATA, cannot search for local match: {SpotifyId}", mapping.SpotifyId); + mapping.LastValidatedAt = DateTime.UtcNow; + await _mappingService.SaveMappingAsync(mapping); + return mapping; + } + + // Search Jellyfin for local match + var localMatch = await SearchJellyfinForTrackAsync( + mapping.Metadata.Title ?? "", + mapping.Metadata.Artist ?? ""); + + if (localMatch != null) + { + // Found in local library! Upgrade mapping + _logger.LogInformation("🎉 UPGRADE: External → Local for {Title} - {Artist}", + mapping.Metadata.Title, + mapping.Metadata.Artist); + _logger.LogInformation(" Old: {Provider}:{ExternalId}", + mapping.ExternalProvider, + mapping.ExternalId); + _logger.LogInformation(" New: Jellyfin:{LocalId}", + localMatch.Id); + + // Update mapping to local + mapping.TargetType = "local"; + mapping.LocalId = localMatch.Id; + mapping.ExternalProvider = null; + mapping.ExternalId = null; + mapping.LastValidatedAt = DateTime.UtcNow; + mapping.UpdatedAt = DateTime.UtcNow; + + await _mappingService.SaveMappingAsync(mapping); + return mapping; + } + else + { + // Still not in local library, keep external mapping + _logger.LogDebug("External track not yet in local library: {Title} - {Artist}", + mapping.Metadata.Title, + mapping.Metadata.Artist); + + mapping.LastValidatedAt = DateTime.UtcNow; + await _mappingService.SaveMappingAsync(mapping); + return mapping; + } + } + + /// + /// Searches Jellyfin for a track using fuzzy matching (same algorithm as playlist matching). + /// Uses greedy algorithm + Levenshtein distance. + /// + private async Task SearchJellyfinForTrackAsync(string title, string artist) + { + if (string.IsNullOrEmpty(title) || string.IsNullOrEmpty(artist)) + { + return null; + } + + try + { + // Search Jellyfin using same query format as playlist matching + var query = $"{title} {artist}"; + var searchParams = new Dictionary + { + ["searchTerm"] = query, + ["includeItemTypes"] = "Audio", + ["recursive"] = "true", + ["limit"] = "10" + }; + + if (!string.IsNullOrEmpty(_jellyfinSettings.LibraryId)) + { + searchParams["parentId"] = _jellyfinSettings.LibraryId; + } + + var (response, _) = await _jellyfinProxy.GetJsonAsyncInternal("Items", searchParams); + + if (response == null || !response.RootElement.TryGetProperty("Items", out var items)) + { + return null; + } + + // Score all results using fuzzy matching (same as SpotifyTrackMatchingService) + var candidates = new List<(Song Song, double Score)>(); + + foreach (var item in items.EnumerateArray()) + { + var itemTitle = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : ""; + var itemArtist = item.TryGetProperty("AlbumArtist", out var artistEl) ? artistEl.GetString() ?? "" : ""; + var itemId = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() ?? "" : ""; + + if (string.IsNullOrEmpty(itemId)) continue; + + // Calculate similarity using aggressive matching (same as playlist matching) + var titleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, itemTitle); + var artistScore = FuzzyMatcher.CalculateSimilarity(artist, itemArtist); + + // Weight: 70% title, 30% artist (same as playlist matching) + var totalScore = (titleScore * 0.7) + (artistScore * 0.3); + + // Same thresholds as playlist matching + if (totalScore >= 40 || (artistScore >= 70 && titleScore >= 30) || titleScore >= 85) + { + var song = new Song + { + Id = itemId, + Title = itemTitle, + Artist = itemArtist, + Album = item.TryGetProperty("Album", out var albumEl) ? albumEl.GetString() ?? "" : "", + IsLocal = true + }; + + candidates.Add((song, totalScore)); + } + } + + // Return best match (highest score) + var bestMatch = candidates.OrderByDescending(c => c.Score).FirstOrDefault(); + + if (bestMatch.Song != null) + { + _logger.LogDebug("Found local match: {Title} - {Artist} (score: {Score:F1})", + bestMatch.Song.Title, + bestMatch.Song.Artist, + bestMatch.Score); + return bestMatch.Song; + } + + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error searching Jellyfin for track: {Title} - {Artist}", title, artist); + return null; + } + } + + /// + /// Validates all mappings for tracks in active playlists. + /// Processes in batches, oldest-first. + /// + public async Task ValidateMappingsForPlaylistAsync( + List tracks, + bool isPlaylistSync = false, + int batchSize = 50) + { + var spotifyIds = tracks.Select(t => t.SpotifyId).Distinct().ToList(); + + _logger.LogInformation("Validating mappings for {Count} tracks from playlist (isPlaylistSync: {IsSync})", + spotifyIds.Count, + isPlaylistSync); + + var validatedCount = 0; + var upgradedCount = 0; + var deletedCount = 0; + + foreach (var spotifyId in spotifyIds) + { + var mapping = await _mappingService.GetMappingAsync(spotifyId); + if (mapping == null) continue; + + var originalType = mapping.TargetType; + var validatedMapping = await ValidateMappingAsync(mapping, isPlaylistSync); + + if (validatedMapping == null) + { + deletedCount++; + } + else if (validatedMapping.TargetType != originalType) + { + upgradedCount++; + } + + validatedCount++; + + // Rate limiting to avoid overwhelming Jellyfin + if (validatedCount % batchSize == 0) + { + await Task.Delay(100); // 100ms pause every 50 validations + } + } + + _logger.LogInformation("✓ Validation complete: {Validated} checked, {Upgraded} upgraded to local, {Deleted} deleted", + validatedCount, + upgradedCount, + deletedCount); + } +} diff --git a/allstarr/Services/Spotify/SpotifyMissingTracksFetcher.cs b/allstarr/Services/Spotify/SpotifyMissingTracksFetcher.cs index 2638e3e..6ef6a5d 100644 --- a/allstarr/Services/Spotify/SpotifyMissingTracksFetcher.cs +++ b/allstarr/Services/Spotify/SpotifyMissingTracksFetcher.cs @@ -188,7 +188,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService foreach (var playlistName in _playlistIdToName.Values) { var filePath = GetCacheFilePath(playlistName); - var cacheKey = $"spotify:missing:{playlistName}"; + var cacheKey = CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlistName); // Check file cache if (File.Exists(filePath)) @@ -245,7 +245,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService if (tracks != null && tracks.Count > 0) { - var cacheKey = $"spotify:missing:{playlistName}"; + var cacheKey = CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlistName); var fileAge = DateTime.UtcNow - File.GetLastWriteTimeUtc(filePath); // No expiration - cache persists until next Jellyfin job generates new file @@ -310,7 +310,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService CancellationToken cancellationToken, DateTime? hintTime = null) { - var cacheKey = $"spotify:missing:{playlistName}"; + var cacheKey = CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlistName); // Check if we have existing cache var existingTracks = await _cache.GetAsync>(cacheKey); @@ -486,7 +486,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService if (tracks.Count > 0) { - var cacheKey = $"spotify:missing:{playlistName}"; + var cacheKey = CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlistName); // Save to both Redis and file with extended TTL until next job runs // Set to 365 days (effectively no expiration) - will be replaced when Jellyfin generates new file diff --git a/allstarr/Services/Spotify/SpotifyPlaylistFetcher.cs b/allstarr/Services/Spotify/SpotifyPlaylistFetcher.cs index a846a09..3f162ad 100644 --- a/allstarr/Services/Spotify/SpotifyPlaylistFetcher.cs +++ b/allstarr/Services/Spotify/SpotifyPlaylistFetcher.cs @@ -261,8 +261,15 @@ public class SpotifyPlaylistFetcher : BackgroundService foreach (var playlist in _spotifyImportSettings.Playlists) { +<<<<<<< HEAD var schedule = string.IsNullOrEmpty(playlist.SyncSchedule) ? "0 8 * * 1" : playlist.SyncSchedule; _logger.LogInformation(" - {Name}: {Schedule}", playlist.Name, schedule); +||||||| f68706f + _logger.LogInformation(" - {Name}", playlist.Name); +======= + var schedule = string.IsNullOrEmpty(playlist.SyncSchedule) ? "0 8 * * *" : playlist.SyncSchedule; + _logger.LogInformation(" - {Name}: {Schedule}", playlist.Name, schedule); +>>>>>>> beta } _logger.LogInformation("========================================"); @@ -273,6 +280,7 @@ public class SpotifyPlaylistFetcher : BackgroundService { try { +<<<<<<< HEAD // Check each playlist to see if it needs refreshing based on cron schedule var now = DateTime.UtcNow; var needsRefresh = new List(); @@ -344,6 +352,81 @@ public class SpotifyPlaylistFetcher : BackgroundService // Sleep for 1 hour before checking again await Task.Delay(TimeSpan.FromHours(1), stoppingToken); +||||||| f68706f + await FetchAllPlaylistsAsync(stoppingToken); +======= + // Check each playlist to see if it needs refreshing based on cron schedule + var now = DateTime.UtcNow; + var needsRefresh = new List(); + + foreach (var config in _spotifyImportSettings.Playlists) + { + var schedule = string.IsNullOrEmpty(config.SyncSchedule) ? "0 8 * * *" : config.SyncSchedule; + + try + { + var cron = CronExpression.Parse(schedule); + + // Check if we have cached data + var cacheKey = $"{CacheKeyPrefix}{config.Name}"; + var cached = await _cache.GetAsync(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.LogWarning("Finished fetching '{Name}' - waiting 3 seconds before next playlist to avoid rate limits...", playlistName); + 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); +>>>>>>> beta } catch (Exception ex) { diff --git a/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs b/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs index e5ef606..abf81cc 100644 --- a/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs +++ b/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs @@ -27,6 +27,8 @@ public class SpotifyTrackMatchingService : BackgroundService private readonly SpotifyImportSettings _spotifySettings; private readonly SpotifyApiSettings _spotifyApiSettings; private readonly RedisCacheService _cache; + private readonly SpotifyMappingService _mappingService; + private readonly SpotifyMappingValidationService _validationService; private readonly ILogger _logger; private readonly IServiceProvider _serviceProvider; private const int DelayBetweenSearchesMs = 150; // 150ms = ~6.6 searches/second to avoid rate limiting @@ -40,12 +42,16 @@ public class SpotifyTrackMatchingService : BackgroundService IOptions spotifySettings, IOptions spotifyApiSettings, RedisCacheService cache, + SpotifyMappingService mappingService, + SpotifyMappingValidationService validationService, IServiceProvider serviceProvider, ILogger logger) { _spotifySettings = spotifySettings.Value; _spotifyApiSettings = spotifyApiSettings.Value; _cache = cache; + _mappingService = mappingService; + _validationService = validationService; _serviceProvider = serviceProvider; _logger = logger; } @@ -106,6 +112,7 @@ public class SpotifyTrackMatchingService : BackgroundService { try { +<<<<<<< HEAD // Calculate next run time for each playlist var now = DateTime.UtcNow; var nextRuns = new List<(string PlaylistName, DateTime NextRun, CronExpression Cron)>(); @@ -181,6 +188,85 @@ public class SpotifyTrackMatchingService : BackgroundService _logger.LogInformation("=== FINISHED: {Playlist} - Next run at {NextRun} UTC ===", nextPlaylist.PlaylistName, nextPlaylist.Cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc)); +||||||| f68706f + 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 * * *" : 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 sync 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.LogWarning("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 full rebuild for this playlist (same as "Rebuild All Remote" button) + await RebuildSinglePlaylistAsync(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)); +>>>>>>> beta } catch (Exception ex) { @@ -191,7 +277,102 @@ public class SpotifyTrackMatchingService : BackgroundService } /// +<<<<<<< HEAD /// Matches tracks for a single playlist (called by cron scheduler or manual trigger). +||||||| f68706f + /// Public method to trigger matching for a specific playlist (called from controller). +======= + /// Rebuilds a single playlist from scratch (clears cache, fetches fresh data, re-matches). + /// This is the unified method used by both cron scheduler and "Rebuild All Remote" button. +>>>>>>> beta + /// +<<<<<<< HEAD + private async Task MatchSinglePlaylistAsync(string playlistName, CancellationToken cancellationToken) +||||||| f68706f + public async Task TriggerMatchingForPlaylistAsync(string playlistName) +======= + private async Task RebuildSinglePlaylistAsync(string playlistName, CancellationToken cancellationToken) +>>>>>>> beta + { + var playlist = _spotifySettings.Playlists + .FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase)); + + if (playlist == null) + { +<<<<<<< HEAD + _logger.LogInformation("Playlist {Playlist} not found in configuration", playlistName); +||||||| f68706f + _logger.LogWarning("Playlist {Playlist} not found in configuration", playlistName); +======= + _logger.LogInformation("Playlist {Playlist} not found in configuration", playlistName); + return; + } + + _logger.LogInformation("Step 1/3: Clearing cache for {Playlist}", playlistName); + + // Clear cache for this playlist (same as "Rebuild All Remote" button) + var keysToDelete = new[] + { + CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name), + CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlist.Name), + $"spotify:matched:{playlist.Name}", // Legacy key + CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name), + $"spotify:playlist:items:{playlist.Name}", + $"spotify:playlist:ordered:{playlist.Name}", + $"spotify:playlist:stats:{playlist.Name}" + }; + + foreach (var key in keysToDelete) + { + await _cache.DeleteAsync(key); + } + + _logger.LogInformation("Step 2/3: Fetching fresh data from Spotify for {Playlist}", playlistName); + + using var scope = _serviceProvider.CreateScope(); + var metadataService = scope.ServiceProvider.GetRequiredService(); + + // Trigger fresh fetch from Spotify + SpotifyPlaylistFetcher? playlistFetcher = null; + if (_spotifyApiSettings.Enabled) + { + playlistFetcher = scope.ServiceProvider.GetService(); + if (playlistFetcher != null) + { + // Force refresh from Spotify (clears cache and re-fetches) + await playlistFetcher.RefreshPlaylistAsync(playlist.Name); + } + } + + _logger.LogInformation("Step 3/3: Matching tracks for {Playlist}", playlistName); + + try + { + if (playlistFetcher != null) + { + // Use new direct API mode with ISRC support + await MatchPlaylistTracksWithIsrcAsync( + playlist.Name, playlistFetcher, metadataService, cancellationToken); + } + else + { + // Fall back to legacy mode + await MatchPlaylistTracksLegacyAsync( + playlist.Name, metadataService, cancellationToken); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error matching tracks for playlist {Playlist}", playlist.Name); + throw; + } + + _logger.LogInformation("✓ Rebuild complete for {Playlist}", playlistName); + } + + /// + /// Matches tracks for a single playlist WITHOUT clearing cache or refreshing from Spotify. + /// Used for lightweight re-matching when only local library has changed. /// private async Task MatchSinglePlaylistAsync(string playlistName, CancellationToken cancellationToken) { @@ -201,6 +382,7 @@ public class SpotifyTrackMatchingService : BackgroundService if (playlist == null) { _logger.LogInformation("Playlist {Playlist} not found in configuration", playlistName); +>>>>>>> beta return; } @@ -236,16 +418,133 @@ public class SpotifyTrackMatchingService : BackgroundService } } +<<<<<<< HEAD /// /// Public method to trigger matching manually for all playlists (called from controller). /// This bypasses cron schedules and runs immediately. /// public async Task TriggerMatchingAsync() +||||||| f68706f + private async Task MatchAllPlaylistsAsync(CancellationToken cancellationToken) +======= + /// + /// Public method to trigger full rebuild for all playlists (called from "Rebuild All Remote" button). + /// This clears caches, fetches fresh data, and re-matches everything - same as cron job. + /// + public async Task TriggerRebuildAllAsync() +>>>>>>> beta + { +<<<<<<< HEAD + _logger.LogInformation("Manual track matching triggered for all playlists (bypassing cron schedules)"); + await MatchAllPlaylistsAsync(CancellationToken.None); + } + + /// + /// Public method to trigger matching for a specific playlist (called from controller). + /// This bypasses cron schedules and runs immediately. + /// + 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)) +||||||| f68706f + // Check if we've run too recently (cooldown period) + var timeSinceLastRun = DateTime.UtcNow - _lastMatchingRun; + if (timeSinceLastRun < _minimumMatchingInterval) +======= + _logger.LogInformation("Manual full rebuild triggered for all playlists (same as cron job)"); + await RebuildAllPlaylistsAsync(CancellationToken.None); + } + + /// + /// Public method to trigger full rebuild for a single playlist (called from individual "Rebuild Remote" button). + /// This clears cache, fetches fresh data, and re-matches - same as cron job. + /// + public async Task TriggerRebuildForPlaylistAsync(string playlistName) + { + _logger.LogInformation("Manual full rebuild triggered for playlist: {Playlist} (same as cron job)", playlistName); + + // Check cooldown to prevent abuse + if (_lastRunTimes.TryGetValue(playlistName, out var lastRun)) +>>>>>>> beta + { +<<<<<<< HEAD + 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"); + } +||||||| f68706f + _logger.LogInformation("Skipping track matching - last run was {Seconds}s ago (minimum interval: {MinSeconds}s)", + (int)timeSinceLastRun.TotalSeconds, (int)_minimumMatchingInterval.TotalSeconds); + return; +======= + var timeSinceLastRun = DateTime.UtcNow - lastRun; + if (timeSinceLastRun < _minimumRunInterval) + { + _logger.LogWarning("Skipping manual rebuild 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 rebuilding again"); + } +>>>>>>> beta + } + +<<<<<<< HEAD + await MatchSinglePlaylistAsync(playlistName, CancellationToken.None); + _lastRunTimes[playlistName] = DateTime.UtcNow; + } + + private async Task MatchAllPlaylistsAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("=== STARTING TRACK MATCHING FOR ALL PLAYLISTS ==="); +||||||| f68706f + _logger.LogInformation("=== STARTING TRACK MATCHING ==="); + _lastMatchingRun = DateTime.UtcNow; +======= + await RebuildSinglePlaylistAsync(playlistName, CancellationToken.None); + _lastRunTimes[playlistName] = DateTime.UtcNow; + } + + /// + /// Public method to trigger lightweight matching for all playlists (called from controller). + /// This bypasses cron schedules and runs immediately WITHOUT clearing cache or refreshing from Spotify. + /// Use this when only the local library has changed. + /// + public async Task TriggerMatchingAsync() { _logger.LogInformation("Manual track matching triggered for all playlists (bypassing cron schedules)"); await MatchAllPlaylistsAsync(CancellationToken.None); } + /// + /// Public method to trigger lightweight matching for a single playlist (called from "Re-match Local" button). + /// This bypasses cron schedules and runs immediately WITHOUT clearing cache or refreshing from Spotify. + /// Use this when only the local library has changed, not when Spotify playlist changed. + /// + public async Task TriggerMatchingForPlaylistAsync(string playlistName) + { + _logger.LogInformation("Manual track matching triggered for playlist: {Playlist} (lightweight, no cache clear)", 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; + } + /// /// Public method to trigger matching for a specific playlist (called from controller). /// This bypasses cron schedules and runs immediately. @@ -270,6 +569,51 @@ public class SpotifyTrackMatchingService : BackgroundService _lastRunTimes[playlistName] = DateTime.UtcNow; } + private async Task RebuildAllPlaylistsAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("=== STARTING FULL REBUILD FOR ALL PLAYLISTS ==="); +>>>>>>> beta + + var playlists = _spotifySettings.Playlists; + if (playlists.Count == 0) + { + _logger.LogInformation("No playlists configured for rebuild"); + return; + } + + foreach (var playlist in playlists) + { + if (cancellationToken.IsCancellationRequested) break; + + try + { +<<<<<<< HEAD + await MatchSinglePlaylistAsync(playlist.Name, cancellationToken); +||||||| f68706f + 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 RebuildSinglePlaylistAsync(playlist.Name, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error rebuilding playlist {Playlist}", playlist.Name); + } + } + + _logger.LogInformation("=== FINISHED FULL REBUILD FOR ALL PLAYLISTS ==="); + } + private async Task MatchAllPlaylistsAsync(CancellationToken cancellationToken) { _logger.LogInformation("=== STARTING TRACK MATCHING FOR ALL PLAYLISTS ==="); @@ -288,6 +632,7 @@ public class SpotifyTrackMatchingService : BackgroundService try { await MatchSinglePlaylistAsync(playlist.Name, cancellationToken); +>>>>>>> beta } catch (Exception ex) { @@ -310,7 +655,7 @@ public class SpotifyTrackMatchingService : BackgroundService IMusicMetadataService metadataService, CancellationToken cancellationToken) { - var matchedTracksKey = $"spotify:matched:ordered:{playlistName}"; + var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlistName); // Get playlist tracks with full metadata including ISRC and position var spotifyTracks = await playlistFetcher.GetPlaylistTracksAsync(playlistName); @@ -438,32 +783,155 @@ public class SpotifyTrackMatchingService : BackgroundService _logger.LogInformation("New manual mappings detected for {Playlist}, rebuilding cache to apply them", playlistName); } + // PHASE 1: Get ALL Jellyfin tracks from the playlist (already injected by plugin) + var jellyfinTracks = new List(); + if (!string.IsNullOrEmpty(playlistConfig?.JellyfinId)) + { + using var scope = _serviceProvider.CreateScope(); + var proxyService = scope.ServiceProvider.GetService(); + var jellyfinSettings = scope.ServiceProvider.GetService>()?.Value; + + if (proxyService != null && jellyfinSettings != null) + { + try + { + var userId = jellyfinSettings.UserId; + var playlistItemsUrl = $"Playlists/{playlistConfig.JellyfinId}/Items"; + var queryParams = new Dictionary { ["Fields"] = "ProviderIds" }; + if (!string.IsNullOrEmpty(userId)) + { + queryParams["UserId"] = userId; + } + + var (response, _) = await proxyService.GetJsonAsyncInternal(playlistItemsUrl, queryParams); + + if (response != null && response.RootElement.TryGetProperty("Items", out var items)) + { + foreach (var item in items.EnumerateArray()) + { + var song = new Song + { + Id = item.GetProperty("Id").GetString() ?? "", + Title = item.GetProperty("Name").GetString() ?? "", + Artist = item.TryGetProperty("AlbumArtist", out var artist) ? artist.GetString() ?? "" : "", + Album = item.TryGetProperty("Album", out var album) ? album.GetString() ?? "" : "", + IsLocal = true + }; + jellyfinTracks.Add(song); + } + _logger.LogInformation("📚 Loaded {Count} tracks from Jellyfin playlist {Playlist}", + jellyfinTracks.Count, playlistName); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load Jellyfin tracks for {Playlist}", playlistName); + } + } + } + + // PHASE 2: Match Jellyfin tracks → Spotify tracks using fuzzy matching + _logger.LogInformation("🔍 Matching {JellyfinCount} Jellyfin tracks to {SpotifyCount} Spotify tracks", + jellyfinTracks.Count, spotifyTracks.Count); + + var localMatches = new Dictionary(); + var usedJellyfinIds = new HashSet(); + var usedSpotifyIds = new HashSet(); + + // Build all possible matches with scores + var allLocalCandidates = new List<(Song JellyfinTrack, SpotifyPlaylistTrack SpotifyTrack, double Score)>(); + + foreach (var jellyfinTrack in jellyfinTracks) + { + foreach (var spotifyTrack in spotifyTracks) + { + var score = CalculateMatchScore(jellyfinTrack.Title, jellyfinTrack.Artist, + spotifyTrack.Title, spotifyTrack.PrimaryArtist); + + if (score >= 70) // Only consider good matches + { + allLocalCandidates.Add((jellyfinTrack, spotifyTrack, score)); + } + } + } + + // Greedy assignment: best matches first + foreach (var (jellyfinTrack, spotifyTrack, score) in allLocalCandidates.OrderByDescending(c => c.Score)) + { + if (usedJellyfinIds.Contains(jellyfinTrack.Id)) continue; + if (usedSpotifyIds.Contains(spotifyTrack.SpotifyId)) continue; + + localMatches[spotifyTrack.SpotifyId] = (jellyfinTrack, spotifyTrack, score); + usedJellyfinIds.Add(jellyfinTrack.Id); + usedSpotifyIds.Add(spotifyTrack.SpotifyId); + + // Save local mapping + var metadata = new TrackMetadata + { + Title = spotifyTrack.Title, + Artist = spotifyTrack.PrimaryArtist, + Album = spotifyTrack.Album, + ArtworkUrl = spotifyTrack.AlbumArtUrl, + DurationMs = spotifyTrack.DurationMs + }; + + await _mappingService.SaveLocalMappingAsync(spotifyTrack.SpotifyId, jellyfinTrack.Id, metadata); + + _logger.LogInformation(" ✓ Local: {SpotifyTitle} → {JellyfinTitle} (score: {Score:F1})", + spotifyTrack.Title, jellyfinTrack.Title, score); + } + + _logger.LogInformation("✅ Matched {LocalCount}/{SpotifyCount} Spotify tracks to local Jellyfin tracks", + localMatches.Count, spotifyTracks.Count); + + // PHASE 3: For remaining unmatched Spotify tracks, search external providers + var unmatchedSpotifyTracks = spotifyTracks + .Where(t => !usedSpotifyIds.Contains(t.SpotifyId)) + .ToList(); + + _logger.LogInformation("🔍 Searching external providers for {Count} unmatched tracks", + unmatchedSpotifyTracks.Count); + var matchedTracks = new List(); var isrcMatches = 0; var fuzzyMatches = 0; var noMatch = 0; - // GREEDY ASSIGNMENT: Collect all possible matches first, then assign optimally var allCandidates = new List<(SpotifyPlaylistTrack SpotifyTrack, Song MatchedSong, double Score, string MatchType)>(); - // Process tracks in batches for parallel searching - var orderedTracks = tracksToMatch.OrderBy(t => t.Position).ToList(); - for (int i = 0; i < orderedTracks.Count; i += BatchSize) + // Process unmatched tracks in batches + for (int i = 0; i < unmatchedSpotifyTracks.Count; i += BatchSize) { if (cancellationToken.IsCancellationRequested) break; - var batch = orderedTracks.Skip(i).Take(BatchSize).ToList(); - _logger.LogDebug("Processing batch {Start}-{End} of {Total}", - i + 1, Math.Min(i + BatchSize, orderedTracks.Count), orderedTracks.Count); - - // Process all tracks in this batch in parallel + var batch = unmatchedSpotifyTracks.Skip(i).Take(BatchSize).ToList(); + var batchTasks = batch.Select(async spotifyTrack => { try { var candidates = new List<(Song Song, double Score, string MatchType)>(); - // Try ISRC match first if available and enabled + // Check global external mapping first + var globalMapping = await _mappingService.GetMappingAsync(spotifyTrack.SpotifyId); + if (globalMapping != null && globalMapping.TargetType == "external") + { + Song? mappedSong = null; + + if (!string.IsNullOrEmpty(globalMapping.ExternalProvider) && + !string.IsNullOrEmpty(globalMapping.ExternalId)) + { + mappedSong = await metadataService.GetSongAsync(globalMapping.ExternalProvider, globalMapping.ExternalId); + } + + if (mappedSong != null) + { + candidates.Add((mappedSong, 100.0, "global-mapping-external")); + return (spotifyTrack, candidates); + } + } + + // Try ISRC match if (_spotifyApiSettings.PreferIsrcMatching && !string.IsNullOrEmpty(spotifyTrack.Isrc)) { var isrcSong = await TryMatchByIsrcAsync(spotifyTrack.Isrc, metadataService); @@ -473,7 +941,7 @@ public class SpotifyTrackMatchingService : BackgroundService } } - // Always try fuzzy matching to get more candidates + // Fuzzy search external providers var fuzzySongs = await TryMatchByFuzzyMultipleAsync( spotifyTrack.Title, spotifyTrack.Artists, @@ -481,97 +949,128 @@ public class SpotifyTrackMatchingService : BackgroundService foreach (var (song, score) in fuzzySongs) { - candidates.Add((song, score, "fuzzy")); + if (!song.IsLocal) // Only external tracks + { + candidates.Add((song, score, "fuzzy-external")); + } } return (spotifyTrack, candidates); } catch (Exception ex) { +<<<<<<< HEAD _logger.LogError(ex, "Failed to match track: {Title} - {Artist}", spotifyTrack.Title, spotifyTrack.PrimaryArtist); +||||||| f68706f + _logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}", + spotifyTrack.Title, spotifyTrack.PrimaryArtist); +======= + _logger.LogError(ex, "Failed to match track: {Title}", spotifyTrack.Title); +>>>>>>> beta return (spotifyTrack, new List<(Song, double, string)>()); } }).ToList(); - // Wait for all tracks in this batch to complete var batchResults = await Task.WhenAll(batchTasks); - // Collect all candidates - foreach (var (spotifyTrack, candidates) in batchResults) + foreach (var result in batchResults) { - foreach (var (song, score, matchType) in candidates) + foreach (var candidate in result.Item2) { - allCandidates.Add((spotifyTrack, song, score, matchType)); + allCandidates.Add((result.Item1, candidate.Item1, candidate.Item2, candidate.Item3)); } } - // Rate limiting between batches - if (i + BatchSize < orderedTracks.Count) + if (i + BatchSize < unmatchedSpotifyTracks.Count) { await Task.Delay(DelayBetweenSearchesMs, cancellationToken); } } - // GREEDY ASSIGNMENT: Assign each Spotify track to its best unique match + // PHASE 4: Greedy assignment for external matches var usedSongIds = new HashSet(); - var assignments = new Dictionary(); + var externalAssignments = new Dictionary(); - // Sort candidates by score (highest first) - var sortedCandidates = allCandidates - .OrderByDescending(c => c.Score) - .ToList(); - - foreach (var (spotifyTrack, song, score, matchType) in sortedCandidates) + foreach (var (spotifyTrack, song, score, matchType) in allCandidates.OrderByDescending(c => c.Score)) { - // Skip if this Spotify track already has a match - if (assignments.ContainsKey(spotifyTrack.SpotifyId)) - continue; + if (externalAssignments.ContainsKey(spotifyTrack.SpotifyId)) continue; + if (usedSongIds.Contains(song.Id)) continue; - // Skip if this song is already used - if (usedSongIds.Contains(song.Id)) - continue; - - // Assign this match - assignments[spotifyTrack.SpotifyId] = (song, score, matchType); + externalAssignments[spotifyTrack.SpotifyId] = (song, score, matchType); usedSongIds.Add(song.Id); + + // Save external mapping + var metadata = new TrackMetadata + { + Title = spotifyTrack.Title, + Artist = spotifyTrack.PrimaryArtist, + Album = spotifyTrack.Album, + ArtworkUrl = spotifyTrack.AlbumArtUrl, + DurationMs = spotifyTrack.DurationMs + }; + + await _mappingService.SaveExternalMappingAsync( + spotifyTrack.SpotifyId, + song.ExternalProvider ?? "Unknown", + song.ExternalId ?? song.Id, + metadata); + + if (matchType == "isrc") isrcMatches++; + else fuzzyMatches++; + + _logger.LogInformation(" ✓ External: {Title} → {Provider}:{ExternalId} (score: {Score:F1})", + spotifyTrack.Title, song.ExternalProvider, song.ExternalId, score); } - // Build final matched tracks list - foreach (var spotifyTrack in orderedTracks) + // PHASE 5: Build final matched tracks list (local + external) + foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position)) { - if (assignments.TryGetValue(spotifyTrack.SpotifyId, out var match)) + MatchedTrack? matched = null; + + // Check local matches first + if (localMatches.TryGetValue(spotifyTrack.SpotifyId, out var localMatch)) { - var matched = new MatchedTrack + matched = new MatchedTrack { Position = spotifyTrack.Position, SpotifyId = spotifyTrack.SpotifyId, SpotifyTitle = spotifyTrack.Title, SpotifyArtist = spotifyTrack.PrimaryArtist, Isrc = spotifyTrack.Isrc, - MatchType = match.MatchType, - MatchedSong = match.Song + MatchType = "fuzzy-local", + MatchedSong = localMatch.JellyfinTrack + }; + } + // Check external matches + else if (externalAssignments.TryGetValue(spotifyTrack.SpotifyId, out var externalMatch)) + { + matched = new MatchedTrack + { + Position = spotifyTrack.Position, + SpotifyId = spotifyTrack.SpotifyId, + SpotifyTitle = spotifyTrack.Title, + SpotifyArtist = spotifyTrack.PrimaryArtist, + Isrc = spotifyTrack.Isrc, + MatchType = externalMatch.MatchType, + MatchedSong = externalMatch.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); + _logger.LogDebug(" #{Position} {Title} → no match", spotifyTrack.Position, spotifyTrack.Title); + } + + if (matched != null) + { + matchedTracks.Add(matched); } } if (matchedTracks.Count > 0) { +<<<<<<< HEAD // 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)); @@ -603,6 +1102,60 @@ public class SpotifyTrackMatchingService : BackgroundService // Cache matched tracks with position data until next cron run await _cache.SetAsync(matchedTracksKey, matchedTracks, cacheExpiration); +||||||| f68706f + // Cache matched tracks with position data + await _cache.SetAsync(matchedTracksKey, matchedTracks, TimeSpan.FromHours(1)); +======= + // UPDATE STATS CACHE: Calculate and cache stats immediately after matching + var statsLocalCount = localMatches.Count; + var statsExternalCount = externalAssignments.Count; + var statsMissingCount = spotifyTracks.Count - statsLocalCount - statsExternalCount; + + var stats = new Dictionary + { + ["local"] = statsLocalCount, + ["external"] = statsExternalCount, + ["missing"] = statsMissingCount + }; + + var statsCacheKey = $"spotify:playlist:stats:{playlistName}"; + await _cache.SetAsync(statsCacheKey, stats, TimeSpan.FromMinutes(30)); + + _logger.LogInformation("📊 Updated stats cache for {Playlist}: {Local} local, {External} external, {Missing} missing", + playlistName, statsLocalCount, statsExternalCount, statsMissingCount); + + // 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.LogError(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); +>>>>>>> beta // Save matched tracks to file for persistence across restarts await SaveMatchedTracksToFileAsync(playlistName, matchedTracks); @@ -630,9 +1183,10 @@ public class SpotifyTrackMatchingService : BackgroundService /// 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. + /// + /// Attempts to match a track by title and artist using fuzzy matching. + /// SEARCHES LOCAL FIRST, then external if no local match found. + /// Returns multiple candidates for greedy assignment. /// private async Task> TryMatchByFuzzyMultipleAsync( string title, @@ -642,41 +1196,130 @@ public class SpotifyTrackMatchingService : BackgroundService 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); + var allCandidates = new List<(Song Song, double Score)>(); - 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 + // STEP 1: Search LOCAL Jellyfin library FIRST + using var scope = _serviceProvider.CreateScope(); + var proxyService = scope.ServiceProvider.GetService(); + if (proxyService != null) + { + try { - 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 + // Search Jellyfin for local tracks + var searchParams = new Dictionary + { + ["searchTerm"] = query, + ["includeItemTypes"] = "Audio", + ["recursive"] = "true", + ["limit"] = "10" + }; + + var (searchResponse, _) = await proxyService.GetJsonAsyncInternal("Items", searchParams); + + if (searchResponse != null && searchResponse.RootElement.TryGetProperty("Items", out var items)) + { + var localResults = new List(); + foreach (var item in items.EnumerateArray()) + { + var id = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() ?? "" : ""; + var songTitle = 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() ?? ""; + } + + localResults.Add(new Song + { + Id = id, + Title = songTitle, + Artist = artist, + IsLocal = true + }); + } + + if (localResults.Count > 0) + { + // Score local results + var scoredLocal = localResults + .Select(song => new + { + Song = song, + 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.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(); + + allCandidates.AddRange(scoredLocal); + + // If we found good local matches, return them (don't search external) + if (scoredLocal.Any(x => x.TotalScore >= 70)) + { + _logger.LogDebug("Found {Count} local matches for '{Title}', skipping external search", + scoredLocal.Count, title); + return allCandidates; + } + } + } + } + catch (Exception ex) { - 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(); + _logger.LogWarning(ex, "Failed to search local library for '{Title}'", title); + } + } - return scoredResults; + // STEP 2: Only search EXTERNAL if no good local match found + var externalResults = await metadataService.SearchSongsAsync(query, limit: 10); + + if (externalResults.Count > 0) + { + var scoredExternal = externalResults + .Select(song => new + { + Song = song, + 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.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(); + + allCandidates.AddRange(scoredExternal); + } + + return allCandidates; } catch { @@ -684,14 +1327,26 @@ public class SpotifyTrackMatchingService : BackgroundService } } + private double CalculateMatchScore(string jellyfinTitle, string jellyfinArtist, string spotifyTitle, string spotifyArtist) + { + var titleScore = FuzzyMatcher.CalculateSimilarityAggressive(spotifyTitle, jellyfinTitle); + var artistScore = FuzzyMatcher.CalculateSimilarity(spotifyArtist, jellyfinArtist); + return (titleScore * 0.7) + (artistScore * 0.3); + } + /// - /// Attempts to match a track by ISRC using provider search. + /// Attempts to match a track by ISRC. + /// SEARCHES LOCAL FIRST, then external if no local match found. /// private async Task TryMatchByIsrcAsync(string isrc, IMusicMetadataService metadataService) { try { - // Search by ISRC directly - most providers support this + // STEP 1: Search LOCAL Jellyfin library FIRST by ISRC + // Note: Jellyfin doesn't have ISRC search, so we skip local ISRC search + // Local tracks will be found via fuzzy matching instead + + // STEP 2: Search EXTERNAL by ISRC var results = await metadataService.SearchSongsAsync($"isrc:{isrc}", limit: 1); if (results.Count > 0 && results[0].Isrc == isrc) { @@ -803,7 +1458,7 @@ public class SpotifyTrackMatchingService : BackgroundService IMusicMetadataService metadataService, CancellationToken cancellationToken) { - var missingTracksKey = $"spotify:missing:{playlistName}"; + var missingTracksKey = CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlistName); var matchedTracksKey = $"spotify:matched:{playlistName}"; // Check if we already have matched tracks cached @@ -896,54 +1551,6 @@ public class SpotifyTrackMatchingService : BackgroundService } } - /// - /// Calculates artist match score ensuring ALL artists are present. - /// Penalizes if artist counts don't match or if any artist is missing. - /// - private static double CalculateArtistMatchScore(List spotifyArtists, string songMainArtist, List songContributors) - { - if (spotifyArtists.Count == 0 || string.IsNullOrEmpty(songMainArtist)) - return 0; - - // Build list of all song artists (main + contributors) - var allSongArtists = new List { 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(); - foreach (var spotifyArtist in spotifyArtists) - { - var bestMatch = allSongArtists.Max(songArtist => - FuzzyMatcher.CalculateSimilarity(spotifyArtist, songArtist)); - spotifyScores.Add(bestMatch); - } - - // Check that each song artist has a good match in Spotify artists - var songScores = new List(); - foreach (var songArtist in allSongArtists) - { - var bestMatch = spotifyArtists.Max(spotifyArtist => - FuzzyMatcher.CalculateSimilarity(songArtist, spotifyArtist)); - songScores.Add(bestMatch); - } - - // Average all scores - this ensures ALL artists must match well - var allScores = spotifyScores.Concat(songScores); - var avgScore = allScores.Average(); - - // Penalize if any individual artist match is poor (< 70) - var minScore = allScores.Min(); - if (minScore < 70) - avgScore *= 0.7; // 30% penalty for poor individual match - - return avgScore; - } - /// /// Pre-builds the playlist items cache for instant serving. /// This combines local Jellyfin tracks with external matched tracks in the correct Spotify order. @@ -1271,6 +1878,7 @@ public class SpotifyTrackMatchingService : BackgroundService var jellyfinId = jellyfinIdObj.ToString(); if (!string.IsNullOrEmpty(jellyfinId)) { +<<<<<<< HEAD if (!itemDict.ContainsKey("ProviderIds")) { itemDict["ProviderIds"] = new Dictionary(); @@ -1301,6 +1909,57 @@ public class SpotifyTrackMatchingService : BackgroundService _logger.LogDebug("Fuzzy matched local track {Title} with Jellyfin ID {Id} (score: {Score:F1})", spotifyTrack.Title, jellyfinId, bestScore); } +||||||| f68706f + itemDict["ProviderIds"] = new Dictionary(); + } + + var providerIds = itemDict["ProviderIds"] as Dictionary; + 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); +======= + if (!itemDict.ContainsKey("ProviderIds")) + { + itemDict["ProviderIds"] = new Dictionary(); + } + + // Handle ProviderIds which might be a JsonElement or Dictionary + Dictionary? providerIds = null; + + if (itemDict["ProviderIds"] is Dictionary dict) + { + providerIds = dict; + } + else if (itemDict["ProviderIds"] is JsonElement jsonEl && jsonEl.ValueKind == JsonValueKind.Object) + { + // Convert JsonElement to Dictionary + providerIds = new Dictionary(); + foreach (var prop in jsonEl.EnumerateObject()) + { + providerIds[prop.Name] = prop.Value.GetString() ?? ""; + } + // Replace the JsonElement with the Dictionary + itemDict["ProviderIds"] = providerIds; + } + + if (providerIds != null) + { + if (!providerIds.ContainsKey("Jellyfin")) + { + providerIds["Jellyfin"] = jellyfinId; + } + + // Add Spotify ID for matching in track details endpoint + if (!providerIds.ContainsKey("Spotify") && !string.IsNullOrEmpty(spotifyTrack.SpotifyId)) + { + providerIds["Spotify"] = spotifyTrack.SpotifyId; + } + + _logger.LogDebug("Fuzzy matched local track {Title} with Jellyfin ID {Id} (score: {Score:F1})", + spotifyTrack.Title, jellyfinId, bestScore); + } +>>>>>>> beta } } @@ -1322,6 +1981,40 @@ public class SpotifyTrackMatchingService : BackgroundService // Convert external song to Jellyfin item format var externalItem = responseBuilder.ConvertSongToJellyfinItem(matched.MatchedSong); +<<<<<<< HEAD +||||||| f68706f + // Add Spotify ID to ProviderIds so lyrics can work + if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId)) + { + if (!externalItem.ContainsKey("ProviderIds")) + { + externalItem["ProviderIds"] = new Dictionary(); + } + + var providerIds = externalItem["ProviderIds"] as Dictionary; + if (providerIds != null && !providerIds.ContainsKey("Spotify")) + { + providerIds["Spotify"] = spotifyTrack.SpotifyId; + } + } + +======= + // Add Spotify ID to ProviderIds for matching in track details endpoint + if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId)) + { + if (!externalItem.ContainsKey("ProviderIds")) + { + externalItem["ProviderIds"] = new Dictionary(); + } + + var providerIds = externalItem["ProviderIds"] as Dictionary; + if (providerIds != null && !providerIds.ContainsKey("Spotify")) + { + providerIds["Spotify"] = spotifyTrack.SpotifyId; + } + } + +>>>>>>> beta finalItems.Add(externalItem); matchedSpotifyIds.Add(spotifyTrack.SpotifyId); // Mark as matched (external) externalUsedCount++; @@ -1335,6 +2028,7 @@ public class SpotifyTrackMatchingService : BackgroundService if (finalItems.Count > 0) { +<<<<<<< HEAD // Enrich external tracks with genres from MusicBrainz if (externalUsedCount > 0) { @@ -1411,6 +2105,88 @@ public class SpotifyTrackMatchingService : BackgroundService // Save to Redis cache with same expiration as matched tracks (until next cron run) var cacheKey = $"spotify:playlist:items:{playlistName}"; await _cache.SetAsync(cacheKey, finalItems, cacheExpiration); +||||||| f68706f + // Save to Redis cache + var cacheKey = $"spotify:playlist:items:{playlistName}"; + await _cache.SetAsync(cacheKey, finalItems, TimeSpan.FromHours(24)); +======= + // Enrich external tracks with genres from MusicBrainz + if (externalUsedCount > 0) + { + try + { + var genreEnrichment = _serviceProvider.GetService(); + if (genreEnrichment != null) + { + _logger.LogDebug("🎨 Enriching {Count} external tracks with genres from MusicBrainz...", externalUsedCount); + + // Extract external songs from externalMatchedTracks that were actually used + var usedExternalSpotifyIds = finalItems + .Where(item => item.TryGetValue("Id", out var idObj) && + idObj is string id && id.StartsWith("ext-")) + .Select(item => + { + // Try to get Spotify ID from ProviderIds + if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj is Dictionary providerIds) + { + providerIds.TryGetValue("Spotify", out var spotifyId); + return spotifyId; + } + return null; + }) + .Where(id => !string.IsNullOrEmpty(id)) + .ToHashSet(); + + var externalSongs = externalMatchedTracks + .Where(t => t.MatchedSong != null && + !t.MatchedSong.IsLocal && + usedExternalSpotifyIds.Contains(t.SpotifyId)) + .Select(t => t.MatchedSong!) + .ToList(); + + // Enrich genres in parallel + await genreEnrichment.EnrichSongsGenresAsync(externalSongs); + + // Update the genres in finalItems + foreach (var item in finalItems) + { + if (item.TryGetValue("Id", out var idObj) && idObj is string id && id.StartsWith("ext-")) + { + // Find the corresponding song + var song = externalSongs.FirstOrDefault(s => s.Id == id); + if (song != null && !string.IsNullOrEmpty(song.Genre)) + { + // Update Genres array + item["Genres"] = new[] { song.Genre }; + + // Update GenreItems array + item["GenreItems"] = new[] + { + new Dictionary + { + ["Name"] = song.Genre, + ["Id"] = $"genre-{song.Genre.ToLowerInvariant()}" + } + }; + + _logger.LogDebug("✓ Enriched {Title} with genre: {Genre}", song.Title, song.Genre); + } + } + } + + _logger.LogInformation("✅ Genre enrichment complete for {Playlist}", playlistName); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to enrich genres for {Playlist}, continuing without genres", playlistName); + } + } + + // Save to Redis cache with same expiration as matched tracks (until next cron run) + var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlistName); + await _cache.SetAsync(cacheKey, finalItems, cacheExpiration); +>>>>>>> beta // Save to file cache for persistence await SavePlaylistItemsToFileAsync(playlistName, finalItems); diff --git a/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs b/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs index 4cd36f2..3690792 100644 --- a/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs +++ b/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs @@ -91,21 +91,10 @@ public class SquidWTFDownloadService : BaseDownloadService return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) => { var response = await _httpClient.GetAsync(baseUrl); - Console.WriteLine($"Response code from is available async: {response.IsSuccessStatusCode}"); return response.IsSuccessStatusCode; }); } - protected override string? ExtractExternalIdFromAlbumId(string albumId) - { - const string prefix = "ext-squidwtf-album-"; - if (albumId.StartsWith(prefix)) - { - Console.WriteLine(albumId[prefix.Length..]); - return albumId[prefix.Length..]; - } - return null; - } protected override async Task DownloadTrackAsync(string trackId, Song song, CancellationToken cancellationToken) { diff --git a/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs b/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs index 750b10b..5533a59 100644 --- a/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs +++ b/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs @@ -85,16 +85,35 @@ public class SquidWTFMetadataService : IMusicMetadataService public async Task> SearchSongsAsync(string query, int limit = 20) +<<<<<<< HEAD { // Use round-robin to distribute load across endpoints (allows parallel processing of multiple tracks) return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) => +||||||| f68706f + { + // Race all endpoints for fastest search results + return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) => +======= +>>>>>>> beta { +<<<<<<< HEAD // 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); if (!response.IsSuccessStatusCode) +||||||| f68706f + // Use 's' parameter for track search as per hifi-api spec + var url = $"{baseUrl}/search/?s={Uri.EscapeDataString(query)}"; + var response = await _httpClient.GetAsync(url, ct); + + if (!response.IsSuccessStatusCode) +======= + // Race top 3 fastest endpoints for search (latency-sensitive) + return await _fallbackHelper.RaceTopEndpointsAsync(3, async (baseUrl, ct) => +>>>>>>> beta { +<<<<<<< HEAD throw new HttpRequestException($"HTTP {response.StatusCode}"); } @@ -115,32 +134,100 @@ public class SquidWTFMetadataService : IMusicMetadataService { int count = 0; foreach (var track in items.EnumerateArray()) - { - if (count >= limit) break; - - var song = ParseTidalTrack(track); - if (ShouldIncludeSong(song)) - { - songs.Add(song); - } - count++; - } +||||||| f68706f + throw new HttpRequestException($"HTTP {response.StatusCode}"); } - return songs; - }); - } + + var json = await response.Content.ReadAsStringAsync(ct); + + // Check for error in response body + var result = JsonDocument.Parse(json); + if (result.RootElement.TryGetProperty("detail", out _) || + result.RootElement.TryGetProperty("error", out _)) + { + throw new HttpRequestException("API returned error response"); + } + + var songs = new List(); + // Per hifi-api spec: track search returns data.items array + if (result.RootElement.TryGetProperty("data", out var data) && + data.TryGetProperty("items", out var items)) + { + int count = 0; + foreach (var track in items.EnumerateArray()) +======= + // Use 's' parameter for track search as per hifi-api spec + var url = $"{baseUrl}/search/?s={Uri.EscapeDataString(query)}"; + var response = await _httpClient.GetAsync(url, ct); + + if (!response.IsSuccessStatusCode) +>>>>>>> beta + { + throw new HttpRequestException($"HTTP {response.StatusCode}"); + } + + var json = await response.Content.ReadAsStringAsync(); + + // Check for error in response body + var result = JsonDocument.Parse(json); + if (result.RootElement.TryGetProperty("detail", out _) || + result.RootElement.TryGetProperty("error", out _)) + { + throw new HttpRequestException("API returned error response"); + } + + var songs = new List(); + // Per hifi-api spec: track search returns data.items array + if (result.RootElement.TryGetProperty("data", out var data) && + data.TryGetProperty("items", out var items)) + { + int count = 0; + foreach (var track in items.EnumerateArray()) + { + if (count >= limit) break; + + var song = ParseTidalTrack(track); + if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter)) + { + songs.Add(song); + } + count++; + } + } + return songs; + }); + } public async Task> SearchAlbumsAsync(string query, int limit = 20) +<<<<<<< HEAD { // Use round-robin to distribute load across endpoints (allows parallel processing) return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) => +||||||| f68706f + { + // Race all endpoints for fastest search results + return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) => +======= +>>>>>>> beta { +<<<<<<< HEAD // 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); if (!response.IsSuccessStatusCode) +||||||| f68706f + // Note: hifi-api doesn't document album search, but 'al' parameter is commonly used + var url = $"{baseUrl}/search/?al={Uri.EscapeDataString(query)}"; + var response = await _httpClient.GetAsync(url, ct); + + if (!response.IsSuccessStatusCode) +======= + // Race top 3 fastest endpoints for search (latency-sensitive) + return await _fallbackHelper.RaceTopEndpointsAsync(3, async (baseUrl, ct) => +>>>>>>> beta { +<<<<<<< HEAD throw new HttpRequestException($"HTTP {response.StatusCode}"); } @@ -155,23 +242,69 @@ public class SquidWTFMetadataService : IMusicMetadataService { int count = 0; foreach (var album in items.EnumerateArray()) - { - if (count >= limit) break; - - albums.Add(ParseTidalAlbum(album)); - count++; - } +||||||| f68706f + throw new HttpRequestException($"HTTP {response.StatusCode}"); } - return albums; - }); - } + var json = await response.Content.ReadAsStringAsync(ct); + var result = JsonDocument.Parse(json); + + var albums = new List(); + // 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)) + { + int count = 0; + foreach (var album in items.EnumerateArray()) +======= + // Use 'al' parameter for album search + // a= is for artists, al= is for albums, p= is for playlists + var url = $"{baseUrl}/search/?al={Uri.EscapeDataString(query)}"; + var response = await _httpClient.GetAsync(url, ct); + + if (!response.IsSuccessStatusCode) +>>>>>>> beta + { + throw new HttpRequestException($"HTTP {response.StatusCode}"); + } + + var json = await response.Content.ReadAsStringAsync(); + var result = JsonDocument.Parse(json); + + var albums = new List(); + // 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)) + { + int count = 0; + foreach (var album in items.EnumerateArray()) + { + if (count >= limit) break; + + albums.Add(ParseTidalAlbum(album)); + count++; + } + } + + return albums; + }); + } public async Task> SearchArtistsAsync(string query, int limit = 20) +<<<<<<< HEAD { // Use round-robin to distribute load across endpoints (allows parallel processing) return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) => +||||||| f68706f + { + // Race all endpoints for fastest search results + return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) => +======= +>>>>>>> beta { +<<<<<<< HEAD // Per hifi-api spec: use 'a' parameter for artist search var url = $"{baseUrl}/search/?a={Uri.EscapeDataString(query)}"; _logger.LogDebug("🔍 SQUIDWTF: Searching artists with URL: {Url}", url); @@ -179,7 +312,20 @@ public class SquidWTFMetadataService : IMusicMetadataService var response = await _httpClient.GetAsync(url); if (!response.IsSuccessStatusCode) +||||||| f68706f + // Per hifi-api spec: use 'a' parameter for artist search + var url = $"{baseUrl}/search/?a={Uri.EscapeDataString(query)}"; + _logger.LogInformation("🔍 SQUIDWTF: Searching artists with URL: {Url}", url); + + var response = await _httpClient.GetAsync(url, ct); + + if (!response.IsSuccessStatusCode) +======= + // Race top 3 fastest endpoints for search (latency-sensitive) + return await _fallbackHelper.RaceTopEndpointsAsync(3, async (baseUrl, ct) => +>>>>>>> beta { +<<<<<<< HEAD _logger.LogWarning("⚠️ SQUIDWTF: Artist search failed with status {StatusCode}", response.StatusCode); throw new HttpRequestException($"HTTP {response.StatusCode}"); } @@ -204,11 +350,70 @@ public class SquidWTFMetadataService : IMusicMetadataService count++; } } +||||||| f68706f + _logger.LogWarning("⚠️ SQUIDWTF: Artist search failed with status {StatusCode}", response.StatusCode); + throw new HttpRequestException($"HTTP {response.StatusCode}"); + } + + var json = await response.Content.ReadAsStringAsync(ct); + var result = JsonDocument.Parse(json); + + var artists = new List(); + // 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)) + { + int count = 0; + foreach (var artist in items.EnumerateArray()) + { + if (count >= limit) break; + + var parsedArtist = ParseTidalArtist(artist); + artists.Add(parsedArtist); + _logger.LogDebug("🎤 SQUIDWTF: Found artist: {Name} (ID: {Id})", parsedArtist.Name, parsedArtist.ExternalId); + count++; + } + } +======= + // Per hifi-api spec: use 'a' parameter for artist search + var url = $"{baseUrl}/search/?a={Uri.EscapeDataString(query)}"; + _logger.LogDebug("🔍 SQUIDWTF: Searching artists with URL: {Url}", url); +>>>>>>> beta - _logger.LogInformation("✓ SQUIDWTF: Artist search returned {Count} results", artists.Count); - return artists; - }); - } + var response = await _httpClient.GetAsync(url, ct); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("⚠️ SQUIDWTF: Artist search failed with status {StatusCode}", response.StatusCode); + throw new HttpRequestException($"HTTP {response.StatusCode}"); + } + + var json = await response.Content.ReadAsStringAsync(); + var result = JsonDocument.Parse(json); + + var artists = new List(); + // 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)) + { + int count = 0; + foreach (var artist in items.EnumerateArray()) + { + if (count >= limit) break; + + 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; + }); + } public async Task> SearchPlaylistsAsync(string query, int limit = 20) { @@ -348,7 +553,7 @@ public class SquidWTFMetadataService : IMusicMetadataService if (trackWrapper.TryGetProperty("item", out var track)) { var song = ParseTidalTrack(track); - if (ShouldIncludeSong(song)) + if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter)) { album.Songs.Add(song); } @@ -374,7 +579,13 @@ public class SquidWTFMetadataService : IMusicMetadataService var cached = await _cache.GetAsync(cacheKey); if (cached != null) { +<<<<<<< HEAD _logger.LogDebug("Returning cached artist {ArtistName}", cached.Name); +||||||| f68706f + _logger.LogInformation("Returning cached artist {ArtistName}", cached.Name); +======= + _logger.LogDebug("Returning cached artist {ArtistName}, ImageUrl: {ImageUrl}", cached.Name, cached.ImageUrl ?? "NULL"); +>>>>>>> beta return cached; } @@ -431,13 +642,21 @@ public class SquidWTFMetadataService : IMusicMetadataService } var artistElement = artistSource.Value; + + // Extract picture UUID (may be null) + string? pictureUuid = null; + if (artistElement.TryGetProperty("picture", out var pictureEl) && pictureEl.ValueKind != JsonValueKind.Null) + { + pictureUuid = pictureEl.GetString(); + } + // Normalize artist data to include album count var normalizedArtist = new JsonObject { ["id"] = artistElement.GetProperty("id").GetInt64(), ["name"] = artistElement.GetProperty("name").GetString(), ["albums_count"] = albumCount, - ["picture"] = artistElement.GetProperty("picture").GetString() + ["picture"] = pictureUuid }; using var doc = JsonDocument.Parse(normalizedArtist.ToJsonString()); @@ -499,27 +718,75 @@ public class SquidWTFMetadataService : IMusicMetadataService }, new List()); } - public async Task GetPlaylistAsync(string externalProvider, string externalId) - { - if (externalProvider != "squidwtf") return null; + public async Task> GetArtistTracksAsync(string externalProvider, string externalId) + { + if (externalProvider != "squidwtf") return new List(); 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; - - var json = await response.Content.ReadAsStringAsync(); - var playlistElement = JsonDocument.Parse(json).RootElement; - - // Check for error response - if (playlistElement.TryGetProperty("error", out _)) return null; + _logger.LogDebug("GetArtistTracksAsync called for SquidWTF artist {ExternalId}", externalId); - // Response structure: { "playlist": { playlist object }, "items": [ track wrappers ] } - return ParseTidalPlaylist(playlistElement); - }, (ExternalPlaylist?)null); + // Same endpoint as albums - /artist/?f={artistId} returns both albums and tracks + var url = $"{baseUrl}/artist/?f={externalId}"; + _logger.LogDebug("Fetching artist tracks from URL: {Url}", url); + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + _logger.LogError("SquidWTF artist tracks request failed with status {StatusCode}", response.StatusCode); + return new List(); + } + + var json = await response.Content.ReadAsStringAsync(); + _logger.LogDebug("SquidWTF artist tracks response for {ExternalId}: {JsonLength} bytes", externalId, json.Length); + var result = JsonDocument.Parse(json); + + var tracks = new List(); + + // Response structure: { "tracks": [ track objects ] } + if (result.RootElement.TryGetProperty("tracks", out var tracksArray)) + { + foreach (var track in tracksArray.EnumerateArray()) + { + var parsedTrack = ParseTidalTrack(track); + tracks.Add(parsedTrack); + } + _logger.LogDebug("Found {TrackCount} tracks for artist {ExternalId}", tracks.Count, externalId); + } + else + { + _logger.LogWarning("No tracks found in response for artist {ExternalId}", externalId); + } + + return tracks; + }, new List()); } + + public async Task GetPlaylistAsync(string externalProvider, string externalId) + { + if (externalProvider != "squidwtf") return null; + + 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; + + var json = await response.Content.ReadAsStringAsync(); + var rootElement = JsonDocument.Parse(json).RootElement; + + // Check for error response + if (rootElement.TryGetProperty("error", out _)) return null; + + // Response structure: { "playlist": { playlist object }, "items": [ track wrappers ] } + // Extract the playlist object from the response + if (!rootElement.TryGetProperty("playlist", out var playlistElement)) + return null; + + return ParseTidalPlaylist(playlistElement); + }, (ExternalPlaylist?)null); + } public async Task> GetPlaylistTracksAsync(string externalProvider, string externalId) { @@ -574,7 +841,11 @@ public class SquidWTFMetadataService : IMusicMetadataService // Override album name to be the playlist name song.Album = playlistName; - if (ShouldIncludeSong(song)) + // Playlists should not have disc numbers - always set to null + // This prevents Jellyfin from splitting the playlist into multiple "discs" + song.DiscNumber = null; + + if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter)) { songs.Add(song); } @@ -892,18 +1163,24 @@ public class SquidWTFMetadataService : IMusicMetadataService private Artist ParseTidalArtist(JsonElement artist) { var externalId = artist.GetProperty("id").GetInt64().ToString(); + var artistName = artist.GetProperty("name").GetString() ?? ""; string? imageUrl = null; if (artist.TryGetProperty("picture", out var picture)) { - var pictureGuid = picture.GetString()?.Replace("-", "/"); - imageUrl = $"https://resources.tidal.com/images/{pictureGuid}/320x320.jpg"; + var pictureUuid = picture.GetString(); + if (!string.IsNullOrEmpty(pictureUuid)) + { + var pictureGuid = pictureUuid.Replace("-", "/"); + imageUrl = $"https://resources.tidal.com/images/{pictureGuid}/320x320.jpg"; + _logger.LogDebug("Artist {ArtistName} picture: {ImageUrl}", artistName, imageUrl); + } } return new Artist { Id = $"ext-squidwtf-artist-{externalId}", - Name = artist.GetProperty("name").GetString() ?? "", + Name = artistName, ImageUrl = imageUrl, AlbumCount = artist.TryGetProperty("albums_count", out var albumsCount) ? albumsCount.GetInt32() @@ -923,76 +1200,87 @@ public class SquidWTFMetadataService : IMusicMetadataService /// Root JSON element containing playlist and items /// Parsed ExternalPlaylist object private ExternalPlaylist ParseTidalPlaylist(JsonElement playlistElement) - { - JsonElement? playlist = null; - JsonElement? tracks = null; + { + // The playlistElement IS the playlist data directly from the API + // No need to look for a "playlist" property wrapper - if (playlistElement.TryGetProperty("playlist", out var playlistEl)) - { - playlist = playlistEl; - } - - if (playlistElement.TryGetProperty("items", out var tracksEl)) - { - tracks = tracksEl; - } - - if (!playlist.HasValue) - { - throw new InvalidOperationException("Playlist data is missing"); - } - - var externalId = playlist.Value.GetProperty("uuid").GetString()!; - - // Get curator/creator name - string? curatorName = null; - if (playlist.Value.TryGetProperty("creator", out var creator) && - creator.TryGetProperty("id", out var id)) - { - curatorName = id.GetString(); - } - - // Get creation date - DateTime? createdDate = null; - if (playlist.Value.TryGetProperty("created", out var creationDateEl)) - { - var dateStr = creationDateEl.GetString(); - if (!string.IsNullOrEmpty(dateStr) && DateTime.TryParse(dateStr, out var date)) + var externalId = playlistElement.GetProperty("uuid").GetString()!; + + // Get curator/creator name + string? curatorName = null; + if (playlistElement.TryGetProperty("creator", out var creator)) { - createdDate = date; + // Try to get the name first, fall back to id if name doesn't exist + if (creator.TryGetProperty("name", out var name)) + { + curatorName = name.GetString(); + } + else if (creator.TryGetProperty("id", out var id)) + { + // Handle both string and number types for creator.id + var idValue = id.ValueKind == JsonValueKind.Number + ? id.GetInt32().ToString() + : id.GetString(); + + // If creator ID is 0 or empty, it's a TIDAL-curated playlist + if (idValue == "0" || string.IsNullOrEmpty(idValue)) + { + curatorName = "TIDAL"; + } + else + { + curatorName = idValue; + } + } + } + + // Final fallback: if still no curator name, use TIDAL + if (string.IsNullOrEmpty(curatorName)) + { + curatorName = "TIDAL"; } - } - - // Get playlist image URL - string? imageUrl = null; - if (playlist.Value.TryGetProperty("squareImage", out var picture)) - { - var pictureGuid = picture.GetString()?.Replace("-", "/"); - imageUrl = $"https://resources.tidal.com/images/{pictureGuid}/1080x1080.jpg"; - // Maybe later add support for potentential fallbacks if this size isn't available - } - return new ExternalPlaylist - { - Id = Common.PlaylistIdHelper.CreatePlaylistId("squidwtf", externalId), - Name = playlist.Value.GetProperty("title").GetString() ?? "", - Description = playlist.Value.TryGetProperty("description", out var desc) - ? desc.GetString() - : null, - CuratorName = curatorName, - Provider = "squidwtf", - ExternalId = externalId, - TrackCount = playlist.Value.TryGetProperty("numberOfTracks", out var nbTracks) - ? nbTracks.GetInt32() - : 0, - Duration = playlist.Value.TryGetProperty("duration", out var duration) - ? duration.GetInt32() - : 0, - CoverUrl = imageUrl, - CreatedDate = createdDate - }; - - } + // Get creation date + DateTime? createdDate = null; + if (playlistElement.TryGetProperty("created", out var creationDateEl)) + { + var dateStr = creationDateEl.GetString(); + if (!string.IsNullOrEmpty(dateStr) && DateTime.TryParse(dateStr, out var date)) + { + createdDate = date; + } + } + + // Get playlist image URL + string? imageUrl = null; + if (playlistElement.TryGetProperty("squareImage", out var picture)) + { + var pictureGuid = picture.GetString()?.Replace("-", "/"); + imageUrl = $"https://resources.tidal.com/images/{pictureGuid}/1080x1080.jpg"; + // Maybe later add support for potential fallbacks if this size isn't available + } + + return new ExternalPlaylist + { + Id = Common.PlaylistIdHelper.CreatePlaylistId("squidwtf", externalId), + Name = playlistElement.GetProperty("title").GetString() ?? "", + Description = playlistElement.TryGetProperty("description", out var desc) + ? desc.GetString() + : null, + CuratorName = curatorName, + Provider = "squidwtf", + ExternalId = externalId, + TrackCount = playlistElement.TryGetProperty("numberOfTracks", out var nbTracks) + ? nbTracks.GetInt32() + : 0, + Duration = playlistElement.TryGetProperty("duration", out var duration) + ? duration.GetInt32() + : 0, + CoverUrl = imageUrl, + CreatedDate = createdDate + }; + + } /// /// Determines whether a song should be included based on the explicit content filter setting @@ -1023,4 +1311,56 @@ public class SquidWTFMetadataService : IMusicMetadataService }; } -} \ No newline at end of file + /// + /// Searches for multiple songs in parallel across all available endpoints. + /// Each endpoint processes songs sequentially. Failed endpoints are blacklisted. + /// + public async Task> SearchSongsInParallelAsync(List queries, int limit = 10, CancellationToken cancellationToken = default) + { + return await _fallbackHelper.ProcessInParallelAsync( + queries, + async (baseUrl, query, ct) => + { + try + { + var url = $"{baseUrl}/search/?s={Uri.EscapeDataString(query)}"; + var response = await _httpClient.GetAsync(url, ct); + + if (!response.IsSuccessStatusCode) + { + return null; + } + + var json = await response.Content.ReadAsStringAsync(); + var result = JsonDocument.Parse(json); + + if (result.RootElement.TryGetProperty("detail", out _) || + result.RootElement.TryGetProperty("error", out _)) + { + return null; + } + + if (result.RootElement.TryGetProperty("data", out var data) && + data.TryGetProperty("items", out var items)) + { + foreach (var track in items.EnumerateArray()) + { + var song = ParseTidalTrack(track); + if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter)) + { + return song; // Return first matching song + } + } + } + + return null; + } + catch + { + throw; // Let the parallel processor handle blacklisting + } + }, + cancellationToken); + } + +} diff --git a/allstarr/Services/SquidWTF/SquidWTFStartupValidator.cs b/allstarr/Services/SquidWTF/SquidWTFStartupValidator.cs index e23ce76..58f93a7 100644 --- a/allstarr/Services/SquidWTF/SquidWTFStartupValidator.cs +++ b/allstarr/Services/SquidWTF/SquidWTFStartupValidator.cs @@ -85,13 +85,34 @@ public class SquidWTFStartupValidator : BaseStartupValidator return false; } }, - pingCount: 2, + pingCount: 5, cancellationToken); if (orderedEndpoints.Count > 0) { _fallbackHelper.SetEndpointOrder(orderedEndpoints); - WriteDetail($"Fastest endpoint: {orderedEndpoints.First()}"); + + // Show top 5 endpoints with their metrics + var topEndpoints = orderedEndpoints.Take(5).ToList(); + WriteDetail($"Fastest endpoint: {topEndpoints.First()}"); + + if (topEndpoints.Count > 1) + { + WriteDetail("Top 5 endpoints by average latency:"); + for (int i = 0; i < topEndpoints.Count; i++) + { + var endpoint = topEndpoints[i]; + var metrics = _benchmarkService.GetMetrics(endpoint); + if (metrics != null) + { + WriteDetail($" {i + 1}. {endpoint} - {metrics.AverageResponseMs}ms avg ({metrics.SuccessRate:P0} success)"); + } + else + { + WriteDetail($" {i + 1}. {endpoint}"); + } + } + } } } diff --git a/allstarr/Services/Subsonic/SubsonicModelMapper.cs b/allstarr/Services/Subsonic/SubsonicModelMapper.cs index a869848..f018e6d 100644 --- a/allstarr/Services/Subsonic/SubsonicModelMapper.cs +++ b/allstarr/Services/Subsonic/SubsonicModelMapper.cs @@ -225,7 +225,7 @@ public class SubsonicModelMapper /// /// Converts an ExternalPlaylist to a JSON object representing an album. - /// Playlists are represented as albums with genre "Playlist" and artist "🎵 {Provider} {Curator}". + /// Playlists are represented as albums with aggregated genres from tracks and artist "🎵 {Provider} {Curator}". /// private Dictionary ConvertPlaylistToAlbumJson(ExternalPlaylist playlist) { @@ -243,7 +243,7 @@ public class SubsonicModelMapper ["name"] = playlist.Name, ["artist"] = artistName, ["artistId"] = artistId, - ["genre"] = "Playlist", + ["genre"] = "Playlist", // Note: This is metadata-only, actual tracks will have their own genres ["songCount"] = playlist.TrackCount, ["duration"] = playlist.Duration }; @@ -264,7 +264,7 @@ public class SubsonicModelMapper /// /// Converts an ExternalPlaylist to an XML element representing an album. - /// Playlists are represented as albums with genre "Playlist" and artist "🎵 {Provider} {Curator}". + /// Playlists are represented as albums with aggregated genres from tracks and artist "🎵 {Provider} {Curator}". /// private XElement ConvertPlaylistToAlbumXml(ExternalPlaylist playlist, XNamespace ns) { @@ -281,7 +281,7 @@ public class SubsonicModelMapper new XAttribute("name", playlist.Name), new XAttribute("artist", artistName), new XAttribute("artistId", artistId), - new XAttribute("genre", "Playlist"), + new XAttribute("genre", "Playlist"), // Note: This is metadata-only, actual tracks will have their own genres new XAttribute("songCount", playlist.TrackCount), new XAttribute("duration", playlist.Duration) ); diff --git a/allstarr/Services/Subsonic/SubsonicResponseBuilder.cs b/allstarr/Services/Subsonic/SubsonicResponseBuilder.cs index 26cadcc..6e61382 100644 --- a/allstarr/Services/Subsonic/SubsonicResponseBuilder.cs +++ b/allstarr/Services/Subsonic/SubsonicResponseBuilder.cs @@ -156,6 +156,14 @@ public class SubsonicResponseBuilder var artistId = $"curator-{playlist.Provider}-{playlist.CuratorName?.ToLowerInvariant().Replace(" ", "-") ?? "unknown"}"; + // Aggregate unique genres from all tracks + var genres = tracks + .Where(s => !string.IsNullOrEmpty(s.Genre)) + .Select(s => s.Genre!) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + var genreString = genres.Count > 0 ? string.Join(", ", genres) : "Playlist"; + if (format == "json") { return CreateJsonResponse(new @@ -172,7 +180,7 @@ public class SubsonicResponseBuilder songCount = tracks.Count, duration = totalDuration, year = playlist.CreatedDate?.Year ?? 0, - genre = "Playlist", + genre = genreString, isCompilation = false, created = playlist.CreatedDate?.ToString("yyyy-MM-ddTHH:mm:ss"), song = tracks.Select(s => ConvertSongToJson(s)).ToList() @@ -188,7 +196,7 @@ public class SubsonicResponseBuilder new XAttribute("artistId", artistId), new XAttribute("songCount", tracks.Count), new XAttribute("duration", totalDuration), - new XAttribute("genre", "Playlist"), + new XAttribute("genre", genreString), new XAttribute("coverArt", playlist.Id) ); diff --git a/allstarr/allstarr.csproj b/allstarr/allstarr.csproj index 19e9821..7ef5f25 100644 --- a/allstarr/allstarr.csproj +++ b/allstarr/allstarr.csproj @@ -5,9 +5,19 @@ enable enable allstarr +<<<<<<< HEAD 1.0.1 1.0.1.0 1.0.1.0 +||||||| f68706f + 1.0.0 + 1.0.0.0 + 1.0.0.0 +======= + 1.1.1 + 1.1.1.0 + 1.1.1.0 +>>>>>>> beta diff --git a/allstarr/appsettings.json b/allstarr/appsettings.json index 15d1523..587fe2a 100644 --- a/allstarr/appsettings.json +++ b/allstarr/appsettings.json @@ -7,6 +7,9 @@ "System.Net.Http.HttpClient.Default.ClientHandler": "Warning" } }, + "Debug": { + "LogAllRequests": false + }, "Backend": { "Type": "Subsonic" }, diff --git a/allstarr/wwwroot/app.js b/allstarr/wwwroot/app.js new file mode 100644 index 0000000..91adaba --- /dev/null +++ b/allstarr/wwwroot/app.js @@ -0,0 +1,16 @@ +// ============================================================================ +// DEPRECATED: This file has been replaced by main.js +// ============================================================================ +// All functionality has been moved to modular ES6 files: +// - main.js (entry point with all initialization and window functions) +// - utils.js (utility functions like showToast, escapeHtml, formatCookieAge) +// - api.js (all API calls) +// - ui.js (all UI update functions) +// - modals.js (modal management) +// - helpers.js (helper functions for mapping, searching, etc.) +// ============================================================================ +// This file is kept for backwards compatibility only. +// All code has been successfully migrated to the modular structure. +// ============================================================================ + +console.warn('⚠️ app.js is deprecated. All functionality is now in main.js and other modules.'); diff --git a/allstarr/wwwroot/index.html b/allstarr/wwwroot/index.html index 0ca4a1c..cb6e19d 100644 --- a/allstarr/wwwroot/index.html +++ b/allstarr/wwwroot/index.html @@ -1,9 +1,11 @@ + Allstarr Dashboard +<<<<<<< HEAD +||||||| f68706f + +======= + +>>>>>>> beta +
⚠️ Configuration changed. Restart required to apply changes. - +
- +

- Allstarr v1.0.0 + Allstarr Loading...

@@ -534,15 +1050,23 @@
- +
Dashboard
Link Playlists
+<<<<<<< HEAD
Injected Playlists
+||||||| f68706f +
Active Playlists
+======= +
Injected Playlists
+
Kept Downloads
+
Scrobbling
+>>>>>>> beta
Configuration
API Analytics
- +
@@ -569,7 +1093,7 @@ -
- +

Jellyfin

@@ -586,7 +1110,7 @@
- +

Quick Actions @@ -595,10 +1119,11 @@ +

- +
@@ -609,19 +1134,23 @@

- Connect your Jellyfin playlists to Spotify playlists. Allstarr will automatically fill in missing tracks from Spotify using your preferred music service (SquidWTF/Deezer/Qobuz). -
Tip: Use the sp_dc cookie method for best results - it's simpler and more reliable. + Connect your Jellyfin playlists to Spotify playlists. Allstarr will automatically fill in missing + tracks from Spotify using your preferred music service (SquidWTF/Deezer/Qobuz). +
Tip: Use the sp_dc cookie method for best results - it's simpler and more + reliable.

- +
- -
- + @@ -643,25 +1172,74 @@
- +
- + + +

Kept Downloads
+

- Downloaded files stored permanently. Download or delete individual tracks. + Downloaded files stored permanently. Download individual tracks or download all as a zip archive.

-
+
Total Files: - 0 + 0
Total Size: - 0 B + 0 + B
@@ -805,10 +1395,109 @@
- + + +
+
+

Scrobbling Configuration

+

+ Scrobble your listening history to Last.fm and ListenBrainz. Tracks are scrobbled when you listen to at least half the track or 4 minutes (whichever comes first). +

+ +
+
+ Scrobbling Enabled + - + +
+
+ Local Track Scrobbling + - + +
+
+ +
+ ℹ️ Recommended: Keep local track scrobbling disabled and use native Jellyfin plugins instead: +
Last.fm Plugin +
ListenBrainz Plugin +
This ensures Allstarr only scrobbles external tracks (Spotify, Deezer, Qobuz). +
+
+ +
+

Last.fm

+

+ Scrobble to Last.fm. Enter your Last.fm username and password below, then click "Authenticate & Save" to generate a session key. +

+ +
+
+ Last.fm Enabled + - + +
+
+ Username + - + +
+
+ Password + - + +
+
+ Session Key + - +
+
+ Status + - +
+
+ +
+ + +
+
+ +
+

ListenBrainz

+

+ Scrobble to ListenBrainz. Get your user token from ListenBrainz Settings. +
Note: Only external tracks (Spotify, Deezer, Qobuz) are scrobbled to ListenBrainz. Local library tracks are not scrobbled. +

+ +
+
+ ListenBrainz Enabled + - + +
+
+ User Token + - + +
+
+ Status + - +
+
+ +
+ + +
+
+
+
+<<<<<<< HEAD

Core Settings

@@ -860,20 +1549,106 @@
+||||||| f68706f +======= +

Core Settings

+
+
+ Backend Type * + - + +
+
+ Music Service * + - + +
+
+ Storage Mode + - + +
+ +
+ Download Mode + - + +
+
+ Explicit Filter + - + +
+
+ Enable External Playlists + - + +
+
+ Playlists Directory + - + +
+
+ Redis Enabled + - + +
+
+
+ +
+

Debug Settings

+
+
+ Log All Requests + - + +
+
+ ℹ️ When enabled, logs every incoming request with method, path, headers, and response status. Auth tokens are automatically masked for security. +
+
+
+ +
+>>>>>>> beta

Spotify API Settings

+<<<<<<< HEAD
⚠️ For active playlists and link functionality to work, sp_dc session cookie must be set!
+||||||| f68706f +======= +
+ ⚠️ For active playlists and link functionality to work, sp_dc session cookie must be set! +
+>>>>>>> beta
API Enabled - - +
Session Cookie (sp_dc) * - - +
Cookie Age @@ -882,80 +1657,96 @@
Cache Duration - - +
ISRC Matching - - +
- +

Deezer Settings

ARL Token - - +
Quality - - +
- +

SquidWTF / Tidal Settings

Quality - +<<<<<<< HEAD +||||||| f68706f + +======= + +>>>>>>> beta
- +

MusicBrainz Settings

Enabled - - +
Username - - +
Password - - +
- +

Qobuz Settings

User Auth Token - - +
Quality - - +
- +

Jellyfin Settings

@@ -967,28 +1758,26 @@
API Key * - - +
User ID * - - -
-
- Library ID - - - +
- +

Library Settings

Download Path (Cache) - - +
Kept Path (Favorited) @@ -997,16 +1786,28 @@
- +

Spotify Import Settings

+<<<<<<< HEAD Spotify Import Enabled - +||||||| f68706f + Sync Start Time + - + +======= + Spotify Import Enabled + - + +>>>>>>> beta
+<<<<<<< HEAD Matching Interval (hours) - @@ -1064,10 +1865,84 @@ Proxy Images (days) - +||||||| f68706f + Sync Window + - + +======= + Matching Interval (hours) + - + +>>>>>>> beta
- + +
+

Cache Settings

+

+ Configure how long different types of data are cached. Longer durations reduce API calls but may + show stale data. +

+
+
+ Search Results (minutes) + - + +
+
+ Playlist Images (hours) + - + +
+
+ Spotify Playlist Items (hours) + - + +
+
+ Spotify Matched Tracks (days) + - + +
+
+ Lyrics (days) + - + +
+
+ Genre Data (days) + - + +
+
+ External Metadata (days) + - + +
+
+ Odesli Lookups (days) + - + +
+
+ Proxy Images (days) + - + +
+
+
+

Configuration Backup

@@ -1076,10 +1951,11 @@

- +
- +

Danger Zone

@@ -1091,7 +1967,7 @@

- +
@@ -1103,34 +1979,45 @@

- Track which Jellyfin API endpoints are being called most frequently. Useful for debugging and understanding client behavior. + Track which Jellyfin API endpoints are being called most frequently. Useful for debugging and + understanding client behavior.

- -
+ +
-
Total Requests
-
0
+
Total + Requests
+
0
-
Unique Endpoints
-
0
+
Unique + Endpoints
+
0
-
Most Called
-
-
+
Most Called +
+
-
- +
- -
- +
@@ -1151,27 +2038,29 @@
- +

About Endpoint Tracking

- Allstarr logs every Jellyfin API endpoint call to help you understand how clients interact with your server. - This data is stored in /app/cache/endpoint-usage/endpoints.csv + Allstarr logs every Jellyfin API endpoint call to help you understand how clients interact with your + server. + This data is stored in /app/cache/endpoint-usage/endpoints.csv and persists across restarts.

Common Endpoints: -

    -
  • /Users/{userId}/Items - Browse library items
  • -
  • /Items/{itemId} - Get item details
  • -
  • /Audio/{itemId}/stream - Stream audio
  • -
  • /Sessions/Playing - Report playback status
  • -
  • /Search/Hints - Search functionality
  • -
+
    +
  • /Users/{userId}/Items - Browse library items
  • +
  • /Items/{itemId} - Get item details
  • +
  • /Audio/{itemId}/stream - Stream audio
  • +
  • /Sessions/Playing - Report playback status
  • +
  • /Search/Hints - Search functionality
  • +

- +
- +
- +
- + +<<<<<<< HEAD +||||||| f68706f + +======= + + + + +>>>>>>> beta