diff --git a/.env.example b/.env.example
index 5824c1d..8b9b972 100644
--- a/.env.example
+++ b/.env.example
@@ -6,6 +6,10 @@ BACKEND_TYPE=Subsonic
# Enable Redis caching for metadata and images (default: true)
REDIS_ENABLED=true
+# Redis data persistence directory (default: ./redis-data)
+# Redis will save snapshots and append-only logs here to persist cache across restarts
+REDIS_DATA_PATH=./redis-data
+
# ===== SUBSONIC/NAVIDROME CONFIGURATION =====
# Server URL (required if using Subsonic backend)
SUBSONIC_URL=http://localhost:4533
@@ -122,13 +126,64 @@ SPOTIFY_IMPORT_SYNC_START_MINUTE=15
# Example: If plugin runs at 4:15 PM and window is 2 hours, checks from 4:00 PM to 6:00 PM
SPOTIFY_IMPORT_SYNC_WINDOW_HOURS=2
-# Playlist IDs to inject (comma-separated)
-# Get IDs from Jellyfin playlist URLs: https://jellyfin.example.com/web/#/details?id=PLAYLIST_ID
-# Example: SPOTIFY_IMPORT_PLAYLIST_IDS=4383a46d8bcac3be2ef9385053ea18df,ba50e26c867ec9d57ab2f7bf24cfd6b0
-SPOTIFY_IMPORT_PLAYLIST_IDS=
+# Matching interval: How often to run track matching (in hours)
+# Spotify playlists like Discover Weekly update once per week, Release Radar updates weekly
+# Most playlists don't change frequently, so running once per day is reasonable
+# Set to 0 to only run once on startup (manual trigger via admin UI still works)
+# Default: 24 hours
+SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS=24
-# Playlist names (comma-separated, must match Spotify Import plugin format)
-# IMPORTANT: Use the exact playlist names as they appear in Jellyfin
-# Must be in same order as SPOTIFY_IMPORT_PLAYLIST_IDS
-# Example: SPOTIFY_IMPORT_PLAYLIST_NAMES=Discover Weekly,Release Radar
-SPOTIFY_IMPORT_PLAYLIST_NAMES=
+# Playlists configuration (JSON ARRAY FORMAT - managed by web UI)
+# Format: [["PlaylistName","SpotifyPlaylistId","first|last"],...]
+# - PlaylistName: Name as it appears in Jellyfin
+# - SpotifyPlaylistId: Get from Spotify URL (e.g., 37i9dQZF1DXcBWIGoYBM5M)
+# Accepts: spotify:playlist:ID, full URL, or just the ID
+# - first|last: Where to position local tracks (first=local tracks first, last=external tracks first)
+#
+# Example:
+# SPOTIFY_IMPORT_PLAYLISTS=[["Discover Weekly","37i9dQZEVXcV6s7Dm7RXsU","first"],["Release Radar","37i9dQZEVXbng2vDHnfQlC","first"]]
+#
+# RECOMMENDED: Use the web UI (Link Playlists tab) to manage playlists instead of editing this manually
+SPOTIFY_IMPORT_PLAYLISTS=[]
+
+# ===== SPOTIFY DIRECT API (RECOMMENDED - ENABLES TRACK ORDERING & LYRICS) =====
+# This is the preferred method for Spotify playlist integration.
+# Provides: Correct track ordering, ISRC-based exact matching, synchronized lyrics
+# Does NOT require the Jellyfin Spotify Import plugin (can work standalone)
+
+# Enable direct Spotify API access (default: false)
+SPOTIFY_API_ENABLED=false
+
+# Spotify Client ID from https://developer.spotify.com/dashboard
+# Create an app in the Spotify Developer Dashboard to get this
+SPOTIFY_API_CLIENT_ID=
+
+# Spotify Client Secret (optional - only needed for certain OAuth flows)
+SPOTIFY_API_CLIENT_SECRET=
+
+# Spotify session cookie (sp_dc) - REQUIRED for editorial playlists
+# Editorial playlists (Release Radar, Discover Weekly, etc.) require authentication
+# via session cookie because they're not accessible through the official API.
+#
+# To get your sp_dc cookie:
+# 1. Open https://open.spotify.com in your browser and log in
+# 2. Open DevTools (F12) → Application → Cookies → https://open.spotify.com
+# 3. Find the cookie named "sp_dc" and copy its value
+# 4. Note: This cookie expires periodically (typically every few months)
+SPOTIFY_API_SESSION_COOKIE=
+
+# Date when the session cookie was set (ISO 8601 format)
+# Automatically set by the web UI when you update the cookie
+# Used to track cookie age and warn when approaching expiration (~1 year)
+SPOTIFY_API_SESSION_COOKIE_SET_DATE=
+
+# Cache duration for playlist data in minutes (default: 60)
+# Release Radar updates weekly, Discover Weekly updates Mondays
+SPOTIFY_API_CACHE_DURATION_MINUTES=60
+
+# Rate limit delay between API requests in milliseconds (default: 100)
+SPOTIFY_API_RATE_LIMIT_DELAY_MS=100
+
+# Prefer ISRC matching over fuzzy title/artist matching (default: true)
+# ISRC provides exact track identification across different streaming services
+SPOTIFY_API_PREFER_ISRC_MATCHING=true
diff --git a/.gitignore b/.gitignore
index 5ed0e7f..505113f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -74,6 +74,12 @@ obj/
downloads/
!downloads/.gitkeep
+# Kept music files (favorited external tracks)
+kept/
+
+# Cache files (Spotify missing tracks, etc.)
+cache/
+
# Docker volumes
redis-data/
@@ -82,6 +88,13 @@ apis/*.md
apis/*.json
!apis/jellyfin-openapi-stable.json
+# Log files for debugging
+apis/*.log
+
+# Endpoint usage tracking
+apis/endpoint-usage.json
+/app/cache/endpoint-usage/
+
# Original source code for reference
originals/
diff --git a/Dockerfile b/Dockerfile
index aa30ca5..0328a37 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -24,7 +24,8 @@ RUN mkdir -p /app/downloads
COPY --from=build /app/publish .
+# Only expose the main proxy port (8080)
+# Admin UI runs on 5275 but is NOT exposed - access via docker exec or SSH tunnel
EXPOSE 8080
-ENV ASPNETCORE_URLS=http://+:8080
ENTRYPOINT ["dotnet", "allstarr.dll"]
diff --git a/README.md b/README.md
index 08b19d3..e0dad46 100644
--- a/README.md
+++ b/README.md
@@ -38,6 +38,46 @@ docker-compose logs -f
The proxy will be available at `http://localhost:5274`.
+## Web Dashboard
+
+Allstarr includes a web-based dashboard for easy configuration and playlist management, accessible at `http://localhost:5275` (internal port, not exposed through reverse proxy).
+
+### Features
+
+- **Real-time Status**: Monitor Spotify authentication, cookie age, and playlist sync status
+- **Playlist Management**: Link Jellyfin playlists to Spotify playlists with a few clicks
+- **Configuration Editor**: Update settings without manually editing .env files
+- **Track Viewer**: Browse tracks in your configured playlists
+- **Cache Management**: Clear cached data and restart the container
+
+### Quick Setup with Web UI
+
+1. **Access the dashboard** at `http://localhost:5275`
+2. **Configure Spotify** (Configuration tab):
+ - Enable Spotify API
+ - Add your `sp_dc` cookie from Spotify (see instructions in UI)
+ - The cookie age is automatically tracked
+3. **Link playlists** (Link Playlists tab):
+ - View all your Jellyfin playlists
+ - Click "Link to Spotify" on any playlist
+ - Paste the Spotify playlist ID, URL, or `spotify:playlist:` URI
+ - Accepts formats like:
+ - `37i9dQZF1DXcBWIGoYBM5M` (just the ID)
+ - `spotify:playlist:37i9dQZF1DXcBWIGoYBM5M` (Spotify URI)
+ - `https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M` (full URL)
+4. **Restart** to apply changes (button in Configuration tab)
+
+### Why Two Playlist Tabs?
+
+- **Link Playlists**: Shows all Jellyfin playlists and lets you connect them to Spotify
+- **Active Playlists**: Shows which Spotify playlists are currently being monitored and filled with tracks
+
+### Configuration Persistence
+
+The web UI updates your `.env` file directly. Changes persist across container restarts, but require a restart to take effect. In development mode, the `.env` file is in your project root. In Docker, it's at `/app/.env`.
+
+**Recommended workflow**: Use the `sp_dc` cookie method (simpler and more reliable than the Jellyfin Spotify Import plugin).
+
### Nginx Proxy Setup (Required)
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!
@@ -250,6 +290,10 @@ Choose your preferred provider via the `MUSIC_SERVICE` environment variable. Add
|---------|-------------|
| `SquidWTF:Quality` | Preferred audio quality: `FLAC`, `MP3_320`, `MP3_128`. If not specified, the highest available quality for your account will be used |
+**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 |
@@ -291,41 +335,151 @@ Subsonic__EnableExternalPlaylists=false
### Spotify Playlist Injection (Jellyfin Only)
-Allstarr can intercept Spotify Import plugin playlists (Release Radar, Discover Weekly) and fill them with tracks automatically matched from your configured streaming provider (SquidWTF, Deezer, or Qobuz).
+Allstarr can automatically fill your Spotify playlists (like Release Radar and Discover Weekly) with tracks from your configured streaming provider (SquidWTF, Deezer, or Qobuz). This feature works by intercepting playlists created by the Jellyfin Spotify Import plugin and matching missing tracks with your streaming service.
-**Requirements:**
-- [Jellyfin Spotify Import Plugin](https://github.com/Viperinius/jellyfin-plugin-spotify-import) installed and configured
-- Plugin must run on a daily schedule (e.g., 4:15 PM daily)
-- Jellyfin URL and API key configured (uses existing JELLYFIN_URL and JELLYFIN_API_KEY settings)
+#### Prerequisites
-**Configuration:**
+1. **Install the Jellyfin Spotify Import Plugin**
+ - Navigate to Jellyfin Dashboard → Plugins → Catalog
+ - Search for "Spotify Import" by Viperinius
+ - Install and restart Jellyfin
+ - Plugin repository: [Viperinius/jellyfin-plugin-spotify-import](https://github.com/Viperinius/jellyfin-plugin-spotify-import)
+
+2. **Configure the Spotify Import Plugin**
+ - Go to Jellyfin Dashboard → Plugins → Spotify Import
+ - Connect your Spotify account
+ - Select which playlists to sync (e.g., Release Radar, Discover Weekly)
+ - Set a daily sync schedule (e.g., 4:15 PM daily)
+ - The plugin will create playlists in Jellyfin and generate "missing tracks" files for songs not in your library
+
+3. **Configure Allstarr**
+ - Allstarr needs to know when the plugin runs and which playlists to intercept
+ - Uses your existing `JELLYFIN_URL` and `JELLYFIN_API_KEY` settings (no additional credentials needed)
+
+#### Configuration
| Setting | Description |
|---------|-------------|
| `SpotifyImport:Enabled` | Enable Spotify playlist injection (default: `false`) |
-| `SpotifyImport:SyncStartHour` | Hour when plugin runs (24-hour format, 0-23) |
-| `SpotifyImport:SyncStartMinute` | Minute when plugin runs (0-59) |
-| `SpotifyImport:SyncWindowHours` | Hours to search for missing tracks files after sync time |
-| `SpotifyImport:Playlists` | Array of playlists to inject (Name, SpotifyName, Enabled) |
+| `SpotifyImport:SyncStartHour` | Hour when the Spotify Import plugin runs (24-hour format, 0-23) |
+| `SpotifyImport:SyncStartMinute` | Minute when the plugin runs (0-59) |
+| `SpotifyImport:SyncWindowHours` | Hours to search for missing tracks files after sync time (default: 2) |
+| `SpotifyImport:PlaylistIds` | Comma-separated Jellyfin playlist IDs to intercept |
+| `SpotifyImport:PlaylistNames` | Comma-separated playlist names (must match order of IDs) |
-**How it works:**
-1. Jellyfin Spotify Import plugin runs daily and creates playlists + missing tracks files
-2. Allstarr fetches these missing tracks files within the configured time window
-3. For each missing track, Allstarr searches your streaming provider (SquidWTF, Deezer, or Qobuz)
-4. When you open the playlist in Jellyfin, Allstarr intercepts the request and returns matched tracks
-5. Tracks are downloaded on-demand when played
-6. On startup, Allstarr will fetch missing tracks if it hasn't run in the last 24 hours
-
-**Environment variables:**
+**Environment variables example:**
```bash
+# Enable the feature
SPOTIFY_IMPORT_ENABLED=true
+
+# Sync window settings (optional - used to prevent fetching too frequently)
+# The fetcher searches backwards from current time for the last 48 hours
SPOTIFY_IMPORT_SYNC_START_HOUR=16
SPOTIFY_IMPORT_SYNC_START_MINUTE=15
SPOTIFY_IMPORT_SYNC_WINDOW_HOURS=2
-SPOTIFY_IMPORT_PLAYLISTS=Release Radar,Discover Weekly
+
+# Get playlist IDs from Jellyfin URLs: https://jellyfin.example.com/web/#/details?id=PLAYLIST_ID
+SPOTIFY_IMPORT_PLAYLIST_IDS=ba50e26c867ec9d57ab2f7bf24cfd6b0,4383a46d8bcac3be2ef9385053ea18df
+
+# Names must match exactly as they appear in Jellyfin (used to find missing tracks files)
+SPOTIFY_IMPORT_PLAYLIST_NAMES=Release Radar,Discover Weekly
```
-> **Note**: This feature uses your existing JELLYFIN_URL and JELLYFIN_API_KEY settings. The plugin must be configured to run on a schedule, and the sync window should cover the plugin's execution time.
+#### How It Works
+
+1. **Spotify Import Plugin Runs** (e.g., daily at 4:15 PM)
+ - Plugin fetches your Spotify playlists
+ - Creates/updates playlists in Jellyfin with tracks already in your library
+ - Generates "missing tracks" JSON files for songs not found locally
+ - Files are named like: `Release Radar_missing_2026-02-01_16-15.json`
+
+2. **Allstarr Fetches Missing Tracks** (within sync window)
+ - Searches for missing tracks files from the Jellyfin plugin
+ - Searches **+24 hours forward first** (newest files), then **-48 hours backward** if not found
+ - This efficiently finds the most recent file regardless of timezone differences
+ - Example: Server time 12 PM EST, file timestamped 9 PM UTC (same day) → Found in forward search
+ - Caches the list of missing tracks in Redis + file cache
+ - Runs automatically on startup (if needed) and every 5 minutes during the sync window
+
+3. **Allstarr Matches Tracks** (2 minutes after startup, then configurable interval)
+ - For each missing track, searches your streaming provider (SquidWTF, Deezer, or Qobuz)
+ - Uses fuzzy matching to find the best match (title + artist similarity)
+ - Rate-limited to avoid overwhelming the service (150ms delay between searches)
+ - Caches matched results for 1 hour
+ - **Pre-builds playlist items cache** for instant serving (no "on the fly" building)
+ - Default interval: 24 hours (configurable via `SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS`)
+ - Set to 0 to only run once on startup (manual trigger via admin UI still works)
+
+4. **You Open the Playlist in Jellyfin**
+ - Allstarr intercepts the request
+ - Returns a merged list: local tracks + matched streaming tracks
+ - Loads instantly from cache (no searching needed!)
+
+5. **You Play a Track**
+ - If it's a local track, streams from Jellyfin normally
+ - If it's a matched track, downloads from streaming provider on-demand
+ - Downloaded tracks are saved to your library for future use
+
+#### Manual Triggers
+
+You can manually trigger syncing and matching via API:
+
+```bash
+# Fetch missing tracks from Jellyfin plugin
+curl "https://your-jellyfin-proxy.com/spotify/sync?api_key=YOUR_API_KEY"
+
+# Trigger track matching (searches streaming provider)
+curl "https://your-jellyfin-proxy.com/spotify/match?api_key=YOUR_API_KEY"
+
+# Clear cache to force re-matching
+curl "https://your-jellyfin-proxy.com/spotify/clear-cache?api_key=YOUR_API_KEY"
+```
+
+#### Startup Behavior
+
+When Allstarr starts with Spotify Import enabled:
+
+**Smart Cache Check:**
+- Checks if today's sync window has passed (e.g., if sync is at 4 PM + 2 hour window = 6 PM)
+- If before 6 PM and yesterday's cache exists → **Skips fetch** (cache is still current)
+- If after 6 PM or no cache exists → **Fetches missing tracks** from Jellyfin plugin
+
+**Track Matching:**
+- **T+2min**: Matches tracks with streaming provider (with rate limiting)
+- Only matches playlists that don't already have cached matches
+- **Result**: Playlists load instantly when you open them!
+
+**Example Timeline:**
+- Plugin runs daily at 4:15 PM, creates files at ~4:16 PM
+- You restart Allstarr at 12:00 PM (noon) the next day
+- Startup check: "Today's sync window ends at 6 PM, and I have yesterday's 4:16 PM file"
+- **Decision**: Skip fetch, use existing cache
+- At 6:01 PM: Next scheduled check will search for new files
+
+#### Troubleshooting
+
+**Playlists are empty:**
+- Check that the Spotify Import plugin is running and creating playlists
+- Verify `SPOTIFY_IMPORT_PLAYLIST_IDS` match your Jellyfin playlist IDs
+- Check logs: `docker-compose logs -f allstarr | grep -i spotify`
+
+**Tracks aren't matching:**
+- Ensure your streaming provider is configured (`MUSIC_SERVICE`, credentials)
+- Check that playlist names in `SPOTIFY_IMPORT_PLAYLIST_NAMES` match exactly
+- Manually trigger matching: `curl "https://your-proxy.com/spotify/match?api_key=KEY"`
+
+**Sync timing issues:**
+- Set `SPOTIFY_IMPORT_SYNC_START_HOUR/MINUTE` to match your plugin schedule
+- Increase `SPOTIFY_IMPORT_SYNC_WINDOW_HOURS` if files aren't being found
+- Check Jellyfin plugin logs to confirm when it runs
+
+#### Notes
+
+- This feature uses your existing `JELLYFIN_URL` and `JELLYFIN_API_KEY` settings
+- Matched tracks are cached for 1 hour to avoid repeated searches
+- Missing tracks cache persists across restarts (stored in Redis + file cache)
+- Rate limiting prevents overwhelming your streaming provider (150ms between searches)
+- Only works with Jellyfin backend (not Subsonic/Navidrome)
### Getting Credentials
diff --git a/allstarr.Tests/JellyfinProxyServiceTests.cs b/allstarr.Tests/JellyfinProxyServiceTests.cs
index 088581f..89179f1 100644
--- a/allstarr.Tests/JellyfinProxyServiceTests.cs
+++ b/allstarr.Tests/JellyfinProxyServiceTests.cs
@@ -63,11 +63,12 @@ public class JellyfinProxyServiceTests
SetupMockResponse(HttpStatusCode.OK, jsonResponse, "application/json");
// Act
- var result = await _service.GetJsonAsync("Items");
+ var (body, statusCode) = await _service.GetJsonAsync("Items");
// Assert
- Assert.NotNull(result);
- Assert.True(result.RootElement.TryGetProperty("Items", out var items));
+ Assert.NotNull(body);
+ Assert.Equal(200, statusCode);
+ Assert.True(body.RootElement.TryGetProperty("Items", out var items));
Assert.Equal(1, items.GetArrayLength());
}
@@ -78,10 +79,11 @@ public class JellyfinProxyServiceTests
SetupMockResponse(HttpStatusCode.InternalServerError, "", "text/plain");
// Act
- var result = await _service.GetJsonAsync("Items");
+ var (body, statusCode) = await _service.GetJsonAsync("Items");
// Assert
- Assert.Null(result);
+ Assert.Null(body);
+ Assert.Equal(500, statusCode);
}
[Fact]
@@ -207,12 +209,13 @@ public class JellyfinProxyServiceTests
});
// Act
- var result = await _service.GetItemAsync("abc-123");
+ var (body, statusCode) = await _service.GetItemAsync("abc-123");
// Assert
Assert.NotNull(captured);
Assert.Contains("/Items/abc-123", captured!.RequestUri!.ToString());
- Assert.NotNull(result);
+ Assert.NotNull(body);
+ Assert.Equal(200, statusCode);
}
[Fact]
diff --git a/allstarr.Tests/JellyfinResponseStructureTests.cs b/allstarr.Tests/JellyfinResponseStructureTests.cs
new file mode 100644
index 0000000..a60b14a
--- /dev/null
+++ b/allstarr.Tests/JellyfinResponseStructureTests.cs
@@ -0,0 +1,334 @@
+using System.Text.Json;
+using Xunit;
+using allstarr.Models.Domain;
+using allstarr.Services.Jellyfin;
+
+namespace allstarr.Tests;
+
+///
+/// Integration tests to verify Jellyfin response structure matches real API responses.
+///
+public class JellyfinResponseStructureTests
+{
+ private readonly JellyfinResponseBuilder _builder;
+
+ public JellyfinResponseStructureTests()
+ {
+ _builder = new JellyfinResponseBuilder();
+ }
+
+ [Fact]
+ public void Track_Response_Should_Have_All_Required_Fields()
+ {
+ // Arrange
+ var song = new Song
+ {
+ Id = "test-id",
+ Title = "Test Song",
+ Artist = "Test Artist",
+ ArtistId = "artist-id",
+ Album = "Test Album",
+ AlbumId = "album-id",
+ Duration = 180,
+ Year = 2024,
+ Track = 1,
+ Genre = "Pop",
+ IsLocal = false,
+ ExternalProvider = "Deezer",
+ ExternalId = "123456"
+ };
+
+ // Act
+ var result = _builder.ConvertSongToJellyfinItem(song);
+
+ // Assert - Required top-level fields
+ Assert.NotNull(result["Name"]);
+ Assert.NotNull(result["ServerId"]);
+ Assert.NotNull(result["Id"]);
+ Assert.NotNull(result["Type"]);
+ Assert.Equal("Audio", result["Type"]);
+ Assert.NotNull(result["MediaType"]);
+ Assert.Equal("Audio", result["MediaType"]);
+
+ // Assert - Metadata fields
+ Assert.NotNull(result["Container"]);
+ Assert.Equal("flac", result["Container"]);
+ Assert.NotNull(result["HasLyrics"]);
+ Assert.False((bool)result["HasLyrics"]!);
+
+ // Assert - Genres (must be array, never null)
+ Assert.NotNull(result["Genres"]);
+ Assert.IsType(result["Genres"]);
+ Assert.NotNull(result["GenreItems"]);
+ Assert.IsAssignableFrom(result["GenreItems"]);
+
+ // Assert - UserData
+ Assert.NotNull(result["UserData"]);
+ var userData = result["UserData"] as Dictionary;
+ Assert.NotNull(userData);
+ Assert.Contains("ItemId", userData.Keys);
+ Assert.Contains("Key", userData.Keys);
+
+ // Assert - Image fields
+ Assert.NotNull(result["ImageTags"]);
+ Assert.NotNull(result["BackdropImageTags"]);
+ Assert.NotNull(result["ImageBlurHashes"]);
+
+ // Assert - Location
+ Assert.NotNull(result["LocationType"]);
+ Assert.Equal("FileSystem", result["LocationType"]);
+
+ // Assert - Parent references
+ Assert.NotNull(result["ParentLogoItemId"]);
+ Assert.NotNull(result["ParentBackdropItemId"]);
+ Assert.NotNull(result["ParentBackdropImageTags"]);
+ }
+
+ [Fact]
+ public void Track_MediaSources_Should_Have_Complete_Structure()
+ {
+ // Arrange
+ var song = new Song
+ {
+ Id = "test-id",
+ Title = "Test Song",
+ Artist = "Test Artist",
+ Album = "Test Album",
+ Duration = 180,
+ IsLocal = false,
+ ExternalProvider = "Deezer",
+ ExternalId = "123456"
+ };
+
+ // Act
+ var result = _builder.ConvertSongToJellyfinItem(song);
+
+ // Assert - MediaSources exists
+ Assert.NotNull(result["MediaSources"]);
+ var mediaSources = result["MediaSources"] as object[];
+ Assert.NotNull(mediaSources);
+ Assert.Single(mediaSources);
+
+ var mediaSource = mediaSources[0] as Dictionary;
+ Assert.NotNull(mediaSource);
+
+ // Assert - Required MediaSource fields
+ Assert.Contains("Protocol", mediaSource.Keys);
+ Assert.Contains("Id", mediaSource.Keys);
+ Assert.Contains("Path", mediaSource.Keys);
+ Assert.Contains("Type", mediaSource.Keys);
+ Assert.Contains("Container", mediaSource.Keys);
+ Assert.Contains("Bitrate", mediaSource.Keys);
+ Assert.Contains("ETag", mediaSource.Keys);
+ Assert.Contains("RunTimeTicks", mediaSource.Keys);
+
+ // Assert - Boolean flags
+ Assert.Contains("IsRemote", mediaSource.Keys);
+ Assert.Contains("IsInfiniteStream", mediaSource.Keys);
+ Assert.Contains("RequiresOpening", mediaSource.Keys);
+ Assert.Contains("RequiresClosing", mediaSource.Keys);
+ Assert.Contains("RequiresLooping", mediaSource.Keys);
+ Assert.Contains("SupportsProbing", mediaSource.Keys);
+ Assert.Contains("SupportsTranscoding", mediaSource.Keys);
+ Assert.Contains("SupportsDirectStream", mediaSource.Keys);
+ Assert.Contains("SupportsDirectPlay", mediaSource.Keys);
+ Assert.Contains("ReadAtNativeFramerate", mediaSource.Keys);
+ Assert.Contains("IgnoreDts", mediaSource.Keys);
+ Assert.Contains("IgnoreIndex", mediaSource.Keys);
+ Assert.Contains("GenPtsInput", mediaSource.Keys);
+ Assert.Contains("UseMostCompatibleTranscodingProfile", mediaSource.Keys);
+ Assert.Contains("HasSegments", mediaSource.Keys);
+
+ // Assert - Arrays (must not be null)
+ Assert.Contains("MediaStreams", mediaSource.Keys);
+ Assert.NotNull(mediaSource["MediaStreams"]);
+ Assert.Contains("MediaAttachments", mediaSource.Keys);
+ Assert.NotNull(mediaSource["MediaAttachments"]);
+ Assert.Contains("Formats", mediaSource.Keys);
+ Assert.NotNull(mediaSource["Formats"]);
+ Assert.Contains("RequiredHttpHeaders", mediaSource.Keys);
+ Assert.NotNull(mediaSource["RequiredHttpHeaders"]);
+
+ // Assert - Other fields
+ Assert.Contains("TranscodingSubProtocol", mediaSource.Keys);
+ Assert.Contains("DefaultAudioStreamIndex", mediaSource.Keys);
+ }
+
+ [Fact]
+ public void Track_MediaStreams_Should_Have_Complete_Audio_Stream()
+ {
+ // Arrange
+ var song = new Song
+ {
+ Id = "test-id",
+ Title = "Test Song",
+ Artist = "Test Artist",
+ IsLocal = false,
+ ExternalProvider = "Deezer"
+ };
+
+ // Act
+ var result = _builder.ConvertSongToJellyfinItem(song);
+ var mediaSources = result["MediaSources"] as object[];
+ var mediaSource = mediaSources![0] as Dictionary;
+ var mediaStreams = mediaSource!["MediaStreams"] as object[];
+
+ // Assert
+ Assert.NotNull(mediaStreams);
+ Assert.Single(mediaStreams);
+
+ var audioStream = mediaStreams[0] as Dictionary;
+ Assert.NotNull(audioStream);
+
+ // Assert - Required audio stream fields
+ Assert.Contains("Codec", audioStream.Keys);
+ Assert.Equal("flac", audioStream["Codec"]);
+ Assert.Contains("Type", audioStream.Keys);
+ Assert.Equal("Audio", audioStream["Type"]);
+ Assert.Contains("BitRate", audioStream.Keys);
+ Assert.Contains("Channels", audioStream.Keys);
+ Assert.Contains("SampleRate", audioStream.Keys);
+ Assert.Contains("BitDepth", audioStream.Keys);
+ Assert.Contains("ChannelLayout", audioStream.Keys);
+ Assert.Contains("TimeBase", audioStream.Keys);
+ Assert.Contains("DisplayTitle", audioStream.Keys);
+
+ // Assert - Video-related fields (required even for audio)
+ Assert.Contains("VideoRange", audioStream.Keys);
+ Assert.Contains("VideoRangeType", audioStream.Keys);
+ Assert.Contains("AudioSpatialFormat", audioStream.Keys);
+
+ // Assert - Localization
+ Assert.Contains("LocalizedDefault", audioStream.Keys);
+ Assert.Contains("LocalizedExternal", audioStream.Keys);
+
+ // Assert - Boolean flags
+ Assert.Contains("IsInterlaced", audioStream.Keys);
+ Assert.Contains("IsAVC", audioStream.Keys);
+ Assert.Contains("IsDefault", audioStream.Keys);
+ Assert.Contains("IsForced", audioStream.Keys);
+ Assert.Contains("IsHearingImpaired", audioStream.Keys);
+ Assert.Contains("IsExternal", audioStream.Keys);
+ Assert.Contains("IsTextSubtitleStream", audioStream.Keys);
+ Assert.Contains("SupportsExternalStream", audioStream.Keys);
+
+ // Assert - Index and Level
+ Assert.Contains("Index", audioStream.Keys);
+ Assert.Contains("Level", audioStream.Keys);
+ }
+
+ [Fact]
+ public void Album_Response_Should_Have_All_Required_Fields()
+ {
+ // Arrange
+ var album = new Album
+ {
+ Id = "album-id",
+ Title = "Test Album",
+ Artist = "Test Artist",
+ Year = 2024,
+ Genre = "Rock",
+ IsLocal = false,
+ ExternalProvider = "Deezer"
+ };
+
+ // Act
+ var result = _builder.ConvertAlbumToJellyfinItem(album);
+
+ // Assert
+ Assert.NotNull(result["Name"]);
+ Assert.NotNull(result["ServerId"]);
+ Assert.NotNull(result["Id"]);
+ Assert.NotNull(result["Type"]);
+ Assert.Equal("MusicAlbum", result["Type"]);
+ Assert.True((bool)result["IsFolder"]!);
+ Assert.NotNull(result["MediaType"]);
+ Assert.Equal("Unknown", result["MediaType"]);
+
+ // Assert - Genres
+ Assert.NotNull(result["Genres"]);
+ Assert.IsType(result["Genres"]);
+ Assert.NotNull(result["GenreItems"]);
+
+ // Assert - Artists
+ Assert.NotNull(result["Artists"]);
+ Assert.NotNull(result["ArtistItems"]);
+ Assert.NotNull(result["AlbumArtist"]);
+ Assert.NotNull(result["AlbumArtists"]);
+
+ // Assert - Parent references
+ Assert.NotNull(result["ParentLogoItemId"]);
+ Assert.NotNull(result["ParentBackdropItemId"]);
+ Assert.NotNull(result["ParentLogoImageTag"]);
+ }
+
+ [Fact]
+ public void Artist_Response_Should_Have_All_Required_Fields()
+ {
+ // Arrange
+ var artist = new Artist
+ {
+ Id = "artist-id",
+ Name = "Test Artist",
+ AlbumCount = 5,
+ IsLocal = false,
+ ExternalProvider = "Deezer"
+ };
+
+ // Act
+ var result = _builder.ConvertArtistToJellyfinItem(artist);
+
+ // Assert
+ Assert.NotNull(result["Name"]);
+ Assert.NotNull(result["ServerId"]);
+ Assert.NotNull(result["Id"]);
+ Assert.NotNull(result["Type"]);
+ Assert.Equal("MusicArtist", result["Type"]);
+ Assert.True((bool)result["IsFolder"]!);
+ Assert.NotNull(result["MediaType"]);
+ Assert.Equal("Unknown", result["MediaType"]);
+
+ // Assert - Genres (empty array for artists)
+ Assert.NotNull(result["Genres"]);
+ Assert.IsType(result["Genres"]);
+ Assert.NotNull(result["GenreItems"]);
+
+ // Assert - Album count
+ Assert.NotNull(result["AlbumCount"]);
+ Assert.Equal(5, result["AlbumCount"]);
+
+ // Assert - RunTimeTicks
+ Assert.NotNull(result["RunTimeTicks"]);
+ Assert.Equal(0, result["RunTimeTicks"]);
+ }
+
+ [Fact]
+ public void All_Entities_Should_Have_UserData_With_ItemId()
+ {
+ // Arrange
+ var song = new Song { Id = "song-id", Title = "Test", Artist = "Test" };
+ var album = new Album { Id = "album-id", Title = "Test", Artist = "Test" };
+ var artist = new Artist { Id = "artist-id", Name = "Test" };
+
+ // Act
+ var songResult = _builder.ConvertSongToJellyfinItem(song);
+ var albumResult = _builder.ConvertAlbumToJellyfinItem(album);
+ var artistResult = _builder.ConvertArtistToJellyfinItem(artist);
+
+ // Assert
+ var songUserData = songResult["UserData"] as Dictionary;
+ Assert.NotNull(songUserData);
+ Assert.Contains("ItemId", songUserData.Keys);
+ Assert.Equal("song-id", songUserData["ItemId"]);
+
+ var albumUserData = albumResult["UserData"] as Dictionary;
+ Assert.NotNull(albumUserData);
+ Assert.Contains("ItemId", albumUserData.Keys);
+ Assert.Equal("album-id", albumUserData["ItemId"]);
+
+ var artistUserData = artistResult["UserData"] as Dictionary;
+ Assert.NotNull(artistUserData);
+ Assert.Contains("ItemId", artistUserData.Keys);
+ Assert.Equal("artist-id", artistUserData["ItemId"]);
+ }
+}
diff --git a/allstarr/Controllers/AdminController.cs b/allstarr/Controllers/AdminController.cs
new file mode 100644
index 0000000..b96b547
--- /dev/null
+++ b/allstarr/Controllers/AdminController.cs
@@ -0,0 +1,2989 @@
+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.
+///
+[ApiController]
+[Route("api/admin")]
+[ServiceFilter(typeof(AdminPortFilter))]
+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 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 deezerSettings,
+ IOptions qobuzSettings,
+ IOptions squidWtfSettings,
+ IOptions musicBrainzSettings,
+ SpotifyApiClient spotifyClient,
+ SpotifyPlaylistFetcher playlistFetcher,
+ RedisCacheService cache,
+ IHttpClientFactory httpClientFactory,
+ IServiceProvider serviceProvider,
+ SpotifyTrackMatchingService? matchingService = null)
+ {
+ _logger = logger;
+ _configuration = configuration;
+ _environment = environment;
+ _spotifyApiSettings = spotifyApiSettings.Value;
+ _spotifyImportSettings = spotifyImportSettings.Value;
+ _jellyfinSettings = jellyfinSettings.Value;
+ _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";
+
+ _logger.LogInformation("Admin controller initialized. .env path: {EnvFilePath}", _envFilePath);
+ }
+
+ 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();
+ }
+
+ ///
+ /// Helper method to safely check if a dynamic cache result has a value
+ /// Handles the case where JsonElement cannot be compared to null directly
+ ///
+ 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,
+ syncTime = $"{_spotifyImportSettings.SyncStartHour:D2}:{_spotifyImportSettings.SyncStartMinute:D2}",
+ syncWindowHours = _spotifyImportSettings.SyncWindowHours,
+ playlistCount = _spotifyImportSettings.Playlists.Count
+ },
+ deezer = new
+ {
+ hasArl = !string.IsNullOrEmpty(_deezerSettings.Arl),
+ quality = _deezerSettings.Quality ?? "FLAC"
+ },
+ qobuz = new
+ {
+ hasToken = !string.IsNullOrEmpty(_qobuzSettings.UserAuthToken),
+ quality = _qobuzSettings.Quality ?? "FLAC"
+ },
+ squidWtf = new
+ {
+ quality = _squidWtfSettings.Quality ?? "LOSSLESS"
+ }
+ });
+ }
+
+ ///
+ /// 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()
+ {
+ var playlists = new List