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)
+
+
+
+- [Musiver](https://music.aqzscn.cn/en/) (Android/iOS/Windows/Android)
+
+
+
+- [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.
+
+
+
+
+#### 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