Compare commits

...

1 Commits

67 changed files with 10295 additions and 4403 deletions
+58
View File
@@ -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
+184
View File
@@ -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
+49
View File
@@ -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)
<img width="1691" height="1128" alt="image" src="https://github.com/user-attachments/assets/c602f71c-c4dd-49a9-b533-1558e24a9f45" />
- [Musiver](https://music.aqzscn.cn/en/) (Android/iOS/Windows/Android)
<img width="523" height="1025" alt="image" src="https://github.com/user-attachments/assets/135e2721-5fd7-482f-bb06-b0736003cfe7" />
- [Finamp](https://github.com/jmshrv/finamp) (Android/iOS)
_Working on getting more currently_
## 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/)
+315
View File
@@ -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.
<img width="1649" height="3764" alt="image" src="https://github.com/user-attachments/assets/a4d3d79c-7741-427f-8c01-ffc90f3a579b" />
#### 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.
+267
View File
@@ -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<T> 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
+25 -589
View File
@@ -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,7 @@ There's an environment variable to modify this.
**Recommended workflow**: Use the `sp_dc` cookie method alongside the [Spotify Import Plugin](https://github.com/Viperinius/jellyfin-plugin-spotify-import?tab=readme-ov-file).
### Nginx Proxy Setup (Required)
### Nginx Proxy Setup (Optional)
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 +133,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 +182,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 +197,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,261 +263,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.
## 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.
<img width="1649" height="3764" alt="image" src="https://github.com/user-attachments/assets/a4d3d79c-7741-427f-8c01-ffc90f3a579b" />
#### 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).
## Manual Installation
@@ -574,338 +330,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<T> 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
+2 -2
View File
@@ -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]
+214
View File
@@ -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<ILogger<EnvMigrationService>> _mockLogger;
private readonly string _testEnvPath;
public EnvMigrationServiceTests()
{
_mockLogger = new Mock<ILogger<EnvMigrationService>>();
_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<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("No .env file found")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
// Helper class to allow testing with custom path
private class TestEnvMigrationService : EnvMigrationService
{
private readonly string _customPath;
public TestEnvMigrationService(ILogger<EnvMigrationService> 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);
}
}
}
@@ -0,0 +1,148 @@
using Xunit;
using allstarr.Services.Admin;
namespace allstarr.Tests;
/// <summary>
/// Tests for environment variable parsing edge cases
/// Ensures Docker Compose .env file parsing works correctly with special characters
/// </summary>
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<pass>", "test<pass>")]
[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);
}
}
+163
View File
@@ -0,0 +1,163 @@
using Xunit;
using allstarr.Services.Admin;
namespace allstarr.Tests;
/// <summary>
/// Tests for input validation and sanitization
/// Ensures user inputs are properly validated and don't cause security issues
/// </summary>
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>", "&lt;script&gt;alert(1)&lt;/script&gt;")]
[InlineData("Normal text", "Normal text")]
[InlineData("Text with <tags>", "Text with &lt;tags&gt;")]
[InlineData("Text & more", "Text &amp; more")]
[InlineData("Text \"quoted\"", "Text &quot;quoted&quot;")]
[InlineData("Text 'quoted'", "Text &#39;quoted&#39;")]
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);
}
}
+5 -7
View File
@@ -116,15 +116,13 @@ public class JavaScriptSyntaxTests
// 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);
// Remove strings and comments to avoid false positives
var cleanedContent = RemoveStringsAndComments(content);
// 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);
var openParens = cleanedContent.Count(c => c == '(');
var closeParens = cleanedContent.Count(c => c == ')');
Assert.Equal(openParens, closeParens);
Assert.True(isValid, $"JavaScript syntax validation failed: {error}");
}
private bool ValidateJavaScriptSyntax(string filePath, out string error)
+2 -2
View File
@@ -277,7 +277,7 @@ public class JellyfinModelMapperTests
// Arrange
var playlists = new List<ExternalPlaylist>
{
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]
@@ -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"
+282
View File
@@ -0,0 +1,282 @@
using Xunit;
using System.Security.Cryptography;
using System.Text;
namespace allstarr.Tests;
/// <summary>
/// Tests for Last.fm API signature generation
/// Ensures signatures are generated correctly with uppercase hex format
/// </summary>
public class LastFmSignatureTests
{
// Replicate the signature generation logic from ScrobblingAdminController
private static string GenerateSignature(Dictionary<string, string> 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<string, string>
{
["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<string, string>
{
["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<string, string>
{
["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<string, string>
{
["api_key"] = "testkey",
["method"] = "auth.getMobileSession",
["username"] = "testuser",
["password"] = "testpass"
};
var parameters2 = new Dictionary<string, string>
{
["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<string, string>
{
["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<string, string>
{
["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<string, string>
{
["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<string, string>
{
["api_key"] = "testkey",
["method"] = "auth.getMobileSession",
["username"] = "testuser",
["password"] = "pass word"
};
var parameters2 = new Dictionary<string, string>
{
["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<string, string>
{
["api_key"] = "testkey",
["method"] = "auth.getMobileSession",
["username"] = "testuser",
["password"] = "Password"
};
var parameters2 = new Dictionary<string, string>
{
["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<string, string>
{
["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);
}
}
+13 -13
View File
@@ -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<ArgumentException>(() => 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<ArgumentException>(() => 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]
+2 -2
View File
@@ -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);
}
@@ -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<IOptions<ScrobblingSettings>> _mockSettings;
private readonly Mock<IConfiguration> _mockConfiguration;
private readonly Mock<ILogger<ScrobblingAdminController>> _mockLogger;
private readonly Mock<IHttpClientFactory> _mockHttpClientFactory;
private readonly ScrobblingAdminController _controller;
public ScrobblingAdminControllerTests()
{
_mockSettings = new Mock<IOptions<ScrobblingSettings>>();
_mockConfiguration = new Mock<IConfiguration>();
_mockLogger = new Mock<ILogger<ScrobblingAdminController>>();
_mockHttpClientFactory = new Mock<IHttpClientFactory>();
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&ampersand", "pass&ampersand")]
[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);
}
}
+189
View File
@@ -0,0 +1,189 @@
using Xunit;
using allstarr.Services.Scrobbling;
using allstarr.Models.Scrobbling;
namespace allstarr.Tests;
/// <summary>
/// Tests for ScrobblingHelper utility functions
/// </summary>
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);
}
}
@@ -40,9 +40,9 @@ public class SquidWTFMetadataServiceTests
_apiUrls = new List<string>
{
"https://squid.wtf",
"https://mirror1.squid.wtf",
"https://mirror2.squid.wtf"
"https://test1.example.com",
"https://test2.example.com",
"https://test3.example.com"
};
var httpClient = new System.Net.Http.HttpClient();
@@ -321,10 +321,10 @@ public class SquidWTFMetadataServiceTests
// Arrange
var multipleUrls = new List<string>
{
"https://primary.squid.wtf",
"https://backup1.squid.wtf",
"https://backup2.squid.wtf",
"https://backup3.squid.wtf"
"https://test-primary.example.com",
"https://test-backup1.example.com",
"https://test-backup2.example.com",
"https://test-backup3.example.com"
};
// Act
+130 -4
View File
@@ -25,6 +25,7 @@ public class ConfigController : ControllerBase
private readonly SquidWTFSettings _squidWtfSettings;
private readonly MusicBrainzSettings _musicBrainzSettings;
private readonly SpotifyImportSettings _spotifyImportSettings;
private readonly ScrobblingSettings _scrobblingSettings;
private readonly AdminHelperService _helperService;
private readonly RedisCacheService _cache;
private const string CacheDirectory = "/app/cache/spotify";
@@ -40,6 +41,7 @@ public class ConfigController : ControllerBase
IOptions<SquidWTFSettings> squidWtfSettings,
IOptions<MusicBrainzSettings> musicBrainzSettings,
IOptions<SpotifyImportSettings> spotifyImportSettings,
IOptions<ScrobblingSettings> scrobblingSettings,
AdminHelperService helperService,
RedisCacheService cache)
{
@@ -53,12 +55,13 @@ public class ConfigController : ControllerBase
_squidWtfSettings = squidWtfSettings.Value;
_musicBrainzSettings = musicBrainzSettings.Value;
_spotifyImportSettings = spotifyImportSettings.Value;
_scrobblingSettings = scrobblingSettings.Value;
_helperService = helperService;
_cache = cache;
}
[HttpGet("config")]
public IActionResult GetConfig()
public async Task<IActionResult> GetConfig()
{
return Ok(new
{
@@ -68,6 +71,10 @@ public class ConfigController : ControllerBase
enableExternalPlaylists = _configuration.GetValue<bool>("EnableExternalPlaylists", false),
playlistsDirectory = _configuration.GetValue<string>("PlaylistsDirectory") ?? "(not set)",
redisEnabled = _configuration.GetValue<bool>("Redis:Enabled", false),
debug = new
{
logAllRequests = _configuration.GetValue<bool>("Debug:LogAllRequests", false)
},
spotifyApi = new
{
enabled = _spotifyApiSettings.Enabled,
@@ -128,10 +135,119 @@ public class ConfigController : ControllerBase
password = AdminHelperService.MaskValue(_musicBrainzSettings.Password),
baseUrl = _musicBrainzSettings.BaseUrl,
rateLimitMs = _musicBrainzSettings.RateLimitMs
}
},
scrobbling = await GetScrobblingSettingsFromEnvAsync()
});
}
/// <summary>
/// Read scrobbling settings directly from .env file for real-time updates
/// </summary>
private async Task<object> GetScrobblingSettingsFromEnvAsync()
{
try
{
var envPath = _helperService.GetEnvFilePath();
if (!System.IO.File.Exists(envPath))
{
// Fallback to IOptions if .env doesn't exist
return new
{
enabled = _scrobblingSettings.Enabled,
lastFm = new
{
enabled = _scrobblingSettings.LastFm.Enabled,
apiKey = AdminHelperService.MaskValue(_scrobblingSettings.LastFm.ApiKey, showLast: 8),
sharedSecret = AdminHelperService.MaskValue(_scrobblingSettings.LastFm.SharedSecret, showLast: 8),
sessionKey = AdminHelperService.MaskValue(_scrobblingSettings.LastFm.SessionKey, showLast: 8),
username = _scrobblingSettings.LastFm.Username ?? "(not set)",
password = AdminHelperService.MaskValue(_scrobblingSettings.LastFm.Password, showLast: 0)
},
listenBrainz = new
{
enabled = _scrobblingSettings.ListenBrainz.Enabled,
userToken = AdminHelperService.MaskValue(_scrobblingSettings.ListenBrainz.UserToken, showLast: 8)
}
};
}
var lines = await System.IO.File.ReadAllLinesAsync(envPath);
var envVars = new Dictionary<string, string>();
foreach (var line in lines)
{
if (AdminHelperService.ShouldSkipEnvLine(line))
continue;
var (key, value) = AdminHelperService.ParseEnvLine(line);
if (!string.IsNullOrEmpty(key))
{
envVars[key] = value;
}
}
return new
{
enabled = envVars.TryGetValue("SCROBBLING_ENABLED", out var scrobblingEnabled)
? scrobblingEnabled.Equals("true", StringComparison.OrdinalIgnoreCase)
: _scrobblingSettings.Enabled,
lastFm = new
{
enabled = envVars.TryGetValue("SCROBBLING_LASTFM_ENABLED", out var lastFmEnabled)
? lastFmEnabled.Equals("true", StringComparison.OrdinalIgnoreCase)
: _scrobblingSettings.LastFm.Enabled,
apiKey = envVars.TryGetValue("SCROBBLING_LASTFM_API_KEY", out var apiKey)
? AdminHelperService.MaskValue(apiKey, showLast: 8)
: AdminHelperService.MaskValue(_scrobblingSettings.LastFm.ApiKey, showLast: 8),
sharedSecret = envVars.TryGetValue("SCROBBLING_LASTFM_SHARED_SECRET", out var sharedSecret)
? AdminHelperService.MaskValue(sharedSecret, showLast: 8)
: AdminHelperService.MaskValue(_scrobblingSettings.LastFm.SharedSecret, showLast: 8),
sessionKey = envVars.TryGetValue("SCROBBLING_LASTFM_SESSION_KEY", out var sessionKey)
? AdminHelperService.MaskValue(sessionKey, showLast: 8)
: AdminHelperService.MaskValue(_scrobblingSettings.LastFm.SessionKey, showLast: 8),
username = envVars.TryGetValue("SCROBBLING_LASTFM_USERNAME", out var username)
? (string.IsNullOrEmpty(username) ? "(not set)" : username)
: (_scrobblingSettings.LastFm.Username ?? "(not set)"),
password = envVars.TryGetValue("SCROBBLING_LASTFM_PASSWORD", out var password)
? AdminHelperService.MaskValue(password, showLast: 0)
: AdminHelperService.MaskValue(_scrobblingSettings.LastFm.Password, showLast: 0)
},
listenBrainz = new
{
enabled = envVars.TryGetValue("SCROBBLING_LISTENBRAINZ_ENABLED", out var lbEnabled)
? lbEnabled.Equals("true", StringComparison.OrdinalIgnoreCase)
: _scrobblingSettings.ListenBrainz.Enabled,
userToken = envVars.TryGetValue("SCROBBLING_LISTENBRAINZ_USER_TOKEN", out var userToken)
? AdminHelperService.MaskValue(userToken, showLast: 8)
: AdminHelperService.MaskValue(_scrobblingSettings.ListenBrainz.UserToken, showLast: 8)
}
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to read scrobbling settings from .env, falling back to IOptions");
// Fallback to IOptions
return new
{
enabled = _scrobblingSettings.Enabled,
lastFm = new
{
enabled = _scrobblingSettings.LastFm.Enabled,
apiKey = AdminHelperService.MaskValue(_scrobblingSettings.LastFm.ApiKey, showLast: 8),
sharedSecret = AdminHelperService.MaskValue(_scrobblingSettings.LastFm.SharedSecret, showLast: 8),
sessionKey = AdminHelperService.MaskValue(_scrobblingSettings.LastFm.SessionKey, showLast: 8),
username = _scrobblingSettings.LastFm.Username ?? "(not set)",
password = AdminHelperService.MaskValue(_scrobblingSettings.LastFm.Password, showLast: 0)
},
listenBrainz = new
{
enabled = _scrobblingSettings.ListenBrainz.Enabled,
userToken = AdminHelperService.MaskValue(_scrobblingSettings.ListenBrainz.UserToken, showLast: 8)
}
};
}
}
/// <summary>
/// Update configuration by modifying .env file
/// </summary>
@@ -169,6 +285,13 @@ public class ConfigController : ControllerBase
{
var key = line[..eqIndex].Trim();
var value = line[(eqIndex + 1)..].Trim();
// Remove surrounding quotes if present (for proper re-quoting)
if (value.StartsWith("\"") && value.EndsWith("\"") && value.Length >= 2)
{
value = value[1..^1];
}
envContent[key] = value;
}
}
@@ -186,10 +309,13 @@ public class ConfigController : ControllerBase
return BadRequest(new { error = $"Invalid environment variable key: {key}" });
}
// IMPORTANT: Docker Compose does NOT need quotes in .env files
// It handles special characters correctly without them
// When quotes are used, they become part of the value itself
envContent[key] = value;
appliedUpdates.Add(key);
_logger.LogInformation(" Setting {Key} = {Value}", key,
key.Contains("COOKIE") || key.Contains("TOKEN") || key.Contains("KEY") || key.Contains("ARL")
key.Contains("COOKIE") || key.Contains("TOKEN") || key.Contains("KEY") || key.Contains("ARL") || key.Contains("PASSWORD")
? "***" + (value.Length > 8 ? value[^8..] : "")
: value);
@@ -204,7 +330,7 @@ public class ConfigController : ControllerBase
}
}
// Write back to .env file
// Write back to .env file (no quoting needed - Docker Compose handles special chars)
var newContent = string.Join("\n", envContent.Select(kv => $"{kv.Key}={kv.Value}"));
await System.IO.File.WriteAllTextAsync(_helperService.GetEnvFilePath(), newContent + "\n");
@@ -54,7 +54,6 @@ public class DiagnosticsController : ControllerBase
var encodedUrls = new[]
{
"aHR0cHM6Ly90cml0b24uc3F1aWQud3Rm",
"aHR0cHM6Ly90aWRhbC1hcGkuYmluaW11bS5vcmc=",
"aHR0cHM6Ly90aWRhbC5raW5vcGx1cy5vbmxpbmU=",
"aHR0cHM6Ly9oaWZpLXR3by5zcG90aXNhdmVyLm5ldA==",
"aHR0cHM6Ly9oaWZpLW9uZS5zcG90aXNhdmVyLm5ldA==",
@@ -62,7 +61,12 @@ public class DiagnosticsController : ControllerBase
"aHR0cDovL2h1bmQucXFkbC5zaXRl",
"aHR0cHM6Ly9rYXR6ZS5xcWRsLnNpdGU=",
"aHR0cHM6Ly92b2dlbC5xcWRsLnNpdGU=",
"aHR0cHM6Ly9tYXVzLnFxZGwuc2l0ZQ=="
"aHR0cHM6Ly9tYXVzLnFxZGwuc2l0ZQ==",
"aHR0cHM6Ly9ldS1jZW50cmFsLm1vbm9jaHJvbWUudGY=",
"aHR0cHM6Ly91cy13ZXN0Lm1vbm9jaHJvbWUudGY=",
"aHR0cHM6Ly9hcnJhbi5tb25vY2hyb21lLnRm",
"aHR0cHM6Ly9hcGkubW9ub2Nocm9tZS50Zg==",
"aHR0cHM6Ly9odW5kLnFxZGwuc2l0ZQ=="
};
return encodedUrls.Select(encoded => System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(encoded))).ToList();
}
+54 -16
View File
@@ -27,12 +27,8 @@ public class DownloadsController : ControllerBase
{
var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
_logger.LogDebug("📂 Checking kept folder: {Path}", keptPath);
_logger.LogInformation("📂 Directory exists: {Exists}", Directory.Exists(keptPath));
if (!Directory.Exists(keptPath))
{
_logger.LogWarning("Kept folder does not exist: {Path}", keptPath);
return Ok(new { files = new List<object>(), totalSize = 0, count = 0 });
}
@@ -46,11 +42,8 @@ public class DownloadsController : ControllerBase
.Where(f => audioExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
.ToList();
_logger.LogDebug("📂 Found {Count} audio files in kept folder", allFiles.Count);
foreach (var filePath in allFiles)
{
_logger.LogDebug("📂 Processing file: {Path}", filePath);
var fileInfo = new FileInfo(filePath);
var relativePath = Path.GetRelativePath(keptPath, filePath);
@@ -77,8 +70,6 @@ public class DownloadsController : ControllerBase
totalSize += fileInfo.Length;
}
_logger.LogDebug("📂 Returning {Count} kept files, total size: {Size}", files.Count, AdminHelperService.FormatFileSize(totalSize));
return Ok(new
{
files = files.OrderBy(f => ((dynamic)f).artist).ThenBy(f => ((dynamic)f).album).ThenBy(f => ((dynamic)f).fileName),
@@ -111,26 +102,21 @@ public class DownloadsController : ControllerBase
var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
var fullPath = Path.Combine(keptPath, path);
_logger.LogDebug("🗑️ Delete request for: {Path}", fullPath);
// Security: Ensure the path is within the kept directory
var normalizedFullPath = Path.GetFullPath(fullPath);
var normalizedKeptPath = Path.GetFullPath(keptPath);
if (!normalizedFullPath.StartsWith(normalizedKeptPath))
{
_logger.LogWarning("🗑️ Invalid path (outside kept folder): {Path}", normalizedFullPath);
return BadRequest(new { error = "Invalid path" });
}
if (!System.IO.File.Exists(fullPath))
{
_logger.LogWarning("🗑️ File not found: {Path}", fullPath);
return NotFound(new { error = "File not found" });
}
System.IO.File.Delete(fullPath);
_logger.LogDebug("🗑️ Deleted file: {Path}", fullPath);
// Clean up empty directories (Album folder, then Artist folder if empty)
var directory = Path.GetDirectoryName(fullPath);
@@ -139,12 +125,10 @@ public class DownloadsController : ControllerBase
if (Directory.Exists(directory) && !Directory.EnumerateFileSystemEntries(directory).Any())
{
Directory.Delete(directory);
_logger.LogInformation("🗑️ Deleted empty directory: {Dir}", directory);
directory = Path.GetDirectoryName(directory);
}
else
{
_logger.LogInformation("🗑️ Directory not empty or doesn't exist, stopping cleanup: {Dir}", directory);
break;
}
}
@@ -201,6 +185,60 @@ public class DownloadsController : ControllerBase
}
}
/// <summary>
/// GET /api/admin/downloads/all
/// Downloads all kept files as a zip archive
/// </summary>
[HttpGet("downloads/all")]
public IActionResult DownloadAllFiles()
{
try
{
var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
if (!Directory.Exists(keptPath))
{
return NotFound(new { error = "No kept files found" });
}
var audioExtensions = new[] { ".flac", ".mp3", ".m4a", ".opus" };
var allFiles = Directory.GetFiles(keptPath, "*.*", SearchOption.AllDirectories)
.Where(f => audioExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
.ToList();
if (allFiles.Count == 0)
{
return NotFound(new { error = "No audio files found in kept folder" });
}
_logger.LogInformation("📦 Creating zip archive with {Count} files", allFiles.Count);
// Create zip in memory
var memoryStream = new MemoryStream();
using (var archive = new System.IO.Compression.ZipArchive(memoryStream, System.IO.Compression.ZipArchiveMode.Create, true))
{
foreach (var filePath in allFiles)
{
var relativePath = Path.GetRelativePath(keptPath, filePath);
var entry = archive.CreateEntry(relativePath, System.IO.Compression.CompressionLevel.NoCompression);
using var entryStream = entry.Open();
using var fileStream = System.IO.File.OpenRead(filePath);
fileStream.CopyTo(entryStream);
}
}
memoryStream.Position = 0;
var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
return File(memoryStream, "application/zip", $"allstarr_kept_{timestamp}.zip");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create zip archive");
return StatusCode(500, new { error = "Failed to create zip archive" });
}
}
/// <summary>
/// Gets all Spotify track mappings (paginated)
/// </summary>
+356
View File
@@ -0,0 +1,356 @@
using System.Text.Json;
using allstarr.Models.Domain;
using allstarr.Models.Spotify;
using allstarr.Services.Common;
using Microsoft.AspNetCore.Mvc;
namespace allstarr.Controllers;
public partial class JellyfinController
{
#region Helpers
/// <summary>
/// Helper to handle proxy responses with proper status code handling.
/// </summary>
private IActionResult HandleProxyResponse(JsonDocument? result, int statusCode, object? fallbackValue = null)
{
if (result != null)
{
return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText()));
}
// Handle error status codes
if (statusCode == 401)
{
return Unauthorized();
}
else if (statusCode == 403)
{
return Forbid();
}
else if (statusCode == 404)
{
return NotFound();
}
else if (statusCode >= 400)
{
return StatusCode(statusCode);
}
// Success with no body - return fallback or empty
if (fallbackValue != null)
{
return new JsonResult(fallbackValue);
}
return NoContent();
}
/// <summary>
/// Updates ChildCount for Spotify playlists in the response to show total tracks (local + matched).
/// </summary>
private async Task<JsonDocument> UpdateSpotifyPlaylistCounts(JsonDocument response)
{
try
{
if (!response.RootElement.TryGetProperty("Items", out var items))
{
return response;
}
var itemsArray = items.EnumerateArray().ToList();
var modified = false;
var updatedItems = new List<Dictionary<string, object>>();
_logger.LogDebug("Checking {Count} items for Spotify playlists", itemsArray.Count);
foreach (var item in itemsArray)
{
var itemDict = JsonSerializer.Deserialize<Dictionary<string, object>>(item.GetRawText());
if (itemDict == null)
{
continue;
}
// Check if this is a Spotify playlist
if (item.TryGetProperty("Id", out var idProp))
{
var playlistId = idProp.GetString();
_logger.LogDebug("Checking item with ID: {Id}", playlistId);
if (!string.IsNullOrEmpty(playlistId) && _spotifySettings.IsSpotifyPlaylist(playlistId))
{
_logger.LogInformation("Found Spotify playlist: {Id}", playlistId);
// This is a Spotify playlist - get the actual track count
var playlistConfig = _spotifySettings.GetPlaylistByJellyfinId(playlistId);
if (playlistConfig != null)
{
_logger.LogInformation(
"Found playlist config for Jellyfin ID {JellyfinId}: {Name} (Spotify ID: {SpotifyId})",
playlistId, playlistConfig.Name, playlistConfig.Id);
var playlistName = playlistConfig.Name;
// Get matched external tracks (tracks that were successfully downloaded/matched)
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlistName);
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
_logger.LogInformation("Cache lookup for {Key}: {Count} matched tracks",
matchedTracksKey, matchedTracks?.Count ?? 0);
// Fallback to legacy cache format
if (matchedTracks == null || matchedTracks.Count == 0)
{
var legacyKey = $"spotify:matched:{playlistName}";
var legacySongs = await _cache.GetAsync<List<Song>>(legacyKey);
if (legacySongs != null && legacySongs.Count > 0)
{
matchedTracks = legacySongs.Select((s, i) => new MatchedTrack
{
Position = i,
MatchedSong = s
}).ToList();
_logger.LogDebug("Loaded {Count} tracks from legacy cache", matchedTracks.Count);
}
}
// Try loading from file cache if Redis is empty
if (matchedTracks == null || matchedTracks.Count == 0)
{
var fileItems = await LoadPlaylistItemsFromFile(playlistName);
if (fileItems != null && fileItems.Count > 0)
{
_logger.LogDebug(
"💿 Loaded {Count} playlist items from file cache for count update",
fileItems.Count);
// Use file cache count directly
itemDict["ChildCount"] = fileItems.Count;
modified = true;
}
}
// Only fetch from Jellyfin if we didn't get count from file cache
if (!itemDict.ContainsKey("ChildCount") ||
(itemDict["ChildCount"] is JsonElement childCountElement &&
childCountElement.GetInt32() == 0) ||
(itemDict["ChildCount"] is int childCountInt && childCountInt == 0))
{
// Get local tracks count from Jellyfin
var localTracksCount = 0;
try
{
// Include UserId parameter to avoid 401 Unauthorized
var userId = _settings.UserId;
var playlistItemsUrl = $"Playlists/{playlistId}/Items";
var queryParams = new Dictionary<string, string>();
if (!string.IsNullOrEmpty(userId))
{
queryParams["UserId"] = userId;
}
var (localTracksResponse, _) = await _proxyService.GetJsonAsyncInternal(
playlistItemsUrl,
queryParams);
if (localTracksResponse != null &&
localTracksResponse.RootElement.TryGetProperty("Items", out var localItems))
{
localTracksCount = localItems.GetArrayLength();
_logger.LogDebug("Found {Count} total items in Jellyfin playlist {Name}",
localTracksCount, playlistName);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get local tracks count for {Name}", playlistName);
}
// Count external matched tracks (not local)
var externalMatchedCount = 0;
if (matchedTracks != null)
{
externalMatchedCount = matchedTracks.Count(t =>
t.MatchedSong != null && !t.MatchedSong.IsLocal);
}
// Total available tracks = local tracks in Jellyfin + external matched tracks
// This represents what users will actually hear when playing the playlist
var totalAvailableCount = localTracksCount + externalMatchedCount;
if (totalAvailableCount > 0)
{
// Update ChildCount to show actual available tracks
itemDict["ChildCount"] = totalAvailableCount;
modified = true;
_logger.LogDebug(
"✓ Updated ChildCount for Spotify playlist {Name} to {Total} ({Local} local + {External} external)",
playlistName, totalAvailableCount, localTracksCount, externalMatchedCount);
}
else
{
_logger.LogWarning(
"No tracks found for {Name} ({Local} local + {External} external = {Total} total)",
playlistName, localTracksCount, externalMatchedCount, totalAvailableCount);
}
}
}
else
{
_logger.LogWarning(
"No playlist config found for Jellyfin ID {JellyfinId} - skipping count update",
playlistId);
}
}
}
updatedItems.Add(itemDict);
}
if (!modified)
{
_logger.LogInformation("No Spotify playlists found to update");
return response;
}
_logger.LogDebug("Modified {Count} Spotify playlists, rebuilding response",
updatedItems.Count(i => i.ContainsKey("ChildCount")));
// Rebuild the response with updated items
var responseDict =
JsonSerializer.Deserialize<Dictionary<string, object>>(response.RootElement.GetRawText());
if (responseDict != null)
{
responseDict["Items"] = updatedItems;
var updatedJson = JsonSerializer.Serialize(responseDict);
return JsonDocument.Parse(updatedJson);
}
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update Spotify playlist counts");
return response;
}
}
/// <summary>
/// Logs endpoint usage to a file for analysis.
/// Creates a CSV file with timestamp, method, path, and query string.
/// </summary>
private async Task LogEndpointUsageAsync(string path, string method)
{
try
{
var logDir = "/app/cache/endpoint-usage";
Directory.CreateDirectory(logDir);
var logFile = Path.Combine(logDir, "endpoints.csv");
var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss");
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : "";
// Sanitize path and query for CSV (remove commas, quotes, newlines)
var sanitizedPath = path.Replace(",", ";").Replace("\"", "'").Replace("\n", " ").Replace("\r", " ");
var sanitizedQuery = queryString.Replace(",", ";").Replace("\"", "'").Replace("\n", " ").Replace("\r", " ");
var logLine = $"{timestamp},{method},{sanitizedPath},{sanitizedQuery}\n";
// Append to file (thread-safe)
await System.IO.File.AppendAllTextAsync(logFile, logLine);
}
catch (Exception ex)
{
// Don't let logging failures break the request
_logger.LogError(ex, "Failed to log endpoint usage");
}
}
private static string[]? ParseItemTypes(string? includeItemTypes)
{
if (string.IsNullOrWhiteSpace(includeItemTypes))
{
return null;
}
return includeItemTypes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
private static string GetContentType(string filePath)
{
var extension = Path.GetExtension(filePath).ToLowerInvariant();
return extension switch
{
".mp3" => "audio/mpeg",
".flac" => "audio/flac",
".ogg" => "audio/ogg",
".m4a" => "audio/mp4",
".wav" => "audio/wav",
".aac" => "audio/aac",
_ => "audio/mpeg"
};
}
/// <summary>
/// Scores search results based on fuzzy matching against the query.
/// Returns items with their relevance scores.
/// External results get a small boost to prioritize the larger catalog.
/// </summary>
private static List<(T Item, int Score)> ScoreSearchResults<T>(
string query,
List<T> items,
Func<T, string> titleField,
Func<T, string?> artistField,
Func<T, string?> albumField,
bool isExternal = false)
{
return items.Select(item =>
{
var title = titleField(item) ?? "";
var artist = artistField(item) ?? "";
var album = albumField(item) ?? "";
// Token-based fuzzy matching: split query and fields into words
var queryTokens = query.ToLower()
.Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries)
.ToList();
var fieldText = $"{title} {artist} {album}".ToLower();
var fieldTokens = fieldText
.Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries)
.ToList();
if (queryTokens.Count == 0) return (item, 0);
// Count how many query tokens match field tokens (with fuzzy tolerance)
var matchedTokens = 0;
foreach (var queryToken in queryTokens)
{
// Check if any field token matches this query token
var hasMatch = fieldTokens.Any(fieldToken =>
{
// Exact match or substring match
if (fieldToken.Contains(queryToken) || queryToken.Contains(fieldToken))
return true;
// Fuzzy match with Levenshtein distance
var similarity = FuzzyMatcher.CalculateSimilarity(queryToken, fieldToken);
return similarity >= 70; // 70% similarity threshold for individual words
});
if (hasMatch) matchedTokens++;
}
// Score = percentage of query tokens that matched
var baseScore = (matchedTokens * 100) / queryTokens.Count;
// Give external results a small boost (+5 points) to prioritize the larger catalog
var finalScore = isExternal ? Math.Min(100, baseScore + 5) : baseScore;
return (item, finalScore);
}).ToList();
}
#endregion
}
@@ -0,0 +1,224 @@
using allstarr.Services.Common;
using Microsoft.AspNetCore.Mvc;
namespace allstarr.Controllers;
public partial class JellyfinController
{
#region Audio Streaming
/// <summary>
/// Downloads/streams audio. Works with local and external content.
/// </summary>
[HttpGet("Items/{itemId}/Download")]
[HttpGet("Items/{itemId}/File")]
public async Task<IActionResult> DownloadAudio(string itemId)
{
if (string.IsNullOrWhiteSpace(itemId))
{
return BadRequest(new { error = "Missing item ID" });
}
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
if (!isExternal)
{
// Build path for Jellyfin download/file endpoint
var endpoint = Request.Path.Value?.Contains("/File", StringComparison.OrdinalIgnoreCase) == true
? "File"
: "Download";
var fullPath = $"Items/{itemId}/{endpoint}";
if (Request.QueryString.HasValue)
{
fullPath = $"{fullPath}{Request.QueryString.Value}";
}
return await ProxyJellyfinStream(fullPath, itemId);
}
// Handle external content
return await StreamExternalContent(provider!, externalId!);
}
/// <summary>
/// Streams audio for a given item. Downloads on-demand for external content.
/// </summary>
[HttpGet("Audio/{itemId}/stream")]
[HttpGet("Audio/{itemId}/stream.{container}")]
public async Task<IActionResult> StreamAudio(string itemId, string? container = null)
{
if (string.IsNullOrWhiteSpace(itemId))
{
return BadRequest(new { error = "Missing item ID" });
}
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
if (!isExternal)
{
// Build path for Jellyfin stream
var fullPath = string.IsNullOrEmpty(container)
? $"Audio/{itemId}/stream"
: $"Audio/{itemId}/stream.{container}";
if (Request.QueryString.HasValue)
{
fullPath = $"{fullPath}{Request.QueryString.Value}";
}
return await ProxyJellyfinStream(fullPath, itemId);
}
// Handle external content
return await StreamExternalContent(provider!, externalId!);
}
/// <summary>
/// Proxies a stream from Jellyfin with proper header forwarding.
/// </summary>
private async Task<IActionResult> ProxyJellyfinStream(string path, string itemId)
{
var jellyfinUrl = $"{_settings.Url?.TrimEnd('/')}/{path}";
try
{
var request = new HttpRequestMessage(HttpMethod.Get, jellyfinUrl);
// Forward auth headers
AuthHeaderHelper.ForwardAuthHeaders(Request.Headers, request);
// Forward Range header for seeking
if (Request.Headers.TryGetValue("Range", out var range))
{
request.Headers.TryAddWithoutValidation("Range", range.ToString());
}
var response = await _proxyService.HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
if (!response.IsSuccessStatusCode)
{
_logger.LogError("Jellyfin stream failed: {StatusCode} for {ItemId}", response.StatusCode, itemId);
return StatusCode((int)response.StatusCode);
}
// Set response status and headers
Response.StatusCode = (int)response.StatusCode;
var contentType = response.Content.Headers.ContentType?.ToString() ?? "audio/mpeg";
// Forward caching headers for client-side caching
if (response.Headers.ETag != null)
{
Response.Headers["ETag"] = response.Headers.ETag.ToString();
}
if (response.Content.Headers.LastModified.HasValue)
{
Response.Headers["Last-Modified"] = response.Content.Headers.LastModified.Value.ToString("R");
}
if (response.Headers.CacheControl != null)
{
Response.Headers["Cache-Control"] = response.Headers.CacheControl.ToString();
}
// Forward range headers for seeking
if (response.Content.Headers.ContentRange != null)
{
Response.Headers["Content-Range"] = response.Content.Headers.ContentRange.ToString();
}
if (response.Headers.AcceptRanges != null)
{
Response.Headers["Accept-Ranges"] = string.Join(", ", response.Headers.AcceptRanges);
}
if (response.Content.Headers.ContentLength.HasValue)
{
Response.Headers["Content-Length"] = response.Content.Headers.ContentLength.Value.ToString();
}
var stream = await response.Content.ReadAsStreamAsync();
return File(stream, contentType);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to proxy stream from Jellyfin for {ItemId}", itemId);
return StatusCode(500, new { error = $"Streaming failed: {ex.Message}" });
}
}
/// <summary>
/// Streams external content, using cache if available or downloading on-demand.
/// </summary>
private async Task<IActionResult> StreamExternalContent(string provider, string externalId)
{
// Check for locally cached file
var localPath = await _localLibraryService.GetLocalPathForExternalSongAsync(provider, externalId);
if (localPath != null && System.IO.File.Exists(localPath))
{
// Update last write time for cache cleanup (extends cache lifetime)
try
{
System.IO.File.SetLastWriteTimeUtc(localPath, DateTime.UtcNow);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update last write time for {Path}", localPath);
}
var stream = System.IO.File.OpenRead(localPath);
return File(stream, GetContentType(localPath), enableRangeProcessing: true);
}
// Download and stream on-demand
try
{
var downloadStream = await _downloadService.DownloadAndStreamAsync(
provider,
externalId,
HttpContext.RequestAborted);
return File(downloadStream, "audio/mpeg", enableRangeProcessing: true);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to stream external song {Provider}:{ExternalId}", provider, externalId);
return StatusCode(500, new { error = $"Streaming failed: {ex.Message}" });
}
}
/// <summary>
/// Universal audio endpoint - handles transcoding, format negotiation, and adaptive streaming.
/// This is the primary endpoint used by Jellyfin Web and most clients.
/// </summary>
[HttpGet("Audio/{itemId}/universal")]
[HttpHead("Audio/{itemId}/universal")]
public async Task<IActionResult> UniversalAudio(string itemId)
{
if (string.IsNullOrWhiteSpace(itemId))
{
return BadRequest(new { error = "Missing item ID" });
}
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
if (!isExternal)
{
// For local content, proxy the universal endpoint with all query parameters
var fullPath = $"Audio/{itemId}/universal";
if (Request.QueryString.HasValue)
{
fullPath = $"{fullPath}{Request.QueryString.Value}";
}
return await ProxyJellyfinStream(fullPath, itemId);
}
// For external content, use simple streaming (no transcoding support yet)
return await StreamExternalContent(provider!, externalId!);
}
#endregion
}
@@ -0,0 +1,123 @@
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
namespace allstarr.Controllers;
public partial class JellyfinController
{
#region Authentication
/// <summary>
/// Authenticates a user by username and password.
/// This is the primary login endpoint for Jellyfin clients.
/// </summary>
[HttpPost("Users/AuthenticateByName")]
public async Task<IActionResult> AuthenticateByName()
{
try
{
// Enable buffering to allow multiple reads of the request body
Request.EnableBuffering();
// Read the request body
using var reader = new StreamReader(Request.Body, leaveOpen: true);
var body = await reader.ReadToEndAsync();
// Reset stream position
Request.Body.Position = 0;
_logger.LogDebug("Authentication request received");
// DO NOT log request body or detailed headers - contains password
// Forward to Jellyfin server with client headers - completely transparent proxy
var (result, statusCode) =
await _proxyService.PostJsonAsync("Users/AuthenticateByName", body, Request.Headers);
// Pass through Jellyfin's response exactly as-is (transparent proxy)
if (result != null)
{
var responseJson = result.RootElement.GetRawText();
// On successful auth, extract access token and post session capabilities in background
if (statusCode == 200)
{
_logger.LogInformation("Authentication successful");
// Extract access token from response for session capabilities
string? accessToken = null;
if (result.RootElement.TryGetProperty("AccessToken", out var tokenEl))
{
accessToken = tokenEl.GetString();
}
// Post session capabilities in background if we have a token
if (!string.IsNullOrEmpty(accessToken))
{
// Capture token in closure - don't use Request.Headers (will be disposed)
var token = accessToken;
_ = Task.Run(async () =>
{
try
{
_logger.LogDebug("🔧 Posting session capabilities after authentication");
// Build auth header with the new token
var authHeaders = new HeaderDictionary
{
["X-Emby-Token"] = token
};
var capabilities = new
{
PlayableMediaTypes = new[] { "Audio" },
SupportedCommands = Array.Empty<string>(),
SupportsMediaControl = false,
SupportsPersistentIdentifier = true,
SupportsSync = false
};
var capabilitiesJson = JsonSerializer.Serialize(capabilities);
var (capResult, capStatus) =
await _proxyService.PostJsonAsync("Sessions/Capabilities/Full", capabilitiesJson,
authHeaders);
if (capStatus == 204 || capStatus == 200)
{
_logger.LogDebug("✓ Session capabilities posted after auth ({StatusCode})",
capStatus);
}
else
{
_logger.LogDebug("⚠ Session capabilities returned {StatusCode} after auth",
capStatus);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to post session capabilities after auth");
}
});
}
}
else
{
_logger.LogError("Authentication failed - status {StatusCode}", statusCode);
}
// Return Jellyfin's exact response
return Content(responseJson, "application/json");
}
// No response body from Jellyfin - return status code only
_logger.LogWarning("Authentication request returned {StatusCode} with no response body", statusCode);
return StatusCode(statusCode);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during authentication");
return StatusCode(500, new { error = $"Authentication error: {ex.Message}" });
}
}
#endregion
}
@@ -0,0 +1,471 @@
using System.Text.Json;
using allstarr.Models.Domain;
using allstarr.Models.Lyrics;
using Microsoft.AspNetCore.Mvc;
namespace allstarr.Controllers;
public partial class JellyfinController
{
#region Lyrics
/// <summary>
/// Gets lyrics for an item.
/// Priority: 1. Jellyfin embedded lyrics, 2. Spotify synced lyrics, 3. LRCLIB
/// </summary>
[HttpGet("Audio/{itemId}/Lyrics")]
[HttpGet("Items/{itemId}/Lyrics")]
public async Task<IActionResult> GetLyrics(string itemId)
{
_logger.LogDebug("🎵 GetLyrics called for itemId: {ItemId}", itemId);
if (string.IsNullOrWhiteSpace(itemId))
{
return NotFound();
}
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
_logger.LogDebug(
"🎵 Lyrics request: itemId={ItemId}, isExternal={IsExternal}, provider={Provider}, externalId={ExternalId}",
itemId, isExternal, provider, externalId);
// For local tracks, check if Jellyfin already has embedded lyrics
if (!isExternal)
{
_logger.LogDebug("Checking Jellyfin for embedded lyrics for local track: {ItemId}", itemId);
// Try to get lyrics from Jellyfin first (it reads embedded lyrics from files)
var (jellyfinLyrics, statusCode) =
await _proxyService.GetJsonAsync($"Audio/{itemId}/Lyrics", null, Request.Headers);
_logger.LogDebug("Jellyfin lyrics check result: statusCode={StatusCode}, hasLyrics={HasLyrics}",
statusCode, jellyfinLyrics != null);
if (jellyfinLyrics != null && statusCode == 200)
{
_logger.LogInformation("Found embedded lyrics in Jellyfin for track {ItemId}", itemId);
return new JsonResult(JsonSerializer.Deserialize<object>(jellyfinLyrics.RootElement.GetRawText()));
}
_logger.LogWarning("No embedded lyrics found in Jellyfin (status: {StatusCode}), trying Spotify/LRCLIB",
statusCode);
}
// Get song metadata for lyrics search
Song? song = null;
string? spotifyTrackId = null;
if (isExternal)
{
song = await _metadataService.GetSongAsync(provider!, externalId!);
// Use Spotify ID from song metadata if available (populated during GetSongAsync)
if (song != null && !string.IsNullOrEmpty(song.SpotifyId))
{
spotifyTrackId = song.SpotifyId;
_logger.LogInformation("Using Spotify ID {SpotifyId} from song metadata for {Provider}/{ExternalId}",
spotifyTrackId, provider, externalId);
}
// Fallback: Try to find Spotify ID from matched tracks cache
else if (song != null)
{
spotifyTrackId = await FindSpotifyIdForExternalTrackAsync(song);
if (!string.IsNullOrEmpty(spotifyTrackId))
{
_logger.LogDebug(
"Found Spotify ID {SpotifyId} for external track {Provider}/{ExternalId} from cache",
spotifyTrackId, provider, externalId);
}
else
{
// Last resort: Try to convert via Odesli/song.link
if (provider == "squidwtf")
{
spotifyTrackId =
await _odesliService.ConvertTidalToSpotifyIdAsync(externalId!, HttpContext.RequestAborted);
}
else
{
// For other providers, build the URL and convert
var sourceUrl = provider?.ToLowerInvariant() switch
{
"deezer" => $"https://www.deezer.com/track/{externalId}",
"qobuz" => $"https://www.qobuz.com/us-en/album/-/-/{externalId}",
_ => null
};
if (!string.IsNullOrEmpty(sourceUrl))
{
spotifyTrackId =
await _odesliService.ConvertUrlToSpotifyIdAsync(sourceUrl, HttpContext.RequestAborted);
}
}
if (!string.IsNullOrEmpty(spotifyTrackId))
{
_logger.LogDebug("Converted {Provider}/{ExternalId} to Spotify ID {SpotifyId} via Odesli",
provider, externalId, spotifyTrackId);
}
}
}
}
else
{
// For local songs, get metadata from Jellyfin
var (item, _) = await _proxyService.GetItemAsync(itemId, Request.Headers);
if (item != null && item.RootElement.TryGetProperty("Type", out var typeEl) &&
typeEl.GetString() == "Audio")
{
song = new Song
{
Title = item.RootElement.TryGetProperty("Name", out var name) ? name.GetString() ?? "" : "",
Artist = item.RootElement.TryGetProperty("AlbumArtist", out var artist)
? artist.GetString() ?? ""
: "",
Album = item.RootElement.TryGetProperty("Album", out var album) ? album.GetString() ?? "" : "",
Duration = item.RootElement.TryGetProperty("RunTimeTicks", out var ticks)
? (int)(ticks.GetInt64() / 10000000)
: 0
};
// Check for Spotify ID in provider IDs
if (item.RootElement.TryGetProperty("ProviderIds", out var providerIds))
{
if (providerIds.TryGetProperty("Spotify", out var spotifyId))
{
spotifyTrackId = spotifyId.GetString();
}
}
}
}
if (song == null)
{
return NotFound(new { error = "Song not found" });
}
// Strip [S] suffix from title, artist, and album for lyrics search
// The [S] tag is added to external tracks but shouldn't be used in lyrics queries
var searchTitle = song.Title.Replace(" [S]", "").Trim();
var searchArtist = song.Artist?.Replace(" [S]", "").Trim() ?? "";
var searchAlbum = song.Album?.Replace(" [S]", "").Trim() ?? "";
var searchArtists = song.Artists.Select(a => a.Replace(" [S]", "").Trim()).ToList();
if (searchArtists.Count == 0 && !string.IsNullOrEmpty(searchArtist))
{
searchArtists.Add(searchArtist);
}
// Use orchestrator for clean, modular lyrics fetching
LyricsInfo? lyrics = null;
if (_lyricsOrchestrator != null)
{
lyrics = await _lyricsOrchestrator.GetLyricsAsync(
trackName: searchTitle,
artistNames: searchArtists.ToArray(),
albumName: searchAlbum,
durationSeconds: song.Duration ?? 0,
spotifyTrackId: spotifyTrackId);
}
else
{
// Fallback to manual fetching if orchestrator not available
_logger.LogWarning("LyricsOrchestrator not available, using fallback method");
// Try Spotify lyrics ONLY if we have a valid Spotify track ID
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled && !string.IsNullOrEmpty(spotifyTrackId))
{
var cleanSpotifyId = spotifyTrackId.Replace("spotify:track:", "").Trim();
if (cleanSpotifyId.Length == 22 && !cleanSpotifyId.Contains(":") && !cleanSpotifyId.Contains("local"))
{
var spotifyLyrics = await _spotifyLyricsService.GetLyricsByTrackIdAsync(cleanSpotifyId);
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
{
lyrics = _spotifyLyricsService.ToLyricsInfo(spotifyLyrics);
}
}
}
// Fall back to LyricsPlus
if (lyrics == null && _lyricsPlusService != null)
{
lyrics = await _lyricsPlusService.GetLyricsAsync(
searchTitle,
searchArtists.ToArray(),
searchAlbum,
song.Duration ?? 0);
}
// Fall back to LRCLIB
if (lyrics == null && _lrclibService != null)
{
lyrics = await _lrclibService.GetLyricsAsync(
searchTitle,
searchArtists.ToArray(),
searchAlbum,
song.Duration ?? 0);
}
}
if (lyrics == null)
{
return NotFound(new { error = "Lyrics not found" });
}
// Prefer synced lyrics, fall back to plain
var lyricsText = lyrics.SyncedLyrics ?? lyrics.PlainLyrics ?? "";
var isSynced = !string.IsNullOrEmpty(lyrics.SyncedLyrics);
_logger.LogInformation(
"Lyrics for {Artist} - {Track}: synced={HasSynced}, plainLength={PlainLen}, syncedLength={SyncLen}",
song.Artist, song.Title, isSynced, lyrics.PlainLyrics?.Length ?? 0, lyrics.SyncedLyrics?.Length ?? 0);
// Parse LRC format into individual lines for Jellyfin
var lyricLines = new List<Dictionary<string, object>>();
if (isSynced && !string.IsNullOrEmpty(lyrics.SyncedLyrics))
{
_logger.LogDebug("Parsing synced lyrics (LRC format)");
// Parse LRC format: [mm:ss.xx] text
// Skip ID tags like [ar:Artist], [ti:Title], etc.
var lines = lyrics.SyncedLyrics.Split('\n', StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines)
{
// Match timestamp format [mm:ss.xx] or [mm:ss.xxx]
var match = System.Text.RegularExpressions.Regex.Match(line, @"^\[(\d+):(\d+)\.(\d+)\]\s*(.*)$");
if (match.Success)
{
var minutes = int.Parse(match.Groups[1].Value);
var seconds = int.Parse(match.Groups[2].Value);
var centiseconds = int.Parse(match.Groups[3].Value);
var text = match.Groups[4].Value;
// Convert to ticks (100 nanoseconds)
var totalMilliseconds = (minutes * 60 + seconds) * 1000 + centiseconds * 10;
var ticks = totalMilliseconds * 10000L;
// For synced lyrics, include Start timestamp
lyricLines.Add(new Dictionary<string, object>
{
["Text"] = text,
["Start"] = ticks
});
}
// Skip ID tags like [ar:Artist], [ti:Title], [length:2:23], etc.
}
_logger.LogDebug("Parsed {Count} synced lyric lines (skipped ID tags)", lyricLines.Count);
}
else if (!string.IsNullOrEmpty(lyricsText))
{
_logger.LogInformation("Splitting plain lyrics into lines (no timestamps)");
// Plain lyrics - split by newlines and return each line separately
// IMPORTANT: Do NOT include "Start" field at all for unsynced lyrics
// Including it (even as null) causes clients to treat it as synced with timestamp 0:00
var lines = lyricsText.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines)
{
lyricLines.Add(new Dictionary<string, object>
{
["Text"] = line.Trim()
});
}
_logger.LogDebug("Split into {Count} plain lyric lines", lyricLines.Count);
}
else
{
_logger.LogWarning("No lyrics text available");
// No lyrics at all
lyricLines.Add(new Dictionary<string, object>
{
["Text"] = ""
});
}
var response = new
{
Metadata = new
{
Artist = lyrics.ArtistName,
Album = lyrics.AlbumName,
Title = lyrics.TrackName,
Length = lyrics.Duration,
IsSynced = isSynced
},
Lyrics = lyricLines
};
_logger.LogDebug("Returning lyrics response: {LineCount} lines, synced={IsSynced}", lyricLines.Count, isSynced);
// Log a sample of the response for debugging
if (lyricLines.Count > 0)
{
var sampleLine = lyricLines[0];
var hasStart = sampleLine.ContainsKey("Start");
_logger.LogDebug("Sample line: Text='{Text}', HasStart={HasStart}",
sampleLine.GetValueOrDefault("Text"), hasStart);
}
return Ok(response);
}
/// <summary>
/// Proactively fetches and caches lyrics for a track in the background.
/// Called when playback starts to ensure lyrics are ready when requested.
/// </summary>
private async Task PrefetchLyricsForTrackAsync(string itemId, bool isExternal, string? provider, string? externalId)
{
try
{
Song? song = null;
string? spotifyTrackId = null;
if (isExternal && !string.IsNullOrEmpty(provider) && !string.IsNullOrEmpty(externalId))
{
// Get external track metadata
song = await _metadataService.GetSongAsync(provider, externalId);
// Try to find Spotify ID from matched tracks cache
if (song != null)
{
spotifyTrackId = await FindSpotifyIdForExternalTrackAsync(song);
// If no cached Spotify ID, try Odesli conversion
if (string.IsNullOrEmpty(spotifyTrackId) && provider == "squidwtf")
{
spotifyTrackId =
await _odesliService.ConvertTidalToSpotifyIdAsync(externalId, HttpContext.RequestAborted);
}
}
}
else
{
// Get local track metadata from Jellyfin
var (item, _) = await _proxyService.GetItemAsync(itemId, Request.Headers);
if (item != null && item.RootElement.TryGetProperty("Type", out var typeEl) &&
typeEl.GetString() == "Audio")
{
song = new Song
{
Title = item.RootElement.TryGetProperty("Name", out var name) ? name.GetString() ?? "" : "",
Artist = item.RootElement.TryGetProperty("AlbumArtist", out var artist)
? artist.GetString() ?? ""
: "",
Album = item.RootElement.TryGetProperty("Album", out var album) ? album.GetString() ?? "" : "",
Duration = item.RootElement.TryGetProperty("RunTimeTicks", out var ticks)
? (int)(ticks.GetInt64() / 10000000)
: 0
};
// Check for Spotify ID in provider IDs
if (item.RootElement.TryGetProperty("ProviderIds", out var providerIds))
{
if (providerIds.TryGetProperty("Spotify", out var spotifyId))
{
spotifyTrackId = spotifyId.GetString();
}
}
}
}
if (song == null)
{
_logger.LogDebug("Could not get song metadata for lyrics prefetch: {ItemId}", itemId);
return;
}
// Strip [S] suffix for lyrics search
var searchTitle = song.Title.Replace(" [S]", "").Trim();
var searchArtist = song.Artist?.Replace(" [S]", "").Trim() ?? "";
var searchAlbum = song.Album?.Replace(" [S]", "").Trim() ?? "";
var searchArtists = song.Artists.Select(a => a.Replace(" [S]", "").Trim()).ToList();
if (searchArtists.Count == 0 && !string.IsNullOrEmpty(searchArtist))
{
searchArtists.Add(searchArtist);
}
_logger.LogDebug("🎵 Prefetching lyrics for: {Artist} - {Title}", searchArtist, searchTitle);
// Use orchestrator for prefetching
if (_lyricsOrchestrator != null)
{
await _lyricsOrchestrator.PrefetchLyricsAsync(
trackName: searchTitle,
artistNames: searchArtists.ToArray(),
albumName: searchAlbum,
durationSeconds: song.Duration ?? 0,
spotifyTrackId: spotifyTrackId);
return;
}
// Fallback to manual prefetching if orchestrator not available
_logger.LogWarning("LyricsOrchestrator not available for prefetch, using fallback method");
// Try Spotify lyrics if we have a valid Spotify track ID
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled && !string.IsNullOrEmpty(spotifyTrackId))
{
var cleanSpotifyId = spotifyTrackId.Replace("spotify:track:", "").Trim();
if (cleanSpotifyId.Length == 22 && !cleanSpotifyId.Contains(":") && !cleanSpotifyId.Contains("local"))
{
var spotifyLyrics = await _spotifyLyricsService.GetLyricsByTrackIdAsync(cleanSpotifyId);
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
{
_logger.LogDebug("✓ Prefetched Spotify lyrics for {Artist} - {Title} ({LineCount} lines)",
searchArtist, searchTitle, spotifyLyrics.Lines.Count);
return; // Success, lyrics are now cached
}
}
}
// Fall back to LyricsPlus
if (_lyricsPlusService != null)
{
var lyrics = await _lyricsPlusService.GetLyricsAsync(
searchTitle,
searchArtists.ToArray(),
searchAlbum,
song.Duration ?? 0);
if (lyrics != null)
{
_logger.LogDebug("✓ Prefetched LyricsPlus lyrics for {Artist} - {Title}", searchArtist,
searchTitle);
return; // Success, lyrics are now cached
}
}
// Fall back to LRCLIB
if (_lrclibService != null)
{
var lyrics = await _lrclibService.GetLyricsAsync(
searchTitle,
searchArtists.ToArray(),
searchAlbum,
song.Duration ?? 0);
if (lyrics != null)
{
_logger.LogDebug("✓ Prefetched LRCLIB lyrics for {Artist} - {Title}", searchArtist, searchTitle);
}
else
{
_logger.LogDebug("No lyrics found for {Artist} - {Title}", searchArtist, searchTitle);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error prefetching lyrics for track {ItemId}", itemId);
}
}
#endregion
}
@@ -0,0 +1,915 @@
using System.Text.Json;
using allstarr.Models.Scrobbling;
using Microsoft.AspNetCore.Mvc;
namespace allstarr.Controllers;
public partial class JellyfinController
{
#region Playback Session Reporting
#region Session Management
/// <summary>
/// Reports session capabilities. Required for Jellyfin to track active sessions.
/// Handles both POST (with body) and GET (query params only) methods.
/// </summary>
[HttpPost("Sessions/Capabilities")]
[HttpPost("Sessions/Capabilities/Full")]
[HttpGet("Sessions/Capabilities")]
[HttpGet("Sessions/Capabilities/Full")]
public async Task<IActionResult> ReportCapabilities()
{
try
{
var method = Request.Method;
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : "";
_logger.LogDebug("📡 Session capabilities reported - Method: {Method}, Query: {Query}", method,
queryString);
_logger.LogInformation("Headers: {Headers}",
string.Join(", ", Request.Headers.Where(h =>
h.Key.Contains("Auth", StringComparison.OrdinalIgnoreCase) ||
h.Key.Contains("Device", StringComparison.OrdinalIgnoreCase) ||
h.Key.Contains("Client", StringComparison.OrdinalIgnoreCase))
.Select(h => $"{h.Key}={h.Value}")));
// Forward to Jellyfin with query string and headers
var endpoint = $"Sessions/Capabilities{queryString}";
// Read body if present (POST requests)
string body = "{}";
if (method == "POST" && Request.ContentLength > 0)
{
Request.EnableBuffering();
using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8,
detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true))
{
body = await reader.ReadToEndAsync();
}
Request.Body.Position = 0;
_logger.LogInformation("Capabilities body: {Body}", body);
}
var (result, statusCode) = await _proxyService.PostJsonAsync(endpoint, body, Request.Headers);
if (statusCode == 204 || statusCode == 200)
{
_logger.LogDebug("✓ Session capabilities forwarded to Jellyfin ({StatusCode})", statusCode);
}
else if (statusCode == 401)
{
_logger.LogWarning("⚠ Jellyfin returned 401 for capabilities (token expired)");
}
else
{
_logger.LogWarning("⚠ Jellyfin returned {StatusCode} for capabilities", statusCode);
}
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to report session capabilities");
return StatusCode(500);
}
}
/// <summary>
/// Reports playback start. Handles both local and external tracks.
/// For local tracks, forwards to Jellyfin. For external tracks, logs locally.
/// Also ensures session is initialized if this is the first report from a device.
/// </summary>
[HttpPost("Sessions/Playing")]
public async Task<IActionResult> ReportPlaybackStart()
{
try
{
Request.EnableBuffering();
string body;
using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8,
detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true))
{
body = await reader.ReadToEndAsync();
}
Request.Body.Position = 0;
_logger.LogDebug("📻 Playback START reported");
// Parse the body to check if it's an external track
var doc = JsonDocument.Parse(body);
string? itemId = null;
string? itemName = null;
long? positionTicks = null;
if (doc.RootElement.TryGetProperty("ItemId", out var itemIdProp))
{
itemId = itemIdProp.GetString();
}
if (doc.RootElement.TryGetProperty("ItemName", out var itemNameProp))
{
itemName = itemNameProp.GetString();
}
if (doc.RootElement.TryGetProperty("PositionTicks", out var posProp))
{
positionTicks = posProp.GetInt64();
}
// Track the playing item for scrobbling on session cleanup (local tracks only)
var (deviceId, client, device, version) = ExtractDeviceInfo(Request.Headers);
// Only update session for local tracks - external tracks don't need session tracking
if (!string.IsNullOrEmpty(deviceId) && !string.IsNullOrEmpty(itemId))
{
var (isExt, _, _) = _localLibraryService.ParseSongId(itemId);
if (!isExt)
{
_sessionManager.UpdatePlayingItem(deviceId, itemId, positionTicks);
}
}
if (!string.IsNullOrEmpty(itemId))
{
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
if (isExternal)
{
// Fetch metadata early so we can log the correct track name
var song = await _metadataService.GetSongAsync(provider!, externalId!);
var trackName = song != null ? $"{song.Artist} - {song.Title}" : "Unknown";
_logger.LogInformation("🎵 External track playback started: {TrackName} ({Provider}/{ExternalId})",
trackName, provider, externalId);
// Proactively fetch lyrics in background for external tracks
_ = Task.Run(async () =>
{
try
{
await PrefetchLyricsForTrackAsync(itemId, isExternal: true, provider, externalId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to prefetch lyrics for external track {ItemId}", itemId);
}
});
// Create a ghost/fake item to report to Jellyfin so "Now Playing" shows up
// Generate a deterministic UUID from the external ID
var ghostUuid = GenerateUuidFromString(itemId);
// Build minimal playback start with just the ghost UUID
// Don't include the Item object - Jellyfin will just track the session without item details
var playbackStart = new
{
ItemId = ghostUuid,
PositionTicks = positionTicks ?? 0,
CanSeek = true,
IsPaused = false,
IsMuted = false,
PlayMethod = "DirectPlay"
};
var playbackJson = JsonSerializer.Serialize(playbackStart);
_logger.LogDebug("📤 Sending ghost playback start for external track: {Json}", playbackJson);
// Forward to Jellyfin with ghost UUID
var (ghostResult, ghostStatusCode) =
await _proxyService.PostJsonAsync("Sessions/Playing", playbackJson, Request.Headers);
if (ghostStatusCode == 204 || ghostStatusCode == 200)
{
_logger.LogDebug(
"✓ Ghost playback start forwarded to Jellyfin for external track ({StatusCode})",
ghostStatusCode);
}
else
{
_logger.LogWarning("⚠️ Ghost playback start returned status {StatusCode} for external track",
ghostStatusCode);
}
// Scrobble external track playback start
_logger.LogInformation(
"🎵 Checking scrobbling: orchestrator={HasOrchestrator}, helper={HasHelper}, deviceId={DeviceId}",
_scrobblingOrchestrator != null, _scrobblingHelper != null, deviceId ?? "null");
if (_scrobblingOrchestrator != null && _scrobblingHelper != null &&
!string.IsNullOrEmpty(deviceId) && song != null)
{
_logger.LogInformation("🎵 Starting scrobble task for external track");
_ = Task.Run(async () =>
{
try
{
var track = _scrobblingHelper.CreateScrobbleTrackFromExternal(
title: song.Title,
artist: song.Artist,
album: song.Album,
albumArtist: song.AlbumArtist,
durationSeconds: song.Duration
);
if (track != null)
{
await _scrobblingOrchestrator.OnPlaybackStartAsync(deviceId, track);
}
else
{
_logger.LogWarning("Failed to create scrobble track from metadata");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to scrobble external track playback start");
}
});
}
return NoContent();
}
// Proactively fetch lyrics in background for local tracks
_ = Task.Run(async () =>
{
try
{
await PrefetchLyricsForTrackAsync(itemId, isExternal: false, null, null);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to prefetch lyrics for local track {ItemId}", itemId);
}
});
}
// For local tracks, forward playback start to Jellyfin FIRST
_logger.LogDebug("Forwarding playback start to Jellyfin...");
// Fetch full item details to include in playback report
try
{
var (itemResult, itemStatus) =
await _proxyService.GetJsonAsync($"Items/{itemId}", null, Request.Headers);
if (itemResult != null && itemStatus == 200)
{
var item = itemResult.RootElement;
// Extract track name from item details for logging
string? trackName = null;
if (item.TryGetProperty("Name", out var nameElement))
{
trackName = nameElement.GetString();
}
_logger.LogInformation("🎵 Local track playback started: {Name} (ID: {ItemId})",
trackName ?? "Unknown", itemId);
// Build playback start info - Jellyfin will fetch item details itself
var playbackStart = new
{
ItemId = itemId,
PositionTicks = positionTicks ?? 0,
// Let Jellyfin fetch the item details - don't include NowPlayingItem
};
var playbackJson = JsonSerializer.Serialize(playbackStart);
_logger.LogInformation("📤 Sending playback start: {Json}", playbackJson);
var (result, statusCode) =
await _proxyService.PostJsonAsync("Sessions/Playing", playbackJson, Request.Headers);
if (statusCode == 204 || statusCode == 200)
{
_logger.LogDebug("✓ Playback start forwarded to Jellyfin ({StatusCode})", statusCode);
// Scrobble local track playback start (only if enabled)
if (_scrobblingSettings.LocalTracksEnabled && _scrobblingOrchestrator != null &&
_scrobblingHelper != null && !string.IsNullOrEmpty(deviceId) &&
!string.IsNullOrEmpty(itemId))
{
_ = Task.Run(async () =>
{
try
{
var track = await _scrobblingHelper.GetScrobbleTrackFromItemIdAsync(itemId,
Request.Headers);
if (track != null)
{
await _scrobblingOrchestrator.OnPlaybackStartAsync(deviceId, track);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to scrobble local track playback start");
}
});
}
// NOW ensure session exists with capabilities (after playback is reported)
if (!string.IsNullOrEmpty(deviceId))
{
var sessionCreated = await _sessionManager.EnsureSessionAsync(deviceId, client ?? "Unknown",
device ?? "Unknown", version ?? "1.0", Request.Headers);
if (sessionCreated)
{
_logger.LogDebug(
"✓ SESSION: Session ensured for device {DeviceId} after playback start", deviceId);
}
else
{
_logger.LogError("⚠️ SESSION: Failed to ensure session for device {DeviceId}",
deviceId);
}
}
else
{
_logger.LogWarning("⚠️ SESSION: No device ID found in headers for playback start");
}
}
else
{
_logger.LogWarning("⚠️ Playback start returned status {StatusCode}", statusCode);
}
}
else
{
_logger.LogWarning("⚠️ Could not fetch item details ({StatusCode}), sending basic playback start",
itemStatus);
// Fall back to basic playback start
var (result, statusCode) =
await _proxyService.PostJsonAsync("Sessions/Playing", body, Request.Headers);
if (statusCode == 204 || statusCode == 200)
{
_logger.LogDebug("✓ Basic playback start forwarded to Jellyfin ({StatusCode})", statusCode);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send playback start, trying basic");
// Fall back to basic playback start
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", body, Request.Headers);
if (statusCode == 204 || statusCode == 200)
{
_logger.LogInformation("✓ Basic playback start forwarded to Jellyfin ({StatusCode})", statusCode);
}
}
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to report playback start");
return NoContent(); // Return success anyway to not break playback
}
}
/// <summary>
/// Reports playback progress. Handles both local and external tracks.
/// </summary>
[HttpPost("Sessions/Playing/Progress")]
public async Task<IActionResult> ReportPlaybackProgress()
{
try
{
Request.EnableBuffering();
string body;
using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8,
detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true))
{
body = await reader.ReadToEndAsync();
}
Request.Body.Position = 0;
// Update session activity (local tracks only)
var (deviceId, _, _, _) = ExtractDeviceInfo(Request.Headers);
// Parse the body to check if it's an external track
var doc = JsonDocument.Parse(body);
string? itemId = null;
long? positionTicks = null;
if (doc.RootElement.TryGetProperty("ItemId", out var itemIdProp))
{
itemId = itemIdProp.GetString();
}
if (doc.RootElement.TryGetProperty("PositionTicks", out var posProp))
{
positionTicks = posProp.GetInt64();
}
// Only update session for local tracks
if (!string.IsNullOrEmpty(deviceId) && !string.IsNullOrEmpty(itemId))
{
var (isExt, _, _) = _localLibraryService.ParseSongId(itemId);
if (!isExt)
{
_sessionManager.UpdateActivity(deviceId);
_sessionManager.UpdatePlayingItem(deviceId, itemId, positionTicks);
}
// Scrobble progress check (both local and external)
if (_scrobblingOrchestrator != null && _scrobblingHelper != null && positionTicks.HasValue)
{
_ = Task.Run(async () =>
{
try
{
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
// Skip local tracks if local scrobbling is disabled
if (!isExternal && !_scrobblingSettings.LocalTracksEnabled)
{
return;
}
ScrobbleTrack? track = null;
if (isExternal)
{
// For external tracks, fetch metadata from provider
var song = await _metadataService.GetSongAsync(provider!, externalId!);
if (song != null)
{
track = _scrobblingHelper.CreateScrobbleTrackFromExternal(
title: song.Title,
artist: song.Artist,
album: song.Album,
albumArtist: song.AlbumArtist,
durationSeconds: song.Duration
);
}
}
else
{
// For local tracks, fetch from Jellyfin
track = await _scrobblingHelper.GetScrobbleTrackFromItemIdAsync(itemId,
Request.Headers);
}
if (track != null)
{
var positionSeconds = (int)(positionTicks.Value / TimeSpan.TicksPerSecond);
await _scrobblingOrchestrator.OnPlaybackProgressAsync(deviceId, track.Artist,
track.Title, positionSeconds);
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to scrobble playback progress");
}
});
}
}
if (!string.IsNullOrEmpty(itemId))
{
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
if (isExternal)
{
// For external tracks, report progress with ghost UUID to Jellyfin
var ghostUuid = GenerateUuidFromString(itemId);
// Build progress report with ghost UUID
var progressReport = new
{
ItemId = ghostUuid,
PositionTicks = positionTicks ?? 0,
IsPaused = false,
IsMuted = false,
CanSeek = true,
PlayMethod = "DirectPlay"
};
var progressJson = JsonSerializer.Serialize(progressReport);
// Forward to Jellyfin with ghost UUID
var (progressResult, progressStatusCode) =
await _proxyService.PostJsonAsync("Sessions/Playing/Progress", progressJson, Request.Headers);
// Log progress occasionally for debugging (every ~30 seconds)
if (positionTicks.HasValue)
{
var position = TimeSpan.FromTicks(positionTicks.Value);
if (position.Seconds % 30 == 0 && position.Milliseconds < 500)
{
_logger.LogDebug(
"▶️ External track progress: {Position:mm\\:ss} ({Provider}/{ExternalId}) - Status: {StatusCode}",
position, provider, externalId, progressStatusCode);
}
}
return NoContent();
}
// Log progress for local tracks (only every ~10 seconds to avoid spam)
if (positionTicks.HasValue)
{
var position = TimeSpan.FromTicks(positionTicks.Value);
// Only log at 10-second intervals
if (position.Seconds % 10 == 0 && position.Milliseconds < 500)
{
_logger.LogDebug("▶️ Progress: {Position:mm\\:ss} for item {ItemId}", position, itemId);
}
}
}
// For local tracks, forward to Jellyfin
_logger.LogDebug("📤 Sending playback progress body: {Body}", body);
var (result, statusCode) =
await _proxyService.PostJsonAsync("Sessions/Playing/Progress", body, Request.Headers);
if (statusCode != 204 && statusCode != 200)
{
_logger.LogWarning("⚠️ Progress report returned {StatusCode} for item {ItemId}", statusCode, itemId);
}
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to report playback progress");
return NoContent();
}
}
/// <summary>
/// Reports playback stopped. Handles both local and external tracks.
/// </summary>
[HttpPost("Sessions/Playing/Stopped")]
public async Task<IActionResult> ReportPlaybackStopped()
{
try
{
Request.EnableBuffering();
string body;
using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8,
detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true))
{
body = await reader.ReadToEndAsync();
}
Request.Body.Position = 0;
_logger.LogInformation("⏹️ Playback STOPPED reported");
_logger.LogDebug("📤 Sending playback stop body: {Body}", body);
// Parse the body to check if it's an external track
var doc = JsonDocument.Parse(body);
string? itemId = null;
string? itemName = null;
long? positionTicks = null;
string? deviceId = null;
if (doc.RootElement.TryGetProperty("ItemId", out var itemIdProp))
{
itemId = itemIdProp.GetString();
}
if (doc.RootElement.TryGetProperty("ItemName", out var itemNameProp))
{
itemName = itemNameProp.GetString();
}
if (doc.RootElement.TryGetProperty("PositionTicks", out var posProp))
{
positionTicks = posProp.GetInt64();
}
// Try to get device ID from headers for session management
if (Request.Headers.TryGetValue("X-Emby-Device-Id", out var deviceIdHeader))
{
deviceId = deviceIdHeader.FirstOrDefault();
}
if (!string.IsNullOrEmpty(itemId))
{
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
if (isExternal)
{
var position = positionTicks.HasValue
? TimeSpan.FromTicks(positionTicks.Value).ToString(@"mm\:ss")
: "unknown";
// Try to get track metadata from provider if not in stop event
if (string.IsNullOrEmpty(itemName))
{
try
{
var song = await _metadataService.GetSongAsync(provider!, externalId!);
if (song != null)
{
itemName = $"{song.Artist} - {song.Title}";
// Update position with actual track duration if available
if (positionTicks.HasValue && song.Duration > 0)
{
var actualPosition = TimeSpan.FromTicks(positionTicks.Value);
var duration = TimeSpan.FromSeconds((double)song.Duration);
position = $"{actualPosition:mm\\:ss}/{duration:mm\\:ss}";
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Could not fetch track name for external track on stop");
}
}
_logger.LogInformation(
"🎵 External track playback stopped: {Name} at {Position} ({Provider}/{ExternalId})",
itemName ?? "Unknown", position, provider, externalId);
// Report stop to Jellyfin with ghost UUID
var ghostUuid = GenerateUuidFromString(itemId);
var externalStopInfo = new
{
ItemId = ghostUuid,
PositionTicks = positionTicks ?? 0
};
var stopJson = JsonSerializer.Serialize(externalStopInfo);
_logger.LogDebug("📤 Sending ghost playback stop for external track: {Json}", stopJson);
var (stopResult, stopStatusCode) =
await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", stopJson, Request.Headers);
if (stopStatusCode == 204 || stopStatusCode == 200)
{
_logger.LogDebug("✓ Ghost playback stop forwarded to Jellyfin ({StatusCode})", stopStatusCode);
}
// Scrobble external track playback stop
if (_scrobblingOrchestrator != null && _scrobblingHelper != null &&
!string.IsNullOrEmpty(deviceId) && positionTicks.HasValue)
{
_ = Task.Run(async () =>
{
try
{
// Fetch full metadata from the provider for scrobbling
var song = await _metadataService.GetSongAsync(provider!, externalId!);
if (song != null)
{
var track = _scrobblingHelper.CreateScrobbleTrackFromExternal(
title: song.Title,
artist: song.Artist,
album: song.Album,
albumArtist: song.AlbumArtist,
durationSeconds: song.Duration
);
if (track != null)
{
var positionSeconds = (int)(positionTicks.Value / TimeSpan.TicksPerSecond);
await _scrobblingOrchestrator.OnPlaybackStopAsync(deviceId, track.Artist,
track.Title, positionSeconds);
}
}
else
{
_logger.LogWarning(
"Could not fetch metadata for external track scrobbling on stop: {Provider}/{ExternalId}",
provider, externalId);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to scrobble external track playback stop");
}
});
}
return NoContent();
}
// For local tracks, fetch item details to get track name
string? trackName = itemName;
if (string.IsNullOrEmpty(trackName))
{
try
{
var (itemResult, itemStatus) =
await _proxyService.GetJsonAsync($"Items/{itemId}", null, Request.Headers);
if (itemResult != null && itemStatus == 200)
{
var item = itemResult.RootElement;
if (item.TryGetProperty("Name", out var nameElement))
{
trackName = nameElement.GetString();
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Could not fetch track name for local track on stop");
}
}
_logger.LogInformation("🎵 Local track playback stopped: {Name} (ID: {ItemId})",
trackName ?? "Unknown", itemId);
// Scrobble local track playback stop (only if enabled)
if (_scrobblingSettings.LocalTracksEnabled && _scrobblingOrchestrator != null &&
_scrobblingHelper != null && !string.IsNullOrEmpty(deviceId) && !string.IsNullOrEmpty(itemId) &&
positionTicks.HasValue)
{
_ = Task.Run(async () =>
{
try
{
var track = await _scrobblingHelper.GetScrobbleTrackFromItemIdAsync(itemId,
Request.Headers);
if (track != null)
{
var positionSeconds = (int)(positionTicks.Value / TimeSpan.TicksPerSecond);
await _scrobblingOrchestrator.OnPlaybackStopAsync(deviceId, track.Artist, track.Title,
positionSeconds);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to scrobble local track playback stop");
}
});
}
}
// For local tracks, forward to Jellyfin
_logger.LogDebug("Forwarding playback stop to Jellyfin...");
// Log the body being sent for debugging
_logger.LogDebug("📤 Original playback stop body: {Body}", body);
// Parse and fix the body - ensure IsPaused is false for a proper stop
var stopDoc = JsonDocument.Parse(body);
var stopInfo = new Dictionary<string, object?>();
foreach (var prop in stopDoc.RootElement.EnumerateObject())
{
if (prop.Name == "IsPaused")
{
// Force IsPaused to false for a proper stop
stopInfo[prop.Name] = false;
}
else if (prop.Value.ValueKind == JsonValueKind.String)
{
stopInfo[prop.Name] = prop.Value.GetString();
}
else if (prop.Value.ValueKind == JsonValueKind.Number)
{
stopInfo[prop.Name] = prop.Value.GetInt64();
}
else if (prop.Value.ValueKind == JsonValueKind.True || prop.Value.ValueKind == JsonValueKind.False)
{
stopInfo[prop.Name] = prop.Value.GetBoolean();
}
else
{
stopInfo[prop.Name] = prop.Value.GetRawText();
}
}
// Ensure required fields are present
if (!stopInfo.ContainsKey("ItemId") && !string.IsNullOrEmpty(itemId))
{
stopInfo["ItemId"] = itemId;
}
if (!stopInfo.ContainsKey("PositionTicks") && positionTicks.HasValue)
{
stopInfo["PositionTicks"] = positionTicks.Value;
}
body = JsonSerializer.Serialize(stopInfo);
_logger.LogInformation("📤 Sending playback stop body (IsPaused=false): {Body}", body);
var (result, statusCode) =
await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", body, Request.Headers);
if (statusCode == 204 || statusCode == 200)
{
_logger.LogDebug("✓ Playback stop forwarded to Jellyfin ({StatusCode})", statusCode);
}
else if (statusCode == 401)
{
_logger.LogWarning("Playback stop returned 401 (token expired)");
}
else
{
_logger.LogWarning("Playback stop forward failed with status {StatusCode}", statusCode);
}
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to report playback stopped");
return NoContent();
}
}
/// <summary>
/// Pings a playback session to keep it alive.
/// </summary>
[HttpPost("Sessions/Playing/Ping")]
public async Task<IActionResult> PingPlaybackSession([FromQuery] string playSessionId)
{
try
{
_logger.LogDebug("Playback session ping: {SessionId}", playSessionId);
// Forward to Jellyfin
var endpoint = $"Sessions/Playing/Ping?playSessionId={Uri.EscapeDataString(playSessionId)}";
var (result, statusCode) = await _proxyService.PostJsonAsync(endpoint, "{}", Request.Headers);
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to ping playback session");
return NoContent();
}
}
/// <summary>
/// Catch-all for any other session-related requests.
/// <summary>
/// Catch-all proxy for any other session-related endpoints we haven't explicitly implemented.
/// This ensures all session management calls get proxied to Jellyfin.
/// Examples: GET /Sessions, POST /Sessions/Logout, etc.
/// </summary>
[HttpGet("Sessions")]
[HttpPost("Sessions")]
[HttpGet("Sessions/{**path}")]
[HttpPost("Sessions/{**path}")]
[HttpPut("Sessions/{**path}")]
[HttpDelete("Sessions/{**path}")]
public async Task<IActionResult> ProxySessionRequest(string? path = null)
{
try
{
var method = Request.Method;
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : "";
var endpoint = string.IsNullOrEmpty(path) ? $"Sessions{queryString}" : $"Sessions/{path}{queryString}";
_logger.LogDebug("🔄 Proxying session request: {Method} {Endpoint}", method, endpoint);
_logger.LogDebug("Session proxy headers: {Headers}",
string.Join(", ", Request.Headers.Where(h => h.Key.Contains("Auth", StringComparison.OrdinalIgnoreCase))
.Select(h => $"{h.Key}={h.Value}")));
// Read body if present
string body = "{}";
if ((method == "POST" || method == "PUT") && Request.ContentLength > 0)
{
Request.EnableBuffering();
using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8,
detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true))
{
body = await reader.ReadToEndAsync();
}
Request.Body.Position = 0;
_logger.LogDebug("Session proxy body: {Body}", body);
}
// Forward to Jellyfin
var (result, statusCode) = method switch
{
"GET" => await _proxyService.GetJsonAsync(endpoint, null, Request.Headers),
"POST" => await _proxyService.PostJsonAsync(endpoint, body, Request.Headers),
"PUT" => await _proxyService.PostJsonAsync(endpoint, body, Request.Headers), // Use POST for PUT
"DELETE" => await _proxyService.PostJsonAsync(endpoint, body, Request.Headers), // Use POST for DELETE
_ => (null, 405)
};
if (result != null)
{
_logger.LogDebug("✓ Session request proxied successfully ({StatusCode})", statusCode);
return new JsonResult(result.RootElement.Clone());
}
_logger.LogDebug("✓ Session request proxied ({StatusCode}, no body)", statusCode);
return StatusCode(statusCode);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to proxy session request: {Path}", path);
return StatusCode(500);
}
}
#endregion // Session Management
#endregion // Playback Session Reporting
}
@@ -0,0 +1,180 @@
using allstarr.Services.Common;
using Microsoft.AspNetCore.Mvc;
namespace allstarr.Controllers;
public partial class JellyfinController
{
#region Playlists
/// <summary>
/// Gets playlist tracks displayed as an album.
/// </summary>
private async Task<IActionResult> GetPlaylistAsAlbum(string playlistId)
{
try
{
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(playlistId);
var playlist = await _metadataService.GetPlaylistAsync(provider, externalId);
if (playlist == null)
{
return _responseBuilder.CreateError(404, "Playlist not found");
}
var tracks = await _metadataService.GetPlaylistTracksAsync(provider, externalId);
// Cache tracks for playlist sync
if (_playlistSyncService != null)
{
foreach (var track in tracks)
{
if (!string.IsNullOrEmpty(track.ExternalId))
{
var trackId = $"ext-{provider}-{track.ExternalId}";
_playlistSyncService.AddTrackToPlaylistCache(trackId, playlistId);
}
}
_logger.LogDebug("Cached {Count} tracks for playlist {PlaylistId}", tracks.Count, playlistId);
}
return _responseBuilder.CreatePlaylistAsAlbumResponse(playlist, tracks);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting playlist {PlaylistId}", playlistId);
return _responseBuilder.CreateError(500, "Failed to get playlist");
}
}
/// <summary>
/// Gets playlist tracks as child items.
/// </summary>
private async Task<IActionResult> GetPlaylistTracks(string playlistId)
{
try
{
_logger.LogDebug("=== GetPlaylistTracks called === PlaylistId: {PlaylistId}", playlistId);
// Check if this is an external playlist (Deezer/Qobuz) first
if (PlaylistIdHelper.IsExternalPlaylist(playlistId))
{
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(playlistId);
var tracks = await _metadataService.GetPlaylistTracksAsync(provider, externalId);
// Convert tracks to Jellyfin items and override ParentId/AlbumId to be the playlist
var items = tracks.Select(track =>
{
var item = _responseBuilder.ConvertSongToJellyfinItem(track);
// Override ParentId and AlbumId to be the playlist ID
// This makes all tracks appear to be from the same "album" (the playlist)
item["ParentId"] = playlistId;
item["AlbumId"] = playlistId;
item["AlbumPrimaryImageTag"] = playlistId;
item["ParentLogoItemId"] = playlistId;
item["ParentLogoImageTag"] = playlistId;
item["ParentBackdropItemId"] = playlistId;
return item;
}).ToList();
return new JsonResult(new
{
Items = items,
TotalRecordCount = items.Count,
StartIndex = 0
});
}
// Check if this is a Spotify playlist (by ID)
_logger.LogInformation("Spotify Import Enabled: {Enabled}, Configured Playlists: {Count}",
_spotifySettings.Enabled, _spotifySettings.Playlists.Count);
if (_spotifySettings.Enabled && _spotifySettings.IsSpotifyPlaylist(playlistId))
{
// Get playlist info from Jellyfin to get the name for matching missing tracks
_logger.LogInformation("Fetching playlist info from Jellyfin for ID: {PlaylistId}", playlistId);
var (playlistInfo, _) = await _proxyService.GetJsonAsync($"Items/{playlistId}", null, Request.Headers);
if (playlistInfo != null && playlistInfo.RootElement.TryGetProperty("Name", out var nameElement))
{
var playlistName = nameElement.GetString() ?? "";
_logger.LogInformation(
"✓ MATCHED! Intercepting Spotify playlist: {PlaylistName} (ID: {PlaylistId})",
playlistName, playlistId);
return await GetSpotifyPlaylistTracksAsync(playlistName, playlistId);
}
else
{
_logger.LogWarning("Could not get playlist name from Jellyfin for ID: {PlaylistId}", playlistId);
}
}
// Regular Jellyfin playlist - proxy through
var endpoint = $"Playlists/{playlistId}/Items";
if (Request.QueryString.HasValue)
{
endpoint = $"{endpoint}{Request.QueryString.Value}";
}
_logger.LogDebug("Proxying to Jellyfin: {Endpoint}", endpoint);
var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
return HandleProxyResponse(result, statusCode);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting playlist tracks {PlaylistId}", playlistId);
return _responseBuilder.CreateError(500, "Failed to get playlist tracks");
}
}
/// <summary>
/// Gets a playlist cover image.
/// </summary>
private async Task<IActionResult> GetPlaylistImage(string playlistId)
{
try
{
// Check cache first (1 hour TTL for playlist images since they can change)
var cacheKey = $"playlist:image:{playlistId}";
var cachedImage = await _cache.GetAsync<byte[]>(cacheKey);
if (cachedImage != null)
{
_logger.LogDebug("Serving cached playlist image for {PlaylistId}", playlistId);
return File(cachedImage, "image/jpeg");
}
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(playlistId);
var playlist = await _metadataService.GetPlaylistAsync(provider, externalId);
if (playlist == null || string.IsNullOrEmpty(playlist.CoverUrl))
{
return NotFound();
}
var response = await _proxyService.HttpClient.GetAsync(playlist.CoverUrl);
if (!response.IsSuccessStatusCode)
{
return NotFound();
}
var imageBytes = await response.Content.ReadAsByteArrayAsync();
var contentType = response.Content.Headers.ContentType?.ToString() ?? "image/jpeg";
// Cache for configurable duration (playlists can change)
await _cache.SetAsync(cacheKey, imageBytes, CacheExtensions.PlaylistImagesTTL);
_logger.LogDebug("Cached playlist image for {PlaylistId}", playlistId);
return File(imageBytes, contentType);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get playlist image {PlaylistId}", playlistId);
return NotFound();
}
}
#endregion
}
@@ -0,0 +1,574 @@
using System.Text.Json;
using allstarr.Models.Subsonic;
using allstarr.Services.Common;
using Microsoft.AspNetCore.Mvc;
namespace allstarr.Controllers;
public partial class JellyfinController
{
#region Search
/// <summary>
/// Searches local Jellyfin library and external providers.
/// Combines songs/albums/artists. Works with /Items and /Users/{userId}/Items.
/// </summary>
[HttpGet("Items", Order = 1)]
[HttpGet("Users/{userId}/Items", Order = 1)]
public async Task<IActionResult> SearchItems(
[FromQuery] string? searchTerm,
[FromQuery] string? includeItemTypes,
[FromQuery] int limit = 20,
[FromQuery] int startIndex = 0,
[FromQuery] string? parentId = null,
[FromQuery] string? artistIds = null,
[FromQuery] string? albumArtistIds = null,
[FromQuery] string? albumIds = null,
[FromQuery] string? sortBy = null,
[FromQuery] bool recursive = true,
string? userId = null)
{
// AlbumArtistIds takes precedence over ArtistIds if both are provided
var effectiveArtistIds = albumArtistIds ?? artistIds;
_logger.LogDebug(
"=== SEARCHITEMS V2 CALLED === searchTerm={SearchTerm}, includeItemTypes={ItemTypes}, parentId={ParentId}, artistIds={ArtistIds}, albumArtistIds={AlbumArtistIds}, albumIds={AlbumIds}, userId={UserId}",
searchTerm, includeItemTypes, parentId, artistIds, albumArtistIds, albumIds, userId);
// ============================================================================
// REQUEST ROUTING LOGIC (Priority Order)
// ============================================================================
// 1. ParentId present → GetChildItems (handles external playlists/albums/artists OR proxies library items)
// 2. AlbumIds present → Handle external albums OR proxy library albums
// 3. ArtistIds present → Handle external artists OR proxy library artists
// 4. SearchTerm present → Integrated search (Jellyfin + external sources)
// 5. Otherwise → Proxy browse request transparently to Jellyfin
// ============================================================================
// PRIORITY 1: ParentId takes precedence - handles both external and library items
if (!string.IsNullOrWhiteSpace(parentId))
{
// Check if this is the music library root with a search term - if so, do integrated search
var isMusicLibrary = parentId == _settings.LibraryId;
if (isMusicLibrary && !string.IsNullOrWhiteSpace(searchTerm))
{
_logger.LogInformation("Searching within music library {ParentId}, including external sources",
parentId);
// Fall through to integrated search below
}
else
{
// Browse parent item (external playlist/album/artist OR library item)
_logger.LogDebug("Browsing parent: {ParentId}", parentId);
return await GetChildItems(parentId, includeItemTypes, limit, startIndex, sortBy);
}
}
// PRIORITY 2: Filter by album (no parentId)
if (string.IsNullOrWhiteSpace(parentId) && !string.IsNullOrWhiteSpace(albumIds))
{
var albumId = albumIds.Split(',')[0]; // Take first album if multiple
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(albumId);
if (isExternal)
{
_logger.LogInformation("Fetching songs for external album: {Provider}/{ExternalId}", provider,
externalId);
var album = await _metadataService.GetAlbumAsync(provider!, externalId!);
if (album == null)
{
return new JsonResult(new
{ Items = Array.Empty<object>(), TotalRecordCount = 0, StartIndex = startIndex });
}
var albumItems = album.Songs.Select(song => _responseBuilder.ConvertSongToJellyfinItem(song)).ToList();
return new JsonResult(new
{
Items = albumItems,
TotalRecordCount = albumItems.Count,
StartIndex = startIndex
});
}
else
{
// Library album - proxy transparently with full query string
_logger.LogDebug("Library album filter requested: {AlbumId}, proxying to Jellyfin", albumId);
var endpoint = userId != null
? $"Users/{userId}/Items{Request.QueryString}"
: $"Items{Request.QueryString}";
var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
return HandleProxyResponse(result, statusCode);
}
}
// PRIORITY 3: Filter by artist (no parentId, no albumIds)
if (string.IsNullOrWhiteSpace(parentId) && string.IsNullOrWhiteSpace(albumIds) &&
!string.IsNullOrWhiteSpace(effectiveArtistIds))
{
var artistId = effectiveArtistIds.Split(',')[0]; // Take first artist if multiple
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(artistId);
if (isExternal)
{
// Check if this is a curator ID (format: ext-{provider}-curator-{name})
if (artistId.Contains("-curator-", StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation("Fetching playlists for curator: {ArtistId}", artistId);
return await GetCuratorPlaylists(provider!, externalId!, includeItemTypes);
}
_logger.LogInformation("Fetching content for external artist: {Provider}/{ExternalId}", provider,
externalId);
return await GetExternalChildItems(provider!, externalId!, includeItemTypes);
}
else
{
// Library artist - proxy transparently with full query string
_logger.LogDebug("Library artist filter requested: {ArtistId}, proxying to Jellyfin", artistId);
var endpoint = userId != null
? $"Users/{userId}/Items{Request.QueryString}"
: $"Items{Request.QueryString}";
var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
return HandleProxyResponse(result, statusCode);
}
}
// PRIORITY 4: Search term present - do integrated search (Jellyfin + external)
if (!string.IsNullOrWhiteSpace(searchTerm))
{
// Check cache for search results (only cache pure searches, not filtered searches)
if (string.IsNullOrWhiteSpace(effectiveArtistIds) && string.IsNullOrWhiteSpace(albumIds))
{
var cacheKey = CacheKeyBuilder.BuildSearchKey(searchTerm, includeItemTypes, limit, startIndex);
var cachedResult = await _cache.GetAsync<object>(cacheKey);
if (cachedResult != null)
{
_logger.LogDebug("✅ Returning cached search results for '{SearchTerm}'", searchTerm);
return new JsonResult(cachedResult);
}
}
// Fall through to integrated search below
}
// PRIORITY 5: No filters, no search - proxy browse request transparently
else
{
_logger.LogDebug("Browse request with no filters, proxying to Jellyfin with full query string");
var endpoint = userId != null ? $"Users/{userId}/Items" : "Items";
// Ensure MediaSources is included in Fields parameter for bitrate info
var queryString = Request.QueryString.Value ?? "";
if (!string.IsNullOrEmpty(queryString))
{
// Parse query string to modify Fields parameter
var queryParams = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString);
if (queryParams.ContainsKey("Fields"))
{
var fieldsValue = queryParams["Fields"].ToString();
if (!fieldsValue.Contains("MediaSources", StringComparison.OrdinalIgnoreCase))
{
// Append MediaSources to existing Fields
var newFields = string.IsNullOrEmpty(fieldsValue)
? "MediaSources"
: $"{fieldsValue},MediaSources";
// Rebuild query string with updated Fields
var newQueryParams = new Dictionary<string, string>();
foreach (var kvp in queryParams)
{
if (kvp.Key == "Fields")
{
newQueryParams[kvp.Key] = newFields;
}
else
{
newQueryParams[kvp.Key] = kvp.Value.ToString();
}
}
queryString = "?" + string.Join("&", newQueryParams.Select(kvp =>
$"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value)}"));
}
}
else
{
// No Fields parameter, add it
queryString = $"{queryString}&Fields=MediaSources";
}
}
else
{
// No query string at all
queryString = "?Fields=MediaSources";
}
endpoint = $"{endpoint}{queryString}";
var (browseResult, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
if (browseResult == null)
{
if (statusCode == 401)
{
_logger.LogInformation("Jellyfin returned 401 Unauthorized, returning 401 to client");
return Unauthorized(new { error = "Authentication required" });
}
_logger.LogDebug("Jellyfin returned {StatusCode}, returning empty result", statusCode);
return new JsonResult(new
{ Items = Array.Empty<object>(), TotalRecordCount = 0, StartIndex = startIndex });
}
// Update Spotify playlist counts if enabled and response contains playlists
if (_spotifySettings.Enabled && browseResult.RootElement.TryGetProperty("Items", out var _))
{
_logger.LogDebug("Browse result has Items, checking for Spotify playlists to update counts");
browseResult = await UpdateSpotifyPlaylistCounts(browseResult);
}
var result = JsonSerializer.Deserialize<object>(browseResult.RootElement.GetRawText());
if (_logger.IsEnabled(LogLevel.Debug))
{
var rawText = browseResult.RootElement.GetRawText();
var preview = rawText.Length > 200 ? rawText[..200] : rawText;
_logger.LogDebug("Jellyfin browse result preview: {Result}", preview);
}
return new JsonResult(result);
}
// ============================================================================
// INTEGRATED SEARCH: Search both Jellyfin library and external sources
// ============================================================================
var cleanQuery = searchTerm?.Trim().Trim('"') ?? "";
_logger.LogDebug("Performing integrated search for: {Query}", cleanQuery);
// Run local and external searches in parallel
var itemTypes = ParseItemTypes(includeItemTypes);
var jellyfinTask = _proxyService.SearchAsync(cleanQuery, itemTypes, limit, recursive, Request.Headers);
// Use parallel metadata service if available (races providers), otherwise use primary
var externalTask = _parallelMetadataService != null
? _parallelMetadataService.SearchAllAsync(cleanQuery, limit, limit, limit)
: _metadataService.SearchAllAsync(cleanQuery, limit, limit, limit);
var playlistTask = _settings.EnableExternalPlaylists
? _metadataService.SearchPlaylistsAsync(cleanQuery, limit)
: Task.FromResult(new List<ExternalPlaylist>());
_logger.LogDebug("Playlist search enabled: {Enabled}, searching for: '{Query}'",
_settings.EnableExternalPlaylists, cleanQuery);
await Task.WhenAll(jellyfinTask, externalTask, playlistTask);
var (jellyfinResult, _) = await jellyfinTask;
var externalResult = await externalTask;
var playlistResult = await playlistTask;
_logger.LogInformation(
"Search results for '{Query}': Jellyfin={JellyfinCount}, External Songs={ExtSongs}, Albums={ExtAlbums}, Artists={ExtArtists}, Playlists={Playlists}",
cleanQuery,
jellyfinResult != null ? "found" : "null",
externalResult.Songs.Count,
externalResult.Albums.Count,
externalResult.Artists.Count,
playlistResult.Count);
// Parse Jellyfin results into domain models
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult);
// Sort all results by match score (local tracks get +10 boost)
// This ensures best matches appear first regardless of source
var allSongs = localSongs.Concat(externalResult.Songs)
.Select(s => new
{ Song = s, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title) + (s.IsLocal ? 10.0 : 0.0) })
.OrderByDescending(x => x.Score)
.Select(x => x.Song)
.ToList();
var allAlbums = localAlbums.Concat(externalResult.Albums)
.Select(a => new
{ Album = a, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title) + (a.IsLocal ? 10.0 : 0.0) })
.OrderByDescending(x => x.Score)
.Select(x => x.Album)
.ToList();
var allArtists = localArtists.Concat(externalResult.Artists)
.Select(a => new
{ Artist = a, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name) + (a.IsLocal ? 10.0 : 0.0) })
.OrderByDescending(x => x.Score)
.Select(x => x.Artist)
.ToList();
// Log top results for debugging
if (_logger.IsEnabled(LogLevel.Debug))
{
if (allSongs.Any())
{
var topSong = allSongs.First();
var topScore = FuzzyMatcher.CalculateSimilarity(cleanQuery, topSong.Title) +
(topSong.IsLocal ? 10.0 : 0.0);
_logger.LogDebug("🎵 Top song: '{Title}' (local={IsLocal}, score={Score:F2})",
topSong.Title, topSong.IsLocal, topScore);
}
if (allAlbums.Any())
{
var topAlbum = allAlbums.First();
var topScore = FuzzyMatcher.CalculateSimilarity(cleanQuery, topAlbum.Title) +
(topAlbum.IsLocal ? 10.0 : 0.0);
_logger.LogDebug("💿 Top album: '{Title}' (local={IsLocal}, score={Score:F2})",
topAlbum.Title, topAlbum.IsLocal, topScore);
}
if (allArtists.Any())
{
var topArtist = allArtists.First();
var topScore = FuzzyMatcher.CalculateSimilarity(cleanQuery, topArtist.Name) +
(topArtist.IsLocal ? 10.0 : 0.0);
_logger.LogDebug("🎤 Top artist: '{Name}' (local={IsLocal}, score={Score:F2})",
topArtist.Name, topArtist.IsLocal, topScore);
}
}
// Convert to Jellyfin format
var mergedSongs = allSongs.Select(s => _responseBuilder.ConvertSongToJellyfinItem(s)).ToList();
var mergedAlbums = allAlbums.Select(a => _responseBuilder.ConvertAlbumToJellyfinItem(a)).ToList();
var mergedArtists = allArtists.Select(a => _responseBuilder.ConvertArtistToJellyfinItem(a)).ToList();
// Add playlists with scoring (albums get +10 boost over playlists)
// Playlists are mixed with albums due to Jellyfin API limitations (no dedicated playlist search)
var mergedPlaylistsWithScore = new List<(Dictionary<string, object?> Item, double Score)>();
if (playlistResult.Count > 0)
{
_logger.LogInformation("Processing {Count} playlists for merging with albums", playlistResult.Count);
foreach (var playlist in playlistResult)
{
var playlistItem = _responseBuilder.ConvertPlaylistToAlbumItem(playlist);
var score = FuzzyMatcher.CalculateSimilarity(cleanQuery, playlist.Name);
mergedPlaylistsWithScore.Add((playlistItem, score));
_logger.LogDebug("Playlist '{Name}' score: {Score:F2}", playlist.Name, score);
}
_logger.LogInformation("Found {Count} playlists, merging with albums (albums get +10 score boost)",
playlistResult.Count);
}
else
{
_logger.LogDebug("No playlists found to merge with albums");
}
// Merge albums and playlists, sorted by score (albums get +10 boost)
var albumsWithScore = mergedAlbums.Select(a =>
{
var title = a.TryGetValue("Name", out var nameObj) && nameObj is JsonElement nameEl
? nameEl.GetString() ?? ""
: "";
var score = FuzzyMatcher.CalculateSimilarity(cleanQuery, title) + 10.0; // Albums get +10 boost
return (Item: a, Score: score);
});
var mergedAlbumsAndPlaylists = albumsWithScore
.Concat(mergedPlaylistsWithScore)
.OrderByDescending(x => x.Score)
.Select(x => x.Item)
.ToList();
_logger.LogDebug(
"Merged and sorted results by score: Songs={Songs}, Albums+Playlists={AlbumsPlaylists}, Artists={Artists}",
mergedSongs.Count, mergedAlbumsAndPlaylists.Count, mergedArtists.Count);
// Pre-fetch lyrics for top 3 songs in background (don't await)
if (_lrclibService != null && mergedSongs.Count > 0)
{
_ = Task.Run(async () =>
{
try
{
var top3 = mergedSongs.Take(3).ToList();
_logger.LogDebug("🎵 Pre-fetching lyrics for top {Count} search results", top3.Count);
foreach (var songItem in top3)
{
if (songItem.TryGetValue("Name", out var nameObj) && nameObj is JsonElement nameEl &&
songItem.TryGetValue("Artists", out var artistsObj) &&
artistsObj is JsonElement artistsEl &&
artistsEl.GetArrayLength() > 0)
{
var title = nameEl.GetString() ?? "";
var artist = artistsEl[0].GetString() ?? "";
if (!string.IsNullOrEmpty(title) && !string.IsNullOrEmpty(artist))
{
await _lrclibService.GetLyricsAsync(title, artist, "", 0);
}
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to pre-fetch lyrics for search results");
}
});
}
// Filter by item types if specified
var items = new List<Dictionary<string, object?>>();
_logger.LogDebug("Filtering by item types: {ItemTypes}",
itemTypes == null ? "null" : string.Join(",", itemTypes));
if (itemTypes == null || itemTypes.Length == 0 || itemTypes.Contains("MusicArtist"))
{
_logger.LogDebug("Adding {Count} artists to results", mergedArtists.Count);
items.AddRange(mergedArtists);
}
if (itemTypes == null || itemTypes.Length == 0 || itemTypes.Contains("MusicAlbum") ||
itemTypes.Contains("Playlist"))
{
_logger.LogDebug("Adding {Count} albums+playlists to results", mergedAlbumsAndPlaylists.Count);
items.AddRange(mergedAlbumsAndPlaylists);
}
if (itemTypes == null || itemTypes.Length == 0 || itemTypes.Contains("Audio"))
{
_logger.LogDebug("Adding {Count} songs to results", mergedSongs.Count);
items.AddRange(mergedSongs);
}
// Apply pagination
var pagedItems = items.Skip(startIndex).Take(limit).ToList();
_logger.LogDebug("Returning {Count} items (total: {Total})", pagedItems.Count, items.Count);
try
{
// Return with PascalCase - use ContentResult to bypass JSON serialization issues
var response = new
{
Items = pagedItems,
TotalRecordCount = items.Count,
StartIndex = startIndex
};
// Cache search results in Redis (15 min TTL, no file persistence)
if (!string.IsNullOrWhiteSpace(searchTerm) && string.IsNullOrWhiteSpace(effectiveArtistIds))
{
var cacheKey = CacheKeyBuilder.BuildSearchKey(searchTerm, includeItemTypes, limit, startIndex);
await _cache.SetAsync(cacheKey, response, CacheExtensions.SearchResultsTTL);
_logger.LogDebug("💾 Cached search results for '{SearchTerm}' ({Minutes} min TTL)", searchTerm,
CacheExtensions.SearchResultsTTL.TotalMinutes);
}
_logger.LogDebug("About to serialize response...");
var json = System.Text.Json.JsonSerializer.Serialize(response, new System.Text.Json.JsonSerializerOptions
{
PropertyNamingPolicy = null,
DictionaryKeyPolicy = null
});
if (_logger.IsEnabled(LogLevel.Debug))
{
var preview = json.Length > 200 ? json[..200] : json;
_logger.LogDebug("JSON response preview: {Json}", preview);
}
return Content(json, "application/json");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error serializing search response");
throw;
}
}
/// <summary>
/// Gets child items of a parent (tracks in album, albums for artist).
/// </summary>
private async Task<IActionResult> GetChildItems(
string parentId,
string? includeItemTypes,
int limit,
int startIndex,
string? sortBy)
{
// Check if this is an external playlist
if (PlaylistIdHelper.IsExternalPlaylist(parentId))
{
return await GetPlaylistTracks(parentId);
}
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(parentId);
if (isExternal)
{
// Get external album or artist content
return await GetExternalChildItems(provider!, externalId!, includeItemTypes);
}
// For library items, proxy transparently with full query string
_logger.LogDebug("Proxying library item request to Jellyfin: ParentId={ParentId}", parentId);
var endpoint = $"Users/{Request.RouteValues["userId"]}/Items{Request.QueryString}";
var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
return HandleProxyResponse(result, statusCode);
}
/// <summary>
/// Quick search endpoint. Works with /Search/Hints and /Users/{userId}/Search/Hints.
/// </summary>
[HttpGet("Search/Hints", Order = 1)]
[HttpGet("Users/{userId}/Search/Hints", Order = 1)]
public async Task<IActionResult> SearchHints(
[FromQuery] string searchTerm,
[FromQuery] int limit = 20,
[FromQuery] string? includeItemTypes = null,
string? userId = null)
{
if (string.IsNullOrWhiteSpace(searchTerm))
{
return _responseBuilder.CreateJsonResponse(new
{
SearchHints = Array.Empty<object>(),
TotalRecordCount = 0
});
}
var cleanQuery = searchTerm.Trim().Trim('"');
var itemTypes = ParseItemTypes(includeItemTypes);
// Run searches in parallel
var jellyfinTask = _proxyService.SearchAsync(cleanQuery, itemTypes, limit, true, Request.Headers);
var externalTask = _metadataService.SearchAllAsync(cleanQuery, limit, limit, limit);
await Task.WhenAll(jellyfinTask, externalTask);
var (jellyfinResult, _) = await jellyfinTask;
var externalResult = await externalTask;
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult);
// NO deduplication - merge all results and take top matches
var allSongs = localSongs.Concat(externalResult.Songs).Take(limit).ToList();
var allAlbums = localAlbums.Concat(externalResult.Albums).Take(limit).ToList();
var allArtists = localArtists.Concat(externalResult.Artists).Take(limit).ToList();
return _responseBuilder.CreateSearchHintsResponse(
allSongs.Take(limit).ToList(),
allAlbums.Take(limit).ToList(),
allArtists.Take(limit).ToList());
}
#endregion
}
@@ -0,0 +1,946 @@
using System.Text.Json;
using allstarr.Models.Domain;
using allstarr.Models.Spotify;
using allstarr.Services.Admin;
using allstarr.Services.Common;
using Microsoft.AspNetCore.Mvc;
namespace allstarr.Controllers;
public partial class JellyfinController
{
#region Spotify Playlist Injection
/// <summary>
/// Gets tracks for a Spotify playlist by matching missing tracks against external providers
/// and merging with existing local tracks from Jellyfin.
///
/// Supports two modes:
/// 1. Direct Spotify API (new): Uses SpotifyPlaylistFetcher for ordered tracks with ISRC matching
/// 2. Jellyfin Plugin (legacy): Uses MissingTrack data from Jellyfin Spotify Import plugin
/// </summary>
private async Task<IActionResult> GetSpotifyPlaylistTracksAsync(string spotifyPlaylistName, string playlistId)
{
try
{
// Only inject tracks if Spotify API is enabled
if (_spotifyApiSettings.Enabled && _spotifyPlaylistFetcher != null)
{
var orderedResult = await GetSpotifyPlaylistTracksOrderedAsync(spotifyPlaylistName, playlistId);
if (orderedResult != null) return orderedResult;
}
// Spotify API not enabled or no ordered tracks - proxy through without modification
_logger.LogInformation(
"Spotify API not enabled or no tracks found, proxying playlist {PlaylistName} without modification",
spotifyPlaylistName);
var endpoint = $"Playlists/{playlistId}/Items";
if (Request.QueryString.HasValue)
{
endpoint = $"{endpoint}{Request.QueryString.Value}";
}
var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
return HandleProxyResponse(result, statusCode);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting Spotify playlist tracks {PlaylistName}", spotifyPlaylistName);
return _responseBuilder.CreateError(500, "Failed to get Spotify playlist tracks");
}
}
/// <summary>
/// New mode: Gets playlist tracks with correct ordering using direct Spotify API data.
/// Optimized to only re-match when Jellyfin playlist changes (cheap check).
/// </summary>
private async Task<IActionResult?> GetSpotifyPlaylistTracksOrderedAsync(string spotifyPlaylistName,
string playlistId)
{
// Check if Jellyfin playlist has changed (cheap API call)
var jellyfinSignatureCacheKey = $"spotify:playlist:jellyfin-signature:{spotifyPlaylistName}";
var currentJellyfinSignature = await GetJellyfinPlaylistSignatureAsync(playlistId);
var cachedJellyfinSignature = await _cache.GetAsync<string>(jellyfinSignatureCacheKey);
var jellyfinPlaylistChanged = cachedJellyfinSignature != currentJellyfinSignature;
// Check Redis cache first for fast serving (only if Jellyfin playlist hasn't changed)
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(spotifyPlaylistName);
var cachedItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(cacheKey);
if (cachedItems != null && cachedItems.Count > 0 && !jellyfinPlaylistChanged)
{
_logger.LogDebug("✅ Loaded {Count} playlist items from Redis cache for {Playlist} (Jellyfin unchanged)",
cachedItems.Count, spotifyPlaylistName);
return new JsonResult(new
{
Items = cachedItems,
TotalRecordCount = cachedItems.Count,
StartIndex = 0
});
}
if (jellyfinPlaylistChanged)
{
_logger.LogInformation("🔄 Jellyfin playlist changed for {Playlist} - re-matching tracks",
spotifyPlaylistName);
}
// Check file cache as fallback
var fileItems = await LoadPlaylistItemsFromFile(spotifyPlaylistName);
if (fileItems != null && fileItems.Count > 0)
{
_logger.LogDebug("✅ Loaded {Count} playlist items from file cache for {Playlist}",
fileItems.Count, spotifyPlaylistName);
// Restore to Redis cache
await _cache.SetAsync(cacheKey, fileItems, CacheExtensions.SpotifyPlaylistItemsTTL);
return new JsonResult(new
{
Items = fileItems,
TotalRecordCount = fileItems.Count,
StartIndex = 0
});
}
// Check for ordered matched tracks from SpotifyTrackMatchingService
var orderedCacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(spotifyPlaylistName);
var orderedTracks = await _cache.GetAsync<List<MatchedTrack>>(orderedCacheKey);
if (orderedTracks == null || orderedTracks.Count == 0)
{
_logger.LogInformation("No ordered matched tracks in cache for {Playlist}, checking if we can fetch",
spotifyPlaylistName);
return null; // Fall back to legacy mode
}
_logger.LogInformation("Using {Count} ordered matched tracks for {Playlist}",
orderedTracks.Count, spotifyPlaylistName);
// Get existing Jellyfin playlist items (RAW - don't convert!)
// CRITICAL: Must include UserId parameter or Jellyfin returns empty results
var userId = _settings.UserId;
if (string.IsNullOrEmpty(userId))
{
_logger.LogError(
"❌ JELLYFIN_USER_ID is NOT configured! Cannot fetch playlist tracks. Set it in .env or admin UI.");
return null; // Fall back to legacy mode
}
// Pass through all requested fields from the original request
var queryString = Request.QueryString.Value ?? "";
var playlistItemsUrl = $"Playlists/{playlistId}/Items?UserId={userId}";
// Append the original query string (which includes Fields parameter)
if (!string.IsNullOrEmpty(queryString))
{
// Remove the leading ? if present
queryString = queryString.TrimStart('?');
playlistItemsUrl = $"{playlistItemsUrl}&{queryString}";
}
_logger.LogInformation("🔍 Fetching existing tracks from Jellyfin playlist {PlaylistId} with UserId {UserId}",
playlistId, userId);
var (existingTracksResponse, statusCode) = await _proxyService.GetJsonAsync(
playlistItemsUrl,
null,
Request.Headers);
if (statusCode != 200)
{
_logger.LogError(
"❌ Failed to fetch Jellyfin playlist items: HTTP {StatusCode}. Check JELLYFIN_USER_ID is correct.",
statusCode);
return null;
}
// Keep raw Jellyfin items - don't convert to Song objects!
var jellyfinItems = new List<JsonElement>();
var jellyfinItemsByName = new Dictionary<string, JsonElement>();
if (existingTracksResponse != null &&
existingTracksResponse.RootElement.TryGetProperty("Items", out var items))
{
foreach (var item in items.EnumerateArray())
{
jellyfinItems.Add(item);
// Index by title+artist for matching
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
var artist = "";
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
{
artist = artistsEl[0].GetString() ?? "";
}
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
{
artist = albumArtistEl.GetString() ?? "";
}
var key = $"{title}|{artist}".ToLowerInvariant();
if (!jellyfinItemsByName.ContainsKey(key))
{
jellyfinItemsByName[key] = item;
}
}
_logger.LogInformation("✅ Found {Count} existing LOCAL tracks in Jellyfin playlist", jellyfinItems.Count);
}
else
{
_logger.LogWarning("⚠️ No existing tracks found in Jellyfin playlist {PlaylistId} - playlist may be empty",
playlistId);
}
// Get the full playlist from Spotify to know the correct order
var spotifyTracks = await _spotifyPlaylistFetcher!.GetPlaylistTracksAsync(spotifyPlaylistName);
if (spotifyTracks.Count == 0)
{
_logger.LogWarning("Could not get Spotify playlist tracks for {Playlist}", spotifyPlaylistName);
return null; // Fall back to legacy
}
// Build the final track list in correct Spotify order
var finalItems = new List<Dictionary<string, object?>>();
var usedJellyfinItems = new HashSet<string>();
var localUsedCount = 0;
var externalUsedCount = 0;
_logger.LogDebug("🔍 Building playlist in Spotify order with {SpotifyCount} positions...", spotifyTracks.Count);
foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position))
{
// Try to find matching Jellyfin item by fuzzy matching
JsonElement? matchedJellyfinItem = null;
string? matchedKey = null;
double bestScore = 0;
foreach (var kvp in jellyfinItemsByName)
{
if (usedJellyfinItems.Contains(kvp.Key)) continue;
var item = kvp.Value;
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
var artist = "";
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
{
artist = artistsEl[0].GetString() ?? "";
}
var titleScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.Title, title);
var artistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, artist);
var totalScore = (titleScore * 0.7) + (artistScore * 0.3);
if (totalScore > bestScore && totalScore >= 70)
{
bestScore = totalScore;
matchedJellyfinItem = item;
matchedKey = kvp.Key;
}
}
if (matchedJellyfinItem.HasValue && matchedKey != null)
{
// Use the raw Jellyfin item (preserves ALL metadata including MediaSources!)
var itemDict = JsonElementToDictionary(matchedJellyfinItem.Value);
finalItems.Add(itemDict);
usedJellyfinItems.Add(matchedKey);
localUsedCount++;
_logger.LogDebug("✅ Position #{Pos}: '{Title}' → LOCAL (score: {Score:F1}%)",
spotifyTrack.Position, spotifyTrack.Title, bestScore);
}
else
{
// No local match via fuzzy matching - try to find in orderedTracks cache
var matched = orderedTracks?.FirstOrDefault(t => t.SpotifyId == spotifyTrack.SpotifyId);
if (matched != null && matched.MatchedSong != null)
{
// Check if this is a LOCAL track that we should fetch from Jellyfin
if (matched.MatchedSong.IsLocal && !string.IsNullOrEmpty(matched.MatchedSong.Id))
{
// Try to find the full Jellyfin item by ID
var jellyfinItem = jellyfinItems.FirstOrDefault(item =>
item.TryGetProperty("Id", out var idProp) &&
idProp.GetString() == matched.MatchedSong.Id);
if (jellyfinItem.ValueKind != JsonValueKind.Undefined)
{
// Found the full Jellyfin item - use it!
var itemDict = JsonElementToDictionary(jellyfinItem);
finalItems.Add(itemDict);
localUsedCount++;
_logger.LogDebug("✅ Position #{Pos}: '{Title}' → LOCAL from cache (ID: {Id})",
spotifyTrack.Position, spotifyTrack.Title, matched.MatchedSong.Id);
continue;
}
else
{
_logger.LogWarning(
"⚠️ Position #{Pos}: '{Title}' marked as LOCAL but not found in Jellyfin items (ID: {Id})",
spotifyTrack.Position, spotifyTrack.Title, matched.MatchedSong.Id);
}
}
// External track or local track not found - convert Song to Jellyfin item format
var externalItem = _responseBuilder.ConvertSongToJellyfinItem(matched.MatchedSong);
// Add Spotify ID to ProviderIds so lyrics can work
if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId))
{
if (!externalItem.ContainsKey("ProviderIds"))
{
externalItem["ProviderIds"] = new Dictionary<string, string>();
}
var providerIds = externalItem["ProviderIds"] as Dictionary<string, string>;
if (providerIds != null && !providerIds.ContainsKey("Spotify"))
{
providerIds["Spotify"] = spotifyTrack.SpotifyId;
}
}
finalItems.Add(externalItem);
externalUsedCount++;
_logger.LogDebug(
"📥 Position #{Pos}: '{Title}' → EXTERNAL: {Provider}/{Id} (Spotify ID: {SpotifyId})",
spotifyTrack.Position, spotifyTrack.Title,
matched.MatchedSong.ExternalProvider, matched.MatchedSong.ExternalId, spotifyTrack.SpotifyId);
}
else
{
_logger.LogDebug("❌ Position #{Pos}: '{Title}' → NO MATCH",
spotifyTrack.Position, spotifyTrack.Title);
}
}
}
_logger.LogDebug("🎵 Final playlist '{Playlist}': {Total} tracks ({Local} LOCAL + {External} EXTERNAL)",
spotifyPlaylistName, finalItems.Count, localUsedCount, externalUsedCount);
// Save to file cache for persistence across restarts
await SavePlaylistItemsToFile(spotifyPlaylistName, finalItems);
// Also cache in Redis for fast serving (reuse the same cache key from top of method)
await _cache.SetAsync(cacheKey, finalItems, CacheExtensions.SpotifyPlaylistItemsTTL);
// Cache the Jellyfin playlist signature to detect future changes
await _cache.SetAsync(jellyfinSignatureCacheKey, currentJellyfinSignature,
CacheExtensions.SpotifyPlaylistItemsTTL);
// Return raw Jellyfin response format
return new JsonResult(new
{
Items = finalItems,
TotalRecordCount = finalItems.Count,
StartIndex = 0
});
}
/// <summary>
/// <summary>
/// Copies an external track to the kept folder when favorited.
/// </summary>
private async Task CopyExternalTrackToKeptAsync(string itemId, string provider, string externalId)
{
try
{
// Check if already favorited (persistent tracking)
if (await IsTrackFavoritedAsync(itemId))
{
_logger.LogInformation("Track already favorited (persistent): {ItemId}", itemId);
return;
}
// Get the song metadata first to build paths
var song = await _metadataService.GetSongAsync(provider, externalId);
if (song == null)
{
_logger.LogWarning("Could not find song metadata for {ItemId}", itemId);
return;
}
// Build kept folder path: Artist/Album/
var keptBasePath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
var keptArtistPath = Path.Combine(keptBasePath, AdminHelperService.SanitizeFileName(song.Artist));
var keptAlbumPath = Path.Combine(keptArtistPath, AdminHelperService.SanitizeFileName(song.Album));
// Check if track already exists in kept folder
if (Directory.Exists(keptAlbumPath))
{
var sanitizedTitle = AdminHelperService.SanitizeFileName(song.Title);
var existingFiles = Directory.GetFiles(keptAlbumPath, $"*{sanitizedTitle}*");
if (existingFiles.Length > 0)
{
_logger.LogInformation("Track already exists in kept folder: {Path}", existingFiles[0]);
// Mark as favorited even if we didn't download it
await MarkTrackAsFavoritedAsync(itemId, song);
return;
}
}
// Look for the track in cache folder first
var cacheBasePath = "/tmp/allstarr-cache";
var cacheArtistPath = Path.Combine(cacheBasePath, AdminHelperService.SanitizeFileName(song.Artist));
var cacheAlbumPath = Path.Combine(cacheArtistPath, AdminHelperService.SanitizeFileName(song.Album));
string? sourceFilePath = null;
if (Directory.Exists(cacheAlbumPath))
{
var sanitizedTitle = AdminHelperService.SanitizeFileName(song.Title);
var cacheFiles = Directory.GetFiles(cacheAlbumPath, $"*{sanitizedTitle}*");
if (cacheFiles.Length > 0)
{
sourceFilePath = cacheFiles[0];
_logger.LogDebug("Found track in cache folder: {Path}", sourceFilePath);
}
}
// If not in cache, download it first
if (sourceFilePath == null)
{
_logger.LogInformation("Track not in cache, downloading: {ItemId}", itemId);
try
{
// Use CancellationToken.None to ensure download completes even if user navigates away
sourceFilePath =
await _downloadService.DownloadSongAsync(provider, externalId, CancellationToken.None);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to download track {ItemId}", itemId);
return;
}
}
// Create the kept folder structure
Directory.CreateDirectory(keptAlbumPath);
// Copy file to kept folder
var fileName = Path.GetFileName(sourceFilePath);
var keptFilePath = Path.Combine(keptAlbumPath, fileName);
// Double-check in case of race condition (multiple favorite clicks)
if (System.IO.File.Exists(keptFilePath))
{
_logger.LogInformation("Track already exists in kept folder (race condition): {Path}", keptFilePath);
await MarkTrackAsFavoritedAsync(itemId, song);
return;
}
// Create hard link instead of copying to save space
// Both locations will point to the same file data on disk
try
{
// Use ln command on Unix systems for hard links
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
{
var process = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = "ln",
Arguments = $"\"{sourceFilePath}\" \"{keptFilePath}\"",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
});
if (process != null)
{
await process.WaitForExitAsync();
_logger.LogDebug("✓ Created hard link to kept folder: {Path}", keptFilePath);
}
}
else
{
// Fall back to copy on Windows
System.IO.File.Copy(sourceFilePath, keptFilePath, overwrite: false);
_logger.LogDebug("✓ Copied track to kept folder: {Path}", keptFilePath);
}
}
catch (Exception ex)
{
// Fall back to copy if hard link fails (e.g., different filesystems)
_logger.LogWarning(ex, "Failed to create hard link, falling back to copy");
System.IO.File.Copy(sourceFilePath, keptFilePath, overwrite: false);
_logger.LogDebug("✓ Copied track to kept folder: {Path}", keptFilePath);
}
// Also create hard link for cover art if it exists
var sourceCoverPath = Path.Combine(Path.GetDirectoryName(sourceFilePath)!, "cover.jpg");
if (System.IO.File.Exists(sourceCoverPath))
{
var keptCoverPath = Path.Combine(keptAlbumPath, "cover.jpg");
if (!System.IO.File.Exists(keptCoverPath))
{
try
{
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
{
var process = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = "ln",
Arguments = $"\"{sourceCoverPath}\" \"{keptCoverPath}\"",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
});
if (process != null)
{
await process.WaitForExitAsync();
_logger.LogDebug("Created hard link for cover art");
}
}
else
{
System.IO.File.Copy(sourceCoverPath, keptCoverPath, overwrite: false);
_logger.LogDebug("Copied cover art to kept folder");
}
}
catch
{
// Fall back to copy if hard link fails
System.IO.File.Copy(sourceCoverPath, keptCoverPath, overwrite: false);
_logger.LogDebug("Copied cover art to kept folder");
}
}
}
// Mark as favorited in persistent storage
await MarkTrackAsFavoritedAsync(itemId, song);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error copying external track {ItemId} to kept folder", itemId);
}
}
/// <summary>
/// Copies an external album (all tracks) to the kept folder when favorited.
/// </summary>
private async Task CopyExternalAlbumToKeptAsync(string itemId, string provider, string externalId)
{
try
{
// Get the album metadata with all tracks
var album = await _metadataService.GetAlbumAsync(provider, externalId);
if (album == null)
{
_logger.LogWarning("Could not find album metadata for {ItemId}", itemId);
return;
}
_logger.LogInformation("Downloading {Count} tracks from album: {Artist} - {Album}",
album.Songs.Count, album.Artist, album.Title);
// Download all tracks in the album
var downloadTasks = album.Songs.Select(async song =>
{
try
{
var songItemId = song.Id; // Already in format: ext-provider-song-id
var (_, songProvider, songExternalId) = _localLibraryService.ParseSongId(songItemId);
if (songProvider != null && songExternalId != null)
{
await CopyExternalTrackToKeptAsync(songItemId, songProvider, songExternalId);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to download track: {Title}", song.Title);
}
});
await Task.WhenAll(downloadTasks);
_logger.LogInformation("✓ Finished downloading album: {Artist} - {Album}", album.Artist, album.Title);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error copying external album {ItemId} to kept folder", itemId);
}
}
/// <summary>
/// Removes an external track from the kept folder when unfavorited.
/// </summary>
private async Task RemoveExternalTrackFromKeptAsync(string itemId, string provider, string externalId)
{
try
{
// Mark for deletion instead of immediate deletion
await MarkTrackForDeletionAsync(itemId);
_logger.LogInformation("✓ Marked track for deletion: {ItemId}", itemId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error marking external track {ItemId} for deletion", itemId);
}
}
#region Persistent Favorites Tracking
private readonly string _favoritesFilePath = "/app/cache/favorites.json";
/// <summary>
/// Checks if a track is already favorited (persistent across restarts).
/// </summary>
private async Task<bool> IsTrackFavoritedAsync(string itemId)
{
try
{
if (!System.IO.File.Exists(_favoritesFilePath))
return false;
var json = await System.IO.File.ReadAllTextAsync(_favoritesFilePath);
var favorites = JsonSerializer.Deserialize<Dictionary<string, FavoriteTrackInfo>>(json) ?? new();
return favorites.ContainsKey(itemId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to check favorite status for {ItemId}", itemId);
return false;
}
}
/// <summary>
/// Marks a track as favorited in persistent storage.
/// </summary>
private async Task MarkTrackAsFavoritedAsync(string itemId, Song song)
{
try
{
var favorites = new Dictionary<string, FavoriteTrackInfo>();
if (System.IO.File.Exists(_favoritesFilePath))
{
var json = await System.IO.File.ReadAllTextAsync(_favoritesFilePath);
favorites = JsonSerializer.Deserialize<Dictionary<string, FavoriteTrackInfo>>(json) ?? new();
}
favorites[itemId] = new FavoriteTrackInfo
{
ItemId = itemId,
Title = song.Title,
Artist = song.Artist,
Album = song.Album,
FavoritedAt = DateTime.UtcNow
};
// Ensure cache directory exists
Directory.CreateDirectory(Path.GetDirectoryName(_favoritesFilePath)!);
var updatedJson = JsonSerializer.Serialize(favorites, new JsonSerializerOptions { WriteIndented = true });
await System.IO.File.WriteAllTextAsync(_favoritesFilePath, updatedJson);
_logger.LogDebug("Marked track as favorited: {ItemId}", itemId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to mark track as favorited: {ItemId}", itemId);
}
}
/// <summary>
/// Removes a track from persistent favorites storage.
/// </summary>
private async Task UnmarkTrackAsFavoritedAsync(string itemId)
{
try
{
if (!System.IO.File.Exists(_favoritesFilePath))
return;
var json = await System.IO.File.ReadAllTextAsync(_favoritesFilePath);
var favorites = JsonSerializer.Deserialize<Dictionary<string, FavoriteTrackInfo>>(json) ?? new();
if (favorites.Remove(itemId))
{
var updatedJson =
JsonSerializer.Serialize(favorites, new JsonSerializerOptions { WriteIndented = true });
await System.IO.File.WriteAllTextAsync(_favoritesFilePath, updatedJson);
_logger.LogDebug("Removed track from favorites: {ItemId}", itemId);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to remove track from favorites: {ItemId}", itemId);
}
}
/// <summary>
/// Marks a track for deletion (delayed deletion for safety).
/// </summary>
private async Task MarkTrackForDeletionAsync(string itemId)
{
try
{
var deletionFilePath = "/app/cache/pending_deletions.json";
var pendingDeletions = new Dictionary<string, DateTime>();
if (System.IO.File.Exists(deletionFilePath))
{
var json = await System.IO.File.ReadAllTextAsync(deletionFilePath);
pendingDeletions = JsonSerializer.Deserialize<Dictionary<string, DateTime>>(json) ?? new();
}
// Mark for deletion 24 hours from now
pendingDeletions[itemId] = DateTime.UtcNow.AddHours(24);
// Ensure cache directory exists
Directory.CreateDirectory(Path.GetDirectoryName(deletionFilePath)!);
var updatedJson =
JsonSerializer.Serialize(pendingDeletions, new JsonSerializerOptions { WriteIndented = true });
await System.IO.File.WriteAllTextAsync(deletionFilePath, updatedJson);
// Also remove from favorites immediately
await UnmarkTrackAsFavoritedAsync(itemId);
_logger.LogDebug("Marked track for deletion in 24 hours: {ItemId}", itemId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to mark track for deletion: {ItemId}", itemId);
}
}
/// <summary>
/// Information about a favorited track for persistent storage.
/// </summary>
private class FavoriteTrackInfo
{
public string ItemId { get; set; } = "";
public string Title { get; set; } = "";
public string Artist { get; set; } = "";
public string Album { get; set; } = "";
public DateTime FavoritedAt { get; set; }
}
/// <summary>
/// Processes pending deletions (called by cleanup service).
/// </summary>
public async Task ProcessPendingDeletionsAsync()
{
try
{
var deletionFilePath = "/app/cache/pending_deletions.json";
if (!System.IO.File.Exists(deletionFilePath))
return;
var json = await System.IO.File.ReadAllTextAsync(deletionFilePath);
var pendingDeletions = JsonSerializer.Deserialize<Dictionary<string, DateTime>>(json) ?? new();
var now = DateTime.UtcNow;
var toDelete = pendingDeletions.Where(kvp => kvp.Value <= now).ToList();
var remaining = pendingDeletions.Where(kvp => kvp.Value > now)
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
foreach (var (itemId, _) in toDelete)
{
await ActuallyDeleteTrackAsync(itemId);
}
if (toDelete.Count > 0)
{
// Update pending deletions file
var updatedJson =
JsonSerializer.Serialize(remaining, new JsonSerializerOptions { WriteIndented = true });
await System.IO.File.WriteAllTextAsync(deletionFilePath, updatedJson);
_logger.LogDebug("Processed {Count} pending deletions", toDelete.Count);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing pending deletions");
}
}
/// <summary>
/// Actually deletes a track from the kept folder.
/// </summary>
private async Task ActuallyDeleteTrackAsync(string itemId)
{
try
{
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
if (!isExternal) return;
var song = await _metadataService.GetSongAsync(provider!, externalId!);
if (song == null) return;
var keptBasePath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
var keptArtistPath = Path.Combine(keptBasePath, AdminHelperService.SanitizeFileName(song.Artist));
var keptAlbumPath = Path.Combine(keptArtistPath, AdminHelperService.SanitizeFileName(song.Album));
if (!Directory.Exists(keptAlbumPath)) return;
var sanitizedTitle = AdminHelperService.SanitizeFileName(song.Title);
var trackFiles = Directory.GetFiles(keptAlbumPath, $"*{sanitizedTitle}*");
foreach (var trackFile in trackFiles)
{
System.IO.File.Delete(trackFile);
_logger.LogDebug("✓ Deleted track from kept folder: {Path}", trackFile);
}
// Clean up empty directories
if (Directory.GetFiles(keptAlbumPath).Length == 0 && Directory.GetDirectories(keptAlbumPath).Length == 0)
{
Directory.Delete(keptAlbumPath);
if (Directory.Exists(keptArtistPath) &&
Directory.GetFiles(keptArtistPath).Length == 0 &&
Directory.GetDirectories(keptArtistPath).Length == 0)
{
Directory.Delete(keptArtistPath);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete track {ItemId}", itemId);
}
}
#endregion
/// <summary>
/// Loads missing tracks from file cache as fallback when Redis is empty.
/// <summary>
/// Gets a signature (hash) of the Jellyfin playlist to detect changes.
/// This is a cheap operation compared to re-matching all tracks.
/// Signature includes: track count + concatenated track IDs.
/// </summary>
private async Task<string> GetJellyfinPlaylistSignatureAsync(string playlistId)
{
try
{
var userId = _settings.UserId;
var playlistItemsUrl = $"Playlists/{playlistId}/Items?Fields=Id";
if (!string.IsNullOrEmpty(userId))
{
playlistItemsUrl += $"&UserId={userId}";
}
var (response, _) = await _proxyService.GetJsonAsync(playlistItemsUrl, null, Request.Headers);
if (response != null && response.RootElement.TryGetProperty("Items", out var items))
{
var trackIds = new List<string>();
foreach (var item in items.EnumerateArray())
{
if (item.TryGetProperty("Id", out var idEl))
{
trackIds.Add(idEl.GetString() ?? "");
}
}
// Create signature: count + sorted IDs (sorted for consistency)
trackIds.Sort();
var signature = $"{trackIds.Count}:{string.Join(",", trackIds)}";
// Hash it to keep it compact
using var sha256 = System.Security.Cryptography.SHA256.Create();
var hashBytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(signature));
return Convert.ToHexString(hashBytes);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get Jellyfin playlist signature for {PlaylistId}", playlistId);
}
// Return empty string if failed (will trigger re-match)
return string.Empty;
}
/// <summary>
/// Saves playlist items (raw Jellyfin JSON) to file cache for persistence across restarts.
/// </summary>
private async Task SavePlaylistItemsToFile(string playlistName, List<Dictionary<string, object?>> items)
{
try
{
var cacheDir = "/app/cache/spotify";
Directory.CreateDirectory(cacheDir);
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
var filePath = Path.Combine(cacheDir, $"{safeName}_items.json");
var json = JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true });
await System.IO.File.WriteAllTextAsync(filePath, json);
_logger.LogDebug("💾 Saved {Count} playlist items to file cache for {Playlist}",
items.Count, playlistName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save playlist items to file for {Playlist}", playlistName);
}
}
/// <summary>
/// Loads playlist items (raw Jellyfin JSON) from file cache.
/// </summary>
private async Task<List<Dictionary<string, object?>>?> LoadPlaylistItemsFromFile(string playlistName)
{
try
{
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
var filePath = Path.Combine("/app/cache/spotify", $"{safeName}_items.json");
if (!System.IO.File.Exists(filePath))
{
_logger.LogDebug("No playlist items file cache found for {Playlist} at {Path}", playlistName, filePath);
return null;
}
var fileAge = DateTime.UtcNow - System.IO.File.GetLastWriteTimeUtc(filePath);
// Check if cache is too old (more than 24 hours)
if (fileAge.TotalHours > 24)
{
_logger.LogDebug("Playlist items file cache for {Playlist} is too old ({Age:F1}h), will rebuild",
playlistName, fileAge.TotalHours);
return null;
}
_logger.LogDebug("Playlist items file cache for {Playlist} age: {Age:F1}h", playlistName,
fileAge.TotalHours);
var json = await System.IO.File.ReadAllTextAsync(filePath);
// Parse as JsonDocument first to preserve nested structures
using var doc = JsonDocument.Parse(json);
var items = new List<Dictionary<string, object?>>();
if (doc.RootElement.ValueKind == JsonValueKind.Array)
{
foreach (var item in doc.RootElement.EnumerateArray())
{
items.Add(JsonElementToDictionary(item));
}
}
_logger.LogDebug("💿 Loaded {Count} playlist items from file cache for {Playlist} (age: {Age:F1}h)",
items.Count, playlistName, fileAge.TotalHours);
return items;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load playlist items from file for {Playlist}", playlistName);
return null;
}
}
#endregion
}
+161 -3332
View File
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Options;
using System.Text.Json;
using allstarr.Models.Domain;
using allstarr.Models.Lyrics;
using allstarr.Models.Scrobbling;
using allstarr.Models.Settings;
using allstarr.Models.Subsonic;
using allstarr.Models.Spotify;
@@ -13,6 +14,7 @@ using allstarr.Services.Jellyfin;
using allstarr.Services.Subsonic;
using allstarr.Services.Lyrics;
using allstarr.Services.Spotify;
using allstarr.Services.Scrobbling;
using allstarr.Services.Admin;
using allstarr.Filters;
@@ -24,11 +26,12 @@ namespace allstarr.Controllers;
/// </summary>
[ApiController]
[Route("")]
public class JellyfinController : ControllerBase
public partial class JellyfinController : ControllerBase
{
private readonly JellyfinSettings _settings;
private readonly SpotifyImportSettings _spotifySettings;
private readonly SpotifyApiSettings _spotifyApiSettings;
private readonly ScrobblingSettings _scrobblingSettings;
private readonly IMusicMetadataService _metadataService;
private readonly ParallelMetadataService? _parallelMetadataService;
private readonly ILocalLibraryService _localLibraryService;
@@ -43,6 +46,8 @@ public class JellyfinController : ControllerBase
private readonly LyricsPlusService? _lyricsPlusService;
private readonly LrclibService? _lrclibService;
private readonly LyricsOrchestrator? _lyricsOrchestrator;
private readonly ScrobblingOrchestrator? _scrobblingOrchestrator;
private readonly ScrobblingHelper? _scrobblingHelper;
private readonly OdesliService _odesliService;
private readonly RedisCacheService _cache;
private readonly IConfiguration _configuration;
@@ -52,6 +57,7 @@ public class JellyfinController : ControllerBase
IOptions<JellyfinSettings> settings,
IOptions<SpotifyImportSettings> spotifySettings,
IOptions<SpotifyApiSettings> spotifyApiSettings,
IOptions<ScrobblingSettings> scrobblingSettings,
IMusicMetadataService metadataService,
ILocalLibraryService localLibraryService,
IDownloadService downloadService,
@@ -69,11 +75,14 @@ public class JellyfinController : ControllerBase
SpotifyLyricsService? spotifyLyricsService = null,
LyricsPlusService? lyricsPlusService = null,
LrclibService? lrclibService = null,
LyricsOrchestrator? lyricsOrchestrator = null)
LyricsOrchestrator? lyricsOrchestrator = null,
ScrobblingOrchestrator? scrobblingOrchestrator = null,
ScrobblingHelper? scrobblingHelper = null)
{
_settings = settings.Value;
_spotifySettings = spotifySettings.Value;
_spotifyApiSettings = spotifyApiSettings.Value;
_scrobblingSettings = scrobblingSettings.Value;
_metadataService = metadataService;
_parallelMetadataService = parallelMetadataService;
_localLibraryService = localLibraryService;
@@ -88,6 +97,8 @@ public class JellyfinController : ControllerBase
_lyricsPlusService = lyricsPlusService;
_lrclibService = lrclibService;
_lyricsOrchestrator = lyricsOrchestrator;
_scrobblingOrchestrator = scrobblingOrchestrator;
_scrobblingHelper = scrobblingHelper;
_odesliService = odesliService;
_cache = cache;
_configuration = configuration;
@@ -99,440 +110,6 @@ public class JellyfinController : ControllerBase
}
}
#region Search
/// <summary>
/// Searches local Jellyfin library and external providers.
/// Combines songs/albums/artists. Works with /Items and /Users/{userId}/Items.
/// </summary>
[HttpGet("Items", Order = 1)]
[HttpGet("Users/{userId}/Items", Order = 1)]
public async Task<IActionResult> SearchItems(
[FromQuery] string? searchTerm,
[FromQuery] string? includeItemTypes,
[FromQuery] int limit = 20,
[FromQuery] int startIndex = 0,
[FromQuery] string? parentId = null,
[FromQuery] string? artistIds = null,
[FromQuery] string? sortBy = null,
[FromQuery] bool recursive = true,
string? userId = null)
{
_logger.LogDebug("=== SEARCHITEMS V2 CALLED === searchTerm={SearchTerm}, includeItemTypes={ItemTypes}, parentId={ParentId}, artistIds={ArtistIds}, userId={UserId}",
searchTerm, includeItemTypes, parentId, artistIds, userId);
// Cache search results in Redis only (no file persistence, 15 min TTL)
// Only cache actual searches, not browse operations
if (!string.IsNullOrWhiteSpace(searchTerm) && string.IsNullOrWhiteSpace(artistIds))
{
var cacheKey = CacheKeyBuilder.BuildSearchKey(searchTerm, includeItemTypes, limit, startIndex);
var cachedResult = await _cache.GetAsync<object>(cacheKey);
if (cachedResult != null)
{
_logger.LogDebug("✅ Returning cached search results for '{SearchTerm}'", searchTerm);
return new JsonResult(cachedResult);
}
}
// If filtering by artist, handle external artists
if (!string.IsNullOrWhiteSpace(artistIds))
{
var artistId = artistIds.Split(',')[0]; // Take first artist if multiple
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(artistId);
if (isExternal)
{
_logger.LogInformation("Fetching albums for external artist: {Provider}/{ExternalId}", provider, externalId);
return await GetExternalChildItems(provider!, externalId!, includeItemTypes);
}
}
// If no search term, proxy to Jellyfin for browsing
// If Jellyfin returns empty results, we'll just return empty (not mixing browse with external)
if (string.IsNullOrWhiteSpace(searchTerm) && string.IsNullOrWhiteSpace(parentId))
{
_logger.LogDebug("No search term or parentId, proxying to Jellyfin with full query string");
// Build the full endpoint path with query string
var endpoint = userId != null ? $"Users/{userId}/Items" : "Items";
// Ensure MediaSources is included in Fields parameter for bitrate info
var queryString = Request.QueryString.Value ?? "";
if (!string.IsNullOrEmpty(queryString))
{
// Parse query string to modify Fields parameter
var queryParams = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString);
if (queryParams.ContainsKey("Fields"))
{
var fieldsValue = queryParams["Fields"].ToString();
if (!fieldsValue.Contains("MediaSources", StringComparison.OrdinalIgnoreCase))
{
// Append MediaSources to existing Fields
var newFields = string.IsNullOrEmpty(fieldsValue)
? "MediaSources"
: $"{fieldsValue},MediaSources";
// Rebuild query string with updated Fields
var newQueryParams = new Dictionary<string, string>();
foreach (var kvp in queryParams)
{
if (kvp.Key == "Fields")
{
newQueryParams[kvp.Key] = newFields;
}
else
{
newQueryParams[kvp.Key] = kvp.Value.ToString();
}
}
queryString = "?" + string.Join("&", newQueryParams.Select(kvp =>
$"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value)}"));
}
}
else
{
// No Fields parameter, add it
queryString = $"{queryString}&Fields=MediaSources";
}
}
else
{
// No query string at all
queryString = "?Fields=MediaSources";
}
endpoint = $"{endpoint}{queryString}";
var (browseResult, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
if (browseResult == null)
{
if (statusCode == 401)
{
_logger.LogInformation("Jellyfin returned 401 Unauthorized, returning 401 to client");
return Unauthorized(new { error = "Authentication required" });
}
_logger.LogDebug("Jellyfin returned {StatusCode}, returning empty result", statusCode);
return new JsonResult(new { Items = Array.Empty<object>(), TotalRecordCount = 0, StartIndex = startIndex });
}
// Update Spotify playlist counts if enabled and response contains playlists
if (_spotifySettings.Enabled && browseResult.RootElement.TryGetProperty("Items", out var _))
{
_logger.LogDebug("Browse result has Items, checking for Spotify playlists to update counts");
browseResult = await UpdateSpotifyPlaylistCounts(browseResult);
}
var result = JsonSerializer.Deserialize<object>(browseResult.RootElement.GetRawText());
if (_logger.IsEnabled(LogLevel.Debug))
{
var rawText = browseResult.RootElement.GetRawText();
var preview = rawText.Length > 200 ? rawText[..200] : rawText;
_logger.LogDebug("Jellyfin browse result preview: {Result}", preview);
}
return new JsonResult(result);
}
// If browsing a specific parent (album, artist, playlist)
if (!string.IsNullOrWhiteSpace(parentId))
{
// Check if this is the music library root - if so, treat as a search
var isMusicLibrary = parentId == _settings.LibraryId;
if (!isMusicLibrary || string.IsNullOrWhiteSpace(searchTerm))
{
_logger.LogDebug("Browsing parent: {ParentId}", parentId);
return await GetChildItems(parentId, includeItemTypes, limit, startIndex, sortBy);
}
// If searching within music library root, continue to integrated search below
_logger.LogInformation("Searching within music library {ParentId}, including external sources", parentId);
}
var cleanQuery = searchTerm?.Trim().Trim('"') ?? "";
_logger.LogDebug("Performing integrated search for: {Query}", cleanQuery);
// Run local and external searches in parallel
var itemTypes = ParseItemTypes(includeItemTypes);
var jellyfinTask = _proxyService.SearchAsync(cleanQuery, itemTypes, limit, recursive, Request.Headers);
// Use parallel metadata service if available (races providers), otherwise use primary
var externalTask = _parallelMetadataService != null
? _parallelMetadataService.SearchAllAsync(cleanQuery, limit, limit, limit)
: _metadataService.SearchAllAsync(cleanQuery, limit, limit, limit);
var playlistTask = _settings.EnableExternalPlaylists
? _metadataService.SearchPlaylistsAsync(cleanQuery, limit)
: Task.FromResult(new List<ExternalPlaylist>());
await Task.WhenAll(jellyfinTask, externalTask, playlistTask);
var (jellyfinResult, _) = await jellyfinTask;
var externalResult = await externalTask;
var playlistResult = await playlistTask;
_logger.LogDebug("Search results: Jellyfin={JellyfinCount}, External Songs={ExtSongs}, Albums={ExtAlbums}, Artists={ExtArtists}, Playlists={Playlists}",
jellyfinResult != null ? "found" : "null",
externalResult.Songs.Count,
externalResult.Albums.Count,
externalResult.Artists.Count,
playlistResult.Count);
// Parse Jellyfin results into domain models
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult);
// Sort all results by match score (local tracks get +10 boost)
// This ensures best matches appear first regardless of source
var allSongs = localSongs.Concat(externalResult.Songs)
.Select(s => new { Song = s, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title) + (s.IsLocal ? 10.0 : 0.0) })
.OrderByDescending(x => x.Score)
.Select(x => x.Song)
.ToList();
var allAlbums = localAlbums.Concat(externalResult.Albums)
.Select(a => new { Album = a, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title) + (a.IsLocal ? 10.0 : 0.0) })
.OrderByDescending(x => x.Score)
.Select(x => x.Album)
.ToList();
var allArtists = localArtists.Concat(externalResult.Artists)
.Select(a => new { Artist = a, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name) + (a.IsLocal ? 10.0 : 0.0) })
.OrderByDescending(x => x.Score)
.Select(x => x.Artist)
.ToList();
// Log top results for debugging
if (_logger.IsEnabled(LogLevel.Debug))
{
if (allSongs.Any())
{
var topSong = allSongs.First();
var topScore = FuzzyMatcher.CalculateSimilarity(cleanQuery, topSong.Title) + (topSong.IsLocal ? 10.0 : 0.0);
_logger.LogDebug("🎵 Top song: '{Title}' (local={IsLocal}, score={Score:F2})",
topSong.Title, topSong.IsLocal, topScore);
}
if (allAlbums.Any())
{
var topAlbum = allAlbums.First();
var topScore = FuzzyMatcher.CalculateSimilarity(cleanQuery, topAlbum.Title) + (topAlbum.IsLocal ? 10.0 : 0.0);
_logger.LogDebug("💿 Top album: '{Title}' (local={IsLocal}, score={Score:F2})",
topAlbum.Title, topAlbum.IsLocal, topScore);
}
if (allArtists.Any())
{
var topArtist = allArtists.First();
var topScore = FuzzyMatcher.CalculateSimilarity(cleanQuery, topArtist.Name) + (topArtist.IsLocal ? 10.0 : 0.0);
_logger.LogDebug("🎤 Top artist: '{Name}' (local={IsLocal}, score={Score:F2})",
topArtist.Name, topArtist.IsLocal, topScore);
}
}
// Convert to Jellyfin format
var mergedSongs = allSongs.Select(s => _responseBuilder.ConvertSongToJellyfinItem(s)).ToList();
var mergedAlbums = allAlbums.Select(a => _responseBuilder.ConvertAlbumToJellyfinItem(a)).ToList();
var mergedArtists = allArtists.Select(a => _responseBuilder.ConvertArtistToJellyfinItem(a)).ToList();
// Add playlists (preserve their order too)
if (playlistResult.Count > 0)
{
var playlistItems = playlistResult
.Select(p => _responseBuilder.ConvertPlaylistToJellyfinItem(p))
.ToList();
mergedAlbums.AddRange(playlistItems);
}
_logger.LogDebug("Merged and sorted results by score: Songs={Songs}, Albums={Albums}, Artists={Artists}",
mergedSongs.Count, mergedAlbums.Count, mergedArtists.Count);
// Pre-fetch lyrics for top 3 songs in background (don't await)
if (_lrclibService != null && mergedSongs.Count > 0)
{
_ = Task.Run(async () =>
{
try
{
var top3 = mergedSongs.Take(3).ToList();
_logger.LogDebug("🎵 Pre-fetching lyrics for top {Count} search results", top3.Count);
foreach (var songItem in top3)
{
if (songItem.TryGetValue("Name", out var nameObj) && nameObj is JsonElement nameEl &&
songItem.TryGetValue("Artists", out var artistsObj) && artistsObj is JsonElement artistsEl &&
artistsEl.GetArrayLength() > 0)
{
var title = nameEl.GetString() ?? "";
var artist = artistsEl[0].GetString() ?? "";
if (!string.IsNullOrEmpty(title) && !string.IsNullOrEmpty(artist))
{
await _lrclibService.GetLyricsAsync(title, artist, "", 0);
}
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to pre-fetch lyrics for search results");
}
});
}
// Filter by item types if specified
var items = new List<Dictionary<string, object?>>();
_logger.LogDebug("Filtering by item types: {ItemTypes}", itemTypes == null ? "null" : string.Join(",", itemTypes));
if (itemTypes == null || itemTypes.Length == 0 || itemTypes.Contains("MusicArtist"))
{
_logger.LogDebug("Adding {Count} artists to results", mergedArtists.Count);
items.AddRange(mergedArtists);
}
if (itemTypes == null || itemTypes.Length == 0 || itemTypes.Contains("MusicAlbum") || itemTypes.Contains("Playlist"))
{
_logger.LogDebug("Adding {Count} albums to results", mergedAlbums.Count);
items.AddRange(mergedAlbums);
}
if (itemTypes == null || itemTypes.Length == 0 || itemTypes.Contains("Audio"))
{
_logger.LogDebug("Adding {Count} songs to results", mergedSongs.Count);
items.AddRange(mergedSongs);
}
// Apply pagination
var pagedItems = items.Skip(startIndex).Take(limit).ToList();
_logger.LogDebug("Returning {Count} items (total: {Total})", pagedItems.Count, items.Count);
try
{
// Return with PascalCase - use ContentResult to bypass JSON serialization issues
var response = new
{
Items = pagedItems,
TotalRecordCount = items.Count,
StartIndex = startIndex
};
// Cache search results in Redis (15 min TTL, no file persistence)
if (!string.IsNullOrWhiteSpace(searchTerm) && string.IsNullOrWhiteSpace(artistIds))
{
var cacheKey = CacheKeyBuilder.BuildSearchKey(searchTerm, includeItemTypes, limit, startIndex);
await _cache.SetAsync(cacheKey, response, CacheExtensions.SearchResultsTTL);
_logger.LogDebug("💾 Cached search results for '{SearchTerm}' ({Minutes} min TTL)", searchTerm, CacheExtensions.SearchResultsTTL.TotalMinutes);
}
_logger.LogDebug("About to serialize response...");
var json = System.Text.Json.JsonSerializer.Serialize(response, new System.Text.Json.JsonSerializerOptions
{
PropertyNamingPolicy = null,
DictionaryKeyPolicy = null
});
if (_logger.IsEnabled(LogLevel.Debug))
{
var preview = json.Length > 200 ? json[..200] : json;
_logger.LogDebug("JSON response preview: {Json}", preview);
}
return Content(json, "application/json");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error serializing search response");
throw;
}
}
/// <summary>
/// Gets child items of a parent (tracks in album, albums for artist).
/// </summary>
private async Task<IActionResult> GetChildItems(
string parentId,
string? includeItemTypes,
int limit,
int startIndex,
string? sortBy)
{
// Check if this is an external playlist
if (PlaylistIdHelper.IsExternalPlaylist(parentId))
{
return await GetPlaylistTracks(parentId);
}
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(parentId);
if (isExternal)
{
// Get external album or artist content
return await GetExternalChildItems(provider!, externalId!, includeItemTypes);
}
// Proxy to Jellyfin for local content
var (result, statusCode) = await _proxyService.GetItemsAsync(
parentId: parentId,
includeItemTypes: ParseItemTypes(includeItemTypes),
sortBy: sortBy,
limit: limit,
startIndex: startIndex,
clientHeaders: Request.Headers);
return HandleProxyResponse(result, statusCode);
}
/// <summary>
/// Quick search endpoint. Works with /Search/Hints and /Users/{userId}/Search/Hints.
/// </summary>
[HttpGet("Search/Hints", Order = 1)]
[HttpGet("Users/{userId}/Search/Hints", Order = 1)]
public async Task<IActionResult> SearchHints(
[FromQuery] string searchTerm,
[FromQuery] int limit = 20,
[FromQuery] string? includeItemTypes = null,
string? userId = null)
{
if (string.IsNullOrWhiteSpace(searchTerm))
{
return _responseBuilder.CreateJsonResponse(new
{
SearchHints = Array.Empty<object>(),
TotalRecordCount = 0
});
}
var cleanQuery = searchTerm.Trim().Trim('"');
var itemTypes = ParseItemTypes(includeItemTypes);
// Run searches in parallel
var jellyfinTask = _proxyService.SearchAsync(cleanQuery, itemTypes, limit, true, Request.Headers);
var externalTask = _metadataService.SearchAllAsync(cleanQuery, limit, limit, limit);
await Task.WhenAll(jellyfinTask, externalTask);
var (jellyfinResult, _) = await jellyfinTask;
var externalResult = await externalTask;
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult);
// NO deduplication - merge all results and take top matches
var allSongs = localSongs.Concat(externalResult.Songs).Take(limit).ToList();
var allAlbums = localAlbums.Concat(externalResult.Albums).Take(limit).ToList();
var allArtists = localArtists.Concat(externalResult.Artists).Take(limit).ToList();
return _responseBuilder.CreateSearchHintsResponse(
allSongs.Take(limit).ToList(),
allAlbums.Take(limit).ToList(),
allArtists.Take(limit).ToList());
}
#endregion
#region Items
/// <summary>
@@ -651,6 +228,45 @@ public class JellyfinController : ControllerBase
return _responseBuilder.CreateAlbumsResponse(albums);
}
private async Task<IActionResult> GetCuratorPlaylists(string provider, string externalId, string? includeItemTypes)
{
var itemTypes = ParseItemTypes(includeItemTypes);
_logger.LogDebug("GetCuratorPlaylists: provider={Provider}, curatorId={CuratorId}, itemTypes={ItemTypes}",
provider, externalId, string.Join(",", itemTypes ?? Array.Empty<string>()));
// Extract curator name from externalId (format: "curator-{name}")
var curatorName = externalId.Replace("curator-", "", StringComparison.OrdinalIgnoreCase);
// Search for playlists by this curator
// Since we don't have a direct "get playlists by curator" method, we'll search for the curator name
// and filter the results
var playlists = await _metadataService.SearchPlaylistsAsync(curatorName, 50);
// Filter to only playlists from this curator (case-insensitive match)
var curatorPlaylists = playlists
.Where(p => !string.IsNullOrEmpty(p.CuratorName) &&
p.CuratorName.Equals(curatorName, StringComparison.OrdinalIgnoreCase))
.ToList();
_logger.LogInformation("Found {Count} playlists for curator '{CuratorName}'", curatorPlaylists.Count, curatorName);
// Convert playlists to album items
var albumItems = curatorPlaylists
.Select(p => _responseBuilder.ConvertPlaylistToAlbumItem(p))
.ToList();
var response = new Dictionary<string, object>
{
["Items"] = albumItems,
["TotalRecordCount"] = albumItems.Count,
["StartIndex"] = 0
};
return new JsonResult(response);
}
#endregion
@@ -809,221 +425,6 @@ public class JellyfinController : ControllerBase
#endregion
#region Audio Streaming
/// <summary>
/// Downloads/streams audio. Works with local and external content.
/// </summary>
[HttpGet("Items/{itemId}/Download")]
[HttpGet("Items/{itemId}/File")]
public async Task<IActionResult> DownloadAudio(string itemId)
{
if (string.IsNullOrWhiteSpace(itemId))
{
return BadRequest(new { error = "Missing item ID" });
}
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
if (!isExternal)
{
// Build path for Jellyfin download/file endpoint
var endpoint = Request.Path.Value?.Contains("/File", StringComparison.OrdinalIgnoreCase) == true ? "File" : "Download";
var fullPath = $"Items/{itemId}/{endpoint}";
if (Request.QueryString.HasValue)
{
fullPath = $"{fullPath}{Request.QueryString.Value}";
}
return await ProxyJellyfinStream(fullPath, itemId);
}
// Handle external content
return await StreamExternalContent(provider!, externalId!);
}
/// <summary>
/// Streams audio for a given item. Downloads on-demand for external content.
/// </summary>
[HttpGet("Audio/{itemId}/stream")]
[HttpGet("Audio/{itemId}/stream.{container}")]
public async Task<IActionResult> StreamAudio(string itemId, string? container = null)
{
if (string.IsNullOrWhiteSpace(itemId))
{
return BadRequest(new { error = "Missing item ID" });
}
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
if (!isExternal)
{
// Build path for Jellyfin stream
var fullPath = string.IsNullOrEmpty(container)
? $"Audio/{itemId}/stream"
: $"Audio/{itemId}/stream.{container}";
if (Request.QueryString.HasValue)
{
fullPath = $"{fullPath}{Request.QueryString.Value}";
}
return await ProxyJellyfinStream(fullPath, itemId);
}
// Handle external content
return await StreamExternalContent(provider!, externalId!);
}
/// <summary>
/// Proxies a stream from Jellyfin with proper header forwarding.
/// </summary>
private async Task<IActionResult> ProxyJellyfinStream(string path, string itemId)
{
var jellyfinUrl = $"{_settings.Url?.TrimEnd('/')}/{path}";
try
{
var request = new HttpRequestMessage(HttpMethod.Get, jellyfinUrl);
// Forward auth headers
AuthHeaderHelper.ForwardAuthHeaders(Request.Headers, request);
// Forward Range header for seeking
if (Request.Headers.TryGetValue("Range", out var range))
{
request.Headers.TryAddWithoutValidation("Range", range.ToString());
}
var response = await _proxyService.HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
if (!response.IsSuccessStatusCode)
{
_logger.LogError("Jellyfin stream failed: {StatusCode} for {ItemId}", response.StatusCode, itemId);
return StatusCode((int)response.StatusCode);
}
// Set response status and headers
Response.StatusCode = (int)response.StatusCode;
var contentType = response.Content.Headers.ContentType?.ToString() ?? "audio/mpeg";
// Forward caching headers for client-side caching
if (response.Headers.ETag != null)
{
Response.Headers["ETag"] = response.Headers.ETag.ToString();
}
if (response.Content.Headers.LastModified.HasValue)
{
Response.Headers["Last-Modified"] = response.Content.Headers.LastModified.Value.ToString("R");
}
if (response.Headers.CacheControl != null)
{
Response.Headers["Cache-Control"] = response.Headers.CacheControl.ToString();
}
// Forward range headers for seeking
if (response.Content.Headers.ContentRange != null)
{
Response.Headers["Content-Range"] = response.Content.Headers.ContentRange.ToString();
}
if (response.Headers.AcceptRanges != null)
{
Response.Headers["Accept-Ranges"] = string.Join(", ", response.Headers.AcceptRanges);
}
if (response.Content.Headers.ContentLength.HasValue)
{
Response.Headers["Content-Length"] = response.Content.Headers.ContentLength.Value.ToString();
}
var stream = await response.Content.ReadAsStreamAsync();
return File(stream, contentType);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to proxy stream from Jellyfin for {ItemId}", itemId);
return StatusCode(500, new { error = $"Streaming failed: {ex.Message}" });
}
}
/// <summary>
/// Streams external content, using cache if available or downloading on-demand.
/// </summary>
private async Task<IActionResult> StreamExternalContent(string provider, string externalId)
{
// Check for locally cached file
var localPath = await _localLibraryService.GetLocalPathForExternalSongAsync(provider, externalId);
if (localPath != null && System.IO.File.Exists(localPath))
{
// Update last write time for cache cleanup (extends cache lifetime)
try
{
System.IO.File.SetLastWriteTimeUtc(localPath, DateTime.UtcNow);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update last write time for {Path}", localPath);
}
var stream = System.IO.File.OpenRead(localPath);
return File(stream, GetContentType(localPath), enableRangeProcessing: true);
}
// Download and stream on-demand
try
{
var downloadStream = await _downloadService.DownloadAndStreamAsync(
provider,
externalId,
HttpContext.RequestAborted);
return File(downloadStream, "audio/mpeg", enableRangeProcessing: true);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to stream external song {Provider}:{ExternalId}", provider, externalId);
return StatusCode(500, new { error = $"Streaming failed: {ex.Message}" });
}
}
/// <summary>
/// Universal audio endpoint - handles transcoding, format negotiation, and adaptive streaming.
/// This is the primary endpoint used by Jellyfin Web and most clients.
/// </summary>
[HttpGet("Audio/{itemId}/universal")]
[HttpHead("Audio/{itemId}/universal")]
public async Task<IActionResult> UniversalAudio(string itemId)
{
if (string.IsNullOrWhiteSpace(itemId))
{
return BadRequest(new { error = "Missing item ID" });
}
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
if (!isExternal)
{
// For local content, proxy the universal endpoint with all query parameters
var fullPath = $"Audio/{itemId}/universal";
if (Request.QueryString.HasValue)
{
fullPath = $"{fullPath}{Request.QueryString.Value}";
}
return await ProxyJellyfinStream(fullPath, itemId);
}
// For external content, use simple streaming (no transcoding support yet)
return await StreamExternalContent(provider!, externalId!);
}
#endregion
#region Images
/// <summary>
@@ -1062,7 +463,45 @@ public class JellyfinController : ControllerBase
if (imageBytes == null || contentType == null)
{
// Return placeholder if Jellyfin doesn't have image
// Try to get the item details to find fallback image (album/parent)
var (itemResult, itemStatus) = await _proxyService.GetJsonAsync($"Items/{itemId}", null, Request.Headers);
if (itemResult != null && itemStatus == 200)
{
var item = itemResult.RootElement;
string? fallbackItemId = null;
// Check for album image fallback (for songs)
if (item.TryGetProperty("AlbumId", out var albumIdProp))
{
fallbackItemId = albumIdProp.GetString();
}
// Check for parent primary image fallback
else if (item.TryGetProperty("ParentPrimaryImageItemId", out var parentIdProp))
{
fallbackItemId = parentIdProp.GetString();
}
// Try to fetch the fallback image
if (!string.IsNullOrEmpty(fallbackItemId))
{
_logger.LogDebug("Item {ItemId} has no {ImageType} image, trying fallback from {FallbackId}",
itemId, imageType, fallbackItemId);
var (fallbackBytes, fallbackContentType) = await _proxyService.GetImageAsync(
fallbackItemId,
imageType,
maxWidth,
maxHeight);
if (fallbackBytes != null && fallbackContentType != null)
{
return File(fallbackBytes, fallbackContentType);
}
}
}
// Return placeholder if no fallback found
return await GetPlaceholderImageAsync();
}
@@ -1131,449 +570,6 @@ public class JellyfinController : ControllerBase
#endregion
#region Lyrics
/// <summary>
/// Gets lyrics for an item.
/// Priority: 1. Jellyfin embedded lyrics, 2. Spotify synced lyrics, 3. LRCLIB
/// </summary>
[HttpGet("Audio/{itemId}/Lyrics")]
[HttpGet("Items/{itemId}/Lyrics")]
public async Task<IActionResult> GetLyrics(string itemId)
{
_logger.LogDebug("🎵 GetLyrics called for itemId: {ItemId}", itemId);
if (string.IsNullOrWhiteSpace(itemId))
{
return NotFound();
}
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
_logger.LogDebug("🎵 Lyrics request: itemId={ItemId}, isExternal={IsExternal}, provider={Provider}, externalId={ExternalId}",
itemId, isExternal, provider, externalId);
// For local tracks, check if Jellyfin already has embedded lyrics
if (!isExternal)
{
_logger.LogDebug("Checking Jellyfin for embedded lyrics for local track: {ItemId}", itemId);
// Try to get lyrics from Jellyfin first (it reads embedded lyrics from files)
var (jellyfinLyrics, statusCode) = await _proxyService.GetJsonAsync($"Audio/{itemId}/Lyrics", null, Request.Headers);
_logger.LogDebug("Jellyfin lyrics check result: statusCode={StatusCode}, hasLyrics={HasLyrics}",
statusCode, jellyfinLyrics != null);
if (jellyfinLyrics != null && statusCode == 200)
{
_logger.LogInformation("Found embedded lyrics in Jellyfin for track {ItemId}", itemId);
return new JsonResult(JsonSerializer.Deserialize<object>(jellyfinLyrics.RootElement.GetRawText()));
}
_logger.LogWarning("No embedded lyrics found in Jellyfin (status: {StatusCode}), trying Spotify/LRCLIB", statusCode);
}
// Get song metadata for lyrics search
Song? song = null;
string? spotifyTrackId = null;
if (isExternal)
{
song = await _metadataService.GetSongAsync(provider!, externalId!);
// Use Spotify ID from song metadata if available (populated during GetSongAsync)
if (song != null && !string.IsNullOrEmpty(song.SpotifyId))
{
spotifyTrackId = song.SpotifyId;
_logger.LogInformation("Using Spotify ID {SpotifyId} from song metadata for {Provider}/{ExternalId}",
spotifyTrackId, provider, externalId);
}
// Fallback: Try to find Spotify ID from matched tracks cache
else if (song != null)
{
spotifyTrackId = await FindSpotifyIdForExternalTrackAsync(song);
if (!string.IsNullOrEmpty(spotifyTrackId))
{
_logger.LogDebug("Found Spotify ID {SpotifyId} for external track {Provider}/{ExternalId} from cache",
spotifyTrackId, provider, externalId);
}
else
{
// Last resort: Try to convert via Odesli/song.link
if (provider == "squidwtf")
{
spotifyTrackId = await _odesliService.ConvertTidalToSpotifyIdAsync(externalId!, HttpContext.RequestAborted);
}
else
{
// For other providers, build the URL and convert
var sourceUrl = provider?.ToLowerInvariant() switch
{
"deezer" => $"https://www.deezer.com/track/{externalId}",
"qobuz" => $"https://www.qobuz.com/us-en/album/-/-/{externalId}",
_ => null
};
if (!string.IsNullOrEmpty(sourceUrl))
{
spotifyTrackId = await _odesliService.ConvertUrlToSpotifyIdAsync(sourceUrl, HttpContext.RequestAborted);
}
}
if (!string.IsNullOrEmpty(spotifyTrackId))
{
_logger.LogDebug("Converted {Provider}/{ExternalId} to Spotify ID {SpotifyId} via Odesli",
provider, externalId, spotifyTrackId);
}
}
}
}
else
{
// For local songs, get metadata from Jellyfin
var (item, _) = await _proxyService.GetItemAsync(itemId, Request.Headers);
if (item != null && item.RootElement.TryGetProperty("Type", out var typeEl) &&
typeEl.GetString() == "Audio")
{
song = new Song
{
Title = item.RootElement.TryGetProperty("Name", out var name) ? name.GetString() ?? "" : "",
Artist = item.RootElement.TryGetProperty("AlbumArtist", out var artist) ? artist.GetString() ?? "" : "",
Album = item.RootElement.TryGetProperty("Album", out var album) ? album.GetString() ?? "" : "",
Duration = item.RootElement.TryGetProperty("RunTimeTicks", out var ticks) ? (int)(ticks.GetInt64() / 10000000) : 0
};
// Check for Spotify ID in provider IDs
if (item.RootElement.TryGetProperty("ProviderIds", out var providerIds))
{
if (providerIds.TryGetProperty("Spotify", out var spotifyId))
{
spotifyTrackId = spotifyId.GetString();
}
}
}
}
if (song == null)
{
return NotFound(new { error = "Song not found" });
}
// Strip [S] suffix from title, artist, and album for lyrics search
// The [S] tag is added to external tracks but shouldn't be used in lyrics queries
var searchTitle = song.Title.Replace(" [S]", "").Trim();
var searchArtist = song.Artist?.Replace(" [S]", "").Trim() ?? "";
var searchAlbum = song.Album?.Replace(" [S]", "").Trim() ?? "";
var searchArtists = song.Artists.Select(a => a.Replace(" [S]", "").Trim()).ToList();
if (searchArtists.Count == 0 && !string.IsNullOrEmpty(searchArtist))
{
searchArtists.Add(searchArtist);
}
// Use orchestrator for clean, modular lyrics fetching
LyricsInfo? lyrics = null;
if (_lyricsOrchestrator != null)
{
lyrics = await _lyricsOrchestrator.GetLyricsAsync(
trackName: searchTitle,
artistNames: searchArtists.ToArray(),
albumName: searchAlbum,
durationSeconds: song.Duration ?? 0,
spotifyTrackId: spotifyTrackId);
}
else
{
// Fallback to manual fetching if orchestrator not available
_logger.LogWarning("LyricsOrchestrator not available, using fallback method");
// Try Spotify lyrics ONLY if we have a valid Spotify track ID
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled && !string.IsNullOrEmpty(spotifyTrackId))
{
var cleanSpotifyId = spotifyTrackId.Replace("spotify:track:", "").Trim();
if (cleanSpotifyId.Length == 22 && !cleanSpotifyId.Contains(":") && !cleanSpotifyId.Contains("local"))
{
var spotifyLyrics = await _spotifyLyricsService.GetLyricsByTrackIdAsync(cleanSpotifyId);
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
{
lyrics = _spotifyLyricsService.ToLyricsInfo(spotifyLyrics);
}
}
}
// Fall back to LyricsPlus
if (lyrics == null && _lyricsPlusService != null)
{
lyrics = await _lyricsPlusService.GetLyricsAsync(
searchTitle,
searchArtists.ToArray(),
searchAlbum,
song.Duration ?? 0);
}
// Fall back to LRCLIB
if (lyrics == null && _lrclibService != null)
{
lyrics = await _lrclibService.GetLyricsAsync(
searchTitle,
searchArtists.ToArray(),
searchAlbum,
song.Duration ?? 0);
}
}
if (lyrics == null)
{
return NotFound(new { error = "Lyrics not found" });
}
// Prefer synced lyrics, fall back to plain
var lyricsText = lyrics.SyncedLyrics ?? lyrics.PlainLyrics ?? "";
var isSynced = !string.IsNullOrEmpty(lyrics.SyncedLyrics);
_logger.LogInformation("Lyrics for {Artist} - {Track}: synced={HasSynced}, plainLength={PlainLen}, syncedLength={SyncLen}",
song.Artist, song.Title, isSynced, lyrics.PlainLyrics?.Length ?? 0, lyrics.SyncedLyrics?.Length ?? 0);
// Parse LRC format into individual lines for Jellyfin
var lyricLines = new List<Dictionary<string, object>>();
if (isSynced && !string.IsNullOrEmpty(lyrics.SyncedLyrics))
{
_logger.LogDebug("Parsing synced lyrics (LRC format)");
// Parse LRC format: [mm:ss.xx] text
// Skip ID tags like [ar:Artist], [ti:Title], etc.
var lines = lyrics.SyncedLyrics.Split('\n', StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines)
{
// Match timestamp format [mm:ss.xx] or [mm:ss.xxx]
var match = System.Text.RegularExpressions.Regex.Match(line, @"^\[(\d+):(\d+)\.(\d+)\]\s*(.*)$");
if (match.Success)
{
var minutes = int.Parse(match.Groups[1].Value);
var seconds = int.Parse(match.Groups[2].Value);
var centiseconds = int.Parse(match.Groups[3].Value);
var text = match.Groups[4].Value;
// Convert to ticks (100 nanoseconds)
var totalMilliseconds = (minutes * 60 + seconds) * 1000 + centiseconds * 10;
var ticks = totalMilliseconds * 10000L;
// For synced lyrics, include Start timestamp
lyricLines.Add(new Dictionary<string, object>
{
["Text"] = text,
["Start"] = ticks
});
}
// Skip ID tags like [ar:Artist], [ti:Title], [length:2:23], etc.
}
_logger.LogDebug("Parsed {Count} synced lyric lines (skipped ID tags)", lyricLines.Count);
}
else if (!string.IsNullOrEmpty(lyricsText))
{
_logger.LogInformation("Splitting plain lyrics into lines (no timestamps)");
// Plain lyrics - split by newlines and return each line separately
// IMPORTANT: Do NOT include "Start" field at all for unsynced lyrics
// Including it (even as null) causes clients to treat it as synced with timestamp 0:00
var lines = lyricsText.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines)
{
lyricLines.Add(new Dictionary<string, object>
{
["Text"] = line.Trim()
});
}
_logger.LogDebug("Split into {Count} plain lyric lines", lyricLines.Count);
}
else
{
_logger.LogWarning("No lyrics text available");
// No lyrics at all
lyricLines.Add(new Dictionary<string, object>
{
["Text"] = ""
});
}
var response = new
{
Metadata = new
{
Artist = lyrics.ArtistName,
Album = lyrics.AlbumName,
Title = lyrics.TrackName,
Length = lyrics.Duration,
IsSynced = isSynced
},
Lyrics = lyricLines
};
_logger.LogDebug("Returning lyrics response: {LineCount} lines, synced={IsSynced}", lyricLines.Count, isSynced);
// Log a sample of the response for debugging
if (lyricLines.Count > 0)
{
var sampleLine = lyricLines[0];
var hasStart = sampleLine.ContainsKey("Start");
_logger.LogDebug("Sample line: Text='{Text}', HasStart={HasStart}",
sampleLine.GetValueOrDefault("Text"), hasStart);
}
return Ok(response);
}
/// <summary>
/// Proactively fetches and caches lyrics for a track in the background.
/// Called when playback starts to ensure lyrics are ready when requested.
/// </summary>
private async Task PrefetchLyricsForTrackAsync(string itemId, bool isExternal, string? provider, string? externalId)
{
try
{
Song? song = null;
string? spotifyTrackId = null;
if (isExternal && !string.IsNullOrEmpty(provider) && !string.IsNullOrEmpty(externalId))
{
// Get external track metadata
song = await _metadataService.GetSongAsync(provider, externalId);
// Try to find Spotify ID from matched tracks cache
if (song != null)
{
spotifyTrackId = await FindSpotifyIdForExternalTrackAsync(song);
// If no cached Spotify ID, try Odesli conversion
if (string.IsNullOrEmpty(spotifyTrackId) && provider == "squidwtf")
{
spotifyTrackId = await _odesliService.ConvertTidalToSpotifyIdAsync(externalId, HttpContext.RequestAborted);
}
}
}
else
{
// Get local track metadata from Jellyfin
var (item, _) = await _proxyService.GetItemAsync(itemId, Request.Headers);
if (item != null && item.RootElement.TryGetProperty("Type", out var typeEl) &&
typeEl.GetString() == "Audio")
{
song = new Song
{
Title = item.RootElement.TryGetProperty("Name", out var name) ? name.GetString() ?? "" : "",
Artist = item.RootElement.TryGetProperty("AlbumArtist", out var artist) ? artist.GetString() ?? "" : "",
Album = item.RootElement.TryGetProperty("Album", out var album) ? album.GetString() ?? "" : "",
Duration = item.RootElement.TryGetProperty("RunTimeTicks", out var ticks) ? (int)(ticks.GetInt64() / 10000000) : 0
};
// Check for Spotify ID in provider IDs
if (item.RootElement.TryGetProperty("ProviderIds", out var providerIds))
{
if (providerIds.TryGetProperty("Spotify", out var spotifyId))
{
spotifyTrackId = spotifyId.GetString();
}
}
}
}
if (song == null)
{
_logger.LogDebug("Could not get song metadata for lyrics prefetch: {ItemId}", itemId);
return;
}
// Strip [S] suffix for lyrics search
var searchTitle = song.Title.Replace(" [S]", "").Trim();
var searchArtist = song.Artist?.Replace(" [S]", "").Trim() ?? "";
var searchAlbum = song.Album?.Replace(" [S]", "").Trim() ?? "";
var searchArtists = song.Artists.Select(a => a.Replace(" [S]", "").Trim()).ToList();
if (searchArtists.Count == 0 && !string.IsNullOrEmpty(searchArtist))
{
searchArtists.Add(searchArtist);
}
_logger.LogDebug("🎵 Prefetching lyrics for: {Artist} - {Title}", searchArtist, searchTitle);
// Use orchestrator for prefetching
if (_lyricsOrchestrator != null)
{
await _lyricsOrchestrator.PrefetchLyricsAsync(
trackName: searchTitle,
artistNames: searchArtists.ToArray(),
albumName: searchAlbum,
durationSeconds: song.Duration ?? 0,
spotifyTrackId: spotifyTrackId);
return;
}
// Fallback to manual prefetching if orchestrator not available
_logger.LogWarning("LyricsOrchestrator not available for prefetch, using fallback method");
// Try Spotify lyrics if we have a valid Spotify track ID
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled && !string.IsNullOrEmpty(spotifyTrackId))
{
var cleanSpotifyId = spotifyTrackId.Replace("spotify:track:", "").Trim();
if (cleanSpotifyId.Length == 22 && !cleanSpotifyId.Contains(":") && !cleanSpotifyId.Contains("local"))
{
var spotifyLyrics = await _spotifyLyricsService.GetLyricsByTrackIdAsync(cleanSpotifyId);
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
{
_logger.LogDebug("✓ Prefetched Spotify lyrics for {Artist} - {Title} ({LineCount} lines)",
searchArtist, searchTitle, spotifyLyrics.Lines.Count);
return; // Success, lyrics are now cached
}
}
}
// Fall back to LyricsPlus
if (_lyricsPlusService != null)
{
var lyrics = await _lyricsPlusService.GetLyricsAsync(
searchTitle,
searchArtists.ToArray(),
searchAlbum,
song.Duration ?? 0);
if (lyrics != null)
{
_logger.LogDebug("✓ Prefetched LyricsPlus lyrics for {Artist} - {Title}", searchArtist, searchTitle);
return; // Success, lyrics are now cached
}
}
// Fall back to LRCLIB
if (_lrclibService != null)
{
var lyrics = await _lrclibService.GetLyricsAsync(
searchTitle,
searchArtists.ToArray(),
searchAlbum,
song.Duration ?? 0);
if (lyrics != null)
{
_logger.LogDebug("✓ Prefetched LRCLIB lyrics for {Artist} - {Title}", searchArtist, searchTitle);
}
else
{
_logger.LogDebug("No lyrics found for {Artist} - {Title}", searchArtist, searchTitle);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error prefetching lyrics for track {ItemId}", itemId);
}
}
#endregion
#region Favorites
/// <summary>
@@ -1628,20 +624,43 @@ public class JellyfinController : ControllerBase
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
if (isExternal)
{
_logger.LogInformation("Favoriting external item {ItemId}, copying to kept folder", itemId);
// Check if it's an album by parsing the full ID with type
var (_, _, type, _) = _localLibraryService.ParseExternalId(itemId);
// Copy the track to kept folder in background
_ = Task.Run(async () =>
if (type == "album")
{
try
_logger.LogInformation("Favoriting external album {ItemId}, downloading all tracks to kept folder", itemId);
// Download entire album to kept folder in background
_ = Task.Run(async () =>
{
await CopyExternalTrackToKeptAsync(itemId, provider!, externalId!);
}
catch (Exception ex)
try
{
await CopyExternalAlbumToKeptAsync(itemId, provider!, externalId!);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to copy external album {ItemId} to kept folder", itemId);
}
});
}
else
{
_logger.LogInformation("Favoriting external track {ItemId}, copying to kept folder", itemId);
// Copy the track to kept folder in background
_ = Task.Run(async () =>
{
_logger.LogError(ex, "Failed to copy external track {ItemId} to kept folder", itemId);
}
});
try
{
await CopyExternalTrackToKeptAsync(itemId, provider!, externalId!);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to copy external track {ItemId} to kept folder", itemId);
}
});
}
// Return a minimal UserItemDataDto response
return Ok(new
@@ -1730,267 +749,6 @@ public class JellyfinController : ControllerBase
#endregion
#region Playlists
/// <summary>
/// Gets playlist tracks displayed as an album.
/// </summary>
private async Task<IActionResult> GetPlaylistAsAlbum(string playlistId)
{
try
{
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(playlistId);
var playlist = await _metadataService.GetPlaylistAsync(provider, externalId);
if (playlist == null)
{
return _responseBuilder.CreateError(404, "Playlist not found");
}
var tracks = await _metadataService.GetPlaylistTracksAsync(provider, externalId);
// Cache tracks for playlist sync
if (_playlistSyncService != null)
{
foreach (var track in tracks)
{
if (!string.IsNullOrEmpty(track.ExternalId))
{
var trackId = $"ext-{provider}-{track.ExternalId}";
_playlistSyncService.AddTrackToPlaylistCache(trackId, playlistId);
}
}
_logger.LogDebug("Cached {Count} tracks for playlist {PlaylistId}", tracks.Count, playlistId);
}
return _responseBuilder.CreatePlaylistAsAlbumResponse(playlist, tracks);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting playlist {PlaylistId}", playlistId);
return _responseBuilder.CreateError(500, "Failed to get playlist");
}
}
/// <summary>
/// Gets playlist tracks as child items.
/// </summary>
private async Task<IActionResult> GetPlaylistTracks(string playlistId)
{
try
{
_logger.LogDebug("=== GetPlaylistTracks called === PlaylistId: {PlaylistId}", playlistId);
// Check if this is an external playlist (Deezer/Qobuz) first
if (PlaylistIdHelper.IsExternalPlaylist(playlistId))
{
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(playlistId);
var tracks = await _metadataService.GetPlaylistTracksAsync(provider, externalId);
return _responseBuilder.CreateItemsResponse(tracks);
}
// Check if this is a Spotify playlist (by ID)
_logger.LogInformation("Spotify Import Enabled: {Enabled}, Configured Playlists: {Count}",
_spotifySettings.Enabled, _spotifySettings.Playlists.Count);
if (_spotifySettings.Enabled && _spotifySettings.IsSpotifyPlaylist(playlistId))
{
// Get playlist info from Jellyfin to get the name for matching missing tracks
_logger.LogInformation("Fetching playlist info from Jellyfin for ID: {PlaylistId}", playlistId);
var (playlistInfo, _) = await _proxyService.GetJsonAsync($"Items/{playlistId}", null, Request.Headers);
if (playlistInfo != null && playlistInfo.RootElement.TryGetProperty("Name", out var nameElement))
{
var playlistName = nameElement.GetString() ?? "";
_logger.LogInformation("✓ MATCHED! Intercepting Spotify playlist: {PlaylistName} (ID: {PlaylistId})",
playlistName, playlistId);
return await GetSpotifyPlaylistTracksAsync(playlistName, playlistId);
}
else
{
_logger.LogWarning("Could not get playlist name from Jellyfin for ID: {PlaylistId}", playlistId);
}
}
// Regular Jellyfin playlist - proxy through
var endpoint = $"Playlists/{playlistId}/Items";
if (Request.QueryString.HasValue)
{
endpoint = $"{endpoint}{Request.QueryString.Value}";
}
_logger.LogDebug("Proxying to Jellyfin: {Endpoint}", endpoint);
var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
return HandleProxyResponse(result, statusCode);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting playlist tracks {PlaylistId}", playlistId);
return _responseBuilder.CreateError(500, "Failed to get playlist tracks");
}
}
/// <summary>
/// Gets a playlist cover image.
/// </summary>
private async Task<IActionResult> GetPlaylistImage(string playlistId)
{
try
{
// Check cache first (1 hour TTL for playlist images since they can change)
var cacheKey = $"playlist:image:{playlistId}";
var cachedImage = await _cache.GetAsync<byte[]>(cacheKey);
if (cachedImage != null)
{
_logger.LogDebug("Serving cached playlist image for {PlaylistId}", playlistId);
return File(cachedImage, "image/jpeg");
}
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(playlistId);
var playlist = await _metadataService.GetPlaylistAsync(provider, externalId);
if (playlist == null || string.IsNullOrEmpty(playlist.CoverUrl))
{
return NotFound();
}
var response = await _proxyService.HttpClient.GetAsync(playlist.CoverUrl);
if (!response.IsSuccessStatusCode)
{
return NotFound();
}
var imageBytes = await response.Content.ReadAsByteArrayAsync();
var contentType = response.Content.Headers.ContentType?.ToString() ?? "image/jpeg";
// Cache for configurable duration (playlists can change)
await _cache.SetAsync(cacheKey, imageBytes, CacheExtensions.PlaylistImagesTTL);
_logger.LogDebug("Cached playlist image for {PlaylistId}", playlistId);
return File(imageBytes, contentType);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get playlist image {PlaylistId}", playlistId);
return NotFound();
}
}
#endregion
#region Authentication
/// <summary>
/// Authenticates a user by username and password.
/// This is the primary login endpoint for Jellyfin clients.
/// </summary>
[HttpPost("Users/AuthenticateByName")]
public async Task<IActionResult> AuthenticateByName()
{
try
{
// Enable buffering to allow multiple reads of the request body
Request.EnableBuffering();
// Read the request body
using var reader = new StreamReader(Request.Body, leaveOpen: true);
var body = await reader.ReadToEndAsync();
// Reset stream position
Request.Body.Position = 0;
_logger.LogDebug("Authentication request received");
// DO NOT log request body or detailed headers - contains password
// Forward to Jellyfin server with client headers - completely transparent proxy
var (result, statusCode) = await _proxyService.PostJsonAsync("Users/AuthenticateByName", body, Request.Headers);
// Pass through Jellyfin's response exactly as-is (transparent proxy)
if (result != null)
{
var responseJson = result.RootElement.GetRawText();
// On successful auth, extract access token and post session capabilities in background
if (statusCode == 200)
{
_logger.LogInformation("Authentication successful");
// Extract access token from response for session capabilities
string? accessToken = null;
if (result.RootElement.TryGetProperty("AccessToken", out var tokenEl))
{
accessToken = tokenEl.GetString();
}
// Post session capabilities in background if we have a token
if (!string.IsNullOrEmpty(accessToken))
{
// Capture token in closure - don't use Request.Headers (will be disposed)
var token = accessToken;
_ = Task.Run(async () =>
{
try
{
_logger.LogDebug("🔧 Posting session capabilities after authentication");
// Build auth header with the new token
var authHeaders = new HeaderDictionary
{
["X-Emby-Token"] = token
};
var capabilities = new
{
PlayableMediaTypes = new[] { "Audio" },
SupportedCommands = Array.Empty<string>(),
SupportsMediaControl = false,
SupportsPersistentIdentifier = true,
SupportsSync = false
};
var capabilitiesJson = JsonSerializer.Serialize(capabilities);
var (capResult, capStatus) = await _proxyService.PostJsonAsync("Sessions/Capabilities/Full", capabilitiesJson, authHeaders);
if (capStatus == 204 || capStatus == 200)
{
_logger.LogDebug("✓ Session capabilities posted after auth ({StatusCode})", capStatus);
}
else
{
_logger.LogDebug("⚠ Session capabilities returned {StatusCode} after auth", capStatus);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to post session capabilities after auth");
}
});
}
}
else
{
_logger.LogError("Authentication failed - status {StatusCode}", statusCode);
}
// Return Jellyfin's exact response
return Content(responseJson, "application/json");
}
// No response body from Jellyfin - return status code only
_logger.LogWarning("Authentication request returned {StatusCode} with no response body", statusCode);
return StatusCode(statusCode);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during authentication");
return StatusCode(500, new { error = $"Authentication error: {ex.Message}" });
}
}
#endregion
#region Recommendations & Instant Mix
/// <summary>
@@ -2195,606 +953,6 @@ public class JellyfinController : ControllerBase
#endregion
#region Playback Session Reporting
#region Session Management
/// <summary>
/// Reports session capabilities. Required for Jellyfin to track active sessions.
/// Handles both POST (with body) and GET (query params only) methods.
/// </summary>
[HttpPost("Sessions/Capabilities")]
[HttpPost("Sessions/Capabilities/Full")]
[HttpGet("Sessions/Capabilities")]
[HttpGet("Sessions/Capabilities/Full")]
public async Task<IActionResult> ReportCapabilities()
{
try
{
var method = Request.Method;
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : "";
_logger.LogDebug("📡 Session capabilities reported - Method: {Method}, Query: {Query}", method, queryString);
_logger.LogInformation("Headers: {Headers}",
string.Join(", ", Request.Headers.Where(h => h.Key.Contains("Auth", StringComparison.OrdinalIgnoreCase) || h.Key.Contains("Device", StringComparison.OrdinalIgnoreCase) || h.Key.Contains("Client", StringComparison.OrdinalIgnoreCase))
.Select(h => $"{h.Key}={h.Value}")));
// Forward to Jellyfin with query string and headers
var endpoint = $"Sessions/Capabilities{queryString}";
// Read body if present (POST requests)
string body = "{}";
if (method == "POST" && Request.ContentLength > 0)
{
Request.EnableBuffering();
using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true))
{
body = await reader.ReadToEndAsync();
}
Request.Body.Position = 0;
_logger.LogInformation("Capabilities body: {Body}", body);
}
var (result, statusCode) = await _proxyService.PostJsonAsync(endpoint, body, Request.Headers);
if (statusCode == 204 || statusCode == 200)
{
_logger.LogDebug("✓ Session capabilities forwarded to Jellyfin ({StatusCode})", statusCode);
}
else if (statusCode == 401)
{
_logger.LogWarning("⚠ Jellyfin returned 401 for capabilities (token expired)");
}
else
{
_logger.LogWarning("⚠ Jellyfin returned {StatusCode} for capabilities", statusCode);
}
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to report session capabilities");
return StatusCode(500);
}
}
/// <summary>
/// Reports playback start. Handles both local and external tracks.
/// For local tracks, forwards to Jellyfin. For external tracks, logs locally.
/// Also ensures session is initialized if this is the first report from a device.
/// </summary>
[HttpPost("Sessions/Playing")]
public async Task<IActionResult> ReportPlaybackStart()
{
try
{
Request.EnableBuffering();
string body;
using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true))
{
body = await reader.ReadToEndAsync();
}
Request.Body.Position = 0;
_logger.LogDebug("📻 Playback START reported");
// Parse the body to check if it's an external track
var doc = JsonDocument.Parse(body);
string? itemId = null;
string? itemName = null;
long? positionTicks = null;
if (doc.RootElement.TryGetProperty("ItemId", out var itemIdProp))
{
itemId = itemIdProp.GetString();
}
if (doc.RootElement.TryGetProperty("ItemName", out var itemNameProp))
{
itemName = itemNameProp.GetString();
}
if (doc.RootElement.TryGetProperty("PositionTicks", out var posProp))
{
positionTicks = posProp.GetInt64();
}
// Track the playing item for scrobbling on session cleanup
var (deviceId, client, device, version) = ExtractDeviceInfo(Request.Headers);
if (!string.IsNullOrEmpty(deviceId) && !string.IsNullOrEmpty(itemId))
{
_sessionManager.UpdatePlayingItem(deviceId, itemId, positionTicks);
}
if (!string.IsNullOrEmpty(itemId))
{
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
if (isExternal)
{
_logger.LogInformation("🎵 External track playback started: {Name} ({Provider}/{ExternalId})",
itemName ?? "Unknown", provider, externalId);
// Proactively fetch lyrics in background for external tracks
_ = Task.Run(async () =>
{
try
{
await PrefetchLyricsForTrackAsync(itemId, isExternal: true, provider, externalId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to prefetch lyrics for external track {ItemId}", itemId);
}
});
// Create a ghost/fake item to report to Jellyfin so "Now Playing" shows up
// Generate a deterministic UUID from the external ID
var ghostUuid = GenerateUuidFromString(itemId);
// Build minimal playback start with just the ghost UUID
// Don't include the Item object - Jellyfin will just track the session without item details
var playbackStart = new
{
ItemId = ghostUuid,
PositionTicks = positionTicks ?? 0,
CanSeek = true,
IsPaused = false,
IsMuted = false,
PlayMethod = "DirectPlay"
};
var playbackJson = JsonSerializer.Serialize(playbackStart);
_logger.LogDebug("📤 Sending ghost playback start for external track: {Json}", playbackJson);
// Forward to Jellyfin with ghost UUID
var (ghostResult, ghostStatusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", playbackJson, Request.Headers);
if (ghostStatusCode == 204 || ghostStatusCode == 200)
{
_logger.LogDebug("✓ Ghost playback start forwarded to Jellyfin for external track ({StatusCode})", ghostStatusCode);
}
else
{
_logger.LogWarning("⚠️ Ghost playback start returned status {StatusCode} for external track", ghostStatusCode);
}
return NoContent();
}
_logger.LogInformation("🎵 Local track playback started: {Name} (ID: {ItemId})",
itemName ?? "Unknown", itemId);
// Proactively fetch lyrics in background for local tracks
_ = Task.Run(async () =>
{
try
{
await PrefetchLyricsForTrackAsync(itemId, isExternal: false, null, null);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to prefetch lyrics for local track {ItemId}", itemId);
}
});
}
// For local tracks, forward playback start to Jellyfin FIRST
_logger.LogDebug("Forwarding playback start to Jellyfin...");
// Fetch full item details to include in playback report
try
{
var (itemResult, itemStatus) = await _proxyService.GetJsonAsync($"Items/{itemId}", null, Request.Headers);
if (itemResult != null && itemStatus == 200)
{
var item = itemResult.RootElement;
_logger.LogInformation("📦 Fetched item details for playback report");
// Build playback start info - Jellyfin will fetch item details itself
var playbackStart = new
{
ItemId = itemId,
PositionTicks = positionTicks ?? 0,
// Let Jellyfin fetch the item details - don't include NowPlayingItem
};
var playbackJson = JsonSerializer.Serialize(playbackStart);
_logger.LogInformation("📤 Sending playback start: {Json}", playbackJson);
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", playbackJson, Request.Headers);
if (statusCode == 204 || statusCode == 200)
{
_logger.LogDebug("✓ Playback start forwarded to Jellyfin ({StatusCode})", statusCode);
// NOW ensure session exists with capabilities (after playback is reported)
if (!string.IsNullOrEmpty(deviceId))
{
var sessionCreated = await _sessionManager.EnsureSessionAsync(deviceId, client ?? "Unknown", device ?? "Unknown", version ?? "1.0", Request.Headers);
if (sessionCreated)
{
_logger.LogDebug("✓ SESSION: Session ensured for device {DeviceId} after playback start", deviceId);
}
else
{
_logger.LogError("⚠️ SESSION: Failed to ensure session for device {DeviceId}", deviceId);
}
}
else
{
_logger.LogWarning("⚠️ SESSION: No device ID found in headers for playback start");
}
}
else
{
_logger.LogWarning("⚠️ Playback start returned status {StatusCode}", statusCode);
}
}
else
{
_logger.LogWarning("⚠️ Could not fetch item details ({StatusCode}), sending basic playback start", itemStatus);
// Fall back to basic playback start
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", body, Request.Headers);
if (statusCode == 204 || statusCode == 200)
{
_logger.LogDebug("✓ Basic playback start forwarded to Jellyfin ({StatusCode})", statusCode);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send playback start, trying basic");
// Fall back to basic playback start
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", body, Request.Headers);
if (statusCode == 204 || statusCode == 200)
{
_logger.LogInformation("✓ Basic playback start forwarded to Jellyfin ({StatusCode})", statusCode);
}
}
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to report playback start");
return NoContent(); // Return success anyway to not break playback
}
}
/// <summary>
/// Reports playback progress. Handles both local and external tracks.
/// </summary>
[HttpPost("Sessions/Playing/Progress")]
public async Task<IActionResult> ReportPlaybackProgress()
{
try
{
Request.EnableBuffering();
string body;
using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true))
{
body = await reader.ReadToEndAsync();
}
Request.Body.Position = 0;
// Update session activity
var (deviceId, _, _, _) = ExtractDeviceInfo(Request.Headers);
if (!string.IsNullOrEmpty(deviceId))
{
_sessionManager.UpdateActivity(deviceId);
}
// Parse the body to check if it's an external track
var doc = JsonDocument.Parse(body);
string? itemId = null;
long? positionTicks = null;
if (doc.RootElement.TryGetProperty("ItemId", out var itemIdProp))
{
itemId = itemIdProp.GetString();
}
if (doc.RootElement.TryGetProperty("PositionTicks", out var posProp))
{
positionTicks = posProp.GetInt64();
}
// Track the playing item for scrobbling on session cleanup
if (!string.IsNullOrEmpty(deviceId) && !string.IsNullOrEmpty(itemId))
{
_sessionManager.UpdatePlayingItem(deviceId, itemId, positionTicks);
}
if (!string.IsNullOrEmpty(itemId))
{
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
if (isExternal)
{
// For external tracks, report progress with ghost UUID to Jellyfin
var ghostUuid = GenerateUuidFromString(itemId);
// Build progress report with ghost UUID
var progressReport = new
{
ItemId = ghostUuid,
PositionTicks = positionTicks ?? 0,
IsPaused = false,
IsMuted = false,
CanSeek = true,
PlayMethod = "DirectPlay"
};
var progressJson = JsonSerializer.Serialize(progressReport);
// Forward to Jellyfin with ghost UUID
var (progressResult, progressStatusCode) = await _proxyService.PostJsonAsync("Sessions/Playing/Progress", progressJson, Request.Headers);
// Log progress occasionally for debugging (every ~30 seconds)
if (positionTicks.HasValue)
{
var position = TimeSpan.FromTicks(positionTicks.Value);
if (position.Seconds % 30 == 0 && position.Milliseconds < 500)
{
_logger.LogDebug("▶️ External track progress: {Position:mm\\:ss} ({Provider}/{ExternalId}) - Status: {StatusCode}",
position, provider, externalId, progressStatusCode);
}
}
return NoContent();
}
// Log progress for local tracks (only every ~10 seconds to avoid spam)
if (positionTicks.HasValue)
{
var position = TimeSpan.FromTicks(positionTicks.Value);
// Only log at 10-second intervals
if (position.Seconds % 10 == 0 && position.Milliseconds < 500)
{
_logger.LogDebug("▶️ Progress: {Position:mm\\:ss} for item {ItemId}", position, itemId);
}
}
}
// For local tracks, forward to Jellyfin
_logger.LogDebug("📤 Sending playback progress body: {Body}", body);
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing/Progress", body, Request.Headers);
if (statusCode != 204 && statusCode != 200)
{
_logger.LogWarning("⚠️ Progress report returned {StatusCode} for item {ItemId}", statusCode, itemId);
}
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to report playback progress");
return NoContent();
}
}
/// <summary>
/// Reports playback stopped. Handles both local and external tracks.
/// </summary>
[HttpPost("Sessions/Playing/Stopped")]
public async Task<IActionResult> ReportPlaybackStopped()
{
try
{
Request.EnableBuffering();
string body;
using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true))
{
body = await reader.ReadToEndAsync();
}
Request.Body.Position = 0;
_logger.LogInformation("⏹️ Playback STOPPED reported");
// Parse the body to check if it's an external track
var doc = JsonDocument.Parse(body);
string? itemId = null;
string? itemName = null;
long? positionTicks = null;
string? deviceId = null;
if (doc.RootElement.TryGetProperty("ItemId", out var itemIdProp))
{
itemId = itemIdProp.GetString();
}
if (doc.RootElement.TryGetProperty("ItemName", out var itemNameProp))
{
itemName = itemNameProp.GetString();
}
if (doc.RootElement.TryGetProperty("PositionTicks", out var posProp))
{
positionTicks = posProp.GetInt64();
}
// Try to get device ID from headers for session management
if (Request.Headers.TryGetValue("X-Emby-Device-Id", out var deviceIdHeader))
{
deviceId = deviceIdHeader.FirstOrDefault();
}
if (!string.IsNullOrEmpty(itemId))
{
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
if (isExternal)
{
var position = positionTicks.HasValue
? TimeSpan.FromTicks(positionTicks.Value).ToString(@"mm\:ss")
: "unknown";
_logger.LogInformation("🎵 External track playback stopped: {Name} at {Position} ({Provider}/{ExternalId})",
itemName ?? "Unknown", position, provider, externalId);
// Report stop to Jellyfin with ghost UUID
var ghostUuid = GenerateUuidFromString(itemId);
var stopInfo = new
{
ItemId = ghostUuid,
PositionTicks = positionTicks ?? 0
};
var stopJson = JsonSerializer.Serialize(stopInfo);
_logger.LogDebug("📤 Sending ghost playback stop for external track: {Json}", stopJson);
var (stopResult, stopStatusCode) = await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", stopJson, Request.Headers);
if (stopStatusCode == 204 || stopStatusCode == 200)
{
_logger.LogDebug("✓ Ghost playback stop forwarded to Jellyfin ({StatusCode})", stopStatusCode);
}
return NoContent();
}
_logger.LogInformation("🎵 Local track playback stopped: {Name} (ID: {ItemId})",
itemName ?? "Unknown", itemId);
}
// For local tracks, forward to Jellyfin
_logger.LogDebug("Forwarding playback stop to Jellyfin...");
// Log the body being sent for debugging
_logger.LogInformation("📤 Sending playback stop body: {Body}", body);
// Validate that body is not empty
if (string.IsNullOrWhiteSpace(body) || body == "{}")
{
_logger.LogWarning("⚠️ Playback stop body is empty, building minimal valid payload");
// Build a minimal valid PlaybackStopInfo
var stopInfo = new
{
ItemId = itemId,
PositionTicks = positionTicks ?? 0
};
body = JsonSerializer.Serialize(stopInfo);
_logger.LogInformation("📤 Built playback stop body: {Body}", body);
}
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", body, Request.Headers);
if (statusCode == 204 || statusCode == 200)
{
_logger.LogDebug("✓ Playback stop forwarded to Jellyfin ({StatusCode})", statusCode);
}
else if (statusCode == 401)
{
_logger.LogWarning("Playback stop returned 401 (token expired)");
}
else
{
_logger.LogWarning("Playback stop forward failed with status {StatusCode}", statusCode);
}
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to report playback stopped");
return NoContent();
}
}
/// <summary>
/// Pings a playback session to keep it alive.
/// </summary>
[HttpPost("Sessions/Playing/Ping")]
public async Task<IActionResult> PingPlaybackSession([FromQuery] string playSessionId)
{
try
{
_logger.LogDebug("Playback session ping: {SessionId}", playSessionId);
// Forward to Jellyfin
var endpoint = $"Sessions/Playing/Ping?playSessionId={Uri.EscapeDataString(playSessionId)}";
var (result, statusCode) = await _proxyService.PostJsonAsync(endpoint, "{}", Request.Headers);
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to ping playback session");
return NoContent();
}
}
/// <summary>
/// Catch-all for any other session-related requests.
/// <summary>
/// Catch-all proxy for any other session-related endpoints we haven't explicitly implemented.
/// This ensures all session management calls get proxied to Jellyfin.
/// Examples: GET /Sessions, POST /Sessions/Logout, etc.
/// </summary>
[HttpGet("Sessions")]
[HttpPost("Sessions")]
[HttpGet("Sessions/{**path}")]
[HttpPost("Sessions/{**path}")]
[HttpPut("Sessions/{**path}")]
[HttpDelete("Sessions/{**path}")]
public async Task<IActionResult> ProxySessionRequest(string? path = null)
{
try
{
var method = Request.Method;
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : "";
var endpoint = string.IsNullOrEmpty(path) ? $"Sessions{queryString}" : $"Sessions/{path}{queryString}";
_logger.LogDebug("🔄 Proxying session request: {Method} {Endpoint}", method, endpoint);
_logger.LogDebug("Session proxy headers: {Headers}",
string.Join(", ", Request.Headers.Where(h => h.Key.Contains("Auth", StringComparison.OrdinalIgnoreCase))
.Select(h => $"{h.Key}={h.Value}")));
// Read body if present
string body = "{}";
if ((method == "POST" || method == "PUT") && Request.ContentLength > 0)
{
Request.EnableBuffering();
using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true))
{
body = await reader.ReadToEndAsync();
}
Request.Body.Position = 0;
_logger.LogDebug("Session proxy body: {Body}", body);
}
// Forward to Jellyfin
var (result, statusCode) = method switch
{
"GET" => await _proxyService.GetJsonAsync(endpoint, null, Request.Headers),
"POST" => await _proxyService.PostJsonAsync(endpoint, body, Request.Headers),
"PUT" => await _proxyService.PostJsonAsync(endpoint, body, Request.Headers), // Use POST for PUT
"DELETE" => await _proxyService.PostJsonAsync(endpoint, body, Request.Headers), // Use POST for DELETE
_ => (null, 405)
};
if (result != null)
{
_logger.LogDebug("✓ Session request proxied successfully ({StatusCode})", statusCode);
return new JsonResult(result.RootElement.Clone());
}
_logger.LogDebug("✓ Session request proxied ({StatusCode}, no body)", statusCode);
return StatusCode(statusCode);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to proxy session request: {Path}", path);
return StatusCode(500);
}
}
#endregion // Session Management
#endregion // Playback Session Reporting
#region System & Proxy
/// <summary>
@@ -3113,1400 +1271,71 @@ public class JellyfinController : ControllerBase
#endregion
#region Helpers
/// <summary>
/// Helper to handle proxy responses with proper status code handling.
/// Converts a JsonElement to a Dictionary while properly preserving nested objects and arrays.
/// This prevents metadata from being stripped when deserializing Jellyfin responses.
/// </summary>
private IActionResult HandleProxyResponse(JsonDocument? result, int statusCode, object? fallbackValue = null)
private Dictionary<string, object?> JsonElementToDictionary(JsonElement element)
{
if (result != null)
var dict = new Dictionary<string, object?>();
foreach (var property in element.EnumerateObject())
{
return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText()));
dict[property.Name] = ConvertJsonElement(property.Value);
}
// Handle error status codes
if (statusCode == 401)
{
return Unauthorized();
}
else if (statusCode == 403)
{
return Forbid();
}
else if (statusCode == 404)
{
return NotFound();
}
else if (statusCode >= 400)
{
return StatusCode(statusCode);
}
// Success with no body - return fallback or empty
if (fallbackValue != null)
{
return new JsonResult(fallbackValue);
}
return NoContent();
return dict;
}
/// <summary>
/// Updates ChildCount for Spotify playlists in the response to show total tracks (local + matched).
/// Recursively converts JsonElement values to proper C# types (Dictionary, List, primitives).
/// </summary>
private async Task<JsonDocument> UpdateSpotifyPlaylistCounts(JsonDocument response)
private object? ConvertJsonElement(JsonElement element)
{
try
switch (element.ValueKind)
{
if (!response.RootElement.TryGetProperty("Items", out var items))
{
return response;
}
var itemsArray = items.EnumerateArray().ToList();
var modified = false;
var updatedItems = new List<Dictionary<string, object>>();
_logger.LogDebug("Checking {Count} items for Spotify playlists", itemsArray.Count);
foreach (var item in itemsArray)
{
var itemDict = JsonSerializer.Deserialize<Dictionary<string, object>>(item.GetRawText());
if (itemDict == null)
case JsonValueKind.Object:
var dict = new Dictionary<string, object?>();
foreach (var property in element.EnumerateObject())
{
continue;
dict[property.Name] = ConvertJsonElement(property.Value);
}
// Check if this is a Spotify playlist
if (item.TryGetProperty("Id", out var idProp))
{
var playlistId = idProp.GetString();
_logger.LogDebug("Checking item with ID: {Id}", playlistId);
if (!string.IsNullOrEmpty(playlistId) && _spotifySettings.IsSpotifyPlaylist(playlistId))
{
_logger.LogInformation("Found Spotify playlist: {Id}", playlistId);
// This is a Spotify playlist - get the actual track count
var playlistConfig = _spotifySettings.GetPlaylistByJellyfinId(playlistId);
if (playlistConfig != null)
{
_logger.LogInformation("Found playlist config for Jellyfin ID {JellyfinId}: {Name} (Spotify ID: {SpotifyId})",
playlistId, playlistConfig.Name, playlistConfig.Id);
var playlistName = playlistConfig.Name;
// Get matched external tracks (tracks that were successfully downloaded/matched)
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlistName);
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
_logger.LogInformation("Cache lookup for {Key}: {Count} matched tracks",
matchedTracksKey, matchedTracks?.Count ?? 0);
// Fallback to legacy cache format
if (matchedTracks == null || matchedTracks.Count == 0)
{
var legacyKey = $"spotify:matched:{playlistName}";
var legacySongs = await _cache.GetAsync<List<Song>>(legacyKey);
if (legacySongs != null && legacySongs.Count > 0)
{
matchedTracks = legacySongs.Select((s, i) => new MatchedTrack
{
Position = i,
MatchedSong = s
}).ToList();
_logger.LogDebug("Loaded {Count} tracks from legacy cache", matchedTracks.Count);
}
}
// Try loading from file cache if Redis is empty
if (matchedTracks == null || matchedTracks.Count == 0)
{
var fileItems = await LoadPlaylistItemsFromFile(playlistName);
if (fileItems != null && fileItems.Count > 0)
{
_logger.LogDebug("💿 Loaded {Count} playlist items from file cache for count update", fileItems.Count);
// Use file cache count directly
itemDict["ChildCount"] = fileItems.Count;
modified = true;
}
}
// Only fetch from Jellyfin if we didn't get count from file cache
if (!itemDict.ContainsKey("ChildCount") ||
(itemDict["ChildCount"] is JsonElement childCountElement && childCountElement.GetInt32() == 0) ||
(itemDict["ChildCount"] is int childCountInt && childCountInt == 0))
{
// Get local tracks count from Jellyfin
var localTracksCount = 0;
try
{
// Include UserId parameter to avoid 401 Unauthorized
var userId = _settings.UserId;
var playlistItemsUrl = $"Playlists/{playlistId}/Items";
var queryParams = new Dictionary<string, string>();
if (!string.IsNullOrEmpty(userId))
{
queryParams["UserId"] = userId;
}
var (localTracksResponse, _) = await _proxyService.GetJsonAsyncInternal(
playlistItemsUrl,
queryParams);
if (localTracksResponse != null &&
localTracksResponse.RootElement.TryGetProperty("Items", out var localItems))
{
localTracksCount = localItems.GetArrayLength();
_logger.LogDebug("Found {Count} total items in Jellyfin playlist {Name}",
localTracksCount, playlistName);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get local tracks count for {Name}", playlistName);
}
// Count external matched tracks (not local)
var externalMatchedCount = 0;
if (matchedTracks != null)
{
externalMatchedCount = matchedTracks.Count(t => t.MatchedSong != null && !t.MatchedSong.IsLocal);
}
// Total available tracks = local tracks in Jellyfin + external matched tracks
// This represents what users will actually hear when playing the playlist
var totalAvailableCount = localTracksCount + externalMatchedCount;
if (totalAvailableCount > 0)
{
// Update ChildCount to show actual available tracks
itemDict["ChildCount"] = totalAvailableCount;
modified = true;
_logger.LogDebug("✓ Updated ChildCount for Spotify playlist {Name} to {Total} ({Local} local + {External} external)",
playlistName, totalAvailableCount, localTracksCount, externalMatchedCount);
}
else
{
_logger.LogWarning("No tracks found for {Name} ({Local} local + {External} external = {Total} total)",
playlistName, localTracksCount, externalMatchedCount, totalAvailableCount);
}
}
}
else
{
_logger.LogWarning("No playlist config found for Jellyfin ID {JellyfinId} - skipping count update", playlistId);
}
}
}
updatedItems.Add(itemDict);
}
if (!modified)
{
_logger.LogInformation("No Spotify playlists found to update");
return response;
}
_logger.LogDebug("Modified {Count} Spotify playlists, rebuilding response",
updatedItems.Count(i => i.ContainsKey("ChildCount")));
// Rebuild the response with updated items
var responseDict = JsonSerializer.Deserialize<Dictionary<string, object>>(response.RootElement.GetRawText());
if (responseDict != null)
{
responseDict["Items"] = updatedItems;
var updatedJson = JsonSerializer.Serialize(responseDict);
return JsonDocument.Parse(updatedJson);
}
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update Spotify playlist counts");
return response;
}
}
/// <summary>
/// Logs endpoint usage to a file for analysis.
/// Creates a CSV file with timestamp, method, path, and query string.
/// </summary>
private async Task LogEndpointUsageAsync(string path, string method)
{
try
{
var logDir = "/app/cache/endpoint-usage";
Directory.CreateDirectory(logDir);
var logFile = Path.Combine(logDir, "endpoints.csv");
var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss");
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : "";
// Sanitize path and query for CSV (remove commas, quotes, newlines)
var sanitizedPath = path.Replace(",", ";").Replace("\"", "'").Replace("\n", " ").Replace("\r", " ");
var sanitizedQuery = queryString.Replace(",", ";").Replace("\"", "'").Replace("\n", " ").Replace("\r", " ");
var logLine = $"{timestamp},{method},{sanitizedPath},{sanitizedQuery}\n";
// Append to file (thread-safe)
await System.IO.File.AppendAllTextAsync(logFile, logLine);
}
catch (Exception ex)
{
// Don't let logging failures break the request
_logger.LogError(ex, "Failed to log endpoint usage");
}
}
private static string[]? ParseItemTypes(string? includeItemTypes)
{
if (string.IsNullOrWhiteSpace(includeItemTypes))
{
return null;
}
return includeItemTypes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
private static string GetContentType(string filePath)
{
var extension = Path.GetExtension(filePath).ToLowerInvariant();
return extension switch
{
".mp3" => "audio/mpeg",
".flac" => "audio/flac",
".ogg" => "audio/ogg",
".m4a" => "audio/mp4",
".wav" => "audio/wav",
".aac" => "audio/aac",
_ => "audio/mpeg"
};
}
/// <summary>
/// Scores search results based on fuzzy matching against the query.
/// Returns items with their relevance scores.
/// External results get a small boost to prioritize the larger catalog.
/// </summary>
private static List<(T Item, int Score)> ScoreSearchResults<T>(
string query,
List<T> items,
Func<T, string> titleField,
Func<T, string?> artistField,
Func<T, string?> albumField,
bool isExternal = false)
{
return items.Select(item =>
{
var title = titleField(item) ?? "";
var artist = artistField(item) ?? "";
var album = albumField(item) ?? "";
// Token-based fuzzy matching: split query and fields into words
var queryTokens = query.ToLower()
.Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries)
.ToList();
var fieldText = $"{title} {artist} {album}".ToLower();
var fieldTokens = fieldText
.Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries)
.ToList();
if (queryTokens.Count == 0) return (item, 0);
// Count how many query tokens match field tokens (with fuzzy tolerance)
var matchedTokens = 0;
foreach (var queryToken in queryTokens)
{
// Check if any field token matches this query token
var hasMatch = fieldTokens.Any(fieldToken =>
{
// Exact match or substring match
if (fieldToken.Contains(queryToken) || queryToken.Contains(fieldToken))
return true;
// Fuzzy match with Levenshtein distance
var similarity = FuzzyMatcher.CalculateSimilarity(queryToken, fieldToken);
return similarity >= 70; // 70% similarity threshold for individual words
});
if (hasMatch) matchedTokens++;
}
// Score = percentage of query tokens that matched
var baseScore = (matchedTokens * 100) / queryTokens.Count;
// Give external results a small boost (+5 points) to prioritize the larger catalog
var finalScore = isExternal ? Math.Min(100, baseScore + 5) : baseScore;
return (item, finalScore);
}).ToList();
}
#endregion
#region Spotify Playlist Injection
/// <summary>
/// Gets tracks for a Spotify playlist by matching missing tracks against external providers
/// and merging with existing local tracks from Jellyfin.
///
/// Supports two modes:
/// 1. Direct Spotify API (new): Uses SpotifyPlaylistFetcher for ordered tracks with ISRC matching
/// 2. Jellyfin Plugin (legacy): Uses MissingTrack data from Jellyfin Spotify Import plugin
/// </summary>
private async Task<IActionResult> GetSpotifyPlaylistTracksAsync(string spotifyPlaylistName, string playlistId)
{
try
{
// Try ordered cache first (from direct Spotify API mode)
if (_spotifyApiSettings.Enabled && _spotifyPlaylistFetcher != null)
{
var orderedResult = await GetSpotifyPlaylistTracksOrderedAsync(spotifyPlaylistName, playlistId);
if (orderedResult != null) return orderedResult;
}
// Fall back to legacy unordered mode
return await GetSpotifyPlaylistTracksLegacyAsync(spotifyPlaylistName, playlistId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting Spotify playlist tracks {PlaylistName}", spotifyPlaylistName);
return _responseBuilder.CreateError(500, "Failed to get Spotify playlist tracks");
}
}
/// <summary>
/// New mode: Gets playlist tracks with correct ordering using direct Spotify API data.
/// Optimized to only re-match when Jellyfin playlist changes (cheap check).
/// </summary>
private async Task<IActionResult?> GetSpotifyPlaylistTracksOrderedAsync(string spotifyPlaylistName, string playlistId)
{
// Check if Jellyfin playlist has changed (cheap API call)
var jellyfinSignatureCacheKey = $"spotify:playlist:jellyfin-signature:{spotifyPlaylistName}";
var currentJellyfinSignature = await GetJellyfinPlaylistSignatureAsync(playlistId);
var cachedJellyfinSignature = await _cache.GetAsync<string>(jellyfinSignatureCacheKey);
var jellyfinPlaylistChanged = cachedJellyfinSignature != currentJellyfinSignature;
// Check Redis cache first for fast serving (only if Jellyfin playlist hasn't changed)
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(spotifyPlaylistName);
var cachedItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(cacheKey);
if (cachedItems != null && cachedItems.Count > 0 && !jellyfinPlaylistChanged)
{
_logger.LogDebug("✅ Loaded {Count} playlist items from Redis cache for {Playlist} (Jellyfin unchanged)",
cachedItems.Count, spotifyPlaylistName);
return new JsonResult(new
{
Items = cachedItems,
TotalRecordCount = cachedItems.Count,
StartIndex = 0
});
}
if (jellyfinPlaylistChanged)
{
_logger.LogInformation("🔄 Jellyfin playlist changed for {Playlist} - re-matching tracks", spotifyPlaylistName);
}
// Check file cache as fallback
var fileItems = await LoadPlaylistItemsFromFile(spotifyPlaylistName);
if (fileItems != null && fileItems.Count > 0)
{
_logger.LogDebug("✅ Loaded {Count} playlist items from file cache for {Playlist}",
fileItems.Count, spotifyPlaylistName);
// Restore to Redis cache
await _cache.SetAsync(cacheKey, fileItems, CacheExtensions.SpotifyPlaylistItemsTTL);
return new JsonResult(new
{
Items = fileItems,
TotalRecordCount = fileItems.Count,
StartIndex = 0
});
}
// Check for ordered matched tracks from SpotifyTrackMatchingService
var orderedCacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(spotifyPlaylistName);
var orderedTracks = await _cache.GetAsync<List<MatchedTrack>>(orderedCacheKey);
if (orderedTracks == null || orderedTracks.Count == 0)
{
_logger.LogInformation("No ordered matched tracks in cache for {Playlist}, checking if we can fetch",
spotifyPlaylistName);
return null; // Fall back to legacy mode
}
_logger.LogInformation("Using {Count} ordered matched tracks for {Playlist}",
orderedTracks.Count, spotifyPlaylistName);
// Get existing Jellyfin playlist items (RAW - don't convert!)
// CRITICAL: Must include UserId parameter or Jellyfin returns empty results
var userId = _settings.UserId;
if (string.IsNullOrEmpty(userId))
{
_logger.LogError("❌ JELLYFIN_USER_ID is NOT configured! Cannot fetch playlist tracks. Set it in .env or admin UI.");
return null; // Fall back to legacy mode
}
// Pass through all requested fields from the original request
var queryString = Request.QueryString.Value ?? "";
var playlistItemsUrl = $"Playlists/{playlistId}/Items?UserId={userId}";
// Append the original query string (which includes Fields parameter)
if (!string.IsNullOrEmpty(queryString))
{
// Remove the leading ? if present
queryString = queryString.TrimStart('?');
playlistItemsUrl = $"{playlistItemsUrl}&{queryString}";
}
_logger.LogInformation("🔍 Fetching existing tracks from Jellyfin playlist {PlaylistId} with UserId {UserId}",
playlistId, userId);
var (existingTracksResponse, statusCode) = await _proxyService.GetJsonAsync(
playlistItemsUrl,
null,
Request.Headers);
if (statusCode != 200)
{
_logger.LogError("❌ Failed to fetch Jellyfin playlist items: HTTP {StatusCode}. Check JELLYFIN_USER_ID is correct.", statusCode);
return null;
}
// Keep raw Jellyfin items - don't convert to Song objects!
var jellyfinItems = new List<JsonElement>();
var jellyfinItemsByName = new Dictionary<string, JsonElement>();
if (existingTracksResponse != null &&
existingTracksResponse.RootElement.TryGetProperty("Items", out var items))
{
foreach (var item in items.EnumerateArray())
{
jellyfinItems.Add(item);
return dict;
// Index by title+artist for matching
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
var artist = "";
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
case JsonValueKind.Array:
var list = new List<object?>();
foreach (var item in element.EnumerateArray())
{
artist = artistsEl[0].GetString() ?? "";
}
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
{
artist = albumArtistEl.GetString() ?? "";
list.Add(ConvertJsonElement(item));
}
return list;
var key = $"{title}|{artist}".ToLowerInvariant();
if (!jellyfinItemsByName.ContainsKey(key))
{
jellyfinItemsByName[key] = item;
}
}
_logger.LogInformation("✅ Found {Count} existing LOCAL tracks in Jellyfin playlist", jellyfinItems.Count);
}
else
{
_logger.LogWarning("⚠️ No existing tracks found in Jellyfin playlist {PlaylistId} - playlist may be empty", playlistId);
}
// Get the full playlist from Spotify to know the correct order
var spotifyTracks = await _spotifyPlaylistFetcher!.GetPlaylistTracksAsync(spotifyPlaylistName);
if (spotifyTracks.Count == 0)
{
_logger.LogWarning("Could not get Spotify playlist tracks for {Playlist}", spotifyPlaylistName);
return null; // Fall back to legacy
}
// Build the final track list in correct Spotify order
var finalItems = new List<Dictionary<string, object?>>();
var usedJellyfinItems = new HashSet<string>();
var localUsedCount = 0;
var externalUsedCount = 0;
_logger.LogDebug("🔍 Building playlist in Spotify order with {SpotifyCount} positions...", spotifyTracks.Count);
foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position))
{
// Try to find matching Jellyfin item by fuzzy matching
JsonElement? matchedJellyfinItem = null;
string? matchedKey = null;
double bestScore = 0;
foreach (var kvp in jellyfinItemsByName)
{
if (usedJellyfinItems.Contains(kvp.Key)) continue;
case JsonValueKind.String:
return element.GetString();
var item = kvp.Value;
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
var artist = "";
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
{
artist = artistsEl[0].GetString() ?? "";
}
case JsonValueKind.Number:
if (element.TryGetInt32(out var intValue))
return intValue;
if (element.TryGetInt64(out var longValue))
return longValue;
if (element.TryGetDouble(out var doubleValue))
return doubleValue;
return element.GetDecimal();
var titleScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.Title, title);
var artistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, artist);
var totalScore = (titleScore * 0.7) + (artistScore * 0.3);
case JsonValueKind.True:
return true;
if (totalScore > bestScore && totalScore >= 70)
{
bestScore = totalScore;
matchedJellyfinItem = item;
matchedKey = kvp.Key;
}
}
if (matchedJellyfinItem.HasValue && matchedKey != null)
{
// Use the raw Jellyfin item (preserves ALL metadata including MediaSources!)
var itemDict = JsonSerializer.Deserialize<Dictionary<string, object?>>(matchedJellyfinItem.Value.GetRawText());
if (itemDict != null)
{
finalItems.Add(itemDict);
usedJellyfinItems.Add(matchedKey);
localUsedCount++;
_logger.LogDebug("✅ Position #{Pos}: '{Title}' → LOCAL (score: {Score:F1}%)",
spotifyTrack.Position, spotifyTrack.Title, bestScore);
}
}
else
{
// No local match - try to find external track
var matched = orderedTracks?.FirstOrDefault(t => t.SpotifyId == spotifyTrack.SpotifyId);
if (matched != null && matched.MatchedSong != null)
{
// Convert external song to Jellyfin item format
var externalItem = _responseBuilder.ConvertSongToJellyfinItem(matched.MatchedSong);
// Add Spotify ID to ProviderIds so lyrics can work
if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId))
{
if (!externalItem.ContainsKey("ProviderIds"))
{
externalItem["ProviderIds"] = new Dictionary<string, string>();
}
var providerIds = externalItem["ProviderIds"] as Dictionary<string, string>;
if (providerIds != null && !providerIds.ContainsKey("Spotify"))
{
providerIds["Spotify"] = spotifyTrack.SpotifyId;
}
}
finalItems.Add(externalItem);
externalUsedCount++;
_logger.LogDebug("📥 Position #{Pos}: '{Title}' → EXTERNAL: {Provider}/{Id} (Spotify ID: {SpotifyId})",
spotifyTrack.Position, spotifyTrack.Title,
matched.MatchedSong.ExternalProvider, matched.MatchedSong.ExternalId, spotifyTrack.SpotifyId);
}
else
{
_logger.LogDebug("❌ Position #{Pos}: '{Title}' → NO MATCH",
spotifyTrack.Position, spotifyTrack.Title);
}
}
}
_logger.LogDebug("🎵 Final playlist '{Playlist}': {Total} tracks ({Local} LOCAL + {External} EXTERNAL)",
spotifyPlaylistName, finalItems.Count, localUsedCount, externalUsedCount);
// Save to file cache for persistence across restarts
await SavePlaylistItemsToFile(spotifyPlaylistName, finalItems);
// Also cache in Redis for fast serving (reuse the same cache key from top of method)
await _cache.SetAsync(cacheKey, finalItems, CacheExtensions.SpotifyPlaylistItemsTTL);
// Cache the Jellyfin playlist signature to detect future changes
await _cache.SetAsync(jellyfinSignatureCacheKey, currentJellyfinSignature, CacheExtensions.SpotifyPlaylistItemsTTL);
// Return raw Jellyfin response format
return new JsonResult(new
{
Items = finalItems,
TotalRecordCount = finalItems.Count,
StartIndex = 0
});
}
/// <summary>
/// Legacy mode: Gets playlist tracks without ordering (from Jellyfin Spotify Import plugin).
/// </summary>
private async Task<IActionResult> GetSpotifyPlaylistTracksLegacyAsync(string spotifyPlaylistName, string playlistId)
{
var cacheKey = $"spotify:matched:{spotifyPlaylistName}";
var cachedTracks = await _cache.GetAsync<List<Song>>(cacheKey);
if (cachedTracks != null && cachedTracks.Count > 0)
{
_logger.LogInformation("Returning {Count} cached matched tracks from Redis for {Playlist}",
cachedTracks.Count, spotifyPlaylistName);
return _responseBuilder.CreateItemsResponse(cachedTracks);
}
// Try file cache if Redis is empty
if (cachedTracks == null || cachedTracks.Count == 0)
{
cachedTracks = await LoadMatchedTracksFromFile(spotifyPlaylistName);
if (cachedTracks != null && cachedTracks.Count > 0)
{
// Restore to Redis with configurable TTL
await _cache.SetAsync(cacheKey, cachedTracks, CacheExtensions.SpotifyMatchedTracksTTL);
_logger.LogInformation("Loaded {Count} matched tracks from file cache for {Playlist}",
cachedTracks.Count, spotifyPlaylistName);
return _responseBuilder.CreateItemsResponse(cachedTracks);
}
}
// Get existing Jellyfin playlist items (tracks the plugin already found)
// CRITICAL: Must include UserId parameter or Jellyfin returns empty results
var userId = _settings.UserId;
var playlistItemsUrl = $"Playlists/{playlistId}/Items";
if (!string.IsNullOrEmpty(userId))
{
playlistItemsUrl += $"?UserId={userId}";
}
else
{
_logger.LogInformation("No UserId configured - may not be able to fetch existing playlist tracks");
}
var (existingTracksResponse, _) = await _proxyService.GetJsonAsync(
playlistItemsUrl,
null,
Request.Headers);
var existingTracks = new List<Song>();
var existingSpotifyIds = new HashSet<string>();
if (existingTracksResponse != null &&
existingTracksResponse.RootElement.TryGetProperty("Items", out var items))
{
foreach (var item in items.EnumerateArray())
{
var song = _modelMapper.ParseSong(item);
existingTracks.Add(song);
// Track Spotify IDs to avoid duplicates
if (item.TryGetProperty("ProviderIds", out var providerIds) &&
providerIds.TryGetProperty("Spotify", out var spotifyId))
{
existingSpotifyIds.Add(spotifyId.GetString() ?? "");
}
}
_logger.LogInformation("Found {Count} existing tracks in Jellyfin playlist", existingTracks.Count);
}
else
{
_logger.LogWarning("No existing tracks found in Jellyfin playlist - may need UserId parameter");
}
var missingTracksKey = CacheKeyBuilder.BuildSpotifyMissingTracksKey(spotifyPlaylistName);
var missingTracks = await _cache.GetAsync<List<MissingTrack>>(missingTracksKey);
// Fallback to file cache if Redis is empty
if (missingTracks == null || missingTracks.Count == 0)
{
missingTracks = await LoadMissingTracksFromFile(spotifyPlaylistName);
// If we loaded from file, restore to Redis with no expiration
if (missingTracks != null && missingTracks.Count > 0)
{
await _cache.SetAsync(missingTracksKey, missingTracks, TimeSpan.FromDays(365));
_logger.LogDebug("Restored {Count} missing tracks from file cache for {Playlist} (no expiration)",
missingTracks.Count, spotifyPlaylistName);
}
}
if (missingTracks == null || missingTracks.Count == 0)
{
_logger.LogInformation("No missing tracks found for {Playlist}, returning {Count} existing tracks",
spotifyPlaylistName, existingTracks.Count);
return _responseBuilder.CreateItemsResponse(existingTracks);
}
_logger.LogDebug("Matching {Count} missing tracks for {Playlist}",
missingTracks.Count, spotifyPlaylistName);
// Match missing tracks sequentially with rate limiting (excluding ones we already have locally)
var matchedBySpotifyId = new Dictionary<string, Song>();
var tracksToMatch = missingTracks
.Where(track => !existingSpotifyIds.Contains(track.SpotifyId))
.ToList();
foreach (var track in tracksToMatch)
{
try
{
// Search with just title and artist for better matching
var query = $"{track.Title} {track.PrimaryArtist}";
var results = await _metadataService.SearchSongsAsync(query, limit: 5);
if (results.Count > 0)
{
// Fuzzy match to find best result
// Check that ALL artists match (not just some)
var bestMatch = results
.Select(song => new
{
Song = song,
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, song.Title),
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors)
})
.Select(x => new
{
x.Song,
x.TitleScore,
x.ArtistScore,
TotalScore = (x.TitleScore * 0.6) + (x.ArtistScore * 0.4) // Weight title more
})
.OrderByDescending(x => x.TotalScore)
.FirstOrDefault();
// Only add if match is good enough (>60% combined score)
if (bestMatch != null && bestMatch.TotalScore >= 60)
{
_logger.LogDebug("Matched '{Title}' by {Artist} -> '{MatchTitle}' by {MatchArtist} (score: {Score:F1})",
track.Title, track.PrimaryArtist,
bestMatch.Song.Title, bestMatch.Song.Artist,
bestMatch.TotalScore);
matchedBySpotifyId[track.SpotifyId] = bestMatch.Song;
}
else
{
_logger.LogDebug("No good match for '{Title}' by {Artist} (best score: {Score:F1})",
track.Title, track.PrimaryArtist, bestMatch?.TotalScore ?? 0);
}
}
// Rate limiting: small delay between searches to avoid overwhelming the service
await Task.Delay(100); // 100ms delay = max 10 searches/second
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to match track: {Title} - {Artist}",
track.Title, track.PrimaryArtist);
}
}
// Build final track list based on playlist configuration
// Local tracks position is configurable per-playlist
var playlistConfig = _spotifySettings.GetPlaylistByJellyfinId(playlistId);
var localTracksPosition = playlistConfig?.LocalTracksPosition ?? LocalTracksPosition.First;
var finalTracks = new List<Song>();
if (localTracksPosition == LocalTracksPosition.First)
{
// Local tracks first, external tracks at the end
finalTracks.AddRange(existingTracks);
finalTracks.AddRange(matchedBySpotifyId.Values);
}
else
{
// External tracks first, local tracks at the end
finalTracks.AddRange(matchedBySpotifyId.Values);
finalTracks.AddRange(existingTracks);
}
await _cache.SetAsync(cacheKey, finalTracks, CacheExtensions.SpotifyMatchedTracksTTL);
// Also save to file cache for persistence across restarts
await SaveMatchedTracksToFile(spotifyPlaylistName, finalTracks);
_logger.LogInformation("Final playlist: {Total} tracks ({Existing} local + {Matched} external, LocalTracksPosition: {Position})",
finalTracks.Count,
existingTracks.Count,
matchedBySpotifyId.Count,
localTracksPosition);
return _responseBuilder.CreateItemsResponse(finalTracks);
}
/// <summary>
/// Copies an external track to the kept folder when favorited.
/// </summary>
private async Task CopyExternalTrackToKeptAsync(string itemId, string provider, string externalId)
{
try
{
// Check if already favorited (persistent tracking)
if (await IsTrackFavoritedAsync(itemId))
{
_logger.LogInformation("Track already favorited (persistent): {ItemId}", itemId);
return;
}
// Get the song metadata first to build paths
var song = await _metadataService.GetSongAsync(provider, externalId);
if (song == null)
{
_logger.LogWarning("Could not find song metadata for {ItemId}", itemId);
return;
}
// Build kept folder path: Artist/Album/
var keptBasePath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
var keptArtistPath = Path.Combine(keptBasePath, AdminHelperService.SanitizeFileName(song.Artist));
var keptAlbumPath = Path.Combine(keptArtistPath, AdminHelperService.SanitizeFileName(song.Album));
// Check if track already exists in kept folder
if (Directory.Exists(keptAlbumPath))
{
var sanitizedTitle = AdminHelperService.SanitizeFileName(song.Title);
var existingFiles = Directory.GetFiles(keptAlbumPath, $"*{sanitizedTitle}*");
if (existingFiles.Length > 0)
{
_logger.LogInformation("Track already exists in kept folder: {Path}", existingFiles[0]);
// Mark as favorited even if we didn't download it
await MarkTrackAsFavoritedAsync(itemId, song);
return;
}
}
// Look for the track in cache folder first
var cacheBasePath = "/tmp/allstarr-cache";
var cacheArtistPath = Path.Combine(cacheBasePath, AdminHelperService.SanitizeFileName(song.Artist));
var cacheAlbumPath = Path.Combine(cacheArtistPath, AdminHelperService.SanitizeFileName(song.Album));
string? sourceFilePath = null;
if (Directory.Exists(cacheAlbumPath))
{
var sanitizedTitle = AdminHelperService.SanitizeFileName(song.Title);
var cacheFiles = Directory.GetFiles(cacheAlbumPath, $"*{sanitizedTitle}*");
if (cacheFiles.Length > 0)
{
sourceFilePath = cacheFiles[0];
_logger.LogDebug("Found track in cache folder: {Path}", sourceFilePath);
}
}
// If not in cache, download it first
if (sourceFilePath == null)
{
_logger.LogInformation("Track not in cache, downloading: {ItemId}", itemId);
try
{
sourceFilePath = await _downloadService.DownloadSongAsync(provider, externalId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to download track {ItemId}", itemId);
return;
}
}
// Create the kept folder structure
Directory.CreateDirectory(keptAlbumPath);
// Copy file to kept folder
var fileName = Path.GetFileName(sourceFilePath);
var keptFilePath = Path.Combine(keptAlbumPath, fileName);
// Double-check in case of race condition (multiple favorite clicks)
if (System.IO.File.Exists(keptFilePath))
{
_logger.LogInformation("Track already exists in kept folder (race condition): {Path}", keptFilePath);
await MarkTrackAsFavoritedAsync(itemId, song);
return;
}
System.IO.File.Copy(sourceFilePath, keptFilePath, overwrite: false);
_logger.LogDebug("✓ Copied track to kept folder: {Path}", keptFilePath);
// Also copy cover art if it exists
var sourceCoverPath = Path.Combine(Path.GetDirectoryName(sourceFilePath)!, "cover.jpg");
if (System.IO.File.Exists(sourceCoverPath))
{
var keptCoverPath = Path.Combine(keptAlbumPath, "cover.jpg");
if (!System.IO.File.Exists(keptCoverPath))
{
System.IO.File.Copy(sourceCoverPath, keptCoverPath, overwrite: false);
_logger.LogDebug("Copied cover art to kept folder");
}
}
// Mark as favorited in persistent storage
await MarkTrackAsFavoritedAsync(itemId, song);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error copying external track {ItemId} to kept folder", itemId);
}
}
/// <summary>
/// Removes an external track from the kept folder when unfavorited.
/// </summary>
private async Task RemoveExternalTrackFromKeptAsync(string itemId, string provider, string externalId)
{
try
{
// Mark for deletion instead of immediate deletion
await MarkTrackForDeletionAsync(itemId);
_logger.LogInformation("✓ Marked track for deletion: {ItemId}", itemId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error marking external track {ItemId} for deletion", itemId);
}
}
#region Persistent Favorites Tracking
private readonly string _favoritesFilePath = "/app/cache/favorites.json";
/// <summary>
/// Checks if a track is already favorited (persistent across restarts).
/// </summary>
private async Task<bool> IsTrackFavoritedAsync(string itemId)
{
try
{
if (!System.IO.File.Exists(_favoritesFilePath))
case JsonValueKind.False:
return false;
var json = await System.IO.File.ReadAllTextAsync(_favoritesFilePath);
var favorites = JsonSerializer.Deserialize<Dictionary<string, FavoriteTrackInfo>>(json) ?? new();
return favorites.ContainsKey(itemId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to check favorite status for {ItemId}", itemId);
return false;
}
}
/// <summary>
/// Marks a track as favorited in persistent storage.
/// </summary>
private async Task MarkTrackAsFavoritedAsync(string itemId, Song song)
{
try
{
var favorites = new Dictionary<string, FavoriteTrackInfo>();
if (System.IO.File.Exists(_favoritesFilePath))
{
var json = await System.IO.File.ReadAllTextAsync(_favoritesFilePath);
favorites = JsonSerializer.Deserialize<Dictionary<string, FavoriteTrackInfo>>(json) ?? new();
}
favorites[itemId] = new FavoriteTrackInfo
{
ItemId = itemId,
Title = song.Title,
Artist = song.Artist,
Album = song.Album,
FavoritedAt = DateTime.UtcNow
};
// Ensure cache directory exists
Directory.CreateDirectory(Path.GetDirectoryName(_favoritesFilePath)!);
var updatedJson = JsonSerializer.Serialize(favorites, new JsonSerializerOptions { WriteIndented = true });
await System.IO.File.WriteAllTextAsync(_favoritesFilePath, updatedJson);
_logger.LogDebug("Marked track as favorited: {ItemId}", itemId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to mark track as favorited: {ItemId}", itemId);
}
}
/// <summary>
/// Removes a track from persistent favorites storage.
/// </summary>
private async Task UnmarkTrackAsFavoritedAsync(string itemId)
{
try
{
if (!System.IO.File.Exists(_favoritesFilePath))
return;
var json = await System.IO.File.ReadAllTextAsync(_favoritesFilePath);
var favorites = JsonSerializer.Deserialize<Dictionary<string, FavoriteTrackInfo>>(json) ?? new();
if (favorites.Remove(itemId))
{
var updatedJson = JsonSerializer.Serialize(favorites, new JsonSerializerOptions { WriteIndented = true });
await System.IO.File.WriteAllTextAsync(_favoritesFilePath, updatedJson);
_logger.LogDebug("Removed track from favorites: {ItemId}", itemId);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to remove track from favorites: {ItemId}", itemId);
}
}
/// <summary>
/// Marks a track for deletion (delayed deletion for safety).
/// </summary>
private async Task MarkTrackForDeletionAsync(string itemId)
{
try
{
var deletionFilePath = "/app/cache/pending_deletions.json";
var pendingDeletions = new Dictionary<string, DateTime>();
if (System.IO.File.Exists(deletionFilePath))
{
var json = await System.IO.File.ReadAllTextAsync(deletionFilePath);
pendingDeletions = JsonSerializer.Deserialize<Dictionary<string, DateTime>>(json) ?? new();
}
// Mark for deletion 24 hours from now
pendingDeletions[itemId] = DateTime.UtcNow.AddHours(24);
// Ensure cache directory exists
Directory.CreateDirectory(Path.GetDirectoryName(deletionFilePath)!);
var updatedJson = JsonSerializer.Serialize(pendingDeletions, new JsonSerializerOptions { WriteIndented = true });
await System.IO.File.WriteAllTextAsync(deletionFilePath, updatedJson);
// Also remove from favorites immediately
await UnmarkTrackAsFavoritedAsync(itemId);
_logger.LogDebug("Marked track for deletion in 24 hours: {ItemId}", itemId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to mark track for deletion: {ItemId}", itemId);
}
}
/// <summary>
/// Information about a favorited track for persistent storage.
/// </summary>
private class FavoriteTrackInfo
{
public string ItemId { get; set; } = "";
public string Title { get; set; } = "";
public string Artist { get; set; } = "";
public string Album { get; set; } = "";
public DateTime FavoritedAt { get; set; }
}
/// <summary>
/// Processes pending deletions (called by cleanup service).
/// </summary>
public async Task ProcessPendingDeletionsAsync()
{
try
{
var deletionFilePath = "/app/cache/pending_deletions.json";
if (!System.IO.File.Exists(deletionFilePath))
return;
var json = await System.IO.File.ReadAllTextAsync(deletionFilePath);
var pendingDeletions = JsonSerializer.Deserialize<Dictionary<string, DateTime>>(json) ?? new();
var now = DateTime.UtcNow;
var toDelete = pendingDeletions.Where(kvp => kvp.Value <= now).ToList();
var remaining = pendingDeletions.Where(kvp => kvp.Value > now).ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
foreach (var (itemId, _) in toDelete)
{
await ActuallyDeleteTrackAsync(itemId);
}
if (toDelete.Count > 0)
{
// Update pending deletions file
var updatedJson = JsonSerializer.Serialize(remaining, new JsonSerializerOptions { WriteIndented = true });
await System.IO.File.WriteAllTextAsync(deletionFilePath, updatedJson);
_logger.LogDebug("Processed {Count} pending deletions", toDelete.Count);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing pending deletions");
}
}
/// <summary>
/// Actually deletes a track from the kept folder.
/// </summary>
private async Task ActuallyDeleteTrackAsync(string itemId)
{
try
{
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
if (!isExternal) return;
var song = await _metadataService.GetSongAsync(provider!, externalId!);
if (song == null) return;
var keptBasePath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
var keptArtistPath = Path.Combine(keptBasePath, AdminHelperService.SanitizeFileName(song.Artist));
var keptAlbumPath = Path.Combine(keptArtistPath, AdminHelperService.SanitizeFileName(song.Album));
if (!Directory.Exists(keptAlbumPath)) return;
var sanitizedTitle = AdminHelperService.SanitizeFileName(song.Title);
var trackFiles = Directory.GetFiles(keptAlbumPath, $"*{sanitizedTitle}*");
foreach (var trackFile in trackFiles)
{
System.IO.File.Delete(trackFile);
_logger.LogDebug("✓ Deleted track from kept folder: {Path}", trackFile);
}
// Clean up empty directories
if (Directory.GetFiles(keptAlbumPath).Length == 0 && Directory.GetDirectories(keptAlbumPath).Length == 0)
{
Directory.Delete(keptAlbumPath);
case JsonValueKind.Null:
return null;
if (Directory.Exists(keptArtistPath) &&
Directory.GetFiles(keptArtistPath).Length == 0 &&
Directory.GetDirectories(keptArtistPath).Length == 0)
{
Directory.Delete(keptArtistPath);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete track {ItemId}", itemId);
}
}
#endregion
/// <summary>
/// Loads missing tracks from file cache as fallback when Redis is empty.
/// </summary>
private async Task<List<allstarr.Models.Spotify.MissingTrack>?> LoadMissingTracksFromFile(string playlistName)
{
try
{
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
var filePath = Path.Combine("/app/cache/spotify", $"{safeName}_missing.json");
if (!System.IO.File.Exists(filePath))
{
_logger.LogDebug("No file cache found for {Playlist} at {Path}", playlistName, filePath);
default:
return null;
}
// No expiration check - cache persists until next Jellyfin job generates new file
var fileAge = DateTime.UtcNow - System.IO.File.GetLastWriteTimeUtc(filePath);
_logger.LogDebug("File cache for {Playlist} age: {Age:F1}h (no expiration)", playlistName, fileAge.TotalHours);
var json = await System.IO.File.ReadAllTextAsync(filePath);
var tracks = JsonSerializer.Deserialize<List<allstarr.Models.Spotify.MissingTrack>>(json);
_logger.LogDebug("Loaded {Count} missing tracks from file cache for {Playlist} (age: {Age:F1}h)",
tracks?.Count ?? 0, playlistName, fileAge.TotalHours);
return tracks;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load missing tracks from file for {Playlist}", playlistName);
return null;
}
}
/// <summary>
/// Loads matched/combined tracks from file cache as fallback when Redis is empty.
/// </summary>
private async Task<List<Song>?> LoadMatchedTracksFromFile(string playlistName)
{
try
{
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
var filePath = Path.Combine("/app/cache/spotify", $"{safeName}_matched.json");
if (!System.IO.File.Exists(filePath))
{
_logger.LogInformation("No matched tracks file cache found for {Playlist} at {Path}", playlistName, filePath);
return null;
}
var fileAge = DateTime.UtcNow - System.IO.File.GetLastWriteTimeUtc(filePath);
// Check if cache is too old (more than 24 hours)
if (fileAge.TotalHours > 24)
{
_logger.LogInformation("Matched tracks file cache for {Playlist} is too old ({Age:F1}h), will rebuild",
playlistName, fileAge.TotalHours);
return null;
}
_logger.LogInformation("Matched tracks file cache for {Playlist} age: {Age:F1}h", playlistName, fileAge.TotalHours);
var json = await System.IO.File.ReadAllTextAsync(filePath);
var tracks = JsonSerializer.Deserialize<List<Song>>(json);
_logger.LogInformation("Loaded {Count} matched tracks from file cache for {Playlist} (age: {Age:F1}h)",
tracks?.Count ?? 0, playlistName, fileAge.TotalHours);
return tracks;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load matched tracks from file for {Playlist}", playlistName);
return null;
}
}
/// <summary>
/// Saves matched/combined tracks to file cache for persistence across restarts.
/// </summary>
private async Task SaveMatchedTracksToFile(string playlistName, List<Song> tracks)
{
try
{
var cacheDir = "/app/cache/spotify";
Directory.CreateDirectory(cacheDir);
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
var filePath = Path.Combine(cacheDir, $"{safeName}_matched.json");
var json = JsonSerializer.Serialize(tracks, new JsonSerializerOptions { WriteIndented = true });
await System.IO.File.WriteAllTextAsync(filePath, json);
_logger.LogInformation("Saved {Count} matched tracks to file cache for {Playlist}",
tracks.Count, playlistName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save matched tracks to file for {Playlist}", playlistName);
}
}
/// <summary>
/// Gets a signature (hash) of the Jellyfin playlist to detect changes.
/// This is a cheap operation compared to re-matching all tracks.
/// Signature includes: track count + concatenated track IDs.
/// </summary>
private async Task<string> GetJellyfinPlaylistSignatureAsync(string playlistId)
{
try
{
var userId = _settings.UserId;
var playlistItemsUrl = $"Playlists/{playlistId}/Items?Fields=Id";
if (!string.IsNullOrEmpty(userId))
{
playlistItemsUrl += $"&UserId={userId}";
}
var (response, _) = await _proxyService.GetJsonAsync(playlistItemsUrl, null, Request.Headers);
if (response != null && response.RootElement.TryGetProperty("Items", out var items))
{
var trackIds = new List<string>();
foreach (var item in items.EnumerateArray())
{
if (item.TryGetProperty("Id", out var idEl))
{
trackIds.Add(idEl.GetString() ?? "");
}
}
// Create signature: count + sorted IDs (sorted for consistency)
trackIds.Sort();
var signature = $"{trackIds.Count}:{string.Join(",", trackIds)}";
// Hash it to keep it compact
using var sha256 = System.Security.Cryptography.SHA256.Create();
var hashBytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(signature));
return Convert.ToHexString(hashBytes);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get Jellyfin playlist signature for {PlaylistId}", playlistId);
}
// Return empty string if failed (will trigger re-match)
return string.Empty;
}
/// <summary>
/// Saves playlist items (raw Jellyfin JSON) to file cache for persistence across restarts.
/// </summary>
private async Task SavePlaylistItemsToFile(string playlistName, List<Dictionary<string, object?>> items)
{
try
{
var cacheDir = "/app/cache/spotify";
Directory.CreateDirectory(cacheDir);
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
var filePath = Path.Combine(cacheDir, $"{safeName}_items.json");
var json = JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true });
await System.IO.File.WriteAllTextAsync(filePath, json);
_logger.LogDebug("💾 Saved {Count} playlist items to file cache for {Playlist}",
items.Count, playlistName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save playlist items to file for {Playlist}", playlistName);
}
}
/// <summary>
/// Loads playlist items (raw Jellyfin JSON) from file cache.
/// </summary>
private async Task<List<Dictionary<string, object?>>?> LoadPlaylistItemsFromFile(string playlistName)
{
try
{
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
var filePath = Path.Combine("/app/cache/spotify", $"{safeName}_items.json");
if (!System.IO.File.Exists(filePath))
{
_logger.LogDebug("No playlist items file cache found for {Playlist} at {Path}", playlistName, filePath);
return null;
}
var fileAge = DateTime.UtcNow - System.IO.File.GetLastWriteTimeUtc(filePath);
// Check if cache is too old (more than 24 hours)
if (fileAge.TotalHours > 24)
{
_logger.LogDebug("Playlist items file cache for {Playlist} is too old ({Age:F1}h), will rebuild",
playlistName, fileAge.TotalHours);
return null;
}
_logger.LogDebug("Playlist items file cache for {Playlist} age: {Age:F1}h", playlistName, fileAge.TotalHours);
var json = await System.IO.File.ReadAllTextAsync(filePath);
var items = JsonSerializer.Deserialize<List<Dictionary<string, object?>>>(json);
_logger.LogDebug("💿 Loaded {Count} playlist items from file cache for {Playlist} (age: {Age:F1}h)",
items?.Count ?? 0, playlistName, fileAge.TotalHours);
return items;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load playlist items from file for {Playlist}", playlistName);
return null;
}
}
#endregion
/// <summary>
/// Extracts device information from Authorization header.
/// </summary>
+78 -2
View File
@@ -553,7 +553,15 @@ public class PlaylistController : ControllerBase
}
else
{
_logger.LogInformation("Playlist {Name} has no JellyfinId configured", config.Name);
// This else block is reached when:
// 1. JellyfinId is empty, OR
// 2. totalPlayable > 0 (modern path already worked), OR
// 3. spotifyTrackCount == 0
// Only log if JellyfinId is actually missing
if (string.IsNullOrEmpty(config.JellyfinId))
{
_logger.LogInformation("Playlist {Name} has no JellyfinId configured", config.Name);
}
}
playlists.Add(playlistInfo);
@@ -662,7 +670,74 @@ public class PlaylistController : ControllerBase
// Check if track is in the playlist cache first
if (cachedItem != null)
{
// Track is in the playlist cache - determine type from ProviderIds
// First check ServerId - if it's "allstarr", it's an external track
if (cachedItem.TryGetValue("ServerId", out var serverIdObj) && serverIdObj != null)
{
string? serverId = null;
if (serverIdObj is string str)
{
serverId = str;
}
else if (serverIdObj is JsonElement jsonEl && jsonEl.ValueKind == JsonValueKind.String)
{
serverId = jsonEl.GetString();
}
if (serverId == "allstarr")
{
// This is an external track stub
isLocal = false;
// Try to determine the provider from ProviderIds
if (cachedItem.TryGetValue("ProviderIds", out var providerIdsObjExt) && providerIdsObjExt != null)
{
Dictionary<string, string>? providerIdsExt = null;
if (providerIdsObjExt is Dictionary<string, string> dictExt)
{
providerIdsExt = dictExt;
}
else if (providerIdsObjExt is JsonElement jsonElExt && jsonElExt.ValueKind == JsonValueKind.Object)
{
providerIdsExt = new Dictionary<string, string>();
foreach (var prop in jsonElExt.EnumerateObject())
{
providerIdsExt[prop.Name] = prop.Value.GetString() ?? "";
}
}
if (providerIdsExt != null)
{
// Check for external provider keys
if (providerIdsExt.ContainsKey("squidwtf"))
externalProvider = "squidwtf";
else if (providerIdsExt.ContainsKey("deezer"))
externalProvider = "deezer";
else if (providerIdsExt.ContainsKey("qobuz"))
externalProvider = "qobuz";
else if (providerIdsExt.ContainsKey("tidal"))
externalProvider = "tidal";
}
}
_logger.LogDebug("✓ Track {Title} identified as EXTERNAL from ServerId=allstarr (provider: {Provider})",
track.Title, externalProvider ?? "unknown");
// Check if this is a manual mapping
var globalMappingExt = await _mappingService.GetMappingAsync(track.SpotifyId);
if (globalMappingExt != null && globalMappingExt.Source == "manual")
{
isManualMapping = true;
manualMappingType = "external";
manualMappingId = globalMappingExt.ExternalId;
}
// Skip the rest of the ProviderIds logic
goto AddTrack;
}
}
// Track is in the playlist cache with real Jellyfin ServerId - determine type from ProviderIds
if (cachedItem.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null)
{
Dictionary<string, string>? providerIds = null;
@@ -780,6 +855,7 @@ public class PlaylistController : ControllerBase
}
}
AddTrack:
// Check lyrics status
var cacheKey = $"lyrics:{track.PrimaryArtist}:{track.Title}:{track.Album}:{track.DurationMs / 1000}";
var existingLyrics = await _cache.GetStringAsync(cacheKey);
@@ -0,0 +1,539 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using System.Security.Cryptography;
using System.Text;
using System.Xml.Linq;
using allstarr.Filters;
using allstarr.Models.Settings;
using allstarr.Services.Admin;
namespace allstarr.Controllers;
/// <summary>
/// Admin controller for scrobbling configuration and authentication.
/// Note: Does not require API key auth - users authenticate with Last.fm directly.
/// </summary>
[ApiController]
[Route("api/admin/scrobbling")]
[ServiceFilter(typeof(AdminPortFilter))]
public class ScrobblingAdminController : ControllerBase
{
private readonly ScrobblingSettings _settings;
private readonly IConfiguration _configuration;
private readonly ILogger<ScrobblingAdminController> _logger;
private readonly HttpClient _httpClient;
private readonly AdminHelperService _adminHelper;
public ScrobblingAdminController(
IOptions<ScrobblingSettings> settings,
IConfiguration configuration,
IHttpClientFactory httpClientFactory,
ILogger<ScrobblingAdminController> logger,
AdminHelperService adminHelper)
{
_settings = settings.Value;
_configuration = configuration;
_logger = logger;
_httpClient = httpClientFactory.CreateClient("LastFm");
_adminHelper = adminHelper;
}
/// <summary>
/// Gets current scrobbling configuration status.
/// </summary>
[HttpGet("status")]
public IActionResult GetStatus()
{
var hasApiCredentials = !string.IsNullOrEmpty(_settings.LastFm.ApiKey) &&
!string.IsNullOrEmpty(_settings.LastFm.SharedSecret);
return Ok(new
{
Enabled = _settings.Enabled,
LocalTracksEnabled = _settings.LocalTracksEnabled,
LastFm = new
{
Enabled = _settings.LastFm.Enabled,
Configured = hasApiCredentials && !string.IsNullOrEmpty(_settings.LastFm.SessionKey),
HasApiKey = hasApiCredentials,
HasSessionKey = !string.IsNullOrEmpty(_settings.LastFm.SessionKey),
Username = _settings.LastFm.Username,
UsingHardcodedCredentials = hasApiCredentials &&
_settings.LastFm.ApiKey == "cb3bdcd415fcb40cd572b137b2b255f5"
},
ListenBrainz = new
{
Enabled = _settings.ListenBrainz.Enabled,
Configured = !string.IsNullOrEmpty(_settings.ListenBrainz.UserToken),
HasUserToken = !string.IsNullOrEmpty(_settings.ListenBrainz.UserToken)
}
});
}
/// <summary>
/// Authenticate with Last.fm using credentials from .env file.
/// Uses hardcoded API credentials from Jellyfin Last.fm plugin for convenience.
/// </summary>
[HttpPost("lastfm/authenticate")]
public async Task<IActionResult> AuthenticateLastFm()
{
// Get username and password from settings (loaded from .env)
var username = _settings.LastFm.Username;
var password = _settings.LastFm.Password;
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password))
{
return BadRequest(new { error = "Username and password must be set in .env file (SCROBBLING_LASTFM_USERNAME and SCROBBLING_LASTFM_PASSWORD)" });
}
// Check if API credentials are available
if (string.IsNullOrEmpty(_settings.LastFm.ApiKey) || string.IsNullOrEmpty(_settings.LastFm.SharedSecret))
{
return BadRequest(new { error = "Last.fm API credentials not configured. This should not happen - please report this bug." });
}
_logger.LogInformation("🔍 DEBUG: Password from settings: '{Password}' (length: {Length})",
password, password.Length);
_logger.LogInformation("🔍 DEBUG: Password bytes: {Bytes}",
string.Join(" ", System.Text.Encoding.UTF8.GetBytes(password).Select(b => b.ToString("X2"))));
try
{
// Build parameters for auth.getMobileSession
var parameters = new Dictionary<string, string>
{
["api_key"] = _settings.LastFm.ApiKey,
["method"] = "auth.getMobileSession",
["username"] = username,
["password"] = password
};
// Generate signature
var signature = GenerateSignature(parameters, _settings.LastFm.SharedSecret);
parameters["api_sig"] = signature;
_logger.LogInformation("🔍 DEBUG: Signature: {Signature}", signature);
// Send POST request over HTTPS
var content = new FormUrlEncodedContent(parameters);
var response = await _httpClient.PostAsync("https://ws.audioscrobbler.com/2.0/", content);
var responseBody = await response.Content.ReadAsStringAsync();
_logger.LogInformation("🔍 DEBUG: Last.fm response: {Status} - {Body}",
response.StatusCode, responseBody);
// Parse response
var doc = XDocument.Parse(responseBody);
var root = doc.Root;
if (root?.Attribute("status")?.Value == "failed")
{
var errorElement = root.Element("error");
var errorCode = errorElement?.Attribute("code")?.Value;
var errorMessage = errorElement?.Value ?? "Unknown error";
if (errorCode == "4")
{
return BadRequest(new { error = "Invalid username or password" });
}
return BadRequest(new { error = $"Last.fm error: {errorMessage}" });
}
// Extract session info
var sessionElement = root?.Element("session");
var sessionKey = sessionElement?.Element("key")?.Value;
var authenticatedUsername = sessionElement?.Element("name")?.Value;
if (string.IsNullOrEmpty(sessionKey))
{
return BadRequest(new { error = "Failed to get session key from Last.fm response" });
}
_logger.LogInformation("Successfully authenticated Last.fm user: {Username}", authenticatedUsername);
// Save session key to .env file
try
{
var updates = new Dictionary<string, string>
{
["SCROBBLING_LASTFM_SESSION_KEY"] = sessionKey
};
await _adminHelper.UpdateEnvConfigAsync(updates);
_logger.LogInformation("Session key saved to .env file");
}
catch (Exception saveEx)
{
_logger.LogError(saveEx, "Failed to save session key to .env file");
return StatusCode(500, new {
error = "Authentication successful but failed to save session key",
sessionKey = sessionKey,
details = saveEx.Message
});
}
return Ok(new
{
Success = true,
SessionKey = sessionKey,
Username = authenticatedUsername,
Message = "Authentication successful! Session key saved. Please restart the container for changes to take effect."
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error authenticating with Last.fm");
return StatusCode(500, new { error = $"Error: {ex.Message}" });
}
}
/// <summary>
/// DEPRECATED: OAuth method - use /authenticate instead for simpler username/password auth.
/// Step 1: Get Last.fm authentication URL for user to authorize the app.
/// </summary>
[HttpGet("lastfm/auth-url")]
public IActionResult GetLastFmAuthUrl()
{
return BadRequest(new {
error = "OAuth authentication is deprecated. Use POST /lastfm/authenticate with username and password instead.",
hint = "This is simpler and doesn't require a callback URL."
});
}
/// <summary>
/// DEPRECATED: OAuth method - use /authenticate instead.
/// Step 2: Exchange Last.fm auth token for session key.
/// </summary>
[HttpPost("lastfm/get-session")]
public IActionResult GetLastFmSession([FromBody] GetSessionRequest request)
{
return BadRequest(new {
error = "OAuth authentication is deprecated. Use POST /lastfm/authenticate with username and password instead.",
hint = "This is simpler and doesn't require a callback URL."
});
}
/// <summary>
/// Test Last.fm connection with current configuration.
/// </summary>
[HttpPost("lastfm/test")]
public async Task<IActionResult> TestLastFmConnection()
{
if (!_settings.LastFm.Enabled)
{
return BadRequest(new { error = "Last.fm scrobbling is not enabled" });
}
if (string.IsNullOrEmpty(_settings.LastFm.ApiKey) ||
string.IsNullOrEmpty(_settings.LastFm.SharedSecret) ||
string.IsNullOrEmpty(_settings.LastFm.SessionKey))
{
return BadRequest(new { error = "Last.fm is not fully configured (missing API key, shared secret, or session key)" });
}
try
{
// Try to get user info to test the session key
var parameters = new Dictionary<string, string>
{
["api_key"] = _settings.LastFm.ApiKey,
["method"] = "user.getInfo",
["sk"] = _settings.LastFm.SessionKey
};
var signature = GenerateSignature(parameters, _settings.LastFm.SharedSecret);
parameters["api_sig"] = signature;
var content = new FormUrlEncodedContent(parameters);
var response = await _httpClient.PostAsync("https://ws.audioscrobbler.com/2.0/", content);
var responseBody = await response.Content.ReadAsStringAsync();
var doc = XDocument.Parse(responseBody);
var root = doc.Root;
if (root?.Attribute("status")?.Value == "failed")
{
var errorElement = root.Element("error");
var errorCode = errorElement?.Attribute("code")?.Value;
var errorMessage = errorElement?.Value ?? "Unknown error";
if (errorCode == "9")
{
return BadRequest(new { error = "Session key is invalid. Please re-authenticate." });
}
return BadRequest(new { error = $"Last.fm error: {errorMessage}" });
}
var userElement = root?.Element("user");
var username = userElement?.Element("name")?.Value;
var playcount = userElement?.Element("playcount")?.Value;
return Ok(new
{
Success = true,
Message = "Last.fm connection successful!",
Username = username,
Playcount = playcount
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error testing Last.fm connection");
return StatusCode(500, new { error = $"Error: {ex.Message}" });
}
}
/// <summary>
/// Update local tracks scrobbling setting.
/// </summary>
[HttpPost("local-tracks/update")]
public async Task<IActionResult> UpdateLocalTracksEnabled([FromBody] UpdateLocalTracksRequest request)
{
try
{
var updates = new Dictionary<string, string>
{
["SCROBBLING_LOCAL_TRACKS_ENABLED"] = request.Enabled.ToString().ToLower()
};
await _adminHelper.UpdateEnvConfigAsync(updates);
_logger.LogInformation("Local tracks scrobbling setting updated to: {Enabled}", request.Enabled);
return Ok(new
{
Success = true,
LocalTracksEnabled = request.Enabled,
Message = "Setting saved! Please restart the container for changes to take effect."
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update local tracks scrobbling setting");
return StatusCode(500, new { error = $"Error: {ex.Message}" });
}
}
/// <summary>
/// Validate ListenBrainz user token.
/// </summary>
[HttpPost("listenbrainz/validate")]
public async Task<IActionResult> ValidateListenBrainzToken([FromBody] ValidateTokenRequest request)
{
if (string.IsNullOrEmpty(request.UserToken))
{
return BadRequest(new { error = "User token is required" });
}
try
{
var httpRequest = new HttpRequestMessage(HttpMethod.Get, "https://api.listenbrainz.org/1/validate-token");
httpRequest.Headers.Add("Authorization", $"Token {request.UserToken}");
var response = await _httpClient.SendAsync(httpRequest);
var responseBody = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
return BadRequest(new { error = "Invalid user token" });
}
var jsonDoc = System.Text.Json.JsonDocument.Parse(responseBody);
var valid = jsonDoc.RootElement.GetProperty("valid").GetBoolean();
if (!valid)
{
return BadRequest(new { error = "Invalid user token" });
}
var username = jsonDoc.RootElement.GetProperty("user_name").GetString();
// Save token to .env file
try
{
var updates = new Dictionary<string, string>
{
["SCROBBLING_LISTENBRAINZ_USER_TOKEN"] = request.UserToken
};
await _adminHelper.UpdateEnvConfigAsync(updates);
_logger.LogInformation("ListenBrainz token saved to .env file");
}
catch (Exception saveEx)
{
_logger.LogError(saveEx, "Failed to save token to .env file");
return StatusCode(500, new {
error = "Token validation successful but failed to save",
userToken = request.UserToken,
username = username,
details = saveEx.Message
});
}
return Ok(new
{
Success = true,
Valid = true,
Username = username,
Message = "Token validated and saved! Please restart the container for changes to take effect."
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error validating ListenBrainz token");
return StatusCode(500, new { error = $"Error: {ex.Message}" });
}
}
/// <summary>
/// Test ListenBrainz connection with current configuration.
/// </summary>
[HttpPost("listenbrainz/test")]
public async Task<IActionResult> TestListenBrainzConnection()
{
if (!_settings.ListenBrainz.Enabled)
{
return BadRequest(new { error = "ListenBrainz scrobbling is not enabled" });
}
if (string.IsNullOrEmpty(_settings.ListenBrainz.UserToken))
{
return BadRequest(new { error = "ListenBrainz user token is not configured" });
}
try
{
var httpRequest = new HttpRequestMessage(HttpMethod.Get, "https://api.listenbrainz.org/1/validate-token");
httpRequest.Headers.Add("Authorization", $"Token {_settings.ListenBrainz.UserToken}");
var response = await _httpClient.SendAsync(httpRequest);
var responseBody = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
return BadRequest(new { error = "Invalid user token" });
}
var jsonDoc = System.Text.Json.JsonDocument.Parse(responseBody);
var valid = jsonDoc.RootElement.GetProperty("valid").GetBoolean();
if (!valid)
{
return BadRequest(new { error = "Invalid user token" });
}
var username = jsonDoc.RootElement.GetProperty("user_name").GetString();
return Ok(new
{
Success = true,
Message = "ListenBrainz connection successful!",
Username = username
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error testing ListenBrainz connection");
return StatusCode(500, new { error = $"Error: {ex.Message}" });
}
}
/// <summary>
/// Debug endpoint to test authentication parameters without actually calling Last.fm.
/// Shows what would be sent to Last.fm for debugging.
/// </summary>
[HttpPost("lastfm/debug-auth")]
public IActionResult DebugAuth([FromBody] AuthenticateRequest request)
{
if (string.IsNullOrEmpty(request.Username) || string.IsNullOrEmpty(request.Password))
{
return BadRequest(new { error = "Username and password are required" });
}
// Build parameters for auth.getMobileSession
var parameters = new Dictionary<string, string>
{
["api_key"] = _settings.LastFm.ApiKey,
["method"] = "auth.getMobileSession",
["username"] = request.Username,
["password"] = request.Password
};
// Generate signature
var signature = GenerateSignature(parameters, _settings.LastFm.SharedSecret);
// Build signature string for debugging
var sorted = parameters.OrderBy(kvp => kvp.Key);
var signatureString = new StringBuilder();
foreach (var kvp in sorted)
{
signatureString.Append(kvp.Key);
signatureString.Append(kvp.Value);
}
signatureString.Append(_settings.LastFm.SharedSecret);
return Ok(new
{
ApiKey = _settings.LastFm.ApiKey,
SharedSecret = _settings.LastFm.SharedSecret.Substring(0, 8) + "...",
Username = request.Username,
PasswordLength = request.Password.Length,
SignatureString = signatureString.ToString(),
Signature = signature,
CurlCommand = $"curl -X POST \"https://ws.audioscrobbler.com/2.0/\" " +
$"-d \"method=auth.getMobileSession\" " +
$"-d \"username={request.Username}\" " +
$"-d \"password={request.Password}\" " +
$"-d \"api_key={_settings.LastFm.ApiKey}\" " +
$"-d \"api_sig={signature}\" " +
$"-d \"format=json\""
});
}
private string GenerateSignature(Dictionary<string, string> parameters, string sharedSecret)
{
var sorted = parameters.OrderBy(kvp => kvp.Key);
var signatureString = new StringBuilder();
foreach (var kvp in sorted)
{
signatureString.Append(kvp.Key);
signatureString.Append(kvp.Value);
}
signatureString.Append(sharedSecret);
var bytes = Encoding.UTF8.GetBytes(signatureString.ToString());
var hash = MD5.HashData(bytes);
// Convert to UPPERCASE hex string (Last.fm requires uppercase)
var sb = new StringBuilder();
foreach (byte b in hash)
{
sb.Append(b.ToString("X2"));
}
return sb.ToString();
}
public class AuthenticateRequest
{
public required string Username { get; set; }
public required string Password { get; set; }
}
public class GetSessionRequest
{
public required string Token { get; set; }
}
public class ValidateTokenRequest
{
public required string UserToken { get; set; }
}
public class UpdateLocalTracksRequest
{
public required bool Enabled { get; set; }
}
}
@@ -0,0 +1,156 @@
using System.Diagnostics;
using System.Text;
namespace allstarr.Middleware;
/// <summary>
/// Middleware that logs all incoming HTTP requests when debug logging is enabled.
/// Useful for debugging client issues and seeing what requests are being made.
/// </summary>
public class RequestLoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RequestLoggingMiddleware> _logger;
private readonly IConfiguration _configuration;
public RequestLoggingMiddleware(
RequestDelegate next,
ILogger<RequestLoggingMiddleware> logger,
IConfiguration configuration)
{
_next = next;
_logger = logger;
_configuration = configuration;
// Log initialization status
var initialValue = _configuration.GetValue<bool>("Debug:LogAllRequests");
_logger.LogWarning("🔍 RequestLoggingMiddleware initialized - LogAllRequests={LogAllRequests}", initialValue);
if (initialValue)
{
_logger.LogWarning("🔍 Request logging ENABLED - all HTTP requests will be logged");
}
else
{
_logger.LogInformation("Request logging disabled (set DEBUG_LOG_ALL_REQUESTS=true to enable)");
}
}
public async Task InvokeAsync(HttpContext context)
{
// Check configuration on every request to allow dynamic toggling
var logAllRequests = _configuration.GetValue<bool>("Debug:LogAllRequests");
if (!logAllRequests)
{
await _next(context);
return;
}
var stopwatch = Stopwatch.StartNew();
var request = context.Request;
// Log request details
var requestLog = new StringBuilder();
requestLog.AppendLine($"📥 HTTP {request.Method} {request.Path}{request.QueryString}");
requestLog.AppendLine($" Host: {request.Host}");
requestLog.AppendLine($" Content-Type: {request.ContentType ?? "(none)"}");
requestLog.AppendLine($" Content-Length: {request.ContentLength?.ToString() ?? "(none)"}");
// Log important headers
if (request.Headers.ContainsKey("User-Agent"))
{
requestLog.AppendLine($" User-Agent: {request.Headers["User-Agent"]}");
}
if (request.Headers.ContainsKey("X-Emby-Authorization"))
{
requestLog.AppendLine($" X-Emby-Authorization: {MaskAuthHeader(request.Headers["X-Emby-Authorization"]!)}");
}
if (request.Headers.ContainsKey("Authorization"))
{
requestLog.AppendLine($" Authorization: {MaskAuthHeader(request.Headers["Authorization"]!)}");
}
if (request.Headers.ContainsKey("X-Emby-Token"))
{
requestLog.AppendLine($" X-Emby-Token: ***");
}
if (request.Headers.ContainsKey("X-Emby-Device-Id"))
{
requestLog.AppendLine($" X-Emby-Device-Id: {request.Headers["X-Emby-Device-Id"]}");
}
if (request.Headers.ContainsKey("X-Emby-Client"))
{
requestLog.AppendLine($" X-Emby-Client: {request.Headers["X-Emby-Client"]}");
}
_logger.LogInformation(requestLog.ToString().TrimEnd());
// Capture response status
var originalBodyStream = context.Response.Body;
try
{
await _next(context);
stopwatch.Stop();
// Log response
_logger.LogInformation(
"📤 HTTP {Method} {Path} → {StatusCode} ({ElapsedMs}ms)",
request.Method,
request.Path,
context.Response.StatusCode,
stopwatch.ElapsedMilliseconds);
}
catch (Exception ex)
{
stopwatch.Stop();
_logger.LogError(ex,
"❌ HTTP {Method} {Path} → EXCEPTION ({ElapsedMs}ms)",
request.Method,
request.Path,
stopwatch.ElapsedMilliseconds);
throw;
}
}
private static string MaskAuthHeader(string authHeader)
{
// Mask tokens in auth headers for security
if (string.IsNullOrEmpty(authHeader))
return "(empty)";
// For MediaBrowser format: MediaBrowser Client="...", Token="..."
if (authHeader.Contains("Token=", StringComparison.OrdinalIgnoreCase))
{
var parts = authHeader.Split(',');
var masked = new List<string>();
foreach (var part in parts)
{
if (part.Contains("Token=", StringComparison.OrdinalIgnoreCase))
{
masked.Add("Token=\"***\"");
}
else
{
masked.Add(part.Trim());
}
}
return string.Join(", ", masked);
}
// For Bearer tokens
if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
return "Bearer ***";
}
// For other formats, just mask everything after first 10 chars
if (authHeader.Length > 10)
{
return authHeader.Substring(0, 10) + "***";
}
return "***";
}
}
@@ -0,0 +1,66 @@
namespace allstarr.Models.Scrobbling;
/// <summary>
/// Tracks playback state for scrobbling decisions.
/// </summary>
public class PlaybackSession
{
/// <summary>
/// Unique identifier for this playback session.
/// </summary>
public required string SessionId { get; init; }
/// <summary>
/// Device ID of the client.
/// </summary>
public required string DeviceId { get; init; }
/// <summary>
/// Track being played.
/// </summary>
public required ScrobbleTrack Track { get; init; }
/// <summary>
/// When playback started (UTC).
/// </summary>
public DateTime StartTime { get; init; }
/// <summary>
/// Last reported playback position in seconds.
/// </summary>
public int LastPositionSeconds { get; set; }
/// <summary>
/// Whether "Now Playing" has been sent for this session.
/// </summary>
public bool NowPlayingSent { get; set; }
/// <summary>
/// Whether the track has been scrobbled.
/// </summary>
public bool Scrobbled { get; set; }
/// <summary>
/// Last activity timestamp (for cleanup).
/// </summary>
public DateTime LastActivity { get; set; }
/// <summary>
/// Checks if the track should be scrobbled based on Last.fm rules:
/// - Track must be longer than 30 seconds
/// - Track has been played for at least half its duration, or for 4 minutes (whichever occurs earlier)
/// </summary>
public bool ShouldScrobble()
{
if (Scrobbled)
return false; // Already scrobbled
if (Track.DurationSeconds == null || Track.DurationSeconds <= 30)
return false; // Track too short or duration unknown
var halfDuration = Track.DurationSeconds.Value / 2;
var scrobbleThreshold = Math.Min(halfDuration, 240); // 4 minutes = 240 seconds
return LastPositionSeconds >= scrobbleThreshold;
}
}
@@ -0,0 +1,90 @@
namespace allstarr.Models.Scrobbling;
/// <summary>
/// Result of a scrobble or now playing request.
/// </summary>
public record ScrobbleResult
{
/// <summary>
/// Whether the request was successful.
/// </summary>
public bool Success { get; init; }
/// <summary>
/// Error message if the request failed.
/// </summary>
public string? ErrorMessage { get; init; }
/// <summary>
/// Error code from the service (e.g., Last.fm error code).
/// </summary>
public int? ErrorCode { get; init; }
/// <summary>
/// Whether the scrobble was ignored by the service (filtered).
/// </summary>
public bool Ignored { get; init; }
/// <summary>
/// Reason the scrobble was ignored (if applicable).
/// </summary>
public string? IgnoredReason { get; init; }
/// <summary>
/// Ignored message code (e.g., Last.fm ignored code).
/// </summary>
public int? IgnoredCode { get; init; }
/// <summary>
/// Whether the artist was corrected by the service.
/// </summary>
public bool ArtistCorrected { get; init; }
/// <summary>
/// Corrected artist name (if applicable).
/// </summary>
public string? CorrectedArtist { get; init; }
/// <summary>
/// Whether the track was corrected by the service.
/// </summary>
public bool TrackCorrected { get; init; }
/// <summary>
/// Corrected track name (if applicable).
/// </summary>
public string? CorrectedTrack { get; init; }
/// <summary>
/// Whether the album was corrected by the service.
/// </summary>
public bool AlbumCorrected { get; init; }
/// <summary>
/// Corrected album name (if applicable).
/// </summary>
public string? CorrectedAlbum { get; init; }
/// <summary>
/// Whether the request should be retried (e.g., service offline, temporary error).
/// </summary>
public bool ShouldRetry { get; init; }
public static ScrobbleResult CreateSuccess() => new() { Success = true };
public static ScrobbleResult CreateError(string message, int? errorCode = null, bool shouldRetry = false) => new()
{
Success = false,
ErrorMessage = message,
ErrorCode = errorCode,
ShouldRetry = shouldRetry
};
public static ScrobbleResult CreateIgnored(string reason, int ignoredCode) => new()
{
Success = true,
Ignored = true,
IgnoredReason = reason,
IgnoredCode = ignoredCode
};
}
@@ -0,0 +1,55 @@
namespace allstarr.Models.Scrobbling;
/// <summary>
/// Represents a track to be scrobbled.
/// </summary>
public record ScrobbleTrack
{
/// <summary>
/// Track title (required).
/// </summary>
public required string Title { get; init; }
/// <summary>
/// Artist name (required).
/// </summary>
public required string Artist { get; init; }
/// <summary>
/// Album name (optional).
/// </summary>
public string? Album { get; init; }
/// <summary>
/// Album artist (optional).
/// </summary>
public string? AlbumArtist { get; init; }
/// <summary>
/// Track duration in seconds (optional but recommended).
/// </summary>
public int? DurationSeconds { get; init; }
/// <summary>
/// MusicBrainz Track ID (optional).
/// </summary>
public string? MusicBrainzId { get; init; }
/// <summary>
/// Unix timestamp when the track started playing (required for scrobbles).
/// </summary>
public long? Timestamp { get; init; }
/// <summary>
/// Whether the track was chosen by the user (true) or by an algorithm/radio (false).
/// Default is true. Set to false for Last.fm radio, recommendation services, etc.
/// </summary>
public bool ChosenByUser { get; init; } = true;
/// <summary>
/// Whether the track is from an external source (Spotify, Deezer, etc.) or local library.
/// Default is false (local library). Set to true for external tracks.
/// ListenBrainz only scrobbles external tracks.
/// </summary>
public bool IsExternal { get; init; } = false;
}
@@ -0,0 +1,87 @@
namespace allstarr.Models.Settings;
/// <summary>
/// Settings for scrobbling services (Last.fm, ListenBrainz, etc.).
/// </summary>
public class ScrobblingSettings
{
/// <summary>
/// Whether scrobbling is enabled globally.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Whether to scrobble local library tracks.
/// Recommended: Keep disabled and use native Jellyfin plugins instead.
/// </summary>
public bool LocalTracksEnabled { get; set; }
/// <summary>
/// Last.fm settings.
/// </summary>
public LastFmSettings LastFm { get; set; } = new();
/// <summary>
/// ListenBrainz settings (future).
/// </summary>
public ListenBrainzSettings ListenBrainz { get; set; } = new();
}
/// <summary>
/// Last.fm scrobbling settings.
/// </summary>
public class LastFmSettings
{
/// <summary>
/// Whether Last.fm scrobbling is enabled.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Last.fm API key (32-character hex string).
/// Uses hardcoded credentials from Jellyfin Last.fm plugin for convenience.
/// Users can override by setting SCROBBLING_LASTFM_API_KEY in .env
/// </summary>
public string ApiKey { get; set; } = "cb3bdcd415fcb40cd572b137b2b255f5";
/// <summary>
/// Last.fm shared secret (32-character hex string).
/// Uses hardcoded credentials from Jellyfin Last.fm plugin for convenience.
/// Users can override by setting SCROBBLING_LASTFM_SHARED_SECRET in .env
/// </summary>
public string SharedSecret { get; set; } = "3a08f9fad6ddc4c35b0dce0062cecb5e";
/// <summary>
/// Last.fm session key (obtained via Mobile Authentication).
/// This is user-specific and has infinite lifetime (unless revoked by user).
/// </summary>
public string SessionKey { get; set; } = string.Empty;
/// <summary>
/// Last.fm username.
/// </summary>
public string? Username { get; set; }
/// <summary>
/// Last.fm password (stored for automatic re-authentication if needed).
/// Only used for authentication, not stored in plaintext in production.
/// </summary>
public string? Password { get; set; }
}
/// <summary>
/// ListenBrainz scrobbling settings (future implementation).
/// </summary>
public class ListenBrainzSettings
{
/// <summary>
/// Whether ListenBrainz scrobbling is enabled.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// ListenBrainz user token.
/// Get from: https://listenbrainz.org/profile/
/// </summary>
public string UserToken { get; set; } = string.Empty;
}
+4 -3
View File
@@ -6,8 +6,9 @@ namespace allstarr.Models.Subsonic;
public class ExternalPlaylist
{
/// <summary>
/// Unique identifier in the format "pl-{provider}-{externalId}"
/// Example: "pl-deezer-123456" or "pl-qobuz-789"
/// Unique identifier in the format "ext-{provider}-playlist-{externalId}"
/// Example: "ext-deezer-playlist-123456" or "ext-qobuz-playlist-789"
/// This matches the format used for albums and songs for consistency.
/// </summary>
public string Id { get; set; } = string.Empty;
@@ -32,7 +33,7 @@ public class ExternalPlaylist
public string Provider { get; set; } = string.Empty;
/// <summary>
/// External ID from the provider (without "pl-" prefix)
/// External ID from the provider (without "ext-{provider}-playlist-" prefix)
/// </summary>
public string ExternalId { get; set; } = string.Empty;
+85 -17
View File
@@ -9,6 +9,7 @@ using allstarr.Services.Subsonic;
using allstarr.Services.Jellyfin;
using allstarr.Services.Common;
using allstarr.Services.Lyrics;
using allstarr.Services.Scrobbling;
using allstarr.Middleware;
using allstarr.Filters;
using Microsoft.Extensions.Http;
@@ -41,21 +42,20 @@ static List<string> DecodeSquidWtfUrls()
{
var encodedUrls = new[]
{
"aHR0cHM6Ly90cml0b24uc3F1aWQud3Rm", // triton.squid.wtf
"aHR0cHM6Ly90aWRhbC1hcGkuYmluaW11bS5vcmc=", // tidal-api.binimum.org
"aHR0cHM6Ly90aWRhbC5raW5vcGx1cy5vbmxpbmU=", // tidal.kinoplus.online
"aHR0cHM6Ly9oaWZpLXR3by5zcG90aXNhdmVyLm5ldA==", // hifi-two.spotisaver.net
"aHR0cHM6Ly9oaWZpLW9uZS5zcG90aXNhdmVyLm5ldA==", // hifi-one.spotisaver.net
"aHR0cHM6Ly93b2xmLnFxZGwuc2l0ZQ==", // wolf.qqdl.site
"aHR0cDovL2h1bmQucXFkbC5zaXRl", // hund.qqdl.site (http)
"aHR0cHM6Ly9rYXR6ZS5xcWRsLnNpdGU=", // katze.qqdl.site
"aHR0cHM6Ly92b2dlbC5xcWRsLnNpdGU=", // vogel.qqdl.site
"aHR0cHM6Ly9tYXVzLnFxZGwuc2l0ZQ==", // maus.qqdl.site
"aHR0cHM6Ly9ldS1jZW50cmFsLm1vbm9jaHJvbWUudGY=", // eu-central.monochrome.tf
"aHR0cHM6Ly91cy13ZXN0Lm1vbm9jaHJvbWUudGY=", // us-west.monochrome.tf
"aHR0cHM6Ly9hcnJhbi5tb25vY2hyb21lLnRm", // arran.monochrome.tf
"aHR0cHM6Ly9hcGkubW9ub2Nocm9tZS50Zg==", // api.monochrome.tf
"aHR0cHM6Ly9odW5kLnFxZGwuc2l0ZQ==" // hund.qqdl.site (https)
"aHR0cHM6Ly90cml0b24uc3F1aWQud3Rm", // triton
"aHR0cHM6Ly90aWRhbC5raW5vcGx1cy5vbmxpbmU=", // kinoplus
"aHR0cHM6Ly9oaWZpLXR3by5zcG90aXNhdmVyLm5ldA==", // spotisaver-two
"aHR0cHM6Ly9oaWZpLW9uZS5zcG90aXNhdmVyLm5ldA==", // spotisaver-one
"aHR0cHM6Ly93b2xmLnFxZGwuc2l0ZQ==", // wolf
"aHR0cDovL2h1bmQucXFkbC5zaXRl", // hund-http
"aHR0cHM6Ly9rYXR6ZS5xcWRsLnNpdGU=", // katze
"aHR0cHM6Ly92b2dlbC5xcWRsLnNpdGU=", // vogel
"aHR0cHM6Ly9tYXVzLnFxZGwuc2l0ZQ==", // maus
"aHR0cHM6Ly9ldS1jZW50cmFsLm1vbm9jaHJvbWUudGY=", // eu-central
"aHR0cHM6Ly91cy13ZXN0Lm1vbm9jaHJvbWUudGY=", // us-west
"aHR0cHM6Ly9hcnJhbi5tb25vY2hyb21lLnRm", // arran
"aHR0cHM6Ly9hcGkubW9ub2Nocm9tZS50Zg==", // api
"aHR0cHM6Ly9odW5kLnFxZGwuc2l0ZQ==" // hund
};
return encodedUrls
@@ -615,6 +615,70 @@ builder.Services.AddHostedService(sp => sp.GetRequiredService<allstarr.Services.
// builder.Services.AddSingleton<allstarr.Services.Lyrics.LyricsPrefetchService>();
// builder.Services.AddHostedService(sp => sp.GetRequiredService<allstarr.Services.Lyrics.LyricsPrefetchService>());
// Register scrobbling services (Last.fm, ListenBrainz, etc.)
builder.Services.Configure<allstarr.Models.Settings.ScrobblingSettings>(options =>
{
// Last.fm settings
var lastFmEnabled = builder.Configuration.GetValue<bool>("Scrobbling:LastFm:Enabled");
var lastFmApiKey = builder.Configuration.GetValue<string>("Scrobbling:LastFm:ApiKey");
var lastFmSharedSecret = builder.Configuration.GetValue<string>("Scrobbling:LastFm:SharedSecret");
var lastFmSessionKey = builder.Configuration.GetValue<string>("Scrobbling:LastFm:SessionKey");
var lastFmUsername = builder.Configuration.GetValue<string>("Scrobbling:LastFm:Username");
var lastFmPassword = builder.Configuration.GetValue<string>("Scrobbling:LastFm:Password");
options.Enabled = builder.Configuration.GetValue<bool>("Scrobbling:Enabled");
options.LocalTracksEnabled = builder.Configuration.GetValue<bool>("Scrobbling:LocalTracksEnabled");
options.LastFm.Enabled = lastFmEnabled;
// Only override hardcoded API credentials if explicitly set in config
if (!string.IsNullOrEmpty(lastFmApiKey))
options.LastFm.ApiKey = lastFmApiKey;
if (!string.IsNullOrEmpty(lastFmSharedSecret))
options.LastFm.SharedSecret = lastFmSharedSecret;
// These don't have defaults, so set them normally
options.LastFm.SessionKey = lastFmSessionKey ?? string.Empty;
options.LastFm.Username = lastFmUsername;
options.LastFm.Password = lastFmPassword;
// ListenBrainz settings
var listenBrainzEnabled = builder.Configuration.GetValue<bool>("Scrobbling:ListenBrainz:Enabled");
var listenBrainzUserToken = builder.Configuration.GetValue<string>("Scrobbling:ListenBrainz:UserToken") ?? string.Empty;
options.ListenBrainz.Enabled = listenBrainzEnabled;
options.ListenBrainz.UserToken = listenBrainzUserToken;
// Debug logging
Console.WriteLine($"Scrobbling Configuration:");
Console.WriteLine($" Enabled: {options.Enabled}");
Console.WriteLine($" Local Tracks Enabled: {options.LocalTracksEnabled}");
Console.WriteLine($" Last.fm Enabled: {options.LastFm.Enabled}");
Console.WriteLine($" Last.fm Username: {options.LastFm.Username ?? "(not set)"}");
Console.WriteLine($" Last.fm Session Key: {(string.IsNullOrEmpty(options.LastFm.SessionKey) ? "(not set)" : "***" + options.LastFm.SessionKey[^8..])}");
Console.WriteLine($" ListenBrainz Enabled: {options.ListenBrainz.Enabled}");
Console.WriteLine($" ListenBrainz Token: {(string.IsNullOrEmpty(options.ListenBrainz.UserToken) ? "(not set)" : "***" + options.ListenBrainz.UserToken[^8..])}");
});
// Register Last.fm HTTP client with proper User-Agent
builder.Services.AddHttpClient("LastFm", client =>
{
client.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0 (https://github.com/sopat712/allstarr)");
client.Timeout = TimeSpan.FromSeconds(30);
});
// Register ListenBrainz HTTP client with proper User-Agent
builder.Services.AddHttpClient("ListenBrainz", client =>
{
client.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0 (https://github.com/sopat712/allstarr)");
client.Timeout = TimeSpan.FromSeconds(30);
});
// Register scrobbling services
builder.Services.AddSingleton<IScrobblingService, LastFmScrobblingService>();
builder.Services.AddSingleton<IScrobblingService, ListenBrainzScrobblingService>();
builder.Services.AddSingleton<ScrobblingOrchestrator>();
builder.Services.AddSingleton<ScrobblingHelper>();
// Register MusicBrainz service for metadata enrichment
builder.Services.Configure<allstarr.Models.Settings.MusicBrainzSettings>(options =>
{
@@ -677,6 +741,9 @@ catch (Exception ex)
// This processes X-Forwarded-For, X-Real-IP, etc. from nginx
app.UseForwardedHeaders();
// Request logging middleware (when DEBUG_LOG_ALL_REQUESTS=true)
app.UseMiddleware<RequestLoggingMiddleware>();
app.UseExceptionHandler(_ => { }); // Global exception handler
// Enable response compression EARLY in the pipeline
@@ -733,7 +800,7 @@ class BackendControllerFeatureProvider : Microsoft.AspNetCore.Mvc.Controllers.Co
// All admin controllers should always be registered (for admin UI)
// This includes: AdminController, ConfigController, DiagnosticsController, DownloadsController,
// PlaylistController, JellyfinAdminController, SpotifyAdminController, LyricsController, MappingController
// PlaylistController, JellyfinAdminController, SpotifyAdminController, LyricsController, MappingController, ScrobblingAdminController
if (typeInfo.Name == "AdminController" ||
typeInfo.Name == "ConfigController" ||
typeInfo.Name == "DiagnosticsController" ||
@@ -742,7 +809,8 @@ class BackendControllerFeatureProvider : Microsoft.AspNetCore.Mvc.Controllers.Co
typeInfo.Name == "JellyfinAdminController" ||
typeInfo.Name == "SpotifyAdminController" ||
typeInfo.Name == "LyricsController" ||
typeInfo.Name == "MappingController")
typeInfo.Name == "MappingController" ||
typeInfo.Name == "ScrobblingAdminController")
{
return true;
}
@@ -102,6 +102,154 @@ public class AdminHelperService
{
return Regex.IsMatch(key, @"^[A-Z_][A-Z0-9_]*$", RegexOptions.IgnoreCase);
}
/// <summary>
/// Truncates a string for safe logging, adding ellipsis if truncated.
/// </summary>
public static string TruncateForLogging(string? str, int maxLength)
{
if (string.IsNullOrEmpty(str))
return str ?? string.Empty;
if (str.Length <= maxLength)
return str;
return str[..maxLength] + "...";
}
/// <summary>
/// Validates if a username is safe (no control characters or shell metacharacters).
/// </summary>
public static bool IsValidUsername(string? username)
{
if (string.IsNullOrWhiteSpace(username))
return false;
// Reject control characters and dangerous shell metacharacters
var dangerousChars = new[] { '\n', '\r', '\t', ';', '|', '&', '`', '$', '(', ')' };
return !username.Any(c => char.IsControl(c) || dangerousChars.Contains(c));
}
/// <summary>
/// Validates if a password is safe (no control characters).
/// </summary>
public static bool IsValidPassword(string? password)
{
if (string.IsNullOrWhiteSpace(password))
return false;
// Reject control characters (except space which is allowed)
return !password.Any(c => char.IsControl(c));
}
/// <summary>
/// Validates if a URL is safe (http or https only).
/// </summary>
public static bool IsValidUrl(string? urlString)
{
if (string.IsNullOrWhiteSpace(urlString))
return false;
if (!Uri.TryCreate(urlString, UriKind.Absolute, out var uri))
return false;
// Only allow http and https
return uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps;
}
/// <summary>
/// Validates if a file path is safe (no shell metacharacters or control characters).
/// </summary>
public static bool IsValidPath(string? pathString)
{
if (string.IsNullOrWhiteSpace(pathString))
return false;
// Reject control characters and dangerous shell metacharacters
var dangerousChars = new[] { '\n', '\r', '\0', ';', '|', '&', '`', '$' };
return !pathString.Any(c => char.IsControl(c) || dangerousChars.Contains(c));
}
/// <summary>
/// Sanitizes HTML by escaping special characters to prevent XSS.
/// </summary>
public static string SanitizeHtml(string? html)
{
if (string.IsNullOrEmpty(html))
return html ?? string.Empty;
return html
.Replace("&", "&amp;")
.Replace("<", "&lt;")
.Replace(">", "&gt;")
.Replace("\"", "&quot;")
.Replace("'", "&#39;");
}
/// <summary>
/// Removes control characters from a string for safe logging/display.
/// </summary>
public static string RemoveControlCharacters(string? str)
{
if (string.IsNullOrEmpty(str))
return str ?? string.Empty;
return new string(str.Where(c => !char.IsControl(c)).ToArray());
}
/// <summary>
/// Quotes a value if it's not already quoted (for .env file values).
/// </summary>
public static string QuoteIfNeeded(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return value ?? string.Empty;
if (value.StartsWith("\"") && value.EndsWith("\""))
return value;
return $"\"{value}\"";
}
/// <summary>
/// Strips surrounding quotes from a value (for reading .env file values).
/// </summary>
public static string StripQuotes(string? value)
{
if (string.IsNullOrEmpty(value))
return value ?? string.Empty;
if (value.StartsWith("\"") && value.EndsWith("\"") && value.Length >= 2)
return value[1..^1];
return value;
}
/// <summary>
/// Parses a line from .env file and returns key-value pair.
/// </summary>
public static (string key, string value) ParseEnvLine(string line)
{
var eqIndex = line.IndexOf('=');
if (eqIndex <= 0)
return (string.Empty, string.Empty);
var key = line[..eqIndex].Trim();
var value = line[(eqIndex + 1)..].Trim();
// Strip quotes from value
value = StripQuotes(value);
return (key, value);
}
/// <summary>
/// Checks if an .env line should be skipped (comment or empty).
/// </summary>
public static bool ShouldSkipEnvLine(string line)
{
return string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith('#');
}
public static string FormatFileSize(long bytes)
{
+68 -52
View File
@@ -88,65 +88,75 @@ public abstract class BaseDownloadService : IDownloadService
#region IDownloadService Implementation
/// <summary>
/// Downloads a song and returns the local file path.
/// This method respects the cancellation token for user-initiated downloads (e.g., playlist downloads).
/// For streaming downloads, use DownloadAndStreamAsync which ensures downloads complete server-side.
/// </summary>
public async Task<string> DownloadSongAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
{
return await DownloadSongInternalAsync(externalProvider, externalId, triggerAlbumDownload: true, cancellationToken);
}
public async Task<Stream> DownloadAndStreamAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
{
var startTime = DateTime.UtcNow;
// Check if already downloaded locally
var localPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId);
if (localPath != null && IOFile.Exists(localPath))
{
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
Logger.LogInformation("Streaming from local cache ({ElapsedMs}ms): {Path}", elapsed, localPath);
// Update write time for cache cleanup (extends cache lifetime)
if (SubsonicSettings.StorageMode == StorageMode.Cache)
var startTime = DateTime.UtcNow;
// Check if already downloaded locally
var localPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId);
if (localPath != null && IOFile.Exists(localPath))
{
IOFile.SetLastWriteTime(localPath, DateTime.UtcNow);
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
Logger.LogInformation("Streaming from local cache ({ElapsedMs}ms): {Path}", elapsed, localPath);
// Update write time for cache cleanup (extends cache lifetime)
if (SubsonicSettings.StorageMode == StorageMode.Cache)
{
IOFile.SetLastWriteTime(localPath, DateTime.UtcNow);
}
// Start background Odesli conversion for lyrics (if not already cached)
StartBackgroundOdesliConversion(externalProvider, externalId);
return IOFile.OpenRead(localPath);
}
// Download to disk first to ensure complete file with metadata
// This is necessary because:
// 1. Clients may seek to arbitrary positions (requires full file)
// 2. Metadata embedding requires complete file
// 3. Caching for future plays
Logger.LogInformation("Downloading song for streaming: {Provider}:{ExternalId}", externalProvider, externalId);
try
{
// IMPORTANT: Use CancellationToken.None for the actual download
// This ensures downloads complete server-side even if the client cancels the request
// The client can request the file again later once it's ready
localPath = await DownloadSongInternalAsync(externalProvider, externalId, triggerAlbumDownload: true, CancellationToken.None);
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
Logger.LogInformation("Download completed, starting stream ({ElapsedMs}ms total): {Path}", elapsed, localPath);
// Start background Odesli conversion for lyrics (after stream starts)
StartBackgroundOdesliConversion(externalProvider, externalId);
return IOFile.OpenRead(localPath);
}
catch (OperationCanceledException)
{
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
Logger.LogWarning("Download cancelled by client after {ElapsedMs}ms for {Provider}:{ExternalId}", elapsed, externalProvider, externalId);
throw;
}
catch (Exception ex)
{
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
Logger.LogError(ex, "Download failed after {ElapsedMs}ms for {Provider}:{ExternalId}", elapsed, externalProvider, externalId);
throw;
}
// Start background Odesli conversion for lyrics (if not already cached)
StartBackgroundOdesliConversion(externalProvider, externalId);
return IOFile.OpenRead(localPath);
}
// Download to disk first to ensure complete file with metadata
// This is necessary because:
// 1. Clients may seek to arbitrary positions (requires full file)
// 2. Metadata embedding requires complete file
// 3. Caching for future plays
Logger.LogInformation("Downloading song for streaming: {Provider}:{ExternalId}", externalProvider, externalId);
try
{
localPath = await DownloadSongInternalAsync(externalProvider, externalId, triggerAlbumDownload: true, cancellationToken);
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
Logger.LogInformation("Download completed, starting stream ({ElapsedMs}ms total): {Path}", elapsed, localPath);
// Start background Odesli conversion for lyrics (after stream starts)
StartBackgroundOdesliConversion(externalProvider, externalId);
return IOFile.OpenRead(localPath);
}
catch (OperationCanceledException)
{
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
Logger.LogWarning("Download cancelled by client after {ElapsedMs}ms for {Provider}:{ExternalId}", elapsed, externalProvider, externalId);
throw;
}
catch (Exception ex)
{
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
Logger.LogError(ex, "Download failed after {ElapsedMs}ms for {Provider}:{ExternalId}", elapsed, externalProvider, externalId);
throw;
}
}
/// <summary>
/// Starts background Odesli conversion for lyrics support.
@@ -301,12 +311,18 @@ public abstract class BaseDownloadService : IDownloadService
DownloadLock.Release();
lockHeld = false;
// Wait for download to complete, checking every 100ms (faster than 500ms)
// Also respect cancellation token so client timeouts are handled immediately
// Wait for download to complete, checking every 100ms
// Note: We check cancellation but don't cancel the actual download
// The download continues server-side even if this client gives up waiting
while (ActiveDownloads.TryGetValue(songId, out activeDownload) && activeDownload.Status == DownloadStatus.InProgress)
{
cancellationToken.ThrowIfCancellationRequested();
await Task.Delay(100, cancellationToken);
// If client cancels, throw but let the download continue in background
if (cancellationToken.IsCancellationRequested)
{
Logger.LogInformation("Client cancelled while waiting for download {SongId}, but download continues server-side", songId);
throw new OperationCanceledException("Client cancelled request, but download continues server-side");
}
await Task.Delay(100, CancellationToken.None);
}
if (activeDownload?.Status == DownloadStatus.Completed && activeDownload.LocalPath != null)
@@ -64,6 +64,39 @@ public class EnvMigrationService
_logger.LogInformation("Migrated SQUIDWTF_QUALITY from {Old} to {New} in .env file", value, newValue);
}
}
// CRITICAL FIX: Remove quotes from password/token values
// Docker Compose does NOT need quotes in .env files - it handles special characters correctly
// When quotes are used, they become part of the value itself
var keysToUnquote = new[]
{
"SCROBBLING_LASTFM_PASSWORD",
"MUSICBRAINZ_PASSWORD",
"DEEZER_ARL",
"DEEZER_ARL_FALLBACK",
"QOBUZ_USER_AUTH_TOKEN",
"SCROBBLING_LASTFM_SESSION_KEY",
"SCROBBLING_LISTENBRAINZ_USER_TOKEN",
"SPOTIFY_API_SESSION_COOKIE"
};
foreach (var key in keysToUnquote)
{
if (line.StartsWith($"{key}="))
{
var value = line.Substring($"{key}=".Length);
// Remove surrounding quotes if present
if (value.StartsWith("\"") && value.EndsWith("\"") && value.Length >= 2)
{
var unquoted = value.Substring(1, value.Length - 2);
lines[i] = $"{key}={unquoted}";
modified = true;
_logger.LogInformation("Removed quotes from {Key} (Docker Compose doesn't need them)", key);
}
break;
}
}
}
if (modified)
@@ -74,7 +107,7 @@ public class EnvMigrationService
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to migrate .env file - please manually update DOWNLOAD_PATH to Library__DownloadPath");
_logger.LogError(ex, "Failed to migrate .env file");
}
}
}
@@ -112,31 +112,14 @@ public class GenreEnrichmentService
/// <summary>
/// Aggregates genres from a list of songs to determine playlist genres.
/// Returns the top 5 most common genres.
/// Returns all unique genres from the songs.
/// </summary>
public List<string> AggregatePlaylistGenres(List<Song> songs)
{
var genreCounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var song in songs)
{
if (!string.IsNullOrEmpty(song.Genre))
{
if (genreCounts.ContainsKey(song.Genre))
{
genreCounts[song.Genre]++;
}
else
{
genreCounts[song.Genre] = 1;
}
}
}
return genreCounts
.OrderByDescending(kvp => kvp.Value)
.Take(5)
.Select(kvp => kvp.Key)
return songs
.Where(s => !string.IsNullOrEmpty(s.Genre))
.Select(s => s.Genre!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
+24 -17
View File
@@ -2,48 +2,55 @@ namespace allstarr.Services.Common;
/// <summary>
/// Helper class for handling external playlist IDs.
/// Playlist IDs use the format: "pl-{provider}-{externalId}"
/// Example: "pl-deezer-123456", "pl-qobuz-789"
/// Playlist IDs use the format: "ext-{provider}-playlist-{externalId}"
/// Example: "ext-deezer-playlist-123456", "ext-qobuz-playlist-789"
/// This matches the format used for albums and songs for consistency.
/// </summary>
public static class PlaylistIdHelper
{
private const string PlaylistPrefix = "pl-";
private const string PlaylistType = "playlist";
/// <summary>
/// Checks if an ID represents an external playlist.
/// Only supports new format: ext-{provider}-playlist-{id}
/// </summary>
/// <param name="id">The ID to check</param>
/// <returns>True if the ID starts with "pl-", false otherwise</returns>
/// <returns>True if the ID is a playlist ID in the new format, false otherwise</returns>
public static bool IsExternalPlaylist(string? id)
{
return !string.IsNullOrEmpty(id) && id.StartsWith(PlaylistPrefix, StringComparison.OrdinalIgnoreCase);
if (string.IsNullOrEmpty(id)) return false;
// New format only: ext-{provider}-playlist-{id}
return id.StartsWith("ext-", StringComparison.OrdinalIgnoreCase) &&
id.Contains("-playlist-", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Parses a playlist ID to extract provider and external ID.
/// Only supports new format: ext-{provider}-playlist-{id}
/// </summary>
/// <param name="id">The playlist ID in format "pl-{provider}-{externalId}"</param>
/// <param name="id">The playlist ID in format "ext-{provider}-playlist-{externalId}"</param>
/// <returns>A tuple containing (provider, externalId)</returns>
/// <exception cref="ArgumentException">Thrown if the ID format is invalid</exception>
public static (string provider, string externalId) ParsePlaylistId(string id)
{
if (!IsExternalPlaylist(id))
{
throw new ArgumentException($"Invalid playlist ID format. Expected 'pl-{{provider}}-{{externalId}}', got '{id}'", nameof(id));
throw new ArgumentException($"Invalid playlist ID format. Expected 'ext-{{provider}}-playlist-{{externalId}}', got '{id}'", nameof(id));
}
// Remove "pl-" prefix
var withoutPrefix = id.Substring(PlaylistPrefix.Length);
// Format: ext-{provider}-playlist-{externalId}
var withoutPrefix = id.Substring(4); // Remove "ext-"
// Split by first dash to get provider and externalId
var dashIndex = withoutPrefix.IndexOf('-');
if (dashIndex == -1)
// Find "-playlist-" separator
var playlistIndex = withoutPrefix.IndexOf("-playlist-", StringComparison.OrdinalIgnoreCase);
if (playlistIndex == -1)
{
throw new ArgumentException($"Invalid playlist ID format. Expected 'pl-{{provider}}-{{externalId}}', got '{id}'", nameof(id));
throw new ArgumentException($"Invalid playlist ID format. Expected 'ext-{{provider}}-playlist-{{externalId}}', got '{id}'", nameof(id));
}
var provider = withoutPrefix.Substring(0, dashIndex);
var externalId = withoutPrefix.Substring(dashIndex + 1);
var provider = withoutPrefix.Substring(0, playlistIndex);
var externalId = withoutPrefix.Substring(playlistIndex + 10); // 10 = length of "-playlist-"
if (string.IsNullOrEmpty(provider) || string.IsNullOrEmpty(externalId))
{
@@ -58,7 +65,7 @@ public static class PlaylistIdHelper
/// </summary>
/// <param name="provider">The provider name (e.g., "deezer", "qobuz")</param>
/// <param name="externalId">The external ID from the provider</param>
/// <returns>A playlist ID in format "pl-{provider}-{externalId}"</returns>
/// <returns>A playlist ID in format "ext-{provider}-playlist-{externalId}"</returns>
public static string CreatePlaylistId(string provider, string externalId)
{
if (string.IsNullOrEmpty(provider))
@@ -71,6 +78,6 @@ public static class PlaylistIdHelper
throw new ArgumentException("External ID cannot be null or empty", nameof(externalId));
}
return $"{PlaylistPrefix}{provider.ToLowerInvariant()}-{externalId}";
return $"ext-{provider.ToLowerInvariant()}-{PlaylistType}-{externalId}";
}
}
@@ -76,9 +76,36 @@ public class RoundRobinFallbackHelper
return isHealthy;
}
catch (TaskCanceledException)
{
// Timeouts are expected when checking multiple mirrors - log at debug level
_logger.LogDebug("{Service} endpoint {Endpoint} health check timed out", _serviceName, baseUrl);
// Cache as unhealthy
lock (_healthCacheLock)
{
_healthCache[baseUrl] = (false, DateTime.UtcNow);
}
return false;
}
catch (HttpRequestException ex)
{
// Connection errors (refused, DNS failures, etc.) - log at debug level
_logger.LogDebug("{Service} endpoint {Endpoint} health check failed: {Message}", _serviceName, baseUrl, ex.Message);
// Cache as unhealthy
lock (_healthCacheLock)
{
_healthCache[baseUrl] = (false, DateTime.UtcNow);
}
return false;
}
catch (Exception ex)
{
_logger.LogError(ex, "{Service} endpoint {Endpoint} health check failed", _serviceName, baseUrl);
// Unexpected errors - still log at debug level for health checks
_logger.LogDebug(ex, "{Service} endpoint {Endpoint} health check failed", _serviceName, baseUrl);
// Cache as unhealthy
lock (_healthCacheLock)
@@ -203,56 +230,68 @@ public class RoundRobinFallbackHelper
/// Races all endpoints in parallel and returns the first successful result.
/// Cancels remaining requests once one succeeds. Great for latency-sensitive operations.
/// </summary>
public async Task<T> RaceAllEndpointsAsync<T>(Func<string, CancellationToken, Task<T>> action, CancellationToken cancellationToken = default)
{
if (_apiUrls.Count == 1)
/// <summary>
/// Races the top N fastest endpoints in parallel and returns the first successful result.
/// Cancels remaining requests once one succeeds. Used for latency-sensitive operations like search.
/// </summary>
public async Task<T> RaceTopEndpointsAsync<T>(int topN, Func<string, CancellationToken, Task<T>> action, CancellationToken cancellationToken = default)
{
// No point racing with one endpoint
return await action(_apiUrls[0], cancellationToken);
}
using var raceCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var tasks = new List<Task<(T result, string endpoint, bool success)>>();
// Start all requests in parallel
foreach (var baseUrl in _apiUrls)
{
var task = Task.Run(async () =>
if (_apiUrls.Count == 1 || topN <= 1)
{
try
{
_logger.LogDebug("Racing {Service} endpoint {Endpoint}", _serviceName, baseUrl);
var result = await action(baseUrl, raceCts.Token);
return (result, baseUrl, true);
}
catch (Exception ex)
{
_logger.LogError(ex, "{Service} race failed for endpoint {Endpoint}", _serviceName, baseUrl);
return (default(T)!, baseUrl, false);
}
}, raceCts.Token);
tasks.Add(task);
}
// Wait for first successful completion
while (tasks.Count > 0)
{
var completedTask = await Task.WhenAny(tasks);
var (result, endpoint, success) = await completedTask;
if (success)
{
_logger.LogDebug("🏁 {Service} race won by {Endpoint}, canceling others", _serviceName, endpoint);
raceCts.Cancel(); // Cancel all other requests
return result;
// No point racing with one endpoint - use fallback instead
return await TryWithFallbackAsync(baseUrl => action(baseUrl, cancellationToken));
}
tasks.Remove(completedTask);
}
throw new Exception($"All {_serviceName} endpoints failed in race");
}
// Get top N fastest healthy endpoints
var endpointsToRace = _apiUrls.Take(Math.Min(topN, _apiUrls.Count)).ToList();
if (endpointsToRace.Count == 1)
{
return await action(endpointsToRace[0], cancellationToken);
}
using var raceCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var tasks = new List<Task<(T result, string endpoint, bool success)>>();
// Start racing the top N endpoints
foreach (var baseUrl in endpointsToRace)
{
var task = Task.Run(async () =>
{
try
{
_logger.LogDebug("🏁 Racing {Service} endpoint {Endpoint}", _serviceName, baseUrl);
var result = await action(baseUrl, raceCts.Token);
return (result, baseUrl, true);
}
catch (Exception ex)
{
_logger.LogDebug("{Service} race failed for endpoint {Endpoint}: {Message}", _serviceName, baseUrl, ex.Message);
return (default(T)!, baseUrl, false);
}
}, raceCts.Token);
tasks.Add(task);
}
// Wait for first successful completion
while (tasks.Count > 0)
{
var completedTask = await Task.WhenAny(tasks);
var (result, endpoint, success) = await completedTask;
if (success)
{
_logger.LogDebug("🏆 {Service} race won by {Endpoint}, canceling others", _serviceName, endpoint);
raceCts.Cancel(); // Cancel all other requests
return result;
}
tasks.Remove(completedTask);
}
throw new Exception($"All {topN} {_serviceName} endpoints failed in race");
}
/// <summary>
/// Tries the request with the next provider in round-robin, then falls back to others on failure.
@@ -310,4 +349,93 @@ public class RoundRobinFallbackHelper
}
return defaultValue;
}
/// <summary>
/// Processes multiple items in parallel across all available endpoints.
/// Each endpoint processes items sequentially. Failed endpoints are blacklisted.
/// </summary>
public async Task<List<TResult>> ProcessInParallelAsync<TItem, TResult>(
List<TItem> items,
Func<string, TItem, CancellationToken, Task<TResult>> action,
CancellationToken cancellationToken = default)
{
if (!items.Any())
{
return new List<TResult>();
}
var results = new List<TResult>();
var resultsLock = new object();
var itemQueue = new Queue<TItem>(items);
var queueLock = new object();
var blacklistedEndpoints = new HashSet<string>();
var blacklistLock = new object();
// Start one task per endpoint
var tasks = _apiUrls.Select(async endpoint =>
{
while (true)
{
// Check if endpoint is blacklisted
lock (blacklistLock)
{
if (blacklistedEndpoints.Contains(endpoint))
{
return;
}
}
// Get next item from queue
TItem? item;
lock (queueLock)
{
if (itemQueue.Count == 0)
{
return; // No more items to process
}
item = itemQueue.Dequeue();
}
// Process the item
try
{
var result = await action(endpoint, item, cancellationToken);
lock (resultsLock)
{
results.Add(result);
}
_logger.LogDebug("✓ {Service} endpoint {Endpoint} processed item ({Completed}/{Total})",
_serviceName, endpoint, results.Count, items.Count);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "✗ {Service} endpoint {Endpoint} failed, blacklisting",
_serviceName, endpoint);
// Blacklist this endpoint
lock (blacklistLock)
{
blacklistedEndpoints.Add(endpoint);
}
// Put item back in queue for another endpoint to try
lock (queueLock)
{
itemQueue.Enqueue(item);
}
return; // Exit this endpoint's task
}
}
}).ToList();
await Task.WhenAll(tasks);
_logger.LogInformation("🏁 {Service} parallel processing complete: {Completed}/{Total} items, {Blacklisted} endpoints blacklisted",
_serviceName, results.Count, items.Count, blacklistedEndpoints.Count);
return results;
}
}
@@ -636,6 +636,10 @@ public class DeezerMetadataService : IMusicMetadataService
// Override album name to be the playlist name
song.Album = playlistName;
// Playlists should not have disc numbers - always set to null
// This prevents Jellyfin from splitting the playlist into multiple "discs"
song.DiscNumber = null;
if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter))
{
songs.Add(song);
@@ -489,8 +489,15 @@ public class JellyfinProxyService
var result = await GetBytesAsync(endpoint, queryParams);
return (result.Body, result.ContentType, true);
}
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// 404s are expected for missing images - log at debug level
_logger.LogDebug("Image not available for {Endpoint}", endpoint);
return (null, null, false);
}
catch (Exception ex)
{
// Actual errors should still be logged
_logger.LogError(ex, "Failed to get bytes from {Endpoint}", endpoint);
return (null, null, false);
}
@@ -498,7 +505,7 @@ public class JellyfinProxyService
/// <summary>
/// Searches for items in Jellyfin.
/// Uses configured or auto-detected LibraryId to filter search to music library only.
/// Does not force any library filtering - clients can specify parentId if they want.
/// </summary>
public async Task<(JsonDocument? Body, int StatusCode)> SearchAsync(
string searchTerm,
@@ -520,12 +527,8 @@ public class JellyfinProxyService
queryParams["userId"] = _settings.UserId;
}
// Only filter search to music library if explicitly configured
if (!string.IsNullOrEmpty(_settings.LibraryId))
{
queryParams["parentId"] = _settings.LibraryId;
_logger.LogInformation("Searching within configured LibraryId {LibraryId}", _settings.LibraryId);
}
// Note: We don't force parentId here - let clients specify which library to search
// The controller will detect music library searches and add external results
if (includeItemTypes != null && includeItemTypes.Length > 0)
{
@@ -94,41 +94,104 @@ public class JellyfinResponseBuilder
/// Creates a response for a playlist represented as an album.
/// </summary>
public IActionResult CreatePlaylistAsAlbumResponse(ExternalPlaylist playlist, List<Song> tracks)
{
var totalDuration = tracks.Sum(s => s.Duration ?? 0);
var curatorName = !string.IsNullOrEmpty(playlist.CuratorName)
? playlist.CuratorName
: playlist.Provider;
var albumItem = new Dictionary<string, object?>
{
["Id"] = playlist.Id,
["Name"] = playlist.Name,
["Type"] = "Playlist",
["AlbumArtist"] = curatorName,
["Genres"] = new[] { "Playlist" },
["ChildCount"] = tracks.Count,
["RunTimeTicks"] = totalDuration * TimeSpan.TicksPerSecond,
["ImageTags"] = new Dictionary<string, string>
var totalDuration = tracks.Sum(s => s.Duration ?? 0);
var curatorName = !string.IsNullOrEmpty(playlist.CuratorName)
? playlist.CuratorName
: playlist.Provider;
// Create artist items for the curator
var artistId = $"ext-{playlist.Provider}-curator-{curatorName.ToLowerInvariant().Replace(" ", "-")}";
var artistItems = new[]
{
["Primary"] = playlist.Id
},
["ProviderIds"] = new Dictionary<string, string>
new Dictionary<string, object>
{
["Name"] = curatorName,
["Id"] = artistId
}
};
// Aggregate unique genres from all tracks
var genres = tracks
.Where(s => !string.IsNullOrEmpty(s.Genre))
.Select(s => s.Genre!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
// If no genres found, fallback to "Playlist"
if (genres.Count == 0)
{
[playlist.Provider] = playlist.ExternalId
},
["Children"] = tracks.Select(ConvertSongToJellyfinItem).ToList()
};
if (playlist.CreatedDate.HasValue)
{
albumItem["PremiereDate"] = playlist.CreatedDate.Value.ToString("o");
albumItem["ProductionYear"] = playlist.CreatedDate.Value.Year;
genres.Add("Playlist");
}
var genreItems = genres.Select(g => new Dictionary<string, object>
{
["Name"] = g,
["Id"] = $"genre-{g.ToLowerInvariant()}"
}).ToArray();
var albumItem = new Dictionary<string, object?>
{
["Id"] = playlist.Id,
["Name"] = $"{playlist.Name} [S/P]", // Label as playlist
["Type"] = "MusicAlbum", // Must be MusicAlbum for Jellyfin clients
["ServerId"] = "allstarr",
["ChannelId"] = null,
["IsFolder"] = true,
["PremiereDate"] = playlist.CreatedDate?.ToString("o"),
["ProductionYear"] = playlist.CreatedDate?.Year,
["Genres"] = genres.ToArray(),
["GenreItems"] = genreItems,
["Artists"] = new[] { curatorName },
["ArtistItems"] = artistItems,
["AlbumArtist"] = curatorName,
["AlbumArtists"] = artistItems,
["ParentLogoItemId"] = artistId,
["ParentBackdropItemId"] = artistId,
["ParentBackdropImageTags"] = new string[0],
["ChildCount"] = tracks.Count,
["RunTimeTicks"] = totalDuration * TimeSpan.TicksPerSecond,
["ImageTags"] = new Dictionary<string, string>
{
["Primary"] = playlist.Id
},
["BackdropImageTags"] = new string[0],
["ParentLogoImageTag"] = artistId,
["ImageBlurHashes"] = new Dictionary<string, object>(),
["LocationType"] = "FileSystem", // Must be FileSystem for Jellyfin to show artist albums
["MediaType"] = "Unknown",
["UserData"] = new Dictionary<string, object>
{
["PlaybackPositionTicks"] = 0,
["PlayCount"] = 0,
["IsFavorite"] = false,
["Played"] = false,
["Key"] = $"{curatorName}-{playlist.Name}",
["ItemId"] = playlist.Id
},
["ProviderIds"] = new Dictionary<string, string>
{
[playlist.Provider] = playlist.ExternalId
},
["Children"] = tracks.Select(song =>
{
var item = ConvertSongToJellyfinItem(song);
// Override ParentId and AlbumId to be the playlist ID
// This makes all tracks appear to be from the same "album" (the playlist)
item["ParentId"] = playlist.Id;
item["AlbumId"] = playlist.Id;
item["AlbumPrimaryImageTag"] = playlist.Id;
item["ParentLogoItemId"] = playlist.Id;
item["ParentLogoImageTag"] = playlist.Id;
item["ParentBackdropItemId"] = playlist.Id;
return item;
}).ToList()
};
// Return album object directly (not wrapped) - same as CreateAlbumResponse
return CreateJsonResponse(albumItem);
}
return CreateJsonResponse(albumItem);
}
/// <summary>
/// Creates a search hints response (Jellyfin search format).
@@ -663,18 +726,51 @@ public class JellyfinResponseBuilder
var item = new Dictionary<string, object?>
{
["Name"] = $"{playlist.Name} [S/P]",
["ServerId"] = "allstarr",
["Id"] = playlist.Id,
["Name"] = playlist.Name,
["Type"] = "Playlist",
["IsFolder"] = true,
["AlbumArtist"] = curatorName,
["ChildCount"] = playlist.TrackCount,
["ChannelId"] = (object?)null,
["Genres"] = new string[0],
["RunTimeTicks"] = playlist.Duration * TimeSpan.TicksPerSecond,
["Genres"] = new[] { "Playlist" },
["IsFolder"] = true,
["Type"] = "MusicAlbum",
["GenreItems"] = new Dictionary<string, object?>[0],
["UserData"] = new Dictionary<string, object>
{
["PlaybackPositionTicks"] = 0,
["PlayCount"] = 0,
["IsFavorite"] = false,
["Played"] = false,
["Key"] = playlist.Id,
["ItemId"] = playlist.Id
},
["ChildCount"] = playlist.TrackCount,
["Artists"] = new[] { curatorName },
["ArtistItems"] = new[]
{
new Dictionary<string, object?>
{
["Name"] = curatorName,
["Id"] = $"ext-{playlist.Provider}-curator-{curatorName.ToLowerInvariant().Replace(" ", "-")}"
}
},
["AlbumArtist"] = curatorName,
["AlbumArtists"] = new[]
{
new Dictionary<string, object?>
{
["Name"] = curatorName,
["Id"] = $"ext-{playlist.Provider}-curator-{curatorName.ToLowerInvariant().Replace(" ", "-")}"
}
},
["ImageTags"] = new Dictionary<string, string>
{
["Primary"] = playlist.Id
},
["BackdropImageTags"] = new string[0],
["ImageBlurHashes"] = new Dictionary<string, object>(),
["LocationType"] = "FileSystem",
["MediaType"] = "Unknown",
["ProviderIds"] = new Dictionary<string, string>
{
[playlist.Provider] = playlist.ExternalId
@@ -93,7 +93,8 @@ public class LyricsOrchestrator
{
var artistName = string.Join(", ", artistNames);
_logger.LogDebug("🎵 Prefetching lyrics for: {Artist} - {Track}", artistName, trackName);
_logger.LogDebug("🎵 Prefetching lyrics for: {Artist} - {Track} (Spotify ID: {SpotifyId})",
artistName, trackName, spotifyTrackId ?? "none");
// 1. Try Spotify lyrics (if Spotify ID provided)
if (!string.IsNullOrEmpty(spotifyTrackId))
@@ -104,6 +105,10 @@ public class LyricsOrchestrator
return true;
}
}
else
{
_logger.LogDebug("No Spotify ID available for prefetch, skipping Spotify lyrics");
}
// 2. Try LyricsPlus
var lyricsPlusLyrics = await TryLyricsPlusLyrics(trackName, artistNames, albumName, durationSeconds, artistName);
@@ -181,7 +186,8 @@ public class LyricsOrchestrator
if (lyrics != null)
{
_logger.LogInformation("✓ Found LyricsPlus lyrics for {Artist} - {Track}", artistName, trackName);
// LyricsPlus already logs with source info, so we just confirm success
_logger.LogDebug("✓ LyricsOrchestrator: Using LyricsPlus lyrics for {Artist} - {Track}", artistName, trackName);
return lyrics;
}
@@ -210,7 +216,7 @@ public class LyricsOrchestrator
if (lyrics != null)
{
_logger.LogInformation("✓ Found LRCLib lyrics for {Artist} - {Track}", artistName, trackName);
_logger.LogInformation("✓ LyricsOrchestrator: Using LRCLib lyrics for {Artist} - {Track}", artistName, trackName);
return lyrics;
}
@@ -4,6 +4,7 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using allstarr.Models.Domain;
using allstarr.Models.Settings;
using allstarr.Services.Common;
using Microsoft.Extensions.Options;
namespace allstarr.Services.MusicBrainz;
@@ -15,6 +16,8 @@ public class MusicBrainzService
{
private readonly HttpClient _httpClient;
private readonly MusicBrainzSettings _settings;
private readonly CacheSettings _cacheSettings;
private readonly RedisCacheService _cache;
private readonly ILogger<MusicBrainzService> _logger;
private DateTime _lastRequestTime = DateTime.MinValue;
private readonly SemaphoreSlim _rateLimitSemaphore = new(1, 1);
@@ -22,6 +25,8 @@ public class MusicBrainzService
public MusicBrainzService(
IHttpClientFactory httpClientFactory,
IOptions<MusicBrainzSettings> settings,
IOptions<CacheSettings> cacheSettings,
RedisCacheService cache,
ILogger<MusicBrainzService> logger)
{
_httpClient = httpClientFactory.CreateClient();
@@ -29,6 +34,8 @@ public class MusicBrainzService
_httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
_settings = settings.Value;
_cacheSettings = cacheSettings.Value;
_cache = cache;
_logger = logger;
// Set up digest authentication if credentials provided
@@ -51,6 +58,15 @@ public class MusicBrainzService
return null;
}
// Check cache first
var cacheKey = $"musicbrainz:isrc:{isrc}";
var cached = await _cache.GetAsync<MusicBrainzRecording>(cacheKey);
if (cached != null)
{
_logger.LogDebug("MusicBrainz ISRC cache hit: {Isrc}", isrc);
return cached;
}
await RateLimitAsync();
try
@@ -81,6 +97,9 @@ public class MusicBrainzService
_logger.LogInformation("✓ Found MusicBrainz recording for ISRC {Isrc}: {Title} by {Artist} (Genres: {Genres})",
isrc, recording.Title, recording.ArtistCredit?[0]?.Name ?? "Unknown", string.Join(", ", genres));
// Cache the result
await _cache.SetAsync(cacheKey, recording, _cacheSettings.GenreTTL);
return recording;
}
catch (Exception ex)
@@ -101,6 +120,15 @@ public class MusicBrainzService
return new List<MusicBrainzRecording>();
}
// Check cache first
var cacheKey = $"musicbrainz:search:{title.ToLowerInvariant()}:{artist.ToLowerInvariant()}:{limit}";
var cached = await _cache.GetAsync<List<MusicBrainzRecording>>(cacheKey);
if (cached != null)
{
_logger.LogDebug("MusicBrainz search cache hit: {Title} - {Artist}", title, artist);
return cached;
}
await RateLimitAsync();
try
@@ -133,6 +161,9 @@ public class MusicBrainzService
_logger.LogDebug("Found {Count} MusicBrainz recordings for: {Title} - {Artist}",
result.Recordings.Count, title, artist);
// Cache the result
await _cache.SetAsync(cacheKey, result.Recordings, _cacheSettings.GenreTTL);
return result.Recordings;
}
catch (Exception ex)
@@ -152,6 +183,15 @@ public class MusicBrainzService
return null;
}
// Check cache first
var cacheKey = $"musicbrainz:mbid:{mbid}";
var cached = await _cache.GetAsync<MusicBrainzRecording>(cacheKey);
if (cached != null)
{
_logger.LogDebug("MusicBrainz MBID cache hit: {Mbid}", mbid);
return cached;
}
await RateLimitAsync();
try
@@ -180,6 +220,9 @@ public class MusicBrainzService
_logger.LogInformation("✓ Found MusicBrainz recording for MBID {Mbid}: {Title} by {Artist} (Genres: {Genres})",
mbid, recording.Title, recording.ArtistCredit?[0]?.Name ?? "Unknown", string.Join(", ", genres));
// Cache the result
await _cache.SetAsync(cacheKey, recording, _cacheSettings.GenreTTL);
return recording;
}
catch (Exception ex)
@@ -423,6 +423,10 @@ public class QobuzMetadataService : IMusicMetadataService
song.Album = playlistName;
song.Track = trackIndex;
// Playlists should not have disc numbers - always set to null
// This prevents Jellyfin from splitting the playlist into multiple "discs"
song.DiscNumber = null;
songs.Add(song);
trackIndex++;
}
@@ -0,0 +1,46 @@
using allstarr.Models.Scrobbling;
namespace allstarr.Services.Scrobbling;
/// <summary>
/// Interface for scrobbling services (Last.fm, ListenBrainz, etc.).
/// </summary>
public interface IScrobblingService
{
/// <summary>
/// Service name (e.g., "Last.fm", "ListenBrainz").
/// </summary>
string ServiceName { get; }
/// <summary>
/// Whether this service is enabled and configured.
/// </summary>
bool IsEnabled { get; }
/// <summary>
/// Updates "Now Playing" status for a track.
/// This is optional but recommended - shows what the user is currently listening to.
/// </summary>
/// <param name="track">Track being played</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Result of the request</returns>
Task<ScrobbleResult> UpdateNowPlayingAsync(ScrobbleTrack track, CancellationToken cancellationToken = default);
/// <summary>
/// Scrobbles a track (adds to listening history).
/// Should only be called when scrobble conditions are met (see Last.fm rules).
/// </summary>
/// <param name="track">Track to scrobble</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Result of the request</returns>
Task<ScrobbleResult> ScrobbleAsync(ScrobbleTrack track, CancellationToken cancellationToken = default);
/// <summary>
/// Scrobbles multiple tracks in a batch (up to 50 for Last.fm).
/// Useful for retrying cached scrobbles.
/// </summary>
/// <param name="tracks">Tracks to scrobble</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Results for each track</returns>
Task<List<ScrobbleResult>> ScrobbleBatchAsync(List<ScrobbleTrack> tracks, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,458 @@
using System.Security.Cryptography;
using System.Text;
using System.Xml.Linq;
using Microsoft.Extensions.Options;
using allstarr.Models.Scrobbling;
using allstarr.Models.Settings;
namespace allstarr.Services.Scrobbling;
/// <summary>
/// Last.fm scrobbling service implementation.
/// Follows the Scrobbling 2.0 API specification.
/// </summary>
public class LastFmScrobblingService : IScrobblingService
{
private const string ApiRoot = "https://ws.audioscrobbler.com/2.0/";
private const int MaxBatchSize = 50;
private readonly LastFmSettings _settings;
private readonly ScrobblingSettings _globalSettings;
private readonly HttpClient _httpClient;
private readonly ILogger<LastFmScrobblingService> _logger;
public string ServiceName => "Last.fm";
public bool IsEnabled => _settings.Enabled &&
!string.IsNullOrEmpty(_settings.ApiKey) &&
!string.IsNullOrEmpty(_settings.SharedSecret) &&
!string.IsNullOrEmpty(_settings.SessionKey);
public LastFmScrobblingService(
IOptions<ScrobblingSettings> settings,
IHttpClientFactory httpClientFactory,
ILogger<LastFmScrobblingService> logger)
{
_globalSettings = settings.Value;
_settings = settings.Value.LastFm;
_httpClient = httpClientFactory.CreateClient("LastFm");
_logger = logger;
if (IsEnabled)
{
_logger.LogInformation("🎵 Last.fm scrobbling enabled for user: {Username}",
_settings.Username ?? "Unknown");
}
}
public async Task<ScrobbleResult> UpdateNowPlayingAsync(ScrobbleTrack track, CancellationToken cancellationToken = default)
{
if (!IsEnabled)
{
return ScrobbleResult.CreateError("Last.fm scrobbling not enabled or configured");
}
// Only scrobble external tracks (unless local tracks are enabled)
if (!track.IsExternal && !_globalSettings.LocalTracksEnabled)
{
return ScrobbleResult.CreateIgnored("Local library tracks are not scrobbled (LocalTracksEnabled=false)", 0);
}
_logger.LogDebug("→ Updating Now Playing on Last.fm: {Artist} - {Track}", track.Artist, track.Title);
try
{
var parameters = BuildBaseParameters("track.updateNowPlaying");
AddTrackParameters(parameters, track, includeTimestamp: false);
var response = await SendRequestAsync(parameters, cancellationToken);
var result = ParseResponse(response, isScrobble: false);
if (result.Success && !result.Ignored)
{
_logger.LogDebug("✓ Now Playing updated on Last.fm: {Artist} - {Track}",
track.Artist, track.Title);
}
else if (result.Ignored)
{
_logger.LogWarning("⚠️ Now Playing ignored by Last.fm: {Reason}", result.IgnoredReason);
}
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "❌ Failed to update Now Playing on Last.fm");
return ScrobbleResult.CreateError($"Exception: {ex.Message}");
}
}
public async Task<ScrobbleResult> ScrobbleAsync(ScrobbleTrack track, CancellationToken cancellationToken = default)
{
if (!IsEnabled)
{
return ScrobbleResult.CreateError("Last.fm scrobbling not enabled or configured");
}
// Only scrobble external tracks (unless local tracks are enabled)
if (!track.IsExternal && !_globalSettings.LocalTracksEnabled)
{
return ScrobbleResult.CreateIgnored("Local library tracks are not scrobbled (LocalTracksEnabled=false)", 0);
}
if (track.Timestamp == null)
{
return ScrobbleResult.CreateError("Timestamp is required for scrobbling");
}
_logger.LogDebug("→ Scrobbling to Last.fm: {Artist} - {Track}", track.Artist, track.Title);
try
{
var parameters = BuildBaseParameters("track.scrobble");
AddTrackParameters(parameters, track, includeTimestamp: true);
var response = await SendRequestAsync(parameters, cancellationToken);
var result = ParseResponse(response, isScrobble: true);
if (result.Success && !result.Ignored)
{
_logger.LogDebug("✓ Scrobbled to Last.fm: {Artist} - {Track}",
track.Artist, track.Title);
if (result.ArtistCorrected || result.TrackCorrected || result.AlbumCorrected)
{
_logger.LogDebug("📝 Last.fm corrections: Artist={Artist}, Track={Track}, Album={Album}",
result.CorrectedArtist ?? track.Artist,
result.CorrectedTrack ?? track.Title,
result.CorrectedAlbum ?? track.Album);
}
}
else if (result.Ignored)
{
_logger.LogWarning("⚠️ Scrobble ignored by Last.fm: {Reason} (code: {Code})",
result.IgnoredReason, result.IgnoredCode);
}
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "❌ Failed to scrobble to Last.fm");
return ScrobbleResult.CreateError($"Exception: {ex.Message}");
}
}
public async Task<List<ScrobbleResult>> ScrobbleBatchAsync(List<ScrobbleTrack> tracks, CancellationToken cancellationToken = default)
{
if (!IsEnabled)
{
return tracks.Select(_ => ScrobbleResult.CreateError("Last.fm scrobbling not enabled or configured")).ToList();
}
if (tracks.Count == 0)
{
return new List<ScrobbleResult>();
}
// Filter out local tracks (unless local tracks are enabled)
var allowedTracks = tracks.Where(t => t.IsExternal || _globalSettings.LocalTracksEnabled).ToList();
var filteredTracks = tracks.Where(t => !t.IsExternal && !_globalSettings.LocalTracksEnabled).ToList();
var results = new List<ScrobbleResult>();
// Add ignored results for filtered local tracks
results.AddRange(filteredTracks.Select(_ =>
ScrobbleResult.CreateIgnored("Local library tracks are not scrobbled (LocalTracksEnabled=false)", 0)));
if (allowedTracks.Count == 0)
{
return results;
}
if (allowedTracks.Count > MaxBatchSize)
{
_logger.LogWarning("Batch size {Count} exceeds maximum {Max}, splitting into multiple requests",
allowedTracks.Count, MaxBatchSize);
for (int i = 0; i < allowedTracks.Count; i += MaxBatchSize)
{
var batch = allowedTracks.Skip(i).Take(MaxBatchSize).ToList();
var batchResults = await ScrobbleBatchAsync(batch, cancellationToken);
results.AddRange(batchResults);
}
return results;
}
_logger.LogDebug("→ Scrobbling batch of {Count} tracks to Last.fm", allowedTracks.Count);
try
{
var parameters = BuildBaseParameters("track.scrobble");
// Add parameters for each track with index suffix
for (int i = 0; i < allowedTracks.Count; i++)
{
AddTrackParameters(parameters, allowedTracks[i], includeTimestamp: true, index: i);
}
var response = await SendRequestAsync(parameters, cancellationToken);
var batchResults = ParseBatchResponse(response, allowedTracks.Count);
var accepted = batchResults.Count(r => r.Success && !r.Ignored);
var ignored = batchResults.Count(r => r.Ignored);
var failed = batchResults.Count(r => !r.Success);
_logger.LogDebug("✓ Batch scrobble complete: {Accepted} accepted, {Ignored} ignored, {Failed} failed",
accepted, ignored, failed);
results.AddRange(batchResults);
return results;
}
catch (Exception ex)
{
_logger.LogError(ex, "❌ Failed to scrobble batch to Last.fm");
results.AddRange(allowedTracks.Select(_ => ScrobbleResult.CreateError($"Exception: {ex.Message}")));
return results;
}
}
#region Helper Methods
/// <summary>
/// Builds base parameters for all API requests (api_key, method, sk).
/// </summary>
private Dictionary<string, string> BuildBaseParameters(string method)
{
return new Dictionary<string, string>
{
["api_key"] = _settings.ApiKey,
["method"] = method,
["sk"] = _settings.SessionKey
};
}
/// <summary>
/// Adds track-specific parameters to the request.
/// </summary>
private void AddTrackParameters(Dictionary<string, string> parameters, ScrobbleTrack track, bool includeTimestamp, int? index = null)
{
var suffix = index.HasValue ? $"[{index}]" : "";
parameters[$"artist{suffix}"] = track.Artist;
parameters[$"track{suffix}"] = track.Title;
if (!string.IsNullOrEmpty(track.Album))
parameters[$"album{suffix}"] = track.Album;
if (!string.IsNullOrEmpty(track.AlbumArtist))
parameters[$"albumArtist{suffix}"] = track.AlbumArtist;
if (track.DurationSeconds.HasValue)
parameters[$"duration{suffix}"] = track.DurationSeconds.Value.ToString();
if (!string.IsNullOrEmpty(track.MusicBrainzId))
parameters[$"mbid{suffix}"] = track.MusicBrainzId;
if (includeTimestamp && track.Timestamp.HasValue)
parameters[$"timestamp{suffix}"] = track.Timestamp.Value.ToString();
// Only include chosenByUser if it's false (default is true)
if (!track.ChosenByUser)
parameters[$"chosenByUser{suffix}"] = "0";
}
/// <summary>
/// Generates MD5 signature for API request.
/// Format: api_key{value}method{value}...{shared_secret}
/// </summary>
private string GenerateSignature(Dictionary<string, string> parameters)
{
// Sort parameters alphabetically by key
var sorted = parameters.OrderBy(kvp => kvp.Key);
// Build signature string: key1value1key2value2...secret
var signatureString = new StringBuilder();
foreach (var kvp in sorted)
{
signatureString.Append(kvp.Key);
signatureString.Append(kvp.Value);
}
signatureString.Append(_settings.SharedSecret);
// Generate MD5 hash
var bytes = Encoding.UTF8.GetBytes(signatureString.ToString());
var hash = MD5.HashData(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
/// <summary>
/// Sends HTTP POST request to Last.fm API.
/// </summary>
private async Task<string> SendRequestAsync(Dictionary<string, string> parameters, CancellationToken cancellationToken)
{
// Add signature
parameters["api_sig"] = GenerateSignature(parameters);
// Create form content
var content = new FormUrlEncodedContent(parameters);
// Send request
var response = await _httpClient.PostAsync(ApiRoot, content, cancellationToken);
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
// Log request/response for debugging
_logger.LogTrace("Last.fm request: {Method}, Response: {StatusCode}",
parameters["method"], response.StatusCode);
// Always inspect response body, even if HTTP status is not 200
return responseBody;
}
/// <summary>
/// Parses Last.fm XML response for single scrobble/now playing.
/// </summary>
private ScrobbleResult ParseResponse(string xml, bool isScrobble)
{
try
{
var doc = XDocument.Parse(xml);
var root = doc.Root;
if (root == null)
{
return ScrobbleResult.CreateError("Invalid XML response");
}
var status = root.Attribute("status")?.Value;
// Check for error
if (status == "failed")
{
var errorElement = root.Element("error");
var errorCode = int.Parse(errorElement?.Attribute("code")?.Value ?? "0");
var errorMessage = errorElement?.Value ?? "Unknown error";
// Determine if should retry based on error code
var shouldRetry = errorCode == 11 || errorCode == 16; // Service offline or temporarily unavailable
// Error code 9 means session key is invalid - log prominently
if (errorCode == 9)
{
_logger.LogError("❌ Last.fm session key is invalid - please re-authenticate");
}
return ScrobbleResult.CreateError(errorMessage, errorCode, shouldRetry);
}
// Success - check for ignored message
if (isScrobble)
{
var scrobbleElement = root.Descendants("scrobble").FirstOrDefault();
if (scrobbleElement != null)
{
return ParseScrobbleElement(scrobbleElement);
}
}
return ScrobbleResult.CreateSuccess();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to parse Last.fm response: {Xml}", xml);
return ScrobbleResult.CreateError($"Parse error: {ex.Message}");
}
}
/// <summary>
/// Parses Last.fm XML response for batch scrobble.
/// </summary>
private List<ScrobbleResult> ParseBatchResponse(string xml, int expectedCount)
{
try
{
var doc = XDocument.Parse(xml);
var root = doc.Root;
if (root == null)
{
return Enumerable.Repeat(ScrobbleResult.CreateError("Invalid XML response"), expectedCount).ToList();
}
var status = root.Attribute("status")?.Value;
// Check for error
if (status == "failed")
{
var errorElement = root.Element("error");
var errorCode = int.Parse(errorElement?.Attribute("code")?.Value ?? "0");
var errorMessage = errorElement?.Value ?? "Unknown error";
var shouldRetry = errorCode == 11 || errorCode == 16;
return Enumerable.Repeat(ScrobbleResult.CreateError(errorMessage, errorCode, shouldRetry), expectedCount).ToList();
}
// Parse individual scrobble results
var scrobbleElements = root.Descendants("scrobble").ToList();
return scrobbleElements.Select(ParseScrobbleElement).ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to parse Last.fm batch response: {Xml}", xml);
return Enumerable.Repeat(ScrobbleResult.CreateError($"Parse error: {ex.Message}"), expectedCount).ToList();
}
}
/// <summary>
/// Parses a single scrobble element from XML response.
/// </summary>
private ScrobbleResult ParseScrobbleElement(XElement scrobbleElement)
{
var result = new ScrobbleResult { Success = true };
// Check for ignored message
var ignoredElement = scrobbleElement.Element("ignoredmessage");
if (ignoredElement != null)
{
var ignoredCode = int.Parse(ignoredElement.Attribute("code")?.Value ?? "0");
if (ignoredCode > 0)
{
return ScrobbleResult.CreateIgnored(ignoredElement.Value.Trim(), ignoredCode);
}
}
// Check for corrections
var artistElement = scrobbleElement.Element("artist");
if (artistElement != null && artistElement.Attribute("corrected")?.Value == "1")
{
result = result with
{
ArtistCorrected = true,
CorrectedArtist = artistElement.Value
};
}
var trackElement = scrobbleElement.Element("track");
if (trackElement != null && trackElement.Attribute("corrected")?.Value == "1")
{
result = result with
{
TrackCorrected = true,
CorrectedTrack = trackElement.Value
};
}
var albumElement = scrobbleElement.Element("album");
if (albumElement != null && albumElement.Attribute("corrected")?.Value == "1")
{
result = result with
{
AlbumCorrected = true,
CorrectedAlbum = albumElement.Value
};
}
return result;
}
#endregion
}
@@ -0,0 +1,320 @@
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Options;
using allstarr.Models.Scrobbling;
using allstarr.Models.Settings;
namespace allstarr.Services.Scrobbling;
/// <summary>
/// ListenBrainz scrobbling service implementation.
/// Follows the ListenBrainz API specification.
/// Only scrobbles external tracks (not local library tracks).
/// </summary>
public class ListenBrainzScrobblingService : IScrobblingService
{
private const string ApiRoot = "https://api.listenbrainz.org/1";
private const int MaxBatchSize = 1000; // ListenBrainz supports up to 1000 listens per request
private readonly ListenBrainzSettings _settings;
private readonly ScrobblingSettings _globalSettings;
private readonly HttpClient _httpClient;
private readonly ILogger<ListenBrainzScrobblingService> _logger;
public string ServiceName => "ListenBrainz";
public bool IsEnabled => _settings.Enabled && !string.IsNullOrEmpty(_settings.UserToken);
public ListenBrainzScrobblingService(
IOptions<ScrobblingSettings> settings,
IHttpClientFactory httpClientFactory,
ILogger<ListenBrainzScrobblingService> logger)
{
_globalSettings = settings.Value;
_settings = settings.Value.ListenBrainz;
_httpClient = httpClientFactory.CreateClient("ListenBrainz");
_logger = logger;
// Debug logging
_logger.LogInformation("ListenBrainz Service Configuration:");
_logger.LogInformation(" Enabled: {Enabled}", _settings.Enabled);
_logger.LogInformation(" UserToken: {Token}", string.IsNullOrEmpty(_settings.UserToken) ? "(empty)" : "***" + _settings.UserToken[^Math.Min(8, _settings.UserToken.Length)..]);
_logger.LogInformation(" IsEnabled: {IsEnabled}", IsEnabled);
if (IsEnabled)
{
_logger.LogInformation("🎵 ListenBrainz scrobbling enabled");
}
else
{
_logger.LogWarning("⚠️ ListenBrainz scrobbling NOT enabled (Enabled={Enabled}, HasToken={HasToken})",
_settings.Enabled, !string.IsNullOrEmpty(_settings.UserToken));
}
}
public async Task<ScrobbleResult> UpdateNowPlayingAsync(ScrobbleTrack track, CancellationToken cancellationToken = default)
{
if (!IsEnabled)
{
return ScrobbleResult.CreateError("ListenBrainz scrobbling not enabled or configured");
}
// Only scrobble external tracks (unless local tracks are enabled)
if (!track.IsExternal && !_globalSettings.LocalTracksEnabled)
{
return ScrobbleResult.CreateIgnored("Local library tracks are not scrobbled (LocalTracksEnabled=false)", 0);
}
_logger.LogDebug("→ Updating Now Playing on ListenBrainz: {Artist} - {Track}", track.Artist, track.Title);
try
{
var payload = BuildListenPayload("playing_now", new[] { track });
var response = await SendRequestAsync("/submit-listens", payload, cancellationToken);
if (response.Success)
{
_logger.LogDebug("✓ Now Playing updated on ListenBrainz: {Artist} - {Track}",
track.Artist, track.Title);
}
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "❌ Failed to update Now Playing on ListenBrainz");
return ScrobbleResult.CreateError($"Exception: {ex.Message}");
}
}
public async Task<ScrobbleResult> ScrobbleAsync(ScrobbleTrack track, CancellationToken cancellationToken = default)
{
if (!IsEnabled)
{
return ScrobbleResult.CreateError("ListenBrainz scrobbling not enabled or configured");
}
// Only scrobble external tracks (unless local tracks are enabled)
if (!track.IsExternal && !_globalSettings.LocalTracksEnabled)
{
return ScrobbleResult.CreateIgnored("Local library tracks are not scrobbled (LocalTracksEnabled=false)", 0);
}
if (track.Timestamp == null)
{
return ScrobbleResult.CreateError("Timestamp is required for scrobbling");
}
_logger.LogDebug("→ Scrobbling to ListenBrainz: {Artist} - {Track}", track.Artist, track.Title);
try
{
var payload = BuildListenPayload("single", new[] { track });
var response = await SendRequestAsync("/submit-listens", payload, cancellationToken);
if (response.Success)
{
_logger.LogDebug("✓ Scrobbled to ListenBrainz: {Artist} - {Track}",
track.Artist, track.Title);
}
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "❌ Failed to scrobble to ListenBrainz");
return ScrobbleResult.CreateError($"Exception: {ex.Message}");
}
}
public async Task<List<ScrobbleResult>> ScrobbleBatchAsync(List<ScrobbleTrack> tracks, CancellationToken cancellationToken = default)
{
if (!IsEnabled)
{
return tracks.Select(_ => ScrobbleResult.CreateError("ListenBrainz scrobbling not enabled or configured")).ToList();
}
if (tracks.Count == 0)
{
return new List<ScrobbleResult>();
}
// Filter out local tracks (unless local tracks are enabled)
var externalTracks = tracks.Where(t => t.IsExternal || _globalSettings.LocalTracksEnabled).ToList();
var localTracks = tracks.Where(t => !t.IsExternal && !_globalSettings.LocalTracksEnabled).ToList();
var results = new List<ScrobbleResult>();
// Add ignored results for local tracks
results.AddRange(localTracks.Select(_ =>
ScrobbleResult.CreateIgnored("Local library tracks are not scrobbled", 0)));
if (externalTracks.Count == 0)
{
return results;
}
if (externalTracks.Count > MaxBatchSize)
{
_logger.LogWarning("Batch size {Count} exceeds maximum {Max}, splitting into multiple requests",
externalTracks.Count, MaxBatchSize);
for (int i = 0; i < externalTracks.Count; i += MaxBatchSize)
{
var batch = externalTracks.Skip(i).Take(MaxBatchSize).ToList();
var batchResults = await ScrobbleBatchAsync(batch, cancellationToken);
results.AddRange(batchResults);
}
return results;
}
_logger.LogDebug("→ Scrobbling batch of {Count} tracks to ListenBrainz", externalTracks.Count);
try
{
var payload = BuildListenPayload("import", externalTracks);
var response = await SendRequestAsync("/submit-listens", payload, cancellationToken);
if (response.Success)
{
_logger.LogDebug("✓ Batch scrobble complete: {Count} tracks submitted to ListenBrainz",
externalTracks.Count);
// ListenBrainz doesn't provide per-track results, so return success for all
results.AddRange(externalTracks.Select(_ => ScrobbleResult.CreateSuccess()));
}
else
{
// If batch fails, all tracks fail
results.AddRange(externalTracks.Select(_ => response));
}
return results;
}
catch (Exception ex)
{
_logger.LogError(ex, "❌ Failed to scrobble batch to ListenBrainz");
results.AddRange(externalTracks.Select(_ => ScrobbleResult.CreateError($"Exception: {ex.Message}")));
return results;
}
}
#region Helper Methods
/// <summary>
/// Builds the JSON payload for ListenBrainz API.
/// </summary>
private string BuildListenPayload(string listenType, IEnumerable<ScrobbleTrack> tracks)
{
var listens = tracks.Select(track =>
{
var additionalInfo = new Dictionary<string, object>();
// Only add MusicBrainz recording ID if available (must be valid UUID format)
if (!string.IsNullOrEmpty(track.MusicBrainzId))
{
additionalInfo["recording_mbid"] = track.MusicBrainzId;
}
if (track.DurationSeconds.HasValue)
{
additionalInfo["duration_ms"] = track.DurationSeconds.Value * 1000;
}
// For single and import, include timestamp
if (listenType != "playing_now" && track.Timestamp.HasValue)
{
return (object)new
{
listened_at = track.Timestamp.Value,
track_metadata = new
{
artist_name = track.Artist,
track_name = track.Title,
release_name = track.Album,
additional_info = additionalInfo
}
};
}
return (object)new
{
track_metadata = new
{
artist_name = track.Artist,
track_name = track.Title,
release_name = track.Album,
additional_info = additionalInfo
}
};
}).ToList();
var payload = new
{
listen_type = listenType,
payload = listens
};
return JsonSerializer.Serialize(payload, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
});
}
/// <summary>
/// Sends HTTP POST request to ListenBrainz API.
/// </summary>
private async Task<ScrobbleResult> SendRequestAsync(string endpoint, string jsonPayload, CancellationToken cancellationToken)
{
try
{
var request = new HttpRequestMessage(HttpMethod.Post, $"{ApiRoot}{endpoint}")
{
Content = new StringContent(jsonPayload, Encoding.UTF8, "application/json")
};
request.Headers.Add("Authorization", $"Token {_settings.UserToken}");
var response = await _httpClient.SendAsync(request, cancellationToken);
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogTrace("ListenBrainz request: {Endpoint}, Response: {StatusCode}",
endpoint, response.StatusCode);
if (response.IsSuccessStatusCode)
{
return ScrobbleResult.CreateSuccess();
}
// Parse error response
try
{
var errorDoc = JsonDocument.Parse(responseBody);
var errorMessage = errorDoc.RootElement.GetProperty("error").GetString() ?? "Unknown error";
var errorCode = (int)response.StatusCode;
// Determine if should retry based on status code
var shouldRetry = errorCode == 429 || errorCode >= 500;
if (errorCode == 401)
{
_logger.LogError("❌ ListenBrainz user token is invalid - please check your token");
}
return ScrobbleResult.CreateError(errorMessage, errorCode, shouldRetry);
}
catch
{
return ScrobbleResult.CreateError($"HTTP {response.StatusCode}: {responseBody}",
(int)response.StatusCode, (int)response.StatusCode >= 500);
}
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "HTTP request failed");
return ScrobbleResult.CreateError($"HTTP error: {ex.Message}", shouldRetry: true);
}
}
#endregion
}
@@ -0,0 +1,261 @@
using System.Text.Json;
using allstarr.Models.Scrobbling;
using allstarr.Services.Jellyfin;
using allstarr.Services.Local;
namespace allstarr.Services.Scrobbling;
/// <summary>
/// Helper methods for extracting scrobble track information from Jellyfin items.
/// </summary>
public class ScrobblingHelper
{
private readonly JellyfinProxyService _proxyService;
private readonly ILocalLibraryService _localLibraryService;
private readonly ILogger<ScrobblingHelper> _logger;
public ScrobblingHelper(
JellyfinProxyService proxyService,
ILocalLibraryService localLibraryService,
ILogger<ScrobblingHelper> logger)
{
_proxyService = proxyService;
_localLibraryService = localLibraryService;
_logger = logger;
}
/// <summary>
/// Extracts scrobble track information from a Jellyfin item.
/// Fetches item details from Jellyfin if needed.
/// </summary>
public async Task<ScrobbleTrack?> GetScrobbleTrackFromItemIdAsync(
string itemId,
Microsoft.AspNetCore.Http.IHeaderDictionary headers,
CancellationToken cancellationToken = default)
{
try
{
// Fetch item details from Jellyfin
var (itemResult, statusCode) = await _proxyService.GetJsonAsync($"Items/{itemId}", null, headers);
if (itemResult == null || statusCode != 200)
{
_logger.LogWarning("Failed to fetch item details for scrobbling: {ItemId} (status: {StatusCode})",
itemId, statusCode);
return null;
}
return ExtractScrobbleTrackFromJson(itemResult);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching item for scrobbling: {ItemId}", itemId);
return null;
}
}
/// <summary>
/// Extracts scrobble track information from a Jellyfin JSON item.
/// </summary>
public ScrobbleTrack? ExtractScrobbleTrackFromJson(JsonDocument itemJson)
{
try
{
var item = itemJson.RootElement;
// Extract required fields
var title = item.TryGetProperty("Name", out var nameProp) ? nameProp.GetString() : null;
var artist = ExtractArtist(item);
if (string.IsNullOrEmpty(title) || string.IsNullOrEmpty(artist))
{
_logger.LogDebug("Cannot create scrobble track - missing title or artist");
return null;
}
// Extract optional fields
var album = item.TryGetProperty("Album", out var albumProp) ? albumProp.GetString() : null;
var albumArtist = ExtractAlbumArtist(item);
var durationSeconds = ExtractDurationSeconds(item);
var musicBrainzId = ExtractMusicBrainzId(item);
// Determine if track is external by checking the Path
var isExternal = false;
if (item.TryGetProperty("Path", out var pathProp))
{
var path = pathProp.GetString();
if (!string.IsNullOrEmpty(path))
{
// External tracks have paths like: ext-deezer-song-123456
// or are in the "kept" directory for favorited external tracks
isExternal = path.StartsWith("ext-") || path.Contains("/kept/") || path.Contains("\\kept\\");
}
}
return new ScrobbleTrack
{
Title = title,
Artist = artist,
Album = album,
AlbumArtist = albumArtist,
DurationSeconds = durationSeconds,
MusicBrainzId = musicBrainzId,
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
IsExternal = isExternal
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error extracting scrobble track from JSON");
return null;
}
}
/// <summary>
/// Creates a scrobble track from external track metadata.
/// </summary>
public ScrobbleTrack? CreateScrobbleTrackFromExternal(
string title,
string artist,
string? album = null,
string? albumArtist = null,
int? durationSeconds = null)
{
if (string.IsNullOrEmpty(title) || string.IsNullOrEmpty(artist))
{
return null;
}
return new ScrobbleTrack
{
Title = title,
Artist = artist,
Album = album,
AlbumArtist = albumArtist,
DurationSeconds = durationSeconds,
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
IsExternal = true // Explicitly mark as external
};
}
#region Private Helper Methods
/// <summary>
/// Checks if a track is long enough to be scrobbled according to Last.fm rules.
/// Tracks must be at least 30 seconds long.
/// </summary>
public static bool IsTrackLongEnoughToScrobble(int durationSeconds)
{
return durationSeconds >= 30;
}
/// <summary>
/// Checks if enough of the track has been listened to for scrobbling.
/// Last.fm rules: Must listen to at least 50% of track OR 4 minutes (whichever comes first).
/// </summary>
public static bool HasListenedEnoughToScrobble(int trackDurationSeconds, int playedSeconds)
{
var halfDuration = trackDurationSeconds / 2.0;
var fourMinutes = 240;
var threshold = Math.Min(halfDuration, fourMinutes);
return playedSeconds >= threshold;
}
/// <summary>
/// Checks if a track has the minimum required metadata for scrobbling.
/// Requires at minimum: track title and artist name.
/// </summary>
public static bool HasRequiredMetadata(string? title, string? artist)
{
return !string.IsNullOrWhiteSpace(title) && !string.IsNullOrWhiteSpace(artist);
}
/// <summary>
/// Formats a track for display in logs.
/// </summary>
public static string FormatTrackForDisplay(string title, string artist)
{
return $"{title} - {artist}";
}
/// <summary>
/// Extracts artist name from Jellyfin item.
/// Tries Artists array first, then AlbumArtist, then ArtistItems.
/// </summary>
private string? ExtractArtist(JsonElement item)
{
// Try Artists array (most common)
if (item.TryGetProperty("Artists", out var artistsProp) && artistsProp.ValueKind == JsonValueKind.Array)
{
var firstArtist = artistsProp.EnumerateArray().FirstOrDefault();
if (firstArtist.ValueKind == JsonValueKind.String)
{
return firstArtist.GetString();
}
}
// Try AlbumArtist
if (item.TryGetProperty("AlbumArtist", out var albumArtistProp))
{
return albumArtistProp.GetString();
}
// Try ArtistItems array
if (item.TryGetProperty("ArtistItems", out var artistItemsProp) && artistItemsProp.ValueKind == JsonValueKind.Array)
{
var firstArtistItem = artistItemsProp.EnumerateArray().FirstOrDefault();
if (firstArtistItem.TryGetProperty("Name", out var artistNameProp))
{
return artistNameProp.GetString();
}
}
return null;
}
/// <summary>
/// Extracts album artist from Jellyfin item.
/// </summary>
private string? ExtractAlbumArtist(JsonElement item)
{
if (item.TryGetProperty("AlbumArtist", out var albumArtistProp))
{
return albumArtistProp.GetString();
}
// Fall back to first artist
return ExtractArtist(item);
}
/// <summary>
/// Extracts track duration in seconds from Jellyfin item.
/// </summary>
private int? ExtractDurationSeconds(JsonElement item)
{
if (item.TryGetProperty("RunTimeTicks", out var ticksProp))
{
var ticks = ticksProp.GetInt64();
return (int)(ticks / TimeSpan.TicksPerSecond);
}
return null;
}
/// <summary>
/// Extracts MusicBrainz Track ID from Jellyfin item.
/// </summary>
private string? ExtractMusicBrainzId(JsonElement item)
{
if (item.TryGetProperty("ProviderIds", out var providerIdsProp))
{
if (providerIdsProp.TryGetProperty("MusicBrainzTrack", out var mbidProp))
{
return mbidProp.GetString();
}
}
return null;
}
#endregion
}
@@ -0,0 +1,338 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Options;
using allstarr.Models.Scrobbling;
using allstarr.Models.Settings;
namespace allstarr.Services.Scrobbling;
/// <summary>
/// Orchestrates scrobbling across multiple services (Last.fm, ListenBrainz, etc.).
/// Manages playback sessions and determines when to scrobble based on listening rules.
/// </summary>
public class ScrobblingOrchestrator
{
private readonly IEnumerable<IScrobblingService> _scrobblingServices;
private readonly ScrobblingSettings _settings;
private readonly ILogger<ScrobblingOrchestrator> _logger;
private readonly ConcurrentDictionary<string, PlaybackSession> _sessions = new();
private readonly Timer _cleanupTimer;
public ScrobblingOrchestrator(
IEnumerable<IScrobblingService> scrobblingServices,
IOptions<ScrobblingSettings> settings,
ILogger<ScrobblingOrchestrator> logger)
{
_scrobblingServices = scrobblingServices;
_settings = settings.Value;
_logger = logger;
// Clean up stale sessions every 5 minutes
_cleanupTimer = new Timer(CleanupStaleSessions, null, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5));
var enabledServices = _scrobblingServices.Where(s => s.IsEnabled).Select(s => s.ServiceName).ToList();
if (enabledServices.Any())
{
_logger.LogInformation("🎵 Scrobbling orchestrator initialized with services: {Services}",
string.Join(", ", enabledServices));
}
else
{
_logger.LogInformation("Scrobbling orchestrator initialized (no services enabled)");
}
}
/// <summary>
/// Handles playback start - sends "Now Playing" to all enabled services.
/// </summary>
public async Task OnPlaybackStartAsync(string deviceId, ScrobbleTrack track)
{
if (!_settings.Enabled)
return;
var sessionId = $"{deviceId}:{track.Artist}:{track.Title}:{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}";
var session = new PlaybackSession
{
SessionId = sessionId,
DeviceId = deviceId,
Track = track with { Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds() },
StartTime = DateTime.UtcNow,
LastPositionSeconds = 0,
LastActivity = DateTime.UtcNow
};
_sessions[sessionId] = session;
_logger.LogDebug("🎵 Playback started: {Artist} - {Track} (session: {SessionId})",
track.Artist, track.Title, sessionId);
// Send "Now Playing" to all enabled services
await SendNowPlayingAsync(session);
}
/// <summary>
/// Handles playback progress - checks if track should be scrobbled.
/// </summary>
public async Task OnPlaybackProgressAsync(string deviceId, string artist, string title, int positionSeconds)
{
if (!_settings.Enabled)
return;
// Find the session for this track
var session = _sessions.Values.FirstOrDefault(s =>
s.DeviceId == deviceId &&
s.Track.Artist == artist &&
s.Track.Title == title);
if (session == null)
{
_logger.LogDebug("No active session found for progress update: {Artist} - {Track}", artist, title);
return;
}
session.LastPositionSeconds = positionSeconds;
session.LastActivity = DateTime.UtcNow;
// Check if we should scrobble (and haven't already)
if (!session.Scrobbled && session.ShouldScrobble())
{
_logger.LogDebug("✓ Scrobble threshold reached for: {Artist} - {Track} (position: {Position}s)",
artist, title, positionSeconds);
await ScrobbleAsync(session);
}
}
/// <summary>
/// Handles playback stop - final chance to scrobble if threshold was met.
/// </summary>
public async Task OnPlaybackStopAsync(string deviceId, string artist, string title, int positionSeconds)
{
if (!_settings.Enabled)
return;
// Find and remove the session
var session = _sessions.Values.FirstOrDefault(s =>
s.DeviceId == deviceId &&
s.Track.Artist == artist &&
s.Track.Title == title);
if (session == null)
{
_logger.LogDebug("No active session found for stop: {Artist} - {Track}", artist, title);
return;
}
session.LastPositionSeconds = positionSeconds;
// Final check if we should scrobble (and haven't already)
if (!session.Scrobbled && session.ShouldScrobble())
{
_logger.LogDebug("✓ Scrobbling on stop: {Artist} - {Track} (position: {Position}s)",
artist, title, positionSeconds);
await ScrobbleAsync(session);
}
else if (session.Scrobbled)
{
_logger.LogDebug("Track already scrobbled during playback: {Artist} - {Track}", artist, title);
}
else
{
_logger.LogDebug("Track not scrobbled (threshold not met): {Artist} - {Track} (position: {Position}s, duration: {Duration}s)",
artist, title, positionSeconds, session.Track.DurationSeconds);
}
// Remove session
_sessions.TryRemove(session.SessionId, out _);
}
/// <summary>
/// Sends "Now Playing" to all enabled services with retry logic.
/// </summary>
private async Task SendNowPlayingAsync(PlaybackSession session)
{
if (session.NowPlayingSent)
return;
var tasks = _scrobblingServices
.Where(s => s.IsEnabled)
.Select(async service =>
{
const int maxRetries = 3;
var retryDelays = new[] { TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(5) };
for (int attempt = 0; attempt < maxRetries; attempt++)
{
try
{
var result = await service.UpdateNowPlayingAsync(session.Track);
if (result.Success)
{
_logger.LogInformation("✓ Now Playing sent to {Service}: {Artist} - {Track}",
service.ServiceName, session.Track.Artist, session.Track.Title);
return; // Success, exit retry loop
}
else if (result.Ignored)
{
return; // Ignored, don't retry
}
else if (result.ShouldRetry && attempt < maxRetries - 1)
{
_logger.LogWarning("⚠️ Now Playing failed for {Service}: {Error} - Retrying in {Delay}s (attempt {Attempt}/{Max})",
service.ServiceName, result.ErrorMessage, retryDelays[attempt].TotalSeconds, attempt + 1, maxRetries);
await Task.Delay(retryDelays[attempt]);
}
else
{
_logger.LogWarning("⚠️ Now Playing failed for {Service}: {Error}",
service.ServiceName, result.ErrorMessage);
return; // Don't retry or max retries reached
}
}
catch (Exception ex)
{
if (attempt < maxRetries - 1)
{
_logger.LogWarning(ex, "❌ Error sending Now Playing to {Service} - Retrying in {Delay}s (attempt {Attempt}/{Max})",
service.ServiceName, retryDelays[attempt].TotalSeconds, attempt + 1, maxRetries);
await Task.Delay(retryDelays[attempt]);
}
else
{
_logger.LogError(ex, "❌ Error sending Now Playing to {Service} after {Max} attempts",
service.ServiceName, maxRetries);
}
}
}
});
await Task.WhenAll(tasks);
session.NowPlayingSent = true;
}
/// <summary>
/// Scrobbles a track to all enabled services with retry logic.
/// Only retries on failure - prevents double scrobbling.
/// </summary>
private async Task ScrobbleAsync(PlaybackSession session)
{
if (session.Scrobbled)
{
_logger.LogDebug("Track already scrobbled, skipping: {Artist} - {Track}",
session.Track.Artist, session.Track.Title);
return;
}
_logger.LogDebug("Scrobbling track to {Count} enabled services: {Artist} - {Track}",
_scrobblingServices.Count(s => s.IsEnabled), session.Track.Artist, session.Track.Title);
var tasks = _scrobblingServices
.Where(s => s.IsEnabled)
.Select(async service =>
{
const int maxRetries = 3;
var retryDelays = new[] { TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10) };
for (int attempt = 0; attempt < maxRetries; attempt++)
{
try
{
var result = await service.ScrobbleAsync(session.Track);
if (result.Success && !result.Ignored)
{
_logger.LogInformation("✓ Scrobbled to {Service}: {Artist} - {Track}",
service.ServiceName, session.Track.Artist, session.Track.Title);
return; // Success, exit retry loop - prevents double scrobbling
}
else if (result.Ignored)
{
_logger.LogDebug("⊘ Scrobble skipped by {Service}: {Reason}",
service.ServiceName, result.IgnoredReason);
return; // Ignored, don't retry
}
else if (result.ShouldRetry && attempt < maxRetries - 1)
{
_logger.LogWarning("❌ Scrobble failed for {Service}: {Error} - Retrying in {Delay}s (attempt {Attempt}/{Max})",
service.ServiceName, result.ErrorMessage, retryDelays[attempt].TotalSeconds, attempt + 1, maxRetries);
await Task.Delay(retryDelays[attempt]);
}
else
{
_logger.LogError("❌ Scrobble failed for {Service}: {Error} - No more retries",
service.ServiceName, result.ErrorMessage);
return; // Don't retry or max retries reached
}
}
catch (Exception ex)
{
if (attempt < maxRetries - 1)
{
_logger.LogWarning(ex, "❌ Error scrobbling to {Service} - Retrying in {Delay}s (attempt {Attempt}/{Max})",
service.ServiceName, retryDelays[attempt].TotalSeconds, attempt + 1, maxRetries);
await Task.Delay(retryDelays[attempt]);
}
else
{
_logger.LogError(ex, "❌ Error scrobbling to {Service} after {Max} attempts",
service.ServiceName, maxRetries);
}
}
}
});
await Task.WhenAll(tasks);
session.Scrobbled = true;
_logger.LogDebug("Marked session as scrobbled: {SessionId}", session.SessionId);
}
/// <summary>
/// Cleans up stale sessions (inactive for more than 10 minutes).
/// </summary>
private void CleanupStaleSessions(object? state)
{
var now = DateTime.UtcNow;
var staleThreshold = TimeSpan.FromMinutes(10);
var staleSessions = _sessions.Where(kvp => now - kvp.Value.LastActivity > staleThreshold).ToList();
foreach (var stale in staleSessions)
{
_logger.LogDebug("🧹 Removing stale scrobbling session: {SessionId}", stale.Key);
_sessions.TryRemove(stale.Key, out _);
}
if (staleSessions.Any())
{
_logger.LogDebug("Cleaned up {Count} stale scrobbling sessions", staleSessions.Count);
}
}
/// <summary>
/// Gets information about active scrobbling sessions (for debugging).
/// </summary>
public object GetSessionsInfo()
{
var now = DateTime.UtcNow;
var sessions = _sessions.Values.Select(s => new
{
SessionId = s.SessionId,
DeviceId = s.DeviceId,
Artist = s.Track.Artist,
Track = s.Track.Title,
Duration = s.Track.DurationSeconds,
Position = s.LastPositionSeconds,
StartTime = s.StartTime,
ElapsedMinutes = Math.Round((now - s.StartTime).TotalMinutes, 1),
NowPlayingSent = s.NowPlayingSent,
Scrobbled = s.Scrobbled,
ShouldScrobble = s.ShouldScrobble()
}).ToList();
return new
{
TotalSessions = sessions.Count,
ScrobbledSessions = sessions.Count(s => s.Scrobbled),
PendingSessions = sessions.Count(s => !s.Scrobbled),
Sessions = sessions.OrderByDescending(s => s.StartTime)
};
}
}
@@ -85,130 +85,131 @@ public class SquidWTFMetadataService : IMusicMetadataService
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20)
{
// Use round-robin to distribute load across endpoints (allows parallel processing of multiple tracks)
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
// Use 's' parameter for track search as per hifi-api spec
var url = $"{baseUrl}/search/?s={Uri.EscapeDataString(query)}";
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
// Race top 3 fastest endpoints for search (latency-sensitive)
return await _fallbackHelper.RaceTopEndpointsAsync(3, async (baseUrl, ct) =>
{
throw new HttpRequestException($"HTTP {response.StatusCode}");
}
var json = await response.Content.ReadAsStringAsync();
// Check for error in response body
var result = JsonDocument.Parse(json);
if (result.RootElement.TryGetProperty("detail", out _) ||
result.RootElement.TryGetProperty("error", out _))
{
throw new HttpRequestException("API returned error response");
}
var songs = new List<Song>();
// Per hifi-api spec: track search returns data.items array
if (result.RootElement.TryGetProperty("data", out var data) &&
data.TryGetProperty("items", out var items))
{
int count = 0;
foreach (var track in items.EnumerateArray())
// Use 's' parameter for track search as per hifi-api spec
var url = $"{baseUrl}/search/?s={Uri.EscapeDataString(query)}";
var response = await _httpClient.GetAsync(url, ct);
if (!response.IsSuccessStatusCode)
{
if (count >= limit) break;
var song = ParseTidalTrack(track);
if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter))
{
songs.Add(song);
}
count++;
throw new HttpRequestException($"HTTP {response.StatusCode}");
}
}
return songs;
});
}
var json = await response.Content.ReadAsStringAsync();
// Check for error in response body
var result = JsonDocument.Parse(json);
if (result.RootElement.TryGetProperty("detail", out _) ||
result.RootElement.TryGetProperty("error", out _))
{
throw new HttpRequestException("API returned error response");
}
var songs = new List<Song>();
// Per hifi-api spec: track search returns data.items array
if (result.RootElement.TryGetProperty("data", out var data) &&
data.TryGetProperty("items", out var items))
{
int count = 0;
foreach (var track in items.EnumerateArray())
{
if (count >= limit) break;
var song = ParseTidalTrack(track);
if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter))
{
songs.Add(song);
}
count++;
}
}
return songs;
});
}
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20)
{
// Use round-robin to distribute load across endpoints (allows parallel processing)
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
// Note: hifi-api doesn't document album search, but 'al' parameter is commonly used
var url = $"{baseUrl}/search/?al={Uri.EscapeDataString(query)}";
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
// Race top 3 fastest endpoints for search (latency-sensitive)
return await _fallbackHelper.RaceTopEndpointsAsync(3, async (baseUrl, ct) =>
{
throw new HttpRequestException($"HTTP {response.StatusCode}");
}
var json = await response.Content.ReadAsStringAsync();
var result = JsonDocument.Parse(json);
var albums = new List<Album>();
// Per hifi-api spec: album search returns data.albums.items array
if (result.RootElement.TryGetProperty("data", out var data) &&
data.TryGetProperty("albums", out var albumsObj) &&
albumsObj.TryGetProperty("items", out var items))
{
int count = 0;
foreach (var album in items.EnumerateArray())
// Use 'al' parameter for album search
// a= is for artists, al= is for albums, p= is for playlists
var url = $"{baseUrl}/search/?al={Uri.EscapeDataString(query)}";
var response = await _httpClient.GetAsync(url, ct);
if (!response.IsSuccessStatusCode)
{
if (count >= limit) break;
albums.Add(ParseTidalAlbum(album));
count++;
throw new HttpRequestException($"HTTP {response.StatusCode}");
}
}
return albums;
});
}
var json = await response.Content.ReadAsStringAsync();
var result = JsonDocument.Parse(json);
var albums = new List<Album>();
// Per hifi-api spec: album search returns data.albums.items array
if (result.RootElement.TryGetProperty("data", out var data) &&
data.TryGetProperty("albums", out var albumsObj) &&
albumsObj.TryGetProperty("items", out var items))
{
int count = 0;
foreach (var album in items.EnumerateArray())
{
if (count >= limit) break;
albums.Add(ParseTidalAlbum(album));
count++;
}
}
return albums;
});
}
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20)
{
// Use round-robin to distribute load across endpoints (allows parallel processing)
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
// Per hifi-api spec: use 'a' parameter for artist search
var url = $"{baseUrl}/search/?a={Uri.EscapeDataString(query)}";
_logger.LogDebug("🔍 SQUIDWTF: Searching artists with URL: {Url}", url);
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
// Race top 3 fastest endpoints for search (latency-sensitive)
return await _fallbackHelper.RaceTopEndpointsAsync(3, async (baseUrl, ct) =>
{
_logger.LogWarning("⚠️ SQUIDWTF: Artist search failed with status {StatusCode}", response.StatusCode);
throw new HttpRequestException($"HTTP {response.StatusCode}");
}
var json = await response.Content.ReadAsStringAsync();
var result = JsonDocument.Parse(json);
var artists = new List<Artist>();
// Per hifi-api spec: artist search returns data.artists.items array
if (result.RootElement.TryGetProperty("data", out var data) &&
data.TryGetProperty("artists", out var artistsObj) &&
artistsObj.TryGetProperty("items", out var items))
{
int count = 0;
foreach (var artist in items.EnumerateArray())
{
if (count >= limit) break;
var parsedArtist = ParseTidalArtist(artist);
artists.Add(parsedArtist);
_logger.LogDebug("🎤 SQUIDWTF: Found artist: {Name} (ID: {Id})", parsedArtist.Name, parsedArtist.ExternalId);
count++;
}
}
// Per hifi-api spec: use 'a' parameter for artist search
var url = $"{baseUrl}/search/?a={Uri.EscapeDataString(query)}";
_logger.LogDebug("🔍 SQUIDWTF: Searching artists with URL: {Url}", url);
_logger.LogInformation("✓ SQUIDWTF: Artist search returned {Count} results", artists.Count);
return artists;
});
}
var response = await _httpClient.GetAsync(url, ct);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("⚠️ SQUIDWTF: Artist search failed with status {StatusCode}", response.StatusCode);
throw new HttpRequestException($"HTTP {response.StatusCode}");
}
var json = await response.Content.ReadAsStringAsync();
var result = JsonDocument.Parse(json);
var artists = new List<Artist>();
// Per hifi-api spec: artist search returns data.artists.items array
if (result.RootElement.TryGetProperty("data", out var data) &&
data.TryGetProperty("artists", out var artistsObj) &&
artistsObj.TryGetProperty("items", out var items))
{
int count = 0;
foreach (var artist in items.EnumerateArray())
{
if (count >= limit) break;
var parsedArtist = ParseTidalArtist(artist);
artists.Add(parsedArtist);
_logger.LogDebug("🎤 SQUIDWTF: Found artist: {Name} (ID: {Id})", parsedArtist.Name, parsedArtist.ExternalId);
count++;
}
}
_logger.LogInformation("✓ SQUIDWTF: Artist search returned {Count} results", artists.Count);
return artists;
});
}
public async Task<List<ExternalPlaylist>> SearchPlaylistsAsync(string query, int limit = 20)
{
@@ -500,26 +501,30 @@ public class SquidWTFMetadataService : IMusicMetadataService
}
public async Task<ExternalPlaylist?> GetPlaylistAsync(string externalProvider, string externalId)
{
if (externalProvider != "squidwtf") return null;
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
// Note: hifi-api doesn't document playlist endpoint, but /playlist/?id={playlistId} is commonly used
var url = $"{baseUrl}/playlist/?id={externalId}";
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode) return null;
var json = await response.Content.ReadAsStringAsync();
var playlistElement = JsonDocument.Parse(json).RootElement;
// Check for error response
if (playlistElement.TryGetProperty("error", out _)) return null;
// Response structure: { "playlist": { playlist object }, "items": [ track wrappers ] }
return ParseTidalPlaylist(playlistElement);
}, (ExternalPlaylist?)null);
}
{
if (externalProvider != "squidwtf") return null;
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
// Note: hifi-api doesn't document playlist endpoint, but /playlist/?id={playlistId} is commonly used
var url = $"{baseUrl}/playlist/?id={externalId}";
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode) return null;
var json = await response.Content.ReadAsStringAsync();
var rootElement = JsonDocument.Parse(json).RootElement;
// Check for error response
if (rootElement.TryGetProperty("error", out _)) return null;
// Response structure: { "playlist": { playlist object }, "items": [ track wrappers ] }
// Extract the playlist object from the response
if (!rootElement.TryGetProperty("playlist", out var playlistElement))
return null;
return ParseTidalPlaylist(playlistElement);
}, (ExternalPlaylist?)null);
}
public async Task<List<Song>> GetPlaylistTracksAsync(string externalProvider, string externalId)
{
@@ -574,6 +579,10 @@ public class SquidWTFMetadataService : IMusicMetadataService
// Override album name to be the playlist name
song.Album = playlistName;
// Playlists should not have disc numbers - always set to null
// This prevents Jellyfin from splitting the playlist into multiple "discs"
song.DiscNumber = null;
if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter))
{
songs.Add(song);
@@ -923,76 +932,87 @@ public class SquidWTFMetadataService : IMusicMetadataService
/// <param name="playlistElement">Root JSON element containing playlist and items</param>
/// <returns>Parsed ExternalPlaylist object</returns>
private ExternalPlaylist ParseTidalPlaylist(JsonElement playlistElement)
{
JsonElement? playlist = null;
JsonElement? tracks = null;
{
// The playlistElement IS the playlist data directly from the API
// No need to look for a "playlist" property wrapper
if (playlistElement.TryGetProperty("playlist", out var playlistEl))
{
playlist = playlistEl;
}
if (playlistElement.TryGetProperty("items", out var tracksEl))
{
tracks = tracksEl;
}
if (!playlist.HasValue)
{
throw new InvalidOperationException("Playlist data is missing");
}
var externalId = playlist.Value.GetProperty("uuid").GetString()!;
// Get curator/creator name
string? curatorName = null;
if (playlist.Value.TryGetProperty("creator", out var creator) &&
creator.TryGetProperty("id", out var id))
{
curatorName = id.GetString();
}
// Get creation date
DateTime? createdDate = null;
if (playlist.Value.TryGetProperty("created", out var creationDateEl))
{
var dateStr = creationDateEl.GetString();
if (!string.IsNullOrEmpty(dateStr) && DateTime.TryParse(dateStr, out var date))
var externalId = playlistElement.GetProperty("uuid").GetString()!;
// Get curator/creator name
string? curatorName = null;
if (playlistElement.TryGetProperty("creator", out var creator))
{
createdDate = date;
// Try to get the name first, fall back to id if name doesn't exist
if (creator.TryGetProperty("name", out var name))
{
curatorName = name.GetString();
}
else if (creator.TryGetProperty("id", out var id))
{
// Handle both string and number types for creator.id
var idValue = id.ValueKind == JsonValueKind.Number
? id.GetInt32().ToString()
: id.GetString();
// If creator ID is 0 or empty, it's a TIDAL-curated playlist
if (idValue == "0" || string.IsNullOrEmpty(idValue))
{
curatorName = "TIDAL";
}
else
{
curatorName = idValue;
}
}
}
// Final fallback: if still no curator name, use TIDAL
if (string.IsNullOrEmpty(curatorName))
{
curatorName = "TIDAL";
}
}
// Get playlist image URL
string? imageUrl = null;
if (playlist.Value.TryGetProperty("squareImage", out var picture))
{
var pictureGuid = picture.GetString()?.Replace("-", "/");
imageUrl = $"https://resources.tidal.com/images/{pictureGuid}/1080x1080.jpg";
// Maybe later add support for potentential fallbacks if this size isn't available
}
return new ExternalPlaylist
{
Id = Common.PlaylistIdHelper.CreatePlaylistId("squidwtf", externalId),
Name = playlist.Value.GetProperty("title").GetString() ?? "",
Description = playlist.Value.TryGetProperty("description", out var desc)
? desc.GetString()
: null,
CuratorName = curatorName,
Provider = "squidwtf",
ExternalId = externalId,
TrackCount = playlist.Value.TryGetProperty("numberOfTracks", out var nbTracks)
? nbTracks.GetInt32()
: 0,
Duration = playlist.Value.TryGetProperty("duration", out var duration)
? duration.GetInt32()
: 0,
CoverUrl = imageUrl,
CreatedDate = createdDate
};
}
// Get creation date
DateTime? createdDate = null;
if (playlistElement.TryGetProperty("created", out var creationDateEl))
{
var dateStr = creationDateEl.GetString();
if (!string.IsNullOrEmpty(dateStr) && DateTime.TryParse(dateStr, out var date))
{
createdDate = date;
}
}
// Get playlist image URL
string? imageUrl = null;
if (playlistElement.TryGetProperty("squareImage", out var picture))
{
var pictureGuid = picture.GetString()?.Replace("-", "/");
imageUrl = $"https://resources.tidal.com/images/{pictureGuid}/1080x1080.jpg";
// Maybe later add support for potential fallbacks if this size isn't available
}
return new ExternalPlaylist
{
Id = Common.PlaylistIdHelper.CreatePlaylistId("squidwtf", externalId),
Name = playlistElement.GetProperty("title").GetString() ?? "",
Description = playlistElement.TryGetProperty("description", out var desc)
? desc.GetString()
: null,
CuratorName = curatorName,
Provider = "squidwtf",
ExternalId = externalId,
TrackCount = playlistElement.TryGetProperty("numberOfTracks", out var nbTracks)
? nbTracks.GetInt32()
: 0,
Duration = playlistElement.TryGetProperty("duration", out var duration)
? duration.GetInt32()
: 0,
CoverUrl = imageUrl,
CreatedDate = createdDate
};
}
/// <summary>
/// Determines whether a song should be included based on the explicit content filter setting
@@ -1023,4 +1043,56 @@ public class SquidWTFMetadataService : IMusicMetadataService
};
}
}
/// <summary>
/// Searches for multiple songs in parallel across all available endpoints.
/// Each endpoint processes songs sequentially. Failed endpoints are blacklisted.
/// </summary>
public async Task<List<Song?>> SearchSongsInParallelAsync(List<string> queries, int limit = 10, CancellationToken cancellationToken = default)
{
return await _fallbackHelper.ProcessInParallelAsync(
queries,
async (baseUrl, query, ct) =>
{
try
{
var url = $"{baseUrl}/search/?s={Uri.EscapeDataString(query)}";
var response = await _httpClient.GetAsync(url, ct);
if (!response.IsSuccessStatusCode)
{
return null;
}
var json = await response.Content.ReadAsStringAsync();
var result = JsonDocument.Parse(json);
if (result.RootElement.TryGetProperty("detail", out _) ||
result.RootElement.TryGetProperty("error", out _))
{
return null;
}
if (result.RootElement.TryGetProperty("data", out var data) &&
data.TryGetProperty("items", out var items))
{
foreach (var track in items.EnumerateArray())
{
var song = ParseTidalTrack(track);
if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter))
{
return song; // Return first matching song
}
}
}
return null;
}
catch
{
throw; // Let the parallel processor handle blacklisting
}
},
cancellationToken);
}
}
@@ -85,13 +85,34 @@ public class SquidWTFStartupValidator : BaseStartupValidator
return false;
}
},
pingCount: 2,
pingCount: 5,
cancellationToken);
if (orderedEndpoints.Count > 0)
{
_fallbackHelper.SetEndpointOrder(orderedEndpoints);
WriteDetail($"Fastest endpoint: {orderedEndpoints.First()}");
// Show top 5 endpoints with their metrics
var topEndpoints = orderedEndpoints.Take(5).ToList();
WriteDetail($"Fastest endpoint: {topEndpoints.First()}");
if (topEndpoints.Count > 1)
{
WriteDetail("Top 5 endpoints by average latency:");
for (int i = 0; i < topEndpoints.Count; i++)
{
var endpoint = topEndpoints[i];
var metrics = _benchmarkService.GetMetrics(endpoint);
if (metrics != null)
{
WriteDetail($" {i + 1}. {endpoint} - {metrics.AverageResponseMs}ms avg ({metrics.SuccessRate:P0} success)");
}
else
{
WriteDetail($" {i + 1}. {endpoint}");
}
}
}
}
}
@@ -225,7 +225,7 @@ public class SubsonicModelMapper
/// <summary>
/// Converts an ExternalPlaylist to a JSON object representing an album.
/// Playlists are represented as albums with genre "Playlist" and artist "🎵 {Provider} {Curator}".
/// Playlists are represented as albums with aggregated genres from tracks and artist "🎵 {Provider} {Curator}".
/// </summary>
private Dictionary<string, object> ConvertPlaylistToAlbumJson(ExternalPlaylist playlist)
{
@@ -243,7 +243,7 @@ public class SubsonicModelMapper
["name"] = playlist.Name,
["artist"] = artistName,
["artistId"] = artistId,
["genre"] = "Playlist",
["genre"] = "Playlist", // Note: This is metadata-only, actual tracks will have their own genres
["songCount"] = playlist.TrackCount,
["duration"] = playlist.Duration
};
@@ -264,7 +264,7 @@ public class SubsonicModelMapper
/// <summary>
/// Converts an ExternalPlaylist to an XML element representing an album.
/// Playlists are represented as albums with genre "Playlist" and artist "🎵 {Provider} {Curator}".
/// Playlists are represented as albums with aggregated genres from tracks and artist "🎵 {Provider} {Curator}".
/// </summary>
private XElement ConvertPlaylistToAlbumXml(ExternalPlaylist playlist, XNamespace ns)
{
@@ -281,7 +281,7 @@ public class SubsonicModelMapper
new XAttribute("name", playlist.Name),
new XAttribute("artist", artistName),
new XAttribute("artistId", artistId),
new XAttribute("genre", "Playlist"),
new XAttribute("genre", "Playlist"), // Note: This is metadata-only, actual tracks will have their own genres
new XAttribute("songCount", playlist.TrackCount),
new XAttribute("duration", playlist.Duration)
);
@@ -156,6 +156,14 @@ public class SubsonicResponseBuilder
var artistId = $"curator-{playlist.Provider}-{playlist.CuratorName?.ToLowerInvariant().Replace(" ", "-") ?? "unknown"}";
// Aggregate unique genres from all tracks
var genres = tracks
.Where(s => !string.IsNullOrEmpty(s.Genre))
.Select(s => s.Genre!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var genreString = genres.Count > 0 ? string.Join(", ", genres) : "Playlist";
if (format == "json")
{
return CreateJsonResponse(new
@@ -172,7 +180,7 @@ public class SubsonicResponseBuilder
songCount = tracks.Count,
duration = totalDuration,
year = playlist.CreatedDate?.Year ?? 0,
genre = "Playlist",
genre = genreString,
isCompilation = false,
created = playlist.CreatedDate?.ToString("yyyy-MM-ddTHH:mm:ss"),
song = tracks.Select(s => ConvertSongToJson(s)).ToList()
@@ -188,7 +196,7 @@ public class SubsonicResponseBuilder
new XAttribute("artistId", artistId),
new XAttribute("songCount", tracks.Count),
new XAttribute("duration", totalDuration),
new XAttribute("genre", "Playlist"),
new XAttribute("genre", genreString),
new XAttribute("coverArt", playlist.Id)
);
+3
View File
@@ -7,6 +7,9 @@
"System.Net.Http.HttpClient.Default.ClientHandler": "Warning"
}
},
"Debug": {
"LogAllRequests": false
},
"Backend": {
"Type": "Subsonic"
},
+134 -8
View File
@@ -34,6 +34,8 @@
<div class="tab active" data-tab="dashboard">Dashboard</div>
<div class="tab" data-tab="jellyfin-playlists">Link Playlists</div>
<div class="tab" data-tab="playlists">Injected Playlists</div>
<div class="tab" data-tab="kept">Kept Downloads</div>
<div class="tab" data-tab="scrobbling">Scrobbling</div>
<div class="tab" data-tab="config">Configuration</div>
<div class="tab" data-tab="endpoints">API Analytics</div>
</div>
@@ -153,6 +155,21 @@
or mappings!
</div>
<div class="card">
<h2>Playlist Injection Settings</h2>
<div style="background: rgba(59, 130, 246, 0.15); border: 1px solid var(--primary); border-radius: 6px; padding: 12px; margin-bottom: 16px; color: var(--text-secondary); font-size: 0.9rem;">
️ Music Library ID is required for injecting external playlists into Jellyfin. This tells Allstarr which library to inject playlists into. Get it from your Jellyfin library URL.
</div>
<div class="config-section">
<div class="config-item">
<span class="label">Music Library ID <span style="color: var(--error);">*</span></span>
<span class="value" id="config-jellyfin-library-id">-</span>
<button
onclick="openEditSetting('JELLYFIN_LIBRARY_ID', 'Music Library ID', 'text', 'Required for playlist injection. Get from Jellyfin music library URL.')">Edit</button>
</div>
</div>
</div>
<div class="card">
<h2>
Injected Spotify Playlists
@@ -278,17 +295,20 @@
</tbody>
</table>
</div>
</div>
<!-- Kept Downloads Section -->
<!-- Kept Downloads Tab -->
<div class="tab-content" id="tab-kept">
<div class="card">
<h2>
Kept Downloads
<div class="actions">
<button onclick="downloadAllKept()" style="background:var(--accent);border-color:var(--accent);">Download All</button>
<button onclick="fetchDownloads()">Refresh</button>
</div>
</h2>
<p style="color: var(--text-secondary); margin-bottom: 12px;">
Downloaded files stored permanently. Download or delete individual tracks.
Downloaded files stored permanently. Download individual tracks or download all as a zip archive.
</p>
<div id="downloads-summary"
style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
@@ -324,6 +344,104 @@
</div>
</div>
<!-- Scrobbling Tab -->
<div class="tab-content" id="tab-scrobbling">
<div class="card">
<h2>Scrobbling Configuration</h2>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
Scrobble your listening history to Last.fm and ListenBrainz. Tracks are scrobbled when you listen to at least half the track or 4 minutes (whichever comes first).
</p>
<div class="config-section" style="margin-bottom: 24px;">
<div class="config-item">
<span class="label">Scrobbling Enabled</span>
<span class="value" id="scrobbling-enabled-value">-</span>
<button onclick="toggleScrobblingEnabled()">Toggle</button>
</div>
<div class="config-item">
<span class="label">Local Track Scrobbling</span>
<span class="value" id="local-tracks-enabled-value">-</span>
<button onclick="toggleLocalTracksEnabled()">Toggle</button>
</div>
</div>
<div style="background: rgba(255, 193, 7, 0.15); border: 1px solid #ffc107; border-radius: 6px; padding: 12px; margin-bottom: 16px; color: var(--text-primary);">
ℹ️ <strong>Recommended:</strong> Keep local track scrobbling disabled and use native Jellyfin plugins instead:
<br><a href="https://github.com/danielfariati/jellyfin-plugin-lastfm" target="_blank" style="color: var(--accent);">Last.fm Plugin</a>
<br><a href="https://github.com/lyarenei/jellyfin-plugin-listenbrainz" target="_blank" style="color: var(--accent);">ListenBrainz Plugin</a>
<br>This ensures Allstarr only scrobbles external tracks (Spotify, Deezer, Qobuz).
</div>
</div>
<div class="card">
<h2>Last.fm</h2>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
Scrobble to Last.fm. Enter your Last.fm username and password below, then click "Authenticate & Save" to generate a session key.
</p>
<div class="config-section" style="margin-bottom: 24px;">
<div class="config-item">
<span class="label">Last.fm Enabled</span>
<span class="value" id="lastfm-enabled-value">-</span>
<button onclick="toggleLastFmEnabled()">Toggle</button>
</div>
<div class="config-item">
<span class="label">Username</span>
<span class="value" id="lastfm-username-value">-</span>
<button onclick="editLastFmUsername()">Edit</button>
</div>
<div class="config-item">
<span class="label">Password</span>
<span class="value" id="lastfm-password-value">-</span>
<button onclick="editLastFmPassword()">Edit</button>
</div>
<div class="config-item" style="grid-template-columns: 200px 1fr;">
<span class="label">Session Key</span>
<span class="value" id="lastfm-session-key-value" style="font-family: monospace; font-size: 0.85rem; word-break: break-all;">-</span>
</div>
<div class="config-item" style="grid-template-columns: 200px 1fr;">
<span class="label">Status</span>
<span class="value" id="lastfm-status-value">-</span>
</div>
</div>
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
<button class="primary" onclick="authenticateLastFm()">Authenticate & Save</button>
<button onclick="testLastFmConnection()">Test Connection</button>
</div>
</div>
<div class="card">
<h2>ListenBrainz</h2>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
Scrobble to ListenBrainz. Get your user token from <a href="https://listenbrainz.org/settings/" target="_blank" style="color: var(--accent);">ListenBrainz Settings</a>.
<br><strong>Note:</strong> Only external tracks (Spotify, Deezer, Qobuz) are scrobbled to ListenBrainz. Local library tracks are not scrobbled.
</p>
<div class="config-section" style="margin-bottom: 24px;">
<div class="config-item">
<span class="label">ListenBrainz Enabled</span>
<span class="value" id="listenbrainz-enabled-value">-</span>
<button onclick="toggleListenBrainzEnabled()">Toggle</button>
</div>
<div class="config-item">
<span class="label">User Token</span>
<span class="value" id="listenbrainz-token-value">-</span>
<button onclick="editListenBrainzToken()">Edit</button>
</div>
<div class="config-item" style="grid-template-columns: 200px 1fr;">
<span class="label">Status</span>
<span class="value" id="listenbrainz-status-value">-</span>
</div>
</div>
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
<button class="primary" onclick="validateListenBrainzToken()">Validate & Save Token</button>
<button onclick="testListenBrainzConnection()">Test Connection</button>
</div>
</div>
</div>
<!-- Configuration Tab -->
<div class="tab-content" id="tab-config">
<div class="card">
@@ -385,6 +503,20 @@
</div>
</div>
<div class="card">
<h2>Debug Settings</h2>
<div class="config-section">
<div class="config-item">
<span class="label">Log All Requests</span>
<span class="value" id="config-debug-log-requests">-</span>
<button onclick="openEditSetting('DEBUG_LOG_ALL_REQUESTS', 'Log All Requests', 'toggle', 'Enable detailed logging of every HTTP request (useful for debugging client issues)')">Edit</button>
</div>
<div style="background: rgba(59, 130, 246, 0.15); border: 1px solid var(--primary); border-radius: 6px; padding: 12px; margin-top: 12px; color: var(--text-secondary); font-size: 0.9rem;">
️ When enabled, logs every incoming request with method, path, headers, and response status. Auth tokens are automatically masked for security.
</div>
</div>
</div>
<div class="card">
<h2>Spotify API Settings</h2>
<div
@@ -515,12 +647,6 @@
<button
onclick="openEditSetting('JELLYFIN_USER_ID', 'Jellyfin User ID', 'text', 'Required for playlist operations. Get from Jellyfin user profile URL: userId=...')">Edit</button>
</div>
<div class="config-item">
<span class="label">Library ID</span>
<span class="value" id="config-jellyfin-library-id">-</span>
<button
onclick="openEditSetting('JELLYFIN_LIBRARY_ID', 'Jellyfin Library ID', 'text')">Edit</button>
</div>
</div>
</div>
+4 -3
View File
@@ -321,7 +321,7 @@ export function extractJellyfinId() {
export function validateExternalMapping(externalId, provider) {
if (provider === 'squidwtf') {
if (!/^https?:\/\//.test(externalId)) {
showToast('SquidWTF requires a full URL (e.g., https://squid.wtf/music/...)', 'error');
showToast('SquidWTF requires a full URL from the search results', 'error');
return false;
}
} else if (provider === 'deezer') {
@@ -391,11 +391,12 @@ export async function saveLyricsMapping() {
export async function searchProvider(query, provider) {
try {
const data = await API.getSquidWTFBaseUrl();
const baseUrl = data.squidWtfBaseUrl || 'https://squid.wtf';
const baseUrl = data.baseUrl; // Use the actual property name from API
const searchUrl = `${baseUrl}/music/search?q=${encodeURIComponent(query)}`;
window.open(searchUrl, '_blank');
} catch (error) {
console.error('Failed to get SquidWTF base URL:', error);
window.open(`https://squid.wtf/music/search?q=${encodeURIComponent(query)}`, '_blank');
// Fallback to first encoded URL (triton)
showToast('Failed to get SquidWTF URL, using fallback', 'warning');
}
}
+335 -1
View File
@@ -237,6 +237,16 @@ window.downloadFile = function(path) {
}
};
window.downloadAllKept = function() {
try {
window.open('/api/admin/downloads/all', '_blank');
showToast('Preparing download archive...', 'info');
} catch (error) {
console.error('Failed to download all files:', error);
showToast('Failed to download all files', 'error');
}
};
window.deleteDownload = async function(path) {
if (!confirm(`Delete this file?\n\n${path}\n\nThis action cannot be undone.`)) {
return;
@@ -690,6 +700,8 @@ window.viewTracks = viewTracks;
// Manual mapping functions
window.openManualMap = openManualMap;
window.openExternalMap = openExternalMap;
window.openMapToLocal = openManualMap; // Alias for compatibility
window.openMapToExternal = openExternalMap; // Alias for compatibility
window.searchJellyfinTracks = searchJellyfinTracks;
window.selectJellyfinTrack = selectJellyfinTrack;
window.saveLocalMapping = saveLocalMapping;
@@ -872,6 +884,17 @@ document.addEventListener('DOMContentLoaded', () => {
// Start auto-refresh
startPlaylistAutoRefresh();
// Load scrobbling config immediately on page load
loadScrobblingConfig();
// Also reload when scrobbling tab is clicked
const scrobblingTab = document.querySelector('.tab[data-tab="scrobbling"]');
if (scrobblingTab) {
scrobblingTab.addEventListener('click', function() {
loadScrobblingConfig();
});
}
// Auto-refresh every 30 seconds
setInterval(() => {
window.fetchStatus();
@@ -887,4 +910,315 @@ document.addEventListener('DOMContentLoaded', () => {
}, 30000);
});
console.log('✅ Main.js module loaded');
console.log('✅ Main.js module loaded');
// ===== SCROBBLING FUNCTIONS =====
window.loadScrobblingConfig = async function() {
try {
const response = await fetch('/api/admin/config', {
headers: { 'X-API-Key': localStorage.getItem('apiKey') || '' }
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
// Update scrobbling enabled
document.getElementById('scrobbling-enabled-value').textContent = data.scrobbling.enabled ? 'Enabled' : 'Disabled';
// Update local tracks enabled
document.getElementById('local-tracks-enabled-value').textContent = data.scrobbling.localTracksEnabled ? 'Enabled' : 'Disabled';
// Update Last.fm config
document.getElementById('lastfm-enabled-value').textContent = data.scrobbling.lastFm.enabled ? 'Enabled' : 'Disabled';
// Username - show actual value or "Not Set"
const username = data.scrobbling.lastFm.username;
document.getElementById('lastfm-username-value').textContent = (username && username !== '(not set)') ? username : 'Not Set';
// Password - show if set (masked)
const password = data.scrobbling.lastFm.password;
document.getElementById('lastfm-password-value').textContent = (password && password !== '(not set)') ? '••••••••' : 'Not Set';
// Session key - show first 32 chars if exists
const sessionKey = data.scrobbling.lastFm.sessionKey;
if (sessionKey && sessionKey !== '(not set)' && !sessionKey.startsWith('••••')) {
document.getElementById('lastfm-session-key-value').textContent = sessionKey.substring(0, 32) + '...';
} else if (sessionKey && sessionKey.startsWith('••••')) {
// It's masked, show it as is
document.getElementById('lastfm-session-key-value').textContent = sessionKey;
} else {
document.getElementById('lastfm-session-key-value').textContent = 'Not Set';
}
// Status - check if API Key and Secret are set
const hasApiKey = data.scrobbling.lastFm.apiKey && data.scrobbling.lastFm.apiKey !== '(not set)' && !data.scrobbling.lastFm.apiKey.startsWith('(not set)');
const hasSecret = data.scrobbling.lastFm.sharedSecret && data.scrobbling.lastFm.sharedSecret !== '(not set)' && !data.scrobbling.lastFm.sharedSecret.startsWith('(not set)');
const hasUsername = username && username !== '(not set)';
const hasPassword = password && password !== '(not set)';
const hasSessionKey = sessionKey && sessionKey !== '(not set)' && sessionKey.length > 0;
let status = '';
if (data.scrobbling.lastFm.enabled && hasSessionKey) {
status = '<span style="color: var(--success);">✓ Configured & Enabled</span>';
} else if (hasApiKey && hasSecret && hasUsername && hasPassword && !hasSessionKey) {
status = '<span style="color: var(--warning);">⚠️ Ready to Authenticate</span>';
} else if (hasApiKey && hasSecret && (!hasUsername || !hasPassword)) {
status = '<span style="color: var(--warning);">⚠️ Needs Username & Password</span>';
} else if (!hasApiKey || !hasSecret) {
status = '<span style="color: var(--success);">✓ Using hardcoded credentials</span>';
} else {
status = '<span style="color: var(--muted);">○ Not Configured</span>';
}
document.getElementById('lastfm-status-value').innerHTML = status;
// Update ListenBrainz config
document.getElementById('listenbrainz-enabled-value').textContent = data.scrobbling.listenBrainz.enabled ? 'Enabled' : 'Disabled';
const hasToken = data.scrobbling.listenBrainz.userToken && data.scrobbling.listenBrainz.userToken !== '(not set)';
document.getElementById('listenbrainz-token-value').textContent = hasToken ? '••••••••' : 'Not Set';
// ListenBrainz status
let lbStatus = '';
if (data.scrobbling.listenBrainz.enabled && hasToken) {
lbStatus = '<span style="color: var(--success);">✓ Configured & Enabled</span>';
} else if (hasToken && !data.scrobbling.listenBrainz.enabled) {
lbStatus = '<span style="color: var(--warning);">⚠️ Token Set (Not Enabled)</span>';
} else if (!hasToken && data.scrobbling.listenBrainz.enabled) {
lbStatus = '<span style="color: var(--warning);">⚠️ Enabled (No Token)</span>';
} else {
lbStatus = '<span style="color: var(--muted);">○ Not Configured</span>';
}
document.getElementById('listenbrainz-status-value').innerHTML = lbStatus;
} catch (error) {
console.error('Failed to load scrobbling config:', error);
showToast('Failed to load scrobbling configuration: ' + error.message, 'error');
}
};
window.toggleScrobblingEnabled = async function() {
try {
const response = await fetch('/api/admin/config', {
headers: { 'X-API-Key': localStorage.getItem('apiKey') || '' }
});
const data = await response.json();
const newValue = !data.scrobbling.enabled;
await API.updateConfigSetting('SCROBBLING_ENABLED', newValue.toString());
showToast(`Scrobbling ${newValue ? 'enabled' : 'disabled'}`, 'success');
await loadScrobblingConfig();
} catch (error) {
showToast('Failed to toggle scrobbling: ' + error.message, 'error');
}
};
window.toggleLocalTracksEnabled = async function() {
try {
const response = await fetch('/api/admin/scrobbling/status', {
headers: { 'X-API-Key': localStorage.getItem('apiKey') || '' }
});
const data = await response.json();
const newValue = !data.localTracksEnabled;
const updateResponse = await fetch('/api/admin/scrobbling/local-tracks/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': localStorage.getItem('apiKey') || ''
},
body: JSON.stringify({ enabled: newValue })
});
if (!updateResponse.ok) {
const error = await updateResponse.json();
throw new Error(error.error || 'Failed to update setting');
}
const result = await updateResponse.json();
showToast(result.message || `Local track scrobbling ${newValue ? 'enabled' : 'disabled'}`, 'success');
await loadScrobblingConfig();
} catch (error) {
showToast('Failed to toggle local track scrobbling: ' + error.message, 'error');
}
};
window.toggleLastFmEnabled = async function() {
try {
const response = await fetch('/api/admin/config', {
headers: { 'X-API-Key': localStorage.getItem('apiKey') || '' }
});
const data = await response.json();
const newValue = !data.scrobbling.lastFm.enabled;
await API.updateConfigSetting('SCROBBLING_LASTFM_ENABLED', newValue.toString());
showToast(`Last.fm ${newValue ? 'enabled' : 'disabled'}`, 'success');
await loadScrobblingConfig();
} catch (error) {
showToast('Failed to toggle Last.fm: ' + error.message, 'error');
}
};
window.toggleListenBrainzEnabled = async function() {
try {
const response = await fetch('/api/admin/config', {
headers: { 'X-API-Key': localStorage.getItem('apiKey') || '' }
});
const data = await response.json();
const newValue = !data.scrobbling.listenBrainz.enabled;
await API.updateConfigSetting('SCROBBLING_LISTENBRAINZ_ENABLED', newValue.toString());
showToast(`ListenBrainz ${newValue ? 'enabled' : 'disabled'}`, 'success');
await loadScrobblingConfig();
} catch (error) {
showToast('Failed to toggle ListenBrainz: ' + error.message, 'error');
}
};
window.editLastFmUsername = async function() {
const value = prompt('Enter your Last.fm username:');
if (value === null) return;
try {
await API.updateConfigSetting('SCROBBLING_LASTFM_USERNAME', value.trim());
showToast('Last.fm username updated', 'success');
await loadScrobblingConfig();
} catch (error) {
showToast('Failed to update username: ' + error.message, 'error');
}
};
window.editLastFmPassword = async function() {
const value = prompt('Enter your Last.fm password:\n\nThis is stored encrypted and only used for authentication.');
if (value === null) return;
try {
await API.updateConfigSetting('SCROBBLING_LASTFM_PASSWORD', value.trim());
showToast('Last.fm password updated', 'success');
await loadScrobblingConfig();
} catch (error) {
showToast('Failed to update password: ' + error.message, 'error');
}
};
window.editListenBrainzToken = async function() {
const value = prompt('Enter your ListenBrainz User Token:\n\nGet from https://listenbrainz.org/profile/');
if (value === null) return;
try {
await API.updateConfigSetting('SCROBBLING_LISTENBRAINZ_USER_TOKEN', value.trim());
showToast('ListenBrainz token updated', 'success');
await loadScrobblingConfig();
} catch (error) {
showToast('Failed to update token: ' + error.message, 'error');
}
};
window.authenticateLastFm = async function() {
try {
showToast('Authenticating with Last.fm...', 'info');
const response = await fetch('/api/admin/scrobbling/lastfm/authenticate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': localStorage.getItem('apiKey') || ''
}
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || `HTTP ${response.status}`);
}
const data = await response.json();
showToast('✓ Authentication successful! Session key saved. Please restart the container.', 'success', 5000);
window.showRestartBanner();
// Reload config to show updated session key
await loadScrobblingConfig();
} catch (error) {
console.error('Failed to authenticate:', error);
showToast('Authentication failed: ' + error.message, 'error');
}
};
window.testLastFmConnection = async function() {
try {
const response = await fetch('/api/admin/scrobbling/lastfm/test', {
method: 'POST',
headers: { 'X-API-Key': localStorage.getItem('apiKey') || '' }
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || `HTTP ${response.status}`);
}
const data = await response.json();
showToast(`✓ Last.fm connection successful! User: ${data.username}, Scrobbles: ${data.playcount}`, 'success');
} catch (error) {
console.error('Failed to test connection:', error);
showToast('Failed to test connection: ' + error.message, 'error');
}
};
window.validateListenBrainzToken = async function() {
const token = prompt('Enter your ListenBrainz User Token:\n\nGet from https://listenbrainz.org/settings/');
if (!token) return;
try {
showToast('Validating ListenBrainz token...', 'info');
const response = await fetch('/api/admin/scrobbling/listenbrainz/validate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': localStorage.getItem('apiKey') || ''
},
body: JSON.stringify({ userToken: token.trim() })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || `HTTP ${response.status}`);
}
const data = await response.json();
showToast(`✓ Token validated! User: ${data.username}. Please restart the container.`, 'success', 5000);
window.showRestartBanner();
// Reload config to show updated token
await loadScrobblingConfig();
} catch (error) {
console.error('Failed to validate token:', error);
showToast('Validation failed: ' + error.message, 'error');
}
};
window.testListenBrainzConnection = async function() {
try {
const response = await fetch('/api/admin/scrobbling/listenbrainz/test', {
method: 'POST',
headers: { 'X-API-Key': localStorage.getItem('apiKey') || '' }
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || `HTTP ${response.status}`);
}
const data = await response.json();
showToast(`✓ ListenBrainz connection successful! User: ${data.username}`, 'success');
} catch (error) {
console.error('Failed to test connection:', error);
showToast('Failed to test connection: ' + error.message, 'error');
}
};
+1
View File
@@ -206,6 +206,7 @@ export function updateConfigUI(data) {
document.getElementById('config-enable-external-playlists').textContent = data.enableExternalPlaylists ? 'Yes' : 'No';
document.getElementById('config-playlists-directory').textContent = data.playlistsDirectory || '(not set)';
document.getElementById('config-redis-enabled').textContent = data.redisEnabled ? 'Yes' : 'No';
document.getElementById('config-debug-log-requests').textContent = data.debug?.logAllRequests ? 'Enabled' : 'Disabled';
document.getElementById('config-spotify-enabled').textContent = data.spotifyApi.enabled ? 'Yes' : 'No';
document.getElementById('config-spotify-cookie').textContent = data.spotifyApi.sessionCookie;
+14
View File
@@ -126,6 +126,20 @@ services:
# Spotify Lyrics API sidecar service URL (internal)
- SpotifyApi__LyricsApiUrl=${SPOTIFY_LYRICS_API_URL:-http://spotify-lyrics:8080}
# ===== SCROBBLING (LAST.FM, LISTENBRAINZ) =====
- Scrobbling__Enabled=${SCROBBLING_ENABLED:-false}
- Scrobbling__LastFm__Enabled=${SCROBBLING_LASTFM_ENABLED:-false}
- Scrobbling__LastFm__ApiKey=${SCROBBLING_LASTFM_API_KEY:-}
- Scrobbling__LastFm__SharedSecret=${SCROBBLING_LASTFM_SHARED_SECRET:-}
- Scrobbling__LastFm__SessionKey=${SCROBBLING_LASTFM_SESSION_KEY:-}
- Scrobbling__LastFm__Username=${SCROBBLING_LASTFM_USERNAME:-}
- Scrobbling__LastFm__Password=${SCROBBLING_LASTFM_PASSWORD:-}
- Scrobbling__ListenBrainz__Enabled=${SCROBBLING_LISTENBRAINZ_ENABLED:-false}
- Scrobbling__ListenBrainz__UserToken=${SCROBBLING_LISTENBRAINZ_USER_TOKEN:-}
# ===== DEBUG SETTINGS =====
- Debug__LogAllRequests=${DEBUG_LOG_ALL_REQUESTS:-false}
# ===== SHARED =====
- Library__DownloadPath=/app/downloads
- SquidWTF__Quality=${SQUIDWTF_QUALITY:-FLAC}