mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
Compare commits
5 Commits
7cee0911b6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
f68706f300
|
|||
|
9f362b4920
|
|||
|
2b09484c0b
|
|||
|
fa9739bfaa
|
|||
|
0ba51e2b30
|
35
.env.example
35
.env.example
@@ -18,28 +18,30 @@ SUBSONIC_URL=http://localhost:4533
|
||||
# Server URL (required if using Jellyfin backend)
|
||||
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=
|
||||
|
||||
# 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=
|
||||
|
||||
# Music library ID (optional, auto-detected if not set)
|
||||
# If you have multiple libraries, set this to filter to music only
|
||||
JELLYFIN_LIBRARY_ID=
|
||||
|
||||
# ===== MUSIC SOURCE SELECTION =====
|
||||
# Music service to use: SquidWTF, Deezer, or Qobuz (default: 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
|
||||
|
||||
# Path where favorited external tracks are permanently kept
|
||||
KEPT_PATH=./kept
|
||||
|
||||
# Path for cache files (Spotify missing tracks, etc.)
|
||||
CACHE_PATH=./cache
|
||||
|
||||
# ===== SQUIDWTF CONFIGURATION =====
|
||||
# Different quality options for SquidWTF. Only FLAC supported right now
|
||||
SQUIDWTF_QUALITY=FLAC
|
||||
@@ -108,27 +110,14 @@ CACHE_DURATION_HOURS=1
|
||||
|
||||
# ===== SPOTIFY PLAYLIST INJECTION (JELLYFIN ONLY) =====
|
||||
# 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
|
||||
# with tracks auto-matched from external providers (SquidWTF, Deezer, Qobuz)
|
||||
# This feature intercepts Spotify Import plugin playlists and fills them with tracks from external providers
|
||||
# Uses JELLYFIN_URL and JELLYFIN_API_KEY configured above (no separate credentials needed)
|
||||
|
||||
# Enable Spotify playlist injection (optional, default: 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)
|
||||
# Spotify playlists like Discover Weekly update once per week, Release Radar updates weekly
|
||||
# Most playlists don't change frequently, so running once per day is reasonable
|
||||
# Set to 0 to only run once on startup (manual trigger via admin UI still works)
|
||||
# Default: 24 hours
|
||||
SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS=24
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -89,6 +89,9 @@ apis/api-calls/*.json
|
||||
!apis/api-calls/jellyfin-openapi-stable.json
|
||||
apis/temp.json
|
||||
|
||||
# Temporary documentation files
|
||||
apis/*.md
|
||||
|
||||
# Log files for debugging
|
||||
apis/api-calls/*.log
|
||||
|
||||
@@ -101,3 +104,6 @@ originals/
|
||||
|
||||
# Sample missing playlists for Spotify integration testing
|
||||
sampleMissingPlaylists/
|
||||
|
||||
# Migration guide (local only)
|
||||
MIGRATION.md
|
||||
333
README.md
333
README.md
@@ -5,11 +5,7 @@
|
||||
[](https://github.com/SoPat712/allstarr/pkgs/container/allstarr)
|
||||
[](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.
|
||||
|
||||
**THIS IS UNDER ACTIVE DEVELOPMENT**
|
||||
|
||||
Please report all bugs as soon as possible, as the Jellyfin addition is entirely a test at this point
|
||||
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.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -40,15 +36,15 @@ The proxy will be available at `http://localhost:5274`.
|
||||
|
||||
## Web Dashboard
|
||||
|
||||
Allstarr includes a web-based dashboard for easy configuration and playlist management, accessible at `http://localhost:5275` (internal port, not exposed through reverse proxy).
|
||||
Allstarr includes a web UI for easy configuration and playlist management, accessible at `http://localhost:5275`
|
||||
|
||||
### Features
|
||||
|
||||
- **Real-time Status**: Monitor Spotify authentication, cookie age, and playlist sync status
|
||||
- **Playlist Management**: Link Jellyfin playlists to Spotify playlists with a few clicks
|
||||
- **Configuration Editor**: Update settings without manually editing .env files
|
||||
- **Track Viewer**: Browse tracks in your configured playlists
|
||||
- **Cache Management**: Clear cached data and restart the container
|
||||
- **Playlist Management**: Link Jellyfin playlists to Spotify playlists with just a few clicks
|
||||
- **Provider Matching**: It should fill in the gaps of your Jellyfin library with tracks from your selected provider
|
||||
- **WebUI**: Update settings without manually editing .env files
|
||||
- **Music**: Using multiple sources for music (optimized for SquidWTF right now, though)
|
||||
- **Lyrics**: Using multiple sources for lyrics, first Jellyfin Lyrics, then Spotify Lyrics, then LrcLib as a last resort
|
||||
|
||||
### 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)
|
||||
- `spotify:playlist:37i9dQZF1DXcBWIGoYBM5M` (Spotify URI)
|
||||
- `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?
|
||||
|
||||
- **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
|
||||
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.
|
||||
|
||||
### Configuration Persistence
|
||||
|
||||
The web UI updates your `.env` file directly. Changes persist across container restarts, but require a restart to take effect. In development mode, the `.env` file is in your project root. In Docker, it's at `/app/.env`.
|
||||
|
||||
**Recommended workflow**: Use the `sp_dc` cookie method (simpler and more reliable than the Jellyfin Spotify Import plugin).
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
- [Musiver](https://music.aqzscn.cn/en/) (Android/IOS/Windows/Android)
|
||||
- [Finamp](https://github.com/jmshrv/finamp) ()
|
||||
|
||||
_Working on getting more currently_
|
||||
|
||||
@@ -335,7 +334,7 @@ Subsonic__EnableExternalPlaylists=false
|
||||
|
||||
### 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
|
||||
|
||||
@@ -349,136 +348,112 @@ Allstarr can automatically fill your Spotify playlists (like Release Radar and D
|
||||
- Go to Jellyfin Dashboard → Plugins → Spotify Import
|
||||
- Connect your Spotify account
|
||||
- Select which playlists to sync (e.g., Release Radar, Discover Weekly)
|
||||
- Set a daily sync schedule (e.g., 4:15 PM daily)
|
||||
- The plugin will create playlists in Jellyfin and generate "missing tracks" files for songs not in your library
|
||||
- Set a sync schedule (the plugin will create playlists in Jellyfin)
|
||||
|
||||
3. **Configure Allstarr**
|
||||
- Allstarr needs to know when the plugin runs and which playlists to intercept
|
||||
- Uses your existing `JELLYFIN_URL` and `JELLYFIN_API_KEY` settings (no additional credentials needed)
|
||||
- Enable Spotify Import in Allstarr (see configuration below)
|
||||
- Link your Jellyfin playlists to Spotify playlists via the Web UI
|
||||
- Uses your existing `JELLYFIN_URL` and `JELLYFIN_API_KEY` settings
|
||||
|
||||
#### Configuration
|
||||
|
||||
| Setting | Description |
|
||||
|---------|-------------|
|
||||
| `SpotifyImport:Enabled` | Enable Spotify playlist injection (default: `false`) |
|
||||
| `SpotifyImport:SyncStartHour` | Hour when the Spotify Import plugin runs (24-hour format, 0-23) |
|
||||
| `SpotifyImport:SyncStartMinute` | Minute when the plugin runs (0-59) |
|
||||
| `SpotifyImport:SyncWindowHours` | Hours to search for missing tracks files after sync time (default: 2) |
|
||||
| `SpotifyImport:PlaylistIds` | Comma-separated Jellyfin playlist IDs to intercept |
|
||||
| `SpotifyImport:PlaylistNames` | Comma-separated playlist names (must match order of IDs) |
|
||||
| `SpotifyImport:MatchingIntervalHours` | How often to run track matching in hours (default: 24, set to 0 for startup only) |
|
||||
| `SpotifyImport:Playlists` | JSON array of playlists (managed via Web UI) |
|
||||
|
||||
**Environment variables example:**
|
||||
```bash
|
||||
# Enable the feature
|
||||
SPOTIFY_IMPORT_ENABLED=true
|
||||
|
||||
# Sync window settings (optional - used to prevent fetching too frequently)
|
||||
# The fetcher searches backwards from current time for the last 48 hours
|
||||
SPOTIFY_IMPORT_SYNC_START_HOUR=16
|
||||
SPOTIFY_IMPORT_SYNC_START_MINUTE=15
|
||||
SPOTIFY_IMPORT_SYNC_WINDOW_HOURS=2
|
||||
# Matching interval (24 hours = once per day)
|
||||
SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS=24
|
||||
|
||||
# Get playlist IDs from Jellyfin URLs: https://jellyfin.example.com/web/#/details?id=PLAYLIST_ID
|
||||
SPOTIFY_IMPORT_PLAYLIST_IDS=ba50e26c867ec9d57ab2f7bf24cfd6b0,4383a46d8bcac3be2ef9385053ea18df
|
||||
|
||||
# Names must match exactly as they appear in Jellyfin (used to find missing tracks files)
|
||||
SPOTIFY_IMPORT_PLAYLIST_NAMES=Release Radar,Discover Weekly
|
||||
# Playlists (use Web UI to manage instead of editing manually)
|
||||
SPOTIFY_IMPORT_PLAYLISTS=[["Discover Weekly","37i9dQZEVXcV6s7Dm7RXsU","first"],["Release Radar","37i9dQZEVXbng2vDHnfQlC","first"]]
|
||||
```
|
||||
|
||||
#### How It Works
|
||||
|
||||
1. **Spotify Import Plugin Runs** (e.g., daily at 4:15 PM)
|
||||
1. **Spotify Import Plugin Runs**
|
||||
- Plugin fetches your Spotify playlists
|
||||
- Creates/updates playlists in Jellyfin with tracks already in your library
|
||||
- Generates "missing tracks" JSON files for songs not found locally
|
||||
- Files are named like: `Release Radar_missing_2026-02-01_16-15.json`
|
||||
|
||||
2. **Allstarr Fetches Missing Tracks** (within sync window)
|
||||
- Searches for missing tracks files from the Jellyfin plugin
|
||||
- Searches **+24 hours forward first** (newest files), then **-48 hours backward** if not found
|
||||
- This efficiently finds the most recent file regardless of timezone differences
|
||||
- Example: Server time 12 PM EST, file timestamped 9 PM UTC (same day) → Found in forward search
|
||||
- Caches the list of missing tracks in Redis + file cache
|
||||
- Runs automatically on startup (if needed) and every 5 minutes during the sync window
|
||||
|
||||
3. **Allstarr Matches Tracks** (2 minutes after startup, then configurable interval)
|
||||
2. **Allstarr Matches Tracks** (on startup + every 24 hours by default)
|
||||
- Reads missing tracks files from the Jellyfin plugin
|
||||
- For each missing track, searches your streaming provider (SquidWTF, Deezer, or Qobuz)
|
||||
- Uses fuzzy matching to find the best match (title + artist similarity)
|
||||
- Rate-limited to avoid overwhelming the service (150ms delay between searches)
|
||||
- Caches matched results for 1 hour
|
||||
- **Pre-builds playlist items cache** for instant serving (no "on the fly" building)
|
||||
- Default interval: 24 hours (configurable via `SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS`)
|
||||
- Set to 0 to only run once on startup (manual trigger via admin UI still works)
|
||||
- Pre-builds playlist cache for instant loading
|
||||
|
||||
4. **You Open the Playlist in Jellyfin**
|
||||
3. **You Open the Playlist in Jellyfin**
|
||||
- Allstarr intercepts the request
|
||||
- Returns a merged list: local tracks + matched streaming tracks
|
||||
- Loads instantly from cache (no searching needed!)
|
||||
- Loads instantly from cache!
|
||||
|
||||
5. **You Play a Track**
|
||||
- If it's a local track, streams from Jellyfin normally
|
||||
- If it's a matched track, downloads from streaming provider on-demand
|
||||
4. **You Play a Track**
|
||||
- Local tracks stream from Jellyfin normally
|
||||
- Matched tracks download from streaming provider on-demand
|
||||
- Downloaded tracks are saved to your library for future use
|
||||
|
||||
#### Manual Triggers
|
||||
#### Manual API Triggers
|
||||
|
||||
You can manually trigger syncing and matching via API:
|
||||
You can manually trigger operations via the admin API:
|
||||
|
||||
```bash
|
||||
# Get API key from your .env file
|
||||
API_KEY="your-api-key-here"
|
||||
|
||||
# Fetch missing tracks from Jellyfin plugin
|
||||
curl "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)
|
||||
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
|
||||
curl "https://your-jellyfin-proxy.com/spotify/clear-cache?api_key=YOUR_API_KEY"
|
||||
# Match all playlists (refresh all matches)
|
||||
curl "http://localhost:5274/spotify/match-all?api_key=$API_KEY"
|
||||
|
||||
# Clear cache and rebuild
|
||||
curl "http://localhost:5274/spotify/clear-cache?api_key=$API_KEY"
|
||||
|
||||
# Refresh specific playlist
|
||||
curl "http://localhost:5274/spotify/refresh-playlist?playlistId=PLAYLIST_ID&api_key=$API_KEY"
|
||||
```
|
||||
|
||||
#### 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:**
|
||||
- Checks if today's sync window has passed (e.g., if sync is at 4 PM + 2 hour window = 6 PM)
|
||||
- If before 6 PM and yesterday's cache exists → **Skips fetch** (cache is still current)
|
||||
- If after 6 PM or no cache exists → **Fetches missing tracks** from Jellyfin plugin
|
||||
|
||||
**Track Matching:**
|
||||
- **T+2min**: Matches tracks with streaming provider (with rate limiting)
|
||||
- Only matches playlists that don't already have cached matches
|
||||
- **Result**: Playlists load instantly when you open them!
|
||||
|
||||
**Example Timeline:**
|
||||
- Plugin runs daily at 4:15 PM, creates files at ~4:16 PM
|
||||
- You restart Allstarr at 12:00 PM (noon) the next day
|
||||
- Startup check: "Today's sync window ends at 6 PM, and I have yesterday's 4:16 PM file"
|
||||
- **Decision**: Skip fetch, use existing cache
|
||||
- At 6:01 PM: Next scheduled check will search for new files
|
||||
1. **Link Playlists Tab**: Link Jellyfin playlists to Spotify playlists
|
||||
2. **Active Playlists Tab**: View status, trigger matching, and manage playlists
|
||||
3. **Configuration Tab**: Enable/disable Spotify Import and adjust settings
|
||||
|
||||
#### Troubleshooting
|
||||
|
||||
**Playlists are empty:**
|
||||
- Check that the Spotify Import plugin is running and creating playlists
|
||||
- Verify `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`
|
||||
|
||||
**Tracks aren't matching:**
|
||||
- Ensure your streaming provider is configured (`MUSIC_SERVICE`, credentials)
|
||||
- Check that playlist names in `SPOTIFY_IMPORT_PLAYLIST_NAMES` match exactly
|
||||
- Manually trigger matching: `curl "https://your-proxy.com/spotify/match?api_key=KEY"`
|
||||
- Manually trigger matching via Web UI or API
|
||||
- Check that the Jellyfin plugin generated missing tracks files
|
||||
|
||||
**Sync timing issues:**
|
||||
- Set `SPOTIFY_IMPORT_SYNC_START_HOUR/MINUTE` to match your plugin schedule
|
||||
- Increase `SPOTIFY_IMPORT_SYNC_WINDOW_HOURS` if files aren't being found
|
||||
- Check Jellyfin plugin logs to confirm when it runs
|
||||
**Performance:**
|
||||
- Matching runs in background with rate limiting (150ms between searches)
|
||||
- First match may take a few minutes for large playlists
|
||||
- Subsequent loads are instant (served from cache)
|
||||
|
||||
#### Notes
|
||||
|
||||
- This feature uses your existing `JELLYFIN_URL` and `JELLYFIN_API_KEY` settings
|
||||
- Matched tracks are cached for 1 hour to avoid repeated searches
|
||||
- Missing tracks cache persists across restarts (stored in Redis + file cache)
|
||||
- Rate limiting prevents overwhelming your streaming provider (150ms between searches)
|
||||
- Uses your existing `JELLYFIN_URL` and `JELLYFIN_API_KEY` settings
|
||||
- Matched tracks cached for fast loading
|
||||
- Missing tracks cache persists across restarts (Redis + file cache)
|
||||
- Rate limiting prevents overwhelming your streaming provider
|
||||
- Only works with Jellyfin backend (not Subsonic/Navidrome)
|
||||
|
||||
### Getting Credentials
|
||||
@@ -592,9 +567,46 @@ If you prefer to run Allstarr without Docker:
|
||||
|
||||
## 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
|
||||
|
||||
The proxy implements the Subsonic API and adds transparent streaming provider integration:
|
||||
The proxy implements the Subsonic API with streaming provider integration:
|
||||
|
||||
| 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.
|
||||
|
||||
### 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 (streaming provider) content uses typed IDs:
|
||||
@@ -636,25 +634,37 @@ Legacy format `ext-deezer-{id}` is also supported (assumes song type).
|
||||
|
||||
## Download Folder Structure
|
||||
|
||||
Downloaded music is organized as:
|
||||
All downloads are organized under a single base directory (default: `./downloads`):
|
||||
|
||||
```
|
||||
downloads/
|
||||
├── Artist Name/
|
||||
│ ├── Album Title/
|
||||
│ │ ├── 01 - Track One.mp3
|
||||
│ │ ├── 02 - Track Two.mp3
|
||||
│ │ └── ...
|
||||
│ └── Another Album/
|
||||
│ └── ...
|
||||
├── Another Artist/
|
||||
│ └── ...
|
||||
└── playlists/
|
||||
├── My Favorite Songs.m3u
|
||||
├── Chill Vibes.m3u
|
||||
└── ...
|
||||
├── permanent/ # Permanent downloads (STORAGE_MODE=Permanent)
|
||||
│ ├── Artist Name/
|
||||
│ │ ├── Album Title/
|
||||
│ │ │ ├── 01 - Track One.flac
|
||||
│ │ │ ├── 02 - Track Two.flac
|
||||
│ │ │ └── ...
|
||||
│ │ └── Another Album/
|
||||
│ │ └── ...
|
||||
│ └── playlists/
|
||||
│ ├── My Favorite Songs.m3u
|
||||
│ └── Chill Vibes.m3u
|
||||
├── cache/ # Temporary cache (STORAGE_MODE=Cache)
|
||||
│ └── Artist Name/
|
||||
│ └── Album Title/
|
||||
│ └── Track.flac
|
||||
└── kept/ # Favorited external tracks (always permanent)
|
||||
└── Artist Name/
|
||||
└── Album Title/
|
||||
└── Track.flac
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
@@ -685,10 +695,17 @@ dotnet test
|
||||
```
|
||||
allstarr/
|
||||
├── Controllers/
|
||||
│ ├── JellyfinController.cs # Jellyfin API controller (registered when Backend:Type=Jellyfin)
|
||||
│ └── SubsonicController.cs # Subsonic API controller (registered when Backend:Type=Subsonic)
|
||||
│ ├── AdminController.cs # Admin dashboard API
|
||||
│ ├── JellyfinController.cs # Jellyfin API controller
|
||||
│ └── SubsonicController.cs # Subsonic API controller
|
||||
├── Filters/
|
||||
│ ├── AdminPortFilter.cs # Admin port access control
|
||||
│ ├── ApiKeyAuthFilter.cs # API key authentication
|
||||
│ └── JellyfinAuthFilter.cs # Jellyfin authentication
|
||||
├── Middleware/
|
||||
│ └── GlobalExceptionHandler.cs # Global error handling
|
||||
│ ├── AdminStaticFilesMiddleware.cs # Admin UI static file serving
|
||||
│ ├── GlobalExceptionHandler.cs # Global error handling
|
||||
│ └── WebSocketProxyMiddleware.cs # WebSocket proxying for Jellyfin
|
||||
├── Models/
|
||||
│ ├── Domain/ # Domain entities
|
||||
│ │ ├── Song.cs
|
||||
@@ -697,18 +714,39 @@ allstarr/
|
||||
│ ├── Settings/ # Configuration models
|
||||
│ │ ├── SubsonicSettings.cs
|
||||
│ │ ├── DeezerSettings.cs
|
||||
│ │ └── QobuzSettings.cs
|
||||
│ │ ├── QobuzSettings.cs
|
||||
│ │ ├── SquidWTFSettings.cs
|
||||
│ │ ├── SpotifyApiSettings.cs
|
||||
│ │ ├── SpotifyImportSettings.cs
|
||||
│ │ ├── MusicBrainzSettings.cs
|
||||
│ │ └── RedisSettings.cs
|
||||
│ ├── Download/ # Download-related models
|
||||
│ │ ├── DownloadInfo.cs
|
||||
│ │ └── DownloadStatus.cs
|
||||
│ ├── Lyrics/
|
||||
│ │ └── LyricsInfo.cs
|
||||
│ ├── Search/
|
||||
│ │ └── SearchResult.cs
|
||||
│ ├── Spotify/
|
||||
│ │ ├── MissingTrack.cs
|
||||
│ │ └── SpotifyPlaylistTrack.cs
|
||||
│ └── Subsonic/
|
||||
│ ├── ExternalPlaylist.cs
|
||||
│ └── ScanStatus.cs
|
||||
├── Services/
|
||||
│ ├── Common/ # Shared services
|
||||
│ │ ├── BaseDownloadService.cs # Template method base class
|
||||
│ │ ├── CacheCleanupService.cs # Cache cleanup background service
|
||||
│ │ ├── CacheWarmingService.cs # Startup cache warming
|
||||
│ │ ├── EndpointBenchmarkService.cs # Endpoint performance benchmarking
|
||||
│ │ ├── FuzzyMatcher.cs # Fuzzy string matching
|
||||
│ │ ├── GenreEnrichmentService.cs # MusicBrainz genre enrichment
|
||||
│ │ ├── OdesliService.cs # Odesli/song.link conversion
|
||||
│ │ ├── ParallelMetadataService.cs # Parallel metadata fetching
|
||||
│ │ ├── PathHelper.cs # Path utilities
|
||||
│ │ ├── PlaylistIdHelper.cs # Playlist ID helpers
|
||||
│ │ ├── RedisCacheService.cs # Redis caching
|
||||
│ │ ├── RoundRobinFallbackHelper.cs # Load balancing and failover
|
||||
│ │ ├── Result.cs # Result<T> pattern
|
||||
│ │ └── Error.cs # Error types
|
||||
│ ├── Deezer/ # Deezer provider
|
||||
@@ -720,12 +758,35 @@ allstarr/
|
||||
│ │ ├── QobuzMetadataService.cs
|
||||
│ │ ├── QobuzBundleService.cs
|
||||
│ │ └── QobuzStartupValidator.cs
|
||||
│ ├── SquidWTF/ # SquidWTF provider
|
||||
│ │ ├── SquidWTFDownloadService.cs
|
||||
│ │ ├── SquidWTFMetadataService.cs
|
||||
│ │ └── SquidWTFStartupValidator.cs
|
||||
│ ├── Jellyfin/ # Jellyfin integration
|
||||
│ │ ├── JellyfinModelMapper.cs # Model mapping
|
||||
│ │ ├── JellyfinProxyService.cs # Request proxying
|
||||
│ │ ├── JellyfinResponseBuilder.cs # Response building
|
||||
│ │ ├── JellyfinSessionManager.cs # Session management
|
||||
│ │ └── JellyfinStartupValidator.cs # Startup validation
|
||||
│ ├── Lyrics/ # Lyrics services
|
||||
│ │ ├── LrclibService.cs # LRCLIB lyrics
|
||||
│ │ ├── LyricsPrefetchService.cs # Background lyrics prefetching
|
||||
│ │ ├── LyricsStartupValidator.cs # Lyrics validation
|
||||
│ │ └── SpotifyLyricsService.cs # Spotify lyrics
|
||||
│ ├── MusicBrainz/
|
||||
│ │ └── MusicBrainzService.cs # MusicBrainz metadata
|
||||
│ ├── Spotify/ # Spotify integration
|
||||
│ │ ├── SpotifyApiClient.cs # Spotify API client
|
||||
│ │ ├── SpotifyMissingTracksFetcher.cs # Missing tracks fetcher
|
||||
│ │ ├── SpotifyPlaylistFetcher.cs # Playlist fetcher
|
||||
│ │ └── SpotifyTrackMatchingService.cs # Track matching
|
||||
│ ├── Local/ # Local library
|
||||
│ │ ├── ILocalLibraryService.cs
|
||||
│ │ └── LocalLibraryService.cs
|
||||
│ ├── Subsonic/ # Subsonic API logic
|
||||
│ │ ├── SubsonicProxyService.cs # Request proxying
|
||||
│ │ ├── PlaylistSyncService.cs # Playlist synchronization
|
||||
│ │ ├── SubsonicModelMapper.cs # Model mapping
|
||||
│ │ ├── SubsonicProxyService.cs # Request proxying
|
||||
│ │ ├── SubsonicRequestParser.cs # Request parsing
|
||||
│ │ └── SubsonicResponseBuilder.cs # Response building
|
||||
│ ├── Validation/ # Startup validation
|
||||
@@ -737,13 +798,17 @@ allstarr/
|
||||
│ ├── IDownloadService.cs # Download interface
|
||||
│ ├── IMusicMetadataService.cs # Metadata interface
|
||||
│ └── StartupValidationService.cs
|
||||
├── wwwroot/ # Admin UI static files
|
||||
│ ├── index.html # Admin dashboard
|
||||
│ └── placeholder.png # Placeholder image
|
||||
├── Program.cs # Application entry point
|
||||
└── appsettings.json # Configuration
|
||||
|
||||
allstarr.Tests/
|
||||
├── DeezerDownloadServiceTests.cs # Deezer download tests
|
||||
├── DeezerMetadataServiceTests.cs # Deezer metadata tests
|
||||
├── QobuzDownloadServiceTests.cs # Qobuz download tests (127 tests)
|
||||
├── JellyfinResponseStructureTests.cs # Jellyfin response tests
|
||||
├── QobuzDownloadServiceTests.cs # Qobuz download tests
|
||||
├── LocalLibraryServiceTests.cs # Local library tests
|
||||
├── SubsonicModelMapperTests.cs # Model mapping tests
|
||||
├── SubsonicProxyServiceTests.cs # Proxy service tests
|
||||
@@ -817,7 +882,7 @@ We welcome contributions! Here's how to get started:
|
||||
- Follow existing code patterns and conventions
|
||||
- Add tests for new features
|
||||
- Update documentation as needed
|
||||
- Keep commits focused and atomic
|
||||
- Keep commits feature focused
|
||||
|
||||
### Testing
|
||||
|
||||
@@ -839,8 +904,14 @@ GPL-3.0
|
||||
|
||||
## 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
|
||||
- [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
|
||||
- [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
|
||||
@@ -28,6 +28,7 @@ public class AdminController : ControllerBase
|
||||
private readonly SpotifyApiSettings _spotifyApiSettings;
|
||||
private readonly SpotifyImportSettings _spotifyImportSettings;
|
||||
private readonly JellyfinSettings _jellyfinSettings;
|
||||
private readonly SubsonicSettings _subsonicSettings;
|
||||
private readonly DeezerSettings _deezerSettings;
|
||||
private readonly QobuzSettings _qobuzSettings;
|
||||
private readonly SquidWTFSettings _squidWtfSettings;
|
||||
@@ -52,6 +53,7 @@ public class AdminController : ControllerBase
|
||||
IOptions<SpotifyApiSettings> spotifyApiSettings,
|
||||
IOptions<SpotifyImportSettings> spotifyImportSettings,
|
||||
IOptions<JellyfinSettings> jellyfinSettings,
|
||||
IOptions<SubsonicSettings> subsonicSettings,
|
||||
IOptions<DeezerSettings> deezerSettings,
|
||||
IOptions<QobuzSettings> qobuzSettings,
|
||||
IOptions<SquidWTFSettings> squidWtfSettings,
|
||||
@@ -69,6 +71,7 @@ public class AdminController : ControllerBase
|
||||
_spotifyApiSettings = spotifyApiSettings.Value;
|
||||
_spotifyImportSettings = spotifyImportSettings.Value;
|
||||
_jellyfinSettings = jellyfinSettings.Value;
|
||||
_subsonicSettings = subsonicSettings.Value;
|
||||
_deezerSettings = deezerSettings.Value;
|
||||
_qobuzSettings = qobuzSettings.Value;
|
||||
_squidWtfSettings = squidWtfSettings.Value;
|
||||
@@ -88,8 +91,6 @@ public class AdminController : ControllerBase
|
||||
_envFilePath = _environment.IsDevelopment()
|
||||
? Path.Combine(_environment.ContentRootPath, "..", ".env")
|
||||
: "/app/.env";
|
||||
|
||||
_logger.LogInformation("Admin controller initialized. .env path: {EnvFilePath}", _envFilePath);
|
||||
}
|
||||
|
||||
private static List<string> DecodeSquidWtfUrls()
|
||||
@@ -165,8 +166,7 @@ public class AdminController : ControllerBase
|
||||
spotifyImport = new
|
||||
{
|
||||
enabled = _spotifyImportSettings.Enabled,
|
||||
syncTime = $"{_spotifyImportSettings.SyncStartHour:D2}:{_spotifyImportSettings.SyncStartMinute:D2}",
|
||||
syncWindowHours = _spotifyImportSettings.SyncWindowHours,
|
||||
matchingIntervalHours = _spotifyImportSettings.MatchingIntervalHours,
|
||||
playlistCount = _spotifyImportSettings.Playlists.Count
|
||||
},
|
||||
deezer = new
|
||||
@@ -469,30 +469,53 @@ public class AdminController : ControllerBase
|
||||
foreach (var track in spotifyTracks)
|
||||
{
|
||||
var isLocal = false;
|
||||
var hasExternalMapping = false;
|
||||
|
||||
if (localTracks.Count > 0)
|
||||
// FIRST: Check for manual Jellyfin mapping
|
||||
var manualMappingKey = $"spotify:manual-map:{config.Name}:{track.SpotifyId}";
|
||||
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(manualJellyfinId))
|
||||
{
|
||||
var bestMatch = localTracks
|
||||
.Select(local => new
|
||||
{
|
||||
Local = local,
|
||||
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, local.Title),
|
||||
ArtistScore = FuzzyMatcher.CalculateSimilarity(track.PrimaryArtist, local.Artist)
|
||||
})
|
||||
.Select(x => new
|
||||
{
|
||||
x.Local,
|
||||
x.TitleScore,
|
||||
x.ArtistScore,
|
||||
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
|
||||
})
|
||||
.OrderByDescending(x => x.TotalScore)
|
||||
.FirstOrDefault();
|
||||
// Manual Jellyfin mapping exists - this track is definitely local
|
||||
isLocal = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Check for external manual mapping
|
||||
var externalMappingKey = $"spotify:external-map:{config.Name}:{track.SpotifyId}";
|
||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||
|
||||
// Use 70% threshold (same as playback matching)
|
||||
if (bestMatch != null && bestMatch.TotalScore >= 70)
|
||||
if (!string.IsNullOrEmpty(externalMappingJson))
|
||||
{
|
||||
isLocal = true;
|
||||
// External manual mapping exists
|
||||
hasExternalMapping = true;
|
||||
}
|
||||
else if (localTracks.Count > 0)
|
||||
{
|
||||
// SECOND: No manual mapping, try fuzzy matching with local tracks
|
||||
var bestMatch = localTracks
|
||||
.Select(local => new
|
||||
{
|
||||
Local = local,
|
||||
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, local.Title),
|
||||
ArtistScore = FuzzyMatcher.CalculateSimilarity(track.PrimaryArtist, local.Artist)
|
||||
})
|
||||
.Select(x => new
|
||||
{
|
||||
x.Local,
|
||||
x.TitleScore,
|
||||
x.ArtistScore,
|
||||
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
|
||||
})
|
||||
.OrderByDescending(x => x.TotalScore)
|
||||
.FirstOrDefault();
|
||||
|
||||
// Use 70% threshold (same as playback matching)
|
||||
if (bestMatch != null && bestMatch.TotalScore >= 70)
|
||||
{
|
||||
isLocal = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -502,8 +525,8 @@ public class AdminController : ControllerBase
|
||||
}
|
||||
else
|
||||
{
|
||||
// Check if external track is matched
|
||||
if (matchedSpotifyIds.Contains(track.SpotifyId))
|
||||
// Check if external track is matched (either manual mapping or auto-matched)
|
||||
if (hasExternalMapping || matchedSpotifyIds.Contains(track.SpotifyId))
|
||||
{
|
||||
externalMatchedCount++;
|
||||
}
|
||||
@@ -547,54 +570,6 @@ public class AdminController : ControllerBase
|
||||
_logger.LogWarning("Playlist {Name} has no JellyfinId configured", config.Name);
|
||||
}
|
||||
|
||||
// Get lyrics completion status
|
||||
try
|
||||
{
|
||||
var tracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name);
|
||||
if (tracks.Count > 0)
|
||||
{
|
||||
var lyricsWithCount = 0;
|
||||
var lyricsWithoutCount = 0;
|
||||
|
||||
foreach (var track in tracks)
|
||||
{
|
||||
var cacheKey = $"lyrics:{track.PrimaryArtist}:{track.Title}:{track.Album}:{track.DurationMs / 1000}";
|
||||
var existingLyrics = await _cache.GetStringAsync(cacheKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(existingLyrics))
|
||||
{
|
||||
lyricsWithCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
lyricsWithoutCount++;
|
||||
}
|
||||
}
|
||||
|
||||
playlistInfo["lyricsTotal"] = tracks.Count;
|
||||
playlistInfo["lyricsCached"] = lyricsWithCount;
|
||||
playlistInfo["lyricsMissing"] = lyricsWithoutCount;
|
||||
playlistInfo["lyricsPercentage"] = tracks.Count > 0
|
||||
? (int)Math.Round((double)lyricsWithCount / tracks.Count * 100)
|
||||
: 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
playlistInfo["lyricsTotal"] = 0;
|
||||
playlistInfo["lyricsCached"] = 0;
|
||||
playlistInfo["lyricsMissing"] = 0;
|
||||
playlistInfo["lyricsPercentage"] = 0;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to get lyrics completion for playlist {Name}", config.Name);
|
||||
playlistInfo["lyricsTotal"] = 0;
|
||||
playlistInfo["lyricsCached"] = 0;
|
||||
playlistInfo["lyricsMissing"] = 0;
|
||||
playlistInfo["lyricsPercentage"] = 0;
|
||||
}
|
||||
|
||||
playlists.Add(playlistInfo);
|
||||
}
|
||||
|
||||
@@ -630,236 +605,221 @@ public class AdminController : ControllerBase
|
||||
// Get Spotify tracks
|
||||
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(decodedName);
|
||||
|
||||
// Get the playlist config to find Jellyfin ID
|
||||
var playlistConfig = _spotifyImportSettings.Playlists
|
||||
.FirstOrDefault(p => p.Name.Equals(decodedName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var tracksWithStatus = new List<object>();
|
||||
|
||||
if (!string.IsNullOrEmpty(playlistConfig?.JellyfinId))
|
||||
// Use the pre-built playlist cache (same as GetPlaylists endpoint)
|
||||
// This cache includes all matched tracks with proper provider IDs
|
||||
var playlistItemsCacheKey = $"spotify:playlist:items:{decodedName}";
|
||||
|
||||
List<Dictionary<string, object?>>? cachedPlaylistItems = null;
|
||||
try
|
||||
{
|
||||
// Get existing tracks from Jellyfin to determine local/external status
|
||||
var userId = _jellyfinSettings.UserId;
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
cachedPlaylistItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(playlistItemsCacheKey);
|
||||
}
|
||||
catch (Exception cacheEx)
|
||||
{
|
||||
_logger.LogWarning(cacheEx, "Failed to deserialize playlist cache for {Playlist}", decodedName);
|
||||
}
|
||||
|
||||
_logger.LogInformation("GetPlaylistTracks for {Playlist}: Cache found: {Found}, Count: {Count}",
|
||||
decodedName, cachedPlaylistItems != null, cachedPlaylistItems?.Count ?? 0);
|
||||
|
||||
if (cachedPlaylistItems != null && cachedPlaylistItems.Count > 0)
|
||||
{
|
||||
// Build a map of Spotify ID -> cached item for quick lookup
|
||||
var spotifyIdToItem = new Dictionary<string, Dictionary<string, object?>>();
|
||||
|
||||
foreach (var item in cachedPlaylistItems)
|
||||
{
|
||||
try
|
||||
if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null)
|
||||
{
|
||||
var url = $"{_jellyfinSettings.Url}/Playlists/{playlistConfig.JellyfinId}/Items?UserId={userId}";
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
||||
Dictionary<string, string>? providerIds = null;
|
||||
|
||||
var response = await _jellyfinHttpClient.SendAsync(request);
|
||||
if (response.IsSuccessStatusCode)
|
||||
if (providerIdsObj is Dictionary<string, string> dict)
|
||||
{
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
// Build list of local tracks (match by name only - no Spotify IDs!)
|
||||
var localTracks = new List<(string Title, string Artist)>();
|
||||
if (doc.RootElement.TryGetProperty("Items", out var items))
|
||||
providerIds = dict;
|
||||
}
|
||||
else if (providerIdsObj is JsonElement jsonEl && jsonEl.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
providerIds = new Dictionary<string, string>();
|
||||
foreach (var prop in jsonEl.EnumerateObject())
|
||||
{
|
||||
foreach (var item in items.EnumerateArray())
|
||||
providerIds[prop.Name] = prop.Value.GetString() ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
if (providerIds != null && providerIds.TryGetValue("Spotify", out var spotifyId) && !string.IsNullOrEmpty(spotifyId))
|
||||
{
|
||||
spotifyIdToItem[spotifyId] = item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Match each Spotify track to its cached item
|
||||
foreach (var track in spotifyTracks)
|
||||
{
|
||||
bool? isLocal = null;
|
||||
string? externalProvider = null;
|
||||
bool isManualMapping = false;
|
||||
string? manualMappingType = null;
|
||||
string? manualMappingId = null;
|
||||
|
||||
if (spotifyIdToItem.TryGetValue(track.SpotifyId, out var cachedItem))
|
||||
{
|
||||
// Track is in the cache - determine if it's local or external
|
||||
if (cachedItem.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null)
|
||||
{
|
||||
Dictionary<string, string>? providerIds = null;
|
||||
|
||||
if (providerIdsObj is Dictionary<string, string> dict)
|
||||
{
|
||||
providerIds = dict;
|
||||
}
|
||||
else if (providerIdsObj is JsonElement jsonEl && jsonEl.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
providerIds = new Dictionary<string, string>();
|
||||
foreach (var prop in jsonEl.EnumerateObject())
|
||||
{
|
||||
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
|
||||
var artist = "";
|
||||
|
||||
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
|
||||
{
|
||||
artist = artistsEl[0].GetString() ?? "";
|
||||
}
|
||||
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
|
||||
{
|
||||
artist = albumArtistEl.GetString() ?? "";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(title))
|
||||
{
|
||||
localTracks.Add((title, artist));
|
||||
}
|
||||
providerIds[prop.Name] = prop.Value.GetString() ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Found {Count} local tracks in Jellyfin playlist {Playlist}",
|
||||
localTracks.Count, decodedName);
|
||||
|
||||
// Get matched external tracks cache
|
||||
var matchedTracksKey = $"spotify:matched:ordered:{decodedName}";
|
||||
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
|
||||
var matchedSpotifyIds = new HashSet<string>(
|
||||
matchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>()
|
||||
);
|
||||
|
||||
// Match Spotify tracks to local tracks by name (fuzzy matching)
|
||||
foreach (var track in spotifyTracks)
|
||||
if (providerIds != null)
|
||||
{
|
||||
bool? isLocal = null;
|
||||
string? externalProvider = null;
|
||||
bool isManualMapping = false;
|
||||
string? manualMappingType = null;
|
||||
string? manualMappingId = null;
|
||||
_logger.LogDebug("Track {Title} has ProviderIds: {Keys}", track.Title, string.Join(", ", providerIds.Keys));
|
||||
|
||||
// FIRST: Check for manual Jellyfin mapping
|
||||
var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}";
|
||||
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
|
||||
// Check for external provider keys (case-insensitive)
|
||||
// External providers: squidwtf, deezer, qobuz, tidal (lowercase)
|
||||
var providerKey = providerIds.Keys.FirstOrDefault(k =>
|
||||
k.Equals("squidwtf", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Equals("SquidWTF", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (!string.IsNullOrEmpty(manualJellyfinId))
|
||||
if (providerKey != null)
|
||||
{
|
||||
// Manual Jellyfin mapping exists - this track is definitely local
|
||||
isLocal = true;
|
||||
isManualMapping = true;
|
||||
manualMappingType = "jellyfin";
|
||||
manualMappingId = manualJellyfinId;
|
||||
_logger.LogDebug("✓ Manual Jellyfin mapping found for {Title}: Jellyfin ID {Id}",
|
||||
track.Title, manualJellyfinId);
|
||||
isLocal = false;
|
||||
externalProvider = "SquidWTF";
|
||||
_logger.LogDebug("✓ Track {Title} identified as SquidWTF", track.Title);
|
||||
}
|
||||
else if ((providerKey = providerIds.Keys.FirstOrDefault(k => k.Equals("deezer", StringComparison.OrdinalIgnoreCase))) != null)
|
||||
{
|
||||
isLocal = false;
|
||||
externalProvider = "Deezer";
|
||||
_logger.LogDebug("✓ Track {Title} identified as Deezer", track.Title);
|
||||
}
|
||||
else if ((providerKey = providerIds.Keys.FirstOrDefault(k => k.Equals("qobuz", StringComparison.OrdinalIgnoreCase))) != null)
|
||||
{
|
||||
isLocal = false;
|
||||
externalProvider = "Qobuz";
|
||||
_logger.LogDebug("✓ Track {Title} identified as Qobuz", track.Title);
|
||||
}
|
||||
else if ((providerKey = providerIds.Keys.FirstOrDefault(k => k.Equals("tidal", StringComparison.OrdinalIgnoreCase))) != null)
|
||||
{
|
||||
isLocal = false;
|
||||
externalProvider = "Tidal";
|
||||
_logger.LogDebug("✓ Track {Title} identified as Tidal", track.Title);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Check for external manual mapping
|
||||
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
|
||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(externalMappingJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var extDoc = JsonDocument.Parse(externalMappingJson);
|
||||
var extRoot = extDoc.RootElement;
|
||||
|
||||
string? provider = null;
|
||||
string? externalId = null;
|
||||
|
||||
if (extRoot.TryGetProperty("provider", out var providerEl))
|
||||
{
|
||||
provider = providerEl.GetString();
|
||||
}
|
||||
|
||||
if (extRoot.TryGetProperty("id", out var idEl))
|
||||
{
|
||||
externalId = idEl.GetString();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(provider) && !string.IsNullOrEmpty(externalId))
|
||||
{
|
||||
// External manual mapping exists
|
||||
isLocal = false;
|
||||
externalProvider = provider;
|
||||
isManualMapping = true;
|
||||
manualMappingType = "external";
|
||||
manualMappingId = externalId;
|
||||
_logger.LogDebug("✓ Manual external mapping found for {Title}: {Provider} {ExternalId}",
|
||||
track.Title, provider, externalId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", track.Title);
|
||||
}
|
||||
}
|
||||
else if (localTracks.Count > 0)
|
||||
{
|
||||
// SECOND: No manual mapping, try fuzzy matching with local tracks
|
||||
var bestMatch = localTracks
|
||||
.Select(local => new
|
||||
{
|
||||
Local = local,
|
||||
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, local.Title),
|
||||
ArtistScore = FuzzyMatcher.CalculateSimilarity(track.PrimaryArtist, local.Artist)
|
||||
})
|
||||
.Select(x => new
|
||||
{
|
||||
x.Local,
|
||||
x.TitleScore,
|
||||
x.ArtistScore,
|
||||
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
|
||||
})
|
||||
.OrderByDescending(x => x.TotalScore)
|
||||
.FirstOrDefault();
|
||||
|
||||
// Use 70% threshold (same as playback matching)
|
||||
if (bestMatch != null && bestMatch.TotalScore >= 70)
|
||||
{
|
||||
isLocal = true;
|
||||
}
|
||||
}
|
||||
// No external provider key found - it's a local track
|
||||
// Local tracks have MusicBrainz, ISRC, Spotify IDs but no external provider
|
||||
isLocal = true;
|
||||
_logger.LogDebug("✓ Track {Title} identified as LOCAL (has ProviderIds but no external provider)", track.Title);
|
||||
}
|
||||
|
||||
// If not local, check if it's externally matched or missing
|
||||
if (isLocal != true)
|
||||
{
|
||||
// Check if there's a manual external mapping
|
||||
if (isManualMapping && manualMappingType == "external")
|
||||
{
|
||||
// Track has manual external mapping - it's available externally
|
||||
isLocal = false;
|
||||
// externalProvider already set above
|
||||
}
|
||||
else if (matchedSpotifyIds.Contains(track.SpotifyId))
|
||||
{
|
||||
// Track is externally matched (search succeeded)
|
||||
isLocal = false;
|
||||
externalProvider = "SquidWTF"; // Default to SquidWTF for external matches
|
||||
}
|
||||
else
|
||||
{
|
||||
// Track is missing (search failed)
|
||||
isLocal = null;
|
||||
externalProvider = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Check lyrics status (only from our cache - lrclib/Spotify)
|
||||
// Note: For local tracks, Jellyfin may have embedded lyrics that we don't check here
|
||||
// Those will be served directly by Jellyfin when requested
|
||||
var cacheKey = $"lyrics:{track.PrimaryArtist}:{track.Title}:{track.Album}:{track.DurationMs / 1000}";
|
||||
var existingLyrics = await _cache.GetStringAsync(cacheKey);
|
||||
var hasLyrics = !string.IsNullOrEmpty(existingLyrics);
|
||||
|
||||
tracksWithStatus.Add(new
|
||||
{
|
||||
position = track.Position,
|
||||
title = track.Title,
|
||||
artists = track.Artists,
|
||||
album = track.Album,
|
||||
isrc = track.Isrc,
|
||||
spotifyId = track.SpotifyId,
|
||||
durationMs = track.DurationMs,
|
||||
albumArtUrl = track.AlbumArtUrl,
|
||||
isLocal = isLocal,
|
||||
externalProvider = externalProvider,
|
||||
searchQuery = isLocal != true ? $"{track.Title} {track.PrimaryArtist}" : null, // Set for both external and missing
|
||||
isManualMapping = isManualMapping,
|
||||
manualMappingType = manualMappingType,
|
||||
manualMappingId = manualMappingId,
|
||||
hasLyrics = hasLyrics
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
else
|
||||
{
|
||||
name = decodedName,
|
||||
trackCount = spotifyTracks.Count,
|
||||
tracks = tracksWithStatus
|
||||
});
|
||||
_logger.LogWarning("Track {Title} has ProviderIds object but it's null after parsing", track.Title);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Track {Title} in cache but has NO ProviderIds - treating as missing", track.Title);
|
||||
isLocal = null;
|
||||
externalProvider = null;
|
||||
}
|
||||
|
||||
// Check if this is a manual mapping
|
||||
var manualJellyfinKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}";
|
||||
var manualJellyfinId = await _cache.GetAsync<string>(manualJellyfinKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(manualJellyfinId))
|
||||
{
|
||||
isManualMapping = true;
|
||||
manualMappingType = "jellyfin";
|
||||
manualMappingId = manualJellyfinId;
|
||||
}
|
||||
else
|
||||
{
|
||||
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
|
||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(externalMappingJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var extDoc = JsonDocument.Parse(externalMappingJson);
|
||||
var extRoot = extDoc.RootElement;
|
||||
|
||||
if (extRoot.TryGetProperty("id", out var idEl))
|
||||
{
|
||||
isManualMapping = true;
|
||||
manualMappingType = "external";
|
||||
manualMappingId = idEl.GetString();
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to get local track status for {Playlist}", decodedName);
|
||||
// Track not in cache - it's missing
|
||||
isLocal = null;
|
||||
externalProvider = null;
|
||||
}
|
||||
|
||||
// Check lyrics status
|
||||
var cacheKey = $"lyrics:{track.PrimaryArtist}:{track.Title}:{track.Album}:{track.DurationMs / 1000}";
|
||||
var existingLyrics = await _cache.GetStringAsync(cacheKey);
|
||||
var hasLyrics = !string.IsNullOrEmpty(existingLyrics);
|
||||
|
||||
tracksWithStatus.Add(new
|
||||
{
|
||||
position = track.Position,
|
||||
title = track.Title,
|
||||
artists = track.Artists,
|
||||
album = track.Album,
|
||||
isrc = track.Isrc,
|
||||
spotifyId = track.SpotifyId,
|
||||
durationMs = track.DurationMs,
|
||||
albumArtUrl = track.AlbumArtUrl,
|
||||
isLocal = isLocal,
|
||||
externalProvider = externalProvider,
|
||||
searchQuery = isLocal != true ? $"{track.Title} {track.PrimaryArtist}" : null,
|
||||
isManualMapping = isManualMapping,
|
||||
manualMappingType = manualMappingType,
|
||||
manualMappingId = manualMappingId,
|
||||
hasLyrics = hasLyrics
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
name = decodedName,
|
||||
trackCount = spotifyTracks.Count,
|
||||
tracks = tracksWithStatus
|
||||
});
|
||||
}
|
||||
|
||||
// If we get here, we couldn't get local tracks from Jellyfin
|
||||
// Just return tracks with basic external/missing status based on cache
|
||||
// Fallback: Cache not available, use matched tracks cache
|
||||
_logger.LogWarning("Playlist cache not available for {Playlist}, using fallback", decodedName);
|
||||
|
||||
var fallbackMatchedTracksKey = $"spotify:matched:ordered:{decodedName}";
|
||||
var fallbackMatchedTracks = await _cache.GetAsync<List<MatchedTrack>>(fallbackMatchedTracksKey);
|
||||
var fallbackMatchedSpotifyIds = new HashSet<string>(
|
||||
fallbackMatchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>()
|
||||
);
|
||||
|
||||
// Clear and reuse tracksWithStatus for fallback
|
||||
tracksWithStatus.Clear();
|
||||
|
||||
foreach (var track in spotifyTracks)
|
||||
{
|
||||
bool? isLocal = null;
|
||||
@@ -906,13 +866,11 @@ public class AdminController : ControllerBase
|
||||
}
|
||||
else if (fallbackMatchedSpotifyIds.Contains(track.SpotifyId))
|
||||
{
|
||||
// Track is externally matched (search succeeded)
|
||||
isLocal = false;
|
||||
externalProvider = "SquidWTF"; // Default to SquidWTF for external matches
|
||||
externalProvider = "SquidWTF";
|
||||
}
|
||||
else
|
||||
{
|
||||
// Track is missing (search failed)
|
||||
isLocal = null;
|
||||
externalProvider = null;
|
||||
}
|
||||
@@ -930,7 +888,7 @@ public class AdminController : ControllerBase
|
||||
albumArtUrl = track.AlbumArtUrl,
|
||||
isLocal = isLocal,
|
||||
externalProvider = externalProvider,
|
||||
searchQuery = isLocal != true ? $"{track.Title} {track.PrimaryArtist}" : null // Set for both external and missing
|
||||
searchQuery = isLocal != true ? $"{track.Title} {track.PrimaryArtist}" : null
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1433,9 +1391,7 @@ public class AdminController : ControllerBase
|
||||
spotifyImport = new
|
||||
{
|
||||
enabled = _spotifyImportSettings.Enabled,
|
||||
syncStartHour = _spotifyImportSettings.SyncStartHour,
|
||||
syncStartMinute = _spotifyImportSettings.SyncStartMinute,
|
||||
syncWindowHours = _spotifyImportSettings.SyncWindowHours,
|
||||
matchingIntervalHours = _spotifyImportSettings.MatchingIntervalHours,
|
||||
playlists = _spotifyImportSettings.Playlists.Select(p => new
|
||||
{
|
||||
name = p.Name,
|
||||
@@ -1450,6 +1406,16 @@ public class AdminController : ControllerBase
|
||||
userId = _jellyfinSettings.UserId ?? "(not set)",
|
||||
libraryId = _jellyfinSettings.LibraryId
|
||||
},
|
||||
library = new
|
||||
{
|
||||
downloadPath = _subsonicSettings.StorageMode == StorageMode.Cache
|
||||
? Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "cache")
|
||||
: Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "permanent"),
|
||||
keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept"),
|
||||
storageMode = _subsonicSettings.StorageMode.ToString(),
|
||||
cacheDurationHours = _subsonicSettings.CacheDurationHours,
|
||||
downloadMode = _subsonicSettings.DownloadMode.ToString()
|
||||
},
|
||||
deezer = new
|
||||
{
|
||||
arl = MaskValue(_deezerSettings.Arl, showLast: 8),
|
||||
@@ -2016,8 +1982,13 @@ public class AdminController : ControllerBase
|
||||
var isConfigured = configuredPlaylist != null;
|
||||
var linkedSpotifyId = configuredPlaylist?.Id;
|
||||
|
||||
// Fetch track details to categorize local vs external
|
||||
var trackStats = await GetPlaylistTrackStats(id!);
|
||||
// Only fetch detailed track stats for configured Spotify playlists
|
||||
// This avoids expensive queries for large non-Spotify playlists
|
||||
var trackStats = (LocalTracks: 0, ExternalTracks: 0, ExternalAvailable: 0);
|
||||
if (isConfigured)
|
||||
{
|
||||
trackStats = await GetPlaylistTrackStats(id!);
|
||||
}
|
||||
|
||||
playlists.Add(new
|
||||
{
|
||||
@@ -2726,7 +2697,11 @@ public class AdminController : ControllerBase
|
||||
if (parts.Length >= 3)
|
||||
{
|
||||
var timestamp = parts[0];
|
||||
var endpoint = parts[1];
|
||||
var method = parts[1];
|
||||
var endpoint = parts[2];
|
||||
|
||||
// Combine method and endpoint for better clarity
|
||||
var fullEndpoint = $"{method} {endpoint}";
|
||||
|
||||
// Filter by date if specified
|
||||
if (sinceDate.HasValue && DateTime.TryParse(timestamp, out var logDate))
|
||||
@@ -2735,7 +2710,7 @@ public class AdminController : ControllerBase
|
||||
continue;
|
||||
}
|
||||
|
||||
usage[endpoint] = usage.GetValueOrDefault(endpoint, 0) + 1;
|
||||
usage[fullEndpoint] = usage.GetValueOrDefault(fullEndpoint, 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3267,7 +3242,6 @@ public class AdminController : ControllerBase
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
public class ManualMappingRequest
|
||||
{
|
||||
@@ -3323,36 +3297,44 @@ public class LinkPlaylistRequest
|
||||
public string SpotifyPlaylistId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/admin/downloads
|
||||
/// Lists all downloaded files in the downloads directory
|
||||
/// Lists all downloaded files in the KEPT folder only (favorited tracks)
|
||||
/// </summary>
|
||||
[HttpGet("downloads")]
|
||||
public IActionResult GetDownloads()
|
||||
{
|
||||
try
|
||||
{
|
||||
var downloadPath = _configuration["Library:DownloadPath"] ?? "./downloads";
|
||||
var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
|
||||
|
||||
if (!Directory.Exists(downloadPath))
|
||||
_logger.LogInformation("📂 Checking kept folder: {Path}", keptPath);
|
||||
_logger.LogInformation("📂 Directory exists: {Exists}", Directory.Exists(keptPath));
|
||||
|
||||
if (!Directory.Exists(keptPath))
|
||||
{
|
||||
return Ok(new { files = new List<object>(), totalSize = 0 });
|
||||
_logger.LogWarning("Kept folder does not exist: {Path}", keptPath);
|
||||
return Ok(new { files = new List<object>(), totalSize = 0, count = 0 });
|
||||
}
|
||||
|
||||
var files = new List<object>();
|
||||
long totalSize = 0;
|
||||
|
||||
// Recursively get all audio files
|
||||
// Recursively get all audio files from kept folder
|
||||
var audioExtensions = new[] { ".flac", ".mp3", ".m4a", ".opus" };
|
||||
var allFiles = Directory.GetFiles(downloadPath, "*.*", SearchOption.AllDirectories)
|
||||
|
||||
var allFiles = Directory.GetFiles(keptPath, "*.*", SearchOption.AllDirectories)
|
||||
.Where(f => audioExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
|
||||
.ToList();
|
||||
|
||||
_logger.LogInformation("📂 Found {Count} audio files in kept folder", allFiles.Count);
|
||||
|
||||
foreach (var filePath in allFiles)
|
||||
{
|
||||
_logger.LogDebug("📂 Processing file: {Path}", filePath);
|
||||
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
var relativePath = Path.GetRelativePath(downloadPath, filePath);
|
||||
var relativePath = Path.GetRelativePath(keptPath, filePath);
|
||||
|
||||
// Parse artist/album/track from path structure
|
||||
var parts = relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
@@ -3376,6 +3358,8 @@ public class LinkPlaylistRequest
|
||||
totalSize += fileInfo.Length;
|
||||
}
|
||||
|
||||
_logger.LogInformation("📂 Returning {Count} kept files, total size: {Size}", files.Count, FormatFileSize(totalSize));
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
files = files.OrderBy(f => ((dynamic)f).artist).ThenBy(f => ((dynamic)f).album).ThenBy(f => ((dynamic)f).fileName),
|
||||
@@ -3386,14 +3370,14 @@ public class LinkPlaylistRequest
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to list downloads");
|
||||
return StatusCode(500, new { error = "Failed to list downloads" });
|
||||
_logger.LogError(ex, "Failed to list kept downloads");
|
||||
return StatusCode(500, new { error = "Failed to list kept downloads" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DELETE /api/admin/downloads
|
||||
/// Deletes a specific downloaded file
|
||||
/// Deletes a specific kept file and cleans up empty folders
|
||||
/// </summary>
|
||||
[HttpDelete("downloads")]
|
||||
public IActionResult DeleteDownload([FromQuery] string path)
|
||||
@@ -3405,54 +3389,59 @@ public class LinkPlaylistRequest
|
||||
return BadRequest(new { error = "Path is required" });
|
||||
}
|
||||
|
||||
var downloadPath = _configuration["Library:DownloadPath"] ?? "./downloads";
|
||||
var fullPath = Path.Combine(downloadPath, path);
|
||||
var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
|
||||
var fullPath = Path.Combine(keptPath, path);
|
||||
|
||||
// Security: Ensure the path is within the download directory
|
||||
_logger.LogInformation("🗑️ Delete request for: {Path}", fullPath);
|
||||
|
||||
// Security: Ensure the path is within the kept directory
|
||||
var normalizedFullPath = Path.GetFullPath(fullPath);
|
||||
var normalizedDownloadPath = Path.GetFullPath(downloadPath);
|
||||
var normalizedKeptPath = Path.GetFullPath(keptPath);
|
||||
|
||||
if (!normalizedFullPath.StartsWith(normalizedDownloadPath))
|
||||
if (!normalizedFullPath.StartsWith(normalizedKeptPath))
|
||||
{
|
||||
_logger.LogWarning("🗑️ Invalid path (outside kept folder): {Path}", normalizedFullPath);
|
||||
return BadRequest(new { error = "Invalid path" });
|
||||
}
|
||||
|
||||
if (!System.IO.File.Exists(fullPath))
|
||||
{
|
||||
_logger.LogWarning("🗑️ File not found: {Path}", fullPath);
|
||||
return NotFound(new { error = "File not found" });
|
||||
}
|
||||
|
||||
System.IO.File.Delete(fullPath);
|
||||
_logger.LogInformation("Deleted download: {Path}", path);
|
||||
_logger.LogInformation("🗑️ Deleted file: {Path}", fullPath);
|
||||
|
||||
// Clean up empty directories
|
||||
// Clean up empty directories (Album folder, then Artist folder if empty)
|
||||
var directory = Path.GetDirectoryName(fullPath);
|
||||
while (directory != null && directory != downloadPath)
|
||||
while (directory != null && directory != keptPath && directory.StartsWith(keptPath))
|
||||
{
|
||||
if (Directory.Exists(directory) && !Directory.EnumerateFileSystemEntries(directory).Any())
|
||||
{
|
||||
Directory.Delete(directory);
|
||||
_logger.LogDebug("Deleted empty directory: {Dir}", directory);
|
||||
_logger.LogInformation("🗑️ Deleted empty directory: {Dir}", directory);
|
||||
directory = Path.GetDirectoryName(directory);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("🗑️ Directory not empty or doesn't exist, stopping cleanup: {Dir}", directory);
|
||||
break;
|
||||
}
|
||||
directory = Path.GetDirectoryName(directory);
|
||||
}
|
||||
|
||||
return Ok(new { success = true, message = "File deleted successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to delete download: {Path}", path);
|
||||
_logger.LogError(ex, "Failed to delete file: {Path}", path);
|
||||
return StatusCode(500, new { error = "Failed to delete file" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/admin/downloads/file
|
||||
/// Downloads a specific file
|
||||
/// Downloads a specific file from the kept folder
|
||||
/// </summary>
|
||||
[HttpGet("downloads/file")]
|
||||
public IActionResult DownloadFile([FromQuery] string path)
|
||||
@@ -3464,14 +3453,14 @@ public class LinkPlaylistRequest
|
||||
return BadRequest(new { error = "Path is required" });
|
||||
}
|
||||
|
||||
var downloadPath = _configuration["Library:DownloadPath"] ?? "./downloads";
|
||||
var fullPath = Path.Combine(downloadPath, path);
|
||||
var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
|
||||
var fullPath = Path.Combine(keptPath, path);
|
||||
|
||||
// Security: Ensure the path is within the download directory
|
||||
// Security: Ensure the path is within the kept directory
|
||||
var normalizedFullPath = Path.GetFullPath(fullPath);
|
||||
var normalizedDownloadPath = Path.GetFullPath(downloadPath);
|
||||
var normalizedKeptPath = Path.GetFullPath(keptPath);
|
||||
|
||||
if (!normalizedFullPath.StartsWith(normalizedDownloadPath))
|
||||
if (!normalizedFullPath.StartsWith(normalizedKeptPath))
|
||||
{
|
||||
return BadRequest(new { error = "Invalid path" });
|
||||
}
|
||||
|
||||
@@ -40,7 +40,9 @@ public class JellyfinController : ControllerBase
|
||||
private readonly SpotifyPlaylistFetcher? _spotifyPlaylistFetcher;
|
||||
private readonly SpotifyLyricsService? _spotifyLyricsService;
|
||||
private readonly LrclibService? _lrclibService;
|
||||
private readonly OdesliService _odesliService;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<JellyfinController> _logger;
|
||||
|
||||
public JellyfinController(
|
||||
@@ -54,7 +56,9 @@ public class JellyfinController : ControllerBase
|
||||
JellyfinModelMapper modelMapper,
|
||||
JellyfinProxyService proxyService,
|
||||
JellyfinSessionManager sessionManager,
|
||||
OdesliService odesliService,
|
||||
RedisCacheService cache,
|
||||
IConfiguration configuration,
|
||||
ILogger<JellyfinController> logger,
|
||||
ParallelMetadataService? parallelMetadataService = null,
|
||||
PlaylistSyncService? playlistSyncService = null,
|
||||
@@ -77,7 +81,9 @@ public class JellyfinController : ControllerBase
|
||||
_spotifyPlaylistFetcher = spotifyPlaylistFetcher;
|
||||
_spotifyLyricsService = spotifyLyricsService;
|
||||
_lrclibService = lrclibService;
|
||||
_odesliService = odesliService;
|
||||
_cache = cache;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_settings.Url))
|
||||
@@ -273,63 +279,71 @@ public class JellyfinController : ControllerBase
|
||||
// Parse Jellyfin results into domain models
|
||||
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult);
|
||||
|
||||
// Score and filter Jellyfin results by relevance
|
||||
var scoredLocalSongs = ScoreSearchResults(cleanQuery, localSongs, s => s.Title, s => s.Artist, s => s.Album, isExternal: false);
|
||||
var scoredLocalAlbums = ScoreSearchResults(cleanQuery, localAlbums, a => a.Title, a => a.Artist, _ => null, isExternal: false);
|
||||
var scoredLocalArtists = ScoreSearchResults(cleanQuery, localArtists, a => a.Name, _ => null, _ => null, isExternal: false);
|
||||
// Respect source ordering (SquidWTF/Tidal has better search ranking than our fuzzy matching)
|
||||
// Just interleave local and external results based on which source has better overall match
|
||||
|
||||
// Score external results with a small boost
|
||||
var scoredExternalSongs = ScoreSearchResults(cleanQuery, externalResult.Songs, s => s.Title, s => s.Artist, s => s.Album, isExternal: true);
|
||||
var scoredExternalAlbums = ScoreSearchResults(cleanQuery, externalResult.Albums, a => a.Title, a => a.Artist, _ => null, isExternal: true);
|
||||
var scoredExternalArtists = ScoreSearchResults(cleanQuery, externalResult.Artists, a => a.Name, _ => null, _ => null, isExternal: true);
|
||||
// Calculate average match score for each source to determine which should come first
|
||||
var localSongsAvgScore = localSongs.Any()
|
||||
? localSongs.Average(s => FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title))
|
||||
: 0.0;
|
||||
var externalSongsAvgScore = externalResult.Songs.Any()
|
||||
? externalResult.Songs.Average(s => FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title))
|
||||
: 0.0;
|
||||
|
||||
// Merge and sort by score (no filtering - just reorder by relevance)
|
||||
var allSongs = scoredLocalSongs.Concat(scoredExternalSongs)
|
||||
.OrderByDescending(x => x.Score)
|
||||
.Select(x => x.Item)
|
||||
.ToList();
|
||||
var localAlbumsAvgScore = localAlbums.Any()
|
||||
? localAlbums.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title))
|
||||
: 0.0;
|
||||
var externalAlbumsAvgScore = externalResult.Albums.Any()
|
||||
? externalResult.Albums.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title))
|
||||
: 0.0;
|
||||
|
||||
var allAlbums = scoredLocalAlbums.Concat(scoredExternalAlbums)
|
||||
.OrderByDescending(x => x.Score)
|
||||
.Select(x => x.Item)
|
||||
.ToList();
|
||||
var localArtistsAvgScore = localArtists.Any()
|
||||
? localArtists.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name))
|
||||
: 0.0;
|
||||
var externalArtistsAvgScore = externalResult.Artists.Any()
|
||||
? externalResult.Artists.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name))
|
||||
: 0.0;
|
||||
|
||||
// NO deduplication - just merge and sort by relevance score
|
||||
// Show ALL matches (local + external) sorted by best match first
|
||||
var artistScores = scoredLocalArtists.Concat(scoredExternalArtists)
|
||||
.OrderByDescending(x => x.Score)
|
||||
.Select(x => x.Item)
|
||||
.ToList();
|
||||
// Interleave results: put better-matching source first, preserve original ordering within each source
|
||||
var allSongs = localSongsAvgScore >= externalSongsAvgScore
|
||||
? localSongs.Concat(externalResult.Songs).ToList()
|
||||
: externalResult.Songs.Concat(localSongs).ToList();
|
||||
|
||||
var allAlbums = localAlbumsAvgScore >= externalAlbumsAvgScore
|
||||
? localAlbums.Concat(externalResult.Albums).ToList()
|
||||
: externalResult.Albums.Concat(localAlbums).ToList();
|
||||
|
||||
var allArtists = localArtistsAvgScore >= externalArtistsAvgScore
|
||||
? localArtists.Concat(externalResult.Artists).ToList()
|
||||
: externalResult.Artists.Concat(localArtists).ToList();
|
||||
|
||||
// Log results for debugging
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
var localArtistNames = scoredLocalArtists.Select(x => $"{x.Item.Name} (local, score: {x.Score:F2})").ToList();
|
||||
var externalArtistNames = scoredExternalArtists.Select(x => $"{x.Item.Name} ({x.Item.ExternalProvider}, score: {x.Score:F2})").ToList();
|
||||
_logger.LogDebug("🎤 Artist results: Local={LocalArtists}, External={ExternalArtists}, Total={TotalCount}",
|
||||
string.Join(", ", localArtistNames),
|
||||
string.Join(", ", externalArtistNames),
|
||||
artistScores.Count);
|
||||
_logger.LogDebug("🎵 Songs: Local avg score={LocalScore:F2}, External avg score={ExtScore:F2}, Local first={LocalFirst}",
|
||||
localSongsAvgScore, externalSongsAvgScore, localSongsAvgScore >= externalSongsAvgScore);
|
||||
_logger.LogDebug("💿 Albums: Local avg score={LocalScore:F2}, External avg score={ExtScore:F2}, Local first={LocalFirst}",
|
||||
localAlbumsAvgScore, externalAlbumsAvgScore, localAlbumsAvgScore >= externalAlbumsAvgScore);
|
||||
_logger.LogDebug("🎤 Artists: Local avg score={LocalScore:F2}, External avg score={ExtScore:F2}, Local first={LocalFirst}",
|
||||
localArtistsAvgScore, externalArtistsAvgScore, localArtistsAvgScore >= externalArtistsAvgScore);
|
||||
}
|
||||
|
||||
// Convert to Jellyfin format
|
||||
var mergedSongs = allSongs.Select(s => _responseBuilder.ConvertSongToJellyfinItem(s)).ToList();
|
||||
var mergedAlbums = allAlbums.Select(a => _responseBuilder.ConvertAlbumToJellyfinItem(a)).ToList();
|
||||
var mergedArtists = artistScores.Select(a => _responseBuilder.ConvertArtistToJellyfinItem(a)).ToList();
|
||||
var mergedArtists = allArtists.Select(a => _responseBuilder.ConvertArtistToJellyfinItem(a)).ToList();
|
||||
|
||||
// Add playlists (score them too)
|
||||
// Add playlists (preserve their order too)
|
||||
if (playlistResult.Count > 0)
|
||||
{
|
||||
var scoredPlaylists = playlistResult
|
||||
.Select(p => new { Playlist = p, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, p.Name) })
|
||||
.OrderByDescending(x => x.Score)
|
||||
.Select(x => _responseBuilder.ConvertPlaylistToJellyfinItem(x.Playlist))
|
||||
var playlistItems = playlistResult
|
||||
.Select(p => _responseBuilder.ConvertPlaylistToJellyfinItem(p))
|
||||
.ToList();
|
||||
|
||||
mergedAlbums.AddRange(scoredPlaylists);
|
||||
mergedAlbums.AddRange(playlistItems);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Scored and filtered results: Songs={Songs}, Albums={Albums}, Artists={Artists}",
|
||||
_logger.LogInformation("Merged results (preserving source order): Songs={Songs}, Albums={Albums}, Artists={Artists}",
|
||||
mergedSongs.Count, mergedAlbums.Count, mergedArtists.Count);
|
||||
|
||||
// Pre-fetch lyrics for top 3 songs in background (don't await)
|
||||
@@ -1130,6 +1144,8 @@ public class JellyfinController : ControllerBase
|
||||
[HttpGet("Items/{itemId}/Lyrics")]
|
||||
public async Task<IActionResult> GetLyrics(string itemId)
|
||||
{
|
||||
_logger.LogInformation("🎵 GetLyrics called for itemId: {ItemId}", itemId);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(itemId))
|
||||
{
|
||||
return NotFound();
|
||||
@@ -1137,6 +1153,9 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
|
||||
|
||||
_logger.LogInformation("🎵 Lyrics request: itemId={ItemId}, isExternal={IsExternal}, provider={Provider}, externalId={ExternalId}",
|
||||
itemId, isExternal, provider, externalId);
|
||||
|
||||
// For local tracks, check if Jellyfin already has embedded lyrics
|
||||
if (!isExternal)
|
||||
{
|
||||
@@ -1145,13 +1164,16 @@ public class JellyfinController : ControllerBase
|
||||
// Try to get lyrics from Jellyfin first (it reads embedded lyrics from files)
|
||||
var (jellyfinLyrics, statusCode) = await _proxyService.GetJsonAsync($"Audio/{itemId}/Lyrics", null, Request.Headers);
|
||||
|
||||
_logger.LogInformation("Jellyfin lyrics check result: statusCode={StatusCode}, hasLyrics={HasLyrics}",
|
||||
statusCode, jellyfinLyrics != null);
|
||||
|
||||
if (jellyfinLyrics != null && statusCode == 200)
|
||||
{
|
||||
_logger.LogInformation("Found embedded lyrics in Jellyfin for track {ItemId}", itemId);
|
||||
return new JsonResult(JsonSerializer.Deserialize<object>(jellyfinLyrics.RootElement.GetRawText()));
|
||||
}
|
||||
|
||||
_logger.LogInformation("No embedded lyrics found in Jellyfin, trying Spotify/LRCLIB");
|
||||
_logger.LogInformation("No embedded lyrics found in Jellyfin (status: {StatusCode}), trying Spotify/LRCLIB", statusCode);
|
||||
}
|
||||
|
||||
// Get song metadata for lyrics search
|
||||
@@ -1162,9 +1184,15 @@ public class JellyfinController : ControllerBase
|
||||
{
|
||||
song = await _metadataService.GetSongAsync(provider!, externalId!);
|
||||
|
||||
// Try to find Spotify ID from matched tracks cache
|
||||
// External tracks from playlists should have been matched and cached
|
||||
if (song != null)
|
||||
// Use Spotify ID from song metadata if available (populated during GetSongAsync)
|
||||
if (song != null && !string.IsNullOrEmpty(song.SpotifyId))
|
||||
{
|
||||
spotifyTrackId = song.SpotifyId;
|
||||
_logger.LogInformation("Using Spotify ID {SpotifyId} from song metadata for {Provider}/{ExternalId}",
|
||||
spotifyTrackId, provider, externalId);
|
||||
}
|
||||
// Fallback: Try to find Spotify ID from matched tracks cache
|
||||
else if (song != null)
|
||||
{
|
||||
spotifyTrackId = await FindSpotifyIdForExternalTrackAsync(song);
|
||||
if (!string.IsNullOrEmpty(spotifyTrackId))
|
||||
@@ -1174,9 +1202,27 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
else
|
||||
{
|
||||
// If no cached Spotify ID, try to convert via Odesli/song.link
|
||||
// This works for SquidWTF (Tidal), Deezer, Qobuz, etc.
|
||||
spotifyTrackId = await ConvertToSpotifyIdViaOdesliAsync(song, provider!, externalId!);
|
||||
// Last resort: Try to convert via Odesli/song.link
|
||||
if (provider == "squidwtf")
|
||||
{
|
||||
spotifyTrackId = await _odesliService.ConvertTidalToSpotifyIdAsync(externalId!, HttpContext.RequestAborted);
|
||||
}
|
||||
else
|
||||
{
|
||||
// For other providers, build the URL and convert
|
||||
var sourceUrl = provider?.ToLowerInvariant() switch
|
||||
{
|
||||
"deezer" => $"https://www.deezer.com/track/{externalId}",
|
||||
"qobuz" => $"https://www.qobuz.com/us-en/album/-/-/{externalId}",
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(sourceUrl))
|
||||
{
|
||||
spotifyTrackId = await _odesliService.ConvertUrlToSpotifyIdAsync(sourceUrl, HttpContext.RequestAborted);
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(spotifyTrackId))
|
||||
{
|
||||
_logger.LogInformation("Converted {Provider}/{ExternalId} to Spotify ID {SpotifyId} via Odesli",
|
||||
@@ -1401,9 +1447,9 @@ public class JellyfinController : ControllerBase
|
||||
spotifyTrackId = await FindSpotifyIdForExternalTrackAsync(song);
|
||||
|
||||
// If no cached Spotify ID, try Odesli conversion
|
||||
if (string.IsNullOrEmpty(spotifyTrackId))
|
||||
if (string.IsNullOrEmpty(spotifyTrackId) && provider == "squidwtf")
|
||||
{
|
||||
spotifyTrackId = await ConvertToSpotifyIdViaOdesliAsync(song, provider, externalId);
|
||||
spotifyTrackId = await _odesliService.ConvertTidalToSpotifyIdAsync(externalId, HttpContext.RequestAborted);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1827,53 +1873,83 @@ public class JellyfinController : ControllerBase
|
||||
_logger.LogInformation("Authentication request received");
|
||||
// DO NOT log request body or detailed headers - contains password
|
||||
|
||||
// Forward to Jellyfin server with client headers
|
||||
// Forward to Jellyfin server with client headers - completely transparent proxy
|
||||
var (result, statusCode) = await _proxyService.PostJsonAsync("Users/AuthenticateByName", body, Request.Headers);
|
||||
|
||||
if (result == null)
|
||||
// Pass through Jellyfin's response exactly as-is (transparent proxy)
|
||||
if (result != null)
|
||||
{
|
||||
_logger.LogWarning("Authentication failed - status {StatusCode}", statusCode);
|
||||
if (statusCode == 401)
|
||||
var responseJson = result.RootElement.GetRawText();
|
||||
|
||||
// On successful auth, extract access token and post session capabilities in background
|
||||
if (statusCode == 200)
|
||||
{
|
||||
return Unauthorized(new { error = "Invalid username or password" });
|
||||
}
|
||||
return StatusCode(statusCode, new { error = "Authentication failed" });
|
||||
}
|
||||
_logger.LogInformation("Authentication successful");
|
||||
|
||||
_logger.LogInformation("Authentication successful");
|
||||
// Extract access token from response for session capabilities
|
||||
string? accessToken = null;
|
||||
if (result.RootElement.TryGetProperty("AccessToken", out var tokenEl))
|
||||
{
|
||||
accessToken = tokenEl.GetString();
|
||||
}
|
||||
|
||||
// Post session capabilities immediately after authentication
|
||||
// This ensures Jellyfin creates a session that will show up in the dashboard
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("🔧 Posting session capabilities after authentication");
|
||||
var capabilities = new
|
||||
{
|
||||
PlayableMediaTypes = new[] { "Audio" },
|
||||
SupportedCommands = Array.Empty<string>(),
|
||||
SupportsMediaControl = false,
|
||||
SupportsPersistentIdentifier = true,
|
||||
SupportsSync = false
|
||||
};
|
||||
// Post session capabilities in background if we have a token
|
||||
if (!string.IsNullOrEmpty(accessToken))
|
||||
{
|
||||
// Capture token in closure - don't use Request.Headers (will be disposed)
|
||||
var token = accessToken;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("🔧 Posting session capabilities after authentication");
|
||||
|
||||
var capabilitiesJson = JsonSerializer.Serialize(capabilities);
|
||||
var (capResult, capStatus) = await _proxyService.PostJsonAsync("Sessions/Capabilities/Full", capabilitiesJson, Request.Headers);
|
||||
// Build auth header with the new token
|
||||
var authHeaders = new HeaderDictionary
|
||||
{
|
||||
["X-Emby-Token"] = token
|
||||
};
|
||||
|
||||
if (capStatus == 204 || capStatus == 200)
|
||||
{
|
||||
_logger.LogInformation("✓ Session capabilities posted after auth ({StatusCode})", capStatus);
|
||||
var capabilities = new
|
||||
{
|
||||
PlayableMediaTypes = new[] { "Audio" },
|
||||
SupportedCommands = Array.Empty<string>(),
|
||||
SupportsMediaControl = false,
|
||||
SupportsPersistentIdentifier = true,
|
||||
SupportsSync = false
|
||||
};
|
||||
|
||||
var capabilitiesJson = JsonSerializer.Serialize(capabilities);
|
||||
var (capResult, capStatus) = await _proxyService.PostJsonAsync("Sessions/Capabilities/Full", capabilitiesJson, authHeaders);
|
||||
|
||||
if (capStatus == 204 || capStatus == 200)
|
||||
{
|
||||
_logger.LogDebug("✓ Session capabilities posted after auth ({StatusCode})", capStatus);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("⚠ Session capabilities returned {StatusCode} after auth", capStatus);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to post session capabilities after auth");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("⚠ Session capabilities returned {StatusCode} after auth", capStatus);
|
||||
_logger.LogWarning("Authentication failed - status {StatusCode}", statusCode);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to post session capabilities after auth, continuing anyway");
|
||||
|
||||
// Return Jellyfin's exact response
|
||||
return Content(responseJson, "application/json");
|
||||
}
|
||||
|
||||
return Content(result.RootElement.GetRawText(), "application/json");
|
||||
// No response body from Jellyfin - return status code only
|
||||
_logger.LogWarning("Authentication request returned {StatusCode} with no response body", statusCode);
|
||||
return StatusCode(statusCode);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -2107,7 +2183,7 @@ public class JellyfinController : ControllerBase
|
||||
var method = Request.Method;
|
||||
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}",
|
||||
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}")));
|
||||
@@ -2132,7 +2208,11 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
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
|
||||
{
|
||||
@@ -2166,7 +2246,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
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
|
||||
var doc = JsonDocument.Parse(body);
|
||||
@@ -2218,35 +2298,37 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
});
|
||||
|
||||
// CRITICAL: Create session for external tracks too!
|
||||
// Even though Jellyfin doesn't know about the track, we need a session
|
||||
// for the client to appear in the dashboard and receive remote control commands
|
||||
if (!string.IsNullOrEmpty(deviceId))
|
||||
{
|
||||
_logger.LogInformation("🔧 SESSION: Creating session for external track playback");
|
||||
var sessionCreated = await _sessionManager.EnsureSessionAsync(
|
||||
deviceId,
|
||||
client ?? "Unknown",
|
||||
device ?? "Unknown",
|
||||
version ?? "1.0",
|
||||
Request.Headers);
|
||||
// Create a ghost/fake item to report to Jellyfin so "Now Playing" shows up
|
||||
// Generate a deterministic UUID from the external ID
|
||||
var ghostUuid = GenerateUuidFromString(itemId);
|
||||
|
||||
if (sessionCreated)
|
||||
{
|
||||
_logger.LogInformation("✓ SESSION: Session created for external track playback on device {DeviceId}", deviceId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("⚠️ SESSION: Failed to create session for external track playback");
|
||||
}
|
||||
// Build minimal playback start with just the ghost UUID
|
||||
// Don't include the Item object - Jellyfin will just track the session without item details
|
||||
var playbackStart = new
|
||||
{
|
||||
ItemId = ghostUuid,
|
||||
PositionTicks = positionTicks ?? 0,
|
||||
CanSeek = true,
|
||||
IsPaused = false,
|
||||
IsMuted = false,
|
||||
PlayMethod = "DirectPlay"
|
||||
};
|
||||
|
||||
var playbackJson = JsonSerializer.Serialize(playbackStart);
|
||||
_logger.LogDebug("📤 Sending ghost playback start for external track: {Json}", playbackJson);
|
||||
|
||||
// Forward to Jellyfin with ghost UUID
|
||||
var (ghostResult, ghostStatusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", playbackJson, Request.Headers);
|
||||
|
||||
if (ghostStatusCode == 204 || ghostStatusCode == 200)
|
||||
{
|
||||
_logger.LogDebug("✓ Ghost playback start forwarded to Jellyfin for external track ({StatusCode})", ghostStatusCode);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("⚠️ SESSION: No device ID found for external track playback");
|
||||
_logger.LogWarning("⚠️ Ghost playback start returned status {StatusCode} for external track", ghostStatusCode);
|
||||
}
|
||||
|
||||
// For external tracks, we can't report playback to Jellyfin since it doesn't know about them
|
||||
// But the session is now active and will appear in the dashboard
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -2268,7 +2350,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
|
||||
// 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
|
||||
try
|
||||
@@ -2294,7 +2376,7 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
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)
|
||||
if (!string.IsNullOrEmpty(deviceId))
|
||||
@@ -2302,7 +2384,7 @@ public class JellyfinController : ControllerBase
|
||||
var sessionCreated = await _sessionManager.EnsureSessionAsync(deviceId, client ?? "Unknown", device ?? "Unknown", version ?? "1.0", Request.Headers);
|
||||
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
|
||||
{
|
||||
@@ -2326,7 +2408,7 @@ public class JellyfinController : ControllerBase
|
||||
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", body, Request.Headers);
|
||||
if (statusCode == 204 || statusCode == 200)
|
||||
{
|
||||
_logger.LogInformation("✓ Basic playback start forwarded to Jellyfin ({StatusCode})", statusCode);
|
||||
_logger.LogDebug("✓ Basic playback start forwarded to Jellyfin ({StatusCode})", statusCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2399,25 +2481,36 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
if (isExternal)
|
||||
{
|
||||
// For external tracks, update session activity to keep it alive
|
||||
// This ensures the session remains visible in Jellyfin dashboard
|
||||
if (!string.IsNullOrEmpty(deviceId))
|
||||
{
|
||||
_sessionManager.UpdateActivity(deviceId);
|
||||
// For external tracks, report progress with ghost UUID to Jellyfin
|
||||
var ghostUuid = GenerateUuidFromString(itemId);
|
||||
|
||||
// Log progress occasionally for debugging (every ~30 seconds)
|
||||
if (positionTicks.HasValue)
|
||||
// Build progress report with ghost UUID
|
||||
var progressReport = new
|
||||
{
|
||||
ItemId = ghostUuid,
|
||||
PositionTicks = positionTicks ?? 0,
|
||||
IsPaused = false,
|
||||
IsMuted = false,
|
||||
CanSeek = true,
|
||||
PlayMethod = "DirectPlay"
|
||||
};
|
||||
|
||||
var progressJson = JsonSerializer.Serialize(progressReport);
|
||||
|
||||
// Forward to Jellyfin with ghost UUID
|
||||
var (progressResult, progressStatusCode) = await _proxyService.PostJsonAsync("Sessions/Playing/Progress", progressJson, Request.Headers);
|
||||
|
||||
// Log progress occasionally for debugging (every ~30 seconds)
|
||||
if (positionTicks.HasValue)
|
||||
{
|
||||
var position = TimeSpan.FromTicks(positionTicks.Value);
|
||||
if (position.Seconds % 30 == 0 && position.Milliseconds < 500)
|
||||
{
|
||||
var position = TimeSpan.FromTicks(positionTicks.Value);
|
||||
if (position.Seconds % 30 == 0 && position.Milliseconds < 500)
|
||||
{
|
||||
_logger.LogDebug("▶️ External track progress: {Position:mm\\:ss} ({Provider}/{ExternalId})",
|
||||
position, provider, externalId);
|
||||
}
|
||||
_logger.LogDebug("▶️ External track progress: {Position:mm\\:ss} ({Provider}/{ExternalId}) - Status: {StatusCode}",
|
||||
position, provider, externalId, progressStatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
// Just acknowledge (no reporting to Jellyfin for external tracks)
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -2428,12 +2521,14 @@ public class JellyfinController : ControllerBase
|
||||
// Only log at 10-second intervals
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For local tracks, forward to Jellyfin
|
||||
_logger.LogDebug("📤 Sending playback progress body: {Body}", body);
|
||||
|
||||
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing/Progress", body, Request.Headers);
|
||||
|
||||
if (statusCode != 204 && statusCode != 200)
|
||||
@@ -2466,7 +2561,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
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
|
||||
var doc = JsonDocument.Parse(body);
|
||||
@@ -2508,11 +2603,23 @@ public class JellyfinController : ControllerBase
|
||||
_logger.LogInformation("🎵 External track playback stopped: {Name} at {Position} ({Provider}/{ExternalId})",
|
||||
itemName ?? "Unknown", position, provider, externalId);
|
||||
|
||||
// Mark session as potentially ended after playback stops
|
||||
// Wait 50 seconds for next song to start before cleaning up
|
||||
if (!string.IsNullOrEmpty(deviceId))
|
||||
// Report stop to Jellyfin with ghost UUID
|
||||
var ghostUuid = GenerateUuidFromString(itemId);
|
||||
|
||||
var stopInfo = new
|
||||
{
|
||||
_sessionManager.MarkSessionPotentiallyEnded(deviceId, TimeSpan.FromSeconds(50));
|
||||
ItemId = ghostUuid,
|
||||
PositionTicks = positionTicks ?? 0
|
||||
};
|
||||
|
||||
var stopJson = JsonSerializer.Serialize(stopInfo);
|
||||
_logger.LogDebug("📤 Sending ghost playback stop for external track: {Json}", stopJson);
|
||||
|
||||
var (stopResult, stopStatusCode) = await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", stopJson, Request.Headers);
|
||||
|
||||
if (stopStatusCode == 204 || stopStatusCode == 200)
|
||||
{
|
||||
_logger.LogDebug("✓ Ghost playback stop forwarded to Jellyfin ({StatusCode})", stopStatusCode);
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
@@ -2523,12 +2630,34 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
|
||||
// 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
|
||||
_logger.LogInformation("📤 Sending playback stop body: {Body}", body);
|
||||
|
||||
// Validate that body is not empty
|
||||
if (string.IsNullOrWhiteSpace(body) || body == "{}")
|
||||
{
|
||||
_logger.LogWarning("⚠️ Playback stop body is empty, building minimal valid payload");
|
||||
// Build a minimal valid PlaybackStopInfo
|
||||
var stopInfo = new
|
||||
{
|
||||
ItemId = itemId,
|
||||
PositionTicks = positionTicks ?? 0
|
||||
};
|
||||
body = JsonSerializer.Serialize(stopInfo);
|
||||
_logger.LogInformation("📤 Built playback stop body: {Body}", body);
|
||||
}
|
||||
|
||||
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", body, Request.Headers);
|
||||
|
||||
if (statusCode == 204 || statusCode == 200)
|
||||
{
|
||||
_logger.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
|
||||
{
|
||||
@@ -2587,7 +2716,7 @@ public class JellyfinController : ControllerBase
|
||||
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : "";
|
||||
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}",
|
||||
string.Join(", ", Request.Headers.Where(h => h.Key.Contains("Auth", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(h => $"{h.Key}={h.Value}")));
|
||||
@@ -2617,11 +2746,11 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
_logger.LogInformation("✓ Session request proxied ({StatusCode}, no body)", statusCode);
|
||||
_logger.LogDebug("✓ Session request proxied ({StatusCode}, no body)", statusCode);
|
||||
return StatusCode(statusCode);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -2679,7 +2808,7 @@ public class JellyfinController : ControllerBase
|
||||
if (path.Contains("session", 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
|
||||
{
|
||||
@@ -3693,8 +3822,7 @@ public class JellyfinController : ControllerBase
|
||||
{
|
||||
Song = song,
|
||||
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, song.Title),
|
||||
// Calculate artist score by checking ALL artists match
|
||||
ArtistScore = CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors)
|
||||
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors)
|
||||
})
|
||||
.Select(x => new
|
||||
{
|
||||
@@ -3787,8 +3915,8 @@ public class JellyfinController : ControllerBase
|
||||
return;
|
||||
}
|
||||
|
||||
// Build kept folder path: /app/kept/Artist/Album/
|
||||
var keptBasePath = "/app/kept";
|
||||
// Build kept folder path: Artist/Album/
|
||||
var keptBasePath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
|
||||
var keptArtistPath = Path.Combine(keptBasePath, PathHelper.SanitizeFileName(song.Artist));
|
||||
var keptAlbumPath = Path.Combine(keptArtistPath, PathHelper.SanitizeFileName(song.Album));
|
||||
|
||||
@@ -4084,7 +4212,7 @@ public class JellyfinController : ControllerBase
|
||||
var song = await _metadataService.GetSongAsync(provider!, externalId!);
|
||||
if (song == null) return;
|
||||
|
||||
var keptBasePath = "/app/kept";
|
||||
var keptBasePath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
|
||||
var keptArtistPath = Path.Combine(keptBasePath, PathHelper.SanitizeFileName(song.Artist));
|
||||
var keptAlbumPath = Path.Combine(keptArtistPath, PathHelper.SanitizeFileName(song.Album));
|
||||
|
||||
@@ -4385,6 +4513,24 @@ public class JellyfinController : ControllerBase
|
||||
return (deviceId, client, device, version);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a deterministic UUID (v5) from a string.
|
||||
/// This allows us to create consistent UUIDs for external track IDs.
|
||||
/// </summary>
|
||||
private string GenerateUuidFromString(string input)
|
||||
{
|
||||
// Use MD5 hash to generate a deterministic UUID
|
||||
using var md5 = System.Security.Cryptography.MD5.Create();
|
||||
var hash = md5.ComputeHash(System.Text.Encoding.UTF8.GetBytes(input));
|
||||
|
||||
// Convert to UUID format (version 5, namespace-based)
|
||||
hash[6] = (byte)((hash[6] & 0x0F) | 0x50); // Version 5
|
||||
hash[8] = (byte)((hash[8] & 0x3F) | 0x80); // Variant
|
||||
|
||||
var guid = new Guid(hash);
|
||||
return guid.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the Spotify ID for an external track by searching through all playlist matched tracks caches.
|
||||
/// This allows us to get Spotify lyrics for external tracks that were matched from Spotify playlists.
|
||||
@@ -4429,122 +4575,6 @@ public class JellyfinController : ControllerBase
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an external track URL (Tidal/Deezer/Qobuz) to a Spotify track ID using Odesli/song.link API.
|
||||
/// This enables Spotify lyrics for external tracks that aren't in injected playlists.
|
||||
/// </summary>
|
||||
private async Task<string?> ConvertToSpotifyIdViaOdesliAsync(Song song, string provider, string externalId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Build the source URL based on provider
|
||||
string? sourceUrl = null;
|
||||
|
||||
switch (provider.ToLowerInvariant())
|
||||
{
|
||||
case "squidwtf":
|
||||
// SquidWTF uses Tidal IDs
|
||||
sourceUrl = $"https://tidal.com/browse/track/{externalId}";
|
||||
break;
|
||||
|
||||
case "deezer":
|
||||
sourceUrl = $"https://www.deezer.com/track/{externalId}";
|
||||
break;
|
||||
|
||||
case "qobuz":
|
||||
sourceUrl = $"https://www.qobuz.com/us-en/album/-/-/{externalId}";
|
||||
break;
|
||||
|
||||
default:
|
||||
_logger.LogDebug("Provider {Provider} not supported for Odesli conversion", provider);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check cache first (cache for 30 days since these mappings don't change)
|
||||
var cacheKey = $"odesli:{provider}:{externalId}";
|
||||
var cachedSpotifyId = await _cache.GetStringAsync(cacheKey);
|
||||
if (!string.IsNullOrEmpty(cachedSpotifyId))
|
||||
{
|
||||
_logger.LogDebug("Returning cached Odesli conversion: {Provider}/{ExternalId} → {SpotifyId}",
|
||||
provider, externalId, cachedSpotifyId);
|
||||
return cachedSpotifyId;
|
||||
}
|
||||
|
||||
// RATE LIMITING: Odesli allows 10 requests per minute
|
||||
// Use a simple semaphore-based rate limiter
|
||||
await OdesliRateLimiter.WaitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Call Odesli API
|
||||
var odesliUrl = $"https://api.song.link/v1-alpha.1/links?url={Uri.EscapeDataString(sourceUrl)}&userCountry=US";
|
||||
|
||||
_logger.LogDebug("Calling Odesli API: {Url}", odesliUrl);
|
||||
|
||||
using var httpClient = new HttpClient();
|
||||
httpClient.Timeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
var response = await httpClient.GetAsync(odesliUrl);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogDebug("Odesli API returned {StatusCode} for {Provider}/{ExternalId}",
|
||||
response.StatusCode, provider, externalId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// Extract Spotify URL from linksByPlatform.spotify.url
|
||||
if (root.TryGetProperty("linksByPlatform", out var platforms) &&
|
||||
platforms.TryGetProperty("spotify", out var spotify) &&
|
||||
spotify.TryGetProperty("url", out var spotifyUrlEl))
|
||||
{
|
||||
var spotifyUrl = spotifyUrlEl.GetString();
|
||||
if (!string.IsNullOrEmpty(spotifyUrl))
|
||||
{
|
||||
// Extract Spotify ID from URL: https://open.spotify.com/track/{id}
|
||||
var match = System.Text.RegularExpressions.Regex.Match(spotifyUrl, @"spotify\.com/track/([a-zA-Z0-9]+)");
|
||||
if (match.Success)
|
||||
{
|
||||
var spotifyId = match.Groups[1].Value;
|
||||
|
||||
// Cache the result (30 days)
|
||||
await _cache.SetStringAsync(cacheKey, spotifyId, TimeSpan.FromDays(30));
|
||||
|
||||
_logger.LogInformation("✓ Odesli converted {Provider}/{ExternalId} → Spotify ID {SpotifyId}",
|
||||
provider, externalId, spotifyId);
|
||||
|
||||
return spotifyId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("No Spotify link found in Odesli response for {Provider}/{ExternalId}", provider, externalId);
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Release rate limiter after 6 seconds (10 requests per 60 seconds = 1 request per 6 seconds)
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(6));
|
||||
OdesliRateLimiter.Release();
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error converting {Provider}/{ExternalId} via Odesli", provider, externalId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Static rate limiter for Odesli API (10 requests per minute = 1 request per 6 seconds)
|
||||
private static readonly SemaphoreSlim OdesliRateLimiter = new SemaphoreSlim(10, 10);
|
||||
}
|
||||
|
||||
// force rebuild Sun Jan 25 13:22:47 EST 2026
|
||||
|
||||
@@ -2,239 +2,44 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Models.Settings;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace allstarr.Filters;
|
||||
|
||||
/// <summary>
|
||||
/// Authentication filter for Jellyfin API endpoints.
|
||||
/// Validates client credentials against configured username and API key.
|
||||
/// Clients can authenticate via:
|
||||
/// - Authorization header: MediaBrowser Token="apikey"
|
||||
/// - X-Emby-Token header
|
||||
/// - Query parameter: api_key
|
||||
/// - JSON body (for login endpoints): Username/Pw fields
|
||||
/// REMOVED: Authentication filter for Jellyfin API endpoints.
|
||||
///
|
||||
/// This filter has been removed because Allstarr acts as a TRANSPARENT PROXY.
|
||||
/// Clients authenticate directly with Jellyfin through the proxy, not with the proxy itself.
|
||||
///
|
||||
/// Authentication flow:
|
||||
/// 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>
|
||||
public partial class JellyfinAuthFilter : IAsyncActionFilter
|
||||
public class JellyfinAuthFilter : IAsyncActionFilter
|
||||
{
|
||||
private readonly JellyfinSettings _settings;
|
||||
private readonly ILogger<JellyfinAuthFilter> _logger;
|
||||
|
||||
public JellyfinAuthFilter(
|
||||
IOptions<JellyfinSettings> settings,
|
||||
ILogger<JellyfinAuthFilter> logger)
|
||||
public JellyfinAuthFilter(ILogger<JellyfinAuthFilter> logger)
|
||||
{
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||
{
|
||||
// Skip auth if no credentials configured (open mode)
|
||||
if (string.IsNullOrEmpty(_settings.ClientUsername) || string.IsNullOrEmpty(_settings.ApiKey))
|
||||
{
|
||||
_logger.LogDebug("Auth skipped - no client credentials configured");
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
// This filter is now a no-op - all authentication is handled by Jellyfin
|
||||
// Keeping the class for backwards compatibility but it does nothing
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ public class WebSocketProxyMiddleware
|
||||
if (context.Request.Path.StartsWithSegments("/socket", StringComparison.OrdinalIgnoreCase) &&
|
||||
context.WebSockets.IsWebSocketRequest)
|
||||
{
|
||||
_logger.LogInformation("🔌 WEBSOCKET: WebSocket connection request received from {RemoteIp}",
|
||||
_logger.LogDebug("🔌 WEBSOCKET: WebSocket connection request received from {RemoteIp}",
|
||||
context.Connection.RemoteIpAddress);
|
||||
|
||||
await HandleWebSocketProxyAsync(context);
|
||||
@@ -142,7 +142,7 @@ public class WebSocketProxyMiddleware
|
||||
serverWebSocket.Options.SetRequestHeader("User-Agent", "Allstarr/1.0");
|
||||
|
||||
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
|
||||
var clientToServer = ProxyMessagesAsync(clientWebSocket, serverWebSocket, "Client→Server", context.RequestAborted);
|
||||
@@ -155,7 +155,15 @@ public class WebSocketProxyMiddleware
|
||||
}
|
||||
catch (WebSocketException wsEx)
|
||||
{
|
||||
_logger.LogWarning(wsEx, "⚠️ WEBSOCKET: WebSocket error: {Message}", wsEx.Message);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -194,7 +202,7 @@ public class WebSocketProxyMiddleware
|
||||
// CRITICAL: Notify session manager that client disconnected
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -239,7 +247,7 @@ public class WebSocketProxyMiddleware
|
||||
if (direction == "Server→Client")
|
||||
{
|
||||
var messageText = System.Text.Encoding.UTF8.GetString(messageBytes);
|
||||
_logger.LogInformation("📥 WEBSOCKET {Direction}: {Preview}",
|
||||
_logger.LogTrace("📥 WEBSOCKET {Direction}: {Preview}",
|
||||
direction,
|
||||
messageText.Length > 500 ? messageText[..500] + "..." : messageText);
|
||||
}
|
||||
@@ -274,7 +282,7 @@ public class WebSocketProxyMiddleware
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "⚠️ WEBSOCKET {Direction}: Error proxying messages", direction);
|
||||
_logger.LogDebug(ex, "WEBSOCKET {Direction}: Error proxying messages (connection closed)", direction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,11 @@ public class Song
|
||||
/// </summary>
|
||||
public string? Isrc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Spotify track ID (for lyrics and matching)
|
||||
/// </summary>
|
||||
public string? SpotifyId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Full release date (format: YYYY-MM-DD)
|
||||
/// </summary>
|
||||
|
||||
@@ -59,27 +59,6 @@ public class SpotifyImportSettings
|
||||
/// </summary>
|
||||
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>
|
||||
/// How often to run track matching in hours.
|
||||
/// Spotify playlists like Discover Weekly update once per week, Release Radar updates weekly.
|
||||
|
||||
@@ -22,16 +22,21 @@ static List<string> DecodeSquidWtfUrls()
|
||||
{
|
||||
var encodedUrls = new[]
|
||||
{
|
||||
"aHR0cHM6Ly90cml0b24uc3F1aWQud3Rm", // triton
|
||||
"aHR0cHM6Ly90aWRhbC1hcGkuYmluaW11bS5vcmc=", // binimum
|
||||
"aHR0cHM6Ly90aWRhbC5raW5vcGx1cy5vbmxpbmU=", // kinoplus
|
||||
"aHR0cHM6Ly9oaWZpLXR3by5zcG90aXNhdmVyLm5ldA==", // spoti-2
|
||||
"aHR0cHM6Ly9oaWZpLW9uZS5zcG90aXNhdmVyLm5ldA==", // spoti-1
|
||||
"aHR0cHM6Ly93b2xmLnFxZGwuc2l0ZQ==", // wolf
|
||||
"aHR0cDovL2h1bmQucXFkbC5zaXRl", // hund
|
||||
"aHR0cHM6Ly9rYXR6ZS5xcWRsLnNpdGU=", // katze
|
||||
"aHR0cHM6Ly92b2dlbC5xcWRsLnNpdGU=", // vogel
|
||||
"aHR0cHM6Ly9tYXVzLnFxZGwuc2l0ZQ==" // maus
|
||||
"aHR0cHM6Ly90cml0b24uc3F1aWQud3Rm", // triton.squid.wtf
|
||||
"aHR0cHM6Ly90aWRhbC1hcGkuYmluaW11bS5vcmc=", // tidal-api.binimum.org
|
||||
"aHR0cHM6Ly90aWRhbC5raW5vcGx1cy5vbmxpbmU=", // tidal.kinoplus.online
|
||||
"aHR0cHM6Ly9oaWZpLXR3by5zcG90aXNhdmVyLm5ldA==", // hifi-two.spotisaver.net
|
||||
"aHR0cHM6Ly9oaWZpLW9uZS5zcG90aXNhdmVyLm5ldA==", // hifi-one.spotisaver.net
|
||||
"aHR0cHM6Ly93b2xmLnFxZGwuc2l0ZQ==", // wolf.qqdl.site
|
||||
"aHR0cDovL2h1bmQucXFkbC5zaXRl", // hund.qqdl.site (http)
|
||||
"aHR0cHM6Ly9rYXR6ZS5xcWRsLnNpdGU=", // katze.qqdl.site
|
||||
"aHR0cHM6Ly92b2dlbC5xcWRsLnNpdGU=", // vogel.qqdl.site
|
||||
"aHR0cHM6Ly9tYXVzLnFxZGwuc2l0ZQ==", // maus.qqdl.site
|
||||
"aHR0cHM6Ly9ldS1jZW50cmFsLm1vbm9jaHJvbWUudGY=", // eu-central.monochrome.tf
|
||||
"aHR0cHM6Ly91cy13ZXN0Lm1vbm9jaHJvbWUudGY=", // us-west.monochrome.tf
|
||||
"aHR0cHM6Ly9hcnJhbi5tb25vY2hyb21lLnRm", // arran.monochrome.tf
|
||||
"aHR0cHM6Ly9hcGkubW9ub2Nocm9tZS50Zg==", // api.monochrome.tf
|
||||
"aHR0cHM6Ly9odW5kLnFxZGwuc2l0ZQ==" // hund.qqdl.site (https)
|
||||
};
|
||||
|
||||
return encodedUrls
|
||||
@@ -353,7 +358,7 @@ builder.Services.Configure<SpotifyImportSettings>(options =>
|
||||
}
|
||||
|
||||
// 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");
|
||||
foreach (var playlist in options.Playlists)
|
||||
{
|
||||
@@ -379,6 +384,7 @@ else
|
||||
|
||||
// Business services - shared across backends
|
||||
builder.Services.AddSingleton<RedisCacheService>();
|
||||
builder.Services.AddSingleton<OdesliService>();
|
||||
builder.Services.AddSingleton<ILocalLibraryService, LocalLibraryService>();
|
||||
builder.Services.AddSingleton<LrclibService>();
|
||||
|
||||
@@ -459,6 +465,7 @@ else if (musicService == MusicService.SquidWTF)
|
||||
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
|
||||
sp,
|
||||
sp.GetRequiredService<ILogger<SquidWTFDownloadService>>(),
|
||||
sp.GetRequiredService<OdesliService>(),
|
||||
squidWtfApiUrls));
|
||||
}
|
||||
|
||||
@@ -475,13 +482,18 @@ else
|
||||
builder.Services.AddSingleton<IStartupValidator, SubsonicStartupValidator>();
|
||||
}
|
||||
|
||||
// Register endpoint benchmark service
|
||||
builder.Services.AddSingleton<EndpointBenchmarkService>();
|
||||
|
||||
builder.Services.AddSingleton<IStartupValidator, DeezerStartupValidator>();
|
||||
builder.Services.AddSingleton<IStartupValidator, QobuzStartupValidator>();
|
||||
builder.Services.AddSingleton<IStartupValidator>(sp =>
|
||||
new SquidWTFStartupValidator(
|
||||
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
|
||||
sp.GetRequiredService<IHttpClientFactory>().CreateClient(),
|
||||
squidWtfApiUrls));
|
||||
squidWtfApiUrls,
|
||||
sp.GetRequiredService<EndpointBenchmarkService>(),
|
||||
sp.GetRequiredService<ILogger<SquidWTFStartupValidator>>()));
|
||||
builder.Services.AddSingleton<IStartupValidator, LyricsStartupValidator>();
|
||||
|
||||
// Register orchestrator as hosted service
|
||||
@@ -568,8 +580,9 @@ builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyTrackMatchingServ
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<allstarr.Services.Spotify.SpotifyTrackMatchingService>());
|
||||
|
||||
// Register lyrics prefetch service (prefetches lyrics for all playlist tracks)
|
||||
builder.Services.AddSingleton<allstarr.Services.Lyrics.LyricsPrefetchService>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<allstarr.Services.Lyrics.LyricsPrefetchService>());
|
||||
// DISABLED - No need to prefetch since Jellyfin and Spotify lyrics are fast
|
||||
// builder.Services.AddSingleton<allstarr.Services.Lyrics.LyricsPrefetchService>();
|
||||
// builder.Services.AddHostedService(sp => sp.GetRequiredService<allstarr.Services.Lyrics.LyricsPrefetchService>());
|
||||
|
||||
// Register MusicBrainz service for metadata enrichment
|
||||
builder.Services.Configure<allstarr.Models.Settings.MusicBrainzSettings>(options =>
|
||||
|
||||
@@ -31,6 +31,11 @@ public abstract class BaseDownloadService : IDownloadService
|
||||
protected readonly ConcurrentDictionary<string, DownloadInfo> ActiveDownloads = new();
|
||||
protected readonly SemaphoreSlim DownloadLock = new(1, 1);
|
||||
|
||||
// Rate limiting fields
|
||||
private readonly SemaphoreSlim _requestLock = new(1, 1);
|
||||
private DateTime _lastRequestTime = DateTime.MinValue;
|
||||
private readonly int _minRequestIntervalMs = 200;
|
||||
|
||||
/// <summary>
|
||||
/// Lazy-loaded PlaylistSyncService to avoid circular dependency
|
||||
/// </summary>
|
||||
@@ -90,22 +95,88 @@ public abstract class BaseDownloadService : IDownloadService
|
||||
|
||||
public async Task<Stream> DownloadAndStreamAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startTime = DateTime.UtcNow;
|
||||
|
||||
// Check if already downloaded locally
|
||||
var localPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId);
|
||||
if (localPath != null && IOFile.Exists(localPath))
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
// 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:
|
||||
// 1. Clients may seek to arbitrary positions (requires full file)
|
||||
// 2. Metadata embedding requires complete file
|
||||
// 3. Caching for future plays
|
||||
Logger.LogInformation("Downloading song for streaming: {Provider}:{ExternalId}", externalProvider, externalId);
|
||||
localPath = await DownloadSongInternalAsync(externalProvider, externalId, triggerAlbumDownload: true, cancellationToken);
|
||||
return IOFile.OpenRead(localPath);
|
||||
|
||||
try
|
||||
{
|
||||
localPath = await DownloadSongInternalAsync(externalProvider, externalId, triggerAlbumDownload: true, cancellationToken);
|
||||
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
|
||||
Logger.LogInformation("Download completed, starting stream ({ElapsedMs}ms total): {Path}", elapsed, localPath);
|
||||
|
||||
// Start background Odesli conversion for lyrics (after stream starts)
|
||||
StartBackgroundOdesliConversion(externalProvider, externalId);
|
||||
|
||||
return IOFile.OpenRead(localPath);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
|
||||
Logger.LogWarning("Download cancelled by client after {ElapsedMs}ms for {Provider}:{ExternalId}", elapsed, externalProvider, externalId);
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
|
||||
Logger.LogError(ex, "Download failed after {ElapsedMs}ms for {Provider}:{ExternalId}", elapsed, externalProvider, externalId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts background Odesli conversion for lyrics support.
|
||||
/// 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)
|
||||
@@ -121,20 +192,13 @@ public abstract class BaseDownloadService : IDownloadService
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check local library
|
||||
// Check local library (works for both cache and permanent storage)
|
||||
var localPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId);
|
||||
if (localPath != null && IOFile.Exists(localPath))
|
||||
{
|
||||
return localPath;
|
||||
}
|
||||
|
||||
// Check cache directory
|
||||
var cachedPath = GetCachedFilePath(externalProvider, externalId);
|
||||
if (cachedPath != null && IOFile.Exists(cachedPath))
|
||||
{
|
||||
return cachedPath;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -203,47 +267,44 @@ public abstract class BaseDownloadService : IDownloadService
|
||||
|
||||
try
|
||||
{
|
||||
// Check if already downloaded (skip for cache mode as we want to check cache folder)
|
||||
if (!isCache)
|
||||
// Check if already downloaded (works for both cache and permanent modes)
|
||||
var existingPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId);
|
||||
if (existingPath != null && IOFile.Exists(existingPath))
|
||||
{
|
||||
var existingPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId);
|
||||
if (existingPath != null && IOFile.Exists(existingPath))
|
||||
Logger.LogInformation("Song already downloaded: {Path}", existingPath);
|
||||
|
||||
// For cache mode, update file access time for cache cleanup logic
|
||||
if (isCache)
|
||||
{
|
||||
Logger.LogInformation("Song already downloaded: {Path}", existingPath);
|
||||
return existingPath;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// For cache mode, check if file exists in cache directory
|
||||
var cachedPath = GetCachedFilePath(externalProvider, externalId);
|
||||
if (cachedPath != null && IOFile.Exists(cachedPath))
|
||||
{
|
||||
Logger.LogInformation("Song found in cache: {Path}", cachedPath);
|
||||
// Update file access time for cache cleanup logic
|
||||
IOFile.SetLastAccessTime(cachedPath, DateTime.UtcNow);
|
||||
return cachedPath;
|
||||
IOFile.SetLastAccessTime(existingPath, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
return existingPath;
|
||||
}
|
||||
|
||||
// Check if download in progress
|
||||
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
|
||||
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)
|
||||
{
|
||||
await Task.Delay(500, cancellationToken);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await Task.Delay(100, cancellationToken);
|
||||
}
|
||||
|
||||
if (activeDownload?.Status == DownloadStatus.Completed && activeDownload.LocalPath != null)
|
||||
{
|
||||
Logger.LogDebug("Download completed while waiting, returning path: {Path}", 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
|
||||
@@ -577,29 +638,34 @@ public abstract class BaseDownloadService : IDownloadService
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#endregion
|
||||
|
||||
#region Rate Limiting
|
||||
|
||||
/// <summary>
|
||||
/// Gets the cached file path for a given provider and external ID
|
||||
/// Returns null if no cached file exists
|
||||
/// Queues a request with rate limiting to prevent overwhelming the API.
|
||||
/// Ensures minimum interval between requests.
|
||||
/// </summary>
|
||||
protected string? GetCachedFilePath(string provider, string externalId)
|
||||
protected async Task<T> QueueRequestAsync<T>(Func<Task<T>> action)
|
||||
{
|
||||
await _requestLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
// Search for cached files matching the pattern: {provider}_{externalId}.*
|
||||
var pattern = $"{provider}_{externalId}.*";
|
||||
var files = Directory.GetFiles(CachePath, pattern, SearchOption.AllDirectories);
|
||||
var now = DateTime.UtcNow;
|
||||
var timeSinceLastRequest = (now - _lastRequestTime).TotalMilliseconds;
|
||||
|
||||
if (files.Length > 0)
|
||||
if (timeSinceLastRequest < _minRequestIntervalMs)
|
||||
{
|
||||
return files[0]; // Return first match
|
||||
await Task.Delay((int)(_minRequestIntervalMs - timeSinceLastRequest));
|
||||
}
|
||||
|
||||
return null;
|
||||
_lastRequestTime = DateTime.UtcNow;
|
||||
return await action();
|
||||
}
|
||||
catch (Exception ex)
|
||||
finally
|
||||
{
|
||||
Logger.LogWarning(ex, "Failed to search for cached file: {Provider}_{ExternalId}", provider, externalId);
|
||||
return null;
|
||||
_requestLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -94,16 +94,16 @@ public class CacheCleanupService : BackgroundService
|
||||
{
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
|
||||
// Use last access time to determine if file should be deleted
|
||||
// This gets updated when a cached file is streamed
|
||||
if (fileInfo.LastAccessTimeUtc < cutoffTime)
|
||||
// Use last write time (when file was created/downloaded) to determine if file should be deleted
|
||||
// LastAccessTime is unreliable on many filesystems (noatime mount option)
|
||||
if (fileInfo.LastWriteTimeUtc < cutoffTime)
|
||||
{
|
||||
var size = fileInfo.Length;
|
||||
File.Delete(filePath);
|
||||
deletedCount++;
|
||||
totalSize += size;
|
||||
_logger.LogDebug("Deleted cached file: {Path} (last accessed: {LastAccess})",
|
||||
filePath, fileInfo.LastAccessTimeUtc);
|
||||
_logger.LogDebug("Deleted cached file: {Path} (age: {Age:F1} hours)",
|
||||
filePath, (DateTime.UtcNow - fileInfo.LastWriteTimeUtc).TotalHours);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
135
allstarr/Services/Common/EndpointBenchmarkService.cs
Normal file
135
allstarr/Services/Common/EndpointBenchmarkService.cs
Normal file
@@ -0,0 +1,135 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace allstarr.Services.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Benchmarks API endpoints on startup and maintains performance metrics.
|
||||
/// Used to prioritize faster endpoints in racing scenarios.
|
||||
/// </summary>
|
||||
public class EndpointBenchmarkService
|
||||
{
|
||||
private readonly ILogger<EndpointBenchmarkService> _logger;
|
||||
private readonly Dictionary<string, EndpointMetrics> _metrics = new();
|
||||
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||
|
||||
public EndpointBenchmarkService(ILogger<EndpointBenchmarkService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Benchmarks a list of endpoints by making test requests.
|
||||
/// Returns endpoints sorted by average response time (fastest first).
|
||||
/// </summary>
|
||||
public async Task<List<string>> BenchmarkEndpointsAsync(
|
||||
List<string> endpoints,
|
||||
Func<string, CancellationToken, Task<bool>> testFunc,
|
||||
int pingCount = 3,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogInformation("🏁 Benchmarking {Count} endpoints with {Pings} pings each...", endpoints.Count, pingCount);
|
||||
|
||||
var tasks = endpoints.Select(async endpoint =>
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var successCount = 0;
|
||||
var totalMs = 0L;
|
||||
|
||||
for (int i = 0; i < pingCount; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pingStart = Stopwatch.GetTimestamp();
|
||||
var success = await testFunc(endpoint, cancellationToken);
|
||||
var pingMs = Stopwatch.GetElapsedTime(pingStart).TotalMilliseconds;
|
||||
|
||||
if (success)
|
||||
{
|
||||
successCount++;
|
||||
totalMs += (long)pingMs;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Benchmark ping failed for {Endpoint}", endpoint);
|
||||
}
|
||||
|
||||
// Small delay between pings
|
||||
if (i < pingCount - 1)
|
||||
{
|
||||
await Task.Delay(100, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
|
||||
var avgMs = successCount > 0 ? totalMs / successCount : long.MaxValue;
|
||||
var metrics = new EndpointMetrics
|
||||
{
|
||||
Endpoint = endpoint,
|
||||
AverageResponseMs = avgMs,
|
||||
SuccessRate = (double)successCount / pingCount,
|
||||
LastBenchmark = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _lock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
_metrics[endpoint] = metrics;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.Release();
|
||||
}
|
||||
|
||||
_logger.LogInformation(" {Endpoint}: {AvgMs}ms avg, {SuccessRate:P0} success rate",
|
||||
endpoint, avgMs, metrics.SuccessRate);
|
||||
|
||||
return metrics;
|
||||
}).ToList();
|
||||
|
||||
var results = await Task.WhenAll(tasks);
|
||||
|
||||
// Sort by: success rate first (must be > 0), then by average response time
|
||||
var sorted = results
|
||||
.Where(m => m.SuccessRate > 0)
|
||||
.OrderByDescending(m => m.SuccessRate)
|
||||
.ThenBy(m => m.AverageResponseMs)
|
||||
.Select(m => m.Endpoint)
|
||||
.ToList();
|
||||
|
||||
_logger.LogInformation("✅ Benchmark complete. Fastest: {Fastest} ({Ms}ms)",
|
||||
sorted.FirstOrDefault() ?? "none",
|
||||
results.Where(m => m.SuccessRate > 0).MinBy(m => m.AverageResponseMs)?.AverageResponseMs ?? 0);
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the metrics for a specific endpoint.
|
||||
/// </summary>
|
||||
public EndpointMetrics? GetMetrics(string endpoint)
|
||||
{
|
||||
_metrics.TryGetValue(endpoint, out var metrics);
|
||||
return metrics;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all endpoint metrics sorted by performance.
|
||||
/// </summary>
|
||||
public List<EndpointMetrics> GetAllMetrics()
|
||||
{
|
||||
return _metrics.Values
|
||||
.OrderByDescending(m => m.SuccessRate)
|
||||
.ThenBy(m => m.AverageResponseMs)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public class EndpointMetrics
|
||||
{
|
||||
public string Endpoint { get; set; } = string.Empty;
|
||||
public long AverageResponseMs { get; set; }
|
||||
public double SuccessRate { get; set; }
|
||||
public DateTime LastBenchmark { get; set; }
|
||||
}
|
||||
@@ -221,4 +221,54 @@ public static class FuzzyMatcher
|
||||
|
||||
return distance[sourceLength, targetLength];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates artist match score between Spotify artists and local song artists.
|
||||
/// Checks bidirectional matching and penalizes mismatches.
|
||||
/// Penalizes if artist counts don't match or if any artist is missing.
|
||||
/// Returns score 0-100.
|
||||
/// </summary>
|
||||
public static double CalculateArtistMatchScore(List<string> spotifyArtists, string songMainArtist, List<string> songContributors)
|
||||
{
|
||||
if (spotifyArtists.Count == 0 || string.IsNullOrEmpty(songMainArtist))
|
||||
return 0;
|
||||
|
||||
// Build list of all song artists (main + contributors)
|
||||
var allSongArtists = new List<string> { songMainArtist };
|
||||
allSongArtists.AddRange(songContributors);
|
||||
|
||||
// If artist counts differ significantly, penalize
|
||||
var countDiff = Math.Abs(spotifyArtists.Count - allSongArtists.Count);
|
||||
if (countDiff > 1) // Allow 1 artist difference (sometimes features are listed differently)
|
||||
return 0;
|
||||
|
||||
// Check that each Spotify artist has a good match in song artists
|
||||
var spotifyScores = new List<double>();
|
||||
foreach (var spotifyArtist in spotifyArtists)
|
||||
{
|
||||
var bestMatch = allSongArtists.Max(songArtist =>
|
||||
CalculateSimilarity(spotifyArtist, songArtist));
|
||||
spotifyScores.Add(bestMatch);
|
||||
}
|
||||
|
||||
// Check that each song artist has a good match in Spotify artists
|
||||
var songScores = new List<double>();
|
||||
foreach (var songArtist in allSongArtists)
|
||||
{
|
||||
var bestMatch = spotifyArtists.Max(spotifyArtist =>
|
||||
CalculateSimilarity(songArtist, spotifyArtist));
|
||||
songScores.Add(bestMatch);
|
||||
}
|
||||
|
||||
// Average all scores - this ensures ALL artists must match well
|
||||
var allScores = spotifyScores.Concat(songScores);
|
||||
var avgScore = allScores.Average();
|
||||
|
||||
// Penalize if any individual artist match is poor (< 70)
|
||||
var minScore = allScores.Min();
|
||||
if (minScore < 70)
|
||||
avgScore *= 0.7; // 30% penalty for poor individual match
|
||||
|
||||
return avgScore;
|
||||
}
|
||||
}
|
||||
|
||||
143
allstarr/Services/Common/OdesliService.cs
Normal file
143
allstarr/Services/Common/OdesliService.cs
Normal file
@@ -0,0 +1,143 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace allstarr.Services.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Service for converting music URLs between platforms using Odesli/song.link API
|
||||
/// </summary>
|
||||
public class OdesliService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<OdesliService> _logger;
|
||||
private readonly RedisCacheService _cache;
|
||||
|
||||
public OdesliService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILogger<OdesliService> logger,
|
||||
RedisCacheService cache)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_logger = logger;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a Tidal track ID to a Spotify track ID using Odesli
|
||||
/// Results are cached for 7 days
|
||||
/// </summary>
|
||||
public async Task<string?> ConvertTidalToSpotifyIdAsync(string tidalTrackId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Check cache first (7 day TTL - these mappings don't change)
|
||||
var cacheKey = $"odesli:tidal-to-spotify:{tidalTrackId}";
|
||||
var cached = await _cache.GetAsync<string>(cacheKey);
|
||||
if (!string.IsNullOrEmpty(cached))
|
||||
{
|
||||
_logger.LogDebug("✓ Using cached Spotify ID for Tidal track {TidalId}", tidalTrackId);
|
||||
return cached;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var tidalUrl = $"https://tidal.com/browse/track/{tidalTrackId}";
|
||||
var odesliUrl = $"https://api.song.link/v1-alpha.1/links?url={Uri.EscapeDataString(tidalUrl)}&userCountry=US";
|
||||
|
||||
_logger.LogDebug("🔗 Converting Tidal track {TidalId} to Spotify ID via Odesli", tidalTrackId);
|
||||
|
||||
var odesliResponse = await _httpClient.GetAsync(odesliUrl, cancellationToken);
|
||||
if (odesliResponse.IsSuccessStatusCode)
|
||||
{
|
||||
var odesliJson = await odesliResponse.Content.ReadAsStringAsync(cancellationToken);
|
||||
var odesliDoc = JsonDocument.Parse(odesliJson);
|
||||
|
||||
// Extract Spotify track ID from the Spotify URL
|
||||
if (odesliDoc.RootElement.TryGetProperty("linksByPlatform", out var platforms) &&
|
||||
platforms.TryGetProperty("spotify", out var spotifyPlatform) &&
|
||||
spotifyPlatform.TryGetProperty("url", out var spotifyUrlEl))
|
||||
{
|
||||
var spotifyUrl = spotifyUrlEl.GetString();
|
||||
if (!string.IsNullOrEmpty(spotifyUrl))
|
||||
{
|
||||
// Extract ID from URL: https://open.spotify.com/track/{id}
|
||||
var match = System.Text.RegularExpressions.Regex.Match(spotifyUrl, @"spotify\.com/track/([a-zA-Z0-9]+)");
|
||||
if (match.Success)
|
||||
{
|
||||
var spotifyId = match.Groups[1].Value;
|
||||
_logger.LogInformation("✓ Converted Tidal/{TidalId} → Spotify ID {SpotifyId}", tidalTrackId, spotifyId);
|
||||
|
||||
// Cache for 7 days
|
||||
await _cache.SetAsync(cacheKey, spotifyId, TimeSpan.FromDays(7));
|
||||
|
||||
return spotifyId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to convert Tidal track to Spotify ID via Odesli");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts any music URL to a Spotify track ID using Odesli
|
||||
/// Results are cached for 7 days
|
||||
/// </summary>
|
||||
public async Task<string?> ConvertUrlToSpotifyIdAsync(string musicUrl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Check cache first
|
||||
var cacheKey = $"odesli:url-to-spotify:{musicUrl}";
|
||||
var cached = await _cache.GetAsync<string>(cacheKey);
|
||||
if (!string.IsNullOrEmpty(cached))
|
||||
{
|
||||
_logger.LogDebug("✓ Using cached Spotify ID for URL {Url}", musicUrl);
|
||||
return cached;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var odesliUrl = $"https://api.song.link/v1-alpha.1/links?url={Uri.EscapeDataString(musicUrl)}&userCountry=US";
|
||||
|
||||
_logger.LogDebug("🔗 Converting URL to Spotify ID via Odesli: {Url}", musicUrl);
|
||||
|
||||
var odesliResponse = await _httpClient.GetAsync(odesliUrl, cancellationToken);
|
||||
if (odesliResponse.IsSuccessStatusCode)
|
||||
{
|
||||
var odesliJson = await odesliResponse.Content.ReadAsStringAsync(cancellationToken);
|
||||
var odesliDoc = JsonDocument.Parse(odesliJson);
|
||||
|
||||
// Extract Spotify track ID from the Spotify URL
|
||||
if (odesliDoc.RootElement.TryGetProperty("linksByPlatform", out var platforms) &&
|
||||
platforms.TryGetProperty("spotify", out var spotifyPlatform) &&
|
||||
spotifyPlatform.TryGetProperty("url", out var spotifyUrlEl))
|
||||
{
|
||||
var spotifyUrl = spotifyUrlEl.GetString();
|
||||
if (!string.IsNullOrEmpty(spotifyUrl))
|
||||
{
|
||||
// Extract ID from URL: https://open.spotify.com/track/{id}
|
||||
var match = System.Text.RegularExpressions.Regex.Match(spotifyUrl, @"spotify\.com/track/([a-zA-Z0-9]+)");
|
||||
if (match.Success)
|
||||
{
|
||||
var spotifyId = match.Groups[1].Value;
|
||||
_logger.LogInformation("✓ Converted URL → Spotify ID {SpotifyId}", spotifyId);
|
||||
|
||||
// Cache for 7 days
|
||||
await _cache.SetAsync(cacheKey, spotifyId, TimeSpan.FromDays(7));
|
||||
|
||||
return spotifyId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to convert URL to Spotify ID via Odesli");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
313
allstarr/Services/Common/RoundRobinFallbackHelper.cs
Normal file
313
allstarr/Services/Common/RoundRobinFallbackHelper.cs
Normal file
@@ -0,0 +1,313 @@
|
||||
namespace allstarr.Services.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Helper for round-robin load balancing with fallback across multiple API endpoints.
|
||||
/// Distributes load evenly while maintaining reliability through automatic failover.
|
||||
/// </summary>
|
||||
public class RoundRobinFallbackHelper
|
||||
{
|
||||
private readonly List<string> _apiUrls;
|
||||
private int _currentUrlIndex = 0;
|
||||
private readonly object _urlIndexLock = new object();
|
||||
private readonly ILogger _logger;
|
||||
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 RoundRobinFallbackHelper(List<string> apiUrls, ILogger logger, string serviceName)
|
||||
{
|
||||
_apiUrls = apiUrls ?? throw new ArgumentNullException(nameof(apiUrls));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_serviceName = serviceName ?? "Service";
|
||||
|
||||
if (_apiUrls.Count == 0)
|
||||
{
|
||||
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>
|
||||
/// Updates the endpoint order based on benchmark results (fastest first).
|
||||
/// </summary>
|
||||
public void SetEndpointOrder(List<string> orderedEndpoints)
|
||||
{
|
||||
lock (_urlIndexLock)
|
||||
{
|
||||
// Reorder _apiUrls to match the benchmarked order
|
||||
var reordered = orderedEndpoints.Where(e => _apiUrls.Contains(e)).ToList();
|
||||
|
||||
// Add any endpoints that weren't benchmarked (shouldn't happen, but be safe)
|
||||
foreach (var url in _apiUrls.Where(u => !reordered.Contains(u)))
|
||||
{
|
||||
reordered.Add(url);
|
||||
}
|
||||
|
||||
_apiUrls.Clear();
|
||||
_apiUrls.AddRange(reordered);
|
||||
_currentUrlIndex = 0;
|
||||
|
||||
_logger.LogInformation("📊 {Service} endpoints reordered by benchmark: {Endpoints}",
|
||||
_serviceName, string.Join(", ", _apiUrls.Take(3)));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// Performs quick health checks first to avoid wasting time on dead endpoints.
|
||||
/// Throws exception if all endpoints fail.
|
||||
/// </summary>
|
||||
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
|
||||
var startIndex = 0;
|
||||
lock (_urlIndexLock)
|
||||
{
|
||||
startIndex = _currentUrlIndex;
|
||||
_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
|
||||
for (int attempt = 0; attempt < endpointsToTry.Count; attempt++)
|
||||
{
|
||||
var urlIndex = (startIndex + attempt) % endpointsToTry.Count;
|
||||
var baseUrl = endpointsToTry[urlIndex];
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Trying {Service} endpoint {Endpoint} (attempt {Attempt}/{Total})",
|
||||
_serviceName, baseUrl, attempt + 1, endpointsToTry.Count);
|
||||
return await action(baseUrl);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "{Service} request failed with endpoint {Endpoint}, trying next...",
|
||||
_serviceName, baseUrl);
|
||||
|
||||
// Mark as unhealthy in cache
|
||||
lock (_healthCacheLock)
|
||||
{
|
||||
_healthCache[baseUrl] = (false, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
if (attempt == endpointsToTry.Count - 1)
|
||||
{
|
||||
_logger.LogError("All {Count} {Service} endpoints failed", endpointsToTry.Count, _serviceName);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Exception($"All {_serviceName} endpoints failed");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Races all endpoints in parallel and returns the first successful result.
|
||||
/// Cancels remaining requests once one succeeds. Great for latency-sensitive operations.
|
||||
/// </summary>
|
||||
public async Task<T> RaceAllEndpointsAsync<T>(Func<string, CancellationToken, Task<T>> action, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_apiUrls.Count == 1)
|
||||
{
|
||||
// No point racing with one endpoint
|
||||
return await action(_apiUrls[0], cancellationToken);
|
||||
}
|
||||
|
||||
using var raceCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
var tasks = new List<Task<(T result, string endpoint, bool success)>>();
|
||||
|
||||
// Start all requests in parallel
|
||||
foreach (var baseUrl in _apiUrls)
|
||||
{
|
||||
var task = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Racing {Service} endpoint {Endpoint}", _serviceName, baseUrl);
|
||||
var result = await action(baseUrl, raceCts.Token);
|
||||
return (result, baseUrl, true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "{Service} race failed for endpoint {Endpoint}", _serviceName, baseUrl);
|
||||
return (default(T)!, baseUrl, false);
|
||||
}
|
||||
}, raceCts.Token);
|
||||
|
||||
tasks.Add(task);
|
||||
}
|
||||
|
||||
// Wait for first successful completion
|
||||
while (tasks.Count > 0)
|
||||
{
|
||||
var completedTask = await Task.WhenAny(tasks);
|
||||
var (result, endpoint, success) = await completedTask;
|
||||
|
||||
if (success)
|
||||
{
|
||||
_logger.LogInformation("🏁 {Service} race won by {Endpoint}, canceling others", _serviceName, endpoint);
|
||||
raceCts.Cancel(); // Cancel all other requests
|
||||
return result;
|
||||
}
|
||||
|
||||
tasks.Remove(completedTask);
|
||||
}
|
||||
|
||||
throw new Exception($"All {_serviceName} endpoints failed in race");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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
|
||||
var startIndex = 0;
|
||||
lock (_urlIndexLock)
|
||||
{
|
||||
startIndex = _currentUrlIndex;
|
||||
_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
|
||||
for (int attempt = 0; attempt < endpointsToTry.Count; attempt++)
|
||||
{
|
||||
var urlIndex = (startIndex + attempt) % endpointsToTry.Count;
|
||||
var baseUrl = endpointsToTry[urlIndex];
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Trying {Service} endpoint {Endpoint} (attempt {Attempt}/{Total})",
|
||||
_serviceName, baseUrl, attempt + 1, endpointsToTry.Count);
|
||||
return await action(baseUrl);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "{Service} request failed with endpoint {Endpoint}, trying next...",
|
||||
_serviceName, baseUrl);
|
||||
|
||||
// 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",
|
||||
endpointsToTry.Count, _serviceName);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,6 @@ namespace allstarr.Services.Deezer;
|
||||
public class DeezerDownloadService : BaseDownloadService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly SemaphoreSlim _requestLock = new(1, 1);
|
||||
|
||||
private readonly string? _arl;
|
||||
private readonly string? _arlFallback;
|
||||
@@ -33,9 +32,6 @@ public class DeezerDownloadService : BaseDownloadService
|
||||
private string? _apiToken;
|
||||
private string? _licenseToken;
|
||||
|
||||
private DateTime _lastRequestTime = DateTime.MinValue;
|
||||
private readonly int _minRequestIntervalMs = 200;
|
||||
|
||||
private const string DeezerApiBase = "https://api.deezer.com";
|
||||
|
||||
// Deezer's standard Blowfish CBC encryption key for track decryption
|
||||
@@ -111,7 +107,10 @@ public class DeezerDownloadService : BaseDownloadService
|
||||
|
||||
// Build organized folder structure: Artist/Album/Track using AlbumArtist (fallback to Artist for singles)
|
||||
var artistForPath = song.AlbumArtist ?? song.Artist;
|
||||
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache ? CachePath : DownloadPath;
|
||||
// Cache mode uses downloads/cache/ folder, Permanent mode uses downloads/permanent/
|
||||
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache
|
||||
? Path.Combine("downloads", "cache")
|
||||
: Path.Combine("downloads", "permanent");
|
||||
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension);
|
||||
|
||||
// Create directories if they don't exist
|
||||
@@ -494,27 +493,6 @@ public class DeezerDownloadService : BaseDownloadService
|
||||
await RetryWithBackoffAsync<bool>(action, maxRetries, initialDelayMs);
|
||||
}
|
||||
|
||||
private async Task<T> QueueRequestAsync<T>(Func<Task<T>> action)
|
||||
{
|
||||
await _requestLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var timeSinceLastRequest = (now - _lastRequestTime).TotalMilliseconds;
|
||||
|
||||
if (timeSinceLastRequest < _minRequestIntervalMs)
|
||||
{
|
||||
await Task.Delay((int)(_minRequestIntervalMs - timeSinceLastRequest));
|
||||
}
|
||||
|
||||
_lastRequestTime = DateTime.UtcNow;
|
||||
return await action();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_requestLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
@@ -168,6 +168,11 @@ public class JellyfinProxyService
|
||||
(h.Value.ToString().Contains("image", StringComparison.OrdinalIgnoreCase) ||
|
||||
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
|
||||
if (clientHeaders != null && clientHeaders.Count > 0)
|
||||
{
|
||||
@@ -179,11 +184,27 @@ public class JellyfinProxyService
|
||||
var headerValue = header.Value.ToString();
|
||||
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
|
||||
authHeaderAdded = true;
|
||||
_logger.LogInformation("✓ Forwarded X-Emby-Authorization: {Value}", headerValue);
|
||||
_logger.LogTrace("Forwarded X-Emby-Authorization header");
|
||||
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
|
||||
// Some clients send it as "Authorization" instead of "X-Emby-Authorization"
|
||||
if (!authHeaderAdded)
|
||||
@@ -201,37 +222,32 @@ public class JellyfinProxyService
|
||||
// Forward as X-Emby-Authorization (Jellyfin's expected header)
|
||||
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
|
||||
authHeaderAdded = true;
|
||||
_logger.LogInformation("✓ Converted Authorization to X-Emby-Authorization: {Value}", headerValue);
|
||||
_logger.LogTrace("Converted Authorization to X-Emby-Authorization");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Standard Bearer token - forward as-is
|
||||
request.Headers.TryAddWithoutValidation("Authorization", headerValue);
|
||||
authHeaderAdded = true;
|
||||
_logger.LogInformation("✓ Forwarded Authorization (Bearer): {Value}", headerValue);
|
||||
_logger.LogTrace("Forwarded Authorization header");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only log warnings for non-browser static requests
|
||||
if (!authHeaderAdded && !isBrowserStaticRequest)
|
||||
// Check for api_key query parameter (some clients use this)
|
||||
if (!authHeaderAdded && url.Contains("api_key=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogWarning("✗ No auth header found. Available headers: {Headers}",
|
||||
string.Join(", ", clientHeaders.Select(h => $"{h.Key}={h.Value}")));
|
||||
authHeaderAdded = true; // It's in the URL, no need to add header
|
||||
_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
|
||||
// If client doesn't provide auth, they get what they deserve (401 from Jellyfin)
|
||||
if (!authHeaderAdded && !isBrowserStaticRequest)
|
||||
// Only log warnings for non-public, non-browser requests without auth
|
||||
if (!authHeaderAdded && !isBrowserStaticRequest && !isPublicEndpoint)
|
||||
{
|
||||
_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"));
|
||||
@@ -248,14 +264,28 @@ public class JellyfinProxyService
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -297,8 +327,10 @@ public class JellyfinProxyService
|
||||
request.Content = new StringContent(bodyToSend, System.Text.Encoding.UTF8, "application/json");
|
||||
|
||||
bool authHeaderAdded = false;
|
||||
bool isAuthEndpoint = endpoint.Contains("Authenticate", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Forward authentication headers from client (case-insensitive)
|
||||
// Try X-Emby-Authorization first
|
||||
foreach (var header in clientHeaders)
|
||||
{
|
||||
if (header.Key.Equals("X-Emby-Authorization", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -306,11 +338,28 @@ public class JellyfinProxyService
|
||||
var headerValue = header.Value.ToString();
|
||||
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
|
||||
authHeaderAdded = true;
|
||||
_logger.LogDebug("Forwarded X-Emby-Authorization from client");
|
||||
_logger.LogTrace("Forwarded X-Emby-Authorization header");
|
||||
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)
|
||||
{
|
||||
foreach (var header in clientHeaders)
|
||||
@@ -325,13 +374,13 @@ public class JellyfinProxyService
|
||||
{
|
||||
// Forward as X-Emby-Authorization
|
||||
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
|
||||
_logger.LogDebug("Converted Authorization to X-Emby-Authorization");
|
||||
_logger.LogTrace("Converted Authorization to X-Emby-Authorization");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Standard Bearer token
|
||||
request.Headers.TryAddWithoutValidation("Authorization", headerValue);
|
||||
_logger.LogDebug("Forwarded Authorization header");
|
||||
_logger.LogTrace("Forwarded Authorization header");
|
||||
}
|
||||
authHeaderAdded = true;
|
||||
break;
|
||||
@@ -339,30 +388,23 @@ public class JellyfinProxyService
|
||||
}
|
||||
}
|
||||
|
||||
// DO NOT use server credentials as fallback
|
||||
// Exception: For auth endpoints, client provides their own credentials in the body
|
||||
// For all other endpoints, if client doesn't provide auth, let Jellyfin reject it
|
||||
if (!authHeaderAdded)
|
||||
// For authentication endpoints, credentials are in the body, not headers
|
||||
// For other endpoints without auth, let Jellyfin reject the request
|
||||
if (!authHeaderAdded && !isAuthEndpoint)
|
||||
{
|
||||
_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"));
|
||||
|
||||
// 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);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("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);
|
||||
}
|
||||
_logger.LogTrace("POST to Jellyfin: {Url}, body length: {Length} bytes", url, bodyToSend.Length);
|
||||
}
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
@@ -372,15 +414,39 @@ public class JellyfinProxyService
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
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
|
||||
if (!string.IsNullOrWhiteSpace(errorContent))
|
||||
{
|
||||
try
|
||||
{
|
||||
var errorDoc = JsonDocument.Parse(errorContent);
|
||||
return (errorDoc, statusCode);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Not valid JSON, return null
|
||||
}
|
||||
}
|
||||
|
||||
return (null, statusCode);
|
||||
}
|
||||
|
||||
// Log successful session-related responses
|
||||
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)
|
||||
@@ -397,13 +463,6 @@ public class JellyfinProxyService
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -38,12 +38,13 @@ public class JellyfinSessionManager : IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
public async Task<bool> EnsureSessionAsync(string deviceId, string client, string device, string version, IHeaderDictionary headers)
|
||||
{
|
||||
if (string.IsNullOrEmpty(deviceId))
|
||||
{
|
||||
_logger.LogWarning("⚠️ SESSION: Cannot create session - no device ID");
|
||||
_logger.LogWarning("Cannot create session - no device ID");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -51,25 +52,37 @@ public class JellyfinSessionManager : IDisposable
|
||||
if (_sessions.TryGetValue(deviceId, out var existingSession))
|
||||
{
|
||||
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
|
||||
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;
|
||||
}
|
||||
|
||||
_logger.LogInformation("🔧 SESSION: 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))}...")));
|
||||
_logger.LogDebug("Creating new session for device: {DeviceId} ({Client} on {Device})", deviceId, client, device);
|
||||
|
||||
try
|
||||
{
|
||||
// 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
|
||||
_sessions[deviceId] = new SessionInfo
|
||||
@@ -89,15 +102,16 @@ public class JellyfinSessionManager : IDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "❌ SESSION: Error creating session for {DeviceId}", deviceId);
|
||||
_logger.LogError(ex, "Error creating session for {DeviceId}", deviceId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Posts session capabilities to Jellyfin.
|
||||
/// Returns true if successful, false if token expired (401).
|
||||
/// </summary>
|
||||
private async Task PostCapabilitiesAsync(IHeaderDictionary headers)
|
||||
private async Task<bool> PostCapabilitiesAsync(IHeaderDictionary headers)
|
||||
{
|
||||
var capabilities = new
|
||||
{
|
||||
@@ -118,12 +132,19 @@ public class JellyfinSessionManager : IDisposable
|
||||
|
||||
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
|
||||
{
|
||||
// 401 is common when cached headers have expired - not a critical error
|
||||
_logger.LogDebug("SESSION: Capabilities post returned {StatusCode} (may be expected if token expired)", statusCode);
|
||||
_logger.LogDebug("Capabilities post returned {StatusCode}", statusCode);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,7 +199,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
if (_sessions.TryGetValue(deviceId, out var currentSession) &&
|
||||
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);
|
||||
}
|
||||
else
|
||||
@@ -223,7 +244,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
{
|
||||
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
|
||||
if (session.WebSocket != null && session.WebSocket.State == WebSocketState.Open)
|
||||
@@ -235,7 +256,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "⚠️ WEBSOCKET: Error closing WebSocket for {DeviceId}", deviceId);
|
||||
_logger.LogDebug(ex, "WEBSOCKET: Error closing WebSocket for {DeviceId}", deviceId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -255,7 +276,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
};
|
||||
var stopJson = JsonSerializer.Serialize(stopPayload);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -298,20 +319,23 @@ public class JellyfinSessionManager : IDisposable
|
||||
webSocket = new ClientWebSocket();
|
||||
session.WebSocket = webSocket;
|
||||
|
||||
// Use stored session headers instead of parameter (parameter might be disposed)
|
||||
var sessionHeaders = session.Headers;
|
||||
|
||||
// Log available headers for debugging
|
||||
_logger.LogDebug("🔍 WEBSOCKET: Available headers for {DeviceId}: {Headers}",
|
||||
deviceId, string.Join(", ", headers.Keys));
|
||||
deviceId, string.Join(", ", sessionHeaders.Keys));
|
||||
|
||||
// Forward authentication headers from the CLIENT - this is critical for session to appear under the right user
|
||||
bool authFound = false;
|
||||
if (headers.TryGetValue("X-Emby-Authorization", out var embyAuth))
|
||||
if (sessionHeaders.TryGetValue("X-Emby-Authorization", out var embyAuth))
|
||||
{
|
||||
webSocket.Options.SetRequestHeader("X-Emby-Authorization", embyAuth.ToString());
|
||||
_logger.LogDebug("🔑 WEBSOCKET: Using X-Emby-Authorization for {DeviceId}: {Auth}",
|
||||
deviceId, embyAuth.ToString().Length > 50 ? embyAuth.ToString()[..50] + "..." : embyAuth.ToString());
|
||||
authFound = true;
|
||||
}
|
||||
else if (headers.TryGetValue("Authorization", out var auth))
|
||||
else if (sessionHeaders.TryGetValue("Authorization", out var auth))
|
||||
{
|
||||
var authValue = auth.ToString();
|
||||
if (authValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -336,11 +360,11 @@ public class JellyfinSessionManager : IDisposable
|
||||
if (!string.IsNullOrEmpty(_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
|
||||
{
|
||||
_logger.LogWarning("❌ WEBSOCKET: No authentication available for {DeviceId}!", deviceId);
|
||||
_logger.LogWarning("❌ WEBSOCKET: No authentication available for {DeviceId} - WebSocket will fail", deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,7 +375,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
|
||||
// Connect to Jellyfin
|
||||
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
|
||||
// This tells Jellyfin to create/show the session in the dashboard
|
||||
@@ -407,8 +431,8 @@ public class JellyfinSessionManager : IDisposable
|
||||
}
|
||||
else
|
||||
{
|
||||
// Log other message types at info level
|
||||
_logger.LogInformation("📥 WEBSOCKET: {DeviceId}: {Message}",
|
||||
// Log other message types at trace level
|
||||
_logger.LogTrace("📥 WEBSOCKET: {DeviceId}: {Message}",
|
||||
deviceId, message.Length > 100 ? message[..100] + "..." : message);
|
||||
}
|
||||
}
|
||||
@@ -430,7 +454,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
}
|
||||
catch (WebSocketException wsEx)
|
||||
{
|
||||
_logger.LogWarning(wsEx, "⚠️ WEBSOCKET: WebSocket error for device {DeviceId}", deviceId);
|
||||
_logger.LogDebug(wsEx, "WEBSOCKET: Connection closed for device {DeviceId}", deviceId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -466,6 +490,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
/// <summary>
|
||||
/// Periodically pings Jellyfin to keep sessions alive.
|
||||
/// Note: This is a backup mechanism. The WebSocket connection is the primary keep-alive.
|
||||
/// Removes sessions with expired tokens (401 responses).
|
||||
/// </summary>
|
||||
private async void KeepSessionsAlive(object? state)
|
||||
{
|
||||
@@ -477,29 +502,43 @@ public class JellyfinSessionManager : IDisposable
|
||||
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)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Post capabilities again to keep session alive
|
||||
// Note: This may fail with 401 if the client's token has expired
|
||||
// That's okay - the WebSocket connection keeps the session alive anyway
|
||||
await PostCapabilitiesAsync(session.Headers);
|
||||
// If this returns false (401), the token has expired
|
||||
var success = 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)
|
||||
{
|
||||
_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
|
||||
// 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();
|
||||
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);
|
||||
await RemoveSessionAsync(stale.Key);
|
||||
}
|
||||
|
||||
@@ -406,9 +406,9 @@ public class LyricsPrefetchService : BackgroundService
|
||||
}
|
||||
|
||||
// Directly check if this track has lyrics using the item ID
|
||||
var (lyricsResult, lyricsStatusCode) = await proxyService.GetJsonAsync(
|
||||
// Use internal method with server API key since this is a background operation
|
||||
var (lyricsResult, lyricsStatusCode) = await proxyService.GetJsonAsyncInternal(
|
||||
$"Audio/{jellyfinItemId}/Lyrics",
|
||||
null,
|
||||
null);
|
||||
|
||||
if (lyricsResult != null && lyricsStatusCode == 200)
|
||||
@@ -455,7 +455,7 @@ public class LyricsPrefetchService : BackgroundService
|
||||
["limit"] = "5" // Get a few results to find best match
|
||||
};
|
||||
|
||||
var (searchResult, statusCode) = await proxyService.GetJsonAsync("Items", searchParams, null);
|
||||
var (searchResult, statusCode) = await proxyService.GetJsonAsyncInternal("Items", searchParams);
|
||||
|
||||
if (searchResult == null || statusCode != 200)
|
||||
{
|
||||
@@ -511,9 +511,9 @@ public class LyricsPrefetchService : BackgroundService
|
||||
}
|
||||
|
||||
// Check if this track has lyrics
|
||||
var (lyricsResult, lyricsStatusCode) = await proxyService.GetJsonAsync(
|
||||
// Use internal method with server API key since this is a background operation
|
||||
var (lyricsResult, lyricsStatusCode) = await proxyService.GetJsonAsyncInternal(
|
||||
$"Audio/{bestMatchId}/Lyrics",
|
||||
null,
|
||||
null);
|
||||
|
||||
if (lyricsResult != null && lyricsStatusCode == 200)
|
||||
|
||||
@@ -63,15 +63,7 @@ public class SpotifyLyricsService
|
||||
// Normalize track ID (remove URI prefix if present)
|
||||
spotifyTrackId = ExtractTrackId(spotifyTrackId);
|
||||
|
||||
// Check cache
|
||||
var cacheKey = $"spotify:lyrics:{spotifyTrackId}";
|
||||
var cached = await _cache.GetAsync<SpotifyLyricsResult>(cacheKey);
|
||||
if (cached != null)
|
||||
{
|
||||
_logger.LogDebug("Returning cached Spotify lyrics for track {TrackId}", spotifyTrackId);
|
||||
return cached;
|
||||
}
|
||||
|
||||
// NO CACHING - Spotify lyrics come from local Docker container (fast)
|
||||
try
|
||||
{
|
||||
var url = $"{_settings.LyricsApiUrl}/?trackid={spotifyTrackId}&format=id3";
|
||||
@@ -92,8 +84,6 @@ public class SpotifyLyricsService
|
||||
|
||||
if (result != null)
|
||||
{
|
||||
// Cache for 30 days (lyrics don't change)
|
||||
await _cache.SetAsync(cacheKey, result, TimeSpan.FromDays(30));
|
||||
_logger.LogInformation("Got Spotify lyrics from sidecar for track {TrackId} ({LineCount} lines)",
|
||||
spotifyTrackId, result.Lines.Count);
|
||||
}
|
||||
|
||||
@@ -110,7 +110,10 @@ public class QobuzDownloadService : BaseDownloadService
|
||||
|
||||
// Build organized folder structure using AlbumArtist (fallback to Artist for singles)
|
||||
var artistForPath = song.AlbumArtist ?? song.Artist;
|
||||
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache ? CachePath : DownloadPath;
|
||||
// Cache mode uses downloads/cache/ folder, Permanent mode uses downloads/permanent/
|
||||
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache
|
||||
? Path.Combine(DownloadPath, "cache")
|
||||
: Path.Combine(DownloadPath, "permanent");
|
||||
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension);
|
||||
|
||||
var albumFolder = Path.GetDirectoryName(outputPath)!;
|
||||
|
||||
@@ -44,7 +44,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
public async Task TriggerFetchAsync()
|
||||
{
|
||||
_logger.LogInformation("Manual fetch triggered");
|
||||
await FetchMissingTracksAsync(CancellationToken.None, bypassSyncWindowCheck: true);
|
||||
await FetchMissingTracksAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
@@ -84,20 +84,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
|
||||
_logger.LogInformation("Spotify Import ENABLED");
|
||||
_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
|
||||
await LoadPlaylistNamesAsync();
|
||||
@@ -109,7 +96,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
}
|
||||
_logger.LogInformation("========================================");
|
||||
|
||||
// Check if we should run on startup
|
||||
// Run on startup if we don't have cache
|
||||
if (!_hasRunOnce)
|
||||
{
|
||||
var shouldRun = await ShouldRunOnStartupAsync();
|
||||
@@ -118,7 +105,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
_logger.LogInformation("Running initial fetch on startup");
|
||||
try
|
||||
{
|
||||
await FetchMissingTracksAsync(stoppingToken, bypassSyncWindowCheck: true);
|
||||
await FetchMissingTracksAsync(stoppingToken);
|
||||
_hasRunOnce = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -128,21 +115,20 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Skipping startup fetch - already have current files");
|
||||
_logger.LogInformation("Skipping startup fetch - already have cached files");
|
||||
_hasRunOnce = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Background loop - check for new files every 5 minutes
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Only fetch if we're past today's sync window AND we haven't fetched today yet
|
||||
var shouldFetch = await ShouldFetchNowAsync();
|
||||
if (shouldFetch)
|
||||
{
|
||||
await FetchMissingTracksAsync(stoppingToken);
|
||||
_hasRunOnce = true;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -156,42 +142,29 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
|
||||
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 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)
|
||||
{
|
||||
var filePath = GetCacheFilePath(playlistName);
|
||||
|
||||
if (File.Exists(filePath))
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
var fileTime = File.GetLastWriteTimeUtc(filePath);
|
||||
|
||||
// If file is from today's sync or later, we already have it
|
||||
if (fileTime >= todaySync)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// Missing cache file for this playlist
|
||||
return true;
|
||||
}
|
||||
|
||||
// Missing today's file for this playlist
|
||||
return true;
|
||||
var fileTime = File.GetLastWriteTimeUtc(filePath);
|
||||
if (fileTime < cacheThreshold)
|
||||
{
|
||||
// Cache file is older than 24 hours
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// All playlists have today's files
|
||||
// All playlists have recent cache files
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -210,120 +183,43 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
{
|
||||
_logger.LogInformation("=== STARTUP CACHE CHECK ===");
|
||||
|
||||
var settings = _spotifySettings.Value;
|
||||
var now = DateTime.UtcNow;
|
||||
var allPlaylistsHaveCache = true;
|
||||
|
||||
// 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)
|
||||
foreach (var playlistName in _playlistIdToName.Values)
|
||||
{
|
||||
_logger.LogInformation("We're before today's sync window end - checking if we have recent cache...");
|
||||
var filePath = GetCacheFilePath(playlistName);
|
||||
var cacheKey = $"spotify:missing:{playlistName}";
|
||||
|
||||
var allPlaylistsHaveCache = true;
|
||||
|
||||
foreach (var playlistName in _playlistIdToName.Values)
|
||||
// Check file cache
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
var filePath = GetCacheFilePath(playlistName);
|
||||
var cacheKey = $"spotify:missing:{playlistName}";
|
||||
var fileAge = DateTime.UtcNow - File.GetLastWriteTimeUtc(filePath);
|
||||
_logger.LogInformation(" {Playlist}: Found file cache (age: {Age:F1}h)", playlistName, fileAge.TotalHours);
|
||||
|
||||
// Check file cache
|
||||
if (File.Exists(filePath))
|
||||
// Load into Redis if not already there
|
||||
if (!await _cache.ExistsAsync(cacheKey))
|
||||
{
|
||||
var fileAge = DateTime.UtcNow - File.GetLastWriteTimeUtc(filePath);
|
||||
_logger.LogInformation(" {Playlist}: Found file cache (age: {Age:F1}h)", playlistName, fileAge.TotalHours);
|
||||
|
||||
// Load into Redis if not already there
|
||||
if (!await _cache.ExistsAsync(cacheKey))
|
||||
{
|
||||
await LoadFromFileCache(playlistName);
|
||||
}
|
||||
continue;
|
||||
await LoadFromFileCache(playlistName);
|
||||
}
|
||||
|
||||
// Check Redis cache
|
||||
if (await _cache.ExistsAsync(cacheKey))
|
||||
{
|
||||
_logger.LogInformation(" {Playlist}: Found in Redis cache", playlistName);
|
||||
continue;
|
||||
}
|
||||
|
||||
// No cache found for this playlist
|
||||
_logger.LogInformation(" {Playlist}: No cache found", playlistName);
|
||||
allPlaylistsHaveCache = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (allPlaylistsHaveCache)
|
||||
// Check Redis cache
|
||||
if (await _cache.ExistsAsync(cacheKey))
|
||||
{
|
||||
_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;
|
||||
_logger.LogInformation(" {Playlist}: Found in Redis cache", playlistName);
|
||||
continue;
|
||||
}
|
||||
|
||||
// No cache found for this playlist
|
||||
_logger.LogInformation(" {Playlist}: No cache found", playlistName);
|
||||
allPlaylistsHaveCache = false;
|
||||
}
|
||||
|
||||
// If we're after today's sync window end, check if we already have today's file
|
||||
if (now >= todaySyncEnd)
|
||||
if (allPlaylistsHaveCache)
|
||||
{
|
||||
_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("=== ALL PLAYLISTS HAVE CACHE - SKIPPING STARTUP FETCH ===");
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.LogInformation("=== WILL FETCH ON STARTUP ===");
|
||||
@@ -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;
|
||||
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("=== FETCHING MISSING TRACKS ===");
|
||||
_logger.LogInformation("Processing {Count} playlists", _playlistIdToName.Count);
|
||||
|
||||
// Track when we find files to optimize search for other playlists
|
||||
|
||||
@@ -513,6 +513,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
matchedTracks.Count, tracksToMatch.Count, playlistName, isrcMatches, fuzzyMatches, noMatch);
|
||||
|
||||
// Pre-build playlist items cache for instant serving
|
||||
// This is what makes the UI show all matched tracks at once
|
||||
await PreBuildPlaylistItemsCacheAsync(playlistName, playlistConfig?.JellyfinId, spotifyTracks, matchedTracks, cancellationToken);
|
||||
}
|
||||
else
|
||||
@@ -553,7 +554,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
Song = song,
|
||||
// Use aggressive matching which follows optimal order internally
|
||||
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
|
||||
ArtistScore = CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
||||
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
||||
})
|
||||
.Select(x => new
|
||||
{
|
||||
@@ -639,7 +640,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
Song = song,
|
||||
// Use aggressive matching which follows optimal order internally
|
||||
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
|
||||
ArtistScore = CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
||||
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
||||
})
|
||||
.Select(x => new
|
||||
{
|
||||
@@ -743,7 +744,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
Song = song,
|
||||
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, song.Title),
|
||||
// Calculate artist score by checking ALL artists match
|
||||
ArtistScore = CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors)
|
||||
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors)
|
||||
})
|
||||
.Select(x => new
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ using allstarr.Models.Search;
|
||||
using allstarr.Models.Subsonic;
|
||||
using allstarr.Services.Local;
|
||||
using allstarr.Services.Common;
|
||||
using allstarr.Services.Lyrics;
|
||||
using Microsoft.Extensions.Options;
|
||||
using IOFile = System.IO.File;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -14,21 +15,48 @@ using Microsoft.Extensions.Logging;
|
||||
namespace allstarr.Services.SquidWTF;
|
||||
|
||||
/// <summary>
|
||||
/// Handles track downloading from tidal.squid.wtf (no encryption, no auth required)
|
||||
/// Downloads are direct from Tidal's CDN via the squid.wtf proxy
|
||||
/// Handles track downloading from tidal.squid.wtf (no encryption, no auth required).
|
||||
///
|
||||
/// Downloads are direct from Tidal's CDN via the squid.wtf proxy. The service:
|
||||
/// 1. Fetches download info from hifi-api /track/ endpoint
|
||||
/// 2. Decodes base64 manifest to get actual Tidal CDN URL
|
||||
/// 3. Downloads directly from Tidal CDN (no decryption needed)
|
||||
/// 4. Converts Tidal track ID to Spotify ID in parallel (for lyrics matching)
|
||||
/// 5. Writes ID3/FLAC metadata tags and embeds cover art
|
||||
///
|
||||
/// Per hifi-api spec, the /track/ endpoint returns:
|
||||
/// { "version": "2.0", "data": {
|
||||
/// trackId, assetPresentation, audioMode, audioQuality,
|
||||
/// manifestMimeType: "application/vnd.tidal.bts",
|
||||
/// manifest: "base64-encoded-json",
|
||||
/// albumReplayGain, trackReplayGain, bitDepth, sampleRate
|
||||
/// }}
|
||||
///
|
||||
/// The manifest decodes to:
|
||||
/// { "mimeType": "audio/flac", "codecs": "flac", "encryptionType": "NONE",
|
||||
/// "urls": ["https://lgf.audio.tidal.com/mediatracks/..."] }
|
||||
///
|
||||
/// Quality Mapping:
|
||||
/// - HI_RES → HI_RES_LOSSLESS (24-bit/192kHz FLAC)
|
||||
/// - FLAC/LOSSLESS → LOSSLESS (16-bit/44.1kHz FLAC)
|
||||
/// - HIGH → HIGH (320kbps AAC)
|
||||
/// - LOW → LOW (96kbps AAC)
|
||||
///
|
||||
/// Features:
|
||||
/// - Racing multiple endpoints for fastest download
|
||||
/// - Automatic failover to backup endpoints
|
||||
/// - Parallel Spotify ID conversion via Odesli
|
||||
/// - Organized folder structure: Artist/Album/Track
|
||||
/// - Unique filename resolution for duplicates
|
||||
/// - Support for both cache and permanent storage modes
|
||||
/// </summary>
|
||||
public class SquidWTFDownloadService : BaseDownloadService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly SemaphoreSlim _requestLock = new(1, 1);
|
||||
private readonly SquidWTFSettings _squidwtfSettings;
|
||||
|
||||
private DateTime _lastRequestTime = DateTime.MinValue;
|
||||
private readonly int _minRequestIntervalMs = 200;
|
||||
|
||||
private readonly List<string> _apiUrls;
|
||||
private int _currentUrlIndex = 0;
|
||||
private readonly object _urlIndexLock = new object();
|
||||
private readonly OdesliService _odesliService;
|
||||
private readonly RoundRobinFallbackHelper _fallbackHelper;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
|
||||
protected override string ProviderName => "squidwtf";
|
||||
|
||||
@@ -41,59 +69,26 @@ public class SquidWTFDownloadService : BaseDownloadService
|
||||
IOptions<SquidWTFSettings> SquidWTFSettings,
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<SquidWTFDownloadService> logger,
|
||||
OdesliService odesliService,
|
||||
List<string> apiUrls)
|
||||
: base(configuration, localLibraryService, metadataService, subsonicSettings.Value, serviceProvider, logger)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_squidwtfSettings = SquidWTFSettings.Value;
|
||||
_apiUrls = apiUrls;
|
||||
_odesliService = odesliService;
|
||||
_fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF");
|
||||
_serviceProvider = serviceProvider;
|
||||
|
||||
// Increase timeout for large downloads and slow endpoints
|
||||
_httpClient.Timeout = TimeSpan.FromMinutes(5);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action)
|
||||
{
|
||||
// Start with the next URL in round-robin to distribute load
|
||||
var startIndex = 0;
|
||||
lock (_urlIndexLock)
|
||||
{
|
||||
startIndex = _currentUrlIndex;
|
||||
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
|
||||
}
|
||||
|
||||
// Try all URLs starting from the round-robin selected one
|
||||
for (int attempt = 0; attempt < _apiUrls.Count; attempt++)
|
||||
{
|
||||
var urlIndex = (startIndex + attempt) % _apiUrls.Count;
|
||||
var baseUrl = _apiUrls[urlIndex];
|
||||
|
||||
try
|
||||
{
|
||||
Logger.LogDebug("Trying endpoint {Endpoint} (attempt {Attempt}/{Total})",
|
||||
baseUrl, attempt + 1, _apiUrls.Count);
|
||||
return await action(baseUrl);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Request failed with endpoint {Endpoint}, trying next...", baseUrl);
|
||||
|
||||
if (attempt == _apiUrls.Count - 1)
|
||||
{
|
||||
Logger.LogError("All {Count} SquidWTF endpoints failed", _apiUrls.Count);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Exception("All SquidWTF endpoints failed");
|
||||
}
|
||||
|
||||
#region BaseDownloadService Implementation
|
||||
|
||||
public override async Task<bool> IsAvailableAsync()
|
||||
{
|
||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
{
|
||||
var response = await _httpClient.GetAsync(baseUrl);
|
||||
Console.WriteLine($"Response code from is available async: {response.IsSuccessStatusCode}");
|
||||
@@ -116,8 +111,8 @@ public class SquidWTFDownloadService : BaseDownloadService
|
||||
{
|
||||
var downloadInfo = await GetTrackDownloadInfoAsync(trackId, cancellationToken);
|
||||
|
||||
Logger.LogInformation("Track token obtained: {Url}", downloadInfo.DownloadUrl);
|
||||
Logger.LogInformation("Using format: {Format}", downloadInfo.MimeType);
|
||||
Logger.LogInformation("Track download URL obtained from hifi-api: {Url}", downloadInfo.DownloadUrl);
|
||||
Logger.LogInformation("Using format: {Format} (Quality: {Quality})", downloadInfo.MimeType, downloadInfo.AudioQuality);
|
||||
|
||||
// Determine extension from MIME type
|
||||
var extension = downloadInfo.MimeType?.ToLower() switch
|
||||
@@ -130,7 +125,10 @@ public class SquidWTFDownloadService : BaseDownloadService
|
||||
|
||||
// Build organized folder structure: Artist/Album/Track using AlbumArtist (fallback to Artist for singles)
|
||||
var artistForPath = song.AlbumArtist ?? song.Artist;
|
||||
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache ? CachePath : DownloadPath;
|
||||
// Cache mode uses downloads/cache/ folder, Permanent mode uses downloads/permanent/
|
||||
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache
|
||||
? Path.Combine("downloads", "cache")
|
||||
: Path.Combine("downloads", "permanent");
|
||||
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension);
|
||||
|
||||
// Create directories if they don't exist
|
||||
@@ -140,10 +138,53 @@ public class SquidWTFDownloadService : BaseDownloadService
|
||||
// Resolve unique path if file already exists
|
||||
outputPath = PathHelper.ResolveUniquePath(outputPath);
|
||||
|
||||
// Download from Tidal CDN (no authentication needed, token is in URL)
|
||||
var response = await QueueRequestAsync(async () =>
|
||||
// Use round-robin with fallback for downloads to reduce CPU usage
|
||||
Logger.LogDebug("Using round-robin endpoint selection for download");
|
||||
|
||||
var response = await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, downloadInfo.DownloadUrl);
|
||||
// Map quality settings to Tidal's quality levels per hifi-api spec
|
||||
var quality = _squidwtfSettings.Quality?.ToUpperInvariant() switch
|
||||
{
|
||||
"FLAC" => "LOSSLESS",
|
||||
"HI_RES" => "HI_RES_LOSSLESS",
|
||||
"LOSSLESS" => "LOSSLESS",
|
||||
"HIGH" => "HIGH",
|
||||
"LOW" => "LOW",
|
||||
_ => "LOSSLESS"
|
||||
};
|
||||
|
||||
var url = $"{baseUrl}/track/?id={trackId}&quality={quality}";
|
||||
|
||||
// Get download info from this endpoint
|
||||
var infoResponse = await _httpClient.GetAsync(url, cancellationToken);
|
||||
infoResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var json = await infoResponse.Content.ReadAsStringAsync(cancellationToken);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
if (!doc.RootElement.TryGetProperty("data", out var data))
|
||||
{
|
||||
throw new Exception("Invalid response from API");
|
||||
}
|
||||
|
||||
var manifestBase64 = data.GetProperty("manifest").GetString()
|
||||
?? throw new Exception("No manifest in response");
|
||||
|
||||
// Decode base64 manifest to get actual CDN URL
|
||||
var manifestJson = Encoding.UTF8.GetString(Convert.FromBase64String(manifestBase64));
|
||||
var manifest = JsonDocument.Parse(manifestJson);
|
||||
|
||||
if (!manifest.RootElement.TryGetProperty("urls", out var urls) || urls.GetArrayLength() == 0)
|
||||
{
|
||||
throw new Exception("No download URLs in manifest");
|
||||
}
|
||||
|
||||
var downloadUrl = urls[0].GetString()
|
||||
?? throw new Exception("Download URL is null");
|
||||
|
||||
// Start the actual download from Tidal CDN (no encryption - squid.wtf handles everything)
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, downloadUrl);
|
||||
request.Headers.Add("User-Agent", "Mozilla/5.0");
|
||||
request.Headers.Add("Accept", "*/*");
|
||||
|
||||
@@ -161,7 +202,26 @@ public class SquidWTFDownloadService : BaseDownloadService
|
||||
// Close file before writing metadata
|
||||
await outputFile.DisposeAsync();
|
||||
|
||||
// Write metadata and cover art
|
||||
// Start Spotify ID conversion in background (for lyrics support)
|
||||
// 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))
|
||||
{
|
||||
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 (without Spotify ID - it's only needed for lyrics)
|
||||
await WriteMetadataAsync(outputPath, song, cancellationToken);
|
||||
|
||||
return outputPath;
|
||||
@@ -171,13 +231,22 @@ public class SquidWTFDownloadService : BaseDownloadService
|
||||
|
||||
#region SquidWTF API Methods
|
||||
|
||||
/// <summary>
|
||||
/// Gets track download information from hifi-api /track/ endpoint.
|
||||
/// Per hifi-api spec: GET /track/?id={trackId}&quality={quality}
|
||||
/// Returns: { "version": "2.0", "data": { trackId, assetPresentation, audioMode, audioQuality,
|
||||
/// manifestMimeType, manifestHash, manifest (base64), albumReplayGain, trackReplayGain, bitDepth, sampleRate } }
|
||||
/// The manifest is base64-encoded JSON containing: { mimeType, codecs, encryptionType, urls: [downloadUrl] }
|
||||
/// Quality options: HI_RES_LOSSLESS (24-bit/192kHz FLAC), LOSSLESS (16-bit/44.1kHz FLAC), HIGH (320kbps AAC), LOW (96kbps AAC)
|
||||
/// </summary>
|
||||
private async Task<DownloadResult> GetTrackDownloadInfoAsync(string trackId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await QueueRequestAsync(async () =>
|
||||
{
|
||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
||||
// Use round-robin with fallback instead of racing to reduce CPU usage
|
||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
{
|
||||
// Map quality settings to Tidal's quality levels
|
||||
// Map quality settings to Tidal's quality levels per hifi-api spec
|
||||
var quality = _squidwtfSettings.Quality?.ToUpperInvariant() switch
|
||||
{
|
||||
"FLAC" => "LOSSLESS",
|
||||
@@ -190,7 +259,7 @@ public class SquidWTFDownloadService : BaseDownloadService
|
||||
|
||||
var url = $"{baseUrl}/track/?id={trackId}&quality={quality}";
|
||||
|
||||
Console.WriteLine($"%%%%%%%%%%%%%%%%%%% URL For downloads??: {url}");
|
||||
Logger.LogDebug("Fetching track download info from: {Url}", url);
|
||||
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
@@ -228,8 +297,7 @@ public class SquidWTFDownloadService : BaseDownloadService
|
||||
? audioQualityEl.GetString()
|
||||
: "LOSSLESS";
|
||||
|
||||
Logger.LogDebug("Decoded manifest - URL: {Url}, MIME: {MimeType}, Quality: {Quality}",
|
||||
downloadUrl, mimeType, audioQuality);
|
||||
Logger.LogInformation("Track download URL obtained from hifi-api: {Url}", downloadUrl);
|
||||
|
||||
return new DownloadResult
|
||||
{
|
||||
@@ -241,29 +309,56 @@ public class SquidWTFDownloadService : BaseDownloadService
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
#endregion
|
||||
|
||||
#region Utility Methods
|
||||
|
||||
private async Task<T> QueueRequestAsync<T>(Func<Task<T>> action)
|
||||
/// <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)
|
||||
{
|
||||
await _requestLock.WaitAsync();
|
||||
try
|
||||
if (externalProvider != "squidwtf")
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var timeSinceLastRequest = (now - _lastRequestTime).TotalMilliseconds;
|
||||
|
||||
if (timeSinceLastRequest < _minRequestIntervalMs)
|
||||
{
|
||||
await Task.Delay((int)(_minRequestIntervalMs - timeSinceLastRequest));
|
||||
}
|
||||
|
||||
_lastRequestTime = DateTime.UtcNow;
|
||||
return await action();
|
||||
return;
|
||||
}
|
||||
finally
|
||||
|
||||
var spotifyId = await _odesliService.ConvertTidalToSpotifyIdAsync(externalId, CancellationToken.None);
|
||||
if (!string.IsNullOrEmpty(spotifyId))
|
||||
{
|
||||
_requestLock.Release();
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,41 @@ using System.Text.Json.Nodes;
|
||||
namespace allstarr.Services.SquidWTF;
|
||||
|
||||
/// <summary>
|
||||
/// Metadata service implementation using the SquidWTF API (free, no key required)
|
||||
/// Metadata service implementation using the SquidWTF API (free, no key required).
|
||||
///
|
||||
/// SquidWTF is a proxy to Tidal's API that provides free access to Tidal's music catalog.
|
||||
/// This implementation follows the hifi-api specification documented at the forked repository.
|
||||
///
|
||||
/// API Endpoints (per hifi-api spec):
|
||||
/// - GET /search/?s={query} - Search tracks (returns data.items array)
|
||||
/// - GET /search/?a={query} - Search artists (returns data.artists.items array)
|
||||
/// - GET /search/?al={query} - Search albums (returns data.albums.items array, undocumented)
|
||||
/// - GET /search/?p={query} - Search playlists (returns data.playlists.items array, undocumented)
|
||||
/// - GET /info/?id={trackId} - Get track metadata (returns data object with full track info)
|
||||
/// - GET /track/?id={trackId}&quality={quality} - Get track download info (returns manifest)
|
||||
/// - GET /album/?id={albumId} - Get album with tracks (undocumented, returns data.items array)
|
||||
/// - GET /artist/?f={artistId} - Get artist with albums (undocumented, returns albums.items array)
|
||||
/// - GET /playlist/?id={playlistId} - Get playlist with tracks (undocumented)
|
||||
///
|
||||
/// Quality Options:
|
||||
/// - HI_RES_LOSSLESS: 24-bit/192kHz FLAC
|
||||
/// - LOSSLESS: 16-bit/44.1kHz FLAC
|
||||
/// - HIGH: 320kbps AAC
|
||||
/// - LOW: 96kbps AAC
|
||||
///
|
||||
/// Response Structure:
|
||||
/// All responses follow: { "version": "2.0", "data": { ... } }
|
||||
/// Track objects include: id, title, duration, trackNumber, volumeNumber, explicit, bpm, isrc,
|
||||
/// artist (singular), artists (array), album (object with id, title, cover UUID)
|
||||
/// Cover art URLs: https://resources.tidal.com/images/{uuid-with-slashes}/{size}.jpg
|
||||
///
|
||||
/// Features:
|
||||
/// - Round-robin load balancing across multiple mirror endpoints
|
||||
/// - Automatic failover to backup endpoints on failure
|
||||
/// - Racing endpoints for fastest response on latency-sensitive operations
|
||||
/// - Redis caching for albums and artists (24-hour TTL)
|
||||
/// - Explicit content filtering support
|
||||
/// - Parallel Spotify ID conversion via Odesli for lyrics matching
|
||||
/// </summary>
|
||||
|
||||
public class SquidWTFMetadataService : IMusicMetadataService
|
||||
@@ -21,9 +55,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
private readonly SubsonicSettings _settings;
|
||||
private readonly ILogger<SquidWTFMetadataService> _logger;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly List<string> _apiUrls;
|
||||
private int _currentUrlIndex = 0;
|
||||
private readonly object _urlIndexLock = new object();
|
||||
private readonly RoundRobinFallbackHelper _fallbackHelper;
|
||||
|
||||
public SquidWTFMetadataService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
@@ -37,79 +69,33 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
_cache = cache;
|
||||
_apiUrls = apiUrls;
|
||||
_fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF");
|
||||
|
||||
// Set up default headers
|
||||
_httpClient.DefaultRequestHeaders.Add("User-Agent",
|
||||
"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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the next URL in round-robin fashion to distribute load across providers
|
||||
/// </summary>
|
||||
private string GetNextBaseUrl()
|
||||
{
|
||||
lock (_urlIndexLock)
|
||||
{
|
||||
var url = _apiUrls[_currentUrlIndex];
|
||||
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action, T defaultValue)
|
||||
{
|
||||
// Start with the next URL in round-robin to distribute load
|
||||
var startIndex = 0;
|
||||
lock (_urlIndexLock)
|
||||
{
|
||||
startIndex = _currentUrlIndex;
|
||||
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
|
||||
}
|
||||
|
||||
// Try all URLs starting from the round-robin selected one
|
||||
for (int attempt = 0; attempt < _apiUrls.Count; attempt++)
|
||||
{
|
||||
var urlIndex = (startIndex + attempt) % _apiUrls.Count;
|
||||
var baseUrl = _apiUrls[urlIndex];
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Trying endpoint {Endpoint} (attempt {Attempt}/{Total})",
|
||||
baseUrl, attempt + 1, _apiUrls.Count);
|
||||
return await action(baseUrl);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Request failed with endpoint {Endpoint}, trying next...", baseUrl);
|
||||
|
||||
if (attempt == _apiUrls.Count - 1)
|
||||
{
|
||||
_logger.LogError("All {Count} SquidWTF endpoints failed", _apiUrls.Count);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20)
|
||||
{
|
||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
||||
// Race all endpoints for fastest search results
|
||||
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
|
||||
{
|
||||
// Use 's' parameter for track search as per hifi-api spec
|
||||
var url = $"{baseUrl}/search/?s={Uri.EscapeDataString(query)}";
|
||||
var response = await _httpClient.GetAsync(url);
|
||||
var response = await _httpClient.GetAsync(url, ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var json = await response.Content.ReadAsStringAsync(ct);
|
||||
|
||||
// Check for error in response body
|
||||
var result = JsonDocument.Parse(json);
|
||||
@@ -120,6 +106,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
}
|
||||
|
||||
var songs = new List<Song>();
|
||||
// Per hifi-api spec: track search returns data.items array
|
||||
if (result.RootElement.TryGetProperty("data", out var data) &&
|
||||
data.TryGetProperty("items", out var items))
|
||||
{
|
||||
@@ -129,30 +116,36 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
if (count >= limit) break;
|
||||
|
||||
var song = ParseTidalTrack(track);
|
||||
songs.Add(song);
|
||||
if (ShouldIncludeSong(song))
|
||||
{
|
||||
songs.Add(song);
|
||||
}
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return songs;
|
||||
}, new List<Song>());
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20)
|
||||
{
|
||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
||||
// Race all endpoints for fastest search results
|
||||
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
|
||||
{
|
||||
// Note: hifi-api doesn't document album search, but 'al' parameter is commonly used
|
||||
var url = $"{baseUrl}/search/?al={Uri.EscapeDataString(query)}";
|
||||
var response = await _httpClient.GetAsync(url);
|
||||
var response = await _httpClient.GetAsync(url, ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return new List<Album>();
|
||||
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var json = await response.Content.ReadAsStringAsync(ct);
|
||||
var result = JsonDocument.Parse(json);
|
||||
|
||||
var albums = new List<Album>();
|
||||
// Per hifi-api spec: album search returns data.albums.items array
|
||||
if (result.RootElement.TryGetProperty("data", out var data) &&
|
||||
data.TryGetProperty("albums", out var albumsObj) &&
|
||||
albumsObj.TryGetProperty("items", out var items))
|
||||
@@ -168,28 +161,31 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
}
|
||||
|
||||
return albums;
|
||||
}, new List<Album>());
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20)
|
||||
{
|
||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
||||
// Race all endpoints for fastest search results
|
||||
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
|
||||
{
|
||||
// Per hifi-api spec: use 'a' parameter for artist search
|
||||
var url = $"{baseUrl}/search/?a={Uri.EscapeDataString(query)}";
|
||||
_logger.LogInformation("🔍 SQUIDWTF: Searching artists with URL: {Url}", url);
|
||||
|
||||
var response = await _httpClient.GetAsync(url);
|
||||
var response = await _httpClient.GetAsync(url, ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("⚠️ SQUIDWTF: Artist search failed with status {StatusCode}", response.StatusCode);
|
||||
return new List<Artist>();
|
||||
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var json = await response.Content.ReadAsStringAsync(ct);
|
||||
var result = JsonDocument.Parse(json);
|
||||
|
||||
var artists = new List<Artist>();
|
||||
// Per hifi-api spec: artist search returns data.artists.items array
|
||||
if (result.RootElement.TryGetProperty("data", out var data) &&
|
||||
data.TryGetProperty("artists", out var artistsObj) &&
|
||||
artistsObj.TryGetProperty("items", out var items))
|
||||
@@ -208,13 +204,14 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
|
||||
_logger.LogInformation("✓ SQUIDWTF: Artist search returned {Count} results", artists.Count);
|
||||
return artists;
|
||||
}, new List<Artist>());
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<List<ExternalPlaylist>> SearchPlaylistsAsync(string query, int limit = 20)
|
||||
{
|
||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
{
|
||||
// Per hifi-api spec: use 'p' parameter for playlist search
|
||||
var url = $"{baseUrl}/search/?p={Uri.EscapeDataString(query)}";
|
||||
var response = await _httpClient.GetAsync(url);
|
||||
if (!response.IsSuccessStatusCode) return new List<ExternalPlaylist>();
|
||||
@@ -223,15 +220,20 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
var result = JsonDocument.Parse(json);
|
||||
|
||||
var playlists = new List<ExternalPlaylist>();
|
||||
// Per hifi-api spec: playlist search returns data.playlists.items array
|
||||
if (result.RootElement.TryGetProperty("data", out var data) &&
|
||||
data.TryGetProperty("playlists", out var playlistObj) &&
|
||||
playlistObj.TryGetProperty("items", out var items))
|
||||
{
|
||||
int count = 0;
|
||||
foreach(var playlist in items.EnumerateArray())
|
||||
{
|
||||
if (count >= limit) break;
|
||||
|
||||
try
|
||||
{
|
||||
playlists.Add(ParseTidalPlaylist(playlist));
|
||||
count++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -267,8 +269,9 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
{
|
||||
if (externalProvider != "squidwtf") return null;
|
||||
|
||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
{
|
||||
// Per hifi-api spec: GET /info/?id={trackId} returns track metadata
|
||||
var url = $"{baseUrl}/info/?id={externalId}";
|
||||
|
||||
var response = await _httpClient.GetAsync(url);
|
||||
@@ -277,10 +280,16 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var result = JsonDocument.Parse(json);
|
||||
|
||||
// Per hifi-api spec: response is { "version": "2.0", "data": { track object } }
|
||||
if (!result.RootElement.TryGetProperty("data", out var track))
|
||||
return null;
|
||||
|
||||
return ParseTidalTrackFull(track);
|
||||
var song = ParseTidalTrackFull(track);
|
||||
|
||||
// NOTE: Spotify ID conversion happens during download (in SquidWTFDownloadService)
|
||||
// This avoids redundant conversions and ensures it's done in parallel with the download
|
||||
|
||||
return song;
|
||||
}, (Song?)null);
|
||||
}
|
||||
|
||||
@@ -293,8 +302,9 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
var cached = await _cache.GetAsync<Album>(cacheKey);
|
||||
if (cached != null) return cached;
|
||||
|
||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
{
|
||||
// Note: hifi-api doesn't document album endpoint, but /album/?id={albumId} is commonly used
|
||||
var url = $"{baseUrl}/album/?id={externalId}";
|
||||
|
||||
var response = await _httpClient.GetAsync(url);
|
||||
@@ -303,17 +313,18 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var result = JsonDocument.Parse(json);
|
||||
|
||||
|
||||
// Response structure: { "data": { album object with "items" array of tracks } }
|
||||
if (!result.RootElement.TryGetProperty("data", out var albumElement))
|
||||
return null;
|
||||
|
||||
var album = ParseTidalAlbum(albumElement);
|
||||
|
||||
// Get album tracks
|
||||
// Get album tracks from items array
|
||||
if (albumElement.TryGetProperty("items", out var tracks))
|
||||
{
|
||||
foreach (var trackWrapper in tracks.EnumerateArray())
|
||||
{
|
||||
// Each item is wrapped: { "item": { track object } }
|
||||
if (trackWrapper.TryGetProperty("item", out var track))
|
||||
{
|
||||
var song = ParseTidalTrack(track);
|
||||
@@ -347,8 +358,9 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
return cached;
|
||||
}
|
||||
|
||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
{
|
||||
// Note: hifi-api doesn't document artist endpoint, but /artist/?f={artistId} is commonly used
|
||||
var url = $"{baseUrl}/artist/?f={externalId}";
|
||||
_logger.LogInformation("Fetching artist from {Url}", url);
|
||||
|
||||
@@ -366,7 +378,8 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
JsonElement? artistSource = null;
|
||||
int albumCount = 0;
|
||||
|
||||
// Try to get artist from albums.items[0].artist
|
||||
// Response structure: { "albums": { "items": [ album objects ] }, "tracks": [ track objects ] }
|
||||
// Extract artist info from albums.items[0].artist (most reliable source)
|
||||
if (result.RootElement.TryGetProperty("albums", out var albums) &&
|
||||
albums.TryGetProperty("items", out var albumItems) &&
|
||||
albumItems.GetArrayLength() > 0)
|
||||
@@ -398,6 +411,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
}
|
||||
|
||||
var artistElement = artistSource.Value;
|
||||
// Normalize artist data to include album count
|
||||
var normalizedArtist = new JsonObject
|
||||
{
|
||||
["id"] = artistElement.GetProperty("id").GetInt64(),
|
||||
@@ -422,10 +436,11 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
{
|
||||
if (externalProvider != "squidwtf") return new List<Album>();
|
||||
|
||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
{
|
||||
_logger.LogInformation("GetArtistAlbumsAsync called for SquidWTF artist {ExternalId}", externalId);
|
||||
|
||||
// Note: hifi-api doesn't document artist endpoint, but /artist/?f={artistId} is commonly used
|
||||
var url = $"{baseUrl}/artist/?f={externalId}";
|
||||
_logger.LogInformation("Fetching artist albums from URL: {Url}", url);
|
||||
var response = await _httpClient.GetAsync(url);
|
||||
@@ -442,6 +457,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
|
||||
var albums = new List<Album>();
|
||||
|
||||
// Response structure: { "albums": { "items": [ album objects ] } }
|
||||
if (result.RootElement.TryGetProperty("albums", out var albumsObj) &&
|
||||
albumsObj.TryGetProperty("items", out var items))
|
||||
{
|
||||
@@ -467,8 +483,9 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
{
|
||||
if (externalProvider != "squidwtf") return null;
|
||||
|
||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
{
|
||||
// Note: hifi-api doesn't document playlist endpoint, but /playlist/?id={playlistId} is commonly used
|
||||
var url = $"{baseUrl}/playlist/?id={externalId}";
|
||||
var response = await _httpClient.GetAsync(url);
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
@@ -476,8 +493,10 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var playlistElement = JsonDocument.Parse(json).RootElement;
|
||||
|
||||
// Check for error response
|
||||
if (playlistElement.TryGetProperty("error", out _)) return null;
|
||||
|
||||
// Response structure: { "playlist": { playlist object }, "items": [ track wrappers ] }
|
||||
return ParseTidalPlaylist(playlistElement);
|
||||
}, (ExternalPlaylist?)null);
|
||||
}
|
||||
@@ -486,8 +505,9 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
{
|
||||
if (externalProvider != "squidwtf") return new List<Song>();
|
||||
|
||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
{
|
||||
// Note: hifi-api doesn't document playlist endpoint, but /playlist/?id={playlistId} is commonly used
|
||||
var url = $"{baseUrl}/playlist/?id={externalId}";
|
||||
var response = await _httpClient.GetAsync(url);
|
||||
if (!response.IsSuccessStatusCode) return new List<Song>();
|
||||
@@ -495,11 +515,13 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var playlistElement = JsonDocument.Parse(json).RootElement;
|
||||
|
||||
// Check for error response
|
||||
if (playlistElement.TryGetProperty("error", out _)) return new List<Song>();
|
||||
|
||||
JsonElement? playlist = null;
|
||||
JsonElement? tracks = null;
|
||||
|
||||
// Response structure: { "playlist": { playlist object }, "items": [ track wrappers ] }
|
||||
if (playlistElement.TryGetProperty("playlist", out var playlistEl))
|
||||
{
|
||||
playlist = playlistEl;
|
||||
@@ -522,6 +544,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
int trackIndex = 1;
|
||||
foreach (var entry in tracks.Value.EnumerateArray())
|
||||
{
|
||||
// Each item is wrapped: { "item": { track object } }
|
||||
if (!entry.TryGetProperty("item", out var track))
|
||||
continue;
|
||||
|
||||
@@ -544,6 +567,14 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
|
||||
// --- Parser functions start here ---
|
||||
|
||||
/// <summary>
|
||||
/// Parses a Tidal track object from hifi-api search/album/playlist responses.
|
||||
/// Per hifi-api spec, track objects contain: id, title, duration, trackNumber, volumeNumber,
|
||||
/// explicit, artist (singular), artists (array), album (object with id, title, cover).
|
||||
/// </summary>
|
||||
/// <param name="track">JSON element containing track data</param>
|
||||
/// <param name="fallbackTrackNumber">Optional track number to use if not present in JSON</param>
|
||||
/// <returns>Parsed Song object</returns>
|
||||
private Song ParseTidalTrack(JsonElement track, int? fallbackTrackNumber = null)
|
||||
{
|
||||
var externalId = track.GetProperty("id").GetInt64().ToString();
|
||||
@@ -633,6 +664,13 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a full Tidal track object from hifi-api /info/ endpoint.
|
||||
/// Per hifi-api spec, full track objects include additional metadata: bpm, isrc, key, keyScale,
|
||||
/// streamStartDate (for year), copyright, replayGain, peak, audioQuality, audioModes.
|
||||
/// </summary>
|
||||
/// <param name="track">JSON element containing full track data</param>
|
||||
/// <returns>Parsed Song object with extended metadata</returns>
|
||||
private Song ParseTidalTrackFull(JsonElement track)
|
||||
{
|
||||
var externalId = track.GetProperty("id").GetInt64().ToString();
|
||||
@@ -754,6 +792,13 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a Tidal album object from hifi-api responses.
|
||||
/// Per hifi-api spec, album objects contain: id, title, releaseDate, numberOfTracks,
|
||||
/// cover (UUID), artist (object) or artists (array).
|
||||
/// </summary>
|
||||
/// <param name="album">JSON element containing album data</param>
|
||||
/// <returns>Parsed Album object</returns>
|
||||
private Album ParseTidalAlbum(JsonElement album)
|
||||
{
|
||||
var externalId = album.GetProperty("id").GetInt64().ToString();
|
||||
@@ -807,8 +852,13 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Think of a way to implement album count when this function is called by search function
|
||||
// as the API endpoint in search does not include this data
|
||||
/// <summary>
|
||||
/// Parses a Tidal artist object from hifi-api responses.
|
||||
/// Per hifi-api spec, artist objects contain: id, name, picture (UUID).
|
||||
/// Note: albums_count is not in the standard API response but is added by GetArtistAsync.
|
||||
/// </summary>
|
||||
/// <param name="artist">JSON element containing artist data</param>
|
||||
/// <returns>Parsed Artist object</returns>
|
||||
private Artist ParseTidalArtist(JsonElement artist)
|
||||
{
|
||||
var externalId = artist.GetProperty("id").GetInt64().ToString();
|
||||
@@ -834,6 +884,14 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a Tidal playlist from hifi-api /playlist/ endpoint response.
|
||||
/// Per hifi-api spec (undocumented), response structure is:
|
||||
/// { "playlist": { uuid, title, description, creator, created, numberOfTracks, duration, squareImage },
|
||||
/// "items": [ { "item": { track object } } ] }
|
||||
/// </summary>
|
||||
/// <param name="playlistElement">Root JSON element containing playlist and items</param>
|
||||
/// <returns>Parsed ExternalPlaylist object</returns>
|
||||
private ExternalPlaylist ParseTidalPlaylist(JsonElement playlistElement)
|
||||
{
|
||||
JsonElement? playlist = null;
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Services.Validation;
|
||||
using allstarr.Services.Common;
|
||||
|
||||
namespace allstarr.Services.SquidWTF;
|
||||
|
||||
@@ -12,56 +13,26 @@ namespace allstarr.Services.SquidWTF;
|
||||
public class SquidWTFStartupValidator : BaseStartupValidator
|
||||
{
|
||||
private readonly SquidWTFSettings _settings;
|
||||
private readonly List<string> _apiUrls;
|
||||
private int _currentUrlIndex = 0;
|
||||
private readonly object _urlIndexLock = new object();
|
||||
private readonly RoundRobinFallbackHelper _fallbackHelper;
|
||||
private readonly EndpointBenchmarkService _benchmarkService;
|
||||
private readonly ILogger<SquidWTFStartupValidator> _logger;
|
||||
|
||||
public override string ServiceName => "SquidWTF";
|
||||
|
||||
public SquidWTFStartupValidator(IOptions<SquidWTFSettings> settings, HttpClient httpClient, List<string> apiUrls)
|
||||
public SquidWTFStartupValidator(
|
||||
IOptions<SquidWTFSettings> settings,
|
||||
HttpClient httpClient,
|
||||
List<string> apiUrls,
|
||||
EndpointBenchmarkService benchmarkService,
|
||||
ILogger<SquidWTFStartupValidator> logger)
|
||||
: base(httpClient)
|
||||
{
|
||||
_settings = settings.Value;
|
||||
_apiUrls = apiUrls;
|
||||
_fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF");
|
||||
_benchmarkService = benchmarkService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action, T defaultValue)
|
||||
{
|
||||
// Start with the next URL in round-robin to distribute load
|
||||
var startIndex = 0;
|
||||
lock (_urlIndexLock)
|
||||
{
|
||||
startIndex = _currentUrlIndex;
|
||||
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
|
||||
}
|
||||
|
||||
// Try all URLs starting from the round-robin selected one
|
||||
for (int attempt = 0; attempt < _apiUrls.Count; attempt++)
|
||||
{
|
||||
var urlIndex = (startIndex + attempt) % _apiUrls.Count;
|
||||
var baseUrl = _apiUrls[urlIndex];
|
||||
|
||||
try
|
||||
{
|
||||
return await action(baseUrl);
|
||||
}
|
||||
catch
|
||||
{
|
||||
WriteDetail($"Endpoint {baseUrl} failed, trying next...");
|
||||
|
||||
if (attempt == _apiUrls.Count - 1)
|
||||
{
|
||||
WriteDetail($"All {_apiUrls.Count} endpoints failed");
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
public override async Task<ValidationResult> ValidateAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -79,8 +50,49 @@ public class SquidWTFStartupValidator : BaseStartupValidator
|
||||
|
||||
WriteStatus("SquidWTF Quality", quality, ConsoleColor.Cyan);
|
||||
|
||||
// Benchmark all endpoints to determine fastest
|
||||
var apiUrls = _fallbackHelper.EndpointCount > 0
|
||||
? Enumerable.Range(0, _fallbackHelper.EndpointCount).Select(_ => "").ToList() // Placeholder, we'll get actual URLs from fallback helper
|
||||
: new List<string>();
|
||||
|
||||
// Get the actual API URLs by reflection (not ideal, but works for now)
|
||||
var fallbackHelperType = _fallbackHelper.GetType();
|
||||
var apiUrlsField = fallbackHelperType.GetField("_apiUrls", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
if (apiUrlsField != null)
|
||||
{
|
||||
apiUrls = (List<string>)apiUrlsField.GetValue(_fallbackHelper)!;
|
||||
}
|
||||
|
||||
if (apiUrls.Count > 1)
|
||||
{
|
||||
WriteStatus("Benchmarking Endpoints", $"{apiUrls.Count} endpoints", ConsoleColor.Cyan);
|
||||
|
||||
var orderedEndpoints = await _benchmarkService.BenchmarkEndpointsAsync(
|
||||
apiUrls,
|
||||
async (endpoint, ct) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync(endpoint, ct);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
},
|
||||
pingCount: 2,
|
||||
cancellationToken);
|
||||
|
||||
if (orderedEndpoints.Count > 0)
|
||||
{
|
||||
_fallbackHelper.SetEndpointOrder(orderedEndpoints);
|
||||
WriteDetail($"Fastest endpoint: {orderedEndpoints.First()}");
|
||||
}
|
||||
}
|
||||
|
||||
// Test connectivity with fallback
|
||||
var result = await TryWithFallbackAsync(async (baseUrl) =>
|
||||
var result = await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
{
|
||||
var response = await _httpClient.GetAsync(baseUrl, cancellationToken);
|
||||
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
"EnableExternalPlaylists": true
|
||||
},
|
||||
"Library": {
|
||||
"DownloadPath": "./downloads"
|
||||
"DownloadPath": "./downloads",
|
||||
"KeptPath": "/app/kept"
|
||||
},
|
||||
"Qobuz": {
|
||||
"UserAuthToken": "your-qobuz-token",
|
||||
|
||||
@@ -539,6 +539,7 @@
|
||||
<div class="tab" data-tab="jellyfin-playlists">Link Playlists</div>
|
||||
<div class="tab" data-tab="playlists">Active Playlists</div>
|
||||
<div class="tab" data-tab="config">Configuration</div>
|
||||
<div class="tab" data-tab="endpoints">API Analytics</div>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Tab -->
|
||||
@@ -644,12 +645,18 @@
|
||||
|
||||
<!-- Active Playlists Tab -->
|
||||
<div class="tab-content" id="tab-playlists">
|
||||
<!-- Warning Banner (hidden by default) -->
|
||||
<div id="matching-warning-banner" style="display:none;background:#f59e0b;color:#000;padding:16px;border-radius:8px;margin-bottom:16px;font-weight:600;text-align:center;box-shadow:0 4px 6px rgba(0,0,0,0.1);">
|
||||
⚠️ TRACK MATCHING IN PROGRESS - Please wait for matching to complete before making changes to playlists or mappings!
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>
|
||||
Active Spotify Playlists
|
||||
<div class="actions">
|
||||
<button onclick="matchAllPlaylists()">Match All Tracks</button>
|
||||
<button onclick="refreshPlaylists()">Refresh All</button>
|
||||
<button onclick="matchAllPlaylists()" title="Match tracks for all playlists against your local library and external providers. This may take several minutes.">Match All Tracks</button>
|
||||
<button onclick="refreshPlaylists()" title="Fetch the latest playlist data from Spotify without re-matching tracks.">Refresh All</button>
|
||||
<button onclick="refreshAndMatchAll()" title="Clear caches, fetch fresh data from Spotify, and match all tracks. This is a full rebuild and may take several minutes." style="background:var(--accent);border-color:var(--accent);">Refresh & Match All</button>
|
||||
</div>
|
||||
</h2>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
||||
@@ -662,14 +669,13 @@
|
||||
<th>Spotify ID</th>
|
||||
<th>Tracks</th>
|
||||
<th>Completion</th>
|
||||
<th>Lyrics</th>
|
||||
<th>Cache Age</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="playlist-table-body">
|
||||
<tr>
|
||||
<td colspan="7" class="loading">
|
||||
<td colspan="6" class="loading">
|
||||
<span class="spinner"></span> Loading playlists...
|
||||
</td>
|
||||
</tr>
|
||||
@@ -920,6 +926,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Library Settings</h2>
|
||||
<div class="config-section">
|
||||
<div class="config-item">
|
||||
<span class="label">Download Path (Cache)</span>
|
||||
<span class="value" id="config-download-path">-</span>
|
||||
<button onclick="openEditSetting('LIBRARY_DOWNLOAD_PATH', 'Download Path', 'text')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Kept Path (Favorited)</span>
|
||||
<span class="value" id="config-kept-path">-</span>
|
||||
<button onclick="openEditSetting('LIBRARY_KEPT_PATH', 'Kept Path', 'text')">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Sync Schedule</h2>
|
||||
<div class="config-section">
|
||||
@@ -959,6 +981,85 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Analytics Tab -->
|
||||
<div class="tab-content" id="tab-endpoints">
|
||||
<div class="card">
|
||||
<h2>
|
||||
API Endpoint Usage
|
||||
<div class="actions">
|
||||
<button onclick="fetchEndpointUsage()">Refresh</button>
|
||||
<button class="danger" onclick="clearEndpointUsage()">Clear Data</button>
|
||||
</div>
|
||||
</h2>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||
Track which Jellyfin API endpoints are being called most frequently. Useful for debugging and understanding client behavior.
|
||||
</p>
|
||||
|
||||
<div id="endpoints-summary" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 20px;">
|
||||
<div style="background: var(--bg-tertiary); padding: 16px; border-radius: 8px;">
|
||||
<div style="color: var(--text-secondary); font-size: 0.85rem; margin-bottom: 4px;">Total Requests</div>
|
||||
<div style="font-size: 1.8rem; font-weight: 600; color: var(--accent);" id="endpoints-total-requests">0</div>
|
||||
</div>
|
||||
<div style="background: var(--bg-tertiary); padding: 16px; border-radius: 8px;">
|
||||
<div style="color: var(--text-secondary); font-size: 0.85rem; margin-bottom: 4px;">Unique Endpoints</div>
|
||||
<div style="font-size: 1.8rem; font-weight: 600; color: var(--success);" id="endpoints-unique-count">0</div>
|
||||
</div>
|
||||
<div style="background: var(--bg-tertiary); padding: 16px; border-radius: 8px;">
|
||||
<div style="color: var(--text-secondary); font-size: 0.85rem; margin-bottom: 4px;">Most Called</div>
|
||||
<div style="font-size: 1.1rem; font-weight: 600; color: var(--text-primary); word-break: break-all;" id="endpoints-most-called">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 16px;">
|
||||
<label style="display: block; margin-bottom: 8px; color: var(--text-secondary); font-size: 0.9rem;">Show Top</label>
|
||||
<select id="endpoints-top-select" onchange="fetchEndpointUsage()" style="padding: 8px; background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary);">
|
||||
<option value="25">Top 25</option>
|
||||
<option value="50" selected>Top 50</option>
|
||||
<option value="100">Top 100</option>
|
||||
<option value="500">Top 500</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="max-height: 600px; overflow-y: auto;">
|
||||
<table class="playlist-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 60px;">#</th>
|
||||
<th>Endpoint</th>
|
||||
<th style="width: 120px; text-align: right;">Requests</th>
|
||||
<th style="width: 120px; text-align: right;">% of Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="endpoints-table-body">
|
||||
<tr>
|
||||
<td colspan="4" class="loading">
|
||||
<span class="spinner"></span> Loading endpoint usage data...
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>About Endpoint Tracking</h2>
|
||||
<p style="color: var(--text-secondary); line-height: 1.6;">
|
||||
Allstarr logs every Jellyfin API endpoint call to help you understand how clients interact with your server.
|
||||
This data is stored in <code style="background: var(--bg-tertiary); padding: 2px 6px; border-radius: 4px;">/app/cache/endpoint-usage/endpoints.csv</code>
|
||||
and persists across restarts.
|
||||
<br><br>
|
||||
<strong>Common Endpoints:</strong>
|
||||
<ul style="margin-top: 8px; margin-left: 20px;">
|
||||
<li><code>/Users/{userId}/Items</code> - Browse library items</li>
|
||||
<li><code>/Items/{itemId}</code> - Get item details</li>
|
||||
<li><code>/Audio/{itemId}/stream</code> - Stream audio</li>
|
||||
<li><code>/Sessions/Playing</code> - Report playback status</li>
|
||||
<li><code>/Search/Hints</code> - Search functionality</li>
|
||||
</ul>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Playlist Modal -->
|
||||
@@ -1180,8 +1281,37 @@
|
||||
if (hash) {
|
||||
switchTab(hash);
|
||||
}
|
||||
|
||||
// Start auto-refresh for playlists tab (every 5 seconds)
|
||||
startPlaylistAutoRefresh();
|
||||
});
|
||||
|
||||
// Auto-refresh functionality for playlists
|
||||
let playlistAutoRefreshInterval = null;
|
||||
|
||||
function startPlaylistAutoRefresh() {
|
||||
// Clear any existing interval
|
||||
if (playlistAutoRefreshInterval) {
|
||||
clearInterval(playlistAutoRefreshInterval);
|
||||
}
|
||||
|
||||
// Refresh every 5 seconds when on playlists tab
|
||||
playlistAutoRefreshInterval = setInterval(() => {
|
||||
const playlistsTab = document.getElementById('tab-playlists');
|
||||
if (playlistsTab && playlistsTab.classList.contains('active')) {
|
||||
// Silently refresh without showing loading state
|
||||
fetchPlaylists(true);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function stopPlaylistAutoRefresh() {
|
||||
if (playlistAutoRefreshInterval) {
|
||||
clearInterval(playlistAutoRefreshInterval);
|
||||
playlistAutoRefreshInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Toast notification
|
||||
function showToast(message, type = 'success', duration = 3000) {
|
||||
const toast = document.createElement('div');
|
||||
@@ -1321,7 +1451,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPlaylists() {
|
||||
async function fetchPlaylists(silent = false) {
|
||||
try {
|
||||
const res = await fetch('/api/admin/playlists');
|
||||
const data = await res.json();
|
||||
@@ -1329,7 +1459,9 @@
|
||||
const tbody = document.getElementById('playlist-table-body');
|
||||
|
||||
if (data.playlists.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists configured. Link playlists from the Jellyfin Playlists tab.</td></tr>';
|
||||
if (!silent) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists configured. Link playlists from the Jellyfin Playlists tab.</td></tr>';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1397,21 +1529,10 @@
|
||||
<span style="font-size:0.85rem;color:${completionColor};font-weight:500;min-width:40px;">${completionPct}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
${p.lyricsTotal > 0 ? `
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<div style="flex:1;background:var(--bg-tertiary);height:12px;border-radius:6px;overflow:hidden;">
|
||||
<div style="width:${p.lyricsPercentage}%;height:100%;background:${p.lyricsPercentage === 100 ? '#10b981' : '#3b82f6'};transition:width 0.3s;" title="${p.lyricsCached} lyrics cached"></div>
|
||||
</div>
|
||||
<span style="font-size:0.85rem;color:var(--text-secondary);font-weight:500;min-width:40px;">${p.lyricsPercentage}%</span>
|
||||
</div>
|
||||
` : '<span style="color:var(--text-secondary);font-size:0.85rem;">-</span>'}
|
||||
</td>
|
||||
<td class="cache-age">${p.cacheAge || '-'}</td>
|
||||
<td>
|
||||
<button onclick="clearPlaylistCache('${escapeJs(p.name)}')">Clear Cache & Rebuild</button>
|
||||
<button onclick="matchPlaylistTracks('${escapeJs(p.name)}')">Match Tracks</button>
|
||||
<button onclick="prefetchLyrics('${escapeJs(p.name)}')">Prefetch Lyrics</button>
|
||||
<button onclick="viewTracks('${escapeJs(p.name)}')">View</button>
|
||||
<button class="danger" onclick="removePlaylist('${escapeJs(p.name)}')">Remove</button>
|
||||
</td>
|
||||
@@ -1546,16 +1667,20 @@
|
||||
}
|
||||
|
||||
tbody.innerHTML = missingTracks.map(t => {
|
||||
const artist = (t.artists && t.artists.length > 0) ? t.artists.join(', ') : '';
|
||||
const searchQuery = `${t.title} ${artist}`;
|
||||
return `
|
||||
<tr>
|
||||
<td><strong>${escapeHtml(t.playlist)}</strong></td>
|
||||
<td>${escapeHtml(t.title)}</td>
|
||||
<td>${escapeHtml(t.artist)}</td>
|
||||
<td>${escapeHtml(artist)}</td>
|
||||
<td style="color:var(--text-secondary);">${t.album ? escapeHtml(t.album) : '-'}</td>
|
||||
<td>
|
||||
<button onclick="openMapToLocal('${escapeJs(t.playlist)}', '${escapeJs(t.spotifyId)}', '${escapeJs(t.title)}', '${escapeJs(t.artist)}')"
|
||||
<button onclick="searchProvider('${escapeJs(searchQuery)}', 'squidwtf')"
|
||||
style="margin-right:4px;font-size:0.75rem;padding:4px 8px;background:#3b82f6;border-color:#3b82f6;color:white;">🔍 Search</button>
|
||||
<button onclick="openMapToLocal('${escapeJs(t.playlist)}', '${escapeJs(t.spotifyId)}', '${escapeJs(t.title)}', '${escapeJs(artist)}')"
|
||||
style="margin-right:4px;font-size:0.75rem;padding:4px 8px;background:var(--success);border-color:var(--success);">Map to Local</button>
|
||||
<button onclick="openMapToExternal('${escapeJs(t.playlist)}', '${escapeJs(t.spotifyId)}', '${escapeJs(t.title)}', '${escapeJs(t.artist)}')"
|
||||
<button onclick="openMapToExternal('${escapeJs(t.playlist)}', '${escapeJs(t.spotifyId)}', '${escapeJs(t.title)}', '${escapeJs(artist)}')"
|
||||
style="font-size:0.75rem;padding:4px 8px;background:var(--warning);border-color:var(--warning);">Map to External</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1687,6 +1812,10 @@
|
||||
document.getElementById('config-jellyfin-user-id').textContent = data.jellyfin.userId || '(not set)';
|
||||
document.getElementById('config-jellyfin-library-id').textContent = data.jellyfin.libraryId || '-';
|
||||
|
||||
// Library settings
|
||||
document.getElementById('config-download-path').textContent = data.library?.downloadPath || './downloads';
|
||||
document.getElementById('config-kept-path').textContent = data.library?.keptPath || '/app/kept';
|
||||
|
||||
// Sync settings
|
||||
const syncHour = data.spotifyImport.syncStartHour;
|
||||
const syncMin = data.spotifyImport.syncStartMinute;
|
||||
@@ -1894,6 +2023,9 @@
|
||||
if (!confirm(`Clear cache and rebuild for "${name}"?\n\nThis will:\n• Clear Redis cache\n• Delete file caches\n• Rebuild with latest Spotify IDs\n\nThis may take a minute.`)) return;
|
||||
|
||||
try {
|
||||
// Show warning banner
|
||||
document.getElementById('matching-warning-banner').style.display = 'block';
|
||||
|
||||
showToast(`Clearing cache for ${name}...`, 'info');
|
||||
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(name)}/clear-cache`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
@@ -1901,17 +2033,26 @@
|
||||
if (res.ok) {
|
||||
showToast(`✓ ${data.message} (Cleared ${data.clearedKeys} cache keys, ${data.clearedFiles} files)`, 'success', 5000);
|
||||
// Refresh the playlists table after a delay to show updated counts
|
||||
setTimeout(fetchPlaylists, 3000);
|
||||
setTimeout(() => {
|
||||
fetchPlaylists();
|
||||
// Hide warning banner after refresh
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}, 3000);
|
||||
} else {
|
||||
showToast(data.error || 'Failed to clear cache', 'error');
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to clear cache', 'error');
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function matchPlaylistTracks(name) {
|
||||
try {
|
||||
// Show warning banner
|
||||
document.getElementById('matching-warning-banner').style.display = 'block';
|
||||
|
||||
showToast(`Matching tracks for ${name}...`, 'success');
|
||||
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(name)}/match`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
@@ -1919,12 +2060,18 @@
|
||||
if (res.ok) {
|
||||
showToast(`✓ ${data.message}`, 'success');
|
||||
// Refresh the playlists table after a delay to show updated counts
|
||||
setTimeout(fetchPlaylists, 2000);
|
||||
setTimeout(() => {
|
||||
fetchPlaylists();
|
||||
// Hide warning banner after refresh
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}, 2000);
|
||||
} else {
|
||||
showToast(data.error || 'Failed to match tracks', 'error');
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to match tracks', 'error');
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1932,6 +2079,9 @@
|
||||
if (!confirm('Match tracks for ALL playlists? This may take a few minutes.')) return;
|
||||
|
||||
try {
|
||||
// Show warning banner
|
||||
document.getElementById('matching-warning-banner').style.display = 'block';
|
||||
|
||||
showToast('Matching tracks for all playlists...', 'success');
|
||||
const res = await fetch('/api/admin/playlists/match-all', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
@@ -1939,32 +2089,68 @@
|
||||
if (res.ok) {
|
||||
showToast(`✓ ${data.message}`, 'success');
|
||||
// Refresh the playlists table after a delay to show updated counts
|
||||
setTimeout(fetchPlaylists, 2000);
|
||||
setTimeout(() => {
|
||||
fetchPlaylists();
|
||||
// Hide warning banner after refresh
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}, 2000);
|
||||
} else {
|
||||
showToast(data.error || 'Failed to match tracks', 'error');
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to match tracks', 'error');
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function prefetchLyrics(name) {
|
||||
async function refreshAndMatchAll() {
|
||||
if (!confirm('Clear caches, refresh from Spotify, and match all tracks?\n\nThis will:\n• Clear all playlist caches\n• Fetch fresh data from Spotify\n• Match all tracks against local library and external providers\n\nThis may take several minutes.')) return;
|
||||
|
||||
try {
|
||||
showToast(`Prefetching lyrics for ${name}...`, 'info', 5000);
|
||||
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(name)}/prefetch-lyrics`, { method: 'POST' });
|
||||
// Show warning banner
|
||||
document.getElementById('matching-warning-banner').style.display = 'block';
|
||||
|
||||
showToast('Starting full refresh and match...', 'info', 3000);
|
||||
|
||||
// Step 1: Clear all caches
|
||||
showToast('Step 1/3: Clearing caches...', 'info', 2000);
|
||||
await fetch('/api/admin/cache/clear', { method: 'POST' });
|
||||
|
||||
// Wait for cache to be fully cleared
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// Step 2: Refresh playlists from Spotify
|
||||
showToast('Step 2/3: Fetching from Spotify...', 'info', 2000);
|
||||
await fetch('/api/admin/playlists/refresh', { method: 'POST' });
|
||||
|
||||
// Wait for Spotify fetch to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
|
||||
// Step 3: Match all tracks
|
||||
showToast('Step 3/3: Matching all tracks (this may take several minutes)...', 'info', 3000);
|
||||
const res = await fetch('/api/admin/playlists/match-all', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
const summary = `Fetched: ${data.fetched}, Cached: ${data.cached}, Missing: ${data.missing}`;
|
||||
showToast(`✓ Lyrics prefetch complete for ${name}. ${summary}`, 'success', 8000);
|
||||
showToast(`✓ Full refresh and match complete!`, 'success', 5000);
|
||||
// Refresh the playlists table after a delay
|
||||
setTimeout(() => {
|
||||
fetchPlaylists();
|
||||
// Hide warning banner after refresh
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}, 3000);
|
||||
} else {
|
||||
showToast(data.error || 'Failed to prefetch lyrics', 'error');
|
||||
showToast(data.error || 'Failed to match tracks', 'error');
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to prefetch lyrics', 'error');
|
||||
showToast('Failed to complete refresh and match', 'error');
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function searchProvider(query, provider) {
|
||||
// Use SquidWTF HiFi API with round-robin base URLs for all searches
|
||||
// Get a random base URL from the backend
|
||||
@@ -2758,6 +2944,7 @@
|
||||
fetchJellyfinUsers();
|
||||
fetchJellyfinPlaylists();
|
||||
fetchConfig();
|
||||
fetchEndpointUsage();
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
setInterval(() => {
|
||||
@@ -2766,7 +2953,102 @@
|
||||
fetchTrackMappings();
|
||||
fetchMissingTracks();
|
||||
fetchDownloads();
|
||||
|
||||
// Refresh endpoint usage if on that tab
|
||||
const endpointsTab = document.getElementById('tab-endpoints');
|
||||
if (endpointsTab && endpointsTab.classList.contains('active')) {
|
||||
fetchEndpointUsage();
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
// Endpoint Usage Functions
|
||||
async function fetchEndpointUsage() {
|
||||
try {
|
||||
const topSelect = document.getElementById('endpoints-top-select');
|
||||
const top = topSelect ? topSelect.value : 50;
|
||||
|
||||
const res = await fetch(`/api/admin/debug/endpoint-usage?top=${top}`);
|
||||
const data = await res.json();
|
||||
|
||||
// Update summary stats
|
||||
document.getElementById('endpoints-total-requests').textContent = data.totalRequests?.toLocaleString() || '0';
|
||||
document.getElementById('endpoints-unique-count').textContent = data.totalEndpoints?.toLocaleString() || '0';
|
||||
|
||||
const mostCalled = data.endpoints && data.endpoints.length > 0
|
||||
? data.endpoints[0].endpoint
|
||||
: '-';
|
||||
document.getElementById('endpoints-most-called').textContent = mostCalled;
|
||||
|
||||
// Update table
|
||||
const tbody = document.getElementById('endpoints-table-body');
|
||||
|
||||
if (!data.endpoints || data.endpoints.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:var(--text-secondary);padding:40px;">No endpoint usage data available yet. Data will appear as clients make requests.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = data.endpoints.map((ep, index) => {
|
||||
const percentage = data.totalRequests > 0
|
||||
? ((ep.count / data.totalRequests) * 100).toFixed(1)
|
||||
: '0.0';
|
||||
|
||||
// Color code based on usage
|
||||
let countColor = 'var(--text-primary)';
|
||||
if (ep.count > 1000) countColor = 'var(--error)';
|
||||
else if (ep.count > 100) countColor = 'var(--warning)';
|
||||
else if (ep.count > 10) countColor = 'var(--accent)';
|
||||
|
||||
// Highlight common patterns
|
||||
let endpointDisplay = ep.endpoint;
|
||||
if (ep.endpoint.includes('/stream')) {
|
||||
endpointDisplay = `<span style="color:var(--success)">${escapeHtml(ep.endpoint)}</span>`;
|
||||
} else if (ep.endpoint.includes('/Playing')) {
|
||||
endpointDisplay = `<span style="color:var(--accent)">${escapeHtml(ep.endpoint)}</span>`;
|
||||
} else if (ep.endpoint.includes('/Search')) {
|
||||
endpointDisplay = `<span style="color:var(--warning)">${escapeHtml(ep.endpoint)}</span>`;
|
||||
} else {
|
||||
endpointDisplay = escapeHtml(ep.endpoint);
|
||||
}
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td style="color:var(--text-secondary);text-align:center;">${index + 1}</td>
|
||||
<td style="font-family:monospace;font-size:0.85rem;">${endpointDisplay}</td>
|
||||
<td style="text-align:right;font-weight:600;color:${countColor}">${ep.count.toLocaleString()}</td>
|
||||
<td style="text-align:right;color:var(--text-secondary)">${percentage}%</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch endpoint usage:', error);
|
||||
const tbody = document.getElementById('endpoints-table-body');
|
||||
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:var(--error);padding:40px;">Failed to load endpoint usage data</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
async function clearEndpointUsage() {
|
||||
if (!confirm('Are you sure you want to clear all endpoint usage data? This cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/debug/endpoint-usage', { method: 'DELETE' });
|
||||
const data = await res.json();
|
||||
|
||||
showToast(data.message || 'Endpoint usage data cleared', 'success');
|
||||
fetchEndpointUsage();
|
||||
} catch (error) {
|
||||
console.error('Failed to clear endpoint usage:', error);
|
||||
showToast('Failed to clear endpoint usage data', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user