Compare commits

..

5 Commits

Author SHA1 Message Date
f68706f300 Release v1.1.1 - Download Structure Fix
Some checks failed
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
Fixed cache and permanent files to use unified downloads/ structure instead of separate paths.
2026-02-08 01:51:18 -05:00
9f362b4920 Release v1.1.0 - Configuration Simplification
Configuration Changes:
- Removed sync window logic from Spotify Import (no more SYNC_START_HOUR, SYNC_START_MINUTE, SYNC_WINDOW_HOURS)
- Simplified to: fetch on startup if cache missing, check every 5 minutes for stale cache
- Unified download folder structure: downloads/{permanent,cache,kept}/ instead of separate paths
- Removed Library:KeptPath config, now uses downloads/kept/

Documentation:
- Updated README with clearer Spotify Import configuration
- Updated .env.example to reflect simplified settings
- Removed MIGRATION.md from repository (local-only file)

Bug Fixes:
- Web UI now correctly displays kept tracks in Active Playlists tab
- Fixed path handling for favorited tracks
2026-02-08 01:33:09 -05:00
2b09484c0b Release v1.0.0 - Production Ready
Major Features:
- Spotify playlist injection with missing tracks search
- Transparent proxy authentication system
- WebSocket session management for external tracks
- Manual track mapping and favorites system
- Lyrics support (Spotify + LRCLib) with prefetching
- Admin dashboard with analytics and configuration
- Performance optimizations with health checks and endpoint racing
- Comprehensive caching and memory management

Performance Improvements:
- Quick health checks (3s timeout) before trying endpoints
- Health check results cached for 30 seconds
- 5 minute timeout for large artist responses
- Background Odesli conversion after streaming starts
- Parallel lyrics prefetching
- Endpoint benchmarking and racing
- 16 SquidWTF endpoints with load balancing

Reliability:
- Automatic endpoint fallback and failover
- Token expiration handling
- Concurrent request optimization
- Memory leak fixes
- Proper session cleanup

User Experience:
- Web UI for configuration and playlist management
- Real-time progress tracking
- API analytics dashboard
- Manual track mapping interface
- Playlist statistics and health monitoring
2026-02-08 00:43:47 -05:00
fa9739bfaa docs: update README
Some checks failed
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-01-31 11:16:00 -05:00
0ba51e2b30 fix: improve auth, search, and stability
Some checks failed
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-01-31 01:14:53 -05:00
18 changed files with 798 additions and 723 deletions

View File

@@ -18,28 +18,30 @@ SUBSONIC_URL=http://localhost:4533
# Server URL (required if using Jellyfin backend) # Server URL (required if using Jellyfin backend)
JELLYFIN_URL=http://localhost:8096 JELLYFIN_URL=http://localhost:8096
# API key for authentication (get from Jellyfin Dashboard > API Keys) # API key for SERVER-SIDE operations only (get from Jellyfin Dashboard > API Keys)
# This is used by Allstarr to query Jellyfin's library on behalf of the server
# CLIENT authentication is handled transparently - clients authenticate directly with Jellyfin
JELLYFIN_API_KEY= JELLYFIN_API_KEY=
# User ID (get from Jellyfin Dashboard > Users > click user > check URL) # User ID for SERVER-SIDE library queries (get from Jellyfin Dashboard > Users > click user > check URL)
# This determines which user's library Allstarr queries when searching/browsing
JELLYFIN_USER_ID= JELLYFIN_USER_ID=
# Music library ID (optional, auto-detected if not set) # Music library ID (optional, auto-detected if not set)
# If you have multiple libraries, set this to filter to music only
JELLYFIN_LIBRARY_ID= JELLYFIN_LIBRARY_ID=
# ===== MUSIC SOURCE SELECTION ===== # ===== MUSIC SOURCE SELECTION =====
# Music service to use: SquidWTF, Deezer, or Qobuz (default: SquidWTF) # Music service to use: SquidWTF, Deezer, or Qobuz (default: SquidWTF)
MUSIC_SERVICE=SquidWTF MUSIC_SERVICE=SquidWTF
# Path where downloaded songs will be stored on the host (only applies if STORAGE_MODE=Permanent) # Base directory for all downloads (default: ./downloads)
# This creates three subdirectories:
# - downloads/permanent/ - Permanently downloaded tracks (STORAGE_MODE=Permanent)
# - downloads/cache/ - Temporarily cached tracks (STORAGE_MODE=Cache)
# - downloads/kept/ - Favorited external tracks (always permanent)
DOWNLOAD_PATH=./downloads DOWNLOAD_PATH=./downloads
# Path where favorited external tracks are permanently kept
KEPT_PATH=./kept
# Path for cache files (Spotify missing tracks, etc.)
CACHE_PATH=./cache
# ===== SQUIDWTF CONFIGURATION ===== # ===== SQUIDWTF CONFIGURATION =====
# Different quality options for SquidWTF. Only FLAC supported right now # Different quality options for SquidWTF. Only FLAC supported right now
SQUIDWTF_QUALITY=FLAC SQUIDWTF_QUALITY=FLAC
@@ -108,27 +110,14 @@ CACHE_DURATION_HOURS=1
# ===== SPOTIFY PLAYLIST INJECTION (JELLYFIN ONLY) ===== # ===== SPOTIFY PLAYLIST INJECTION (JELLYFIN ONLY) =====
# REQUIRES: Jellyfin Spotify Import Plugin (https://github.com/Viperinius/jellyfin-plugin-spotify-import) # REQUIRES: Jellyfin Spotify Import Plugin (https://github.com/Viperinius/jellyfin-plugin-spotify-import)
# This feature intercepts Spotify Import plugin playlists (Release Radar, Discover Weekly) and fills them # This feature intercepts Spotify Import plugin playlists and fills them with tracks from external providers
# with tracks auto-matched from external providers (SquidWTF, Deezer, Qobuz)
# Uses JELLYFIN_URL and JELLYFIN_API_KEY configured above (no separate credentials needed) # Uses JELLYFIN_URL and JELLYFIN_API_KEY configured above (no separate credentials needed)
# Enable Spotify playlist injection (optional, default: false) # Enable Spotify playlist injection (optional, default: false)
SPOTIFY_IMPORT_ENABLED=false SPOTIFY_IMPORT_ENABLED=false
# Sync schedule: When does the Spotify Import plugin run?
# Set these to match your plugin's sync schedule in Jellyfin
# Example: If plugin runs daily at 4:15 PM, set HOUR=16 and MINUTE=15
SPOTIFY_IMPORT_SYNC_START_HOUR=16
SPOTIFY_IMPORT_SYNC_START_MINUTE=15
# Sync window: How long to search for missing tracks files (in hours)
# The fetcher will check every 5 minutes within this window
# Example: If plugin runs at 4:15 PM and window is 2 hours, checks from 4:00 PM to 6:00 PM
SPOTIFY_IMPORT_SYNC_WINDOW_HOURS=2
# Matching interval: How often to run track matching (in hours) # Matching interval: How often to run track matching (in hours)
# Spotify playlists like Discover Weekly update once per week, Release Radar updates weekly # Spotify playlists like Discover Weekly update once per week, Release Radar updates weekly
# Most playlists don't change frequently, so running once per day is reasonable
# Set to 0 to only run once on startup (manual trigger via admin UI still works) # Set to 0 to only run once on startup (manual trigger via admin UI still works)
# Default: 24 hours # Default: 24 hours
SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS=24 SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS=24

3
.gitignore vendored
View File

@@ -104,3 +104,6 @@ originals/
# Sample missing playlists for Spotify integration testing # Sample missing playlists for Spotify integration testing
sampleMissingPlaylists/ sampleMissingPlaylists/
# Migration guide (local only)
MIGRATION.md

331
README.md
View File

@@ -5,11 +5,7 @@
[![Docker Image](https://img.shields.io/badge/docker-ghcr.io%2Fsopat712%2Fallstarr-blue)](https://github.com/SoPat712/allstarr/pkgs/container/allstarr) [![Docker Image](https://img.shields.io/badge/docker-ghcr.io%2Fsopat712%2Fallstarr-blue)](https://github.com/SoPat712/allstarr/pkgs/container/allstarr)
[![License](https://img.shields.io/badge/license-GPL--3.0-green)](LICENSE) [![License](https://img.shields.io/badge/license-GPL--3.0-green)](LICENSE)
A media server proxy that integrates music streaming providers with your local library. Works with **Jellyfin** and **Subsonic-compatible** servers (Navidrome). When a song isn't in your local library, it gets fetched from your configured provider, downloaded, and served to your client. The downloaded song then lives in your library for next time. A media server proxy that integrates music streaming providers with your local library. Works with **Jellyfin** and **Subsonic-compatible** servers. When a song isn't in your local library, it gets fetched from your configured provider, downloaded, and served to your client. The downloaded song then lives in your library for next time.
**THIS IS UNDER ACTIVE DEVELOPMENT**
Please report all bugs as soon as possible, as the Jellyfin addition is entirely a test at this point
## Quick Start ## Quick Start
@@ -40,15 +36,15 @@ The proxy will be available at `http://localhost:5274`.
## Web Dashboard ## Web Dashboard
Allstarr includes a web-based dashboard for easy configuration and playlist management, accessible at `http://localhost:5275` (internal port, not exposed through reverse proxy). Allstarr includes a web UI for easy configuration and playlist management, accessible at `http://localhost:5275`
### Features ### Features
- **Real-time Status**: Monitor Spotify authentication, cookie age, and playlist sync status - **Playlist Management**: Link Jellyfin playlists to Spotify playlists with just a few clicks
- **Playlist Management**: Link Jellyfin playlists to Spotify playlists with a few clicks - **Provider Matching**: It should fill in the gaps of your Jellyfin library with tracks from your selected provider
- **Configuration Editor**: Update settings without manually editing .env files - **WebUI**: Update settings without manually editing .env files
- **Track Viewer**: Browse tracks in your configured playlists - **Music**: Using multiple sources for music (optimized for SquidWTF right now, though)
- **Cache Management**: Clear cached data and restart the container - **Lyrics**: Using multiple sources for lyrics, first Jellyfin Lyrics, then Spotify Lyrics, then LrcLib as a last resort
### Quick Setup with Web UI ### Quick Setup with Web UI
@@ -65,18 +61,20 @@ Allstarr includes a web-based dashboard for easy configuration and playlist mana
- `37i9dQZF1DXcBWIGoYBM5M` (just the ID) - `37i9dQZF1DXcBWIGoYBM5M` (just the ID)
- `spotify:playlist:37i9dQZF1DXcBWIGoYBM5M` (Spotify URI) - `spotify:playlist:37i9dQZF1DXcBWIGoYBM5M` (Spotify URI)
- `https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M` (full URL) - `https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M` (full URL)
4. **Restart** to apply changes (button in Configuration tab) 4. **Restart** to apply changes (should be a banner)
### Why Two Playlist Tabs? Then, proceeed to **Active Playlists**, which shows you which Spotify playlists are currently being monitored and filled with tracks, and lets you do a bunch of useful operations on them.
- **Link Playlists**: Shows all Jellyfin playlists and lets you connect them to Spotify
- **Active Playlists**: Shows which Spotify playlists are currently being monitored and filled with tracks
### Configuration Persistence ### Configuration Persistence
The web UI updates your `.env` file directly. Changes persist across container restarts, but require a restart to take effect. In development mode, the `.env` file is in your project root. In Docker, it's at `/app/.env`. The web UI updates your `.env` file directly. Changes persist across container restarts, but require a restart to take effect. In development mode, the `.env` file is in your project root. In Docker, it's at `/app/.env`.
**Recommended workflow**: Use the `sp_dc` cookie method (simpler and more reliable than the Jellyfin Spotify Import plugin). There's an environment variable to modify this.
**Recommended workflow**: Use the `sp_dc` cookie method alongside the [Spotify Import Plugin](https://github.com/Viperinius/jellyfin-plugin-spotify-import?tab=readme-ov-file).
### Nginx Proxy Setup (Required) ### Nginx Proxy Setup (Required)
@@ -142,6 +140,7 @@ This project brings together all the music streaming providers into one unified
- [Feishin](https://github.com/jeffvli/feishin) (Mac/Windows/Linux) - [Feishin](https://github.com/jeffvli/feishin) (Mac/Windows/Linux)
- [Musiver](https://music.aqzscn.cn/en/) (Android/IOS/Windows/Android) - [Musiver](https://music.aqzscn.cn/en/) (Android/IOS/Windows/Android)
- [Finamp](https://github.com/jmshrv/finamp) ()
_Working on getting more currently_ _Working on getting more currently_
@@ -335,7 +334,7 @@ Subsonic__EnableExternalPlaylists=false
### Spotify Playlist Injection (Jellyfin Only) ### Spotify Playlist Injection (Jellyfin Only)
Allstarr can automatically fill your Spotify playlists (like Release Radar and Discover Weekly) with tracks from your configured streaming provider (SquidWTF, Deezer, or Qobuz). This feature works by intercepting playlists created by the Jellyfin Spotify Import plugin and matching missing tracks with your streaming service. Allstarr automatically fills your Spotify playlists (like Release Radar and Discover Weekly) with tracks from your configured streaming provider (SquidWTF, Deezer, or Qobuz). This works by intercepting playlists created by the Jellyfin Spotify Import plugin and matching missing tracks with your streaming service.
#### Prerequisites #### Prerequisites
@@ -349,136 +348,112 @@ Allstarr can automatically fill your Spotify playlists (like Release Radar and D
- Go to Jellyfin Dashboard → Plugins → Spotify Import - Go to Jellyfin Dashboard → Plugins → Spotify Import
- Connect your Spotify account - Connect your Spotify account
- Select which playlists to sync (e.g., Release Radar, Discover Weekly) - Select which playlists to sync (e.g., Release Radar, Discover Weekly)
- Set a daily sync schedule (e.g., 4:15 PM daily) - Set a sync schedule (the plugin will create playlists in Jellyfin)
- The plugin will create playlists in Jellyfin and generate "missing tracks" files for songs not in your library
3. **Configure Allstarr** 3. **Configure Allstarr**
- Allstarr needs to know when the plugin runs and which playlists to intercept - Enable Spotify Import in Allstarr (see configuration below)
- Uses your existing `JELLYFIN_URL` and `JELLYFIN_API_KEY` settings (no additional credentials needed) - Link your Jellyfin playlists to Spotify playlists via the Web UI
- Uses your existing `JELLYFIN_URL` and `JELLYFIN_API_KEY` settings
#### Configuration #### Configuration
| Setting | Description | | Setting | Description |
|---------|-------------| |---------|-------------|
| `SpotifyImport:Enabled` | Enable Spotify playlist injection (default: `false`) | | `SpotifyImport:Enabled` | Enable Spotify playlist injection (default: `false`) |
| `SpotifyImport:SyncStartHour` | Hour when the Spotify Import plugin runs (24-hour format, 0-23) | | `SpotifyImport:MatchingIntervalHours` | How often to run track matching in hours (default: 24, set to 0 for startup only) |
| `SpotifyImport:SyncStartMinute` | Minute when the plugin runs (0-59) | | `SpotifyImport:Playlists` | JSON array of playlists (managed via Web UI) |
| `SpotifyImport:SyncWindowHours` | Hours to search for missing tracks files after sync time (default: 2) |
| `SpotifyImport:PlaylistIds` | Comma-separated Jellyfin playlist IDs to intercept |
| `SpotifyImport:PlaylistNames` | Comma-separated playlist names (must match order of IDs) |
**Environment variables example:** **Environment variables example:**
```bash ```bash
# Enable the feature # Enable the feature
SPOTIFY_IMPORT_ENABLED=true SPOTIFY_IMPORT_ENABLED=true
# Sync window settings (optional - used to prevent fetching too frequently) # Matching interval (24 hours = once per day)
# The fetcher searches backwards from current time for the last 48 hours SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS=24
SPOTIFY_IMPORT_SYNC_START_HOUR=16
SPOTIFY_IMPORT_SYNC_START_MINUTE=15
SPOTIFY_IMPORT_SYNC_WINDOW_HOURS=2
# Get playlist IDs from Jellyfin URLs: https://jellyfin.example.com/web/#/details?id=PLAYLIST_ID # Playlists (use Web UI to manage instead of editing manually)
SPOTIFY_IMPORT_PLAYLIST_IDS=ba50e26c867ec9d57ab2f7bf24cfd6b0,4383a46d8bcac3be2ef9385053ea18df SPOTIFY_IMPORT_PLAYLISTS=[["Discover Weekly","37i9dQZEVXcV6s7Dm7RXsU","first"],["Release Radar","37i9dQZEVXbng2vDHnfQlC","first"]]
# Names must match exactly as they appear in Jellyfin (used to find missing tracks files)
SPOTIFY_IMPORT_PLAYLIST_NAMES=Release Radar,Discover Weekly
``` ```
#### How It Works #### How It Works
1. **Spotify Import Plugin Runs** (e.g., daily at 4:15 PM) 1. **Spotify Import Plugin Runs**
- Plugin fetches your Spotify playlists - Plugin fetches your Spotify playlists
- Creates/updates playlists in Jellyfin with tracks already in your library - Creates/updates playlists in Jellyfin with tracks already in your library
- Generates "missing tracks" JSON files for songs not found locally - Generates "missing tracks" JSON files for songs not found locally
- Files are named like: `Release Radar_missing_2026-02-01_16-15.json`
2. **Allstarr Fetches Missing Tracks** (within sync window) 2. **Allstarr Matches Tracks** (on startup + every 24 hours by default)
- Searches for missing tracks files from the Jellyfin plugin - Reads missing tracks files from the Jellyfin plugin
- Searches **+24 hours forward first** (newest files), then **-48 hours backward** if not found
- This efficiently finds the most recent file regardless of timezone differences
- Example: Server time 12 PM EST, file timestamped 9 PM UTC (same day) → Found in forward search
- Caches the list of missing tracks in Redis + file cache
- Runs automatically on startup (if needed) and every 5 minutes during the sync window
3. **Allstarr Matches Tracks** (2 minutes after startup, then configurable interval)
- For each missing track, searches your streaming provider (SquidWTF, Deezer, or Qobuz) - For each missing track, searches your streaming provider (SquidWTF, Deezer, or Qobuz)
- Uses fuzzy matching to find the best match (title + artist similarity) - Uses fuzzy matching to find the best match (title + artist similarity)
- Rate-limited to avoid overwhelming the service (150ms delay between searches) - Rate-limited to avoid overwhelming the service (150ms delay between searches)
- Caches matched results for 1 hour - Pre-builds playlist cache for instant loading
- **Pre-builds playlist items cache** for instant serving (no "on the fly" building)
- Default interval: 24 hours (configurable via `SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS`)
- Set to 0 to only run once on startup (manual trigger via admin UI still works)
4. **You Open the Playlist in Jellyfin** 3. **You Open the Playlist in Jellyfin**
- Allstarr intercepts the request - Allstarr intercepts the request
- Returns a merged list: local tracks + matched streaming tracks - Returns a merged list: local tracks + matched streaming tracks
- Loads instantly from cache (no searching needed!) - Loads instantly from cache!
5. **You Play a Track** 4. **You Play a Track**
- If it's a local track, streams from Jellyfin normally - Local tracks stream from Jellyfin normally
- If it's a matched track, downloads from streaming provider on-demand - Matched tracks download from streaming provider on-demand
- Downloaded tracks are saved to your library for future use - Downloaded tracks are saved to your library for future use
#### Manual Triggers #### Manual API Triggers
You can manually trigger syncing and matching via API: You can manually trigger operations via the admin API:
```bash ```bash
# Get API key from your .env file
API_KEY="your-api-key-here"
# Fetch missing tracks from Jellyfin plugin # Fetch missing tracks from Jellyfin plugin
curl "https://your-jellyfin-proxy.com/spotify/sync?api_key=YOUR_API_KEY" curl "http://localhost:5274/spotify/sync?api_key=$API_KEY"
# Trigger track matching (searches streaming provider) # Trigger track matching (searches streaming provider)
curl "https://your-jellyfin-proxy.com/spotify/match?api_key=YOUR_API_KEY" curl "http://localhost:5274/spotify/match?api_key=$API_KEY"
# Clear cache to force re-matching # Match all playlists (refresh all matches)
curl "https://your-jellyfin-proxy.com/spotify/clear-cache?api_key=YOUR_API_KEY" curl "http://localhost:5274/spotify/match-all?api_key=$API_KEY"
# Clear cache and rebuild
curl "http://localhost:5274/spotify/clear-cache?api_key=$API_KEY"
# Refresh specific playlist
curl "http://localhost:5274/spotify/refresh-playlist?playlistId=PLAYLIST_ID&api_key=$API_KEY"
``` ```
#### Startup Behavior #### Web UI Management
When Allstarr starts with Spotify Import enabled: The easiest way to manage Spotify playlists is through the Web UI at `http://localhost:5275`:
**Smart Cache Check:** 1. **Link Playlists Tab**: Link Jellyfin playlists to Spotify playlists
- Checks if today's sync window has passed (e.g., if sync is at 4 PM + 2 hour window = 6 PM) 2. **Active Playlists Tab**: View status, trigger matching, and manage playlists
- If before 6 PM and yesterday's cache exists → **Skips fetch** (cache is still current) 3. **Configuration Tab**: Enable/disable Spotify Import and adjust settings
- If after 6 PM or no cache exists → **Fetches missing tracks** from Jellyfin plugin
**Track Matching:**
- **T+2min**: Matches tracks with streaming provider (with rate limiting)
- Only matches playlists that don't already have cached matches
- **Result**: Playlists load instantly when you open them!
**Example Timeline:**
- Plugin runs daily at 4:15 PM, creates files at ~4:16 PM
- You restart Allstarr at 12:00 PM (noon) the next day
- Startup check: "Today's sync window ends at 6 PM, and I have yesterday's 4:16 PM file"
- **Decision**: Skip fetch, use existing cache
- At 6:01 PM: Next scheduled check will search for new files
#### Troubleshooting #### Troubleshooting
**Playlists are empty:** **Playlists are empty:**
- Check that the Spotify Import plugin is running and creating playlists - Check that the Spotify Import plugin is running and creating playlists
- Verify `SPOTIFY_IMPORT_PLAYLIST_IDS` match your Jellyfin playlist IDs - Verify playlists are linked in the Web UI
- Check logs: `docker-compose logs -f allstarr | grep -i spotify` - Check logs: `docker-compose logs -f allstarr | grep -i spotify`
**Tracks aren't matching:** **Tracks aren't matching:**
- Ensure your streaming provider is configured (`MUSIC_SERVICE`, credentials) - Ensure your streaming provider is configured (`MUSIC_SERVICE`, credentials)
- Check that playlist names in `SPOTIFY_IMPORT_PLAYLIST_NAMES` match exactly - Manually trigger matching via Web UI or API
- Manually trigger matching: `curl "https://your-proxy.com/spotify/match?api_key=KEY"` - Check that the Jellyfin plugin generated missing tracks files
**Sync timing issues:** **Performance:**
- Set `SPOTIFY_IMPORT_SYNC_START_HOUR/MINUTE` to match your plugin schedule - Matching runs in background with rate limiting (150ms between searches)
- Increase `SPOTIFY_IMPORT_SYNC_WINDOW_HOURS` if files aren't being found - First match may take a few minutes for large playlists
- Check Jellyfin plugin logs to confirm when it runs - Subsequent loads are instant (served from cache)
#### Notes #### Notes
- This feature uses your existing `JELLYFIN_URL` and `JELLYFIN_API_KEY` settings - Uses your existing `JELLYFIN_URL` and `JELLYFIN_API_KEY` settings
- Matched tracks are cached for 1 hour to avoid repeated searches - Matched tracks cached for fast loading
- Missing tracks cache persists across restarts (stored in Redis + file cache) - Missing tracks cache persists across restarts (Redis + file cache)
- Rate limiting prevents overwhelming your streaming provider (150ms between searches) - Rate limiting prevents overwhelming your streaming provider
- Only works with Jellyfin backend (not Subsonic/Navidrome) - Only works with Jellyfin backend (not Subsonic/Navidrome)
### Getting Credentials ### Getting Credentials
@@ -592,9 +567,46 @@ If you prefer to run Allstarr without Docker:
## API Endpoints ## API Endpoints
### Jellyfin Backend (Primary Focus)
The proxy provides comprehensive Jellyfin API support with streaming provider integration:
| Endpoint | Description |
|----------|-------------|
| `GET /Items` | Search and browse library items (local + streaming providers) |
| `GET /Artists` | Browse artists with merged results from local + streaming |
| `GET /Artists/AlbumArtists` | Album artists with streaming provider results |
| `GET /Users/{userId}/Items` | User library items with external content |
| `GET /Audio/{id}/stream` | Stream audio, downloading from provider on-demand |
| `GET /Audio/{id}/Lyrics` | Lyrics from Jellyfin, Spotify, or LRCLib |
| `GET /Items/{id}/Images/{type}` | Proxy cover art for external content |
| `GET /Playlists/{id}/Items` | Playlist items (Spotify Import integration) |
| `POST /UserFavoriteItems/{id}` | Favorite items; copies external tracks to kept folder |
| `DELETE /UserFavoriteItems/{id}` | Unfavorite items |
| `POST /Sessions/Playing` | Playback reporting for external tracks |
| `POST /Sessions/Playing/Progress` | Playback progress tracking |
| `POST /Sessions/Playing/Stopped` | Playback stopped reporting |
| `WebSocket /socket` | Real-time session management and remote control |
**Admin API (Port 5275):**
| Endpoint | Description |
|----------|-------------|
| `GET /api/config` | Get current configuration |
| `POST /api/config` | Update configuration |
| `GET /api/playlists` | List Spotify Import playlists |
| `POST /api/playlists/link` | Link Jellyfin playlist to Spotify |
| `DELETE /api/playlists/{id}` | Unlink playlist |
| `POST /spotify/sync` | Fetch missing tracks from Jellyfin plugin |
| `POST /spotify/match` | Trigger track matching |
| `POST /spotify/match-all` | Match all playlists |
| `POST /spotify/clear-cache` | Clear playlist cache |
| `POST /spotify/refresh-playlist` | Refresh specific playlist |
All other Jellyfin API endpoints are passed through unchanged.
### Subsonic Backend ### Subsonic Backend
The proxy implements the Subsonic API and adds transparent streaming provider integration: The proxy implements the Subsonic API with streaming provider integration:
| Endpoint | Description | | Endpoint | Description |
|----------|-------------| |----------|-------------|
@@ -608,20 +620,6 @@ The proxy implements the Subsonic API and adds transparent streaming provider in
All other Subsonic API endpoints are passed through to Navidrome unchanged. All other Subsonic API endpoints are passed through to Navidrome unchanged.
### Jellyfin Backend
The proxy implements a subset of the Jellyfin API:
| Endpoint | Description |
|----------|-------------|
| `GET /Items` | Search and browse library items |
| `GET /Artists` | Browse artists with streaming provider results |
| `GET /Audio/{id}/stream` | Stream audio, downloading from provider if needed |
| `GET /Items/{id}/Images/{type}` | Proxy cover art for external content |
| `POST /UserFavoriteItems/{id}` | Favorite items; triggers playlist download |
All other Jellyfin API endpoints are passed through unchanged.
## External ID Format ## External ID Format
External (streaming provider) content uses typed IDs: External (streaming provider) content uses typed IDs:
@@ -636,25 +634,37 @@ Legacy format `ext-deezer-{id}` is also supported (assumes song type).
## Download Folder Structure ## Download Folder Structure
Downloaded music is organized as: All downloads are organized under a single base directory (default: `./downloads`):
``` ```
downloads/ downloads/
├── Artist Name/ ├── permanent/ # Permanent downloads (STORAGE_MODE=Permanent)
│ ├── Album Title/ │ ├── Artist Name/
│ │ ├── 01 - Track One.mp3 │ │ ├── Album Title/
│ │ ├── 02 - Track Two.mp3 │ │ ├── 01 - Track One.flac
│ │ │ ├── 02 - Track Two.flac
│ │ │ └── ...
│ │ └── Another Album/
│ │ └── ... │ │ └── ...
│ └── Another Album/ │ └── playlists/
── ... ── My Favorite Songs.m3u
├── Another Artist/ │ └── Chill Vibes.m3u
│ └── ... ├── cache/ # Temporary cache (STORAGE_MODE=Cache)
└── playlists/ │ └── Artist Name/
── My Favorite Songs.m3u ── Album Title/
├── Chill Vibes.m3u └── Track.flac
└── ... └── kept/ # Favorited external tracks (always permanent)
└── Artist Name/
└── Album Title/
└── Track.flac
``` ```
Playlists are stored as M3U files with relative paths to downloaded tracks, making them portable and compatible with most music players. **Storage modes:**
- **Permanent** (`downloads/permanent/`): Files saved permanently and registered in your media server
- **Cache** (`downloads/cache/`): Temporary files, auto-cleaned after `CACHE_DURATION_HOURS`
- **Kept** (`downloads/kept/`): External tracks you've favorited - always permanent, separate from cache
Playlists are stored as M3U files with relative paths, making them portable and compatible with most music players.
## Metadata Embedding ## Metadata Embedding
@@ -685,10 +695,17 @@ dotnet test
``` ```
allstarr/ allstarr/
├── Controllers/ ├── Controllers/
│ ├── JellyfinController.cs # Jellyfin API controller (registered when Backend:Type=Jellyfin) │ ├── AdminController.cs # Admin dashboard API
── SubsonicController.cs # Subsonic API controller (registered when Backend:Type=Subsonic) ── 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/ ├── Middleware/
── GlobalExceptionHandler.cs # Global error handling ── AdminStaticFilesMiddleware.cs # Admin UI static file serving
│ ├── GlobalExceptionHandler.cs # Global error handling
│ └── WebSocketProxyMiddleware.cs # WebSocket proxying for Jellyfin
├── Models/ ├── Models/
│ ├── Domain/ # Domain entities │ ├── Domain/ # Domain entities
│ │ ├── Song.cs │ │ ├── Song.cs
@@ -697,18 +714,39 @@ allstarr/
│ ├── Settings/ # Configuration models │ ├── Settings/ # Configuration models
│ │ ├── SubsonicSettings.cs │ │ ├── SubsonicSettings.cs
│ │ ├── DeezerSettings.cs │ │ ├── DeezerSettings.cs
│ │ ── QobuzSettings.cs │ │ ── QobuzSettings.cs
│ │ ├── SquidWTFSettings.cs
│ │ ├── SpotifyApiSettings.cs
│ │ ├── SpotifyImportSettings.cs
│ │ ├── MusicBrainzSettings.cs
│ │ └── RedisSettings.cs
│ ├── Download/ # Download-related models │ ├── Download/ # Download-related models
│ │ ├── DownloadInfo.cs │ │ ├── DownloadInfo.cs
│ │ └── DownloadStatus.cs │ │ └── DownloadStatus.cs
│ ├── Lyrics/
│ │ └── LyricsInfo.cs
│ ├── Search/ │ ├── Search/
│ │ └── SearchResult.cs │ │ └── SearchResult.cs
│ ├── Spotify/
│ │ ├── MissingTrack.cs
│ │ └── SpotifyPlaylistTrack.cs
│ └── Subsonic/ │ └── Subsonic/
│ ├── ExternalPlaylist.cs
│ └── ScanStatus.cs │ └── ScanStatus.cs
├── Services/ ├── Services/
│ ├── Common/ # Shared services │ ├── Common/ # Shared services
│ │ ├── BaseDownloadService.cs # Template method base class │ │ ├── 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 │ │ ├── PathHelper.cs # Path utilities
│ │ ├── PlaylistIdHelper.cs # Playlist ID helpers
│ │ ├── RedisCacheService.cs # Redis caching
│ │ ├── RoundRobinFallbackHelper.cs # Load balancing and failover
│ │ ├── Result.cs # Result<T> pattern │ │ ├── Result.cs # Result<T> pattern
│ │ └── Error.cs # Error types │ │ └── Error.cs # Error types
│ ├── Deezer/ # Deezer provider │ ├── Deezer/ # Deezer provider
@@ -720,12 +758,35 @@ allstarr/
│ │ ├── QobuzMetadataService.cs │ │ ├── QobuzMetadataService.cs
│ │ ├── QobuzBundleService.cs │ │ ├── QobuzBundleService.cs
│ │ └── QobuzStartupValidator.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 │ ├── Local/ # Local library
│ │ ├── ILocalLibraryService.cs │ │ ├── ILocalLibraryService.cs
│ │ └── LocalLibraryService.cs │ │ └── LocalLibraryService.cs
│ ├── Subsonic/ # Subsonic API logic │ ├── Subsonic/ # Subsonic API logic
│ │ ├── SubsonicProxyService.cs # Request proxying │ │ ├── PlaylistSyncService.cs # Playlist synchronization
│ │ ├── SubsonicModelMapper.cs # Model mapping │ │ ├── SubsonicModelMapper.cs # Model mapping
│ │ ├── SubsonicProxyService.cs # Request proxying
│ │ ├── SubsonicRequestParser.cs # Request parsing │ │ ├── SubsonicRequestParser.cs # Request parsing
│ │ └── SubsonicResponseBuilder.cs # Response building │ │ └── SubsonicResponseBuilder.cs # Response building
│ ├── Validation/ # Startup validation │ ├── Validation/ # Startup validation
@@ -737,13 +798,17 @@ allstarr/
│ ├── IDownloadService.cs # Download interface │ ├── IDownloadService.cs # Download interface
│ ├── IMusicMetadataService.cs # Metadata interface │ ├── IMusicMetadataService.cs # Metadata interface
│ └── StartupValidationService.cs │ └── StartupValidationService.cs
├── wwwroot/ # Admin UI static files
│ ├── index.html # Admin dashboard
│ └── placeholder.png # Placeholder image
├── Program.cs # Application entry point ├── Program.cs # Application entry point
└── appsettings.json # Configuration └── appsettings.json # Configuration
allstarr.Tests/ allstarr.Tests/
├── DeezerDownloadServiceTests.cs # Deezer download tests ├── DeezerDownloadServiceTests.cs # Deezer download tests
├── DeezerMetadataServiceTests.cs # Deezer metadata tests ├── DeezerMetadataServiceTests.cs # Deezer metadata tests
├── QobuzDownloadServiceTests.cs # Qobuz download tests (127 tests) ├── JellyfinResponseStructureTests.cs # Jellyfin response tests
├── QobuzDownloadServiceTests.cs # Qobuz download tests
├── LocalLibraryServiceTests.cs # Local library tests ├── LocalLibraryServiceTests.cs # Local library tests
├── SubsonicModelMapperTests.cs # Model mapping tests ├── SubsonicModelMapperTests.cs # Model mapping tests
├── SubsonicProxyServiceTests.cs # Proxy service tests ├── SubsonicProxyServiceTests.cs # Proxy service tests
@@ -817,7 +882,7 @@ We welcome contributions! Here's how to get started:
- Follow existing code patterns and conventions - Follow existing code patterns and conventions
- Add tests for new features - Add tests for new features
- Update documentation as needed - Update documentation as needed
- Keep commits focused and atomic - Keep commits feature focused
### Testing ### Testing
@@ -839,8 +904,14 @@ GPL-3.0
## Acknowledgments ## Acknowledgments
- [Navidrome](https://www.navidrome.org/) - The excellent self-hosted music server - [octo-fiesta](https://github.com/V1ck3s/octo-fiesta) - The original
- [octo-fiestarr](https://github.com/bransoned/octo-fiestarr) - The fork that introduced me to this idea based on the above
- [Jellyfin Spotify Import Plugin](https://github.com/Viperinius/jellyfin-plugin-spotify-import?tab=readme-ov-file) - The plugin that I **strongly** recommend using alongside this repo
- [Jellyfin](https://jellyfin.org/) - The free and open-source media server - [Jellyfin](https://jellyfin.org/) - The free and open-source media server
- [Navidrome](https://www.navidrome.org/) - The excellent self-hosted music server
- [Subsonic API](http://www.subsonic.org/pages/api.jsp) - The API specification
- [Hi-Fi API](https://github.com/binimum/hifi-api) - These people do some great work, and you should thank them for this even existing!
- [Deezer](https://www.deezer.com/) - Music streaming service - [Deezer](https://www.deezer.com/) - Music streaming service
- [Qobuz](https://www.qobuz.com/) - Hi-Res music streaming service - [Qobuz](https://www.qobuz.com/) - Hi-Res music streaming service
- [Subsonic API](http://www.subsonic.org/pages/api.jsp) - The API specification - [spotify-lyrics-api](https://github.com/akashrchandran/spotify-lyrics-api) - Thank them for the fact that we have access to Spotify's lyrics!
- [LRCLIB](https://github.com/tranxuanthang/lrclib) - The GOATS for giving us a free api for lyrics! They power LRCGET, which I'm sure some of you have heard of

View File

@@ -166,8 +166,7 @@ public class AdminController : ControllerBase
spotifyImport = new spotifyImport = new
{ {
enabled = _spotifyImportSettings.Enabled, enabled = _spotifyImportSettings.Enabled,
syncTime = $"{_spotifyImportSettings.SyncStartHour:D2}:{_spotifyImportSettings.SyncStartMinute:D2}", matchingIntervalHours = _spotifyImportSettings.MatchingIntervalHours,
syncWindowHours = _spotifyImportSettings.SyncWindowHours,
playlistCount = _spotifyImportSettings.Playlists.Count playlistCount = _spotifyImportSettings.Playlists.Count
}, },
deezer = new deezer = new
@@ -1392,9 +1391,7 @@ public class AdminController : ControllerBase
spotifyImport = new spotifyImport = new
{ {
enabled = _spotifyImportSettings.Enabled, enabled = _spotifyImportSettings.Enabled,
syncStartHour = _spotifyImportSettings.SyncStartHour, matchingIntervalHours = _spotifyImportSettings.MatchingIntervalHours,
syncStartMinute = _spotifyImportSettings.SyncStartMinute,
syncWindowHours = _spotifyImportSettings.SyncWindowHours,
playlists = _spotifyImportSettings.Playlists.Select(p => new playlists = _spotifyImportSettings.Playlists.Select(p => new
{ {
name = p.Name, name = p.Name,
@@ -1412,9 +1409,9 @@ public class AdminController : ControllerBase
library = new library = new
{ {
downloadPath = _subsonicSettings.StorageMode == StorageMode.Cache downloadPath = _subsonicSettings.StorageMode == StorageMode.Cache
? Path.Combine("cache", "Music") ? Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "cache")
: (_configuration["Library:DownloadPath"] ?? "./downloads"), : Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "permanent"),
keptPath = _configuration["Library:KeptPath"] ?? "/app/kept", keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept"),
storageMode = _subsonicSettings.StorageMode.ToString(), storageMode = _subsonicSettings.StorageMode.ToString(),
cacheDurationHours = _subsonicSettings.CacheDurationHours, cacheDurationHours = _subsonicSettings.CacheDurationHours,
downloadMode = _subsonicSettings.DownloadMode.ToString() downloadMode = _subsonicSettings.DownloadMode.ToString()
@@ -3309,7 +3306,7 @@ public class LinkPlaylistRequest
{ {
try try
{ {
var keptPath = _configuration["Library:KeptPath"] ?? "/app/kept"; var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
_logger.LogInformation("📂 Checking kept folder: {Path}", keptPath); _logger.LogInformation("📂 Checking kept folder: {Path}", keptPath);
_logger.LogInformation("📂 Directory exists: {Exists}", Directory.Exists(keptPath)); _logger.LogInformation("📂 Directory exists: {Exists}", Directory.Exists(keptPath));
@@ -3392,7 +3389,7 @@ public class LinkPlaylistRequest
return BadRequest(new { error = "Path is required" }); return BadRequest(new { error = "Path is required" });
} }
var keptPath = _configuration["Library:KeptPath"] ?? "/app/kept"; var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
var fullPath = Path.Combine(keptPath, path); var fullPath = Path.Combine(keptPath, path);
_logger.LogInformation("🗑️ Delete request for: {Path}", fullPath); _logger.LogInformation("🗑️ Delete request for: {Path}", fullPath);
@@ -3456,7 +3453,7 @@ public class LinkPlaylistRequest
return BadRequest(new { error = "Path is required" }); return BadRequest(new { error = "Path is required" });
} }
var keptPath = _configuration["Library:KeptPath"] ?? "/app/kept"; var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
var fullPath = Path.Combine(keptPath, path); var fullPath = Path.Combine(keptPath, path);
// Security: Ensure the path is within the kept directory // Security: Ensure the path is within the kept directory

View File

@@ -2183,7 +2183,7 @@ public class JellyfinController : ControllerBase
var method = Request.Method; var method = Request.Method;
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : ""; var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : "";
_logger.LogInformation("📡 Session capabilities reported - Method: {Method}, Query: {Query}", method, queryString); _logger.LogDebug("📡 Session capabilities reported - Method: {Method}, Query: {Query}", method, queryString);
_logger.LogInformation("Headers: {Headers}", _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)) 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}"))); .Select(h => $"{h.Key}={h.Value}")));
@@ -2208,7 +2208,11 @@ public class JellyfinController : ControllerBase
if (statusCode == 204 || statusCode == 200) if (statusCode == 204 || statusCode == 200)
{ {
_logger.LogInformation("✓ Session capabilities forwarded to Jellyfin ({StatusCode})", statusCode); _logger.LogDebug("✓ Session capabilities forwarded to Jellyfin ({StatusCode})", statusCode);
}
else if (statusCode == 401)
{
_logger.LogDebug("⚠ Jellyfin returned 401 for capabilities (token expired)");
} }
else else
{ {
@@ -2242,7 +2246,7 @@ public class JellyfinController : ControllerBase
} }
Request.Body.Position = 0; Request.Body.Position = 0;
_logger.LogInformation("📻 Playback START reported"); _logger.LogDebug("📻 Playback START reported");
// Parse the body to check if it's an external track // Parse the body to check if it's an external track
var doc = JsonDocument.Parse(body); var doc = JsonDocument.Parse(body);
@@ -2318,7 +2322,7 @@ public class JellyfinController : ControllerBase
if (ghostStatusCode == 204 || ghostStatusCode == 200) if (ghostStatusCode == 204 || ghostStatusCode == 200)
{ {
_logger.LogInformation("✓ Ghost playback start forwarded to Jellyfin for external track ({StatusCode})", ghostStatusCode); _logger.LogDebug("✓ Ghost playback start forwarded to Jellyfin for external track ({StatusCode})", ghostStatusCode);
} }
else else
{ {
@@ -2346,7 +2350,7 @@ public class JellyfinController : ControllerBase
} }
// For local tracks, forward playback start to Jellyfin FIRST // For local tracks, forward playback start to Jellyfin FIRST
_logger.LogInformation("Forwarding playback start to Jellyfin..."); _logger.LogDebug("Forwarding playback start to Jellyfin...");
// Fetch full item details to include in playback report // Fetch full item details to include in playback report
try try
@@ -2372,7 +2376,7 @@ public class JellyfinController : ControllerBase
if (statusCode == 204 || statusCode == 200) if (statusCode == 204 || statusCode == 200)
{ {
_logger.LogInformation("✓ Playback start forwarded to Jellyfin ({StatusCode})", statusCode); _logger.LogDebug("✓ Playback start forwarded to Jellyfin ({StatusCode})", statusCode);
// NOW ensure session exists with capabilities (after playback is reported) // NOW ensure session exists with capabilities (after playback is reported)
if (!string.IsNullOrEmpty(deviceId)) if (!string.IsNullOrEmpty(deviceId))
@@ -2380,7 +2384,7 @@ public class JellyfinController : ControllerBase
var sessionCreated = await _sessionManager.EnsureSessionAsync(deviceId, client ?? "Unknown", device ?? "Unknown", version ?? "1.0", Request.Headers); var sessionCreated = await _sessionManager.EnsureSessionAsync(deviceId, client ?? "Unknown", device ?? "Unknown", version ?? "1.0", Request.Headers);
if (sessionCreated) if (sessionCreated)
{ {
_logger.LogWarning("✓ SESSION: Session ensured for device {DeviceId} after playback start", deviceId); _logger.LogDebug("✓ SESSION: Session ensured for device {DeviceId} after playback start", deviceId);
} }
else else
{ {
@@ -2404,7 +2408,7 @@ public class JellyfinController : ControllerBase
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", body, Request.Headers); var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", body, Request.Headers);
if (statusCode == 204 || statusCode == 200) if (statusCode == 204 || statusCode == 200)
{ {
_logger.LogInformation("✓ Basic playback start forwarded to Jellyfin ({StatusCode})", statusCode); _logger.LogDebug("✓ Basic playback start forwarded to Jellyfin ({StatusCode})", statusCode);
} }
} }
} }
@@ -2517,7 +2521,7 @@ public class JellyfinController : ControllerBase
// Only log at 10-second intervals // Only log at 10-second intervals
if (position.Seconds % 10 == 0 && position.Milliseconds < 500) if (position.Seconds % 10 == 0 && position.Milliseconds < 500)
{ {
_logger.LogInformation("▶️ Progress: {Position:mm\\:ss} for item {ItemId}", position, itemId); _logger.LogDebug("▶️ Progress: {Position:mm\\:ss} for item {ItemId}", position, itemId);
} }
} }
} }
@@ -2557,7 +2561,7 @@ public class JellyfinController : ControllerBase
} }
Request.Body.Position = 0; Request.Body.Position = 0;
_logger.LogInformation("⏹️ Playback STOPPED reported"); _logger.LogDebug("⏹️ Playback STOPPED reported");
// Parse the body to check if it's an external track // Parse the body to check if it's an external track
var doc = JsonDocument.Parse(body); var doc = JsonDocument.Parse(body);
@@ -2615,7 +2619,7 @@ public class JellyfinController : ControllerBase
if (stopStatusCode == 204 || stopStatusCode == 200) if (stopStatusCode == 204 || stopStatusCode == 200)
{ {
_logger.LogInformation("✓ Ghost playback stop forwarded to Jellyfin ({StatusCode})", stopStatusCode); _logger.LogDebug("✓ Ghost playback stop forwarded to Jellyfin ({StatusCode})", stopStatusCode);
} }
return NoContent(); return NoContent();
@@ -2626,7 +2630,7 @@ public class JellyfinController : ControllerBase
} }
// For local tracks, forward to Jellyfin // For local tracks, forward to Jellyfin
_logger.LogInformation("Forwarding playback stop to Jellyfin..."); _logger.LogDebug("Forwarding playback stop to Jellyfin...");
// Log the body being sent for debugging // Log the body being sent for debugging
_logger.LogInformation("📤 Sending playback stop body: {Body}", body); _logger.LogInformation("📤 Sending playback stop body: {Body}", body);
@@ -2649,7 +2653,11 @@ public class JellyfinController : ControllerBase
if (statusCode == 204 || statusCode == 200) if (statusCode == 204 || statusCode == 200)
{ {
_logger.LogInformation("✓ Playback stop forwarded to Jellyfin ({StatusCode})", statusCode); _logger.LogDebug("✓ Playback stop forwarded to Jellyfin ({StatusCode})", statusCode);
}
else if (statusCode == 401)
{
_logger.LogDebug("Playback stop returned 401 (token expired)");
} }
else else
{ {
@@ -2708,7 +2716,7 @@ public class JellyfinController : ControllerBase
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : ""; var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : "";
var endpoint = string.IsNullOrEmpty(path) ? $"Sessions{queryString}" : $"Sessions/{path}{queryString}"; var endpoint = string.IsNullOrEmpty(path) ? $"Sessions{queryString}" : $"Sessions/{path}{queryString}";
_logger.LogInformation("🔄 Proxying session request: {Method} {Endpoint}", method, endpoint); _logger.LogDebug("🔄 Proxying session request: {Method} {Endpoint}", method, endpoint);
_logger.LogDebug("Session proxy headers: {Headers}", _logger.LogDebug("Session proxy headers: {Headers}",
string.Join(", ", Request.Headers.Where(h => h.Key.Contains("Auth", StringComparison.OrdinalIgnoreCase)) string.Join(", ", Request.Headers.Where(h => h.Key.Contains("Auth", StringComparison.OrdinalIgnoreCase))
.Select(h => $"{h.Key}={h.Value}"))); .Select(h => $"{h.Key}={h.Value}")));
@@ -2738,11 +2746,11 @@ public class JellyfinController : ControllerBase
if (result != null) if (result != null)
{ {
_logger.LogInformation("✓ Session request proxied successfully ({StatusCode})", statusCode); _logger.LogDebug("✓ Session request proxied successfully ({StatusCode})", statusCode);
return new JsonResult(result.RootElement.Clone()); return new JsonResult(result.RootElement.Clone());
} }
_logger.LogInformation("✓ Session request proxied ({StatusCode}, no body)", statusCode); _logger.LogDebug("✓ Session request proxied ({StatusCode}, no body)", statusCode);
return StatusCode(statusCode); return StatusCode(statusCode);
} }
catch (Exception ex) catch (Exception ex)
@@ -2800,7 +2808,7 @@ public class JellyfinController : ControllerBase
if (path.Contains("session", StringComparison.OrdinalIgnoreCase) || if (path.Contains("session", StringComparison.OrdinalIgnoreCase) ||
path.Contains("capabilit", StringComparison.OrdinalIgnoreCase)) path.Contains("capabilit", StringComparison.OrdinalIgnoreCase))
{ {
_logger.LogWarning("🔍 SESSION/CAPABILITY REQUEST: {Method} /{Path}{Query}", Request.Method, path, Request.QueryString); _logger.LogDebug("🔍 SESSION/CAPABILITY REQUEST: {Method} /{Path}{Query}", Request.Method, path, Request.QueryString);
} }
else else
{ {
@@ -3908,7 +3916,7 @@ public class JellyfinController : ControllerBase
} }
// Build kept folder path: Artist/Album/ // Build kept folder path: Artist/Album/
var keptBasePath = _configuration["Library:KeptPath"] ?? "/app/kept"; var keptBasePath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
var keptArtistPath = Path.Combine(keptBasePath, PathHelper.SanitizeFileName(song.Artist)); var keptArtistPath = Path.Combine(keptBasePath, PathHelper.SanitizeFileName(song.Artist));
var keptAlbumPath = Path.Combine(keptArtistPath, PathHelper.SanitizeFileName(song.Album)); var keptAlbumPath = Path.Combine(keptArtistPath, PathHelper.SanitizeFileName(song.Album));
@@ -4204,7 +4212,7 @@ public class JellyfinController : ControllerBase
var song = await _metadataService.GetSongAsync(provider!, externalId!); var song = await _metadataService.GetSongAsync(provider!, externalId!);
if (song == null) return; if (song == null) return;
var keptBasePath = _configuration["Library:KeptPath"] ?? "/app/kept"; var keptBasePath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
var keptArtistPath = Path.Combine(keptBasePath, PathHelper.SanitizeFileName(song.Artist)); var keptArtistPath = Path.Combine(keptBasePath, PathHelper.SanitizeFileName(song.Artist));
var keptAlbumPath = Path.Combine(keptArtistPath, PathHelper.SanitizeFileName(song.Album)); var keptAlbumPath = Path.Combine(keptArtistPath, PathHelper.SanitizeFileName(song.Album));

View File

@@ -2,239 +2,44 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using allstarr.Models.Settings; using allstarr.Models.Settings;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace allstarr.Filters; namespace allstarr.Filters;
/// <summary> /// <summary>
/// Authentication filter for Jellyfin API endpoints. /// REMOVED: Authentication filter for Jellyfin API endpoints.
/// Validates client credentials against configured username and API key. ///
/// Clients can authenticate via: /// This filter has been removed because Allstarr acts as a TRANSPARENT PROXY.
/// - Authorization header: MediaBrowser Token="apikey" /// Clients authenticate directly with Jellyfin through the proxy, not with the proxy itself.
/// - X-Emby-Token header ///
/// - Query parameter: api_key /// Authentication flow:
/// - JSON body (for login endpoints): Username/Pw fields /// 1. Client sends credentials to /Users/AuthenticateByName
/// 2. Proxy forwards request to Jellyfin (no validation)
/// 3. Jellyfin validates credentials and returns AccessToken
/// 4. Client uses AccessToken in subsequent requests
/// 5. Proxy forwards token to Jellyfin for validation
///
/// The proxy NEVER validates credentials or tokens - that's Jellyfin's job.
/// The proxy only forwards authentication headers transparently.
///
/// If you need to restrict access to the proxy itself, use network-level controls
/// (firewall, VPN, reverse proxy with auth) instead of application-level auth.
/// </summary> /// </summary>
public partial class JellyfinAuthFilter : IAsyncActionFilter public class JellyfinAuthFilter : IAsyncActionFilter
{ {
private readonly JellyfinSettings _settings;
private readonly ILogger<JellyfinAuthFilter> _logger; private readonly ILogger<JellyfinAuthFilter> _logger;
public JellyfinAuthFilter( public JellyfinAuthFilter(ILogger<JellyfinAuthFilter> logger)
IOptions<JellyfinSettings> settings,
ILogger<JellyfinAuthFilter> logger)
{ {
_settings = settings.Value;
_logger = logger; _logger = logger;
} }
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{ {
// Skip auth if no credentials configured (open mode) // This filter is now a no-op - all authentication is handled by Jellyfin
if (string.IsNullOrEmpty(_settings.ClientUsername) || string.IsNullOrEmpty(_settings.ApiKey)) // Keeping the class for backwards compatibility but it does nothing
{
_logger.LogDebug("Auth skipped - no client credentials configured");
await next();
return;
}
var request = context.HttpContext.Request; _logger.LogTrace("JellyfinAuthFilter: Transparent proxy mode - no authentication check");
// Try to extract credentials from various sources
var (username, token) = await ExtractCredentialsAsync(request);
// Validate credentials
if (!ValidateCredentials(username, token))
{
_logger.LogWarning("Authentication failed for user '{Username}' from {IP}",
username ?? "unknown",
context.HttpContext.Connection.RemoteIpAddress);
context.Result = new UnauthorizedObjectResult(new
{
error = "Invalid credentials",
message = "Authentication required. Provide valid username and API key."
});
return;
}
_logger.LogDebug("Authentication successful for user '{Username}'", username);
await next(); await next();
} }
private async Task<(string? username, string? token)> ExtractCredentialsAsync(HttpRequest request)
{
string? username = null;
string? token = null;
// 1. Check Authorization header (MediaBrowser format)
if (request.Headers.TryGetValue("Authorization", out var authHeader))
{
var authValue = authHeader.ToString();
// Parse MediaBrowser auth header: MediaBrowser Client="...", Token="..."
if (authValue.StartsWith("MediaBrowser", StringComparison.OrdinalIgnoreCase))
{
token = ExtractTokenFromMediaBrowser(authValue);
username = ExtractUserIdFromMediaBrowser(authValue);
}
// Basic auth: Basic base64(username:password)
else if (authValue.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))
{
(username, token) = ParseBasicAuth(authValue);
}
}
// 2. Check X-Emby-Token header
if (string.IsNullOrEmpty(token) && request.Headers.TryGetValue("X-Emby-Token", out var embyToken))
{
token = embyToken.ToString();
}
// 3. Check X-MediaBrowser-Token header
if (string.IsNullOrEmpty(token) && request.Headers.TryGetValue("X-MediaBrowser-Token", out var mbToken))
{
token = mbToken.ToString();
}
// 4. Check X-Emby-Authorization header (alternative format)
if (string.IsNullOrEmpty(token) && request.Headers.TryGetValue("X-Emby-Authorization", out var embyAuth))
{
token = ExtractTokenFromMediaBrowser(embyAuth.ToString());
if (string.IsNullOrEmpty(username))
{
username = ExtractUserIdFromMediaBrowser(embyAuth.ToString());
}
}
// 5. Check query parameters
if (string.IsNullOrEmpty(token))
{
token = request.Query["api_key"].FirstOrDefault()
?? request.Query["ApiKey"].FirstOrDefault()
?? request.Query["X-Emby-Token"].FirstOrDefault();
}
if (string.IsNullOrEmpty(username))
{
username = request.Query["userId"].FirstOrDefault()
?? request.Query["UserId"].FirstOrDefault()
?? request.Query["u"].FirstOrDefault();
}
// 6. Check JSON body for login endpoints (Jellyfin: Username/Pw, Navidrome: username/password)
if ((string.IsNullOrEmpty(username) || string.IsNullOrEmpty(token)) &&
request.ContentType?.Contains("application/json", StringComparison.OrdinalIgnoreCase) == true &&
request.ContentLength > 0)
{
var (bodyUsername, bodyPassword) = await ExtractCredentialsFromBodyAsync(request);
if (string.IsNullOrEmpty(username)) username = bodyUsername;
if (string.IsNullOrEmpty(token)) token = bodyPassword;
}
return (username, token);
}
private async Task<(string? username, string? password)> ExtractCredentialsFromBodyAsync(HttpRequest request)
{
try
{
request.EnableBuffering();
request.Body.Position = 0;
using var reader = new StreamReader(request.Body, leaveOpen: true);
var body = await reader.ReadToEndAsync();
request.Body.Position = 0;
if (string.IsNullOrEmpty(body)) return (null, null);
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
// Try Jellyfin format: Username, Pw
string? username = null;
string? password = null;
if (root.TryGetProperty("Username", out var usernameProp))
username = usernameProp.GetString();
else if (root.TryGetProperty("username", out var usernameLowerProp))
username = usernameLowerProp.GetString();
if (root.TryGetProperty("Pw", out var pwProp))
password = pwProp.GetString();
else if (root.TryGetProperty("pw", out var pwLowerProp))
password = pwLowerProp.GetString();
else if (root.TryGetProperty("Password", out var passwordProp))
password = passwordProp.GetString();
else if (root.TryGetProperty("password", out var passwordLowerProp))
password = passwordLowerProp.GetString();
return (username, password);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to parse credentials from request body");
return (null, null);
}
}
private string? ExtractTokenFromMediaBrowser(string header)
{
var match = TokenRegex().Match(header);
return match.Success ? match.Groups[1].Value : null;
}
private string? ExtractUserIdFromMediaBrowser(string header)
{
var match = UserIdRegex().Match(header);
return match.Success ? match.Groups[1].Value : null;
}
private static (string? username, string? password) ParseBasicAuth(string authHeader)
{
try
{
var base64 = authHeader["Basic ".Length..].Trim();
var bytes = Convert.FromBase64String(base64);
var credentials = System.Text.Encoding.UTF8.GetString(bytes);
var parts = credentials.Split(':', 2);
return parts.Length == 2 ? (parts[0], parts[1]) : (null, null);
}
catch
{
return (null, null);
}
}
private bool ValidateCredentials(string? username, string? token)
{
// Must have token (API key used as password)
if (string.IsNullOrEmpty(token))
{
return false;
}
// Token must match API key
if (!string.Equals(token, _settings.ApiKey, StringComparison.Ordinal))
{
return false;
}
// If username provided, it must match configured client username
if (!string.IsNullOrEmpty(username) &&
!string.Equals(username, _settings.ClientUsername, StringComparison.OrdinalIgnoreCase))
{
return false;
}
return true;
}
[GeneratedRegex(@"Token=""([^""]+)""", RegexOptions.IgnoreCase)]
private static partial Regex TokenRegex();
[GeneratedRegex(@"UserId=""([^""]+)""", RegexOptions.IgnoreCase)]
private static partial Regex UserIdRegex();
} }

View File

@@ -54,7 +54,7 @@ public class WebSocketProxyMiddleware
if (context.Request.Path.StartsWithSegments("/socket", StringComparison.OrdinalIgnoreCase) && if (context.Request.Path.StartsWithSegments("/socket", StringComparison.OrdinalIgnoreCase) &&
context.WebSockets.IsWebSocketRequest) context.WebSockets.IsWebSocketRequest)
{ {
_logger.LogInformation("🔌 WEBSOCKET: WebSocket connection request received from {RemoteIp}", _logger.LogDebug("🔌 WEBSOCKET: WebSocket connection request received from {RemoteIp}",
context.Connection.RemoteIpAddress); context.Connection.RemoteIpAddress);
await HandleWebSocketProxyAsync(context); await HandleWebSocketProxyAsync(context);
@@ -142,7 +142,7 @@ public class WebSocketProxyMiddleware
serverWebSocket.Options.SetRequestHeader("User-Agent", "Allstarr/1.0"); serverWebSocket.Options.SetRequestHeader("User-Agent", "Allstarr/1.0");
await serverWebSocket.ConnectAsync(new Uri(jellyfinWsUrl), context.RequestAborted); await serverWebSocket.ConnectAsync(new Uri(jellyfinWsUrl), context.RequestAborted);
_logger.LogInformation("✓ WEBSOCKET: Connected to Jellyfin WebSocket"); _logger.LogDebug("✓ WEBSOCKET: Connected to Jellyfin WebSocket");
// Start bidirectional proxying // Start bidirectional proxying
var clientToServer = ProxyMessagesAsync(clientWebSocket, serverWebSocket, "Client→Server", context.RequestAborted); var clientToServer = ProxyMessagesAsync(clientWebSocket, serverWebSocket, "Client→Server", context.RequestAborted);
@@ -154,9 +154,17 @@ public class WebSocketProxyMiddleware
_logger.LogDebug("🔌 WEBSOCKET: WebSocket proxy connection closed"); _logger.LogDebug("🔌 WEBSOCKET: WebSocket proxy connection closed");
} }
catch (WebSocketException wsEx) catch (WebSocketException wsEx)
{
// 403 is expected when tokens expire or session ends - don't spam logs
if (wsEx.Message.Contains("403"))
{
_logger.LogDebug("WEBSOCKET: Connection rejected with 403 (token expired or session ended)");
}
else
{ {
_logger.LogWarning(wsEx, "⚠️ WEBSOCKET: WebSocket error: {Message}", wsEx.Message); _logger.LogWarning(wsEx, "⚠️ WEBSOCKET: WebSocket error: {Message}", wsEx.Message);
} }
}
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "❌ WEBSOCKET: Error in WebSocket proxy"); _logger.LogError(ex, "❌ WEBSOCKET: Error in WebSocket proxy");
@@ -194,7 +202,7 @@ public class WebSocketProxyMiddleware
// CRITICAL: Notify session manager that client disconnected // CRITICAL: Notify session manager that client disconnected
if (!string.IsNullOrEmpty(deviceId)) if (!string.IsNullOrEmpty(deviceId))
{ {
_logger.LogInformation("🧹 WEBSOCKET: Client disconnected, removing session for device {DeviceId}", deviceId); _logger.LogDebug("🧹 WEBSOCKET: Client disconnected, removing session for device {DeviceId}", deviceId);
await _sessionManager.RemoveSessionAsync(deviceId); await _sessionManager.RemoveSessionAsync(deviceId);
} }
@@ -239,7 +247,7 @@ public class WebSocketProxyMiddleware
if (direction == "Server→Client") if (direction == "Server→Client")
{ {
var messageText = System.Text.Encoding.UTF8.GetString(messageBytes); var messageText = System.Text.Encoding.UTF8.GetString(messageBytes);
_logger.LogInformation("📥 WEBSOCKET {Direction}: {Preview}", _logger.LogTrace("📥 WEBSOCKET {Direction}: {Preview}",
direction, direction,
messageText.Length > 500 ? messageText[..500] + "..." : messageText); messageText.Length > 500 ? messageText[..500] + "..." : messageText);
} }
@@ -274,7 +282,7 @@ public class WebSocketProxyMiddleware
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "⚠️ WEBSOCKET {Direction}: Error proxying messages", direction); _logger.LogDebug(ex, "WEBSOCKET {Direction}: Error proxying messages (connection closed)", direction);
} }
} }
} }

View File

@@ -59,27 +59,6 @@ public class SpotifyImportSettings
/// </summary> /// </summary>
public bool Enabled { get; set; } public bool Enabled { get; set; }
/// <summary>
/// Hour when Spotify Import plugin runs (24-hour format, 0-23)
/// NOTE: This setting is now optional and only used for the sync window check.
/// The fetcher will search backwards from current time for the last 48 hours,
/// so timezone confusion is avoided.
/// </summary>
public int SyncStartHour { get; set; } = 16;
/// <summary>
/// Minute when Spotify Import plugin runs (0-59)
/// NOTE: This setting is now optional and only used for the sync window check.
/// </summary>
public int SyncStartMinute { get; set; } = 15;
/// <summary>
/// How many hours to search for missing tracks files after sync start time
/// This prevents the fetcher from running too frequently.
/// Set to 0 to disable the sync window check and always search on startup.
/// </summary>
public int SyncWindowHours { get; set; } = 2;
/// <summary> /// <summary>
/// How often to run track matching in hours. /// How often to run track matching in hours.
/// Spotify playlists like Discover Weekly update once per week, Release Radar updates weekly. /// Spotify playlists like Discover Weekly update once per week, Release Radar updates weekly.

View File

@@ -22,16 +22,21 @@ static List<string> DecodeSquidWtfUrls()
{ {
var encodedUrls = new[] var encodedUrls = new[]
{ {
"aHR0cHM6Ly90cml0b24uc3F1aWQud3Rm", // triton "aHR0cHM6Ly90cml0b24uc3F1aWQud3Rm", // triton.squid.wtf
"aHR0cHM6Ly90aWRhbC1hcGkuYmluaW11bS5vcmc=", // binimum "aHR0cHM6Ly90aWRhbC1hcGkuYmluaW11bS5vcmc=", // tidal-api.binimum.org
"aHR0cHM6Ly90aWRhbC5raW5vcGx1cy5vbmxpbmU=", // kinoplus "aHR0cHM6Ly90aWRhbC5raW5vcGx1cy5vbmxpbmU=", // tidal.kinoplus.online
"aHR0cHM6Ly9oaWZpLXR3by5zcG90aXNhdmVyLm5ldA==", // spoti-2 "aHR0cHM6Ly9oaWZpLXR3by5zcG90aXNhdmVyLm5ldA==", // hifi-two.spotisaver.net
"aHR0cHM6Ly9oaWZpLW9uZS5zcG90aXNhdmVyLm5ldA==", // spoti-1 "aHR0cHM6Ly9oaWZpLW9uZS5zcG90aXNhdmVyLm5ldA==", // hifi-one.spotisaver.net
"aHR0cHM6Ly93b2xmLnFxZGwuc2l0ZQ==", // wolf "aHR0cHM6Ly93b2xmLnFxZGwuc2l0ZQ==", // wolf.qqdl.site
"aHR0cDovL2h1bmQucXFkbC5zaXRl", // hund "aHR0cDovL2h1bmQucXFkbC5zaXRl", // hund.qqdl.site (http)
"aHR0cHM6Ly9rYXR6ZS5xcWRsLnNpdGU=", // katze "aHR0cHM6Ly9rYXR6ZS5xcWRsLnNpdGU=", // katze.qqdl.site
"aHR0cHM6Ly92b2dlbC5xcWRsLnNpdGU=", // vogel "aHR0cHM6Ly92b2dlbC5xcWRsLnNpdGU=", // vogel.qqdl.site
"aHR0cHM6Ly9tYXVzLnFxZGwuc2l0ZQ==" // maus "aHR0cHM6Ly9tYXVzLnFxZGwuc2l0ZQ==", // maus.qqdl.site
"aHR0cHM6Ly9ldS1jZW50cmFsLm1vbm9jaHJvbWUudGY=", // eu-central.monochrome.tf
"aHR0cHM6Ly91cy13ZXN0Lm1vbm9jaHJvbWUudGY=", // us-west.monochrome.tf
"aHR0cHM6Ly9hcnJhbi5tb25vY2hyb21lLnRm", // arran.monochrome.tf
"aHR0cHM6Ly9hcGkubW9ub2Nocm9tZS50Zg==", // api.monochrome.tf
"aHR0cHM6Ly9odW5kLnFxZGwuc2l0ZQ==" // hund.qqdl.site (https)
}; };
return encodedUrls return encodedUrls
@@ -353,7 +358,7 @@ builder.Services.Configure<SpotifyImportSettings>(options =>
} }
// Log configuration at startup // Log configuration at startup
Console.WriteLine($"Spotify Import: Enabled={options.Enabled}, SyncHour={options.SyncStartHour}:{options.SyncStartMinute:D2}, WindowHours={options.SyncWindowHours}"); Console.WriteLine($"Spotify Import: Enabled={options.Enabled}, MatchingInterval={options.MatchingIntervalHours}h");
Console.WriteLine($"Spotify Import Playlists: {options.Playlists.Count} configured"); Console.WriteLine($"Spotify Import Playlists: {options.Playlists.Count} configured");
foreach (var playlist in options.Playlists) foreach (var playlist in options.Playlists)
{ {

View File

@@ -95,23 +95,89 @@ public abstract class BaseDownloadService : IDownloadService
public async Task<Stream> DownloadAndStreamAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) public async Task<Stream> DownloadAndStreamAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
{ {
var startTime = DateTime.UtcNow;
// Check if already downloaded locally // Check if already downloaded locally
var localPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId); var localPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId);
if (localPath != null && IOFile.Exists(localPath)) if (localPath != null && IOFile.Exists(localPath))
{ {
Logger.LogInformation("Streaming from local cache: {Path}", localPath); var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
Logger.LogInformation("Streaming from local cache ({ElapsedMs}ms): {Path}", elapsed, localPath);
// Update access time for cache cleanup
if (SubsonicSettings.StorageMode == StorageMode.Cache)
{
IOFile.SetLastAccessTime(localPath, DateTime.UtcNow);
}
// Start background Odesli conversion for lyrics (if not already cached)
StartBackgroundOdesliConversion(externalProvider, externalId);
return IOFile.OpenRead(localPath); return IOFile.OpenRead(localPath);
} }
// For on-demand streaming, download to disk first to ensure complete file // Download to disk first to ensure complete file with metadata
// This is necessary because: // This is necessary because:
// 1. Clients may seek to arbitrary positions (requires full file) // 1. Clients may seek to arbitrary positions (requires full file)
// 2. Metadata embedding requires complete file // 2. Metadata embedding requires complete file
// 3. Caching for future plays // 3. Caching for future plays
Logger.LogInformation("Downloading song for streaming: {Provider}:{ExternalId}", externalProvider, externalId); Logger.LogInformation("Downloading song for streaming: {Provider}:{ExternalId}", externalProvider, externalId);
try
{
localPath = await DownloadSongInternalAsync(externalProvider, externalId, triggerAlbumDownload: true, cancellationToken); 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); return IOFile.OpenRead(localPath);
} }
catch (OperationCanceledException)
{
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
Logger.LogWarning("Download cancelled by client after {ElapsedMs}ms for {Provider}:{ExternalId}", elapsed, externalProvider, externalId);
throw;
}
catch (Exception ex)
{
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
Logger.LogError(ex, "Download failed after {ElapsedMs}ms for {Provider}:{ExternalId}", elapsed, externalProvider, externalId);
throw;
}
}
/// <summary>
/// Starts background Odesli conversion for lyrics support.
/// This is called AFTER streaming starts so it doesn't block the client.
/// </summary>
private void StartBackgroundOdesliConversion(string externalProvider, string externalId)
{
_ = Task.Run(async () =>
{
try
{
// Provider-specific conversion (override in subclasses if needed)
await ConvertToSpotifyIdAsync(externalProvider, externalId);
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Background Spotify ID conversion failed for {Provider}:{ExternalId}", externalProvider, externalId);
}
});
}
/// <summary>
/// Converts external track ID to Spotify ID for lyrics support.
/// Override in provider-specific services if needed.
/// </summary>
protected virtual Task ConvertToSpotifyIdAsync(string externalProvider, string externalId)
{
// Default implementation does nothing
// Provider-specific services can override this
return Task.CompletedTask;
}
public DownloadInfo? GetDownloadStatus(string songId) public DownloadInfo? GetDownloadStatus(string songId)
{ {
@@ -219,21 +285,26 @@ public abstract class BaseDownloadService : IDownloadService
// Check if download in progress // Check if download in progress
if (ActiveDownloads.TryGetValue(songId, out var activeDownload) && activeDownload.Status == DownloadStatus.InProgress) if (ActiveDownloads.TryGetValue(songId, out var activeDownload) && activeDownload.Status == DownloadStatus.InProgress)
{ {
Logger.LogInformation("Download already in progress for {SongId}, waiting...", songId); Logger.LogDebug("Download already in progress for {SongId}, waiting for completion...", songId);
// Release lock while waiting // Release lock while waiting
DownloadLock.Release(); DownloadLock.Release();
// Wait for download to complete, checking every 100ms (faster than 500ms)
// Also respect cancellation token so client timeouts are handled immediately
while (ActiveDownloads.TryGetValue(songId, out activeDownload) && activeDownload.Status == DownloadStatus.InProgress) while (ActiveDownloads.TryGetValue(songId, out activeDownload) && activeDownload.Status == DownloadStatus.InProgress)
{ {
await Task.Delay(500, cancellationToken); cancellationToken.ThrowIfCancellationRequested();
await Task.Delay(100, cancellationToken);
} }
if (activeDownload?.Status == DownloadStatus.Completed && activeDownload.LocalPath != null) if (activeDownload?.Status == DownloadStatus.Completed && activeDownload.LocalPath != null)
{ {
Logger.LogDebug("Download completed while waiting, returning path: {Path}", activeDownload.LocalPath);
return activeDownload.LocalPath; return activeDownload.LocalPath;
} }
throw new Exception(activeDownload?.ErrorMessage ?? "Download failed"); // Download failed or was cancelled
throw new Exception(activeDownload?.ErrorMessage ?? "Download failed while waiting");
} }
// Get metadata // Get metadata

View File

@@ -11,6 +11,12 @@ public class RoundRobinFallbackHelper
private readonly object _urlIndexLock = new object(); private readonly object _urlIndexLock = new object();
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly string _serviceName; private readonly string _serviceName;
private readonly HttpClient _healthCheckClient;
// Cache health check results for 30 seconds to avoid excessive checks
private readonly Dictionary<string, (bool isHealthy, DateTime checkedAt)> _healthCache = new();
private readonly object _healthCacheLock = new object();
private readonly TimeSpan _healthCacheExpiry = TimeSpan.FromSeconds(30);
public int EndpointCount => _apiUrls.Count; public int EndpointCount => _apiUrls.Count;
@@ -24,6 +30,91 @@ public class RoundRobinFallbackHelper
{ {
throw new ArgumentException("API URLs list cannot be empty", nameof(apiUrls)); throw new ArgumentException("API URLs list cannot be empty", nameof(apiUrls));
} }
// Create a dedicated HttpClient for health checks with short timeout
_healthCheckClient = new HttpClient
{
Timeout = TimeSpan.FromSeconds(3) // Quick health check timeout
};
}
/// <summary>
/// Quickly checks if an endpoint is healthy (responds within 3 seconds).
/// Results are cached for 30 seconds to avoid excessive health checks.
/// </summary>
private async Task<bool> IsEndpointHealthyAsync(string baseUrl)
{
// Check cache first
lock (_healthCacheLock)
{
if (_healthCache.TryGetValue(baseUrl, out var cached))
{
if (DateTime.UtcNow - cached.checkedAt < _healthCacheExpiry)
{
return cached.isHealthy;
}
}
}
// Perform health check
try
{
var response = await _healthCheckClient.GetAsync(baseUrl, HttpCompletionOption.ResponseHeadersRead);
var isHealthy = response.IsSuccessStatusCode;
// Cache result
lock (_healthCacheLock)
{
_healthCache[baseUrl] = (isHealthy, DateTime.UtcNow);
}
if (!isHealthy)
{
_logger.LogDebug("{Service} endpoint {Endpoint} health check failed: {StatusCode}",
_serviceName, baseUrl, response.StatusCode);
}
return isHealthy;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "{Service} endpoint {Endpoint} health check failed", _serviceName, baseUrl);
// Cache as unhealthy
lock (_healthCacheLock)
{
_healthCache[baseUrl] = (false, DateTime.UtcNow);
}
return false;
}
}
/// <summary>
/// Gets a list of healthy endpoints, checking them in parallel.
/// Falls back to all endpoints if none are healthy.
/// </summary>
private async Task<List<string>> GetHealthyEndpointsAsync()
{
var healthCheckTasks = _apiUrls.Select(async url => new
{
Url = url,
IsHealthy = await IsEndpointHealthyAsync(url)
}).ToList();
var results = await Task.WhenAll(healthCheckTasks);
var healthyEndpoints = results.Where(r => r.IsHealthy).Select(r => r.Url).ToList();
if (healthyEndpoints.Count == 0)
{
_logger.LogWarning("{Service} health check: no healthy endpoints found, will try all", _serviceName);
return _apiUrls;
}
_logger.LogDebug("{Service} health check: {Healthy}/{Total} endpoints healthy",
_serviceName, healthyEndpoints.Count, _apiUrls.Count);
return healthyEndpoints;
} }
/// <summary> /// <summary>
@@ -54,10 +145,14 @@ public class RoundRobinFallbackHelper
/// <summary> /// <summary>
/// Tries the request with the next provider in round-robin, then falls back to others on failure. /// Tries the request with the next provider in round-robin, then falls back to others on failure.
/// This distributes load evenly across all providers while maintaining reliability. /// This distributes load evenly across all providers while maintaining reliability.
/// Performs quick health checks first to avoid wasting time on dead endpoints.
/// Throws exception if all endpoints fail. /// Throws exception if all endpoints fail.
/// </summary> /// </summary>
public async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action) public async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action)
{ {
// Get healthy endpoints first (with caching to avoid excessive checks)
var healthyEndpoints = await GetHealthyEndpointsAsync();
// Start with the next URL in round-robin to distribute load // Start with the next URL in round-robin to distribute load
var startIndex = 0; var startIndex = 0;
lock (_urlIndexLock) lock (_urlIndexLock)
@@ -66,16 +161,21 @@ public class RoundRobinFallbackHelper
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count; _currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
} }
// Try healthy endpoints first, then fall back to all if needed
var endpointsToTry = healthyEndpoints.Count < _apiUrls.Count
? healthyEndpoints.Concat(_apiUrls.Except(healthyEndpoints)).ToList()
: healthyEndpoints;
// Try all URLs starting from the round-robin selected one // Try all URLs starting from the round-robin selected one
for (int attempt = 0; attempt < _apiUrls.Count; attempt++) for (int attempt = 0; attempt < endpointsToTry.Count; attempt++)
{ {
var urlIndex = (startIndex + attempt) % _apiUrls.Count; var urlIndex = (startIndex + attempt) % endpointsToTry.Count;
var baseUrl = _apiUrls[urlIndex]; var baseUrl = endpointsToTry[urlIndex];
try try
{ {
_logger.LogDebug("Trying {Service} endpoint {Endpoint} (attempt {Attempt}/{Total})", _logger.LogDebug("Trying {Service} endpoint {Endpoint} (attempt {Attempt}/{Total})",
_serviceName, baseUrl, attempt + 1, _apiUrls.Count); _serviceName, baseUrl, attempt + 1, endpointsToTry.Count);
return await action(baseUrl); return await action(baseUrl);
} }
catch (Exception ex) catch (Exception ex)
@@ -83,9 +183,15 @@ public class RoundRobinFallbackHelper
_logger.LogWarning(ex, "{Service} request failed with endpoint {Endpoint}, trying next...", _logger.LogWarning(ex, "{Service} request failed with endpoint {Endpoint}, trying next...",
_serviceName, baseUrl); _serviceName, baseUrl);
if (attempt == _apiUrls.Count - 1) // Mark as unhealthy in cache
lock (_healthCacheLock)
{ {
_logger.LogError("All {Count} {Service} endpoints failed", _apiUrls.Count, _serviceName); _healthCache[baseUrl] = (false, DateTime.UtcNow);
}
if (attempt == endpointsToTry.Count - 1)
{
_logger.LogError("All {Count} {Service} endpoints failed", endpointsToTry.Count, _serviceName);
throw; throw;
} }
} }
@@ -150,10 +256,14 @@ public class RoundRobinFallbackHelper
/// <summary> /// <summary>
/// Tries the request with the next provider in round-robin, then falls back to others on failure. /// Tries the request with the next provider in round-robin, then falls back to others on failure.
/// Performs quick health checks first to avoid wasting time on dead endpoints.
/// Returns default value if all endpoints fail (does not throw). /// Returns default value if all endpoints fail (does not throw).
/// </summary> /// </summary>
public async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action, T defaultValue) public async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action, T defaultValue)
{ {
// Get healthy endpoints first (with caching to avoid excessive checks)
var healthyEndpoints = await GetHealthyEndpointsAsync();
// Start with the next URL in round-robin to distribute load // Start with the next URL in round-robin to distribute load
var startIndex = 0; var startIndex = 0;
lock (_urlIndexLock) lock (_urlIndexLock)
@@ -162,16 +272,21 @@ public class RoundRobinFallbackHelper
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count; _currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
} }
// Try healthy endpoints first, then fall back to all if needed
var endpointsToTry = healthyEndpoints.Count < _apiUrls.Count
? healthyEndpoints.Concat(_apiUrls.Except(healthyEndpoints)).ToList()
: healthyEndpoints;
// Try all URLs starting from the round-robin selected one // Try all URLs starting from the round-robin selected one
for (int attempt = 0; attempt < _apiUrls.Count; attempt++) for (int attempt = 0; attempt < endpointsToTry.Count; attempt++)
{ {
var urlIndex = (startIndex + attempt) % _apiUrls.Count; var urlIndex = (startIndex + attempt) % endpointsToTry.Count;
var baseUrl = _apiUrls[urlIndex]; var baseUrl = endpointsToTry[urlIndex];
try try
{ {
_logger.LogDebug("Trying {Service} endpoint {Endpoint} (attempt {Attempt}/{Total})", _logger.LogDebug("Trying {Service} endpoint {Endpoint} (attempt {Attempt}/{Total})",
_serviceName, baseUrl, attempt + 1, _apiUrls.Count); _serviceName, baseUrl, attempt + 1, endpointsToTry.Count);
return await action(baseUrl); return await action(baseUrl);
} }
catch (Exception ex) catch (Exception ex)
@@ -179,10 +294,16 @@ public class RoundRobinFallbackHelper
_logger.LogWarning(ex, "{Service} request failed with endpoint {Endpoint}, trying next...", _logger.LogWarning(ex, "{Service} request failed with endpoint {Endpoint}, trying next...",
_serviceName, baseUrl); _serviceName, baseUrl);
if (attempt == _apiUrls.Count - 1) // Mark as unhealthy in cache
lock (_healthCacheLock)
{
_healthCache[baseUrl] = (false, DateTime.UtcNow);
}
if (attempt == endpointsToTry.Count - 1)
{ {
_logger.LogError("All {Count} {Service} endpoints failed, returning default value", _logger.LogError("All {Count} {Service} endpoints failed, returning default value",
_apiUrls.Count, _serviceName); endpointsToTry.Count, _serviceName);
return defaultValue; return defaultValue;
} }
} }

View File

@@ -107,10 +107,10 @@ public class DeezerDownloadService : BaseDownloadService
// Build organized folder structure: Artist/Album/Track using AlbumArtist (fallback to Artist for singles) // Build organized folder structure: Artist/Album/Track using AlbumArtist (fallback to Artist for singles)
var artistForPath = song.AlbumArtist ?? song.Artist; var artistForPath = song.AlbumArtist ?? song.Artist;
// Cache mode uses cache/Music folder (cleaned up after 24h), Permanent mode uses downloads folder // Cache mode uses downloads/cache/ folder, Permanent mode uses downloads/permanent/
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache var basePath = SubsonicSettings.StorageMode == StorageMode.Cache
? Path.Combine("cache", "Music") ? Path.Combine("downloads", "cache")
: "downloads"; : Path.Combine("downloads", "permanent");
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension); var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension);
// Create directories if they don't exist // Create directories if they don't exist

View File

@@ -168,6 +168,11 @@ public class JellyfinProxyService
(h.Value.ToString().Contains("image", StringComparison.OrdinalIgnoreCase) || (h.Value.ToString().Contains("image", StringComparison.OrdinalIgnoreCase) ||
h.Value.ToString().Contains("document", StringComparison.OrdinalIgnoreCase))) == true); h.Value.ToString().Contains("document", StringComparison.OrdinalIgnoreCase))) == true);
// Check if this is a public endpoint that doesn't require authentication
bool isPublicEndpoint = url.Contains("/System/Info/Public", StringComparison.OrdinalIgnoreCase) ||
url.Contains("/Branding/", StringComparison.OrdinalIgnoreCase) ||
url.Contains("/Startup/", StringComparison.OrdinalIgnoreCase);
// Forward authentication headers from client if provided // Forward authentication headers from client if provided
if (clientHeaders != null && clientHeaders.Count > 0) if (clientHeaders != null && clientHeaders.Count > 0)
{ {
@@ -179,11 +184,27 @@ public class JellyfinProxyService
var headerValue = header.Value.ToString(); var headerValue = header.Value.ToString();
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue); request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
authHeaderAdded = true; authHeaderAdded = true;
_logger.LogInformation("Forwarded X-Emby-Authorization: {Value}", headerValue); _logger.LogTrace("Forwarded X-Emby-Authorization header");
break; break;
} }
} }
// Try X-Emby-Token (simpler format used by some clients)
if (!authHeaderAdded)
{
foreach (var header in clientHeaders)
{
if (header.Key.Equals("X-Emby-Token", StringComparison.OrdinalIgnoreCase))
{
var headerValue = header.Value.ToString();
request.Headers.TryAddWithoutValidation("X-Emby-Token", headerValue);
authHeaderAdded = true;
_logger.LogTrace("Forwarded X-Emby-Token header");
break;
}
}
}
// If no X-Emby-Authorization, check if Authorization header contains MediaBrowser format // If no X-Emby-Authorization, check if Authorization header contains MediaBrowser format
// Some clients send it as "Authorization" instead of "X-Emby-Authorization" // Some clients send it as "Authorization" instead of "X-Emby-Authorization"
if (!authHeaderAdded) if (!authHeaderAdded)
@@ -201,37 +222,32 @@ public class JellyfinProxyService
// Forward as X-Emby-Authorization (Jellyfin's expected header) // Forward as X-Emby-Authorization (Jellyfin's expected header)
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue); request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
authHeaderAdded = true; authHeaderAdded = true;
_logger.LogInformation("Converted Authorization to X-Emby-Authorization: {Value}", headerValue); _logger.LogTrace("Converted Authorization to X-Emby-Authorization");
} }
else else
{ {
// Standard Bearer token - forward as-is // Standard Bearer token - forward as-is
request.Headers.TryAddWithoutValidation("Authorization", headerValue); request.Headers.TryAddWithoutValidation("Authorization", headerValue);
authHeaderAdded = true; authHeaderAdded = true;
_logger.LogInformation("Forwarded Authorization (Bearer): {Value}", headerValue); _logger.LogTrace("Forwarded Authorization header");
} }
break; break;
} }
} }
} }
// Only log warnings for non-browser static requests // Check for api_key query parameter (some clients use this)
if (!authHeaderAdded && !isBrowserStaticRequest) if (!authHeaderAdded && url.Contains("api_key=", StringComparison.OrdinalIgnoreCase))
{ {
_logger.LogWarning("✗ No auth header found. Available headers: {Headers}", authHeaderAdded = true; // It's in the URL, no need to add header
string.Join(", ", clientHeaders.Select(h => $"{h.Key}={h.Value}"))); _logger.LogTrace("Using api_key from query string");
} }
} }
else if (!isBrowserStaticRequest)
{
_logger.LogWarning("✗ No client headers provided for {Url}", url);
}
// DO NOT use server API key as fallback - let Jellyfin handle unauthenticated requests // Only log warnings for non-public, non-browser requests without auth
// If client doesn't provide auth, they get what they deserve (401 from Jellyfin) if (!authHeaderAdded && !isBrowserStaticRequest && !isPublicEndpoint)
if (!authHeaderAdded && !isBrowserStaticRequest)
{ {
_logger.LogInformation("No client auth provided for {Url} - forwarding without auth", url); _logger.LogDebug("No client auth provided for {Url} - Jellyfin will handle authentication", url);
} }
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
@@ -248,14 +264,28 @@ public class JellyfinProxyService
{ {
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{ {
_logger.LogWarning("Jellyfin returned 401 Unauthorized for {Url} - passing through to client", url); // 401 means token expired or invalid - client needs to re-authenticate
_logger.LogInformation("Jellyfin returned 401 Unauthorized for {Url} - client should re-authenticate", url);
} }
else if (!isBrowserStaticRequest) // Don't log 404s for browser static requests else if (!isBrowserStaticRequest && !isPublicEndpoint)
{ {
_logger.LogWarning("Jellyfin request failed: {StatusCode} for {Url}", response.StatusCode, url); _logger.LogWarning("Jellyfin request failed: {StatusCode} for {Url}", response.StatusCode, url);
} }
// Return null body with the actual status code // Try to parse error response to pass through to client
if (!string.IsNullOrWhiteSpace(content))
{
try
{
var errorDoc = JsonDocument.Parse(content);
return (errorDoc, statusCode);
}
catch
{
// Not valid JSON, return null
}
}
return (null, statusCode); return (null, statusCode);
} }
@@ -297,8 +327,10 @@ public class JellyfinProxyService
request.Content = new StringContent(bodyToSend, System.Text.Encoding.UTF8, "application/json"); request.Content = new StringContent(bodyToSend, System.Text.Encoding.UTF8, "application/json");
bool authHeaderAdded = false; bool authHeaderAdded = false;
bool isAuthEndpoint = endpoint.Contains("Authenticate", StringComparison.OrdinalIgnoreCase);
// Forward authentication headers from client (case-insensitive) // Forward authentication headers from client (case-insensitive)
// Try X-Emby-Authorization first
foreach (var header in clientHeaders) foreach (var header in clientHeaders)
{ {
if (header.Key.Equals("X-Emby-Authorization", StringComparison.OrdinalIgnoreCase)) if (header.Key.Equals("X-Emby-Authorization", StringComparison.OrdinalIgnoreCase))
@@ -306,11 +338,28 @@ public class JellyfinProxyService
var headerValue = header.Value.ToString(); var headerValue = header.Value.ToString();
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue); request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
authHeaderAdded = true; authHeaderAdded = true;
_logger.LogDebug("Forwarded X-Emby-Authorization from client"); _logger.LogTrace("Forwarded X-Emby-Authorization header");
break; break;
} }
} }
// Try X-Emby-Token
if (!authHeaderAdded)
{
foreach (var header in clientHeaders)
{
if (header.Key.Equals("X-Emby-Token", StringComparison.OrdinalIgnoreCase))
{
var headerValue = header.Value.ToString();
request.Headers.TryAddWithoutValidation("X-Emby-Token", headerValue);
authHeaderAdded = true;
_logger.LogTrace("Forwarded X-Emby-Token header");
break;
}
}
}
// Try Authorization header
if (!authHeaderAdded) if (!authHeaderAdded)
{ {
foreach (var header in clientHeaders) foreach (var header in clientHeaders)
@@ -325,13 +374,13 @@ public class JellyfinProxyService
{ {
// Forward as X-Emby-Authorization // Forward as X-Emby-Authorization
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue); request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
_logger.LogDebug("Converted Authorization to X-Emby-Authorization"); _logger.LogTrace("Converted Authorization to X-Emby-Authorization");
} }
else else
{ {
// Standard Bearer token // Standard Bearer token
request.Headers.TryAddWithoutValidation("Authorization", headerValue); request.Headers.TryAddWithoutValidation("Authorization", headerValue);
_logger.LogDebug("Forwarded Authorization header"); _logger.LogTrace("Forwarded Authorization header");
} }
authHeaderAdded = true; authHeaderAdded = true;
break; break;
@@ -339,30 +388,23 @@ public class JellyfinProxyService
} }
} }
// DO NOT use server credentials as fallback // For authentication endpoints, credentials are in the body, not headers
// Exception: For auth endpoints, client provides their own credentials in the body // For other endpoints without auth, let Jellyfin reject the request
// For all other endpoints, if client doesn't provide auth, let Jellyfin reject it if (!authHeaderAdded && !isAuthEndpoint)
if (!authHeaderAdded)
{ {
_logger.LogInformation("No client auth provided for POST {Url} - forwarding without auth", url); _logger.LogDebug("No client auth provided for POST {Url} - Jellyfin will handle authentication", url);
} }
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
// DO NOT log the body for auth endpoints - it contains passwords! // DO NOT log the body for auth endpoints - it contains passwords!
if (endpoint.Contains("Authenticate", StringComparison.OrdinalIgnoreCase)) if (isAuthEndpoint)
{ {
_logger.LogDebug("POST to Jellyfin: {Url} (auth request - body not logged)", url); _logger.LogDebug("POST to Jellyfin: {Url} (auth request - body not logged)", url);
} }
else else
{ {
_logger.LogInformation("POST to Jellyfin: {Url}, body length: {Length} bytes", url, bodyToSend.Length); _logger.LogTrace("POST to Jellyfin: {Url}, body length: {Length} bytes", url, bodyToSend.Length);
// Log body content for playback endpoints to debug
if (endpoint.Contains("Playing", StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation("Sending body to Jellyfin: {Body}", bodyToSend);
}
} }
var response = await _httpClient.SendAsync(request); var response = await _httpClient.SendAsync(request);
@@ -372,8 +414,17 @@ public class JellyfinProxyService
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
var errorContent = await response.Content.ReadAsStringAsync(); var errorContent = await response.Content.ReadAsStringAsync();
_logger.LogWarning("❌ SESSION: Jellyfin POST request failed: {StatusCode} for {Url}. Response: {Response}",
response.StatusCode, url, errorContent); // 401 is expected when tokens expire - don't spam logs
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
_logger.LogInformation("Jellyfin POST returned 401 for {Url} - client should re-authenticate", url);
}
else
{
_logger.LogWarning("Jellyfin POST request failed: {StatusCode} for {Url}. Response: {Response}",
response.StatusCode, url, errorContent.Length > 200 ? errorContent[..200] + "..." : errorContent);
}
// Try to parse error response as JSON to pass through to client // Try to parse error response as JSON to pass through to client
if (!string.IsNullOrWhiteSpace(errorContent)) if (!string.IsNullOrWhiteSpace(errorContent))
@@ -395,7 +446,7 @@ public class JellyfinProxyService
// Log successful session-related responses // Log successful session-related responses
if (endpoint.Contains("Sessions", StringComparison.OrdinalIgnoreCase)) if (endpoint.Contains("Sessions", StringComparison.OrdinalIgnoreCase))
{ {
_logger.LogWarning("✓ SESSION: Jellyfin responded {StatusCode} for {Endpoint}", statusCode, endpoint); _logger.LogTrace("Jellyfin responded {StatusCode} for {Endpoint}", statusCode, endpoint);
} }
// Handle 204 No Content responses (e.g., /sessions/playing, /sessions/playing/progress) // Handle 204 No Content responses (e.g., /sessions/playing, /sessions/playing/progress)
@@ -412,13 +463,6 @@ public class JellyfinProxyService
return (null, statusCode); return (null, statusCode);
} }
// Log response content for session endpoints
if (endpoint.Contains("Sessions", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(responseContent))
{
var preview = responseContent.Length > 200 ? responseContent[..200] + "..." : responseContent;
_logger.LogWarning("📥 SESSION: Jellyfin response body: {Body}", preview);
}
return (JsonDocument.Parse(responseContent), statusCode); return (JsonDocument.Parse(responseContent), statusCode);
} }

View File

@@ -38,12 +38,13 @@ public class JellyfinSessionManager : IDisposable
/// <summary> /// <summary>
/// Ensures a session exists for the given device. Creates one if needed. /// Ensures a session exists for the given device. Creates one if needed.
/// Returns false if token is expired (401), indicating client needs to re-authenticate.
/// </summary> /// </summary>
public async Task<bool> EnsureSessionAsync(string deviceId, string client, string device, string version, IHeaderDictionary headers) public async Task<bool> EnsureSessionAsync(string deviceId, string client, string device, string version, IHeaderDictionary headers)
{ {
if (string.IsNullOrEmpty(deviceId)) if (string.IsNullOrEmpty(deviceId))
{ {
_logger.LogWarning("⚠️ SESSION: Cannot create session - no device ID"); _logger.LogWarning("Cannot create session - no device ID");
return false; return false;
} }
@@ -51,25 +52,37 @@ public class JellyfinSessionManager : IDisposable
if (_sessions.TryGetValue(deviceId, out var existingSession)) if (_sessions.TryGetValue(deviceId, out var existingSession))
{ {
existingSession.LastActivity = DateTime.UtcNow; existingSession.LastActivity = DateTime.UtcNow;
_logger.LogDebug("✓ SESSION: Session already exists for device {DeviceId}", deviceId); _logger.LogTrace("Session already exists for device {DeviceId}", deviceId);
// Refresh capabilities to keep session alive // Refresh capabilities to keep session alive
await PostCapabilitiesAsync(headers); // If this returns false (401), the token expired and client needs to re-auth
var success = await PostCapabilitiesAsync(headers);
if (!success)
{
// Token expired - remove the stale session
_logger.LogInformation("Token expired for device {DeviceId} - removing session", deviceId);
await RemoveSessionAsync(deviceId);
return false;
}
return true; return true;
} }
_logger.LogInformation("🔧 SESSION: Creating new session for device: {DeviceId} ({Client} on {Device})", deviceId, client, device); _logger.LogDebug("Creating new session for device: {DeviceId} ({Client} on {Device})", deviceId, client, device);
// Log the headers we received for debugging
_logger.LogDebug("🔍 SESSION: Headers received for session creation: {Headers}",
string.Join(", ", headers.Select(h => $"{h.Key}={h.Value.ToString().Substring(0, Math.Min(30, h.Value.ToString().Length))}...")));
try try
{ {
// Post session capabilities to Jellyfin - this creates the session // Post session capabilities to Jellyfin - this creates the session
await PostCapabilitiesAsync(headers); var success = await PostCapabilitiesAsync(headers);
_logger.LogInformation("✓ SESSION: Session created for {DeviceId}", deviceId); if (!success)
{
// Token expired or invalid - client needs to re-authenticate
_logger.LogInformation("Failed to create session for {DeviceId} - token may be expired", deviceId);
return false;
}
_logger.LogDebug("Session created for {DeviceId}", deviceId);
// Track this session // Track this session
_sessions[deviceId] = new SessionInfo _sessions[deviceId] = new SessionInfo
@@ -89,15 +102,16 @@ public class JellyfinSessionManager : IDisposable
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "❌ SESSION: Error creating session for {DeviceId}", deviceId); _logger.LogError(ex, "Error creating session for {DeviceId}", deviceId);
return false; return false;
} }
} }
/// <summary> /// <summary>
/// Posts session capabilities to Jellyfin. /// Posts session capabilities to Jellyfin.
/// Returns true if successful, false if token expired (401).
/// </summary> /// </summary>
private async Task PostCapabilitiesAsync(IHeaderDictionary headers) private async Task<bool> PostCapabilitiesAsync(IHeaderDictionary headers)
{ {
var capabilities = new var capabilities = new
{ {
@@ -118,12 +132,19 @@ public class JellyfinSessionManager : IDisposable
if (statusCode == 204 || statusCode == 200) if (statusCode == 204 || statusCode == 200)
{ {
_logger.LogDebug("✓ SESSION: Posted capabilities successfully ({StatusCode})", statusCode); _logger.LogTrace("Posted capabilities successfully ({StatusCode})", statusCode);
return true;
}
else if (statusCode == 401)
{
// Token expired - this is expected, client needs to re-authenticate
_logger.LogDebug("Capabilities returned 401 (token expired) - client should re-authenticate");
return false;
} }
else else
{ {
// 401 is common when cached headers have expired - not a critical error _logger.LogDebug("Capabilities post returned {StatusCode}", statusCode);
_logger.LogDebug("SESSION: Capabilities post returned {StatusCode} (may be expected if token expired)", statusCode); return false;
} }
} }
@@ -178,7 +199,7 @@ public class JellyfinSessionManager : IDisposable
if (_sessions.TryGetValue(deviceId, out var currentSession) && if (_sessions.TryGetValue(deviceId, out var currentSession) &&
currentSession.LastActivity <= markedTime) currentSession.LastActivity <= markedTime)
{ {
_logger.LogInformation("🧹 SESSION: Auto-removing inactive session {DeviceId} after playback stop", deviceId); _logger.LogDebug("🧹 SESSION: Auto-removing inactive session {DeviceId} after playback stop", deviceId);
await RemoveSessionAsync(deviceId); await RemoveSessionAsync(deviceId);
} }
else else
@@ -223,7 +244,7 @@ public class JellyfinSessionManager : IDisposable
{ {
if (_sessions.TryRemove(deviceId, out var session)) if (_sessions.TryRemove(deviceId, out var session))
{ {
_logger.LogInformation("🗑️ SESSION: Removing session for device {DeviceId}", deviceId); _logger.LogDebug("🗑️ SESSION: Removing session for device {DeviceId}", deviceId);
// Close WebSocket if it exists // Close WebSocket if it exists
if (session.WebSocket != null && session.WebSocket.State == WebSocketState.Open) if (session.WebSocket != null && session.WebSocket.State == WebSocketState.Open)
@@ -235,7 +256,7 @@ public class JellyfinSessionManager : IDisposable
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "⚠️ WEBSOCKET: Error closing WebSocket for {DeviceId}", deviceId); _logger.LogDebug(ex, "WEBSOCKET: Error closing WebSocket for {DeviceId}", deviceId);
} }
finally finally
{ {
@@ -255,7 +276,7 @@ public class JellyfinSessionManager : IDisposable
}; };
var stopJson = JsonSerializer.Serialize(stopPayload); var stopJson = JsonSerializer.Serialize(stopPayload);
await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", stopJson, session.Headers); await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", stopJson, session.Headers);
_logger.LogInformation("🛑 SESSION: Reported playback stopped for {DeviceId} (ItemId: {ItemId}, Position: {Position})", _logger.LogDebug("🛑 SESSION: Reported playback stopped for {DeviceId} (ItemId: {ItemId}, Position: {Position})",
deviceId, session.LastPlayingItemId, session.LastPlayingPositionTicks); deviceId, session.LastPlayingItemId, session.LastPlayingPositionTicks);
} }
@@ -339,11 +360,11 @@ public class JellyfinSessionManager : IDisposable
if (!string.IsNullOrEmpty(_settings.ApiKey)) if (!string.IsNullOrEmpty(_settings.ApiKey))
{ {
jellyfinWsUrl += $"?api_key={_settings.ApiKey}"; jellyfinWsUrl += $"?api_key={_settings.ApiKey}";
_logger.LogWarning("⚠️ WEBSOCKET: No client auth found in headers, falling back to server API key for {DeviceId}", deviceId); _logger.LogDebug("WEBSOCKET: No client auth found in headers, falling back to server API key for {DeviceId}", deviceId);
} }
else else
{ {
_logger.LogWarning("❌ WEBSOCKET: No authentication available for {DeviceId}!", deviceId); _logger.LogWarning("❌ WEBSOCKET: No authentication available for {DeviceId} - WebSocket will fail", deviceId);
} }
} }
@@ -354,7 +375,7 @@ public class JellyfinSessionManager : IDisposable
// Connect to Jellyfin // Connect to Jellyfin
await webSocket.ConnectAsync(new Uri(jellyfinWsUrl), CancellationToken.None); await webSocket.ConnectAsync(new Uri(jellyfinWsUrl), CancellationToken.None);
_logger.LogInformation("✓ WEBSOCKET: Connected to Jellyfin for device {DeviceId}", deviceId); _logger.LogDebug("✓ WEBSOCKET: Connected to Jellyfin for device {DeviceId}", deviceId);
// CRITICAL: Send ForceKeepAlive message to initialize session in Jellyfin // CRITICAL: Send ForceKeepAlive message to initialize session in Jellyfin
// This tells Jellyfin to create/show the session in the dashboard // This tells Jellyfin to create/show the session in the dashboard
@@ -410,8 +431,8 @@ public class JellyfinSessionManager : IDisposable
} }
else else
{ {
// Log other message types at info level // Log other message types at trace level
_logger.LogInformation("📥 WEBSOCKET: {DeviceId}: {Message}", _logger.LogTrace("📥 WEBSOCKET: {DeviceId}: {Message}",
deviceId, message.Length > 100 ? message[..100] + "..." : message); deviceId, message.Length > 100 ? message[..100] + "..." : message);
} }
} }
@@ -433,7 +454,7 @@ public class JellyfinSessionManager : IDisposable
} }
catch (WebSocketException wsEx) catch (WebSocketException wsEx)
{ {
_logger.LogWarning(wsEx, "⚠️ WEBSOCKET: WebSocket error for device {DeviceId}", deviceId); _logger.LogDebug(wsEx, "WEBSOCKET: Connection closed for device {DeviceId}", deviceId);
break; break;
} }
} }
@@ -469,6 +490,7 @@ public class JellyfinSessionManager : IDisposable
/// <summary> /// <summary>
/// Periodically pings Jellyfin to keep sessions alive. /// Periodically pings Jellyfin to keep sessions alive.
/// Note: This is a backup mechanism. The WebSocket connection is the primary keep-alive. /// Note: This is a backup mechanism. The WebSocket connection is the primary keep-alive.
/// Removes sessions with expired tokens (401 responses).
/// </summary> /// </summary>
private async void KeepSessionsAlive(object? state) private async void KeepSessionsAlive(object? state)
{ {
@@ -480,29 +502,43 @@ public class JellyfinSessionManager : IDisposable
return; return;
} }
_logger.LogDebug("💓 SESSION: Keeping {Count} sessions alive", activeSessions.Count); _logger.LogTrace("Keeping {Count} sessions alive", activeSessions.Count);
var expiredSessions = new List<string>();
foreach (var session in activeSessions) foreach (var session in activeSessions)
{ {
try try
{ {
// Post capabilities again to keep session alive // Post capabilities again to keep session alive
// Note: This may fail with 401 if the client's token has expired // If this returns false (401), the token has expired
// That's okay - the WebSocket connection keeps the session alive anyway var success = await PostCapabilitiesAsync(session.Headers);
await PostCapabilitiesAsync(session.Headers);
if (!success)
{
_logger.LogInformation("Token expired for device {DeviceId} during keep-alive - marking for removal", session.DeviceId);
expiredSessions.Add(session.DeviceId);
}
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogDebug(ex, "SESSION: Error keeping session alive for {DeviceId} (WebSocket still active)", session.DeviceId); _logger.LogDebug(ex, "Error keeping session alive for {DeviceId}", session.DeviceId);
} }
} }
// Remove sessions with expired tokens
foreach (var deviceId in expiredSessions)
{
_logger.LogInformation("Removing session with expired token: {DeviceId}", deviceId);
await RemoveSessionAsync(deviceId);
}
// Clean up stale sessions after 3 minutes of inactivity // Clean up stale sessions after 3 minutes of inactivity
// This balances cleaning up finished sessions with allowing brief pauses/network issues // This balances cleaning up finished sessions with allowing brief pauses/network issues
var staleSessions = _sessions.Where(kvp => now - kvp.Value.LastActivity > TimeSpan.FromMinutes(3)).ToList(); var staleSessions = _sessions.Where(kvp => now - kvp.Value.LastActivity > TimeSpan.FromMinutes(3)).ToList();
foreach (var stale in staleSessions) foreach (var stale in staleSessions)
{ {
_logger.LogInformation("🧹 SESSION: Removing stale session for {DeviceId} (inactive for {Minutes:F1} minutes)", _logger.LogDebug("Removing stale session for {DeviceId} (inactive for {Minutes:F1} minutes)",
stale.Key, (now - stale.Value.LastActivity).TotalMinutes); stale.Key, (now - stale.Value.LastActivity).TotalMinutes);
await RemoveSessionAsync(stale.Key); await RemoveSessionAsync(stale.Key);
} }

View File

@@ -110,10 +110,10 @@ public class QobuzDownloadService : BaseDownloadService
// Build organized folder structure using AlbumArtist (fallback to Artist for singles) // Build organized folder structure using AlbumArtist (fallback to Artist for singles)
var artistForPath = song.AlbumArtist ?? song.Artist; var artistForPath = song.AlbumArtist ?? song.Artist;
// Cache mode uses cache/Music folder (cleaned up after 24h), Permanent mode uses downloads folder // Cache mode uses downloads/cache/ folder, Permanent mode uses downloads/permanent/
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache var basePath = SubsonicSettings.StorageMode == StorageMode.Cache
? Path.Combine("cache", "Music") ? Path.Combine(DownloadPath, "cache")
: "downloads"; : Path.Combine(DownloadPath, "permanent");
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension); var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension);
var albumFolder = Path.GetDirectoryName(outputPath)!; var albumFolder = Path.GetDirectoryName(outputPath)!;

View File

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

View File

@@ -7,6 +7,7 @@ using allstarr.Models.Search;
using allstarr.Models.Subsonic; using allstarr.Models.Subsonic;
using allstarr.Services.Local; using allstarr.Services.Local;
using allstarr.Services.Common; using allstarr.Services.Common;
using allstarr.Services.Lyrics;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using IOFile = System.IO.File; using IOFile = System.IO.File;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -55,6 +56,7 @@ public class SquidWTFDownloadService : BaseDownloadService
private readonly SquidWTFSettings _squidwtfSettings; private readonly SquidWTFSettings _squidwtfSettings;
private readonly OdesliService _odesliService; private readonly OdesliService _odesliService;
private readonly RoundRobinFallbackHelper _fallbackHelper; private readonly RoundRobinFallbackHelper _fallbackHelper;
private readonly IServiceProvider _serviceProvider;
protected override string ProviderName => "squidwtf"; protected override string ProviderName => "squidwtf";
@@ -75,6 +77,10 @@ public class SquidWTFDownloadService : BaseDownloadService
_squidwtfSettings = SquidWTFSettings.Value; _squidwtfSettings = SquidWTFSettings.Value;
_odesliService = odesliService; _odesliService = odesliService;
_fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF"); _fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF");
_serviceProvider = serviceProvider;
// Increase timeout for large downloads and slow endpoints
_httpClient.Timeout = TimeSpan.FromMinutes(5);
} }
@@ -108,9 +114,6 @@ public class SquidWTFDownloadService : BaseDownloadService
Logger.LogInformation("Track download URL obtained from hifi-api: {Url}", downloadInfo.DownloadUrl); Logger.LogInformation("Track download URL obtained from hifi-api: {Url}", downloadInfo.DownloadUrl);
Logger.LogInformation("Using format: {Format} (Quality: {Quality})", downloadInfo.MimeType, downloadInfo.AudioQuality); Logger.LogInformation("Using format: {Format} (Quality: {Quality})", downloadInfo.MimeType, downloadInfo.AudioQuality);
// Start Spotify ID conversion in parallel with download (don't await yet)
var spotifyIdTask = _odesliService.ConvertTidalToSpotifyIdAsync(trackId, cancellationToken);
// Determine extension from MIME type // Determine extension from MIME type
var extension = downloadInfo.MimeType?.ToLower() switch var extension = downloadInfo.MimeType?.ToLower() switch
{ {
@@ -122,10 +125,10 @@ public class SquidWTFDownloadService : BaseDownloadService
// Build organized folder structure: Artist/Album/Track using AlbumArtist (fallback to Artist for singles) // Build organized folder structure: Artist/Album/Track using AlbumArtist (fallback to Artist for singles)
var artistForPath = song.AlbumArtist ?? song.Artist; var artistForPath = song.AlbumArtist ?? song.Artist;
// Cache mode uses cache/Music folder (cleaned up after 24h), Permanent mode uses downloads folder // Cache mode uses downloads/cache/ folder, Permanent mode uses downloads/permanent/
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache var basePath = SubsonicSettings.StorageMode == StorageMode.Cache
? Path.Combine("cache", "Music") ? Path.Combine("downloads", "cache")
: "downloads"; : Path.Combine("downloads", "permanent");
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension); var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension);
// Create directories if they don't exist // Create directories if they don't exist
@@ -199,14 +202,26 @@ public class SquidWTFDownloadService : BaseDownloadService
// Close file before writing metadata // Close file before writing metadata
await outputFile.DisposeAsync(); await outputFile.DisposeAsync();
// Wait for Spotify ID conversion to complete and update song metadata // Start Spotify ID conversion in background (for lyrics support)
var spotifyId = await spotifyIdTask; // This doesn't block streaming - lyrics endpoint will fetch it on-demand if needed
_ = Task.Run(async () =>
{
try
{
var spotifyId = await _odesliService.ConvertTidalToSpotifyIdAsync(trackId, CancellationToken.None);
if (!string.IsNullOrEmpty(spotifyId)) if (!string.IsNullOrEmpty(spotifyId))
{ {
song.SpotifyId = spotifyId; Logger.LogDebug("Background Spotify ID obtained for Tidal/{TrackId}: {SpotifyId}", trackId, spotifyId);
// Spotify ID is cached by Odesli service for future lyrics requests
} }
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Background Spotify ID conversion failed for Tidal/{TrackId}", trackId);
}
});
// Write metadata and cover art // Write metadata and cover art (without Spotify ID - it's only needed for lyrics)
await WriteMetadataAsync(outputPath, song, cancellationToken); await WriteMetadataAsync(outputPath, song, cancellationToken);
return outputPath; return outputPath;
@@ -299,6 +314,53 @@ public class SquidWTFDownloadService : BaseDownloadService
#region Utility Methods #region Utility Methods
/// <summary>
/// Converts Tidal track ID to Spotify ID for lyrics support.
/// Called in background after streaming starts.
/// Also prefetches lyrics immediately after conversion.
/// </summary>
protected override async Task ConvertToSpotifyIdAsync(string externalProvider, string externalId)
{
if (externalProvider != "squidwtf")
{
return;
}
var spotifyId = await _odesliService.ConvertTidalToSpotifyIdAsync(externalId, CancellationToken.None);
if (!string.IsNullOrEmpty(spotifyId))
{
Logger.LogDebug("Background Spotify ID obtained for Tidal/{TrackId}: {SpotifyId}", externalId, spotifyId);
// Immediately prefetch lyrics now that we have the Spotify ID
// This ensures lyrics are cached and ready when the client requests them
_ = Task.Run(async () =>
{
try
{
using var scope = _serviceProvider.CreateScope();
var spotifyLyricsService = scope.ServiceProvider.GetService<SpotifyLyricsService>();
if (spotifyLyricsService != null)
{
var lyrics = await spotifyLyricsService.GetLyricsByTrackIdAsync(spotifyId);
if (lyrics != null && lyrics.Lines.Count > 0)
{
Logger.LogDebug("Background lyrics prefetched for Spotify/{SpotifyId}: {LineCount} lines",
spotifyId, lyrics.Lines.Count);
}
else
{
Logger.LogDebug("No lyrics available for Spotify/{SpotifyId}", spotifyId);
}
}
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Background lyrics prefetch failed for Spotify/{SpotifyId}", spotifyId);
}
});
}
}
#endregion #endregion

View File

@@ -74,6 +74,9 @@ public class SquidWTFMetadataService : IMusicMetadataService
// Set up default headers // Set up default headers
_httpClient.DefaultRequestHeaders.Add("User-Agent", _httpClient.DefaultRequestHeaders.Add("User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0"); "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0");
// Increase timeout for large artist/album responses (some artists have 100+ albums)
_httpClient.Timeout = TimeSpan.FromMinutes(5);
} }