mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-10 07:58:39 -05:00
Compare commits
5 Commits
4229924f61
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
f68706f300
|
|||
|
9f362b4920
|
|||
|
2b09484c0b
|
|||
|
fa9739bfaa
|
|||
|
0ba51e2b30
|
44
.env.example
44
.env.example
@@ -18,28 +18,30 @@ SUBSONIC_URL=http://localhost:4533
|
|||||||
# Server URL (required if using Jellyfin backend)
|
# Server URL (required if using Jellyfin backend)
|
||||||
JELLYFIN_URL=http://localhost:8096
|
JELLYFIN_URL=http://localhost:8096
|
||||||
|
|
||||||
# API key for authentication (get from Jellyfin Dashboard > API Keys)
|
# API key for SERVER-SIDE operations only (get from Jellyfin Dashboard > API Keys)
|
||||||
|
# This is used by Allstarr to query Jellyfin's library on behalf of the server
|
||||||
|
# CLIENT authentication is handled transparently - clients authenticate directly with Jellyfin
|
||||||
JELLYFIN_API_KEY=
|
JELLYFIN_API_KEY=
|
||||||
|
|
||||||
# User ID (get from Jellyfin Dashboard > Users > click user > check URL)
|
# User ID for SERVER-SIDE library queries (get from Jellyfin Dashboard > Users > click user > check URL)
|
||||||
|
# This determines which user's library Allstarr queries when searching/browsing
|
||||||
JELLYFIN_USER_ID=
|
JELLYFIN_USER_ID=
|
||||||
|
|
||||||
# Music library ID (optional, auto-detected if not set)
|
# Music library ID (optional, auto-detected if not set)
|
||||||
|
# If you have multiple libraries, set this to filter to music only
|
||||||
JELLYFIN_LIBRARY_ID=
|
JELLYFIN_LIBRARY_ID=
|
||||||
|
|
||||||
# ===== MUSIC SOURCE SELECTION =====
|
# ===== MUSIC SOURCE SELECTION =====
|
||||||
# Music service to use: SquidWTF, Deezer, or Qobuz (default: SquidWTF)
|
# Music service to use: SquidWTF, Deezer, or Qobuz (default: SquidWTF)
|
||||||
MUSIC_SERVICE=SquidWTF
|
MUSIC_SERVICE=SquidWTF
|
||||||
|
|
||||||
# Path where downloaded songs will be stored on the host (only applies if STORAGE_MODE=Permanent)
|
# Base directory for all downloads (default: ./downloads)
|
||||||
|
# This creates three subdirectories:
|
||||||
|
# - downloads/permanent/ - Permanently downloaded tracks (STORAGE_MODE=Permanent)
|
||||||
|
# - downloads/cache/ - Temporarily cached tracks (STORAGE_MODE=Cache)
|
||||||
|
# - downloads/kept/ - Favorited external tracks (always permanent)
|
||||||
DOWNLOAD_PATH=./downloads
|
DOWNLOAD_PATH=./downloads
|
||||||
|
|
||||||
# Path where favorited external tracks are permanently kept
|
|
||||||
KEPT_PATH=./kept
|
|
||||||
|
|
||||||
# Path for cache files (Spotify missing tracks, etc.)
|
|
||||||
CACHE_PATH=./cache
|
|
||||||
|
|
||||||
# ===== SQUIDWTF CONFIGURATION =====
|
# ===== SQUIDWTF CONFIGURATION =====
|
||||||
# Different quality options for SquidWTF. Only FLAC supported right now
|
# Different quality options for SquidWTF. Only FLAC supported right now
|
||||||
SQUIDWTF_QUALITY=FLAC
|
SQUIDWTF_QUALITY=FLAC
|
||||||
@@ -108,23 +110,17 @@ CACHE_DURATION_HOURS=1
|
|||||||
|
|
||||||
# ===== SPOTIFY PLAYLIST INJECTION (JELLYFIN ONLY) =====
|
# ===== SPOTIFY PLAYLIST INJECTION (JELLYFIN ONLY) =====
|
||||||
# REQUIRES: Jellyfin Spotify Import Plugin (https://github.com/Viperinius/jellyfin-plugin-spotify-import)
|
# REQUIRES: Jellyfin Spotify Import Plugin (https://github.com/Viperinius/jellyfin-plugin-spotify-import)
|
||||||
# This feature intercepts Spotify Import plugin playlists (Release Radar, Discover Weekly) and fills them
|
# This feature intercepts Spotify Import plugin playlists and fills them with tracks from external providers
|
||||||
# with tracks auto-matched from external providers (SquidWTF, Deezer, Qobuz)
|
|
||||||
# Uses JELLYFIN_URL and JELLYFIN_API_KEY configured above (no separate credentials needed)
|
# Uses JELLYFIN_URL and JELLYFIN_API_KEY configured above (no separate credentials needed)
|
||||||
|
|
||||||
# Enable Spotify playlist injection (optional, default: false)
|
# Enable Spotify playlist injection (optional, default: false)
|
||||||
SPOTIFY_IMPORT_ENABLED=false
|
SPOTIFY_IMPORT_ENABLED=false
|
||||||
|
|
||||||
# Sync schedule: When does the Spotify Import plugin run?
|
# Matching interval: How often to run track matching (in hours)
|
||||||
# Set these to match your plugin's sync schedule in Jellyfin
|
# Spotify playlists like Discover Weekly update once per week, Release Radar updates weekly
|
||||||
# Example: If plugin runs daily at 4:15 PM, set HOUR=16 and MINUTE=15
|
# Set to 0 to only run once on startup (manual trigger via admin UI still works)
|
||||||
SPOTIFY_IMPORT_SYNC_START_HOUR=16
|
# Default: 24 hours
|
||||||
SPOTIFY_IMPORT_SYNC_START_MINUTE=15
|
SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS=24
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
# Playlists configuration (JSON ARRAY FORMAT - managed by web UI)
|
# Playlists configuration (JSON ARRAY FORMAT - managed by web UI)
|
||||||
# Format: [["PlaylistName","SpotifyPlaylistId","first|last"],...]
|
# Format: [["PlaylistName","SpotifyPlaylistId","first|last"],...]
|
||||||
@@ -180,3 +176,9 @@ SPOTIFY_API_RATE_LIMIT_DELAY_MS=100
|
|||||||
# Prefer ISRC matching over fuzzy title/artist matching (default: true)
|
# Prefer ISRC matching over fuzzy title/artist matching (default: true)
|
||||||
# ISRC provides exact track identification across different streaming services
|
# ISRC provides exact track identification across different streaming services
|
||||||
SPOTIFY_API_PREFER_ISRC_MATCHING=true
|
SPOTIFY_API_PREFER_ISRC_MATCHING=true
|
||||||
|
|
||||||
|
# Spotify Lyrics API URL (default: http://spotify-lyrics:8080)
|
||||||
|
# Uses the spotify-lyrics-api sidecar service for fetching synchronized lyrics
|
||||||
|
# This service is automatically started in docker-compose
|
||||||
|
# Leave as default unless running a custom deployment
|
||||||
|
SPOTIFY_LYRICS_API_URL=http://spotify-lyrics:8080
|
||||||
|
|||||||
18
.gitignore
vendored
18
.gitignore
vendored
@@ -84,12 +84,26 @@ cache/
|
|||||||
redis-data/
|
redis-data/
|
||||||
|
|
||||||
# API keys and specs (ignore markdown docs, keep OpenAPI spec)
|
# API keys and specs (ignore markdown docs, keep OpenAPI spec)
|
||||||
|
apis/steering/
|
||||||
|
apis/api-calls/*.json
|
||||||
|
!apis/api-calls/jellyfin-openapi-stable.json
|
||||||
|
apis/temp.json
|
||||||
|
|
||||||
|
# Temporary documentation files
|
||||||
apis/*.md
|
apis/*.md
|
||||||
apis/*.json
|
|
||||||
!apis/jellyfin-openapi-stable.json
|
# Log files for debugging
|
||||||
|
apis/api-calls/*.log
|
||||||
|
|
||||||
|
# Endpoint usage tracking
|
||||||
|
apis/api-calls/endpoint-usage.json
|
||||||
|
/app/cache/endpoint-usage/
|
||||||
|
|
||||||
# Original source code for reference
|
# Original source code for reference
|
||||||
originals/
|
originals/
|
||||||
|
|
||||||
# Sample missing playlists for Spotify integration testing
|
# Sample missing playlists for Spotify integration testing
|
||||||
sampleMissingPlaylists/
|
sampleMissingPlaylists/
|
||||||
|
|
||||||
|
# Migration guide (local only)
|
||||||
|
MIGRATION.md
|
||||||
328
README.md
328
README.md
@@ -5,11 +5,7 @@
|
|||||||
[](https://github.com/SoPat712/allstarr/pkgs/container/allstarr)
|
[](https://github.com/SoPat712/allstarr/pkgs/container/allstarr)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
|
|
||||||
A media server proxy that integrates music streaming providers with your local library. Works with **Jellyfin** and **Subsonic-compatible** servers (Navidrome). When a song isn't in your local library, it gets fetched from your configured provider, downloaded, and served to your client. The downloaded song then lives in your library for next time.
|
A media server proxy that integrates music streaming providers with your local library. Works with **Jellyfin** and **Subsonic-compatible** servers. When a song isn't in your local library, it gets fetched from your configured provider, downloaded, and served to your client. The downloaded song then lives in your library for next time.
|
||||||
|
|
||||||
**THIS IS UNDER ACTIVE DEVELOPMENT**
|
|
||||||
|
|
||||||
Please report all bugs as soon as possible, as the Jellyfin addition is entirely a test at this point
|
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -40,15 +36,15 @@ The proxy will be available at `http://localhost:5274`.
|
|||||||
|
|
||||||
## Web Dashboard
|
## Web Dashboard
|
||||||
|
|
||||||
Allstarr includes a web-based dashboard for easy configuration and playlist management, accessible at `http://localhost:5275` (internal port, not exposed through reverse proxy).
|
Allstarr includes a web UI for easy configuration and playlist management, accessible at `http://localhost:5275`
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
- **Real-time Status**: Monitor Spotify authentication, cookie age, and playlist sync status
|
- **Playlist Management**: Link Jellyfin playlists to Spotify playlists with just a few clicks
|
||||||
- **Playlist Management**: Link Jellyfin playlists to Spotify playlists with a few clicks
|
- **Provider Matching**: It should fill in the gaps of your Jellyfin library with tracks from your selected provider
|
||||||
- **Configuration Editor**: Update settings without manually editing .env files
|
- **WebUI**: Update settings without manually editing .env files
|
||||||
- **Track Viewer**: Browse tracks in your configured playlists
|
- **Music**: Using multiple sources for music (optimized for SquidWTF right now, though)
|
||||||
- **Cache Management**: Clear cached data and restart the container
|
- **Lyrics**: Using multiple sources for lyrics, first Jellyfin Lyrics, then Spotify Lyrics, then LrcLib as a last resort
|
||||||
|
|
||||||
### Quick Setup with Web UI
|
### Quick Setup with Web UI
|
||||||
|
|
||||||
@@ -65,18 +61,20 @@ Allstarr includes a web-based dashboard for easy configuration and playlist mana
|
|||||||
- `37i9dQZF1DXcBWIGoYBM5M` (just the ID)
|
- `37i9dQZF1DXcBWIGoYBM5M` (just the ID)
|
||||||
- `spotify:playlist:37i9dQZF1DXcBWIGoYBM5M` (Spotify URI)
|
- `spotify:playlist:37i9dQZF1DXcBWIGoYBM5M` (Spotify URI)
|
||||||
- `https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M` (full URL)
|
- `https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M` (full URL)
|
||||||
4. **Restart** to apply changes (button in Configuration tab)
|
4. **Restart** to apply changes (should be a banner)
|
||||||
|
|
||||||
### Why Two Playlist Tabs?
|
Then, proceeed to **Active Playlists**, which shows you which Spotify playlists are currently being monitored and filled with tracks, and lets you do a bunch of useful operations on them.
|
||||||
|
|
||||||
- **Link Playlists**: Shows all Jellyfin playlists and lets you connect them to Spotify
|
|
||||||
- **Active Playlists**: Shows which Spotify playlists are currently being monitored and filled with tracks
|
|
||||||
|
|
||||||
### Configuration Persistence
|
### Configuration Persistence
|
||||||
|
|
||||||
The web UI updates your `.env` file directly. Changes persist across container restarts, but require a restart to take effect. In development mode, the `.env` file is in your project root. In Docker, it's at `/app/.env`.
|
The web UI updates your `.env` file directly. Changes persist across container restarts, but require a restart to take effect. In development mode, the `.env` file is in your project root. In Docker, it's at `/app/.env`.
|
||||||
|
|
||||||
**Recommended workflow**: Use the `sp_dc` cookie method (simpler and more reliable than the Jellyfin Spotify Import plugin).
|
There's an environment variable to modify this.
|
||||||
|
|
||||||
|
|
||||||
|
**Recommended workflow**: Use the `sp_dc` cookie method alongside the [Spotify Import Plugin](https://github.com/Viperinius/jellyfin-plugin-spotify-import?tab=readme-ov-file).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Nginx Proxy Setup (Required)
|
### Nginx Proxy Setup (Required)
|
||||||
|
|
||||||
@@ -142,6 +140,7 @@ This project brings together all the music streaming providers into one unified
|
|||||||
|
|
||||||
- [Feishin](https://github.com/jeffvli/feishin) (Mac/Windows/Linux)
|
- [Feishin](https://github.com/jeffvli/feishin) (Mac/Windows/Linux)
|
||||||
- [Musiver](https://music.aqzscn.cn/en/) (Android/IOS/Windows/Android)
|
- [Musiver](https://music.aqzscn.cn/en/) (Android/IOS/Windows/Android)
|
||||||
|
- [Finamp](https://github.com/jmshrv/finamp) ()
|
||||||
|
|
||||||
_Working on getting more currently_
|
_Working on getting more currently_
|
||||||
|
|
||||||
@@ -335,7 +334,7 @@ Subsonic__EnableExternalPlaylists=false
|
|||||||
|
|
||||||
### Spotify Playlist Injection (Jellyfin Only)
|
### Spotify Playlist Injection (Jellyfin Only)
|
||||||
|
|
||||||
Allstarr can automatically fill your Spotify playlists (like Release Radar and Discover Weekly) with tracks from your configured streaming provider (SquidWTF, Deezer, or Qobuz). This feature works by intercepting playlists created by the Jellyfin Spotify Import plugin and matching missing tracks with your streaming service.
|
Allstarr automatically fills your Spotify playlists (like Release Radar and Discover Weekly) with tracks from your configured streaming provider (SquidWTF, Deezer, or Qobuz). This works by intercepting playlists created by the Jellyfin Spotify Import plugin and matching missing tracks with your streaming service.
|
||||||
|
|
||||||
#### Prerequisites
|
#### Prerequisites
|
||||||
|
|
||||||
@@ -349,133 +348,112 @@ Allstarr can automatically fill your Spotify playlists (like Release Radar and D
|
|||||||
- Go to Jellyfin Dashboard → Plugins → Spotify Import
|
- Go to Jellyfin Dashboard → Plugins → Spotify Import
|
||||||
- Connect your Spotify account
|
- Connect your Spotify account
|
||||||
- Select which playlists to sync (e.g., Release Radar, Discover Weekly)
|
- Select which playlists to sync (e.g., Release Radar, Discover Weekly)
|
||||||
- Set a daily sync schedule (e.g., 4:15 PM daily)
|
- Set a sync schedule (the plugin will create playlists in Jellyfin)
|
||||||
- The plugin will create playlists in Jellyfin and generate "missing tracks" files for songs not in your library
|
|
||||||
|
|
||||||
3. **Configure Allstarr**
|
3. **Configure Allstarr**
|
||||||
- Allstarr needs to know when the plugin runs and which playlists to intercept
|
- Enable Spotify Import in Allstarr (see configuration below)
|
||||||
- Uses your existing `JELLYFIN_URL` and `JELLYFIN_API_KEY` settings (no additional credentials needed)
|
- Link your Jellyfin playlists to Spotify playlists via the Web UI
|
||||||
|
- Uses your existing `JELLYFIN_URL` and `JELLYFIN_API_KEY` settings
|
||||||
|
|
||||||
#### Configuration
|
#### Configuration
|
||||||
|
|
||||||
| Setting | Description |
|
| Setting | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `SpotifyImport:Enabled` | Enable Spotify playlist injection (default: `false`) |
|
| `SpotifyImport:Enabled` | Enable Spotify playlist injection (default: `false`) |
|
||||||
| `SpotifyImport:SyncStartHour` | Hour when the Spotify Import plugin runs (24-hour format, 0-23) |
|
| `SpotifyImport:MatchingIntervalHours` | How often to run track matching in hours (default: 24, set to 0 for startup only) |
|
||||||
| `SpotifyImport:SyncStartMinute` | Minute when the plugin runs (0-59) |
|
| `SpotifyImport:Playlists` | JSON array of playlists (managed via Web UI) |
|
||||||
| `SpotifyImport:SyncWindowHours` | Hours to search for missing tracks files after sync time (default: 2) |
|
|
||||||
| `SpotifyImport:PlaylistIds` | Comma-separated Jellyfin playlist IDs to intercept |
|
|
||||||
| `SpotifyImport:PlaylistNames` | Comma-separated playlist names (must match order of IDs) |
|
|
||||||
|
|
||||||
**Environment variables example:**
|
**Environment variables example:**
|
||||||
```bash
|
```bash
|
||||||
# Enable the feature
|
# Enable the feature
|
||||||
SPOTIFY_IMPORT_ENABLED=true
|
SPOTIFY_IMPORT_ENABLED=true
|
||||||
|
|
||||||
# Sync window settings (optional - used to prevent fetching too frequently)
|
# Matching interval (24 hours = once per day)
|
||||||
# The fetcher searches backwards from current time for the last 48 hours
|
SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS=24
|
||||||
SPOTIFY_IMPORT_SYNC_START_HOUR=16
|
|
||||||
SPOTIFY_IMPORT_SYNC_START_MINUTE=15
|
|
||||||
SPOTIFY_IMPORT_SYNC_WINDOW_HOURS=2
|
|
||||||
|
|
||||||
# Get playlist IDs from Jellyfin URLs: https://jellyfin.example.com/web/#/details?id=PLAYLIST_ID
|
# Playlists (use Web UI to manage instead of editing manually)
|
||||||
SPOTIFY_IMPORT_PLAYLIST_IDS=ba50e26c867ec9d57ab2f7bf24cfd6b0,4383a46d8bcac3be2ef9385053ea18df
|
SPOTIFY_IMPORT_PLAYLISTS=[["Discover Weekly","37i9dQZEVXcV6s7Dm7RXsU","first"],["Release Radar","37i9dQZEVXbng2vDHnfQlC","first"]]
|
||||||
|
|
||||||
# Names must match exactly as they appear in Jellyfin (used to find missing tracks files)
|
|
||||||
SPOTIFY_IMPORT_PLAYLIST_NAMES=Release Radar,Discover Weekly
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### How It Works
|
#### How It Works
|
||||||
|
|
||||||
1. **Spotify Import Plugin Runs** (e.g., daily at 4:15 PM)
|
1. **Spotify Import Plugin Runs**
|
||||||
- Plugin fetches your Spotify playlists
|
- Plugin fetches your Spotify playlists
|
||||||
- Creates/updates playlists in Jellyfin with tracks already in your library
|
- Creates/updates playlists in Jellyfin with tracks already in your library
|
||||||
- Generates "missing tracks" JSON files for songs not found locally
|
- Generates "missing tracks" JSON files for songs not found locally
|
||||||
- Files are named like: `Release Radar_missing_2026-02-01_16-15.json`
|
|
||||||
|
|
||||||
2. **Allstarr Fetches Missing Tracks** (within sync window)
|
2. **Allstarr Matches Tracks** (on startup + every 24 hours by default)
|
||||||
- Searches for missing tracks files from the Jellyfin plugin
|
- Reads missing tracks files from the Jellyfin plugin
|
||||||
- Searches **+24 hours forward first** (newest files), then **-48 hours backward** if not found
|
|
||||||
- This efficiently finds the most recent file regardless of timezone differences
|
|
||||||
- Example: Server time 12 PM EST, file timestamped 9 PM UTC (same day) → Found in forward search
|
|
||||||
- Caches the list of missing tracks in Redis + file cache
|
|
||||||
- Runs automatically on startup (if needed) and every 5 minutes during the sync window
|
|
||||||
|
|
||||||
3. **Allstarr Matches Tracks** (2 minutes after startup, then every 30 minutes)
|
|
||||||
- For each missing track, searches your streaming provider (SquidWTF, Deezer, or Qobuz)
|
- For each missing track, searches your streaming provider (SquidWTF, Deezer, or Qobuz)
|
||||||
- Uses fuzzy matching to find the best match (title + artist similarity)
|
- Uses fuzzy matching to find the best match (title + artist similarity)
|
||||||
- Rate-limited to avoid overwhelming the service (150ms delay between searches)
|
- Rate-limited to avoid overwhelming the service (150ms delay between searches)
|
||||||
- Caches matched results for 1 hour
|
- Pre-builds playlist cache for instant loading
|
||||||
|
|
||||||
4. **You Open the Playlist in Jellyfin**
|
3. **You Open the Playlist in Jellyfin**
|
||||||
- Allstarr intercepts the request
|
- Allstarr intercepts the request
|
||||||
- Returns a merged list: local tracks + matched streaming tracks
|
- Returns a merged list: local tracks + matched streaming tracks
|
||||||
- Loads instantly from cache (no searching needed!)
|
- Loads instantly from cache!
|
||||||
|
|
||||||
5. **You Play a Track**
|
4. **You Play a Track**
|
||||||
- If it's a local track, streams from Jellyfin normally
|
- Local tracks stream from Jellyfin normally
|
||||||
- If it's a matched track, downloads from streaming provider on-demand
|
- Matched tracks download from streaming provider on-demand
|
||||||
- Downloaded tracks are saved to your library for future use
|
- Downloaded tracks are saved to your library for future use
|
||||||
|
|
||||||
#### Manual Triggers
|
#### Manual API Triggers
|
||||||
|
|
||||||
You can manually trigger syncing and matching via API:
|
You can manually trigger operations via the admin API:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Get API key from your .env file
|
||||||
|
API_KEY="your-api-key-here"
|
||||||
|
|
||||||
# Fetch missing tracks from Jellyfin plugin
|
# Fetch missing tracks from Jellyfin plugin
|
||||||
curl "https://your-jellyfin-proxy.com/spotify/sync?api_key=YOUR_API_KEY"
|
curl "http://localhost:5274/spotify/sync?api_key=$API_KEY"
|
||||||
|
|
||||||
# Trigger track matching (searches streaming provider)
|
# Trigger track matching (searches streaming provider)
|
||||||
curl "https://your-jellyfin-proxy.com/spotify/match?api_key=YOUR_API_KEY"
|
curl "http://localhost:5274/spotify/match?api_key=$API_KEY"
|
||||||
|
|
||||||
# Clear cache to force re-matching
|
# Match all playlists (refresh all matches)
|
||||||
curl "https://your-jellyfin-proxy.com/spotify/clear-cache?api_key=YOUR_API_KEY"
|
curl "http://localhost:5274/spotify/match-all?api_key=$API_KEY"
|
||||||
|
|
||||||
|
# Clear cache and rebuild
|
||||||
|
curl "http://localhost:5274/spotify/clear-cache?api_key=$API_KEY"
|
||||||
|
|
||||||
|
# Refresh specific playlist
|
||||||
|
curl "http://localhost:5274/spotify/refresh-playlist?playlistId=PLAYLIST_ID&api_key=$API_KEY"
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Startup Behavior
|
#### Web UI Management
|
||||||
|
|
||||||
When Allstarr starts with Spotify Import enabled:
|
The easiest way to manage Spotify playlists is through the Web UI at `http://localhost:5275`:
|
||||||
|
|
||||||
**Smart Cache Check:**
|
1. **Link Playlists Tab**: Link Jellyfin playlists to Spotify playlists
|
||||||
- Checks if today's sync window has passed (e.g., if sync is at 4 PM + 2 hour window = 6 PM)
|
2. **Active Playlists Tab**: View status, trigger matching, and manage playlists
|
||||||
- If before 6 PM and yesterday's cache exists → **Skips fetch** (cache is still current)
|
3. **Configuration Tab**: Enable/disable Spotify Import and adjust settings
|
||||||
- If after 6 PM or no cache exists → **Fetches missing tracks** from Jellyfin plugin
|
|
||||||
|
|
||||||
**Track Matching:**
|
|
||||||
- **T+2min**: Matches tracks with streaming provider (with rate limiting)
|
|
||||||
- Only matches playlists that don't already have cached matches
|
|
||||||
- **Result**: Playlists load instantly when you open them!
|
|
||||||
|
|
||||||
**Example Timeline:**
|
|
||||||
- Plugin runs daily at 4:15 PM, creates files at ~4:16 PM
|
|
||||||
- You restart Allstarr at 12:00 PM (noon) the next day
|
|
||||||
- Startup check: "Today's sync window ends at 6 PM, and I have yesterday's 4:16 PM file"
|
|
||||||
- **Decision**: Skip fetch, use existing cache
|
|
||||||
- At 6:01 PM: Next scheduled check will search for new files
|
|
||||||
|
|
||||||
#### Troubleshooting
|
#### Troubleshooting
|
||||||
|
|
||||||
**Playlists are empty:**
|
**Playlists are empty:**
|
||||||
- Check that the Spotify Import plugin is running and creating playlists
|
- Check that the Spotify Import plugin is running and creating playlists
|
||||||
- Verify `SPOTIFY_IMPORT_PLAYLIST_IDS` match your Jellyfin playlist IDs
|
- Verify playlists are linked in the Web UI
|
||||||
- Check logs: `docker-compose logs -f allstarr | grep -i spotify`
|
- Check logs: `docker-compose logs -f allstarr | grep -i spotify`
|
||||||
|
|
||||||
**Tracks aren't matching:**
|
**Tracks aren't matching:**
|
||||||
- Ensure your streaming provider is configured (`MUSIC_SERVICE`, credentials)
|
- Ensure your streaming provider is configured (`MUSIC_SERVICE`, credentials)
|
||||||
- Check that playlist names in `SPOTIFY_IMPORT_PLAYLIST_NAMES` match exactly
|
- Manually trigger matching via Web UI or API
|
||||||
- Manually trigger matching: `curl "https://your-proxy.com/spotify/match?api_key=KEY"`
|
- Check that the Jellyfin plugin generated missing tracks files
|
||||||
|
|
||||||
**Sync timing issues:**
|
**Performance:**
|
||||||
- Set `SPOTIFY_IMPORT_SYNC_START_HOUR/MINUTE` to match your plugin schedule
|
- Matching runs in background with rate limiting (150ms between searches)
|
||||||
- Increase `SPOTIFY_IMPORT_SYNC_WINDOW_HOURS` if files aren't being found
|
- First match may take a few minutes for large playlists
|
||||||
- Check Jellyfin plugin logs to confirm when it runs
|
- Subsequent loads are instant (served from cache)
|
||||||
|
|
||||||
#### Notes
|
#### Notes
|
||||||
|
|
||||||
- This feature uses your existing `JELLYFIN_URL` and `JELLYFIN_API_KEY` settings
|
- Uses your existing `JELLYFIN_URL` and `JELLYFIN_API_KEY` settings
|
||||||
- Matched tracks are cached for 1 hour to avoid repeated searches
|
- Matched tracks cached for fast loading
|
||||||
- Missing tracks cache persists across restarts (stored in Redis + file cache)
|
- Missing tracks cache persists across restarts (Redis + file cache)
|
||||||
- Rate limiting prevents overwhelming your streaming provider (150ms between searches)
|
- Rate limiting prevents overwhelming your streaming provider
|
||||||
- Only works with Jellyfin backend (not Subsonic/Navidrome)
|
- Only works with Jellyfin backend (not Subsonic/Navidrome)
|
||||||
|
|
||||||
### Getting Credentials
|
### Getting Credentials
|
||||||
@@ -589,9 +567,46 @@ If you prefer to run Allstarr without Docker:
|
|||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
|
### Jellyfin Backend (Primary Focus)
|
||||||
|
|
||||||
|
The proxy provides comprehensive Jellyfin API support with streaming provider integration:
|
||||||
|
|
||||||
|
| Endpoint | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `GET /Items` | Search and browse library items (local + streaming providers) |
|
||||||
|
| `GET /Artists` | Browse artists with merged results from local + streaming |
|
||||||
|
| `GET /Artists/AlbumArtists` | Album artists with streaming provider results |
|
||||||
|
| `GET /Users/{userId}/Items` | User library items with external content |
|
||||||
|
| `GET /Audio/{id}/stream` | Stream audio, downloading from provider on-demand |
|
||||||
|
| `GET /Audio/{id}/Lyrics` | Lyrics from Jellyfin, Spotify, or LRCLib |
|
||||||
|
| `GET /Items/{id}/Images/{type}` | Proxy cover art for external content |
|
||||||
|
| `GET /Playlists/{id}/Items` | Playlist items (Spotify Import integration) |
|
||||||
|
| `POST /UserFavoriteItems/{id}` | Favorite items; copies external tracks to kept folder |
|
||||||
|
| `DELETE /UserFavoriteItems/{id}` | Unfavorite items |
|
||||||
|
| `POST /Sessions/Playing` | Playback reporting for external tracks |
|
||||||
|
| `POST /Sessions/Playing/Progress` | Playback progress tracking |
|
||||||
|
| `POST /Sessions/Playing/Stopped` | Playback stopped reporting |
|
||||||
|
| `WebSocket /socket` | Real-time session management and remote control |
|
||||||
|
|
||||||
|
**Admin API (Port 5275):**
|
||||||
|
| Endpoint | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `GET /api/config` | Get current configuration |
|
||||||
|
| `POST /api/config` | Update configuration |
|
||||||
|
| `GET /api/playlists` | List Spotify Import playlists |
|
||||||
|
| `POST /api/playlists/link` | Link Jellyfin playlist to Spotify |
|
||||||
|
| `DELETE /api/playlists/{id}` | Unlink playlist |
|
||||||
|
| `POST /spotify/sync` | Fetch missing tracks from Jellyfin plugin |
|
||||||
|
| `POST /spotify/match` | Trigger track matching |
|
||||||
|
| `POST /spotify/match-all` | Match all playlists |
|
||||||
|
| `POST /spotify/clear-cache` | Clear playlist cache |
|
||||||
|
| `POST /spotify/refresh-playlist` | Refresh specific playlist |
|
||||||
|
|
||||||
|
All other Jellyfin API endpoints are passed through unchanged.
|
||||||
|
|
||||||
### Subsonic Backend
|
### Subsonic Backend
|
||||||
|
|
||||||
The proxy implements the Subsonic API and adds transparent streaming provider integration:
|
The proxy implements the Subsonic API with streaming provider integration:
|
||||||
|
|
||||||
| Endpoint | Description |
|
| Endpoint | Description |
|
||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
@@ -605,20 +620,6 @@ The proxy implements the Subsonic API and adds transparent streaming provider in
|
|||||||
|
|
||||||
All other Subsonic API endpoints are passed through to Navidrome unchanged.
|
All other Subsonic API endpoints are passed through to Navidrome unchanged.
|
||||||
|
|
||||||
### Jellyfin Backend
|
|
||||||
|
|
||||||
The proxy implements a subset of the Jellyfin API:
|
|
||||||
|
|
||||||
| Endpoint | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| `GET /Items` | Search and browse library items |
|
|
||||||
| `GET /Artists` | Browse artists with streaming provider results |
|
|
||||||
| `GET /Audio/{id}/stream` | Stream audio, downloading from provider if needed |
|
|
||||||
| `GET /Items/{id}/Images/{type}` | Proxy cover art for external content |
|
|
||||||
| `POST /UserFavoriteItems/{id}` | Favorite items; triggers playlist download |
|
|
||||||
|
|
||||||
All other Jellyfin API endpoints are passed through unchanged.
|
|
||||||
|
|
||||||
## External ID Format
|
## External ID Format
|
||||||
|
|
||||||
External (streaming provider) content uses typed IDs:
|
External (streaming provider) content uses typed IDs:
|
||||||
@@ -633,25 +634,37 @@ Legacy format `ext-deezer-{id}` is also supported (assumes song type).
|
|||||||
|
|
||||||
## Download Folder Structure
|
## Download Folder Structure
|
||||||
|
|
||||||
Downloaded music is organized as:
|
All downloads are organized under a single base directory (default: `./downloads`):
|
||||||
|
|
||||||
```
|
```
|
||||||
downloads/
|
downloads/
|
||||||
├── Artist Name/
|
├── permanent/ # Permanent downloads (STORAGE_MODE=Permanent)
|
||||||
│ ├── Album Title/
|
│ ├── Artist Name/
|
||||||
│ │ ├── 01 - Track One.mp3
|
│ │ ├── Album Title/
|
||||||
│ │ ├── 02 - Track Two.mp3
|
│ │ │ ├── 01 - Track One.flac
|
||||||
|
│ │ │ ├── 02 - Track Two.flac
|
||||||
|
│ │ │ └── ...
|
||||||
|
│ │ └── Another Album/
|
||||||
│ │ └── ...
|
│ │ └── ...
|
||||||
│ └── Another Album/
|
│ └── playlists/
|
||||||
│ └── ...
|
│ ├── My Favorite Songs.m3u
|
||||||
├── Another Artist/
|
│ └── Chill Vibes.m3u
|
||||||
│ └── ...
|
├── cache/ # Temporary cache (STORAGE_MODE=Cache)
|
||||||
└── playlists/
|
│ └── Artist Name/
|
||||||
├── My Favorite Songs.m3u
|
│ └── Album Title/
|
||||||
├── Chill Vibes.m3u
|
│ └── Track.flac
|
||||||
└── ...
|
└── kept/ # Favorited external tracks (always permanent)
|
||||||
|
└── Artist Name/
|
||||||
|
└── Album Title/
|
||||||
|
└── Track.flac
|
||||||
```
|
```
|
||||||
|
|
||||||
Playlists are stored as M3U files with relative paths to downloaded tracks, making them portable and compatible with most music players.
|
**Storage modes:**
|
||||||
|
- **Permanent** (`downloads/permanent/`): Files saved permanently and registered in your media server
|
||||||
|
- **Cache** (`downloads/cache/`): Temporary files, auto-cleaned after `CACHE_DURATION_HOURS`
|
||||||
|
- **Kept** (`downloads/kept/`): External tracks you've favorited - always permanent, separate from cache
|
||||||
|
|
||||||
|
Playlists are stored as M3U files with relative paths, making them portable and compatible with most music players.
|
||||||
|
|
||||||
## Metadata Embedding
|
## Metadata Embedding
|
||||||
|
|
||||||
@@ -682,10 +695,17 @@ dotnet test
|
|||||||
```
|
```
|
||||||
allstarr/
|
allstarr/
|
||||||
├── Controllers/
|
├── Controllers/
|
||||||
│ ├── JellyfinController.cs # Jellyfin API controller (registered when Backend:Type=Jellyfin)
|
│ ├── AdminController.cs # Admin dashboard API
|
||||||
│ └── SubsonicController.cs # Subsonic API controller (registered when Backend:Type=Subsonic)
|
│ ├── JellyfinController.cs # Jellyfin API controller
|
||||||
|
│ └── SubsonicController.cs # Subsonic API controller
|
||||||
|
├── Filters/
|
||||||
|
│ ├── AdminPortFilter.cs # Admin port access control
|
||||||
|
│ ├── ApiKeyAuthFilter.cs # API key authentication
|
||||||
|
│ └── JellyfinAuthFilter.cs # Jellyfin authentication
|
||||||
├── Middleware/
|
├── Middleware/
|
||||||
│ └── GlobalExceptionHandler.cs # Global error handling
|
│ ├── AdminStaticFilesMiddleware.cs # Admin UI static file serving
|
||||||
|
│ ├── GlobalExceptionHandler.cs # Global error handling
|
||||||
|
│ └── WebSocketProxyMiddleware.cs # WebSocket proxying for Jellyfin
|
||||||
├── Models/
|
├── Models/
|
||||||
│ ├── Domain/ # Domain entities
|
│ ├── Domain/ # Domain entities
|
||||||
│ │ ├── Song.cs
|
│ │ ├── Song.cs
|
||||||
@@ -694,18 +714,39 @@ allstarr/
|
|||||||
│ ├── Settings/ # Configuration models
|
│ ├── Settings/ # Configuration models
|
||||||
│ │ ├── SubsonicSettings.cs
|
│ │ ├── SubsonicSettings.cs
|
||||||
│ │ ├── DeezerSettings.cs
|
│ │ ├── DeezerSettings.cs
|
||||||
│ │ └── QobuzSettings.cs
|
│ │ ├── QobuzSettings.cs
|
||||||
|
│ │ ├── SquidWTFSettings.cs
|
||||||
|
│ │ ├── SpotifyApiSettings.cs
|
||||||
|
│ │ ├── SpotifyImportSettings.cs
|
||||||
|
│ │ ├── MusicBrainzSettings.cs
|
||||||
|
│ │ └── RedisSettings.cs
|
||||||
│ ├── Download/ # Download-related models
|
│ ├── Download/ # Download-related models
|
||||||
│ │ ├── DownloadInfo.cs
|
│ │ ├── DownloadInfo.cs
|
||||||
│ │ └── DownloadStatus.cs
|
│ │ └── DownloadStatus.cs
|
||||||
|
│ ├── Lyrics/
|
||||||
|
│ │ └── LyricsInfo.cs
|
||||||
│ ├── Search/
|
│ ├── Search/
|
||||||
│ │ └── SearchResult.cs
|
│ │ └── SearchResult.cs
|
||||||
|
│ ├── Spotify/
|
||||||
|
│ │ ├── MissingTrack.cs
|
||||||
|
│ │ └── SpotifyPlaylistTrack.cs
|
||||||
│ └── Subsonic/
|
│ └── Subsonic/
|
||||||
|
│ ├── ExternalPlaylist.cs
|
||||||
│ └── ScanStatus.cs
|
│ └── ScanStatus.cs
|
||||||
├── Services/
|
├── Services/
|
||||||
│ ├── Common/ # Shared services
|
│ ├── Common/ # Shared services
|
||||||
│ │ ├── BaseDownloadService.cs # Template method base class
|
│ │ ├── BaseDownloadService.cs # Template method base class
|
||||||
|
│ │ ├── CacheCleanupService.cs # Cache cleanup background service
|
||||||
|
│ │ ├── CacheWarmingService.cs # Startup cache warming
|
||||||
|
│ │ ├── EndpointBenchmarkService.cs # Endpoint performance benchmarking
|
||||||
|
│ │ ├── FuzzyMatcher.cs # Fuzzy string matching
|
||||||
|
│ │ ├── GenreEnrichmentService.cs # MusicBrainz genre enrichment
|
||||||
|
│ │ ├── OdesliService.cs # Odesli/song.link conversion
|
||||||
|
│ │ ├── ParallelMetadataService.cs # Parallel metadata fetching
|
||||||
│ │ ├── PathHelper.cs # Path utilities
|
│ │ ├── PathHelper.cs # Path utilities
|
||||||
|
│ │ ├── PlaylistIdHelper.cs # Playlist ID helpers
|
||||||
|
│ │ ├── RedisCacheService.cs # Redis caching
|
||||||
|
│ │ ├── RoundRobinFallbackHelper.cs # Load balancing and failover
|
||||||
│ │ ├── Result.cs # Result<T> pattern
|
│ │ ├── Result.cs # Result<T> pattern
|
||||||
│ │ └── Error.cs # Error types
|
│ │ └── Error.cs # Error types
|
||||||
│ ├── Deezer/ # Deezer provider
|
│ ├── Deezer/ # Deezer provider
|
||||||
@@ -717,12 +758,35 @@ allstarr/
|
|||||||
│ │ ├── QobuzMetadataService.cs
|
│ │ ├── QobuzMetadataService.cs
|
||||||
│ │ ├── QobuzBundleService.cs
|
│ │ ├── QobuzBundleService.cs
|
||||||
│ │ └── QobuzStartupValidator.cs
|
│ │ └── QobuzStartupValidator.cs
|
||||||
|
│ ├── SquidWTF/ # SquidWTF provider
|
||||||
|
│ │ ├── SquidWTFDownloadService.cs
|
||||||
|
│ │ ├── SquidWTFMetadataService.cs
|
||||||
|
│ │ └── SquidWTFStartupValidator.cs
|
||||||
|
│ ├── Jellyfin/ # Jellyfin integration
|
||||||
|
│ │ ├── JellyfinModelMapper.cs # Model mapping
|
||||||
|
│ │ ├── JellyfinProxyService.cs # Request proxying
|
||||||
|
│ │ ├── JellyfinResponseBuilder.cs # Response building
|
||||||
|
│ │ ├── JellyfinSessionManager.cs # Session management
|
||||||
|
│ │ └── JellyfinStartupValidator.cs # Startup validation
|
||||||
|
│ ├── Lyrics/ # Lyrics services
|
||||||
|
│ │ ├── LrclibService.cs # LRCLIB lyrics
|
||||||
|
│ │ ├── LyricsPrefetchService.cs # Background lyrics prefetching
|
||||||
|
│ │ ├── LyricsStartupValidator.cs # Lyrics validation
|
||||||
|
│ │ └── SpotifyLyricsService.cs # Spotify lyrics
|
||||||
|
│ ├── MusicBrainz/
|
||||||
|
│ │ └── MusicBrainzService.cs # MusicBrainz metadata
|
||||||
|
│ ├── Spotify/ # Spotify integration
|
||||||
|
│ │ ├── SpotifyApiClient.cs # Spotify API client
|
||||||
|
│ │ ├── SpotifyMissingTracksFetcher.cs # Missing tracks fetcher
|
||||||
|
│ │ ├── SpotifyPlaylistFetcher.cs # Playlist fetcher
|
||||||
|
│ │ └── SpotifyTrackMatchingService.cs # Track matching
|
||||||
│ ├── Local/ # Local library
|
│ ├── Local/ # Local library
|
||||||
│ │ ├── ILocalLibraryService.cs
|
│ │ ├── ILocalLibraryService.cs
|
||||||
│ │ └── LocalLibraryService.cs
|
│ │ └── LocalLibraryService.cs
|
||||||
│ ├── Subsonic/ # Subsonic API logic
|
│ ├── Subsonic/ # Subsonic API logic
|
||||||
│ │ ├── SubsonicProxyService.cs # Request proxying
|
│ │ ├── PlaylistSyncService.cs # Playlist synchronization
|
||||||
│ │ ├── SubsonicModelMapper.cs # Model mapping
|
│ │ ├── SubsonicModelMapper.cs # Model mapping
|
||||||
|
│ │ ├── SubsonicProxyService.cs # Request proxying
|
||||||
│ │ ├── SubsonicRequestParser.cs # Request parsing
|
│ │ ├── SubsonicRequestParser.cs # Request parsing
|
||||||
│ │ └── SubsonicResponseBuilder.cs # Response building
|
│ │ └── SubsonicResponseBuilder.cs # Response building
|
||||||
│ ├── Validation/ # Startup validation
|
│ ├── Validation/ # Startup validation
|
||||||
@@ -734,13 +798,17 @@ allstarr/
|
|||||||
│ ├── IDownloadService.cs # Download interface
|
│ ├── IDownloadService.cs # Download interface
|
||||||
│ ├── IMusicMetadataService.cs # Metadata interface
|
│ ├── IMusicMetadataService.cs # Metadata interface
|
||||||
│ └── StartupValidationService.cs
|
│ └── StartupValidationService.cs
|
||||||
|
├── wwwroot/ # Admin UI static files
|
||||||
|
│ ├── index.html # Admin dashboard
|
||||||
|
│ └── placeholder.png # Placeholder image
|
||||||
├── Program.cs # Application entry point
|
├── Program.cs # Application entry point
|
||||||
└── appsettings.json # Configuration
|
└── appsettings.json # Configuration
|
||||||
|
|
||||||
allstarr.Tests/
|
allstarr.Tests/
|
||||||
├── DeezerDownloadServiceTests.cs # Deezer download tests
|
├── DeezerDownloadServiceTests.cs # Deezer download tests
|
||||||
├── DeezerMetadataServiceTests.cs # Deezer metadata tests
|
├── DeezerMetadataServiceTests.cs # Deezer metadata tests
|
||||||
├── QobuzDownloadServiceTests.cs # Qobuz download tests (127 tests)
|
├── JellyfinResponseStructureTests.cs # Jellyfin response tests
|
||||||
|
├── QobuzDownloadServiceTests.cs # Qobuz download tests
|
||||||
├── LocalLibraryServiceTests.cs # Local library tests
|
├── LocalLibraryServiceTests.cs # Local library tests
|
||||||
├── SubsonicModelMapperTests.cs # Model mapping tests
|
├── SubsonicModelMapperTests.cs # Model mapping tests
|
||||||
├── SubsonicProxyServiceTests.cs # Proxy service tests
|
├── SubsonicProxyServiceTests.cs # Proxy service tests
|
||||||
@@ -814,7 +882,7 @@ We welcome contributions! Here's how to get started:
|
|||||||
- Follow existing code patterns and conventions
|
- Follow existing code patterns and conventions
|
||||||
- Add tests for new features
|
- Add tests for new features
|
||||||
- Update documentation as needed
|
- Update documentation as needed
|
||||||
- Keep commits focused and atomic
|
- Keep commits feature focused
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
@@ -836,8 +904,14 @@ GPL-3.0
|
|||||||
|
|
||||||
## Acknowledgments
|
## Acknowledgments
|
||||||
|
|
||||||
- [Navidrome](https://www.navidrome.org/) - The excellent self-hosted music server
|
- [octo-fiesta](https://github.com/V1ck3s/octo-fiesta) - The original
|
||||||
|
- [octo-fiestarr](https://github.com/bransoned/octo-fiestarr) - The fork that introduced me to this idea based on the above
|
||||||
|
- [Jellyfin Spotify Import Plugin](https://github.com/Viperinius/jellyfin-plugin-spotify-import?tab=readme-ov-file) - The plugin that I **strongly** recommend using alongside this repo
|
||||||
- [Jellyfin](https://jellyfin.org/) - The free and open-source media server
|
- [Jellyfin](https://jellyfin.org/) - The free and open-source media server
|
||||||
|
- [Navidrome](https://www.navidrome.org/) - The excellent self-hosted music server
|
||||||
|
- [Subsonic API](http://www.subsonic.org/pages/api.jsp) - The API specification
|
||||||
|
- [Hi-Fi API](https://github.com/binimum/hifi-api) - These people do some great work, and you should thank them for this even existing!
|
||||||
- [Deezer](https://www.deezer.com/) - Music streaming service
|
- [Deezer](https://www.deezer.com/) - Music streaming service
|
||||||
- [Qobuz](https://www.qobuz.com/) - Hi-Res music streaming service
|
- [Qobuz](https://www.qobuz.com/) - Hi-Res music streaming service
|
||||||
- [Subsonic API](http://www.subsonic.org/pages/api.jsp) - The API specification
|
- [spotify-lyrics-api](https://github.com/akashrchandran/spotify-lyrics-api) - Thank them for the fact that we have access to Spotify's lyrics!
|
||||||
|
- [LRCLIB](https://github.com/tranxuanthang/lrclib) - The GOATS for giving us a free api for lyrics! They power LRCGET, which I'm sure some of you have heard of
|
||||||
334
allstarr.Tests/JellyfinResponseStructureTests.cs
Normal file
334
allstarr.Tests/JellyfinResponseStructureTests.cs
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Xunit;
|
||||||
|
using allstarr.Models.Domain;
|
||||||
|
using allstarr.Services.Jellyfin;
|
||||||
|
|
||||||
|
namespace allstarr.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Integration tests to verify Jellyfin response structure matches real API responses.
|
||||||
|
/// </summary>
|
||||||
|
public class JellyfinResponseStructureTests
|
||||||
|
{
|
||||||
|
private readonly JellyfinResponseBuilder _builder;
|
||||||
|
|
||||||
|
public JellyfinResponseStructureTests()
|
||||||
|
{
|
||||||
|
_builder = new JellyfinResponseBuilder();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Track_Response_Should_Have_All_Required_Fields()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var song = new Song
|
||||||
|
{
|
||||||
|
Id = "test-id",
|
||||||
|
Title = "Test Song",
|
||||||
|
Artist = "Test Artist",
|
||||||
|
ArtistId = "artist-id",
|
||||||
|
Album = "Test Album",
|
||||||
|
AlbumId = "album-id",
|
||||||
|
Duration = 180,
|
||||||
|
Year = 2024,
|
||||||
|
Track = 1,
|
||||||
|
Genre = "Pop",
|
||||||
|
IsLocal = false,
|
||||||
|
ExternalProvider = "Deezer",
|
||||||
|
ExternalId = "123456"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = _builder.ConvertSongToJellyfinItem(song);
|
||||||
|
|
||||||
|
// Assert - Required top-level fields
|
||||||
|
Assert.NotNull(result["Name"]);
|
||||||
|
Assert.NotNull(result["ServerId"]);
|
||||||
|
Assert.NotNull(result["Id"]);
|
||||||
|
Assert.NotNull(result["Type"]);
|
||||||
|
Assert.Equal("Audio", result["Type"]);
|
||||||
|
Assert.NotNull(result["MediaType"]);
|
||||||
|
Assert.Equal("Audio", result["MediaType"]);
|
||||||
|
|
||||||
|
// Assert - Metadata fields
|
||||||
|
Assert.NotNull(result["Container"]);
|
||||||
|
Assert.Equal("flac", result["Container"]);
|
||||||
|
Assert.NotNull(result["HasLyrics"]);
|
||||||
|
Assert.False((bool)result["HasLyrics"]!);
|
||||||
|
|
||||||
|
// Assert - Genres (must be array, never null)
|
||||||
|
Assert.NotNull(result["Genres"]);
|
||||||
|
Assert.IsType<string[]>(result["Genres"]);
|
||||||
|
Assert.NotNull(result["GenreItems"]);
|
||||||
|
Assert.IsAssignableFrom<System.Collections.IEnumerable>(result["GenreItems"]);
|
||||||
|
|
||||||
|
// Assert - UserData
|
||||||
|
Assert.NotNull(result["UserData"]);
|
||||||
|
var userData = result["UserData"] as Dictionary<string, object>;
|
||||||
|
Assert.NotNull(userData);
|
||||||
|
Assert.Contains("ItemId", userData.Keys);
|
||||||
|
Assert.Contains("Key", userData.Keys);
|
||||||
|
|
||||||
|
// Assert - Image fields
|
||||||
|
Assert.NotNull(result["ImageTags"]);
|
||||||
|
Assert.NotNull(result["BackdropImageTags"]);
|
||||||
|
Assert.NotNull(result["ImageBlurHashes"]);
|
||||||
|
|
||||||
|
// Assert - Location
|
||||||
|
Assert.NotNull(result["LocationType"]);
|
||||||
|
Assert.Equal("FileSystem", result["LocationType"]);
|
||||||
|
|
||||||
|
// Assert - Parent references
|
||||||
|
Assert.NotNull(result["ParentLogoItemId"]);
|
||||||
|
Assert.NotNull(result["ParentBackdropItemId"]);
|
||||||
|
Assert.NotNull(result["ParentBackdropImageTags"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Track_MediaSources_Should_Have_Complete_Structure()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var song = new Song
|
||||||
|
{
|
||||||
|
Id = "test-id",
|
||||||
|
Title = "Test Song",
|
||||||
|
Artist = "Test Artist",
|
||||||
|
Album = "Test Album",
|
||||||
|
Duration = 180,
|
||||||
|
IsLocal = false,
|
||||||
|
ExternalProvider = "Deezer",
|
||||||
|
ExternalId = "123456"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = _builder.ConvertSongToJellyfinItem(song);
|
||||||
|
|
||||||
|
// Assert - MediaSources exists
|
||||||
|
Assert.NotNull(result["MediaSources"]);
|
||||||
|
var mediaSources = result["MediaSources"] as object[];
|
||||||
|
Assert.NotNull(mediaSources);
|
||||||
|
Assert.Single(mediaSources);
|
||||||
|
|
||||||
|
var mediaSource = mediaSources[0] as Dictionary<string, object?>;
|
||||||
|
Assert.NotNull(mediaSource);
|
||||||
|
|
||||||
|
// Assert - Required MediaSource fields
|
||||||
|
Assert.Contains("Protocol", mediaSource.Keys);
|
||||||
|
Assert.Contains("Id", mediaSource.Keys);
|
||||||
|
Assert.Contains("Path", mediaSource.Keys);
|
||||||
|
Assert.Contains("Type", mediaSource.Keys);
|
||||||
|
Assert.Contains("Container", mediaSource.Keys);
|
||||||
|
Assert.Contains("Bitrate", mediaSource.Keys);
|
||||||
|
Assert.Contains("ETag", mediaSource.Keys);
|
||||||
|
Assert.Contains("RunTimeTicks", mediaSource.Keys);
|
||||||
|
|
||||||
|
// Assert - Boolean flags
|
||||||
|
Assert.Contains("IsRemote", mediaSource.Keys);
|
||||||
|
Assert.Contains("IsInfiniteStream", mediaSource.Keys);
|
||||||
|
Assert.Contains("RequiresOpening", mediaSource.Keys);
|
||||||
|
Assert.Contains("RequiresClosing", mediaSource.Keys);
|
||||||
|
Assert.Contains("RequiresLooping", mediaSource.Keys);
|
||||||
|
Assert.Contains("SupportsProbing", mediaSource.Keys);
|
||||||
|
Assert.Contains("SupportsTranscoding", mediaSource.Keys);
|
||||||
|
Assert.Contains("SupportsDirectStream", mediaSource.Keys);
|
||||||
|
Assert.Contains("SupportsDirectPlay", mediaSource.Keys);
|
||||||
|
Assert.Contains("ReadAtNativeFramerate", mediaSource.Keys);
|
||||||
|
Assert.Contains("IgnoreDts", mediaSource.Keys);
|
||||||
|
Assert.Contains("IgnoreIndex", mediaSource.Keys);
|
||||||
|
Assert.Contains("GenPtsInput", mediaSource.Keys);
|
||||||
|
Assert.Contains("UseMostCompatibleTranscodingProfile", mediaSource.Keys);
|
||||||
|
Assert.Contains("HasSegments", mediaSource.Keys);
|
||||||
|
|
||||||
|
// Assert - Arrays (must not be null)
|
||||||
|
Assert.Contains("MediaStreams", mediaSource.Keys);
|
||||||
|
Assert.NotNull(mediaSource["MediaStreams"]);
|
||||||
|
Assert.Contains("MediaAttachments", mediaSource.Keys);
|
||||||
|
Assert.NotNull(mediaSource["MediaAttachments"]);
|
||||||
|
Assert.Contains("Formats", mediaSource.Keys);
|
||||||
|
Assert.NotNull(mediaSource["Formats"]);
|
||||||
|
Assert.Contains("RequiredHttpHeaders", mediaSource.Keys);
|
||||||
|
Assert.NotNull(mediaSource["RequiredHttpHeaders"]);
|
||||||
|
|
||||||
|
// Assert - Other fields
|
||||||
|
Assert.Contains("TranscodingSubProtocol", mediaSource.Keys);
|
||||||
|
Assert.Contains("DefaultAudioStreamIndex", mediaSource.Keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Track_MediaStreams_Should_Have_Complete_Audio_Stream()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var song = new Song
|
||||||
|
{
|
||||||
|
Id = "test-id",
|
||||||
|
Title = "Test Song",
|
||||||
|
Artist = "Test Artist",
|
||||||
|
IsLocal = false,
|
||||||
|
ExternalProvider = "Deezer"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = _builder.ConvertSongToJellyfinItem(song);
|
||||||
|
var mediaSources = result["MediaSources"] as object[];
|
||||||
|
var mediaSource = mediaSources![0] as Dictionary<string, object?>;
|
||||||
|
var mediaStreams = mediaSource!["MediaStreams"] as object[];
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(mediaStreams);
|
||||||
|
Assert.Single(mediaStreams);
|
||||||
|
|
||||||
|
var audioStream = mediaStreams[0] as Dictionary<string, object?>;
|
||||||
|
Assert.NotNull(audioStream);
|
||||||
|
|
||||||
|
// Assert - Required audio stream fields
|
||||||
|
Assert.Contains("Codec", audioStream.Keys);
|
||||||
|
Assert.Equal("flac", audioStream["Codec"]);
|
||||||
|
Assert.Contains("Type", audioStream.Keys);
|
||||||
|
Assert.Equal("Audio", audioStream["Type"]);
|
||||||
|
Assert.Contains("BitRate", audioStream.Keys);
|
||||||
|
Assert.Contains("Channels", audioStream.Keys);
|
||||||
|
Assert.Contains("SampleRate", audioStream.Keys);
|
||||||
|
Assert.Contains("BitDepth", audioStream.Keys);
|
||||||
|
Assert.Contains("ChannelLayout", audioStream.Keys);
|
||||||
|
Assert.Contains("TimeBase", audioStream.Keys);
|
||||||
|
Assert.Contains("DisplayTitle", audioStream.Keys);
|
||||||
|
|
||||||
|
// Assert - Video-related fields (required even for audio)
|
||||||
|
Assert.Contains("VideoRange", audioStream.Keys);
|
||||||
|
Assert.Contains("VideoRangeType", audioStream.Keys);
|
||||||
|
Assert.Contains("AudioSpatialFormat", audioStream.Keys);
|
||||||
|
|
||||||
|
// Assert - Localization
|
||||||
|
Assert.Contains("LocalizedDefault", audioStream.Keys);
|
||||||
|
Assert.Contains("LocalizedExternal", audioStream.Keys);
|
||||||
|
|
||||||
|
// Assert - Boolean flags
|
||||||
|
Assert.Contains("IsInterlaced", audioStream.Keys);
|
||||||
|
Assert.Contains("IsAVC", audioStream.Keys);
|
||||||
|
Assert.Contains("IsDefault", audioStream.Keys);
|
||||||
|
Assert.Contains("IsForced", audioStream.Keys);
|
||||||
|
Assert.Contains("IsHearingImpaired", audioStream.Keys);
|
||||||
|
Assert.Contains("IsExternal", audioStream.Keys);
|
||||||
|
Assert.Contains("IsTextSubtitleStream", audioStream.Keys);
|
||||||
|
Assert.Contains("SupportsExternalStream", audioStream.Keys);
|
||||||
|
|
||||||
|
// Assert - Index and Level
|
||||||
|
Assert.Contains("Index", audioStream.Keys);
|
||||||
|
Assert.Contains("Level", audioStream.Keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Album_Response_Should_Have_All_Required_Fields()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var album = new Album
|
||||||
|
{
|
||||||
|
Id = "album-id",
|
||||||
|
Title = "Test Album",
|
||||||
|
Artist = "Test Artist",
|
||||||
|
Year = 2024,
|
||||||
|
Genre = "Rock",
|
||||||
|
IsLocal = false,
|
||||||
|
ExternalProvider = "Deezer"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = _builder.ConvertAlbumToJellyfinItem(album);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(result["Name"]);
|
||||||
|
Assert.NotNull(result["ServerId"]);
|
||||||
|
Assert.NotNull(result["Id"]);
|
||||||
|
Assert.NotNull(result["Type"]);
|
||||||
|
Assert.Equal("MusicAlbum", result["Type"]);
|
||||||
|
Assert.True((bool)result["IsFolder"]!);
|
||||||
|
Assert.NotNull(result["MediaType"]);
|
||||||
|
Assert.Equal("Unknown", result["MediaType"]);
|
||||||
|
|
||||||
|
// Assert - Genres
|
||||||
|
Assert.NotNull(result["Genres"]);
|
||||||
|
Assert.IsType<string[]>(result["Genres"]);
|
||||||
|
Assert.NotNull(result["GenreItems"]);
|
||||||
|
|
||||||
|
// Assert - Artists
|
||||||
|
Assert.NotNull(result["Artists"]);
|
||||||
|
Assert.NotNull(result["ArtistItems"]);
|
||||||
|
Assert.NotNull(result["AlbumArtist"]);
|
||||||
|
Assert.NotNull(result["AlbumArtists"]);
|
||||||
|
|
||||||
|
// Assert - Parent references
|
||||||
|
Assert.NotNull(result["ParentLogoItemId"]);
|
||||||
|
Assert.NotNull(result["ParentBackdropItemId"]);
|
||||||
|
Assert.NotNull(result["ParentLogoImageTag"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Artist_Response_Should_Have_All_Required_Fields()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var artist = new Artist
|
||||||
|
{
|
||||||
|
Id = "artist-id",
|
||||||
|
Name = "Test Artist",
|
||||||
|
AlbumCount = 5,
|
||||||
|
IsLocal = false,
|
||||||
|
ExternalProvider = "Deezer"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = _builder.ConvertArtistToJellyfinItem(artist);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(result["Name"]);
|
||||||
|
Assert.NotNull(result["ServerId"]);
|
||||||
|
Assert.NotNull(result["Id"]);
|
||||||
|
Assert.NotNull(result["Type"]);
|
||||||
|
Assert.Equal("MusicArtist", result["Type"]);
|
||||||
|
Assert.True((bool)result["IsFolder"]!);
|
||||||
|
Assert.NotNull(result["MediaType"]);
|
||||||
|
Assert.Equal("Unknown", result["MediaType"]);
|
||||||
|
|
||||||
|
// Assert - Genres (empty array for artists)
|
||||||
|
Assert.NotNull(result["Genres"]);
|
||||||
|
Assert.IsType<string[]>(result["Genres"]);
|
||||||
|
Assert.NotNull(result["GenreItems"]);
|
||||||
|
|
||||||
|
// Assert - Album count
|
||||||
|
Assert.NotNull(result["AlbumCount"]);
|
||||||
|
Assert.Equal(5, result["AlbumCount"]);
|
||||||
|
|
||||||
|
// Assert - RunTimeTicks
|
||||||
|
Assert.NotNull(result["RunTimeTicks"]);
|
||||||
|
Assert.Equal(0, result["RunTimeTicks"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void All_Entities_Should_Have_UserData_With_ItemId()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var song = new Song { Id = "song-id", Title = "Test", Artist = "Test" };
|
||||||
|
var album = new Album { Id = "album-id", Title = "Test", Artist = "Test" };
|
||||||
|
var artist = new Artist { Id = "artist-id", Name = "Test" };
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var songResult = _builder.ConvertSongToJellyfinItem(song);
|
||||||
|
var albumResult = _builder.ConvertAlbumToJellyfinItem(album);
|
||||||
|
var artistResult = _builder.ConvertArtistToJellyfinItem(artist);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var songUserData = songResult["UserData"] as Dictionary<string, object>;
|
||||||
|
Assert.NotNull(songUserData);
|
||||||
|
Assert.Contains("ItemId", songUserData.Keys);
|
||||||
|
Assert.Equal("song-id", songUserData["ItemId"]);
|
||||||
|
|
||||||
|
var albumUserData = albumResult["UserData"] as Dictionary<string, object>;
|
||||||
|
Assert.NotNull(albumUserData);
|
||||||
|
Assert.Contains("ItemId", albumUserData.Keys);
|
||||||
|
Assert.Equal("album-id", albumUserData["ItemId"]);
|
||||||
|
|
||||||
|
var artistUserData = artistResult["UserData"] as Dictionary<string, object>;
|
||||||
|
Assert.NotNull(artistUserData);
|
||||||
|
Assert.Contains("ItemId", artistUserData.Keys);
|
||||||
|
Assert.Equal("artist-id", artistUserData["ItemId"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using allstarr.Models.Settings;
|
using allstarr.Models.Settings;
|
||||||
|
using allstarr.Models.Spotify;
|
||||||
using allstarr.Services.Spotify;
|
using allstarr.Services.Spotify;
|
||||||
using allstarr.Services.Jellyfin;
|
using allstarr.Services.Jellyfin;
|
||||||
using allstarr.Services.Common;
|
using allstarr.Services.Common;
|
||||||
|
using allstarr.Services;
|
||||||
using allstarr.Filters;
|
using allstarr.Filters;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Runtime;
|
||||||
|
|
||||||
namespace allstarr.Controllers;
|
namespace allstarr.Controllers;
|
||||||
|
|
||||||
@@ -25,16 +28,22 @@ public class AdminController : ControllerBase
|
|||||||
private readonly SpotifyApiSettings _spotifyApiSettings;
|
private readonly SpotifyApiSettings _spotifyApiSettings;
|
||||||
private readonly SpotifyImportSettings _spotifyImportSettings;
|
private readonly SpotifyImportSettings _spotifyImportSettings;
|
||||||
private readonly JellyfinSettings _jellyfinSettings;
|
private readonly JellyfinSettings _jellyfinSettings;
|
||||||
|
private readonly SubsonicSettings _subsonicSettings;
|
||||||
private readonly DeezerSettings _deezerSettings;
|
private readonly DeezerSettings _deezerSettings;
|
||||||
private readonly QobuzSettings _qobuzSettings;
|
private readonly QobuzSettings _qobuzSettings;
|
||||||
private readonly SquidWTFSettings _squidWtfSettings;
|
private readonly SquidWTFSettings _squidWtfSettings;
|
||||||
|
private readonly MusicBrainzSettings _musicBrainzSettings;
|
||||||
private readonly SpotifyApiClient _spotifyClient;
|
private readonly SpotifyApiClient _spotifyClient;
|
||||||
private readonly SpotifyPlaylistFetcher _playlistFetcher;
|
private readonly SpotifyPlaylistFetcher _playlistFetcher;
|
||||||
private readonly SpotifyTrackMatchingService? _matchingService;
|
private readonly SpotifyTrackMatchingService? _matchingService;
|
||||||
private readonly RedisCacheService _cache;
|
private readonly RedisCacheService _cache;
|
||||||
private readonly HttpClient _jellyfinHttpClient;
|
private readonly HttpClient _jellyfinHttpClient;
|
||||||
private readonly IWebHostEnvironment _environment;
|
private readonly IWebHostEnvironment _environment;
|
||||||
|
private readonly IServiceProvider _serviceProvider;
|
||||||
private readonly string _envFilePath;
|
private readonly string _envFilePath;
|
||||||
|
private readonly List<string> _squidWtfApiUrls;
|
||||||
|
private static int _urlIndex = 0;
|
||||||
|
private static readonly object _urlIndexLock = new();
|
||||||
private const string CacheDirectory = "/app/cache/spotify";
|
private const string CacheDirectory = "/app/cache/spotify";
|
||||||
|
|
||||||
public AdminController(
|
public AdminController(
|
||||||
@@ -44,13 +53,16 @@ public class AdminController : ControllerBase
|
|||||||
IOptions<SpotifyApiSettings> spotifyApiSettings,
|
IOptions<SpotifyApiSettings> spotifyApiSettings,
|
||||||
IOptions<SpotifyImportSettings> spotifyImportSettings,
|
IOptions<SpotifyImportSettings> spotifyImportSettings,
|
||||||
IOptions<JellyfinSettings> jellyfinSettings,
|
IOptions<JellyfinSettings> jellyfinSettings,
|
||||||
|
IOptions<SubsonicSettings> subsonicSettings,
|
||||||
IOptions<DeezerSettings> deezerSettings,
|
IOptions<DeezerSettings> deezerSettings,
|
||||||
IOptions<QobuzSettings> qobuzSettings,
|
IOptions<QobuzSettings> qobuzSettings,
|
||||||
IOptions<SquidWTFSettings> squidWtfSettings,
|
IOptions<SquidWTFSettings> squidWtfSettings,
|
||||||
|
IOptions<MusicBrainzSettings> musicBrainzSettings,
|
||||||
SpotifyApiClient spotifyClient,
|
SpotifyApiClient spotifyClient,
|
||||||
SpotifyPlaylistFetcher playlistFetcher,
|
SpotifyPlaylistFetcher playlistFetcher,
|
||||||
RedisCacheService cache,
|
RedisCacheService cache,
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
|
IServiceProvider serviceProvider,
|
||||||
SpotifyTrackMatchingService? matchingService = null)
|
SpotifyTrackMatchingService? matchingService = null)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@@ -59,22 +71,58 @@ public class AdminController : ControllerBase
|
|||||||
_spotifyApiSettings = spotifyApiSettings.Value;
|
_spotifyApiSettings = spotifyApiSettings.Value;
|
||||||
_spotifyImportSettings = spotifyImportSettings.Value;
|
_spotifyImportSettings = spotifyImportSettings.Value;
|
||||||
_jellyfinSettings = jellyfinSettings.Value;
|
_jellyfinSettings = jellyfinSettings.Value;
|
||||||
|
_subsonicSettings = subsonicSettings.Value;
|
||||||
_deezerSettings = deezerSettings.Value;
|
_deezerSettings = deezerSettings.Value;
|
||||||
_qobuzSettings = qobuzSettings.Value;
|
_qobuzSettings = qobuzSettings.Value;
|
||||||
_squidWtfSettings = squidWtfSettings.Value;
|
_squidWtfSettings = squidWtfSettings.Value;
|
||||||
|
_musicBrainzSettings = musicBrainzSettings.Value;
|
||||||
_spotifyClient = spotifyClient;
|
_spotifyClient = spotifyClient;
|
||||||
_playlistFetcher = playlistFetcher;
|
_playlistFetcher = playlistFetcher;
|
||||||
_matchingService = matchingService;
|
_matchingService = matchingService;
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
_jellyfinHttpClient = httpClientFactory.CreateClient();
|
_jellyfinHttpClient = httpClientFactory.CreateClient();
|
||||||
|
_serviceProvider = serviceProvider;
|
||||||
|
|
||||||
|
// Decode SquidWTF base URLs
|
||||||
|
_squidWtfApiUrls = DecodeSquidWtfUrls();
|
||||||
|
|
||||||
// .env file path is always /app/.env in Docker (mounted from host)
|
// .env file path is always /app/.env in Docker (mounted from host)
|
||||||
// In development, it's in the parent directory of ContentRootPath
|
// In development, it's in the parent directory of ContentRootPath
|
||||||
_envFilePath = _environment.IsDevelopment()
|
_envFilePath = _environment.IsDevelopment()
|
||||||
? Path.Combine(_environment.ContentRootPath, "..", ".env")
|
? Path.Combine(_environment.ContentRootPath, "..", ".env")
|
||||||
: "/app/.env";
|
: "/app/.env";
|
||||||
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Admin controller initialized. .env path: {EnvFilePath}", _envFilePath);
|
private 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
|
||||||
|
};
|
||||||
|
|
||||||
|
return encodedUrls
|
||||||
|
.Select(encoded => System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(encoded)))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper method to safely check if a dynamic cache result has a value
|
||||||
|
/// Handles the case where JsonElement cannot be compared to null directly
|
||||||
|
/// </summary>
|
||||||
|
private static bool HasValue(object? obj)
|
||||||
|
{
|
||||||
|
if (obj == null) return false;
|
||||||
|
if (obj is JsonElement jsonEl) return jsonEl.ValueKind != JsonValueKind.Null && jsonEl.ValueKind != JsonValueKind.Undefined;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -118,8 +166,7 @@ public class AdminController : ControllerBase
|
|||||||
spotifyImport = new
|
spotifyImport = new
|
||||||
{
|
{
|
||||||
enabled = _spotifyImportSettings.Enabled,
|
enabled = _spotifyImportSettings.Enabled,
|
||||||
syncTime = $"{_spotifyImportSettings.SyncStartHour:D2}:{_spotifyImportSettings.SyncStartMinute:D2}",
|
matchingIntervalHours = _spotifyImportSettings.MatchingIntervalHours,
|
||||||
syncWindowHours = _spotifyImportSettings.SyncWindowHours,
|
|
||||||
playlistCount = _spotifyImportSettings.Playlists.Count
|
playlistCount = _spotifyImportSettings.Playlists.Count
|
||||||
},
|
},
|
||||||
deezer = new
|
deezer = new
|
||||||
@@ -139,15 +186,72 @@ public class AdminController : ControllerBase
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get a random SquidWTF base URL for searching (round-robin)
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("squidwtf-base-url")]
|
||||||
|
public IActionResult GetSquidWtfBaseUrl()
|
||||||
|
{
|
||||||
|
if (_squidWtfApiUrls.Count == 0)
|
||||||
|
{
|
||||||
|
return NotFound(new { error = "No SquidWTF base URLs configured" });
|
||||||
|
}
|
||||||
|
|
||||||
|
string baseUrl;
|
||||||
|
lock (_urlIndexLock)
|
||||||
|
{
|
||||||
|
baseUrl = _squidWtfApiUrls[_urlIndex];
|
||||||
|
_urlIndex = (_urlIndex + 1) % _squidWtfApiUrls.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new { baseUrl });
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get list of configured playlists with their current data
|
/// Get list of configured playlists with their current data
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("playlists")]
|
[HttpGet("playlists")]
|
||||||
public async Task<IActionResult> GetPlaylists()
|
public async Task<IActionResult> GetPlaylists([FromQuery] bool refresh = false)
|
||||||
{
|
{
|
||||||
|
var playlistCacheFile = "/app/cache/admin_playlists_summary.json";
|
||||||
|
|
||||||
|
// Check file cache first (5 minute TTL) unless refresh is requested
|
||||||
|
if (!refresh && System.IO.File.Exists(playlistCacheFile))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fileInfo = new FileInfo(playlistCacheFile);
|
||||||
|
var age = DateTime.UtcNow - fileInfo.LastWriteTimeUtc;
|
||||||
|
|
||||||
|
if (age.TotalMinutes < 5)
|
||||||
|
{
|
||||||
|
var cachedJson = await System.IO.File.ReadAllTextAsync(playlistCacheFile);
|
||||||
|
var cachedData = JsonSerializer.Deserialize<Dictionary<string, object>>(cachedJson);
|
||||||
|
_logger.LogDebug("📦 Returning cached playlist summary (age: {Age:F1}m)", age.TotalMinutes);
|
||||||
|
return Ok(cachedData);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogDebug("🔄 Cache expired (age: {Age:F1}m), refreshing...", age.TotalMinutes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to read cached playlist summary");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (refresh)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("🔄 Force refresh requested for playlist summary");
|
||||||
|
}
|
||||||
|
|
||||||
var playlists = new List<object>();
|
var playlists = new List<object>();
|
||||||
|
|
||||||
foreach (var config in _spotifyImportSettings.Playlists)
|
// Read playlists directly from .env file to get the latest configuration
|
||||||
|
// (IOptions is cached and doesn't reload after .env changes)
|
||||||
|
var configuredPlaylists = await ReadPlaylistsFromEnvFile();
|
||||||
|
|
||||||
|
foreach (var config in configuredPlaylists)
|
||||||
{
|
{
|
||||||
var playlistInfo = new Dictionary<string, object?>
|
var playlistInfo = new Dictionary<string, object?>
|
||||||
{
|
{
|
||||||
@@ -243,49 +347,206 @@ public class AdminController : ControllerBase
|
|||||||
|
|
||||||
if (jellyfinDoc.RootElement.TryGetProperty("Items", out var items))
|
if (jellyfinDoc.RootElement.TryGetProperty("Items", out var items))
|
||||||
{
|
{
|
||||||
var localCount = 0;
|
// Get Spotify tracks to match against
|
||||||
var externalMatchedCount = 0;
|
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name);
|
||||||
|
|
||||||
// Count local vs external tracks
|
// Try to use the pre-built playlist cache first (includes manual mappings!)
|
||||||
|
var playlistItemsCacheKey = $"spotify:playlist:items:{config.Name}";
|
||||||
|
|
||||||
|
List<Dictionary<string, object?>>? cachedPlaylistItems = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
cachedPlaylistItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(playlistItemsCacheKey);
|
||||||
|
}
|
||||||
|
catch (Exception cacheEx)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(cacheEx, "Failed to deserialize playlist cache for {Playlist}", config.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Checking cache for {Playlist}: {CacheKey}, Found: {Found}, Count: {Count}",
|
||||||
|
config.Name, playlistItemsCacheKey, cachedPlaylistItems != null, cachedPlaylistItems?.Count ?? 0);
|
||||||
|
|
||||||
|
if (cachedPlaylistItems != null && cachedPlaylistItems.Count > 0)
|
||||||
|
{
|
||||||
|
// Use the pre-built cache which respects manual mappings
|
||||||
|
var localCount = 0;
|
||||||
|
var externalCount = 0;
|
||||||
|
|
||||||
|
foreach (var item in cachedPlaylistItems)
|
||||||
|
{
|
||||||
|
// Check if it's external by looking for external provider in ProviderIds
|
||||||
|
// External providers: SquidWTF, Deezer, Qobuz, Tidal
|
||||||
|
var isExternal = false;
|
||||||
|
|
||||||
|
if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null)
|
||||||
|
{
|
||||||
|
// Handle both Dictionary<string, string> and JsonElement
|
||||||
|
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())
|
||||||
|
{
|
||||||
|
providerIds[prop.Name] = prop.Value.GetString() ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (providerIds != null)
|
||||||
|
{
|
||||||
|
// Check for external provider keys (not MusicBrainz, ISRC, Spotify, etc)
|
||||||
|
isExternal = providerIds.Keys.Any(k =>
|
||||||
|
k.Equals("SquidWTF", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
k.Equals("Deezer", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
k.Equals("Qobuz", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
k.Equals("Tidal", StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isExternal)
|
||||||
|
{
|
||||||
|
externalCount++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
localCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var externalMissingCount = spotifyTracks.Count - cachedPlaylistItems.Count;
|
||||||
|
if (externalMissingCount < 0) externalMissingCount = 0;
|
||||||
|
|
||||||
|
playlistInfo["localTracks"] = localCount;
|
||||||
|
playlistInfo["externalMatched"] = externalCount;
|
||||||
|
playlistInfo["externalMissing"] = externalMissingCount;
|
||||||
|
playlistInfo["externalTotal"] = externalCount + externalMissingCount;
|
||||||
|
playlistInfo["totalInJellyfin"] = cachedPlaylistItems.Count;
|
||||||
|
playlistInfo["totalPlayable"] = localCount + externalCount; // Total tracks that will be served
|
||||||
|
|
||||||
|
_logger.LogInformation("Playlist {Name} (from cache): {Total} Spotify tracks, {Local} local, {ExtMatched} external matched, {ExtMissing} external missing, {Playable} total playable",
|
||||||
|
config.Name, spotifyTracks.Count, localCount, externalCount, externalMissingCount, localCount + externalCount);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Fallback: Build list of local tracks from Jellyfin (match by name only)
|
||||||
|
var localTracks = new List<(string Title, string Artist)>();
|
||||||
foreach (var item in items.EnumerateArray())
|
foreach (var item in items.EnumerateArray())
|
||||||
{
|
{
|
||||||
// Check if track has a real file path (local) or is external
|
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
|
||||||
var hasPath = item.TryGetProperty("Path", out var pathProp) &&
|
var artist = "";
|
||||||
pathProp.ValueKind == JsonValueKind.String &&
|
|
||||||
!string.IsNullOrEmpty(pathProp.GetString());
|
|
||||||
|
|
||||||
if (hasPath)
|
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
|
||||||
{
|
{
|
||||||
var pathStr = pathProp.GetString()!;
|
artist = artistsEl[0].GetString() ?? "";
|
||||||
// Local tracks have filesystem paths starting with / or containing :\
|
}
|
||||||
if (pathStr.StartsWith("/") || pathStr.Contains(":\\"))
|
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
|
||||||
|
{
|
||||||
|
artist = albumArtistEl.GetString() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(title))
|
||||||
|
{
|
||||||
|
localTracks.Add((title, artist));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get matched external tracks cache once
|
||||||
|
var matchedTracksKey = $"spotify:matched:ordered:{config.Name}";
|
||||||
|
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
|
||||||
|
var matchedSpotifyIds = new HashSet<string>(
|
||||||
|
matchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>()
|
||||||
|
);
|
||||||
|
|
||||||
|
var localCount = 0;
|
||||||
|
var externalMatchedCount = 0;
|
||||||
|
var externalMissingCount = 0;
|
||||||
|
|
||||||
|
// Match each Spotify track to determine if it's local, external, or missing
|
||||||
|
foreach (var track in spotifyTracks)
|
||||||
|
{
|
||||||
|
var isLocal = false;
|
||||||
|
var hasExternalMapping = false;
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
{
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(externalMappingJson))
|
||||||
|
{
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLocal)
|
||||||
{
|
{
|
||||||
localCount++;
|
localCount++;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// External track (downloaded from Deezer/Qobuz/etc)
|
// Check if external track is matched (either manual mapping or auto-matched)
|
||||||
|
if (hasExternalMapping || matchedSpotifyIds.Contains(track.SpotifyId))
|
||||||
|
{
|
||||||
externalMatchedCount++;
|
externalMatchedCount++;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// No path means external
|
externalMissingCount++;
|
||||||
externalMatchedCount++;
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var totalInJellyfin = localCount + externalMatchedCount;
|
|
||||||
var externalMissingCount = Math.Max(0, spotifyTrackCount - totalInJellyfin);
|
|
||||||
|
|
||||||
playlistInfo["localTracks"] = localCount;
|
playlistInfo["localTracks"] = localCount;
|
||||||
playlistInfo["externalMatched"] = externalMatchedCount;
|
playlistInfo["externalMatched"] = externalMatchedCount;
|
||||||
playlistInfo["externalMissing"] = externalMissingCount;
|
playlistInfo["externalMissing"] = externalMissingCount;
|
||||||
playlistInfo["externalTotal"] = externalMatchedCount + externalMissingCount;
|
playlistInfo["externalTotal"] = externalMatchedCount + externalMissingCount;
|
||||||
playlistInfo["totalInJellyfin"] = totalInJellyfin;
|
playlistInfo["totalInJellyfin"] = localCount + externalMatchedCount;
|
||||||
|
playlistInfo["totalPlayable"] = localCount + externalMatchedCount; // Total tracks that will be served
|
||||||
|
|
||||||
_logger.LogDebug("Playlist {Name}: {Total} Spotify tracks, {Local} local, {ExtMatched} external matched, {ExtMissing} external missing",
|
_logger.LogDebug("Playlist {Name} (fallback): {Total} Spotify tracks, {Local} local, {ExtMatched} external matched, {ExtMissing} external missing, {Playable} total playable",
|
||||||
config.Name, spotifyTrackCount, localCount, externalMatchedCount, externalMissingCount);
|
config.Name, spotifyTracks.Count, localCount, externalMatchedCount, externalMissingCount, localCount + externalMatchedCount);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -312,6 +573,24 @@ public class AdminController : ControllerBase
|
|||||||
playlists.Add(playlistInfo);
|
playlists.Add(playlistInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save to file cache
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cacheDir = "/app/cache";
|
||||||
|
Directory.CreateDirectory(cacheDir);
|
||||||
|
var cacheFile = Path.Combine(cacheDir, "admin_playlists_summary.json");
|
||||||
|
|
||||||
|
var response = new { playlists };
|
||||||
|
var json = JsonSerializer.Serialize(response, new JsonSerializerOptions { WriteIndented = false });
|
||||||
|
await System.IO.File.WriteAllTextAsync(cacheFile, json);
|
||||||
|
|
||||||
|
_logger.LogDebug("💾 Saved playlist summary to cache");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to save playlist summary cache");
|
||||||
|
}
|
||||||
|
|
||||||
return Ok(new { playlists });
|
return Ok(new { playlists });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,87 +605,275 @@ public class AdminController : ControllerBase
|
|||||||
// Get Spotify tracks
|
// Get Spotify tracks
|
||||||
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(decodedName);
|
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>();
|
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
|
cachedPlaylistItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(playlistItemsCacheKey);
|
||||||
var userId = _jellyfinSettings.UserId;
|
}
|
||||||
if (!string.IsNullOrEmpty(userId))
|
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)
|
||||||
|
{
|
||||||
|
if (item.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())
|
||||||
|
{
|
||||||
|
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())
|
||||||
|
{
|
||||||
|
providerIds[prop.Name] = prop.Value.GetString() ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (providerIds != null)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Track {Title} has ProviderIds: {Keys}", track.Title, string.Join(", ", providerIds.Keys));
|
||||||
|
|
||||||
|
// 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 (providerKey != null)
|
||||||
|
{
|
||||||
|
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
|
||||||
|
{
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_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
|
try
|
||||||
{
|
{
|
||||||
var url = $"{_jellyfinSettings.Url}/Playlists/{playlistConfig.JellyfinId}/Items?UserId={userId}";
|
using var extDoc = JsonDocument.Parse(externalMappingJson);
|
||||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
var extRoot = extDoc.RootElement;
|
||||||
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
|
||||||
|
|
||||||
var response = await _jellyfinHttpClient.SendAsync(request);
|
if (extRoot.TryGetProperty("id", out var idEl))
|
||||||
if (response.IsSuccessStatusCode)
|
|
||||||
{
|
{
|
||||||
var json = await response.Content.ReadAsStringAsync();
|
isManualMapping = true;
|
||||||
using var doc = JsonDocument.Parse(json);
|
manualMappingType = "external";
|
||||||
|
manualMappingId = idEl.GetString();
|
||||||
// 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))
|
|
||||||
{
|
|
||||||
foreach (var item in items.EnumerateArray())
|
|
||||||
{
|
|
||||||
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() ?? "";
|
|
||||||
}
|
}
|
||||||
|
catch { }
|
||||||
if (!string.IsNullOrEmpty(title))
|
|
||||||
{
|
|
||||||
localTracks.Add((title, artist));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Track not in cache - it's missing
|
||||||
|
isLocal = null;
|
||||||
|
externalProvider = null;
|
||||||
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Found {Count} local tracks in Jellyfin playlist {Playlist}",
|
// Check lyrics status
|
||||||
localTracks.Count, decodedName);
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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>()
|
||||||
|
);
|
||||||
|
|
||||||
// Match Spotify tracks to local tracks by name (fuzzy matching)
|
|
||||||
foreach (var track in spotifyTracks)
|
foreach (var track in spotifyTracks)
|
||||||
{
|
{
|
||||||
var isLocal = false;
|
bool? isLocal = null;
|
||||||
|
string? externalProvider = null;
|
||||||
|
|
||||||
if (localTracks.Count > 0)
|
// Check for manual Jellyfin mapping
|
||||||
{
|
var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}";
|
||||||
var bestMatch = localTracks
|
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
|
||||||
.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 (!string.IsNullOrEmpty(manualJellyfinId))
|
||||||
if (bestMatch != null && bestMatch.TotalScore >= 70)
|
|
||||||
{
|
{
|
||||||
isLocal = true;
|
isLocal = true;
|
||||||
}
|
}
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (extRoot.TryGetProperty("provider", out var providerEl))
|
||||||
|
{
|
||||||
|
provider = providerEl.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(provider))
|
||||||
|
{
|
||||||
|
isLocal = false;
|
||||||
|
externalProvider = provider;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", track.Title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (fallbackMatchedSpotifyIds.Contains(track.SpotifyId))
|
||||||
|
{
|
||||||
|
isLocal = false;
|
||||||
|
externalProvider = "SquidWTF";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
isLocal = null;
|
||||||
|
externalProvider = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tracksWithStatus.Add(new
|
tracksWithStatus.Add(new
|
||||||
@@ -419,7 +886,9 @@ public class AdminController : ControllerBase
|
|||||||
spotifyId = track.SpotifyId,
|
spotifyId = track.SpotifyId,
|
||||||
durationMs = track.DurationMs,
|
durationMs = track.DurationMs,
|
||||||
albumArtUrl = track.AlbumArtUrl,
|
albumArtUrl = track.AlbumArtUrl,
|
||||||
isLocal = isLocal
|
isLocal = isLocal,
|
||||||
|
externalProvider = externalProvider,
|
||||||
|
searchQuery = isLocal != true ? $"{track.Title} {track.PrimaryArtist}" : null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,33 +899,6 @@ public class AdminController : ControllerBase
|
|||||||
tracks = tracksWithStatus
|
tracks = tracksWithStatus
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "Failed to get local track status for {Playlist}", decodedName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: return tracks without local/external status
|
|
||||||
return Ok(new
|
|
||||||
{
|
|
||||||
name = decodedName,
|
|
||||||
trackCount = spotifyTracks.Count,
|
|
||||||
tracks = spotifyTracks.Select(t => new
|
|
||||||
{
|
|
||||||
position = t.Position,
|
|
||||||
title = t.Title,
|
|
||||||
artists = t.Artists,
|
|
||||||
album = t.Album,
|
|
||||||
isrc = t.Isrc,
|
|
||||||
spotifyId = t.SpotifyId,
|
|
||||||
durationMs = t.DurationMs,
|
|
||||||
albumArtUrl = t.AlbumArtUrl,
|
|
||||||
isLocal = (bool?)null // Unknown
|
|
||||||
})
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Trigger a manual refresh of all playlists
|
/// Trigger a manual refresh of all playlists
|
||||||
@@ -466,6 +908,10 @@ public class AdminController : ControllerBase
|
|||||||
{
|
{
|
||||||
_logger.LogInformation("Manual playlist refresh triggered from admin UI");
|
_logger.LogInformation("Manual playlist refresh triggered from admin UI");
|
||||||
await _playlistFetcher.TriggerFetchAsync();
|
await _playlistFetcher.TriggerFetchAsync();
|
||||||
|
|
||||||
|
// Invalidate playlist summary cache
|
||||||
|
InvalidatePlaylistSummaryCache();
|
||||||
|
|
||||||
return Ok(new { message = "Playlist refresh triggered", timestamp = DateTime.UtcNow });
|
return Ok(new { message = "Playlist refresh triggered", timestamp = DateTime.UtcNow });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,6 +932,10 @@ public class AdminController : ControllerBase
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _matchingService.TriggerMatchingForPlaylistAsync(decodedName);
|
await _matchingService.TriggerMatchingForPlaylistAsync(decodedName);
|
||||||
|
|
||||||
|
// Invalidate playlist summary cache
|
||||||
|
InvalidatePlaylistSummaryCache();
|
||||||
|
|
||||||
return Ok(new { message = $"Track matching triggered for {decodedName}", timestamp = DateTime.UtcNow });
|
return Ok(new { message = $"Track matching triggered for {decodedName}", timestamp = DateTime.UtcNow });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -495,6 +945,77 @@ public class AdminController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clear cache and rebuild for a specific playlist
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("playlists/{name}/clear-cache")]
|
||||||
|
public async Task<IActionResult> ClearPlaylistCache(string name)
|
||||||
|
{
|
||||||
|
var decodedName = Uri.UnescapeDataString(name);
|
||||||
|
_logger.LogInformation("Clear cache & rebuild triggered for playlist: {Name}", decodedName);
|
||||||
|
|
||||||
|
if (_matchingService == null)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Track matching service is not available" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Clear all cache keys for this playlist
|
||||||
|
var cacheKeys = new[]
|
||||||
|
{
|
||||||
|
$"spotify:playlist:items:{decodedName}", // Pre-built items cache
|
||||||
|
$"spotify:matched:ordered:{decodedName}", // Ordered matched tracks
|
||||||
|
$"spotify:matched:{decodedName}", // Legacy matched tracks
|
||||||
|
$"spotify:missing:{decodedName}" // Missing tracks
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var key in cacheKeys)
|
||||||
|
{
|
||||||
|
await _cache.DeleteAsync(key);
|
||||||
|
_logger.LogDebug("Cleared cache key: {Key}", key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete file caches
|
||||||
|
var safeName = string.Join("_", decodedName.Split(Path.GetInvalidFileNameChars()));
|
||||||
|
var filesToDelete = new[]
|
||||||
|
{
|
||||||
|
Path.Combine(CacheDirectory, $"{safeName}_items.json"),
|
||||||
|
Path.Combine(CacheDirectory, $"{safeName}_matched.json")
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var file in filesToDelete)
|
||||||
|
{
|
||||||
|
if (System.IO.File.Exists(file))
|
||||||
|
{
|
||||||
|
System.IO.File.Delete(file);
|
||||||
|
_logger.LogDebug("Deleted cache file: {File}", file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("✓ Cleared all caches for playlist: {Name}", decodedName);
|
||||||
|
|
||||||
|
// Trigger rebuild
|
||||||
|
await _matchingService.TriggerMatchingForPlaylistAsync(decodedName);
|
||||||
|
|
||||||
|
// Invalidate playlist summary cache
|
||||||
|
InvalidatePlaylistSummaryCache();
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
message = $"Cache cleared and rebuild triggered for {decodedName}",
|
||||||
|
timestamp = DateTime.UtcNow,
|
||||||
|
clearedKeys = cacheKeys.Length,
|
||||||
|
clearedFiles = filesToDelete.Count(System.IO.File.Exists)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to clear cache for {Name}", decodedName);
|
||||||
|
return StatusCode(500, new { error = "Failed to clear cache", details = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Search Jellyfin library for tracks (for manual mapping)
|
/// Search Jellyfin library for tracks (for manual mapping)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -508,13 +1029,25 @@ public class AdminController : ControllerBase
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var userId = _jellyfinSettings.UserId;
|
||||||
|
|
||||||
|
// Build URL with UserId if available
|
||||||
var url = $"{_jellyfinSettings.Url}/Items?searchTerm={Uri.EscapeDataString(query)}&includeItemTypes=Audio&recursive=true&limit=20";
|
var url = $"{_jellyfinSettings.Url}/Items?searchTerm={Uri.EscapeDataString(query)}&includeItemTypes=Audio&recursive=true&limit=20";
|
||||||
|
if (!string.IsNullOrEmpty(userId))
|
||||||
|
{
|
||||||
|
url += $"&UserId={userId}";
|
||||||
|
}
|
||||||
|
|
||||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
||||||
|
|
||||||
|
_logger.LogDebug("Searching Jellyfin: {Url}", url);
|
||||||
|
|
||||||
var response = await _jellyfinHttpClient.SendAsync(request);
|
var response = await _jellyfinHttpClient.SendAsync(request);
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
|
var errorBody = await response.Content.ReadAsStringAsync();
|
||||||
|
_logger.LogWarning("Jellyfin search failed: {StatusCode} - {Error}", response.StatusCode, errorBody);
|
||||||
return StatusCode((int)response.StatusCode, new { error = "Failed to search Jellyfin" });
|
return StatusCode((int)response.StatusCode, new { error = "Failed to search Jellyfin" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,6 +1059,14 @@ public class AdminController : ControllerBase
|
|||||||
{
|
{
|
||||||
foreach (var item in items.EnumerateArray())
|
foreach (var item in items.EnumerateArray())
|
||||||
{
|
{
|
||||||
|
// Verify it's actually an Audio item
|
||||||
|
var type = item.TryGetProperty("Type", out var typeEl) ? typeEl.GetString() : "";
|
||||||
|
if (type != "Audio")
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Skipping non-audio item: {Type}", type);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
var id = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() : "";
|
var id = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() : "";
|
||||||
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : "";
|
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : "";
|
||||||
var album = item.TryGetProperty("Album", out var albumEl) ? albumEl.GetString() : "";
|
var album = item.TryGetProperty("Album", out var albumEl) ? albumEl.GetString() : "";
|
||||||
@@ -554,32 +1095,249 @@ public class AdminController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Save manual track mapping
|
/// Get track details by Jellyfin ID (for URL-based mapping)
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("jellyfin/track/{id}")]
|
||||||
|
public async Task<IActionResult> GetJellyfinTrack(string id)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(id))
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Track ID is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var userId = _jellyfinSettings.UserId;
|
||||||
|
|
||||||
|
var url = $"{_jellyfinSettings.Url}/Items/{id}";
|
||||||
|
if (!string.IsNullOrEmpty(userId))
|
||||||
|
{
|
||||||
|
url += $"?UserId={userId}";
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
|
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
||||||
|
|
||||||
|
_logger.LogDebug("Fetching Jellyfin track {Id} from {Url}", id, url);
|
||||||
|
|
||||||
|
var response = await _jellyfinHttpClient.SendAsync(request);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var errorBody = await response.Content.ReadAsStringAsync();
|
||||||
|
_logger.LogWarning("Failed to fetch Jellyfin track {Id}: {StatusCode} - {Error}",
|
||||||
|
id, response.StatusCode, errorBody);
|
||||||
|
return StatusCode((int)response.StatusCode, new { error = "Track not found in Jellyfin" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
|
||||||
|
var item = doc.RootElement;
|
||||||
|
|
||||||
|
// Verify it's an Audio item
|
||||||
|
var type = item.TryGetProperty("Type", out var typeEl) ? typeEl.GetString() : "";
|
||||||
|
if (type != "Audio")
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Item {Id} is not an Audio track, it's a {Type}", id, type);
|
||||||
|
return BadRequest(new { error = $"Item is not an audio track (it's a {type})" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var trackId = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() : "";
|
||||||
|
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : "";
|
||||||
|
var album = item.TryGetProperty("Album", out var albumEl) ? albumEl.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() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Found Jellyfin track: {Title} by {Artist}", title, artist);
|
||||||
|
|
||||||
|
return Ok(new { id = trackId, title, artist, album });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to get Jellyfin track {Id}", id);
|
||||||
|
return StatusCode(500, new { error = "Failed to get track details" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Save manual track mapping (local Jellyfin or external provider)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("playlists/{name}/map")]
|
[HttpPost("playlists/{name}/map")]
|
||||||
public async Task<IActionResult> SaveManualMapping(string name, [FromBody] ManualMappingRequest request)
|
public async Task<IActionResult> SaveManualMapping(string name, [FromBody] ManualMappingRequest request)
|
||||||
{
|
{
|
||||||
var decodedName = Uri.UnescapeDataString(name);
|
var decodedName = Uri.UnescapeDataString(name);
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(request.SpotifyId) || string.IsNullOrWhiteSpace(request.JellyfinId))
|
if (string.IsNullOrWhiteSpace(request.SpotifyId))
|
||||||
{
|
{
|
||||||
return BadRequest(new { error = "SpotifyId and JellyfinId are required" });
|
return BadRequest(new { error = "SpotifyId is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that either Jellyfin mapping or external mapping is provided
|
||||||
|
var hasJellyfinMapping = !string.IsNullOrWhiteSpace(request.JellyfinId);
|
||||||
|
var hasExternalMapping = !string.IsNullOrWhiteSpace(request.ExternalProvider) && !string.IsNullOrWhiteSpace(request.ExternalId);
|
||||||
|
|
||||||
|
if (!hasJellyfinMapping && !hasExternalMapping)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Either JellyfinId or (ExternalProvider + ExternalId) is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasJellyfinMapping && hasExternalMapping)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Cannot specify both Jellyfin and external mapping for the same track" });
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Store mapping in cache (you could also persist to a file)
|
string? normalizedProvider = null;
|
||||||
|
|
||||||
|
if (hasJellyfinMapping)
|
||||||
|
{
|
||||||
|
// Store Jellyfin mapping in cache (NO EXPIRATION - manual mappings are permanent)
|
||||||
var mappingKey = $"spotify:manual-map:{decodedName}:{request.SpotifyId}";
|
var mappingKey = $"spotify:manual-map:{decodedName}:{request.SpotifyId}";
|
||||||
await _cache.SetAsync(mappingKey, request.JellyfinId, TimeSpan.FromDays(365)); // Long TTL
|
await _cache.SetAsync(mappingKey, request.JellyfinId!);
|
||||||
|
|
||||||
_logger.LogInformation("Manual mapping saved: {Playlist} - Spotify {SpotifyId} → Jellyfin {JellyfinId}",
|
// Also save to file for persistence across restarts
|
||||||
|
await SaveManualMappingToFileAsync(decodedName, request.SpotifyId, request.JellyfinId!, null, null);
|
||||||
|
|
||||||
|
_logger.LogInformation("Manual Jellyfin mapping saved: {Playlist} - Spotify {SpotifyId} → Jellyfin {JellyfinId}",
|
||||||
decodedName, request.SpotifyId, request.JellyfinId);
|
decodedName, request.SpotifyId, request.JellyfinId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Store external mapping in cache (NO EXPIRATION - manual mappings are permanent)
|
||||||
|
var externalMappingKey = $"spotify:external-map:{decodedName}:{request.SpotifyId}";
|
||||||
|
normalizedProvider = request.ExternalProvider!.ToLowerInvariant(); // Normalize to lowercase
|
||||||
|
var externalMapping = new { provider = normalizedProvider, id = request.ExternalId };
|
||||||
|
await _cache.SetAsync(externalMappingKey, externalMapping);
|
||||||
|
|
||||||
// Clear the matched tracks cache to force re-matching
|
// Also save to file for persistence across restarts
|
||||||
var cacheKey = $"spotify:matched:{decodedName}";
|
await SaveManualMappingToFileAsync(decodedName, request.SpotifyId, null, normalizedProvider, request.ExternalId!);
|
||||||
await _cache.DeleteAsync(cacheKey);
|
|
||||||
|
|
||||||
return Ok(new { message = "Mapping saved successfully" });
|
_logger.LogInformation("Manual external mapping saved: {Playlist} - Spotify {SpotifyId} → {Provider} {ExternalId}",
|
||||||
|
decodedName, request.SpotifyId, normalizedProvider, request.ExternalId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear all related caches to force rebuild
|
||||||
|
var matchedCacheKey = $"spotify:matched:{decodedName}";
|
||||||
|
var orderedCacheKey = $"spotify:matched:ordered:{decodedName}";
|
||||||
|
var playlistItemsKey = $"spotify:playlist:items:{decodedName}";
|
||||||
|
|
||||||
|
await _cache.DeleteAsync(matchedCacheKey);
|
||||||
|
await _cache.DeleteAsync(orderedCacheKey);
|
||||||
|
await _cache.DeleteAsync(playlistItemsKey);
|
||||||
|
|
||||||
|
// Also delete file caches to force rebuild
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cacheDir = "/app/cache/spotify";
|
||||||
|
var safeName = string.Join("_", decodedName.Split(Path.GetInvalidFileNameChars()));
|
||||||
|
var matchedFile = Path.Combine(cacheDir, $"{safeName}_matched.json");
|
||||||
|
var itemsFile = Path.Combine(cacheDir, $"{safeName}_items.json");
|
||||||
|
|
||||||
|
if (System.IO.File.Exists(matchedFile))
|
||||||
|
{
|
||||||
|
System.IO.File.Delete(matchedFile);
|
||||||
|
_logger.LogDebug("Deleted matched tracks file cache for {Playlist}", decodedName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (System.IO.File.Exists(itemsFile))
|
||||||
|
{
|
||||||
|
System.IO.File.Delete(itemsFile);
|
||||||
|
_logger.LogDebug("Deleted playlist items file cache for {Playlist}", decodedName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to delete file caches for {Playlist}", decodedName);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Cleared playlist caches for {Playlist} to force rebuild", decodedName);
|
||||||
|
|
||||||
|
// Fetch external provider track details to return to the UI (only for external mappings)
|
||||||
|
string? trackTitle = null;
|
||||||
|
string? trackArtist = null;
|
||||||
|
string? trackAlbum = null;
|
||||||
|
|
||||||
|
if (hasExternalMapping && normalizedProvider != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var metadataService = HttpContext.RequestServices.GetRequiredService<IMusicMetadataService>();
|
||||||
|
var externalSong = await metadataService.GetSongAsync(normalizedProvider, request.ExternalId!);
|
||||||
|
|
||||||
|
if (externalSong != null)
|
||||||
|
{
|
||||||
|
trackTitle = externalSong.Title;
|
||||||
|
trackArtist = externalSong.Artist;
|
||||||
|
trackAlbum = externalSong.Album;
|
||||||
|
_logger.LogInformation("✓ Fetched external track metadata: {Title} by {Artist}", trackTitle, trackArtist);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Failed to fetch external track metadata for {Provider} ID {Id}",
|
||||||
|
normalizedProvider, request.ExternalId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to fetch external track metadata, but mapping was saved");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger immediate playlist rebuild with the new mapping
|
||||||
|
if (_matchingService != null)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Triggering immediate playlist rebuild for {Playlist} with new manual mapping", decodedName);
|
||||||
|
|
||||||
|
// Run rebuild in background with timeout to avoid blocking the response
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2)); // 2 minute timeout
|
||||||
|
await _matchingService.TriggerMatchingForPlaylistAsync(decodedName);
|
||||||
|
_logger.LogInformation("✓ Playlist {Playlist} rebuilt successfully with manual mapping", decodedName);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Playlist rebuild for {Playlist} timed out after 2 minutes", decodedName);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to rebuild playlist {Playlist} after manual mapping", decodedName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Matching service not available - playlist will rebuild on next scheduled run");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return success with track details if available
|
||||||
|
var mappedTrack = new
|
||||||
|
{
|
||||||
|
id = request.ExternalId,
|
||||||
|
title = trackTitle ?? "Unknown",
|
||||||
|
artist = trackArtist ?? "Unknown",
|
||||||
|
album = trackAlbum ?? "Unknown",
|
||||||
|
isLocal = false,
|
||||||
|
externalProvider = request.ExternalProvider!.ToLowerInvariant()
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
message = "Mapping saved and playlist rebuild triggered",
|
||||||
|
track = mappedTrack,
|
||||||
|
rebuildTriggered = _matchingService != null
|
||||||
|
});
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -588,12 +1346,6 @@ public class AdminController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ManualMappingRequest
|
|
||||||
{
|
|
||||||
public string SpotifyId { get; set; } = "";
|
|
||||||
public string JellyfinId { get; set; } = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Trigger track matching for all playlists
|
/// Trigger track matching for all playlists
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -639,9 +1391,7 @@ public class AdminController : ControllerBase
|
|||||||
spotifyImport = new
|
spotifyImport = new
|
||||||
{
|
{
|
||||||
enabled = _spotifyImportSettings.Enabled,
|
enabled = _spotifyImportSettings.Enabled,
|
||||||
syncStartHour = _spotifyImportSettings.SyncStartHour,
|
matchingIntervalHours = _spotifyImportSettings.MatchingIntervalHours,
|
||||||
syncStartMinute = _spotifyImportSettings.SyncStartMinute,
|
|
||||||
syncWindowHours = _spotifyImportSettings.SyncWindowHours,
|
|
||||||
playlists = _spotifyImportSettings.Playlists.Select(p => new
|
playlists = _spotifyImportSettings.Playlists.Select(p => new
|
||||||
{
|
{
|
||||||
name = p.Name,
|
name = p.Name,
|
||||||
@@ -656,6 +1406,16 @@ public class AdminController : ControllerBase
|
|||||||
userId = _jellyfinSettings.UserId ?? "(not set)",
|
userId = _jellyfinSettings.UserId ?? "(not set)",
|
||||||
libraryId = _jellyfinSettings.LibraryId
|
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
|
deezer = new
|
||||||
{
|
{
|
||||||
arl = MaskValue(_deezerSettings.Arl, showLast: 8),
|
arl = MaskValue(_deezerSettings.Arl, showLast: 8),
|
||||||
@@ -671,6 +1431,14 @@ public class AdminController : ControllerBase
|
|||||||
squidWtf = new
|
squidWtf = new
|
||||||
{
|
{
|
||||||
quality = _squidWtfSettings.Quality ?? "LOSSLESS"
|
quality = _squidWtfSettings.Quality ?? "LOSSLESS"
|
||||||
|
},
|
||||||
|
musicBrainz = new
|
||||||
|
{
|
||||||
|
enabled = _musicBrainzSettings.Enabled,
|
||||||
|
username = _musicBrainzSettings.Username ?? "(not set)",
|
||||||
|
password = MaskValue(_musicBrainzSettings.Password),
|
||||||
|
baseUrl = _musicBrainzSettings.BaseUrl,
|
||||||
|
rateLimitMs = _musicBrainzSettings.RateLimitMs
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -896,7 +1664,7 @@ public class AdminController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Clear ALL Redis cache keys for Spotify playlists
|
// Clear ALL Redis cache keys for Spotify playlists
|
||||||
// This includes matched tracks, ordered tracks, missing tracks, etc.
|
// This includes matched tracks, ordered tracks, missing tracks, playlist items, etc.
|
||||||
foreach (var playlist in _spotifyImportSettings.Playlists)
|
foreach (var playlist in _spotifyImportSettings.Playlists)
|
||||||
{
|
{
|
||||||
var keysToDelete = new[]
|
var keysToDelete = new[]
|
||||||
@@ -904,7 +1672,8 @@ public class AdminController : ControllerBase
|
|||||||
$"spotify:playlist:{playlist.Name}",
|
$"spotify:playlist:{playlist.Name}",
|
||||||
$"spotify:missing:{playlist.Name}",
|
$"spotify:missing:{playlist.Name}",
|
||||||
$"spotify:matched:{playlist.Name}",
|
$"spotify:matched:{playlist.Name}",
|
||||||
$"spotify:matched:ordered:{playlist.Name}"
|
$"spotify:matched:ordered:{playlist.Name}",
|
||||||
|
$"spotify:playlist:items:{playlist.Name}" // NEW: Clear file-backed playlist items cache
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach (var key in keysToDelete)
|
foreach (var key in keysToDelete)
|
||||||
@@ -917,7 +1686,16 @@ public class AdminController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Cache cleared: {Files} files, {RedisKeys} Redis keys", clearedFiles, clearedRedisKeys);
|
// Clear all search cache keys (pattern-based deletion)
|
||||||
|
var searchKeysDeleted = await _cache.DeleteByPatternAsync("search:*");
|
||||||
|
clearedRedisKeys += searchKeysDeleted;
|
||||||
|
|
||||||
|
// Clear all image cache keys (pattern-based deletion)
|
||||||
|
var imageKeysDeleted = await _cache.DeleteByPatternAsync("image:*");
|
||||||
|
clearedRedisKeys += imageKeysDeleted;
|
||||||
|
|
||||||
|
_logger.LogInformation("Cache cleared: {Files} files, {RedisKeys} Redis keys (including {SearchKeys} search keys, {ImageKeys} image keys)",
|
||||||
|
clearedFiles, clearedRedisKeys, searchKeysDeleted, imageKeysDeleted);
|
||||||
|
|
||||||
return Ok(new {
|
return Ok(new {
|
||||||
message = "Cache cleared successfully",
|
message = "Cache cleared successfully",
|
||||||
@@ -1204,8 +1982,13 @@ public class AdminController : ControllerBase
|
|||||||
var isConfigured = configuredPlaylist != null;
|
var isConfigured = configuredPlaylist != null;
|
||||||
var linkedSpotifyId = configuredPlaylist?.Id;
|
var linkedSpotifyId = configuredPlaylist?.Id;
|
||||||
|
|
||||||
// Fetch track details to categorize local vs external
|
// Only fetch detailed track stats for configured Spotify playlists
|
||||||
var trackStats = await GetPlaylistTrackStats(id!);
|
// 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
|
playlists.Add(new
|
||||||
{
|
{
|
||||||
@@ -1491,6 +2274,1009 @@ public class AdminController : ControllerBase
|
|||||||
// Only allow alphanumeric, underscore, and must start with letter/underscore
|
// Only allow alphanumeric, underscore, and must start with letter/underscore
|
||||||
return Regex.IsMatch(key, @"^[A-Z_][A-Z0-9_]*$", RegexOptions.IgnoreCase);
|
return Regex.IsMatch(key, @"^[A-Z_][A-Z0-9_]*$", RegexOptions.IgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Export .env file for backup/transfer
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("export-env")]
|
||||||
|
public IActionResult ExportEnv()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!System.IO.File.Exists(_envFilePath))
|
||||||
|
{
|
||||||
|
return NotFound(new { error = ".env file not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var envContent = System.IO.File.ReadAllText(_envFilePath);
|
||||||
|
var bytes = System.Text.Encoding.UTF8.GetBytes(envContent);
|
||||||
|
|
||||||
|
return File(bytes, "text/plain", ".env");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to export .env file");
|
||||||
|
return StatusCode(500, new { error = "Failed to export .env file", details = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Import .env file from upload
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("import-env")]
|
||||||
|
public async Task<IActionResult> ImportEnv([FromForm] IFormFile file)
|
||||||
|
{
|
||||||
|
if (file == null || file.Length == 0)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "No file provided" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file.FileName.EndsWith(".env"))
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "File must be a .env file" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Read uploaded file
|
||||||
|
using var reader = new StreamReader(file.OpenReadStream());
|
||||||
|
var content = await reader.ReadToEndAsync();
|
||||||
|
|
||||||
|
// Validate it's a valid .env file (basic check)
|
||||||
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = ".env file is empty" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup existing .env
|
||||||
|
if (System.IO.File.Exists(_envFilePath))
|
||||||
|
{
|
||||||
|
var backupPath = $"{_envFilePath}.backup.{DateTime.UtcNow:yyyyMMddHHmmss}";
|
||||||
|
System.IO.File.Copy(_envFilePath, backupPath, true);
|
||||||
|
_logger.LogInformation("Backed up existing .env to {BackupPath}", backupPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write new .env file
|
||||||
|
await System.IO.File.WriteAllTextAsync(_envFilePath, content);
|
||||||
|
|
||||||
|
_logger.LogInformation(".env file imported successfully");
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
success = true,
|
||||||
|
message = ".env file imported successfully. Restart the application for changes to take effect."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to import .env file");
|
||||||
|
return StatusCode(500, new { error = "Failed to import .env file", details = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets detailed memory usage statistics for debugging.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("memory-stats")]
|
||||||
|
public IActionResult GetMemoryStats()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Get memory stats BEFORE GC
|
||||||
|
var memoryBeforeGC = GC.GetTotalMemory(false);
|
||||||
|
var gen0Before = GC.CollectionCount(0);
|
||||||
|
var gen1Before = GC.CollectionCount(1);
|
||||||
|
var gen2Before = GC.CollectionCount(2);
|
||||||
|
|
||||||
|
// Force garbage collection to get accurate numbers
|
||||||
|
GC.Collect();
|
||||||
|
GC.WaitForPendingFinalizers();
|
||||||
|
GC.Collect();
|
||||||
|
|
||||||
|
var memoryAfterGC = GC.GetTotalMemory(false);
|
||||||
|
var gen0After = GC.CollectionCount(0);
|
||||||
|
var gen1After = GC.CollectionCount(1);
|
||||||
|
var gen2After = GC.CollectionCount(2);
|
||||||
|
|
||||||
|
// Get process memory info
|
||||||
|
var process = System.Diagnostics.Process.GetCurrentProcess();
|
||||||
|
|
||||||
|
return Ok(new {
|
||||||
|
Timestamp = DateTime.UtcNow,
|
||||||
|
BeforeGC = new {
|
||||||
|
GCMemoryBytes = memoryBeforeGC,
|
||||||
|
GCMemoryMB = Math.Round(memoryBeforeGC / (1024.0 * 1024.0), 2)
|
||||||
|
},
|
||||||
|
AfterGC = new {
|
||||||
|
GCMemoryBytes = memoryAfterGC,
|
||||||
|
GCMemoryMB = Math.Round(memoryAfterGC / (1024.0 * 1024.0), 2)
|
||||||
|
},
|
||||||
|
MemoryFreedMB = Math.Round((memoryBeforeGC - memoryAfterGC) / (1024.0 * 1024.0), 2),
|
||||||
|
ProcessWorkingSetBytes = process.WorkingSet64,
|
||||||
|
ProcessWorkingSetMB = Math.Round(process.WorkingSet64 / (1024.0 * 1024.0), 2),
|
||||||
|
ProcessPrivateMemoryBytes = process.PrivateMemorySize64,
|
||||||
|
ProcessPrivateMemoryMB = Math.Round(process.PrivateMemorySize64 / (1024.0 * 1024.0), 2),
|
||||||
|
ProcessVirtualMemoryBytes = process.VirtualMemorySize64,
|
||||||
|
ProcessVirtualMemoryMB = Math.Round(process.VirtualMemorySize64 / (1024.0 * 1024.0), 2),
|
||||||
|
GCCollections = new {
|
||||||
|
Gen0Before = gen0Before,
|
||||||
|
Gen0After = gen0After,
|
||||||
|
Gen0Triggered = gen0After - gen0Before,
|
||||||
|
Gen1Before = gen1Before,
|
||||||
|
Gen1After = gen1After,
|
||||||
|
Gen1Triggered = gen1After - gen1Before,
|
||||||
|
Gen2Before = gen2Before,
|
||||||
|
Gen2After = gen2After,
|
||||||
|
Gen2Triggered = gen2After - gen2Before
|
||||||
|
},
|
||||||
|
GCMode = GCSettings.IsServerGC ? "Server" : "Workstation",
|
||||||
|
GCLatencyMode = GCSettings.LatencyMode.ToString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Forces garbage collection to free up memory (emergency use only).
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("force-gc")]
|
||||||
|
public IActionResult ForceGarbageCollection()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var memoryBefore = GC.GetTotalMemory(false);
|
||||||
|
var processBefore = System.Diagnostics.Process.GetCurrentProcess().WorkingSet64;
|
||||||
|
|
||||||
|
// Force full garbage collection
|
||||||
|
GC.Collect(2, GCCollectionMode.Forced);
|
||||||
|
GC.WaitForPendingFinalizers();
|
||||||
|
GC.Collect(2, GCCollectionMode.Forced);
|
||||||
|
|
||||||
|
var memoryAfter = GC.GetTotalMemory(false);
|
||||||
|
var processAfter = System.Diagnostics.Process.GetCurrentProcess().WorkingSet64;
|
||||||
|
|
||||||
|
return Ok(new {
|
||||||
|
Timestamp = DateTime.UtcNow,
|
||||||
|
MemoryFreedMB = Math.Round((memoryBefore - memoryAfter) / (1024.0 * 1024.0), 2),
|
||||||
|
ProcessMemoryFreedMB = Math.Round((processBefore - processAfter) / (1024.0 * 1024.0), 2),
|
||||||
|
BeforeGCMB = Math.Round(memoryBefore / (1024.0 * 1024.0), 2),
|
||||||
|
AfterGCMB = Math.Round(memoryAfter / (1024.0 * 1024.0), 2),
|
||||||
|
BeforeProcessMB = Math.Round(processBefore / (1024.0 * 1024.0), 2),
|
||||||
|
AfterProcessMB = Math.Round(processAfter / (1024.0 * 1024.0), 2)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets current active sessions for debugging.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("sessions")]
|
||||||
|
public IActionResult GetActiveSessions()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sessionManager = HttpContext.RequestServices.GetService<JellyfinSessionManager>();
|
||||||
|
if (sessionManager == null)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Session manager not available" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessionInfo = sessionManager.GetSessionsInfo();
|
||||||
|
return Ok(sessionInfo);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper method to trigger GC after large file operations to prevent memory leaks.
|
||||||
|
/// </summary>
|
||||||
|
private static void TriggerGCAfterLargeOperation(int sizeInBytes)
|
||||||
|
{
|
||||||
|
// Only trigger GC for files larger than 1MB to avoid performance impact
|
||||||
|
if (sizeInBytes > 1024 * 1024)
|
||||||
|
{
|
||||||
|
// Suggest GC collection for large objects (they go to LOH and aren't collected as frequently)
|
||||||
|
GC.Collect(2, GCCollectionMode.Optimized, blocking: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Spotify Admin Endpoints
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manual trigger endpoint to force fetch Spotify missing tracks.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("spotify/sync")]
|
||||||
|
public async Task<IActionResult> TriggerSpotifySync([FromServices] IEnumerable<IHostedService> hostedServices)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!_spotifyImportSettings.Enabled)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Spotify Import is not enabled" });
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Manual Spotify sync triggered via admin endpoint");
|
||||||
|
|
||||||
|
// Find the SpotifyMissingTracksFetcher service
|
||||||
|
var fetcherService = hostedServices
|
||||||
|
.OfType<allstarr.Services.Spotify.SpotifyMissingTracksFetcher>()
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (fetcherService == null)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "SpotifyMissingTracksFetcher service not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger the sync in background
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Use reflection to call the private ExecuteOnceAsync method
|
||||||
|
var method = fetcherService.GetType().GetMethod("ExecuteOnceAsync",
|
||||||
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||||
|
|
||||||
|
if (method != null)
|
||||||
|
{
|
||||||
|
await (Task)method.Invoke(fetcherService, new object[] { CancellationToken.None })!;
|
||||||
|
_logger.LogInformation("Manual Spotify sync completed successfully");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogError("Could not find ExecuteOnceAsync method on SpotifyMissingTracksFetcher");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error during manual Spotify sync");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Ok(new {
|
||||||
|
message = "Spotify sync started in background",
|
||||||
|
timestamp = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error triggering Spotify sync");
|
||||||
|
return StatusCode(500, new { error = "Internal server error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manual trigger endpoint to force Spotify track matching.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("spotify/match")]
|
||||||
|
public async Task<IActionResult> TriggerSpotifyMatch([FromServices] IEnumerable<IHostedService> hostedServices)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!_spotifyApiSettings.Enabled)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Spotify API is not enabled" });
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Manual Spotify track matching triggered via admin endpoint");
|
||||||
|
|
||||||
|
// Find the SpotifyTrackMatchingService
|
||||||
|
var matchingService = hostedServices
|
||||||
|
.OfType<allstarr.Services.Spotify.SpotifyTrackMatchingService>()
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (matchingService == null)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "SpotifyTrackMatchingService not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger matching in background
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Use reflection to call the private ExecuteOnceAsync method
|
||||||
|
var method = matchingService.GetType().GetMethod("ExecuteOnceAsync",
|
||||||
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||||
|
|
||||||
|
if (method != null)
|
||||||
|
{
|
||||||
|
await (Task)method.Invoke(matchingService, new object[] { CancellationToken.None })!;
|
||||||
|
_logger.LogInformation("Manual Spotify track matching completed successfully");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogError("Could not find ExecuteOnceAsync method on SpotifyTrackMatchingService");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error during manual Spotify track matching");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Ok(new {
|
||||||
|
message = "Spotify track matching started in background",
|
||||||
|
timestamp = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error triggering Spotify track matching");
|
||||||
|
return StatusCode(500, new { error = "Internal server error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clear Spotify playlist cache to force re-matching.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("spotify/clear-cache")]
|
||||||
|
public async Task<IActionResult> ClearSpotifyCache()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var clearedKeys = new List<string>();
|
||||||
|
|
||||||
|
// Clear Redis cache for all configured playlists
|
||||||
|
foreach (var playlist in _spotifyImportSettings.Playlists)
|
||||||
|
{
|
||||||
|
var keys = new[]
|
||||||
|
{
|
||||||
|
$"spotify:playlist:{playlist.Name}",
|
||||||
|
$"spotify:playlist:items:{playlist.Name}",
|
||||||
|
$"spotify:matched:{playlist.Name}"
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var key in keys)
|
||||||
|
{
|
||||||
|
await _cache.DeleteAsync(key);
|
||||||
|
clearedKeys.Add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Cleared Spotify cache for {Count} keys via admin endpoint", clearedKeys.Count);
|
||||||
|
|
||||||
|
return Ok(new {
|
||||||
|
message = "Spotify cache cleared successfully",
|
||||||
|
clearedKeys = clearedKeys,
|
||||||
|
timestamp = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error clearing Spotify cache");
|
||||||
|
return StatusCode(500, new { error = "Internal server error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Debug Endpoints
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets endpoint usage statistics from the log file.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("debug/endpoint-usage")]
|
||||||
|
public async Task<IActionResult> GetEndpointUsage(
|
||||||
|
[FromQuery] int top = 100,
|
||||||
|
[FromQuery] string? since = null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var logFile = "/app/cache/endpoint-usage/endpoints.csv";
|
||||||
|
|
||||||
|
if (!System.IO.File.Exists(logFile))
|
||||||
|
{
|
||||||
|
return Ok(new {
|
||||||
|
message = "No endpoint usage data available",
|
||||||
|
endpoints = new object[0]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines = await System.IO.File.ReadAllLinesAsync(logFile);
|
||||||
|
var usage = new Dictionary<string, int>();
|
||||||
|
DateTime? sinceDate = null;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(since) && DateTime.TryParse(since, out var parsedDate))
|
||||||
|
{
|
||||||
|
sinceDate = parsedDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var line in lines.Skip(1)) // Skip header
|
||||||
|
{
|
||||||
|
var parts = line.Split(',');
|
||||||
|
if (parts.Length >= 3)
|
||||||
|
{
|
||||||
|
var timestamp = parts[0];
|
||||||
|
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))
|
||||||
|
{
|
||||||
|
if (logDate < sinceDate.Value)
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
usage[fullEndpoint] = usage.GetValueOrDefault(fullEndpoint, 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var topEndpoints = usage
|
||||||
|
.OrderByDescending(kv => kv.Value)
|
||||||
|
.Take(top)
|
||||||
|
.Select(kv => new { endpoint = kv.Key, count = kv.Value })
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return Ok(new {
|
||||||
|
totalEndpoints = usage.Count,
|
||||||
|
totalRequests = usage.Values.Sum(),
|
||||||
|
since = since,
|
||||||
|
top = top,
|
||||||
|
endpoints = topEndpoints
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error getting endpoint usage");
|
||||||
|
return StatusCode(500, new { error = "Internal server error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clears the endpoint usage log file.
|
||||||
|
/// </summary>
|
||||||
|
[HttpDelete("debug/endpoint-usage")]
|
||||||
|
public IActionResult ClearEndpointUsage()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var logFile = "/app/cache/endpoint-usage/endpoints.csv";
|
||||||
|
|
||||||
|
if (System.IO.File.Exists(logFile))
|
||||||
|
{
|
||||||
|
System.IO.File.Delete(logFile);
|
||||||
|
_logger.LogInformation("Cleared endpoint usage log via admin endpoint");
|
||||||
|
|
||||||
|
return Ok(new {
|
||||||
|
message = "Endpoint usage log cleared successfully",
|
||||||
|
timestamp = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return Ok(new {
|
||||||
|
message = "No endpoint usage log file found",
|
||||||
|
timestamp = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error clearing endpoint usage log");
|
||||||
|
return StatusCode(500, new { error = "Internal server error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Private Helper Methods
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves a manual mapping to file for persistence across restarts.
|
||||||
|
/// Manual mappings NEVER expire - they are permanent user decisions.
|
||||||
|
/// </summary>
|
||||||
|
private async Task SaveManualMappingToFileAsync(
|
||||||
|
string playlistName,
|
||||||
|
string spotifyId,
|
||||||
|
string? jellyfinId,
|
||||||
|
string? externalProvider,
|
||||||
|
string? externalId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var mappingsDir = "/app/cache/mappings";
|
||||||
|
Directory.CreateDirectory(mappingsDir);
|
||||||
|
|
||||||
|
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
|
||||||
|
var filePath = Path.Combine(mappingsDir, $"{safeName}_mappings.json");
|
||||||
|
|
||||||
|
// Load existing mappings
|
||||||
|
var mappings = new Dictionary<string, ManualMappingEntry>();
|
||||||
|
if (System.IO.File.Exists(filePath))
|
||||||
|
{
|
||||||
|
var json = await System.IO.File.ReadAllTextAsync(filePath);
|
||||||
|
mappings = JsonSerializer.Deserialize<Dictionary<string, ManualMappingEntry>>(json)
|
||||||
|
?? new Dictionary<string, ManualMappingEntry>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add or update mapping
|
||||||
|
mappings[spotifyId] = new ManualMappingEntry
|
||||||
|
{
|
||||||
|
SpotifyId = spotifyId,
|
||||||
|
JellyfinId = jellyfinId,
|
||||||
|
ExternalProvider = externalProvider,
|
||||||
|
ExternalId = externalId,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save back to file
|
||||||
|
var updatedJson = JsonSerializer.Serialize(mappings, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
await System.IO.File.WriteAllTextAsync(filePath, updatedJson);
|
||||||
|
|
||||||
|
_logger.LogDebug("💾 Saved manual mapping to file: {Playlist} - {SpotifyId}", playlistName, spotifyId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to save manual mapping to file for {Playlist}", playlistName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Save lyrics mapping to file for persistence across restarts.
|
||||||
|
/// Lyrics mappings NEVER expire - they are permanent user decisions.
|
||||||
|
/// </summary>
|
||||||
|
private async Task SaveLyricsMappingToFileAsync(
|
||||||
|
string artist,
|
||||||
|
string title,
|
||||||
|
string album,
|
||||||
|
int durationSeconds,
|
||||||
|
int lyricsId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var mappingsFile = "/app/cache/lyrics_mappings.json";
|
||||||
|
|
||||||
|
// Load existing mappings
|
||||||
|
var mappings = new List<LyricsMappingEntry>();
|
||||||
|
if (System.IO.File.Exists(mappingsFile))
|
||||||
|
{
|
||||||
|
var json = await System.IO.File.ReadAllTextAsync(mappingsFile);
|
||||||
|
mappings = JsonSerializer.Deserialize<List<LyricsMappingEntry>>(json)
|
||||||
|
?? new List<LyricsMappingEntry>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove any existing mapping for this track
|
||||||
|
mappings.RemoveAll(m =>
|
||||||
|
m.Artist.Equals(artist, StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
m.Title.Equals(title, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
// Add new mapping
|
||||||
|
mappings.Add(new LyricsMappingEntry
|
||||||
|
{
|
||||||
|
Artist = artist,
|
||||||
|
Title = title,
|
||||||
|
Album = album,
|
||||||
|
DurationSeconds = durationSeconds,
|
||||||
|
LyricsId = lyricsId,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save back to file
|
||||||
|
var updatedJson = JsonSerializer.Serialize(mappings, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
await System.IO.File.WriteAllTextAsync(mappingsFile, updatedJson);
|
||||||
|
|
||||||
|
_logger.LogDebug("💾 Saved lyrics mapping to file: {Artist} - {Title} → Lyrics ID {LyricsId}",
|
||||||
|
artist, title, lyricsId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to save lyrics mapping to file for {Artist} - {Title}", artist, title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Save manual lyrics ID mapping for a track
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("lyrics/map")]
|
||||||
|
public async Task<IActionResult> SaveLyricsMapping([FromBody] LyricsMappingRequest request)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Artist) || string.IsNullOrWhiteSpace(request.Title))
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Artist and Title are required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.LyricsId <= 0)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Valid LyricsId is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Store lyrics mapping in cache (NO EXPIRATION - manual mappings are permanent)
|
||||||
|
var mappingKey = $"lyrics:manual-map:{request.Artist}:{request.Title}";
|
||||||
|
await _cache.SetStringAsync(mappingKey, request.LyricsId.ToString());
|
||||||
|
|
||||||
|
// Also save to file for persistence across restarts
|
||||||
|
await SaveLyricsMappingToFileAsync(request.Artist, request.Title, request.Album ?? "", request.DurationSeconds, request.LyricsId);
|
||||||
|
|
||||||
|
_logger.LogInformation("Manual lyrics mapping saved: {Artist} - {Title} → Lyrics ID {LyricsId}",
|
||||||
|
request.Artist, request.Title, request.LyricsId);
|
||||||
|
|
||||||
|
// Optionally fetch and cache the lyrics immediately
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var lyricsService = _serviceProvider.GetService<allstarr.Services.Lyrics.LrclibService>();
|
||||||
|
if (lyricsService != null)
|
||||||
|
{
|
||||||
|
var lyricsInfo = await lyricsService.GetLyricsByIdAsync(request.LyricsId);
|
||||||
|
if (lyricsInfo != null && !string.IsNullOrEmpty(lyricsInfo.PlainLyrics))
|
||||||
|
{
|
||||||
|
// Cache the lyrics using the standard cache key
|
||||||
|
var lyricsCacheKey = $"lyrics:{request.Artist}:{request.Title}:{request.Album ?? ""}:{request.DurationSeconds}";
|
||||||
|
await _cache.SetAsync(lyricsCacheKey, lyricsInfo.PlainLyrics);
|
||||||
|
_logger.LogInformation("✓ Fetched and cached lyrics for {Artist} - {Title}", request.Artist, request.Title);
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
message = "Lyrics mapping saved and lyrics cached successfully",
|
||||||
|
lyricsId = request.LyricsId,
|
||||||
|
cached = true,
|
||||||
|
lyrics = new
|
||||||
|
{
|
||||||
|
id = lyricsInfo.Id,
|
||||||
|
trackName = lyricsInfo.TrackName,
|
||||||
|
artistName = lyricsInfo.ArtistName,
|
||||||
|
albumName = lyricsInfo.AlbumName,
|
||||||
|
duration = lyricsInfo.Duration,
|
||||||
|
instrumental = lyricsInfo.Instrumental
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to fetch lyrics after mapping, but mapping was saved");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
message = "Lyrics mapping saved successfully",
|
||||||
|
lyricsId = request.LyricsId,
|
||||||
|
cached = false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to save lyrics mapping");
|
||||||
|
return StatusCode(500, new { error = "Failed to save lyrics mapping" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get manual lyrics mappings
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("lyrics/mappings")]
|
||||||
|
public async Task<IActionResult> GetLyricsMappings()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var mappingsFile = "/app/cache/lyrics_mappings.json";
|
||||||
|
|
||||||
|
if (!System.IO.File.Exists(mappingsFile))
|
||||||
|
{
|
||||||
|
return Ok(new { mappings = new List<object>() });
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = await System.IO.File.ReadAllTextAsync(mappingsFile);
|
||||||
|
var mappings = JsonSerializer.Deserialize<List<LyricsMappingEntry>>(json) ?? new List<LyricsMappingEntry>();
|
||||||
|
|
||||||
|
return Ok(new { mappings });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to get lyrics mappings");
|
||||||
|
return StatusCode(500, new { error = "Failed to get lyrics mappings" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get all manual track mappings (both Jellyfin and external) for all playlists
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("mappings/tracks")]
|
||||||
|
public async Task<IActionResult> GetAllTrackMappings()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var mappingsDir = "/app/cache/mappings";
|
||||||
|
var allMappings = new List<object>();
|
||||||
|
|
||||||
|
if (!Directory.Exists(mappingsDir))
|
||||||
|
{
|
||||||
|
return Ok(new { mappings = allMappings, totalCount = 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
var files = Directory.GetFiles(mappingsDir, "*_mappings.json");
|
||||||
|
|
||||||
|
foreach (var file in files)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = await System.IO.File.ReadAllTextAsync(file);
|
||||||
|
var playlistMappings = JsonSerializer.Deserialize<Dictionary<string, ManualMappingEntry>>(json);
|
||||||
|
|
||||||
|
if (playlistMappings != null)
|
||||||
|
{
|
||||||
|
var fileName = Path.GetFileNameWithoutExtension(file);
|
||||||
|
var playlistName = fileName.Replace("_mappings", "").Replace("_", " ");
|
||||||
|
|
||||||
|
foreach (var mapping in playlistMappings.Values)
|
||||||
|
{
|
||||||
|
allMappings.Add(new
|
||||||
|
{
|
||||||
|
playlist = playlistName,
|
||||||
|
spotifyId = mapping.SpotifyId,
|
||||||
|
type = !string.IsNullOrEmpty(mapping.JellyfinId) ? "jellyfin" : "external",
|
||||||
|
jellyfinId = mapping.JellyfinId,
|
||||||
|
externalProvider = mapping.ExternalProvider,
|
||||||
|
externalId = mapping.ExternalId,
|
||||||
|
createdAt = mapping.CreatedAt
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to read mapping file {File}", file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
mappings = allMappings.OrderBy(m => ((dynamic)m).playlist).ThenBy(m => ((dynamic)m).createdAt),
|
||||||
|
totalCount = allMappings.Count,
|
||||||
|
jellyfinCount = allMappings.Count(m => ((dynamic)m).type == "jellyfin"),
|
||||||
|
externalCount = allMappings.Count(m => ((dynamic)m).type == "external")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to get track mappings");
|
||||||
|
return StatusCode(500, new { error = "Failed to get track mappings" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Delete a manual track mapping
|
||||||
|
/// </summary>
|
||||||
|
[HttpDelete("mappings/tracks")]
|
||||||
|
public async Task<IActionResult> DeleteTrackMapping([FromQuery] string playlist, [FromQuery] string spotifyId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(playlist) || string.IsNullOrEmpty(spotifyId))
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "playlist and spotifyId parameters are required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var mappingsDir = "/app/cache/mappings";
|
||||||
|
var safeName = string.Join("_", playlist.Split(Path.GetInvalidFileNameChars()));
|
||||||
|
var filePath = Path.Combine(mappingsDir, $"{safeName}_mappings.json");
|
||||||
|
|
||||||
|
if (!System.IO.File.Exists(filePath))
|
||||||
|
{
|
||||||
|
return NotFound(new { error = "Mapping file not found for playlist" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load existing mappings
|
||||||
|
var json = await System.IO.File.ReadAllTextAsync(filePath);
|
||||||
|
var mappings = JsonSerializer.Deserialize<Dictionary<string, ManualMappingEntry>>(json);
|
||||||
|
|
||||||
|
if (mappings == null || !mappings.ContainsKey(spotifyId))
|
||||||
|
{
|
||||||
|
return NotFound(new { error = "Mapping not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the mapping
|
||||||
|
mappings.Remove(spotifyId);
|
||||||
|
|
||||||
|
// Save back to file (or delete file if empty)
|
||||||
|
if (mappings.Count == 0)
|
||||||
|
{
|
||||||
|
System.IO.File.Delete(filePath);
|
||||||
|
_logger.LogInformation("🗑️ Deleted empty mapping file for playlist {Playlist}", playlist);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var updatedJson = JsonSerializer.Serialize(mappings, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
await System.IO.File.WriteAllTextAsync(filePath, updatedJson);
|
||||||
|
_logger.LogInformation("🗑️ Deleted mapping: {Playlist} - {SpotifyId}", playlist, spotifyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also remove from Redis cache
|
||||||
|
var cacheKey = $"manual:mapping:{playlist}:{spotifyId}";
|
||||||
|
await _cache.DeleteAsync(cacheKey);
|
||||||
|
|
||||||
|
return Ok(new { success = true, message = "Mapping deleted successfully" });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to delete track mapping for {Playlist} - {SpotifyId}", playlist, spotifyId);
|
||||||
|
return StatusCode(500, new { error = "Failed to delete track mapping" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test Spotify lyrics API by fetching lyrics for a specific Spotify track ID
|
||||||
|
/// Example: GET /api/admin/lyrics/spotify/test?trackId=3yII7UwgLF6K5zW3xad3MP
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("lyrics/spotify/test")]
|
||||||
|
public async Task<IActionResult> TestSpotifyLyrics([FromQuery] string trackId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(trackId))
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "trackId parameter is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var spotifyLyricsService = _serviceProvider.GetService<allstarr.Services.Lyrics.SpotifyLyricsService>();
|
||||||
|
|
||||||
|
if (spotifyLyricsService == null)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new { error = "Spotify lyrics service not available" });
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Testing Spotify lyrics for track ID: {TrackId}", trackId);
|
||||||
|
|
||||||
|
var result = await spotifyLyricsService.GetLyricsByTrackIdAsync(trackId);
|
||||||
|
|
||||||
|
if (result == null)
|
||||||
|
{
|
||||||
|
return NotFound(new
|
||||||
|
{
|
||||||
|
error = "No lyrics found",
|
||||||
|
trackId,
|
||||||
|
message = "Lyrics may not be available for this track, or the Spotify API is not configured correctly"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
success = true,
|
||||||
|
trackId = result.SpotifyTrackId,
|
||||||
|
syncType = result.SyncType,
|
||||||
|
lineCount = result.Lines.Count,
|
||||||
|
language = result.Language,
|
||||||
|
provider = result.Provider,
|
||||||
|
providerDisplayName = result.ProviderDisplayName,
|
||||||
|
lines = result.Lines.Select(l => new
|
||||||
|
{
|
||||||
|
startTimeMs = l.StartTimeMs,
|
||||||
|
endTimeMs = l.EndTimeMs,
|
||||||
|
words = l.Words
|
||||||
|
}).ToList(),
|
||||||
|
// Also show LRC format
|
||||||
|
lrcFormat = string.Join("\n", result.Lines.Select(l =>
|
||||||
|
{
|
||||||
|
var timestamp = TimeSpan.FromMilliseconds(l.StartTimeMs);
|
||||||
|
var mm = (int)timestamp.TotalMinutes;
|
||||||
|
var ss = timestamp.Seconds;
|
||||||
|
var ms = timestamp.Milliseconds / 10;
|
||||||
|
return $"[{mm:D2}:{ss:D2}.{ms:D2}]{l.Words}";
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to test Spotify lyrics for track {TrackId}", trackId);
|
||||||
|
return StatusCode(500, new { error = $"Failed to fetch lyrics: {ex.Message}" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prefetch lyrics for a specific playlist
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("playlists/{name}/prefetch-lyrics")]
|
||||||
|
public async Task<IActionResult> PrefetchPlaylistLyrics(string name)
|
||||||
|
{
|
||||||
|
var decodedName = Uri.UnescapeDataString(name);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var lyricsPrefetchService = _serviceProvider.GetService<allstarr.Services.Lyrics.LyricsPrefetchService>();
|
||||||
|
|
||||||
|
if (lyricsPrefetchService == null)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new { error = "Lyrics prefetch service not available" });
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Starting lyrics prefetch for playlist: {Playlist}", decodedName);
|
||||||
|
|
||||||
|
var (fetched, cached, missing) = await lyricsPrefetchService.PrefetchPlaylistLyricsAsync(
|
||||||
|
decodedName,
|
||||||
|
HttpContext.RequestAborted);
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
message = "Lyrics prefetch complete",
|
||||||
|
playlist = decodedName,
|
||||||
|
fetched,
|
||||||
|
cached,
|
||||||
|
missing,
|
||||||
|
total = fetched + cached + missing
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to prefetch lyrics for playlist {Playlist}", decodedName);
|
||||||
|
return StatusCode(500, new { error = $"Failed to prefetch lyrics: {ex.Message}" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Helper Methods
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Invalidates the cached playlist summary so it will be regenerated on next request
|
||||||
|
/// </summary>
|
||||||
|
private void InvalidatePlaylistSummaryCache()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cacheFile = "/app/cache/admin_playlists_summary.json";
|
||||||
|
if (System.IO.File.Exists(cacheFile))
|
||||||
|
{
|
||||||
|
System.IO.File.Delete(cacheFile);
|
||||||
|
_logger.LogDebug("🗑️ Invalidated playlist summary cache");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to invalidate playlist summary cache");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
public class ManualMappingRequest
|
||||||
|
{
|
||||||
|
public string SpotifyId { get; set; } = "";
|
||||||
|
public string? JellyfinId { get; set; }
|
||||||
|
public string? ExternalProvider { get; set; }
|
||||||
|
public string? ExternalId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LyricsMappingRequest
|
||||||
|
{
|
||||||
|
public string Artist { get; set; } = "";
|
||||||
|
public string Title { get; set; } = "";
|
||||||
|
public string? Album { get; set; }
|
||||||
|
public int DurationSeconds { get; set; }
|
||||||
|
public int LyricsId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ManualMappingEntry
|
||||||
|
{
|
||||||
|
public string SpotifyId { get; set; } = "";
|
||||||
|
public string? JellyfinId { get; set; }
|
||||||
|
public string? ExternalProvider { get; set; }
|
||||||
|
public string? ExternalId { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LyricsMappingEntry
|
||||||
|
{
|
||||||
|
public string Artist { get; set; } = "";
|
||||||
|
public string Title { get; set; } = "";
|
||||||
|
public string? Album { get; set; }
|
||||||
|
public int DurationSeconds { get; set; }
|
||||||
|
public int LyricsId { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ConfigUpdateRequest
|
public class ConfigUpdateRequest
|
||||||
@@ -1510,3 +3296,202 @@ public class LinkPlaylistRequest
|
|||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
public string SpotifyPlaylistId { get; set; } = string.Empty;
|
public string SpotifyPlaylistId { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// GET /api/admin/downloads
|
||||||
|
/// Lists all downloaded files in the KEPT folder only (favorited tracks)
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("downloads")]
|
||||||
|
public IActionResult GetDownloads()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
|
||||||
|
|
||||||
|
_logger.LogInformation("📂 Checking kept folder: {Path}", keptPath);
|
||||||
|
_logger.LogInformation("📂 Directory exists: {Exists}", Directory.Exists(keptPath));
|
||||||
|
|
||||||
|
if (!Directory.Exists(keptPath))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Kept folder does not exist: {Path}", keptPath);
|
||||||
|
return Ok(new { files = new List<object>(), totalSize = 0, count = 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
var files = new List<object>();
|
||||||
|
long totalSize = 0;
|
||||||
|
|
||||||
|
// Recursively get all audio files from kept folder
|
||||||
|
var audioExtensions = new[] { ".flac", ".mp3", ".m4a", ".opus" };
|
||||||
|
|
||||||
|
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(keptPath, filePath);
|
||||||
|
|
||||||
|
// Parse artist/album/track from path structure
|
||||||
|
var parts = relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||||
|
var artist = parts.Length > 0 ? parts[0] : "";
|
||||||
|
var album = parts.Length > 1 ? parts[1] : "";
|
||||||
|
var fileName = parts.Length > 2 ? parts[^1] : Path.GetFileName(filePath);
|
||||||
|
|
||||||
|
files.Add(new
|
||||||
|
{
|
||||||
|
path = relativePath,
|
||||||
|
fullPath = filePath,
|
||||||
|
artist,
|
||||||
|
album,
|
||||||
|
fileName,
|
||||||
|
size = fileInfo.Length,
|
||||||
|
sizeFormatted = FormatFileSize(fileInfo.Length),
|
||||||
|
lastModified = fileInfo.LastWriteTimeUtc,
|
||||||
|
extension = fileInfo.Extension
|
||||||
|
});
|
||||||
|
|
||||||
|
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),
|
||||||
|
totalSize,
|
||||||
|
totalSizeFormatted = FormatFileSize(totalSize),
|
||||||
|
count = files.Count
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_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 kept file and cleans up empty folders
|
||||||
|
/// </summary>
|
||||||
|
[HttpDelete("downloads")]
|
||||||
|
public IActionResult DeleteDownload([FromQuery] string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(path))
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Path is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
|
||||||
|
var fullPath = Path.Combine(keptPath, path);
|
||||||
|
|
||||||
|
_logger.LogInformation("🗑️ Delete request for: {Path}", fullPath);
|
||||||
|
|
||||||
|
// Security: Ensure the path is within the kept directory
|
||||||
|
var normalizedFullPath = Path.GetFullPath(fullPath);
|
||||||
|
var normalizedKeptPath = Path.GetFullPath(keptPath);
|
||||||
|
|
||||||
|
if (!normalizedFullPath.StartsWith(normalizedKeptPath))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("🗑️ Invalid path (outside kept folder): {Path}", normalizedFullPath);
|
||||||
|
return BadRequest(new { error = "Invalid path" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!System.IO.File.Exists(fullPath))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("🗑️ File not found: {Path}", fullPath);
|
||||||
|
return NotFound(new { error = "File not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
System.IO.File.Delete(fullPath);
|
||||||
|
_logger.LogInformation("🗑️ Deleted file: {Path}", fullPath);
|
||||||
|
|
||||||
|
// Clean up empty directories (Album folder, then Artist folder if empty)
|
||||||
|
var directory = Path.GetDirectoryName(fullPath);
|
||||||
|
while (directory != null && directory != keptPath && directory.StartsWith(keptPath))
|
||||||
|
{
|
||||||
|
if (Directory.Exists(directory) && !Directory.EnumerateFileSystemEntries(directory).Any())
|
||||||
|
{
|
||||||
|
Directory.Delete(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new { success = true, message = "File deleted successfully" });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_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 from the kept folder
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("downloads/file")]
|
||||||
|
public IActionResult DownloadFile([FromQuery] string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(path))
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Path is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
|
||||||
|
var fullPath = Path.Combine(keptPath, path);
|
||||||
|
|
||||||
|
// Security: Ensure the path is within the kept directory
|
||||||
|
var normalizedFullPath = Path.GetFullPath(fullPath);
|
||||||
|
var normalizedKeptPath = Path.GetFullPath(keptPath);
|
||||||
|
|
||||||
|
if (!normalizedFullPath.StartsWith(normalizedKeptPath))
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Invalid path" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!System.IO.File.Exists(fullPath))
|
||||||
|
{
|
||||||
|
return NotFound(new { error = "File not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var fileName = Path.GetFileName(fullPath);
|
||||||
|
var fileStream = System.IO.File.OpenRead(fullPath);
|
||||||
|
|
||||||
|
return File(fileStream, "application/octet-stream", fileName);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to download file: {Path}", path);
|
||||||
|
return StatusCode(500, new { error = "Failed to download file" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatFileSize(long bytes)
|
||||||
|
{
|
||||||
|
string[] sizes = { "B", "KB", "MB", "GB", "TB" };
|
||||||
|
double len = bytes;
|
||||||
|
int order = 0;
|
||||||
|
while (len >= 1024 && order < sizes.Length - 1)
|
||||||
|
{
|
||||||
|
order++;
|
||||||
|
len = len / 1024;
|
||||||
|
}
|
||||||
|
return $"{len:0.##} {sizes[order]}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ public class JellyfinController : ControllerBase
|
|||||||
private readonly SpotifyImportSettings _spotifySettings;
|
private readonly SpotifyImportSettings _spotifySettings;
|
||||||
private readonly SpotifyApiSettings _spotifyApiSettings;
|
private readonly SpotifyApiSettings _spotifyApiSettings;
|
||||||
private readonly IMusicMetadataService _metadataService;
|
private readonly IMusicMetadataService _metadataService;
|
||||||
|
private readonly ParallelMetadataService? _parallelMetadataService;
|
||||||
private readonly ILocalLibraryService _localLibraryService;
|
private readonly ILocalLibraryService _localLibraryService;
|
||||||
private readonly IDownloadService _downloadService;
|
private readonly IDownloadService _downloadService;
|
||||||
private readonly JellyfinResponseBuilder _responseBuilder;
|
private readonly JellyfinResponseBuilder _responseBuilder;
|
||||||
@@ -38,7 +39,10 @@ public class JellyfinController : ControllerBase
|
|||||||
private readonly PlaylistSyncService? _playlistSyncService;
|
private readonly PlaylistSyncService? _playlistSyncService;
|
||||||
private readonly SpotifyPlaylistFetcher? _spotifyPlaylistFetcher;
|
private readonly SpotifyPlaylistFetcher? _spotifyPlaylistFetcher;
|
||||||
private readonly SpotifyLyricsService? _spotifyLyricsService;
|
private readonly SpotifyLyricsService? _spotifyLyricsService;
|
||||||
|
private readonly LrclibService? _lrclibService;
|
||||||
|
private readonly OdesliService _odesliService;
|
||||||
private readonly RedisCacheService _cache;
|
private readonly RedisCacheService _cache;
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
private readonly ILogger<JellyfinController> _logger;
|
private readonly ILogger<JellyfinController> _logger;
|
||||||
|
|
||||||
public JellyfinController(
|
public JellyfinController(
|
||||||
@@ -52,16 +56,21 @@ public class JellyfinController : ControllerBase
|
|||||||
JellyfinModelMapper modelMapper,
|
JellyfinModelMapper modelMapper,
|
||||||
JellyfinProxyService proxyService,
|
JellyfinProxyService proxyService,
|
||||||
JellyfinSessionManager sessionManager,
|
JellyfinSessionManager sessionManager,
|
||||||
|
OdesliService odesliService,
|
||||||
RedisCacheService cache,
|
RedisCacheService cache,
|
||||||
|
IConfiguration configuration,
|
||||||
ILogger<JellyfinController> logger,
|
ILogger<JellyfinController> logger,
|
||||||
|
ParallelMetadataService? parallelMetadataService = null,
|
||||||
PlaylistSyncService? playlistSyncService = null,
|
PlaylistSyncService? playlistSyncService = null,
|
||||||
SpotifyPlaylistFetcher? spotifyPlaylistFetcher = null,
|
SpotifyPlaylistFetcher? spotifyPlaylistFetcher = null,
|
||||||
SpotifyLyricsService? spotifyLyricsService = null)
|
SpotifyLyricsService? spotifyLyricsService = null,
|
||||||
|
LrclibService? lrclibService = null)
|
||||||
{
|
{
|
||||||
_settings = settings.Value;
|
_settings = settings.Value;
|
||||||
_spotifySettings = spotifySettings.Value;
|
_spotifySettings = spotifySettings.Value;
|
||||||
_spotifyApiSettings = spotifyApiSettings.Value;
|
_spotifyApiSettings = spotifyApiSettings.Value;
|
||||||
_metadataService = metadataService;
|
_metadataService = metadataService;
|
||||||
|
_parallelMetadataService = parallelMetadataService;
|
||||||
_localLibraryService = localLibraryService;
|
_localLibraryService = localLibraryService;
|
||||||
_downloadService = downloadService;
|
_downloadService = downloadService;
|
||||||
_responseBuilder = responseBuilder;
|
_responseBuilder = responseBuilder;
|
||||||
@@ -71,7 +80,10 @@ public class JellyfinController : ControllerBase
|
|||||||
_playlistSyncService = playlistSyncService;
|
_playlistSyncService = playlistSyncService;
|
||||||
_spotifyPlaylistFetcher = spotifyPlaylistFetcher;
|
_spotifyPlaylistFetcher = spotifyPlaylistFetcher;
|
||||||
_spotifyLyricsService = spotifyLyricsService;
|
_spotifyLyricsService = spotifyLyricsService;
|
||||||
|
_lrclibService = lrclibService;
|
||||||
|
_odesliService = odesliService;
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
|
_configuration = configuration;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(_settings.Url))
|
if (string.IsNullOrWhiteSpace(_settings.Url))
|
||||||
@@ -84,7 +96,7 @@ public class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Searches local Jellyfin library and external providers.
|
/// Searches local Jellyfin library and external providers.
|
||||||
/// Dedupes artists, combines songs/albums. Works with /Items and /Users/{userId}/Items.
|
/// Combines songs/albums/artists. Works with /Items and /Users/{userId}/Items.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("Items", Order = 1)]
|
[HttpGet("Items", Order = 1)]
|
||||||
[HttpGet("Users/{userId}/Items", Order = 1)]
|
[HttpGet("Users/{userId}/Items", Order = 1)]
|
||||||
@@ -102,6 +114,20 @@ public class JellyfinController : ControllerBase
|
|||||||
_logger.LogInformation("=== SEARCHITEMS V2 CALLED === searchTerm={SearchTerm}, includeItemTypes={ItemTypes}, parentId={ParentId}, artistIds={ArtistIds}, userId={UserId}",
|
_logger.LogInformation("=== SEARCHITEMS V2 CALLED === searchTerm={SearchTerm}, includeItemTypes={ItemTypes}, parentId={ParentId}, artistIds={ArtistIds}, userId={UserId}",
|
||||||
searchTerm, includeItemTypes, parentId, artistIds, userId);
|
searchTerm, includeItemTypes, parentId, artistIds, userId);
|
||||||
|
|
||||||
|
// Cache search results in Redis only (no file persistence, 15 min TTL)
|
||||||
|
// Only cache actual searches, not browse operations
|
||||||
|
if (!string.IsNullOrWhiteSpace(searchTerm) && string.IsNullOrWhiteSpace(artistIds))
|
||||||
|
{
|
||||||
|
var cacheKey = $"search:{searchTerm?.ToLowerInvariant()}:{includeItemTypes}:{limit}:{startIndex}";
|
||||||
|
var cachedResult = await _cache.GetAsync<object>(cacheKey);
|
||||||
|
|
||||||
|
if (cachedResult != null)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("✅ Returning cached search results for '{SearchTerm}'", searchTerm);
|
||||||
|
return new JsonResult(cachedResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If filtering by artist, handle external artists
|
// If filtering by artist, handle external artists
|
||||||
if (!string.IsNullOrWhiteSpace(artistIds))
|
if (!string.IsNullOrWhiteSpace(artistIds))
|
||||||
{
|
{
|
||||||
@@ -126,17 +152,50 @@ public class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
// Ensure MediaSources is included in Fields parameter for bitrate info
|
// Ensure MediaSources is included in Fields parameter for bitrate info
|
||||||
var queryString = Request.QueryString.Value ?? "";
|
var queryString = Request.QueryString.Value ?? "";
|
||||||
if (!queryString.Contains("Fields=", StringComparison.OrdinalIgnoreCase))
|
|
||||||
|
if (!string.IsNullOrEmpty(queryString))
|
||||||
{
|
{
|
||||||
// No Fields parameter, add MediaSources
|
// Parse query string to modify Fields parameter
|
||||||
queryString = string.IsNullOrEmpty(queryString)
|
var queryParams = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString);
|
||||||
? "?Fields=MediaSources"
|
|
||||||
: $"{queryString}&Fields=MediaSources";
|
if (queryParams.ContainsKey("Fields"))
|
||||||
|
{
|
||||||
|
var fieldsValue = queryParams["Fields"].ToString();
|
||||||
|
if (!fieldsValue.Contains("MediaSources", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// Append MediaSources to existing Fields
|
||||||
|
var newFields = string.IsNullOrEmpty(fieldsValue)
|
||||||
|
? "MediaSources"
|
||||||
|
: $"{fieldsValue},MediaSources";
|
||||||
|
|
||||||
|
// Rebuild query string with updated Fields
|
||||||
|
var newQueryParams = new Dictionary<string, string>();
|
||||||
|
foreach (var kvp in queryParams)
|
||||||
|
{
|
||||||
|
if (kvp.Key == "Fields")
|
||||||
|
{
|
||||||
|
newQueryParams[kvp.Key] = newFields;
|
||||||
}
|
}
|
||||||
else if (!queryString.Contains("MediaSources", StringComparison.OrdinalIgnoreCase))
|
else
|
||||||
{
|
{
|
||||||
// Fields parameter exists but doesn't include MediaSources, append it
|
newQueryParams[kvp.Key] = kvp.Value.ToString();
|
||||||
queryString = $"{queryString},MediaSources";
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
queryString = "?" + string.Join("&", newQueryParams.Select(kvp =>
|
||||||
|
$"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value)}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// No Fields parameter, add it
|
||||||
|
queryString = $"{queryString}&Fields=MediaSources";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// No query string at all
|
||||||
|
queryString = "?Fields=MediaSources";
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoint = $"{endpoint}{queryString}";
|
endpoint = $"{endpoint}{queryString}";
|
||||||
@@ -194,7 +253,11 @@ public class JellyfinController : ControllerBase
|
|||||||
// Run local and external searches in parallel
|
// Run local and external searches in parallel
|
||||||
var itemTypes = ParseItemTypes(includeItemTypes);
|
var itemTypes = ParseItemTypes(includeItemTypes);
|
||||||
var jellyfinTask = _proxyService.SearchAsync(cleanQuery, itemTypes, limit, recursive, Request.Headers);
|
var jellyfinTask = _proxyService.SearchAsync(cleanQuery, itemTypes, limit, recursive, Request.Headers);
|
||||||
var externalTask = _metadataService.SearchAllAsync(cleanQuery, limit, limit, limit);
|
|
||||||
|
// Use parallel metadata service if available (races providers), otherwise use primary
|
||||||
|
var externalTask = _parallelMetadataService != null
|
||||||
|
? _parallelMetadataService.SearchAllAsync(cleanQuery, limit, limit, limit)
|
||||||
|
: _metadataService.SearchAllAsync(cleanQuery, limit, limit, limit);
|
||||||
|
|
||||||
var playlistTask = _settings.EnableExternalPlaylists
|
var playlistTask = _settings.EnableExternalPlaylists
|
||||||
? _metadataService.SearchPlaylistsAsync(cleanQuery, limit)
|
? _metadataService.SearchPlaylistsAsync(cleanQuery, limit)
|
||||||
@@ -216,55 +279,106 @@ public class JellyfinController : ControllerBase
|
|||||||
// Parse Jellyfin results into domain models
|
// Parse Jellyfin results into domain models
|
||||||
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult);
|
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult);
|
||||||
|
|
||||||
// Score and filter Jellyfin results by relevance
|
// Respect source ordering (SquidWTF/Tidal has better search ranking than our fuzzy matching)
|
||||||
var scoredLocalSongs = ScoreSearchResults(cleanQuery, localSongs, s => s.Title, s => s.Artist, s => s.Album, isExternal: false);
|
// Just interleave local and external results based on which source has better overall match
|
||||||
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);
|
|
||||||
|
|
||||||
// Score external results with a small boost
|
// Calculate average match score for each source to determine which should come first
|
||||||
var scoredExternalSongs = ScoreSearchResults(cleanQuery, externalResult.Songs, s => s.Title, s => s.Artist, s => s.Album, isExternal: true);
|
var localSongsAvgScore = localSongs.Any()
|
||||||
var scoredExternalAlbums = ScoreSearchResults(cleanQuery, externalResult.Albums, a => a.Title, a => a.Artist, _ => null, isExternal: true);
|
? localSongs.Average(s => FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title))
|
||||||
var scoredExternalArtists = ScoreSearchResults(cleanQuery, externalResult.Artists, a => a.Name, _ => null, _ => null, isExternal: true);
|
: 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 localAlbumsAvgScore = localAlbums.Any()
|
||||||
var allSongs = scoredLocalSongs.Concat(scoredExternalSongs)
|
? localAlbums.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title))
|
||||||
.OrderByDescending(x => x.Score)
|
: 0.0;
|
||||||
.Select(x => x.Item)
|
var externalAlbumsAvgScore = externalResult.Albums.Any()
|
||||||
.ToList();
|
? externalResult.Albums.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title))
|
||||||
|
: 0.0;
|
||||||
|
|
||||||
var allAlbums = scoredLocalAlbums.Concat(scoredExternalAlbums)
|
var localArtistsAvgScore = localArtists.Any()
|
||||||
.OrderByDescending(x => x.Score)
|
? localArtists.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name))
|
||||||
.Select(x => x.Item)
|
: 0.0;
|
||||||
.ToList();
|
var externalArtistsAvgScore = externalResult.Artists.Any()
|
||||||
|
? externalResult.Artists.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name))
|
||||||
|
: 0.0;
|
||||||
|
|
||||||
// Dedupe artists by name, keeping highest scored version
|
// Interleave results: put better-matching source first, preserve original ordering within each source
|
||||||
var artistScores = scoredLocalArtists.Concat(scoredExternalArtists)
|
var allSongs = localSongsAvgScore >= externalSongsAvgScore
|
||||||
.GroupBy(x => x.Item.Name, StringComparer.OrdinalIgnoreCase)
|
? localSongs.Concat(externalResult.Songs).ToList()
|
||||||
.Select(g => g.OrderByDescending(x => x.Score).First())
|
: externalResult.Songs.Concat(localSongs).ToList();
|
||||||
.OrderByDescending(x => x.Score)
|
|
||||||
.Select(x => x.Item)
|
var allAlbums = localAlbumsAvgScore >= externalAlbumsAvgScore
|
||||||
.ToList();
|
? 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))
|
||||||
|
{
|
||||||
|
_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
|
// Convert to Jellyfin format
|
||||||
var mergedSongs = allSongs.Select(s => _responseBuilder.ConvertSongToJellyfinItem(s)).ToList();
|
var mergedSongs = allSongs.Select(s => _responseBuilder.ConvertSongToJellyfinItem(s)).ToList();
|
||||||
var mergedAlbums = allAlbums.Select(a => _responseBuilder.ConvertAlbumToJellyfinItem(a)).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)
|
if (playlistResult.Count > 0)
|
||||||
{
|
{
|
||||||
var scoredPlaylists = playlistResult
|
var playlistItems = playlistResult
|
||||||
.Select(p => new { Playlist = p, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, p.Name) })
|
.Select(p => _responseBuilder.ConvertPlaylistToJellyfinItem(p))
|
||||||
.OrderByDescending(x => x.Score)
|
|
||||||
.Select(x => _responseBuilder.ConvertPlaylistToJellyfinItem(x.Playlist))
|
|
||||||
.ToList();
|
.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);
|
mergedSongs.Count, mergedAlbums.Count, mergedArtists.Count);
|
||||||
|
|
||||||
|
// Pre-fetch lyrics for top 3 songs in background (don't await)
|
||||||
|
if (_lrclibService != null && mergedSongs.Count > 0)
|
||||||
|
{
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var top3 = mergedSongs.Take(3).ToList();
|
||||||
|
_logger.LogDebug("🎵 Pre-fetching lyrics for top {Count} search results", top3.Count);
|
||||||
|
|
||||||
|
foreach (var songItem in top3)
|
||||||
|
{
|
||||||
|
if (songItem.TryGetValue("Name", out var nameObj) && nameObj is JsonElement nameEl &&
|
||||||
|
songItem.TryGetValue("Artists", out var artistsObj) && artistsObj is JsonElement artistsEl &&
|
||||||
|
artistsEl.GetArrayLength() > 0)
|
||||||
|
{
|
||||||
|
var title = nameEl.GetString() ?? "";
|
||||||
|
var artist = artistsEl[0].GetString() ?? "";
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(title) && !string.IsNullOrEmpty(artist))
|
||||||
|
{
|
||||||
|
await _lrclibService.GetLyricsAsync(title, artist, "", 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Failed to pre-fetch lyrics for search results");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Filter by item types if specified
|
// Filter by item types if specified
|
||||||
var items = new List<Dictionary<string, object?>>();
|
var items = new List<Dictionary<string, object?>>();
|
||||||
|
|
||||||
@@ -301,6 +415,14 @@ public class JellyfinController : ControllerBase
|
|||||||
StartIndex = startIndex
|
StartIndex = startIndex
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Cache search results in Redis (15 min TTL, no file persistence)
|
||||||
|
if (!string.IsNullOrWhiteSpace(searchTerm) && string.IsNullOrWhiteSpace(artistIds))
|
||||||
|
{
|
||||||
|
var cacheKey = $"search:{searchTerm?.ToLowerInvariant()}:{includeItemTypes}:{limit}:{startIndex}";
|
||||||
|
await _cache.SetAsync(cacheKey, response, TimeSpan.FromMinutes(15));
|
||||||
|
_logger.LogDebug("💾 Cached search results for '{SearchTerm}' (15 min TTL)", searchTerm);
|
||||||
|
}
|
||||||
|
|
||||||
_logger.LogInformation("About to serialize response...");
|
_logger.LogInformation("About to serialize response...");
|
||||||
|
|
||||||
var json = System.Text.Json.JsonSerializer.Serialize(response, new System.Text.Json.JsonSerializerOptions
|
var json = System.Text.Json.JsonSerializer.Serialize(response, new System.Text.Json.JsonSerializerOptions
|
||||||
@@ -394,20 +516,10 @@ public class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult);
|
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult);
|
||||||
|
|
||||||
// Merge and convert to search hints format
|
// NO deduplication - merge all results and take top matches
|
||||||
var allSongs = localSongs.Concat(externalResult.Songs).Take(limit).ToList();
|
var allSongs = localSongs.Concat(externalResult.Songs).Take(limit).ToList();
|
||||||
var allAlbums = localAlbums.Concat(externalResult.Albums).Take(limit).ToList();
|
var allAlbums = localAlbums.Concat(externalResult.Albums).Take(limit).ToList();
|
||||||
|
var allArtists = localArtists.Concat(externalResult.Artists).Take(limit).ToList();
|
||||||
// Dedupe artists by name
|
|
||||||
var artistNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
var allArtists = new List<Artist>();
|
|
||||||
foreach (var artist in localArtists.Concat(externalResult.Artists))
|
|
||||||
{
|
|
||||||
if (artistNames.Add(artist.Name))
|
|
||||||
{
|
|
||||||
allArtists.Add(artist);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return _responseBuilder.CreateSearchHintsResponse(
|
return _responseBuilder.CreateSearchHintsResponse(
|
||||||
allSongs.Take(limit).ToList(),
|
allSongs.Take(limit).ToList(),
|
||||||
@@ -500,9 +612,13 @@ public class JellyfinController : ControllerBase
|
|||||||
{
|
{
|
||||||
var itemTypes = ParseItemTypes(includeItemTypes);
|
var itemTypes = ParseItemTypes(includeItemTypes);
|
||||||
|
|
||||||
|
_logger.LogInformation("GetExternalChildItems: provider={Provider}, externalId={ExternalId}, itemTypes={ItemTypes}",
|
||||||
|
provider, externalId, string.Join(",", itemTypes ?? Array.Empty<string>()));
|
||||||
|
|
||||||
// Check if asking for audio (album tracks)
|
// Check if asking for audio (album tracks)
|
||||||
if (itemTypes?.Contains("Audio") == true)
|
if (itemTypes?.Contains("Audio") == true)
|
||||||
{
|
{
|
||||||
|
_logger.LogDebug("Fetching album tracks for {Provider}/{ExternalId}", provider, externalId);
|
||||||
var album = await _metadataService.GetAlbumAsync(provider, externalId);
|
var album = await _metadataService.GetAlbumAsync(provider, externalId);
|
||||||
if (album == null)
|
if (album == null)
|
||||||
{
|
{
|
||||||
@@ -513,9 +629,12 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise assume it's artist albums
|
// Otherwise assume it's artist albums
|
||||||
|
_logger.LogDebug("Fetching artist albums for {Provider}/{ExternalId}", provider, externalId);
|
||||||
var albums = await _metadataService.GetArtistAlbumsAsync(provider, externalId);
|
var albums = await _metadataService.GetArtistAlbumsAsync(provider, externalId);
|
||||||
var artist = await _metadataService.GetArtistAsync(provider, externalId);
|
var artist = await _metadataService.GetArtistAsync(provider, externalId);
|
||||||
|
|
||||||
|
_logger.LogInformation("Found {Count} albums for artist {ArtistName}", albums.Count, artist?.Name ?? "unknown");
|
||||||
|
|
||||||
// Fill artist info
|
// Fill artist info
|
||||||
if (artist != null)
|
if (artist != null)
|
||||||
{
|
{
|
||||||
@@ -575,27 +694,11 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge and deduplicate by name
|
// NO deduplication - merge all artists and sort by relevance
|
||||||
var artistNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
// Show ALL matches (local + external) sorted by best match first
|
||||||
var mergedArtists = new List<Artist>();
|
var mergedArtists = localArtists.Concat(externalArtists).ToList();
|
||||||
|
|
||||||
foreach (var artist in localArtists)
|
_logger.LogInformation("Returning {Count} total artists (local + external, no deduplication)", mergedArtists.Count);
|
||||||
{
|
|
||||||
if (artistNames.Add(artist.Name))
|
|
||||||
{
|
|
||||||
mergedArtists.Add(artist);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var artist in externalArtists)
|
|
||||||
{
|
|
||||||
if (artistNames.Add(artist.Name))
|
|
||||||
{
|
|
||||||
mergedArtists.Add(artist);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("Returning {Count} merged artists", mergedArtists.Count);
|
|
||||||
|
|
||||||
// Convert to Jellyfin format
|
// Convert to Jellyfin format
|
||||||
var artistItems = mergedArtists.Select(a => _responseBuilder.ConvertArtistToJellyfinItem(a)).ToList();
|
var artistItems = mergedArtists.Select(a => _responseBuilder.ConvertArtistToJellyfinItem(a)).ToList();
|
||||||
@@ -962,7 +1065,8 @@ public class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
if (imageBytes == null || contentType == null)
|
if (imageBytes == null || contentType == null)
|
||||||
{
|
{
|
||||||
return NotFound();
|
// Return placeholder if Jellyfin doesn't have image
|
||||||
|
return await GetPlaceholderImageAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
return File(imageBytes, contentType);
|
return File(imageBytes, contentType);
|
||||||
@@ -979,7 +1083,8 @@ public class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
if (string.IsNullOrEmpty(coverUrl))
|
if (string.IsNullOrEmpty(coverUrl))
|
||||||
{
|
{
|
||||||
return NotFound();
|
// Return placeholder "no image available" image
|
||||||
|
return await GetPlaceholderImageAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch and return the image using the proxy service's HttpClient
|
// Fetch and return the image using the proxy service's HttpClient
|
||||||
@@ -988,7 +1093,8 @@ public class JellyfinController : ControllerBase
|
|||||||
var response = await _proxyService.HttpClient.GetAsync(coverUrl);
|
var response = await _proxyService.HttpClient.GetAsync(coverUrl);
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
return NotFound();
|
// Return placeholder on fetch failure
|
||||||
|
return await GetPlaceholderImageAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
var imageBytes = await response.Content.ReadAsByteArrayAsync();
|
var imageBytes = await response.Content.ReadAsByteArrayAsync();
|
||||||
@@ -998,10 +1104,34 @@ public class JellyfinController : ControllerBase
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to fetch cover art from {Url}", coverUrl);
|
_logger.LogWarning(ex, "Failed to fetch cover art from {Url}", coverUrl);
|
||||||
return NotFound();
|
// Return placeholder on exception
|
||||||
|
return await GetPlaceholderImageAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a placeholder "no image available" image.
|
||||||
|
/// Generates a simple 1x1 transparent PNG as a minimal placeholder.
|
||||||
|
/// TODO: Replace with actual "no image available" graphic from wwwroot/placeholder.png
|
||||||
|
/// </summary>
|
||||||
|
private async Task<IActionResult> GetPlaceholderImageAsync()
|
||||||
|
{
|
||||||
|
// Check if custom placeholder exists in wwwroot
|
||||||
|
var placeholderPath = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "placeholder.png");
|
||||||
|
if (System.IO.File.Exists(placeholderPath))
|
||||||
|
{
|
||||||
|
var imageBytes = await System.IO.File.ReadAllBytesAsync(placeholderPath);
|
||||||
|
return File(imageBytes, "image/png");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Return a 1x1 transparent PNG as minimal placeholder
|
||||||
|
var transparentPng = Convert.FromBase64String(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||||
|
);
|
||||||
|
|
||||||
|
return File(transparentPng, "image/png");
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Lyrics
|
#region Lyrics
|
||||||
@@ -1014,6 +1144,8 @@ public class JellyfinController : ControllerBase
|
|||||||
[HttpGet("Items/{itemId}/Lyrics")]
|
[HttpGet("Items/{itemId}/Lyrics")]
|
||||||
public async Task<IActionResult> GetLyrics(string itemId)
|
public async Task<IActionResult> GetLyrics(string itemId)
|
||||||
{
|
{
|
||||||
|
_logger.LogInformation("🎵 GetLyrics called for itemId: {ItemId}", itemId);
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(itemId))
|
if (string.IsNullOrWhiteSpace(itemId))
|
||||||
{
|
{
|
||||||
return NotFound();
|
return NotFound();
|
||||||
@@ -1021,6 +1153,9 @@ public class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
|
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
|
// For local tracks, check if Jellyfin already has embedded lyrics
|
||||||
if (!isExternal)
|
if (!isExternal)
|
||||||
{
|
{
|
||||||
@@ -1029,13 +1164,16 @@ public class JellyfinController : ControllerBase
|
|||||||
// Try to get lyrics from Jellyfin first (it reads embedded lyrics from files)
|
// 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);
|
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)
|
if (jellyfinLyrics != null && statusCode == 200)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Found embedded lyrics in Jellyfin for track {ItemId}", itemId);
|
_logger.LogInformation("Found embedded lyrics in Jellyfin for track {ItemId}", itemId);
|
||||||
return new JsonResult(JsonSerializer.Deserialize<object>(jellyfinLyrics.RootElement.GetRawText()));
|
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
|
// Get song metadata for lyrics search
|
||||||
@@ -1045,7 +1183,53 @@ public class JellyfinController : ControllerBase
|
|||||||
if (isExternal)
|
if (isExternal)
|
||||||
{
|
{
|
||||||
song = await _metadataService.GetSongAsync(provider!, externalId!);
|
song = await _metadataService.GetSongAsync(provider!, externalId!);
|
||||||
// For Deezer tracks, we'll search Spotify by metadata
|
|
||||||
|
// Use Spotify ID from song metadata if available (populated during GetSongAsync)
|
||||||
|
if (song != null && !string.IsNullOrEmpty(song.SpotifyId))
|
||||||
|
{
|
||||||
|
spotifyTrackId = song.SpotifyId;
|
||||||
|
_logger.LogInformation("Using Spotify ID {SpotifyId} from song metadata for {Provider}/{ExternalId}",
|
||||||
|
spotifyTrackId, provider, externalId);
|
||||||
|
}
|
||||||
|
// Fallback: Try to find Spotify ID from matched tracks cache
|
||||||
|
else if (song != null)
|
||||||
|
{
|
||||||
|
spotifyTrackId = await FindSpotifyIdForExternalTrackAsync(song);
|
||||||
|
if (!string.IsNullOrEmpty(spotifyTrackId))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Found Spotify ID {SpotifyId} for external track {Provider}/{ExternalId} from cache",
|
||||||
|
spotifyTrackId, provider, externalId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Last resort: Try to convert via Odesli/song.link
|
||||||
|
if (provider == "squidwtf")
|
||||||
|
{
|
||||||
|
spotifyTrackId = await _odesliService.ConvertTidalToSpotifyIdAsync(externalId!, HttpContext.RequestAborted);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// For other providers, build the URL and convert
|
||||||
|
var sourceUrl = provider?.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"deezer" => $"https://www.deezer.com/track/{externalId}",
|
||||||
|
"qobuz" => $"https://www.qobuz.com/us-en/album/-/-/{externalId}",
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(sourceUrl))
|
||||||
|
{
|
||||||
|
spotifyTrackId = await _odesliService.ConvertUrlToSpotifyIdAsync(sourceUrl, HttpContext.RequestAborted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(spotifyTrackId))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Converted {Provider}/{ExternalId} to Spotify ID {SpotifyId} via Odesli",
|
||||||
|
provider, externalId, spotifyTrackId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -1078,51 +1262,65 @@ public class JellyfinController : ControllerBase
|
|||||||
return NotFound(new { error = "Song not found" });
|
return NotFound(new { error = "Song not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Strip [S] suffix from title, artist, and album for lyrics search
|
||||||
|
// The [S] tag is added to external tracks but shouldn't be used in lyrics queries
|
||||||
|
var searchTitle = song.Title.Replace(" [S]", "").Trim();
|
||||||
|
var searchArtist = song.Artist?.Replace(" [S]", "").Trim() ?? "";
|
||||||
|
var searchAlbum = song.Album?.Replace(" [S]", "").Trim() ?? "";
|
||||||
|
var searchArtists = song.Artists.Select(a => a.Replace(" [S]", "").Trim()).ToList();
|
||||||
|
|
||||||
|
if (searchArtists.Count == 0 && !string.IsNullOrEmpty(searchArtist))
|
||||||
|
{
|
||||||
|
searchArtists.Add(searchArtist);
|
||||||
|
}
|
||||||
|
|
||||||
LyricsInfo? lyrics = null;
|
LyricsInfo? lyrics = null;
|
||||||
|
|
||||||
// Try Spotify lyrics first (better synced lyrics quality)
|
// Try Spotify lyrics ONLY if we have a valid Spotify track ID
|
||||||
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled)
|
// Spotify lyrics only work for tracks from injected playlists that have been matched
|
||||||
|
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled && !string.IsNullOrEmpty(spotifyTrackId))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Trying Spotify lyrics for: {Artist} - {Title}", song.Artist, song.Title);
|
// Validate that this is a real Spotify ID (not spotify:local or other invalid formats)
|
||||||
|
var cleanSpotifyId = spotifyTrackId.Replace("spotify:track:", "").Trim();
|
||||||
|
|
||||||
SpotifyLyricsResult? spotifyLyrics = null;
|
// Spotify track IDs are 22 characters, base62 encoded
|
||||||
|
if (cleanSpotifyId.Length == 22 && !cleanSpotifyId.Contains(":") && !cleanSpotifyId.Contains("local"))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Trying Spotify lyrics for track ID: {SpotifyId} ({Artist} - {Title})",
|
||||||
|
cleanSpotifyId, searchArtist, searchTitle);
|
||||||
|
|
||||||
// If we have a Spotify track ID, use it directly
|
var spotifyLyrics = await _spotifyLyricsService.GetLyricsByTrackIdAsync(cleanSpotifyId);
|
||||||
if (!string.IsNullOrEmpty(spotifyTrackId))
|
|
||||||
{
|
|
||||||
spotifyLyrics = await _spotifyLyricsService.GetLyricsByTrackIdAsync(spotifyTrackId);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Search by metadata
|
|
||||||
spotifyLyrics = await _spotifyLyricsService.SearchAndGetLyricsAsync(
|
|
||||||
song.Title,
|
|
||||||
song.Artists.Count > 0 ? song.Artists[0] : song.Artist ?? "",
|
|
||||||
song.Album,
|
|
||||||
song.Duration.HasValue ? song.Duration.Value * 1000 : null);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
|
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Found Spotify lyrics for {Artist} - {Title} ({LineCount} lines, type: {SyncType})",
|
_logger.LogInformation("Found Spotify lyrics for {Artist} - {Title} ({LineCount} lines, type: {SyncType})",
|
||||||
song.Artist, song.Title, spotifyLyrics.Lines.Count, spotifyLyrics.SyncType);
|
searchArtist, searchTitle, spotifyLyrics.Lines.Count, spotifyLyrics.SyncType);
|
||||||
lyrics = _spotifyLyricsService.ToLyricsInfo(spotifyLyrics);
|
lyrics = _spotifyLyricsService.ToLyricsInfo(spotifyLyrics);
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogDebug("No Spotify lyrics found for track ID {SpotifyId}", cleanSpotifyId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Invalid Spotify ID format: {SpotifyId}, skipping Spotify lyrics", spotifyTrackId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to LRCLIB if no Spotify lyrics
|
// Fall back to LRCLIB if no Spotify lyrics
|
||||||
if (lyrics == null)
|
if (lyrics == null)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Searching LRCLIB for lyrics: {Artists} - {Title}",
|
_logger.LogInformation("Searching LRCLIB for lyrics: {Artists} - {Title}",
|
||||||
song.Artists.Count > 0 ? string.Join(", ", song.Artists) : song.Artist,
|
string.Join(", ", searchArtists),
|
||||||
song.Title);
|
searchTitle);
|
||||||
var lrclibService = HttpContext.RequestServices.GetService<LrclibService>();
|
var lrclibService = HttpContext.RequestServices.GetService<LrclibService>();
|
||||||
if (lrclibService != null)
|
if (lrclibService != null)
|
||||||
{
|
{
|
||||||
lyrics = await lrclibService.GetLyricsAsync(
|
lyrics = await lrclibService.GetLyricsAsync(
|
||||||
song.Title,
|
searchTitle,
|
||||||
song.Artists.Count > 0 ? song.Artists.ToArray() : new[] { song.Artist ?? "" },
|
searchArtists.ToArray(),
|
||||||
song.Album ?? "",
|
searchAlbum,
|
||||||
song.Duration ?? 0);
|
song.Duration ?? 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1227,6 +1425,122 @@ public class JellyfinController : ControllerBase
|
|||||||
return Ok(response);
|
return Ok(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Proactively fetches and caches lyrics for a track in the background.
|
||||||
|
/// Called when playback starts to ensure lyrics are ready when requested.
|
||||||
|
/// </summary>
|
||||||
|
private async Task PrefetchLyricsForTrackAsync(string itemId, bool isExternal, string? provider, string? externalId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Song? song = null;
|
||||||
|
string? spotifyTrackId = null;
|
||||||
|
|
||||||
|
if (isExternal && !string.IsNullOrEmpty(provider) && !string.IsNullOrEmpty(externalId))
|
||||||
|
{
|
||||||
|
// Get external track metadata
|
||||||
|
song = await _metadataService.GetSongAsync(provider, externalId);
|
||||||
|
|
||||||
|
// Try to find Spotify ID from matched tracks cache
|
||||||
|
if (song != null)
|
||||||
|
{
|
||||||
|
spotifyTrackId = await FindSpotifyIdForExternalTrackAsync(song);
|
||||||
|
|
||||||
|
// If no cached Spotify ID, try Odesli conversion
|
||||||
|
if (string.IsNullOrEmpty(spotifyTrackId) && provider == "squidwtf")
|
||||||
|
{
|
||||||
|
spotifyTrackId = await _odesliService.ConvertTidalToSpotifyIdAsync(externalId, HttpContext.RequestAborted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Get local track metadata from Jellyfin
|
||||||
|
var (item, _) = await _proxyService.GetItemAsync(itemId, Request.Headers);
|
||||||
|
if (item != null && item.RootElement.TryGetProperty("Type", out var typeEl) &&
|
||||||
|
typeEl.GetString() == "Audio")
|
||||||
|
{
|
||||||
|
song = new Song
|
||||||
|
{
|
||||||
|
Title = item.RootElement.TryGetProperty("Name", out var name) ? name.GetString() ?? "" : "",
|
||||||
|
Artist = item.RootElement.TryGetProperty("AlbumArtist", out var artist) ? artist.GetString() ?? "" : "",
|
||||||
|
Album = item.RootElement.TryGetProperty("Album", out var album) ? album.GetString() ?? "" : "",
|
||||||
|
Duration = item.RootElement.TryGetProperty("RunTimeTicks", out var ticks) ? (int)(ticks.GetInt64() / 10000000) : 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check for Spotify ID in provider IDs
|
||||||
|
if (item.RootElement.TryGetProperty("ProviderIds", out var providerIds))
|
||||||
|
{
|
||||||
|
if (providerIds.TryGetProperty("Spotify", out var spotifyId))
|
||||||
|
{
|
||||||
|
spotifyTrackId = spotifyId.GetString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (song == null)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Could not get song metadata for lyrics prefetch: {ItemId}", itemId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip [S] suffix for lyrics search
|
||||||
|
var searchTitle = song.Title.Replace(" [S]", "").Trim();
|
||||||
|
var searchArtist = song.Artist?.Replace(" [S]", "").Trim() ?? "";
|
||||||
|
var searchAlbum = song.Album?.Replace(" [S]", "").Trim() ?? "";
|
||||||
|
var searchArtists = song.Artists.Select(a => a.Replace(" [S]", "").Trim()).ToList();
|
||||||
|
|
||||||
|
if (searchArtists.Count == 0 && !string.IsNullOrEmpty(searchArtist))
|
||||||
|
{
|
||||||
|
searchArtists.Add(searchArtist);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("🎵 Prefetching lyrics for: {Artist} - {Title}", searchArtist, searchTitle);
|
||||||
|
|
||||||
|
// Try Spotify lyrics if we have a valid Spotify track ID
|
||||||
|
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled && !string.IsNullOrEmpty(spotifyTrackId))
|
||||||
|
{
|
||||||
|
var cleanSpotifyId = spotifyTrackId.Replace("spotify:track:", "").Trim();
|
||||||
|
|
||||||
|
if (cleanSpotifyId.Length == 22 && !cleanSpotifyId.Contains(":") && !cleanSpotifyId.Contains("local"))
|
||||||
|
{
|
||||||
|
var spotifyLyrics = await _spotifyLyricsService.GetLyricsByTrackIdAsync(cleanSpotifyId);
|
||||||
|
|
||||||
|
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("✓ Prefetched Spotify lyrics for {Artist} - {Title} ({LineCount} lines)",
|
||||||
|
searchArtist, searchTitle, spotifyLyrics.Lines.Count);
|
||||||
|
return; // Success, lyrics are now cached
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to LRCLIB
|
||||||
|
if (_lrclibService != null)
|
||||||
|
{
|
||||||
|
var lyrics = await _lrclibService.GetLyricsAsync(
|
||||||
|
searchTitle,
|
||||||
|
searchArtists.ToArray(),
|
||||||
|
searchAlbum,
|
||||||
|
song.Duration ?? 0);
|
||||||
|
|
||||||
|
if (lyrics != null)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("✓ Prefetched LRCLIB lyrics for {Artist} - {Title}", searchArtist, searchTitle);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogDebug("No lyrics found for {Artist} - {Title}", searchArtist, searchTitle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Error prefetching lyrics for track {ItemId}", itemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Favorites
|
#region Favorites
|
||||||
@@ -1338,11 +1652,25 @@ public class JellyfinController : ControllerBase
|
|||||||
_logger.LogInformation("UnmarkFavorite called: userId={UserId}, itemId={ItemId}, route={Route}",
|
_logger.LogInformation("UnmarkFavorite called: userId={UserId}, itemId={ItemId}, route={Route}",
|
||||||
userId, itemId, Request.Path);
|
userId, itemId, Request.Path);
|
||||||
|
|
||||||
// External items can't be unfavorited (they're not really favorited in Jellyfin)
|
// External items - remove from kept folder if it exists
|
||||||
var (isExternal, _, _) = _localLibraryService.ParseSongId(itemId);
|
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
|
||||||
if (isExternal || PlaylistIdHelper.IsExternalPlaylist(itemId))
|
if (isExternal || PlaylistIdHelper.IsExternalPlaylist(itemId))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Unfavoriting external item {ItemId} - returning success", itemId);
|
_logger.LogInformation("Unfavoriting external item {ItemId} - removing from kept folder", itemId);
|
||||||
|
|
||||||
|
// Remove from kept folder in background
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await RemoveExternalTrackFromKeptAsync(itemId, provider!, externalId!);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to remove external track {ItemId} from kept folder", itemId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
IsFavorite = false,
|
IsFavorite = false,
|
||||||
@@ -1479,6 +1807,16 @@ public class JellyfinController : ControllerBase
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// Check cache first (1 hour TTL for playlist images since they can change)
|
||||||
|
var cacheKey = $"playlist:image:{playlistId}";
|
||||||
|
var cachedImage = await _cache.GetAsync<byte[]>(cacheKey);
|
||||||
|
|
||||||
|
if (cachedImage != null)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Serving cached playlist image for {PlaylistId}", playlistId);
|
||||||
|
return File(cachedImage, "image/jpeg");
|
||||||
|
}
|
||||||
|
|
||||||
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(playlistId);
|
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(playlistId);
|
||||||
var playlist = await _metadataService.GetPlaylistAsync(provider, externalId);
|
var playlist = await _metadataService.GetPlaylistAsync(provider, externalId);
|
||||||
|
|
||||||
@@ -1495,6 +1833,11 @@ public class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
var imageBytes = await response.Content.ReadAsByteArrayAsync();
|
var imageBytes = await response.Content.ReadAsByteArrayAsync();
|
||||||
var contentType = response.Content.Headers.ContentType?.ToString() ?? "image/jpeg";
|
var contentType = response.Content.Headers.ContentType?.ToString() ?? "image/jpeg";
|
||||||
|
|
||||||
|
// Cache for 1 hour (playlists can change, so don't cache too long)
|
||||||
|
await _cache.SetAsync(cacheKey, imageBytes, TimeSpan.FromHours(1));
|
||||||
|
_logger.LogDebug("Cached playlist image for {PlaylistId}", playlistId);
|
||||||
|
|
||||||
return File(imageBytes, contentType);
|
return File(imageBytes, contentType);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -1530,26 +1873,43 @@ public class JellyfinController : ControllerBase
|
|||||||
_logger.LogInformation("Authentication request received");
|
_logger.LogInformation("Authentication request received");
|
||||||
// DO NOT log request body or detailed headers - contains password
|
// 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);
|
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);
|
var responseJson = result.RootElement.GetRawText();
|
||||||
if (statusCode == 401)
|
|
||||||
{
|
|
||||||
return Unauthorized(new { error = "Invalid username or password" });
|
|
||||||
}
|
|
||||||
return StatusCode(statusCode, new { error = "Authentication failed" });
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// On successful auth, extract access token and post session capabilities in background
|
||||||
|
if (statusCode == 200)
|
||||||
|
{
|
||||||
_logger.LogInformation("Authentication successful");
|
_logger.LogInformation("Authentication successful");
|
||||||
|
|
||||||
// Post session capabilities immediately after authentication
|
// Extract access token from response for session capabilities
|
||||||
// This ensures Jellyfin creates a session that will show up in the dashboard
|
string? accessToken = null;
|
||||||
|
if (result.RootElement.TryGetProperty("AccessToken", out var tokenEl))
|
||||||
|
{
|
||||||
|
accessToken = tokenEl.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post session capabilities in background if we have a token
|
||||||
|
if (!string.IsNullOrEmpty(accessToken))
|
||||||
|
{
|
||||||
|
// Capture token in closure - don't use Request.Headers (will be disposed)
|
||||||
|
var token = accessToken;
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_logger.LogInformation("🔧 Posting session capabilities after authentication");
|
_logger.LogDebug("🔧 Posting session capabilities after authentication");
|
||||||
|
|
||||||
|
// Build auth header with the new token
|
||||||
|
var authHeaders = new HeaderDictionary
|
||||||
|
{
|
||||||
|
["X-Emby-Token"] = token
|
||||||
|
};
|
||||||
|
|
||||||
var capabilities = new
|
var capabilities = new
|
||||||
{
|
{
|
||||||
PlayableMediaTypes = new[] { "Audio" },
|
PlayableMediaTypes = new[] { "Audio" },
|
||||||
@@ -1560,23 +1920,36 @@ public class JellyfinController : ControllerBase
|
|||||||
};
|
};
|
||||||
|
|
||||||
var capabilitiesJson = JsonSerializer.Serialize(capabilities);
|
var capabilitiesJson = JsonSerializer.Serialize(capabilities);
|
||||||
var (capResult, capStatus) = await _proxyService.PostJsonAsync("Sessions/Capabilities/Full", capabilitiesJson, Request.Headers);
|
var (capResult, capStatus) = await _proxyService.PostJsonAsync("Sessions/Capabilities/Full", capabilitiesJson, authHeaders);
|
||||||
|
|
||||||
if (capStatus == 204 || capStatus == 200)
|
if (capStatus == 204 || capStatus == 200)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("✓ Session capabilities posted after auth ({StatusCode})", capStatus);
|
_logger.LogDebug("✓ Session capabilities posted after auth ({StatusCode})", capStatus);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogWarning("⚠ Session capabilities returned {StatusCode} after auth", capStatus);
|
_logger.LogDebug("⚠ Session capabilities returned {StatusCode} after auth", capStatus);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to post session capabilities after auth, continuing anyway");
|
_logger.LogDebug(ex, "Failed to post session capabilities after auth");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Authentication failed - status {StatusCode}", statusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Content(result.RootElement.GetRawText(), "application/json");
|
// Return Jellyfin's exact response
|
||||||
|
return Content(responseJson, "application/json");
|
||||||
|
}
|
||||||
|
|
||||||
|
// No response body from Jellyfin - return status code only
|
||||||
|
_logger.LogWarning("Authentication request returned {StatusCode} with no response body", statusCode);
|
||||||
|
return StatusCode(statusCode);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -1810,7 +2183,7 @@ public class JellyfinController : ControllerBase
|
|||||||
var method = Request.Method;
|
var method = Request.Method;
|
||||||
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : "";
|
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : "";
|
||||||
|
|
||||||
_logger.LogInformation("📡 Session capabilities reported - Method: {Method}, Query: {Query}", method, queryString);
|
_logger.LogDebug("📡 Session capabilities reported - Method: {Method}, Query: {Query}", method, queryString);
|
||||||
_logger.LogInformation("Headers: {Headers}",
|
_logger.LogInformation("Headers: {Headers}",
|
||||||
string.Join(", ", Request.Headers.Where(h => h.Key.Contains("Auth", StringComparison.OrdinalIgnoreCase) || h.Key.Contains("Device", StringComparison.OrdinalIgnoreCase) || h.Key.Contains("Client", StringComparison.OrdinalIgnoreCase))
|
string.Join(", ", Request.Headers.Where(h => h.Key.Contains("Auth", StringComparison.OrdinalIgnoreCase) || h.Key.Contains("Device", StringComparison.OrdinalIgnoreCase) || h.Key.Contains("Client", StringComparison.OrdinalIgnoreCase))
|
||||||
.Select(h => $"{h.Key}={h.Value}")));
|
.Select(h => $"{h.Key}={h.Value}")));
|
||||||
@@ -1835,7 +2208,11 @@ public class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
if (statusCode == 204 || statusCode == 200)
|
if (statusCode == 204 || statusCode == 200)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("✓ Session capabilities forwarded to Jellyfin ({StatusCode})", statusCode);
|
_logger.LogDebug("✓ Session capabilities forwarded to Jellyfin ({StatusCode})", statusCode);
|
||||||
|
}
|
||||||
|
else if (statusCode == 401)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("⚠ Jellyfin returned 401 for capabilities (token expired)");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -1869,12 +2246,13 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
Request.Body.Position = 0;
|
Request.Body.Position = 0;
|
||||||
|
|
||||||
_logger.LogInformation("📻 Playback START reported");
|
_logger.LogDebug("📻 Playback START reported");
|
||||||
|
|
||||||
// Parse the body to check if it's an external track
|
// Parse the body to check if it's an external track
|
||||||
var doc = JsonDocument.Parse(body);
|
var doc = JsonDocument.Parse(body);
|
||||||
string? itemId = null;
|
string? itemId = null;
|
||||||
string? itemName = null;
|
string? itemName = null;
|
||||||
|
long? positionTicks = null;
|
||||||
|
|
||||||
if (doc.RootElement.TryGetProperty("ItemId", out var itemIdProp))
|
if (doc.RootElement.TryGetProperty("ItemId", out var itemIdProp))
|
||||||
{
|
{
|
||||||
@@ -1886,6 +2264,18 @@ public class JellyfinController : ControllerBase
|
|||||||
itemName = itemNameProp.GetString();
|
itemName = itemNameProp.GetString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (doc.RootElement.TryGetProperty("PositionTicks", out var posProp))
|
||||||
|
{
|
||||||
|
positionTicks = posProp.GetInt64();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track the playing item for scrobbling on session cleanup
|
||||||
|
var (deviceId, client, device, version) = ExtractDeviceInfo(Request.Headers);
|
||||||
|
if (!string.IsNullOrEmpty(deviceId) && !string.IsNullOrEmpty(itemId))
|
||||||
|
{
|
||||||
|
_sessionManager.UpdatePlayingItem(deviceId, itemId, positionTicks);
|
||||||
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(itemId))
|
if (!string.IsNullOrEmpty(itemId))
|
||||||
{
|
{
|
||||||
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
|
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
|
||||||
@@ -1894,17 +2284,73 @@ public class JellyfinController : ControllerBase
|
|||||||
{
|
{
|
||||||
_logger.LogInformation("🎵 External track playback started: {Name} ({Provider}/{ExternalId})",
|
_logger.LogInformation("🎵 External track playback started: {Name} ({Provider}/{ExternalId})",
|
||||||
itemName ?? "Unknown", provider, externalId);
|
itemName ?? "Unknown", provider, externalId);
|
||||||
// For external tracks, we can't report to Jellyfin since it doesn't know about them
|
|
||||||
// Just return success so the client is happy
|
// Proactively fetch lyrics in background for external tracks
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await PrefetchLyricsForTrackAsync(itemId, isExternal: true, provider, externalId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Failed to prefetch lyrics for external track {ItemId}", itemId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a ghost/fake item to report to Jellyfin so "Now Playing" shows up
|
||||||
|
// Generate a deterministic UUID from the external ID
|
||||||
|
var ghostUuid = GenerateUuidFromString(itemId);
|
||||||
|
|
||||||
|
// Build minimal playback start with just the ghost UUID
|
||||||
|
// Don't include the Item object - Jellyfin will just track the session without item details
|
||||||
|
var playbackStart = new
|
||||||
|
{
|
||||||
|
ItemId = ghostUuid,
|
||||||
|
PositionTicks = positionTicks ?? 0,
|
||||||
|
CanSeek = true,
|
||||||
|
IsPaused = false,
|
||||||
|
IsMuted = false,
|
||||||
|
PlayMethod = "DirectPlay"
|
||||||
|
};
|
||||||
|
|
||||||
|
var playbackJson = JsonSerializer.Serialize(playbackStart);
|
||||||
|
_logger.LogDebug("📤 Sending ghost playback start for external track: {Json}", playbackJson);
|
||||||
|
|
||||||
|
// Forward to Jellyfin with ghost UUID
|
||||||
|
var (ghostResult, ghostStatusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", playbackJson, Request.Headers);
|
||||||
|
|
||||||
|
if (ghostStatusCode == 204 || ghostStatusCode == 200)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("✓ Ghost playback start forwarded to Jellyfin for external track ({StatusCode})", ghostStatusCode);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("⚠️ Ghost playback start returned status {StatusCode} for external track", ghostStatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("🎵 Local track playback started: {Name} (ID: {ItemId})",
|
_logger.LogInformation("🎵 Local track playback started: {Name} (ID: {ItemId})",
|
||||||
itemName ?? "Unknown", itemId);
|
itemName ?? "Unknown", itemId);
|
||||||
|
|
||||||
|
// Proactively fetch lyrics in background for local tracks
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await PrefetchLyricsForTrackAsync(itemId, isExternal: false, null, null);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Failed to prefetch lyrics for local track {ItemId}", itemId);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// For local tracks, forward playback start to Jellyfin FIRST
|
// For local tracks, forward playback start to Jellyfin FIRST
|
||||||
_logger.LogInformation("Forwarding playback start to Jellyfin...");
|
_logger.LogDebug("Forwarding playback start to Jellyfin...");
|
||||||
|
|
||||||
// Fetch full item details to include in playback report
|
// Fetch full item details to include in playback report
|
||||||
try
|
try
|
||||||
@@ -1919,7 +2365,7 @@ public class JellyfinController : ControllerBase
|
|||||||
var playbackStart = new
|
var playbackStart = new
|
||||||
{
|
{
|
||||||
ItemId = itemId,
|
ItemId = itemId,
|
||||||
PositionTicks = doc.RootElement.TryGetProperty("PositionTicks", out var posProp) ? posProp.GetInt64() : 0,
|
PositionTicks = positionTicks ?? 0,
|
||||||
// Let Jellyfin fetch the item details - don't include NowPlayingItem
|
// Let Jellyfin fetch the item details - don't include NowPlayingItem
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1930,16 +2376,15 @@ public class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
if (statusCode == 204 || statusCode == 200)
|
if (statusCode == 204 || statusCode == 200)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("✓ Playback start forwarded to Jellyfin ({StatusCode})", statusCode);
|
_logger.LogDebug("✓ Playback start forwarded to Jellyfin ({StatusCode})", statusCode);
|
||||||
|
|
||||||
// NOW ensure session exists with capabilities (after playback is reported)
|
// NOW ensure session exists with capabilities (after playback is reported)
|
||||||
var (deviceId, client, device, version) = ExtractDeviceInfo(Request.Headers);
|
|
||||||
if (!string.IsNullOrEmpty(deviceId))
|
if (!string.IsNullOrEmpty(deviceId))
|
||||||
{
|
{
|
||||||
var sessionCreated = await _sessionManager.EnsureSessionAsync(deviceId, client ?? "Unknown", device ?? "Unknown", version ?? "1.0", Request.Headers);
|
var sessionCreated = await _sessionManager.EnsureSessionAsync(deviceId, client ?? "Unknown", device ?? "Unknown", version ?? "1.0", Request.Headers);
|
||||||
if (sessionCreated)
|
if (sessionCreated)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("✓ SESSION: Session ensured for device {DeviceId} after playback start", deviceId);
|
_logger.LogDebug("✓ SESSION: Session ensured for device {DeviceId} after playback start", deviceId);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -1963,7 +2408,7 @@ public class JellyfinController : ControllerBase
|
|||||||
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", body, Request.Headers);
|
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", body, Request.Headers);
|
||||||
if (statusCode == 204 || statusCode == 200)
|
if (statusCode == 204 || statusCode == 200)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("✓ Basic playback start forwarded to Jellyfin ({StatusCode})", statusCode);
|
_logger.LogDebug("✓ Basic playback start forwarded to Jellyfin ({StatusCode})", statusCode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2024,13 +2469,48 @@ public class JellyfinController : ControllerBase
|
|||||||
positionTicks = posProp.GetInt64();
|
positionTicks = posProp.GetInt64();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track the playing item for scrobbling on session cleanup
|
||||||
|
if (!string.IsNullOrEmpty(deviceId) && !string.IsNullOrEmpty(itemId))
|
||||||
|
{
|
||||||
|
_sessionManager.UpdatePlayingItem(deviceId, itemId, positionTicks);
|
||||||
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(itemId))
|
if (!string.IsNullOrEmpty(itemId))
|
||||||
{
|
{
|
||||||
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
|
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
|
||||||
|
|
||||||
if (isExternal)
|
if (isExternal)
|
||||||
{
|
{
|
||||||
// For external tracks, just acknowledge (no logging to avoid spam)
|
// For external tracks, report progress with ghost UUID to Jellyfin
|
||||||
|
var ghostUuid = GenerateUuidFromString(itemId);
|
||||||
|
|
||||||
|
// Build progress report with ghost UUID
|
||||||
|
var progressReport = new
|
||||||
|
{
|
||||||
|
ItemId = ghostUuid,
|
||||||
|
PositionTicks = positionTicks ?? 0,
|
||||||
|
IsPaused = false,
|
||||||
|
IsMuted = false,
|
||||||
|
CanSeek = true,
|
||||||
|
PlayMethod = "DirectPlay"
|
||||||
|
};
|
||||||
|
|
||||||
|
var progressJson = JsonSerializer.Serialize(progressReport);
|
||||||
|
|
||||||
|
// Forward to Jellyfin with ghost UUID
|
||||||
|
var (progressResult, progressStatusCode) = await _proxyService.PostJsonAsync("Sessions/Playing/Progress", progressJson, Request.Headers);
|
||||||
|
|
||||||
|
// Log progress occasionally for debugging (every ~30 seconds)
|
||||||
|
if (positionTicks.HasValue)
|
||||||
|
{
|
||||||
|
var position = TimeSpan.FromTicks(positionTicks.Value);
|
||||||
|
if (position.Seconds % 30 == 0 && position.Milliseconds < 500)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("▶️ External track progress: {Position:mm\\:ss} ({Provider}/{ExternalId}) - Status: {StatusCode}",
|
||||||
|
position, provider, externalId, progressStatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2041,12 +2521,14 @@ public class JellyfinController : ControllerBase
|
|||||||
// Only log at 10-second intervals
|
// Only log at 10-second intervals
|
||||||
if (position.Seconds % 10 == 0 && position.Milliseconds < 500)
|
if (position.Seconds % 10 == 0 && position.Milliseconds < 500)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("▶️ Progress: {Position:mm\\:ss} for item {ItemId}", position, itemId);
|
_logger.LogDebug("▶️ Progress: {Position:mm\\:ss} for item {ItemId}", position, itemId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For local tracks, forward to Jellyfin
|
// 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);
|
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing/Progress", body, Request.Headers);
|
||||||
|
|
||||||
if (statusCode != 204 && statusCode != 200)
|
if (statusCode != 204 && statusCode != 200)
|
||||||
@@ -2079,13 +2561,14 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
Request.Body.Position = 0;
|
Request.Body.Position = 0;
|
||||||
|
|
||||||
_logger.LogInformation("⏹️ Playback STOPPED reported");
|
_logger.LogDebug("⏹️ Playback STOPPED reported");
|
||||||
|
|
||||||
// Parse the body to check if it's an external track
|
// Parse the body to check if it's an external track
|
||||||
var doc = JsonDocument.Parse(body);
|
var doc = JsonDocument.Parse(body);
|
||||||
string? itemId = null;
|
string? itemId = null;
|
||||||
string? itemName = null;
|
string? itemName = null;
|
||||||
long? positionTicks = null;
|
long? positionTicks = null;
|
||||||
|
string? deviceId = null;
|
||||||
|
|
||||||
if (doc.RootElement.TryGetProperty("ItemId", out var itemIdProp))
|
if (doc.RootElement.TryGetProperty("ItemId", out var itemIdProp))
|
||||||
{
|
{
|
||||||
@@ -2102,6 +2585,12 @@ public class JellyfinController : ControllerBase
|
|||||||
positionTicks = posProp.GetInt64();
|
positionTicks = posProp.GetInt64();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try to get device ID from headers for session management
|
||||||
|
if (Request.Headers.TryGetValue("X-Emby-Device-Id", out var deviceIdHeader))
|
||||||
|
{
|
||||||
|
deviceId = deviceIdHeader.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(itemId))
|
if (!string.IsNullOrEmpty(itemId))
|
||||||
{
|
{
|
||||||
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
|
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
|
||||||
@@ -2113,6 +2602,26 @@ public class JellyfinController : ControllerBase
|
|||||||
: "unknown";
|
: "unknown";
|
||||||
_logger.LogInformation("🎵 External track playback stopped: {Name} at {Position} ({Provider}/{ExternalId})",
|
_logger.LogInformation("🎵 External track playback stopped: {Name} at {Position} ({Provider}/{ExternalId})",
|
||||||
itemName ?? "Unknown", position, provider, externalId);
|
itemName ?? "Unknown", position, provider, externalId);
|
||||||
|
|
||||||
|
// Report stop to Jellyfin with ghost UUID
|
||||||
|
var ghostUuid = GenerateUuidFromString(itemId);
|
||||||
|
|
||||||
|
var stopInfo = new
|
||||||
|
{
|
||||||
|
ItemId = ghostUuid,
|
||||||
|
PositionTicks = positionTicks ?? 0
|
||||||
|
};
|
||||||
|
|
||||||
|
var stopJson = JsonSerializer.Serialize(stopInfo);
|
||||||
|
_logger.LogDebug("📤 Sending ghost playback stop for external track: {Json}", stopJson);
|
||||||
|
|
||||||
|
var (stopResult, stopStatusCode) = await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", stopJson, Request.Headers);
|
||||||
|
|
||||||
|
if (stopStatusCode == 204 || stopStatusCode == 200)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("✓ Ghost playback stop forwarded to Jellyfin ({StatusCode})", stopStatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2121,12 +2630,34 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
// For local tracks, forward to Jellyfin
|
// For local tracks, forward to Jellyfin
|
||||||
_logger.LogInformation("Forwarding playback stop to Jellyfin...");
|
_logger.LogDebug("Forwarding playback stop to Jellyfin...");
|
||||||
|
|
||||||
|
// Log the body being sent for debugging
|
||||||
|
_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);
|
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", body, Request.Headers);
|
||||||
|
|
||||||
if (statusCode == 204 || statusCode == 200)
|
if (statusCode == 204 || statusCode == 200)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("✓ Playback stop forwarded to Jellyfin ({StatusCode})", statusCode);
|
_logger.LogDebug("✓ Playback stop forwarded to Jellyfin ({StatusCode})", statusCode);
|
||||||
|
}
|
||||||
|
else if (statusCode == 401)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Playback stop returned 401 (token expired)");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -2185,7 +2716,7 @@ public class JellyfinController : ControllerBase
|
|||||||
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : "";
|
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : "";
|
||||||
var endpoint = string.IsNullOrEmpty(path) ? $"Sessions{queryString}" : $"Sessions/{path}{queryString}";
|
var endpoint = string.IsNullOrEmpty(path) ? $"Sessions{queryString}" : $"Sessions/{path}{queryString}";
|
||||||
|
|
||||||
_logger.LogInformation("🔄 Proxying session request: {Method} {Endpoint}", method, endpoint);
|
_logger.LogDebug("🔄 Proxying session request: {Method} {Endpoint}", method, endpoint);
|
||||||
_logger.LogDebug("Session proxy headers: {Headers}",
|
_logger.LogDebug("Session proxy headers: {Headers}",
|
||||||
string.Join(", ", Request.Headers.Where(h => h.Key.Contains("Auth", StringComparison.OrdinalIgnoreCase))
|
string.Join(", ", Request.Headers.Where(h => h.Key.Contains("Auth", StringComparison.OrdinalIgnoreCase))
|
||||||
.Select(h => $"{h.Key}={h.Value}")));
|
.Select(h => $"{h.Key}={h.Value}")));
|
||||||
@@ -2215,11 +2746,11 @@ public class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
if (result != null)
|
if (result != null)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("✓ Session request proxied successfully ({StatusCode})", statusCode);
|
_logger.LogDebug("✓ Session request proxied successfully ({StatusCode})", statusCode);
|
||||||
return new JsonResult(result.RootElement.Clone());
|
return new JsonResult(result.RootElement.Clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("✓ Session request proxied ({StatusCode}, no body)", statusCode);
|
_logger.LogDebug("✓ Session request proxied ({StatusCode}, no body)", statusCode);
|
||||||
return StatusCode(statusCode);
|
return StatusCode(statusCode);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -2277,7 +2808,7 @@ public class JellyfinController : ControllerBase
|
|||||||
if (path.Contains("session", StringComparison.OrdinalIgnoreCase) ||
|
if (path.Contains("session", StringComparison.OrdinalIgnoreCase) ||
|
||||||
path.Contains("capabilit", StringComparison.OrdinalIgnoreCase))
|
path.Contains("capabilit", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("🔍 SESSION/CAPABILITY REQUEST: {Method} /{Path}{Query}", Request.Method, path, Request.QueryString);
|
_logger.LogDebug("🔍 SESSION/CAPABILITY REQUEST: {Method} /{Path}{Query}", Request.Method, path, Request.QueryString);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -2636,17 +3167,19 @@ public class JellyfinController : ControllerBase
|
|||||||
_logger.LogInformation("Found Spotify playlist: {Id}", playlistId);
|
_logger.LogInformation("Found Spotify playlist: {Id}", playlistId);
|
||||||
|
|
||||||
// This is a Spotify playlist - get the actual track count
|
// This is a Spotify playlist - get the actual track count
|
||||||
var playlistConfig = _spotifySettings.GetPlaylistById(playlistId);
|
var playlistConfig = _spotifySettings.GetPlaylistByJellyfinId(playlistId);
|
||||||
|
|
||||||
if (playlistConfig != null)
|
if (playlistConfig != null)
|
||||||
{
|
{
|
||||||
|
_logger.LogInformation("Found playlist config for Jellyfin ID {JellyfinId}: {Name} (Spotify ID: {SpotifyId})",
|
||||||
|
playlistId, playlistConfig.Name, playlistConfig.Id);
|
||||||
var playlistName = playlistConfig.Name;
|
var playlistName = playlistConfig.Name;
|
||||||
|
|
||||||
// Get matched external tracks (tracks that were successfully downloaded/matched)
|
// Get matched external tracks (tracks that were successfully downloaded/matched)
|
||||||
var matchedTracksKey = $"spotify:matched:ordered:{playlistName}";
|
var matchedTracksKey = $"spotify:matched:ordered:{playlistName}";
|
||||||
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
|
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
|
||||||
|
|
||||||
_logger.LogInformation("Cache lookup for {Key}: {Count} matched tracks",
|
_logger.LogDebug("Cache lookup for {Key}: {Count} matched tracks",
|
||||||
matchedTracksKey, matchedTracks?.Count ?? 0);
|
matchedTracksKey, matchedTracks?.Count ?? 0);
|
||||||
|
|
||||||
// Fallback to legacy cache format
|
// Fallback to legacy cache format
|
||||||
@@ -2661,18 +3194,44 @@ public class JellyfinController : ControllerBase
|
|||||||
Position = i,
|
Position = i,
|
||||||
MatchedSong = s
|
MatchedSong = s
|
||||||
}).ToList();
|
}).ToList();
|
||||||
_logger.LogInformation("Loaded {Count} tracks from legacy cache", matchedTracks.Count);
|
_logger.LogDebug("Loaded {Count} tracks from legacy cache", matchedTracks.Count);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try loading from file cache if Redis is empty
|
||||||
|
if (matchedTracks == null || matchedTracks.Count == 0)
|
||||||
|
{
|
||||||
|
var fileItems = await LoadPlaylistItemsFromFile(playlistName);
|
||||||
|
if (fileItems != null && fileItems.Count > 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("💿 Loaded {Count} playlist items from file cache for count update", fileItems.Count);
|
||||||
|
// Use file cache count directly
|
||||||
|
itemDict["ChildCount"] = fileItems.Count;
|
||||||
|
modified = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only fetch from Jellyfin if we didn't get count from file cache
|
||||||
|
if (!itemDict.ContainsKey("ChildCount") ||
|
||||||
|
(itemDict["ChildCount"] is JsonElement childCountElement && childCountElement.GetInt32() == 0) ||
|
||||||
|
(itemDict["ChildCount"] is int childCountInt && childCountInt == 0))
|
||||||
|
{
|
||||||
// Get local tracks count from Jellyfin
|
// Get local tracks count from Jellyfin
|
||||||
var localTracksCount = 0;
|
var localTracksCount = 0;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var (localTracksResponse, _) = await _proxyService.GetJsonAsync(
|
// Include UserId parameter to avoid 401 Unauthorized
|
||||||
$"Playlists/{playlistId}/Items",
|
var userId = _settings.UserId;
|
||||||
null,
|
var playlistItemsUrl = $"Playlists/{playlistId}/Items";
|
||||||
Request.Headers);
|
var queryParams = new Dictionary<string, string>();
|
||||||
|
if (!string.IsNullOrEmpty(userId))
|
||||||
|
{
|
||||||
|
queryParams["UserId"] = userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (localTracksResponse, _) = await _proxyService.GetJsonAsyncInternal(
|
||||||
|
playlistItemsUrl,
|
||||||
|
queryParams);
|
||||||
|
|
||||||
if (localTracksResponse != null &&
|
if (localTracksResponse != null &&
|
||||||
localTracksResponse.RootElement.TryGetProperty("Items", out var localItems))
|
localTracksResponse.RootElement.TryGetProperty("Items", out var localItems))
|
||||||
@@ -2694,24 +3253,30 @@ public class JellyfinController : ControllerBase
|
|||||||
externalMatchedCount = matchedTracks.Count(t => t.MatchedSong != null && !t.MatchedSong.IsLocal);
|
externalMatchedCount = matchedTracks.Count(t => t.MatchedSong != null && !t.MatchedSong.IsLocal);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Total available tracks = what's actually in Jellyfin (local + external matched)
|
// Total available tracks = local tracks in Jellyfin + external matched tracks
|
||||||
// This is what clients should see as the track count
|
// This represents what users will actually hear when playing the playlist
|
||||||
var totalAvailableCount = localTracksCount;
|
var totalAvailableCount = localTracksCount + externalMatchedCount;
|
||||||
|
|
||||||
if (totalAvailableCount > 0)
|
if (totalAvailableCount > 0)
|
||||||
{
|
{
|
||||||
// Update ChildCount to show actual available tracks
|
// Update ChildCount to show actual available tracks
|
||||||
itemDict["ChildCount"] = totalAvailableCount;
|
itemDict["ChildCount"] = totalAvailableCount;
|
||||||
modified = true;
|
modified = true;
|
||||||
_logger.LogInformation("✓ Updated ChildCount for Spotify playlist {Name} to {Total} (actual tracks in Jellyfin)",
|
_logger.LogInformation("✓ Updated ChildCount for Spotify playlist {Name} to {Total} ({Local} local + {External} external)",
|
||||||
playlistName, totalAvailableCount);
|
playlistName, totalAvailableCount, localTracksCount, externalMatchedCount);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogWarning("No tracks found in Jellyfin for {Name}", playlistName);
|
_logger.LogWarning("No tracks found for {Name} ({Local} local + {External} external = {Total} total)",
|
||||||
|
playlistName, localTracksCount, externalMatchedCount, totalAvailableCount);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("No playlist config found for Jellyfin ID {JellyfinId} - skipping count update", playlistId);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updatedItems.Add(itemDict);
|
updatedItems.Add(itemDict);
|
||||||
@@ -2898,6 +3463,49 @@ public class JellyfinController : ControllerBase
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<IActionResult?> GetSpotifyPlaylistTracksOrderedAsync(string spotifyPlaylistName, string playlistId)
|
private async Task<IActionResult?> GetSpotifyPlaylistTracksOrderedAsync(string spotifyPlaylistName, string playlistId)
|
||||||
{
|
{
|
||||||
|
// Check Redis cache first for fast serving
|
||||||
|
var cacheKey = $"spotify:playlist:items:{spotifyPlaylistName}";
|
||||||
|
var cachedItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(cacheKey);
|
||||||
|
|
||||||
|
if (cachedItems != null && cachedItems.Count > 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("✅ Loaded {Count} playlist items from Redis cache for {Playlist}",
|
||||||
|
cachedItems.Count, spotifyPlaylistName);
|
||||||
|
|
||||||
|
// Log sample item to verify Spotify IDs are present
|
||||||
|
if (cachedItems.Count > 0 && cachedItems[0].ContainsKey("ProviderIds"))
|
||||||
|
{
|
||||||
|
var providerIds = cachedItems[0]["ProviderIds"] as Dictionary<string, object>;
|
||||||
|
var hasSpotifyId = providerIds?.ContainsKey("Spotify") ?? false;
|
||||||
|
_logger.LogDebug("Sample cached item has Spotify ID: {HasSpotifyId}", hasSpotifyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResult(new
|
||||||
|
{
|
||||||
|
Items = cachedItems,
|
||||||
|
TotalRecordCount = cachedItems.Count,
|
||||||
|
StartIndex = 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file cache as fallback
|
||||||
|
var fileItems = await LoadPlaylistItemsFromFile(spotifyPlaylistName);
|
||||||
|
if (fileItems != null && fileItems.Count > 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("✅ Loaded {Count} playlist items from file cache for {Playlist}",
|
||||||
|
fileItems.Count, spotifyPlaylistName);
|
||||||
|
|
||||||
|
// Restore to Redis cache
|
||||||
|
await _cache.SetAsync(cacheKey, fileItems, TimeSpan.FromHours(24));
|
||||||
|
|
||||||
|
return new JsonResult(new
|
||||||
|
{
|
||||||
|
Items = fileItems,
|
||||||
|
TotalRecordCount = fileItems.Count,
|
||||||
|
StartIndex = 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Check for ordered matched tracks from SpotifyTrackMatchingService
|
// Check for ordered matched tracks from SpotifyTrackMatchingService
|
||||||
var orderedCacheKey = $"spotify:matched:ordered:{spotifyPlaylistName}";
|
var orderedCacheKey = $"spotify:matched:ordered:{spotifyPlaylistName}";
|
||||||
var orderedTracks = await _cache.GetAsync<List<MatchedTrack>>(orderedCacheKey);
|
var orderedTracks = await _cache.GetAsync<List<MatchedTrack>>(orderedCacheKey);
|
||||||
@@ -2912,7 +3520,7 @@ public class JellyfinController : ControllerBase
|
|||||||
_logger.LogDebug("Using {Count} ordered matched tracks for {Playlist}",
|
_logger.LogDebug("Using {Count} ordered matched tracks for {Playlist}",
|
||||||
orderedTracks.Count, spotifyPlaylistName);
|
orderedTracks.Count, spotifyPlaylistName);
|
||||||
|
|
||||||
// Get existing Jellyfin playlist items (tracks the Spotify Import plugin already found)
|
// Get existing Jellyfin playlist items (RAW - don't convert!)
|
||||||
// CRITICAL: Must include UserId parameter or Jellyfin returns empty results
|
// CRITICAL: Must include UserId parameter or Jellyfin returns empty results
|
||||||
var userId = _settings.UserId;
|
var userId = _settings.UserId;
|
||||||
if (string.IsNullOrEmpty(userId))
|
if (string.IsNullOrEmpty(userId))
|
||||||
@@ -2921,7 +3529,8 @@ public class JellyfinController : ControllerBase
|
|||||||
return null; // Fall back to legacy mode
|
return null; // Fall back to legacy mode
|
||||||
}
|
}
|
||||||
|
|
||||||
var playlistItemsUrl = $"Playlists/{playlistId}/Items?UserId={userId}";
|
// Request MediaSources field to get bitrate info
|
||||||
|
var playlistItemsUrl = $"Playlists/{playlistId}/Items?UserId={userId}&Fields=MediaSources";
|
||||||
|
|
||||||
_logger.LogInformation("🔍 Fetching existing tracks from Jellyfin playlist {PlaylistId} with UserId {UserId}",
|
_logger.LogInformation("🔍 Fetching existing tracks from Jellyfin playlist {PlaylistId} with UserId {UserId}",
|
||||||
playlistId, userId);
|
playlistId, userId);
|
||||||
@@ -2937,24 +3546,41 @@ public class JellyfinController : ControllerBase
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var existingTracks = new List<Song>();
|
// Keep raw Jellyfin items - don't convert to Song objects!
|
||||||
|
var jellyfinItems = new List<JsonElement>();
|
||||||
|
var jellyfinItemsByName = new Dictionary<string, JsonElement>();
|
||||||
|
|
||||||
if (existingTracksResponse != null &&
|
if (existingTracksResponse != null &&
|
||||||
existingTracksResponse.RootElement.TryGetProperty("Items", out var items))
|
existingTracksResponse.RootElement.TryGetProperty("Items", out var items))
|
||||||
{
|
{
|
||||||
foreach (var item in items.EnumerateArray())
|
foreach (var item in items.EnumerateArray())
|
||||||
{
|
{
|
||||||
var song = _modelMapper.ParseSong(item);
|
jellyfinItems.Add(item);
|
||||||
existingTracks.Add(song);
|
|
||||||
_logger.LogDebug(" 📌 Local track: {Title} - {Artist}", song.Title, song.Artist);
|
// Index by title+artist for matching
|
||||||
|
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
|
||||||
|
var artist = "";
|
||||||
|
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
|
||||||
|
{
|
||||||
|
artist = artistsEl[0].GetString() ?? "";
|
||||||
}
|
}
|
||||||
_logger.LogInformation("✅ Found {Count} existing LOCAL tracks in Jellyfin playlist - will match by name only",
|
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
|
||||||
existingTracks.Count);
|
{
|
||||||
|
artist = albumArtistEl.GetString() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = $"{title}|{artist}".ToLowerInvariant();
|
||||||
|
if (!jellyfinItemsByName.ContainsKey(key))
|
||||||
|
{
|
||||||
|
jellyfinItemsByName[key] = item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("✅ Found {Count} existing LOCAL tracks in Jellyfin playlist", jellyfinItems.Count);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogWarning("⚠️ No existing tracks found in Jellyfin playlist {PlaylistId} - playlist may be empty", playlistId);
|
_logger.LogWarning("⚠️ No existing tracks found in Jellyfin playlist {PlaylistId} - playlist may be empty", playlistId);
|
||||||
// Don't return null - continue with external tracks only
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the full playlist from Spotify to know the correct order
|
// Get the full playlist from Spotify to know the correct order
|
||||||
@@ -2966,225 +3592,112 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build the final track list in correct Spotify order
|
// Build the final track list in correct Spotify order
|
||||||
// STRATEGY: Match Jellyfin tracks to Spotify positions, then fill gaps with external
|
var finalItems = new List<Dictionary<string, object?>>();
|
||||||
var finalTracks = new List<Song>();
|
var usedJellyfinItems = new HashSet<string>();
|
||||||
var localUsedCount = 0;
|
var localUsedCount = 0;
|
||||||
var externalUsedCount = 0;
|
var externalUsedCount = 0;
|
||||||
var skippedCount = 0;
|
|
||||||
|
|
||||||
_logger.LogInformation("🔍 Matching {JellyfinCount} Jellyfin tracks to {SpotifyCount} Spotify positions...",
|
_logger.LogInformation("🔍 Building playlist in Spotify order with {SpotifyCount} positions...", spotifyTracks.Count);
|
||||||
existingTracks.Count, spotifyTracks.Count);
|
|
||||||
|
|
||||||
// Step 1: Check for manual mappings first
|
|
||||||
var manualMappings = new Dictionary<string, string>(); // Spotify ID -> Jellyfin ID
|
|
||||||
foreach (var spotifyTrack in spotifyTracks)
|
|
||||||
{
|
|
||||||
var mappingKey = $"spotify:manual-map:{spotifyPlaylistName}:{spotifyTrack.SpotifyId}";
|
|
||||||
var jellyfinId = await _cache.GetAsync<string>(mappingKey);
|
|
||||||
if (!string.IsNullOrEmpty(jellyfinId))
|
|
||||||
{
|
|
||||||
manualMappings[spotifyTrack.SpotifyId] = jellyfinId;
|
|
||||||
_logger.LogInformation("📌 Manual mapping found: Spotify {SpotifyId} → Jellyfin {JellyfinId}",
|
|
||||||
spotifyTrack.SpotifyId, jellyfinId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: For each Spotify position, find the best matching Jellyfin track
|
|
||||||
var spotifyToJellyfinMap = new Dictionary<int, Song>(); // Spotify position -> Jellyfin track
|
|
||||||
var usedJellyfinTracks = new HashSet<string>(); // Track which Jellyfin tracks we've used
|
|
||||||
|
|
||||||
foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position))
|
foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position))
|
||||||
{
|
{
|
||||||
if (existingTracks.Count == 0) break;
|
// Try to find matching Jellyfin item by fuzzy matching
|
||||||
|
JsonElement? matchedJellyfinItem = null;
|
||||||
|
string? matchedKey = null;
|
||||||
|
double bestScore = 0;
|
||||||
|
|
||||||
// Check for manual mapping first
|
foreach (var kvp in jellyfinItemsByName)
|
||||||
if (manualMappings.TryGetValue(spotifyTrack.SpotifyId, out var mappedJellyfinId))
|
|
||||||
{
|
{
|
||||||
var mappedTrack = existingTracks.FirstOrDefault(t => t.Id == mappedJellyfinId);
|
if (usedJellyfinItems.Contains(kvp.Key)) continue;
|
||||||
if (mappedTrack != null && !usedJellyfinTracks.Contains(mappedTrack.Id))
|
|
||||||
|
var item = kvp.Value;
|
||||||
|
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
|
||||||
|
var artist = "";
|
||||||
|
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
|
||||||
{
|
{
|
||||||
spotifyToJellyfinMap[spotifyTrack.Position] = mappedTrack;
|
artist = artistsEl[0].GetString() ?? "";
|
||||||
usedJellyfinTracks.Add(mappedTrack.Id);
|
}
|
||||||
_logger.LogInformation("✅ Position #{Pos}: '{SpotifyTitle}' → LOCAL (manual): '{JellyfinTitle}'",
|
|
||||||
spotifyTrack.Position, spotifyTrack.Title, mappedTrack.Title);
|
var titleScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.Title, title);
|
||||||
continue;
|
var artistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, artist);
|
||||||
|
var totalScore = (titleScore * 0.7) + (artistScore * 0.3);
|
||||||
|
|
||||||
|
if (totalScore > bestScore && totalScore >= 70)
|
||||||
|
{
|
||||||
|
bestScore = totalScore;
|
||||||
|
matchedJellyfinItem = item;
|
||||||
|
matchedKey = kvp.Key;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find best matching Jellyfin track that hasn't been used yet
|
if (matchedJellyfinItem.HasValue && matchedKey != null)
|
||||||
var bestMatch = existingTracks
|
|
||||||
.Where(song => !usedJellyfinTracks.Contains(song.Id))
|
|
||||||
.Select(song => new
|
|
||||||
{
|
{
|
||||||
Song = song,
|
// Use the raw Jellyfin item (preserves ALL metadata including MediaSources!)
|
||||||
TitleScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.Title, song.Title),
|
var itemDict = JsonSerializer.Deserialize<Dictionary<string, object?>>(matchedJellyfinItem.Value.GetRawText());
|
||||||
ArtistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, song.Artist)
|
if (itemDict != null)
|
||||||
})
|
|
||||||
.Select(x => new
|
|
||||||
{
|
{
|
||||||
x.Song,
|
finalItems.Add(itemDict);
|
||||||
x.TitleScore,
|
usedJellyfinItems.Add(matchedKey);
|
||||||
x.ArtistScore,
|
|
||||||
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
|
|
||||||
})
|
|
||||||
.OrderByDescending(x => x.TotalScore)
|
|
||||||
.FirstOrDefault();
|
|
||||||
|
|
||||||
// Use 70% threshold for matching
|
|
||||||
if (bestMatch != null && bestMatch.TotalScore >= 70)
|
|
||||||
{
|
|
||||||
spotifyToJellyfinMap[spotifyTrack.Position] = bestMatch.Song;
|
|
||||||
usedJellyfinTracks.Add(bestMatch.Song.Id);
|
|
||||||
_logger.LogInformation("✅ Position #{Pos}: '{SpotifyTitle}' by {SpotifyArtist} → LOCAL: '{JellyfinTitle}' by {JellyfinArtist} (score: {Score:F1}%)",
|
|
||||||
spotifyTrack.Position,
|
|
||||||
spotifyTrack.Title,
|
|
||||||
spotifyTrack.PrimaryArtist,
|
|
||||||
bestMatch.Song.Title,
|
|
||||||
bestMatch.Song.Artist,
|
|
||||||
bestMatch.TotalScore);
|
|
||||||
}
|
|
||||||
else if (bestMatch != null)
|
|
||||||
{
|
|
||||||
_logger.LogDebug(" ⚠️ Position #{Pos} '{SpotifyTitle}' - Best Jellyfin match too low: {Score:F1}% (need 70%)",
|
|
||||||
spotifyTrack.Position, spotifyTrack.Title, bestMatch.TotalScore);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("📊 Matched {Matched}/{Total} Spotify positions to Jellyfin tracks ({Manual} manual)",
|
|
||||||
spotifyToJellyfinMap.Count, spotifyTracks.Count, manualMappings.Count);
|
|
||||||
|
|
||||||
// Step 3: Build final playlist in Spotify order
|
|
||||||
foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position))
|
|
||||||
{
|
|
||||||
// Check if we have a Jellyfin track for this position
|
|
||||||
if (spotifyToJellyfinMap.TryGetValue(spotifyTrack.Position, out var jellyfinTrack))
|
|
||||||
{
|
|
||||||
finalTracks.Add(jellyfinTrack);
|
|
||||||
localUsedCount++;
|
localUsedCount++;
|
||||||
continue; // Use local track, skip external search
|
_logger.LogDebug("✅ Position #{Pos}: '{Title}' → LOCAL (score: {Score:F1}%)",
|
||||||
|
spotifyTrack.Position, spotifyTrack.Title, bestScore);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
// No local match - try to find external track
|
// No local match - try to find external track
|
||||||
// First check pre-matched cache
|
|
||||||
var matched = orderedTracks?.FirstOrDefault(t => t.SpotifyId == spotifyTrack.SpotifyId);
|
var matched = orderedTracks?.FirstOrDefault(t => t.SpotifyId == spotifyTrack.SpotifyId);
|
||||||
if (matched != null)
|
if (matched != null && matched.MatchedSong != null)
|
||||||
{
|
{
|
||||||
finalTracks.Add(matched.MatchedSong);
|
// Convert external song to Jellyfin item format
|
||||||
|
var externalItem = _responseBuilder.ConvertSongToJellyfinItem(matched.MatchedSong);
|
||||||
|
|
||||||
|
// Add Spotify ID to ProviderIds so lyrics can work
|
||||||
|
if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId))
|
||||||
|
{
|
||||||
|
if (!externalItem.ContainsKey("ProviderIds"))
|
||||||
|
{
|
||||||
|
externalItem["ProviderIds"] = new Dictionary<string, string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var providerIds = externalItem["ProviderIds"] as Dictionary<string, string>;
|
||||||
|
if (providerIds != null && !providerIds.ContainsKey("Spotify"))
|
||||||
|
{
|
||||||
|
providerIds["Spotify"] = spotifyTrack.SpotifyId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finalItems.Add(externalItem);
|
||||||
externalUsedCount++;
|
externalUsedCount++;
|
||||||
_logger.LogInformation("📥 Position #{Pos}: '{Title}' by {Artist} → EXTERNAL (cached): {Provider}/{Id}",
|
_logger.LogDebug("📥 Position #{Pos}: '{Title}' → EXTERNAL: {Provider}/{Id} (Spotify ID: {SpotifyId})",
|
||||||
spotifyTrack.Position,
|
spotifyTrack.Position, spotifyTrack.Title,
|
||||||
spotifyTrack.Title,
|
matched.MatchedSong.ExternalProvider, matched.MatchedSong.ExternalId, spotifyTrack.SpotifyId);
|
||||||
spotifyTrack.PrimaryArtist,
|
|
||||||
matched.MatchedSong.ExternalProvider,
|
|
||||||
matched.MatchedSong.ExternalId);
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// No cached match - search external providers on-demand
|
_logger.LogDebug("❌ Position #{Pos}: '{Title}' → NO MATCH",
|
||||||
try
|
spotifyTrack.Position, spotifyTrack.Title);
|
||||||
{
|
|
||||||
var query = $"{spotifyTrack.Title} {spotifyTrack.PrimaryArtist}";
|
|
||||||
var searchResults = await _metadataService.SearchSongsAsync(query, limit: 5);
|
|
||||||
|
|
||||||
if (searchResults.Count > 0)
|
|
||||||
{
|
|
||||||
// Fuzzy match to find best result
|
|
||||||
var bestExternalMatch = searchResults
|
|
||||||
.Select(song => new
|
|
||||||
{
|
|
||||||
Song = song,
|
|
||||||
TitleScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.Title, song.Title),
|
|
||||||
ArtistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, song.Artist)
|
|
||||||
})
|
|
||||||
.Select(x => new
|
|
||||||
{
|
|
||||||
x.Song,
|
|
||||||
x.TitleScore,
|
|
||||||
x.ArtistScore,
|
|
||||||
TotalScore = (x.TitleScore * 0.6) + (x.ArtistScore * 0.4)
|
|
||||||
})
|
|
||||||
.OrderByDescending(x => x.TotalScore)
|
|
||||||
.FirstOrDefault();
|
|
||||||
|
|
||||||
if (bestExternalMatch != null && bestExternalMatch.TotalScore >= 60)
|
|
||||||
{
|
|
||||||
finalTracks.Add(bestExternalMatch.Song);
|
|
||||||
externalUsedCount++;
|
|
||||||
_logger.LogInformation("📥 Position #{Pos}: '{Title}' by {Artist} → EXTERNAL (on-demand): {Provider}/{Id} (score: {Score:F1}%)",
|
|
||||||
spotifyTrack.Position,
|
|
||||||
spotifyTrack.Title,
|
|
||||||
spotifyTrack.PrimaryArtist,
|
|
||||||
bestExternalMatch.Song.ExternalProvider,
|
|
||||||
bestExternalMatch.Song.ExternalId,
|
|
||||||
bestExternalMatch.TotalScore);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
skippedCount++;
|
|
||||||
_logger.LogWarning("❌ Position #{Pos}: '{Title}' by {Artist} → NO MATCH (best external score: {Score:F1}%, need 60%)",
|
|
||||||
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist,
|
|
||||||
bestExternalMatch?.TotalScore ?? 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
skippedCount++;
|
|
||||||
_logger.LogWarning("❌ Position #{Pos}: '{Title}' by {Artist} → NO MATCH (no external results)",
|
|
||||||
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
skippedCount++;
|
|
||||||
_logger.LogError(ex, "❌ Position #{Pos}: '{Title}' by {Artist} → ERROR searching external providers",
|
|
||||||
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Add any unmatched Jellyfin tracks at the end
|
|
||||||
var unmatchedJellyfinTracks = existingTracks
|
|
||||||
.Where(song => !usedJellyfinTracks.Contains(song.Id))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
if (unmatchedJellyfinTracks.Count > 0)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("📌 Adding {Count} unmatched Jellyfin tracks at the end (not in Spotify playlist)",
|
|
||||||
unmatchedJellyfinTracks.Count);
|
|
||||||
|
|
||||||
foreach (var track in unmatchedJellyfinTracks)
|
|
||||||
{
|
|
||||||
finalTracks.Add(track);
|
|
||||||
localUsedCount++;
|
|
||||||
_logger.LogInformation(" + '{Title}' by {Artist} (Jellyfin only)", track.Title, track.Artist);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
var cacheKey = $"spotify:matched:{spotifyPlaylistName}";
|
|
||||||
await _cache.SetAsync(cacheKey, finalTracks, TimeSpan.FromHours(1));
|
|
||||||
await SaveMatchedTracksToFile(spotifyPlaylistName, finalTracks);
|
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"🎵 Final playlist '{Playlist}': {Total} tracks ({Local} LOCAL + {External} EXTERNAL, {Skipped} not available)",
|
"🎵 Final playlist '{Playlist}': {Total} tracks ({Local} LOCAL + {External} EXTERNAL)",
|
||||||
spotifyPlaylistName,
|
spotifyPlaylistName, finalItems.Count, localUsedCount, externalUsedCount);
|
||||||
finalTracks.Count,
|
|
||||||
localUsedCount,
|
|
||||||
externalUsedCount,
|
|
||||||
skippedCount);
|
|
||||||
|
|
||||||
if (localUsedCount == 0 && existingTracks.Count > 0)
|
// Save to file cache for persistence across restarts
|
||||||
{
|
await SavePlaylistItemsToFile(spotifyPlaylistName, finalItems);
|
||||||
_logger.LogWarning("⚠️ WARNING: Found {Count} tracks in Jellyfin playlist but NONE matched by name!", existingTracks.Count);
|
|
||||||
_logger.LogWarning(" → Track names may be too different between Spotify and Jellyfin");
|
|
||||||
_logger.LogWarning(" → Check that the Jellyfin playlist has the correct tracks");
|
|
||||||
}
|
|
||||||
else if (localUsedCount > 0)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("✅ Successfully used {Local} LOCAL tracks from Jellyfin playlist", localUsedCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
return _responseBuilder.CreateItemsResponse(finalTracks);
|
// Also cache in Redis for fast serving (reuse the same cache key from top of method)
|
||||||
|
await _cache.SetAsync(cacheKey, finalItems, TimeSpan.FromHours(24));
|
||||||
|
|
||||||
|
// Return raw Jellyfin response format
|
||||||
|
return new JsonResult(new
|
||||||
|
{
|
||||||
|
Items = finalItems,
|
||||||
|
TotalRecordCount = finalItems.Count,
|
||||||
|
StartIndex = 0
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -3309,8 +3822,7 @@ public class JellyfinController : ControllerBase
|
|||||||
{
|
{
|
||||||
Song = song,
|
Song = song,
|
||||||
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, song.Title),
|
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, song.Title),
|
||||||
// Calculate artist score by checking ALL artists match
|
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors)
|
||||||
ArtistScore = CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors)
|
|
||||||
})
|
})
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
@@ -3350,7 +3862,7 @@ public class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
// Build final track list based on playlist configuration
|
// Build final track list based on playlist configuration
|
||||||
// Local tracks position is configurable per-playlist
|
// Local tracks position is configurable per-playlist
|
||||||
var playlistConfig = _spotifySettings.GetPlaylistById(playlistId);
|
var playlistConfig = _spotifySettings.GetPlaylistByJellyfinId(playlistId);
|
||||||
var localTracksPosition = playlistConfig?.LocalTracksPosition ?? LocalTracksPosition.First;
|
var localTracksPosition = playlistConfig?.LocalTracksPosition ?? LocalTracksPosition.First;
|
||||||
|
|
||||||
var finalTracks = new List<Song>();
|
var finalTracks = new List<Song>();
|
||||||
@@ -3388,7 +3900,14 @@ public class JellyfinController : ControllerBase
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Get the song metadata first to check if already in kept folder
|
// Check if already favorited (persistent tracking)
|
||||||
|
if (await IsTrackFavoritedAsync(itemId))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Track already favorited (persistent): {ItemId}", itemId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the song metadata first to build paths
|
||||||
var song = await _metadataService.GetSongAsync(provider, externalId);
|
var song = await _metadataService.GetSongAsync(provider, externalId);
|
||||||
if (song == null)
|
if (song == null)
|
||||||
{
|
{
|
||||||
@@ -3396,66 +3915,90 @@ public class JellyfinController : ControllerBase
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build kept folder path: /app/kept/Artist/Album/
|
// Build kept folder path: Artist/Album/
|
||||||
var keptBasePath = "/app/kept";
|
var keptBasePath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
|
||||||
var keptArtistPath = Path.Combine(keptBasePath, PathHelper.SanitizeFileName(song.Artist));
|
var keptArtistPath = Path.Combine(keptBasePath, PathHelper.SanitizeFileName(song.Artist));
|
||||||
var keptAlbumPath = Path.Combine(keptArtistPath, PathHelper.SanitizeFileName(song.Album));
|
var keptAlbumPath = Path.Combine(keptArtistPath, PathHelper.SanitizeFileName(song.Album));
|
||||||
|
|
||||||
// Check if track already exists in kept folder BEFORE downloading
|
// Check if track already exists in kept folder
|
||||||
// Look for any file matching the song title pattern (any extension)
|
|
||||||
if (Directory.Exists(keptAlbumPath))
|
if (Directory.Exists(keptAlbumPath))
|
||||||
{
|
{
|
||||||
var sanitizedTitle = PathHelper.SanitizeFileName(song.Title);
|
var sanitizedTitle = PathHelper.SanitizeFileName(song.Title);
|
||||||
var existingFiles = Directory.GetFiles(keptAlbumPath, $"{sanitizedTitle}.*");
|
var existingFiles = Directory.GetFiles(keptAlbumPath, $"*{sanitizedTitle}*");
|
||||||
if (existingFiles.Length > 0)
|
if (existingFiles.Length > 0)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Track already exists in kept folder: {Path}", existingFiles[0]);
|
_logger.LogInformation("Track already exists in kept folder: {Path}", existingFiles[0]);
|
||||||
|
// Mark as favorited even if we didn't download it
|
||||||
|
await MarkTrackAsFavoritedAsync(itemId, song);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track not in kept folder - download it
|
// Look for the track in cache folder first
|
||||||
_logger.LogInformation("Downloading track for kept folder: {ItemId}", itemId);
|
var cacheBasePath = "/tmp/allstarr-cache";
|
||||||
string downloadPath;
|
var cacheArtistPath = Path.Combine(cacheBasePath, PathHelper.SanitizeFileName(song.Artist));
|
||||||
|
var cacheAlbumPath = Path.Combine(cacheArtistPath, PathHelper.SanitizeFileName(song.Album));
|
||||||
|
|
||||||
|
string? sourceFilePath = null;
|
||||||
|
|
||||||
|
if (Directory.Exists(cacheAlbumPath))
|
||||||
|
{
|
||||||
|
var sanitizedTitle = PathHelper.SanitizeFileName(song.Title);
|
||||||
|
var cacheFiles = Directory.GetFiles(cacheAlbumPath, $"*{sanitizedTitle}*");
|
||||||
|
if (cacheFiles.Length > 0)
|
||||||
|
{
|
||||||
|
sourceFilePath = cacheFiles[0];
|
||||||
|
_logger.LogInformation("Found track in cache folder: {Path}", sourceFilePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not in cache, download it first
|
||||||
|
if (sourceFilePath == null)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Track not in cache, downloading: {ItemId}", itemId);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
downloadPath = await _downloadService.DownloadSongAsync(provider, externalId);
|
sourceFilePath = await _downloadService.DownloadSongAsync(provider, externalId);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to download track {ItemId}", itemId);
|
_logger.LogWarning(ex, "Failed to download track {ItemId}", itemId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create the kept folder structure
|
// Create the kept folder structure
|
||||||
Directory.CreateDirectory(keptAlbumPath);
|
Directory.CreateDirectory(keptAlbumPath);
|
||||||
|
|
||||||
// Copy file to kept folder
|
// Copy file to kept folder
|
||||||
var fileName = Path.GetFileName(downloadPath);
|
var fileName = Path.GetFileName(sourceFilePath);
|
||||||
var keptFilePath = Path.Combine(keptAlbumPath, fileName);
|
var keptFilePath = Path.Combine(keptAlbumPath, fileName);
|
||||||
|
|
||||||
// Double-check in case of race condition (multiple favorite clicks)
|
// Double-check in case of race condition (multiple favorite clicks)
|
||||||
if (System.IO.File.Exists(keptFilePath))
|
if (System.IO.File.Exists(keptFilePath))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Track already exists in kept folder (race condition): {Path}", keptFilePath);
|
_logger.LogInformation("Track already exists in kept folder (race condition): {Path}", keptFilePath);
|
||||||
|
await MarkTrackAsFavoritedAsync(itemId, song);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
System.IO.File.Copy(downloadPath, keptFilePath, overwrite: false);
|
System.IO.File.Copy(sourceFilePath, keptFilePath, overwrite: false);
|
||||||
_logger.LogInformation("✓ Copied favorited track to kept folder: {Path}", keptFilePath);
|
_logger.LogInformation("✓ Copied track to kept folder: {Path}", keptFilePath);
|
||||||
|
|
||||||
// Also copy cover art if it exists
|
// Also copy cover art if it exists
|
||||||
var coverPath = Path.Combine(Path.GetDirectoryName(downloadPath)!, "cover.jpg");
|
var sourceCoverPath = Path.Combine(Path.GetDirectoryName(sourceFilePath)!, "cover.jpg");
|
||||||
if (System.IO.File.Exists(coverPath))
|
if (System.IO.File.Exists(sourceCoverPath))
|
||||||
{
|
{
|
||||||
var keptCoverPath = Path.Combine(keptAlbumPath, "cover.jpg");
|
var keptCoverPath = Path.Combine(keptAlbumPath, "cover.jpg");
|
||||||
if (!System.IO.File.Exists(keptCoverPath))
|
if (!System.IO.File.Exists(keptCoverPath))
|
||||||
{
|
{
|
||||||
System.IO.File.Copy(coverPath, keptCoverPath, overwrite: false);
|
System.IO.File.Copy(sourceCoverPath, keptCoverPath, overwrite: false);
|
||||||
_logger.LogDebug("Copied cover art to kept folder");
|
_logger.LogDebug("Copied cover art to kept folder");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark as favorited in persistent storage
|
||||||
|
await MarkTrackAsFavoritedAsync(itemId, song);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -3463,6 +4006,248 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes an external track from the kept folder when unfavorited.
|
||||||
|
/// </summary>
|
||||||
|
private async Task RemoveExternalTrackFromKeptAsync(string itemId, string provider, string externalId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Mark for deletion instead of immediate deletion
|
||||||
|
await MarkTrackForDeletionAsync(itemId);
|
||||||
|
_logger.LogInformation("✓ Marked track for deletion: {ItemId}", itemId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error marking external track {ItemId} for deletion", itemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Persistent Favorites Tracking
|
||||||
|
|
||||||
|
private readonly string _favoritesFilePath = "/app/cache/favorites.json";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if a track is already favorited (persistent across restarts).
|
||||||
|
/// </summary>
|
||||||
|
private async Task<bool> IsTrackFavoritedAsync(string itemId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!System.IO.File.Exists(_favoritesFilePath))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var json = await System.IO.File.ReadAllTextAsync(_favoritesFilePath);
|
||||||
|
var favorites = JsonSerializer.Deserialize<Dictionary<string, FavoriteTrackInfo>>(json) ?? new();
|
||||||
|
|
||||||
|
return favorites.ContainsKey(itemId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to check favorite status for {ItemId}", itemId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marks a track as favorited in persistent storage.
|
||||||
|
/// </summary>
|
||||||
|
private async Task MarkTrackAsFavoritedAsync(string itemId, Song song)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var favorites = new Dictionary<string, FavoriteTrackInfo>();
|
||||||
|
|
||||||
|
if (System.IO.File.Exists(_favoritesFilePath))
|
||||||
|
{
|
||||||
|
var json = await System.IO.File.ReadAllTextAsync(_favoritesFilePath);
|
||||||
|
favorites = JsonSerializer.Deserialize<Dictionary<string, FavoriteTrackInfo>>(json) ?? new();
|
||||||
|
}
|
||||||
|
|
||||||
|
favorites[itemId] = new FavoriteTrackInfo
|
||||||
|
{
|
||||||
|
ItemId = itemId,
|
||||||
|
Title = song.Title,
|
||||||
|
Artist = song.Artist,
|
||||||
|
Album = song.Album,
|
||||||
|
FavoritedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensure cache directory exists
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(_favoritesFilePath)!);
|
||||||
|
|
||||||
|
var updatedJson = JsonSerializer.Serialize(favorites, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
await System.IO.File.WriteAllTextAsync(_favoritesFilePath, updatedJson);
|
||||||
|
|
||||||
|
_logger.LogDebug("Marked track as favorited: {ItemId}", itemId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to mark track as favorited: {ItemId}", itemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes a track from persistent favorites storage.
|
||||||
|
/// </summary>
|
||||||
|
private async Task UnmarkTrackAsFavoritedAsync(string itemId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!System.IO.File.Exists(_favoritesFilePath))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var json = await System.IO.File.ReadAllTextAsync(_favoritesFilePath);
|
||||||
|
var favorites = JsonSerializer.Deserialize<Dictionary<string, FavoriteTrackInfo>>(json) ?? new();
|
||||||
|
|
||||||
|
if (favorites.Remove(itemId))
|
||||||
|
{
|
||||||
|
var updatedJson = JsonSerializer.Serialize(favorites, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
await System.IO.File.WriteAllTextAsync(_favoritesFilePath, updatedJson);
|
||||||
|
_logger.LogDebug("Removed track from favorites: {ItemId}", itemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to remove track from favorites: {ItemId}", itemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marks a track for deletion (delayed deletion for safety).
|
||||||
|
/// </summary>
|
||||||
|
private async Task MarkTrackForDeletionAsync(string itemId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var deletionFilePath = "/app/cache/pending_deletions.json";
|
||||||
|
var pendingDeletions = new Dictionary<string, DateTime>();
|
||||||
|
|
||||||
|
if (System.IO.File.Exists(deletionFilePath))
|
||||||
|
{
|
||||||
|
var json = await System.IO.File.ReadAllTextAsync(deletionFilePath);
|
||||||
|
pendingDeletions = JsonSerializer.Deserialize<Dictionary<string, DateTime>>(json) ?? new();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark for deletion 24 hours from now
|
||||||
|
pendingDeletions[itemId] = DateTime.UtcNow.AddHours(24);
|
||||||
|
|
||||||
|
// Ensure cache directory exists
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(deletionFilePath)!);
|
||||||
|
|
||||||
|
var updatedJson = JsonSerializer.Serialize(pendingDeletions, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
await System.IO.File.WriteAllTextAsync(deletionFilePath, updatedJson);
|
||||||
|
|
||||||
|
// Also remove from favorites immediately
|
||||||
|
await UnmarkTrackAsFavoritedAsync(itemId);
|
||||||
|
|
||||||
|
_logger.LogDebug("Marked track for deletion in 24 hours: {ItemId}", itemId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to mark track for deletion: {ItemId}", itemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Information about a favorited track for persistent storage.
|
||||||
|
/// </summary>
|
||||||
|
private class FavoriteTrackInfo
|
||||||
|
{
|
||||||
|
public string ItemId { get; set; } = "";
|
||||||
|
public string Title { get; set; } = "";
|
||||||
|
public string Artist { get; set; } = "";
|
||||||
|
public string Album { get; set; } = "";
|
||||||
|
public DateTime FavoritedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Processes pending deletions (called by cleanup service).
|
||||||
|
/// </summary>
|
||||||
|
public async Task ProcessPendingDeletionsAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var deletionFilePath = "/app/cache/pending_deletions.json";
|
||||||
|
if (!System.IO.File.Exists(deletionFilePath))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var json = await System.IO.File.ReadAllTextAsync(deletionFilePath);
|
||||||
|
var pendingDeletions = JsonSerializer.Deserialize<Dictionary<string, DateTime>>(json) ?? new();
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var toDelete = pendingDeletions.Where(kvp => kvp.Value <= now).ToList();
|
||||||
|
var remaining = pendingDeletions.Where(kvp => kvp.Value > now).ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
|
||||||
|
|
||||||
|
foreach (var (itemId, _) in toDelete)
|
||||||
|
{
|
||||||
|
await ActuallyDeleteTrackAsync(itemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toDelete.Count > 0)
|
||||||
|
{
|
||||||
|
// Update pending deletions file
|
||||||
|
var updatedJson = JsonSerializer.Serialize(remaining, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
await System.IO.File.WriteAllTextAsync(deletionFilePath, updatedJson);
|
||||||
|
|
||||||
|
_logger.LogInformation("Processed {Count} pending deletions", toDelete.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error processing pending deletions");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Actually deletes a track from the kept folder.
|
||||||
|
/// </summary>
|
||||||
|
private async Task ActuallyDeleteTrackAsync(string itemId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
|
||||||
|
if (!isExternal) return;
|
||||||
|
|
||||||
|
var song = await _metadataService.GetSongAsync(provider!, externalId!);
|
||||||
|
if (song == null) return;
|
||||||
|
|
||||||
|
var keptBasePath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
|
||||||
|
var keptArtistPath = Path.Combine(keptBasePath, PathHelper.SanitizeFileName(song.Artist));
|
||||||
|
var keptAlbumPath = Path.Combine(keptArtistPath, PathHelper.SanitizeFileName(song.Album));
|
||||||
|
|
||||||
|
if (!Directory.Exists(keptAlbumPath)) return;
|
||||||
|
|
||||||
|
var sanitizedTitle = PathHelper.SanitizeFileName(song.Title);
|
||||||
|
var trackFiles = Directory.GetFiles(keptAlbumPath, $"*{sanitizedTitle}*");
|
||||||
|
|
||||||
|
foreach (var trackFile in trackFiles)
|
||||||
|
{
|
||||||
|
System.IO.File.Delete(trackFile);
|
||||||
|
_logger.LogInformation("✓ Deleted track from kept folder: {Path}", trackFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up empty directories
|
||||||
|
if (Directory.GetFiles(keptAlbumPath).Length == 0 && Directory.GetDirectories(keptAlbumPath).Length == 0)
|
||||||
|
{
|
||||||
|
Directory.Delete(keptAlbumPath);
|
||||||
|
|
||||||
|
if (Directory.Exists(keptArtistPath) &&
|
||||||
|
Directory.GetFiles(keptArtistPath).Length == 0 &&
|
||||||
|
Directory.GetDirectories(keptArtistPath).Length == 0)
|
||||||
|
{
|
||||||
|
Directory.Delete(keptArtistPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to delete track {ItemId}", itemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Loads missing tracks from file cache as fallback when Redis is empty.
|
/// Loads missing tracks from file cache as fallback when Redis is empty.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -3567,278 +4352,70 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Manual trigger endpoint to force fetch Spotify missing tracks.
|
/// Saves playlist items (raw Jellyfin JSON) to file cache for persistence across restarts.
|
||||||
/// GET /spotify/sync?api_key=YOUR_KEY
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("spotify/sync", Order = 1)]
|
private async Task SavePlaylistItemsToFile(string playlistName, List<Dictionary<string, object?>> items)
|
||||||
[ServiceFilter(typeof(ApiKeyAuthFilter))]
|
|
||||||
public async Task<IActionResult> TriggerSpotifySync([FromServices] IEnumerable<IHostedService> hostedServices)
|
|
||||||
{
|
|
||||||
if (!_spotifySettings.Enabled)
|
|
||||||
{
|
|
||||||
return BadRequest(new { error = "Spotify Import is not enabled" });
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("Manual Spotify sync triggered");
|
|
||||||
|
|
||||||
// Find the SpotifyMissingTracksFetcher service
|
|
||||||
var fetcherService = hostedServices
|
|
||||||
.OfType<allstarr.Services.Spotify.SpotifyMissingTracksFetcher>()
|
|
||||||
.FirstOrDefault();
|
|
||||||
|
|
||||||
if (fetcherService == null)
|
|
||||||
{
|
|
||||||
return StatusCode(500, new { error = "SpotifyMissingTracksFetcher not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trigger fetch manually
|
|
||||||
await fetcherService.TriggerFetchAsync();
|
|
||||||
|
|
||||||
// Check what was cached
|
|
||||||
var results = new Dictionary<string, object>();
|
|
||||||
foreach (var playlist in _spotifySettings.Playlists)
|
|
||||||
{
|
|
||||||
var cacheKey = $"spotify:missing:{playlist.Name}";
|
|
||||||
var tracks = await _cache.GetAsync<List<allstarr.Models.Spotify.MissingTrack>>(cacheKey);
|
|
||||||
|
|
||||||
if (tracks != null && tracks.Count > 0)
|
|
||||||
{
|
|
||||||
results[playlist.Name] = new {
|
|
||||||
status = "success",
|
|
||||||
tracks = tracks.Count,
|
|
||||||
localTracksPosition = playlist.LocalTracksPosition.ToString()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
results[playlist.Name] = new {
|
|
||||||
status = "not_found",
|
|
||||||
message = "No missing tracks found"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(results);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Manually trigger track matching for all Spotify playlists.
|
|
||||||
/// GET /spotify/match?api_key=YOUR_KEY
|
|
||||||
/// </summary>
|
|
||||||
[HttpGet("spotify/match", Order = 1)]
|
|
||||||
[ServiceFilter(typeof(ApiKeyAuthFilter))]
|
|
||||||
public async Task<IActionResult> TriggerSpotifyMatch([FromServices] IEnumerable<IHostedService> hostedServices)
|
|
||||||
{
|
|
||||||
if (!_spotifySettings.Enabled)
|
|
||||||
{
|
|
||||||
return BadRequest(new { error = "Spotify Import is not enabled" });
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("Manual Spotify track matching triggered");
|
|
||||||
|
|
||||||
// Find the SpotifyTrackMatchingService
|
|
||||||
var matchingService = hostedServices
|
|
||||||
.OfType<allstarr.Services.Spotify.SpotifyTrackMatchingService>()
|
|
||||||
.FirstOrDefault();
|
|
||||||
|
|
||||||
if (matchingService == null)
|
|
||||||
{
|
|
||||||
return StatusCode(500, new { error = "SpotifyTrackMatchingService not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trigger matching asynchronously
|
|
||||||
_ = Task.Run(async () =>
|
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await matchingService.TriggerMatchingAsync();
|
var cacheDir = "/app/cache/spotify";
|
||||||
|
Directory.CreateDirectory(cacheDir);
|
||||||
|
|
||||||
|
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
|
||||||
|
var filePath = Path.Combine(cacheDir, $"{safeName}_items.json");
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
await System.IO.File.WriteAllTextAsync(filePath, json);
|
||||||
|
|
||||||
|
_logger.LogInformation("💾 Saved {Count} playlist items to file cache for {Playlist}",
|
||||||
|
items.Count, playlistName);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error during manual track matching");
|
_logger.LogError(ex, "Failed to save playlist items to file for {Playlist}", playlistName);
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return Ok(new
|
|
||||||
{
|
|
||||||
status = "started",
|
|
||||||
message = "Track matching started in background. Check logs for progress.",
|
|
||||||
playlists = _spotifySettings.Playlists.Select(p => new { p.Name, p.Id, localTracksPosition = p.LocalTracksPosition.ToString() })
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<allstarr.Models.Spotify.MissingTrack> ParseMissingTracksJson(string json)
|
|
||||||
{
|
|
||||||
var tracks = new List<allstarr.Models.Spotify.MissingTrack>();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var doc = JsonDocument.Parse(json);
|
|
||||||
|
|
||||||
foreach (var item in doc.RootElement.EnumerateArray())
|
|
||||||
{
|
|
||||||
var track = new allstarr.Models.Spotify.MissingTrack
|
|
||||||
{
|
|
||||||
SpotifyId = item.GetProperty("Id").GetString() ?? "",
|
|
||||||
Title = item.GetProperty("Name").GetString() ?? "",
|
|
||||||
Album = item.GetProperty("AlbumName").GetString() ?? "",
|
|
||||||
Artists = item.GetProperty("ArtistNames")
|
|
||||||
.EnumerateArray()
|
|
||||||
.Select(a => a.GetString() ?? "")
|
|
||||||
.Where(a => !string.IsNullOrEmpty(a))
|
|
||||||
.ToList()
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(track.Title))
|
|
||||||
{
|
|
||||||
tracks.Add(track);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Failed to parse missing tracks JSON");
|
|
||||||
}
|
|
||||||
|
|
||||||
return tracks;
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Spotify Debug
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Clear Spotify playlist cache to force re-matching.
|
|
||||||
/// GET /spotify/clear-cache?api_key=YOUR_KEY
|
|
||||||
/// </summary>
|
|
||||||
[HttpGet("spotify/clear-cache")]
|
|
||||||
[ServiceFilter(typeof(ApiKeyAuthFilter))]
|
|
||||||
public async Task<IActionResult> ClearSpotifyCache()
|
|
||||||
{
|
|
||||||
if (!_spotifySettings.Enabled)
|
|
||||||
{
|
|
||||||
return BadRequest(new { error = "Spotify Import is not enabled" });
|
|
||||||
}
|
|
||||||
|
|
||||||
var cleared = new List<string>();
|
|
||||||
|
|
||||||
foreach (var playlist in _spotifySettings.Playlists)
|
|
||||||
{
|
|
||||||
var matchedKey = $"spotify:matched:{playlist.Name}";
|
|
||||||
await _cache.DeleteAsync(matchedKey);
|
|
||||||
cleared.Add(playlist.Name);
|
|
||||||
_logger.LogInformation("Cleared cache for {Playlist}", playlist.Name);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(new { status = "success", cleared = cleared });
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Debug & Monitoring
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets endpoint usage statistics from the log file.
|
|
||||||
/// GET /debug/endpoint-usage?api_key=YOUR_KEY
|
|
||||||
/// Optional query params: top=50 (default 100), since=2024-01-01
|
|
||||||
/// </summary>
|
|
||||||
[HttpGet("debug/endpoint-usage")]
|
|
||||||
[ServiceFilter(typeof(ApiKeyAuthFilter))]
|
|
||||||
public async Task<IActionResult> GetEndpointUsage(
|
|
||||||
[FromQuery] int top = 100,
|
|
||||||
[FromQuery] string? since = null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var logFile = "/app/cache/endpoint-usage/endpoints.csv";
|
|
||||||
|
|
||||||
if (!System.IO.File.Exists(logFile))
|
|
||||||
{
|
|
||||||
return Ok(new
|
|
||||||
{
|
|
||||||
message = "No endpoint usage data collected yet",
|
|
||||||
endpoints = Array.Empty<object>()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
var lines = await System.IO.File.ReadAllLinesAsync(logFile);
|
|
||||||
|
|
||||||
// Parse CSV and filter by date if provided
|
|
||||||
DateTime? sinceDate = null;
|
|
||||||
if (!string.IsNullOrEmpty(since) && DateTime.TryParse(since, out var parsedDate))
|
|
||||||
{
|
|
||||||
sinceDate = parsedDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
var entries = lines
|
|
||||||
.Select(line => line.Split(','))
|
|
||||||
.Where(parts => parts.Length >= 3)
|
|
||||||
.Where(parts => !sinceDate.HasValue ||
|
|
||||||
(DateTime.TryParse(parts[0], out var entryDate) && entryDate >= sinceDate.Value))
|
|
||||||
.Select(parts => new
|
|
||||||
{
|
|
||||||
Timestamp = parts[0],
|
|
||||||
Method = parts.Length > 1 ? parts[1] : "",
|
|
||||||
Path = parts.Length > 2 ? parts[2] : "",
|
|
||||||
Query = parts.Length > 3 ? parts[3] : ""
|
|
||||||
})
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
// Group by path and count
|
|
||||||
var pathCounts = entries
|
|
||||||
.GroupBy(e => new { e.Method, e.Path })
|
|
||||||
.Select(g => new
|
|
||||||
{
|
|
||||||
Method = g.Key.Method,
|
|
||||||
Path = g.Key.Path,
|
|
||||||
Count = g.Count(),
|
|
||||||
FirstSeen = g.Min(e => e.Timestamp),
|
|
||||||
LastSeen = g.Max(e => e.Timestamp)
|
|
||||||
})
|
|
||||||
.OrderByDescending(x => x.Count)
|
|
||||||
.Take(top)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return Ok(new
|
|
||||||
{
|
|
||||||
totalRequests = entries.Count,
|
|
||||||
uniqueEndpoints = pathCounts.Count,
|
|
||||||
topEndpoints = pathCounts,
|
|
||||||
logFile = logFile,
|
|
||||||
logSize = new FileInfo(logFile).Length
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Failed to get endpoint usage");
|
|
||||||
return StatusCode(500, new { error = ex.Message });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Clears the endpoint usage log file.
|
/// Loads playlist items (raw Jellyfin JSON) from file cache.
|
||||||
/// DELETE /debug/endpoint-usage?api_key=YOUR_KEY
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpDelete("debug/endpoint-usage")]
|
private async Task<List<Dictionary<string, object?>>?> LoadPlaylistItemsFromFile(string playlistName)
|
||||||
[ServiceFilter(typeof(ApiKeyAuthFilter))]
|
|
||||||
public IActionResult ClearEndpointUsage()
|
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var logFile = "/app/cache/endpoint-usage/endpoints.csv";
|
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
|
||||||
|
var filePath = Path.Combine("/app/cache/spotify", $"{safeName}_items.json");
|
||||||
|
|
||||||
if (System.IO.File.Exists(logFile))
|
if (!System.IO.File.Exists(filePath))
|
||||||
{
|
{
|
||||||
System.IO.File.Delete(logFile);
|
_logger.LogDebug("No playlist items file cache found for {Playlist} at {Path}", playlistName, filePath);
|
||||||
return Ok(new { status = "success", message = "Endpoint usage log cleared" });
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(new { status = "success", message = "No log file to clear" });
|
var fileAge = DateTime.UtcNow - System.IO.File.GetLastWriteTimeUtc(filePath);
|
||||||
|
|
||||||
|
// Check if cache is too old (more than 24 hours)
|
||||||
|
if (fileAge.TotalHours > 24)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Playlist items file cache for {Playlist} is too old ({Age:F1}h), will rebuild",
|
||||||
|
playlistName, fileAge.TotalHours);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("Playlist items file cache for {Playlist} age: {Age:F1}h", playlistName, fileAge.TotalHours);
|
||||||
|
|
||||||
|
var json = await System.IO.File.ReadAllTextAsync(filePath);
|
||||||
|
var items = JsonSerializer.Deserialize<List<Dictionary<string, object?>>>(json);
|
||||||
|
|
||||||
|
_logger.LogInformation("💿 Loaded {Count} playlist items from file cache for {Playlist} (age: {Age:F1}h)",
|
||||||
|
items?.Count ?? 0, playlistName, fileAge.TotalHours);
|
||||||
|
|
||||||
|
return items;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Failed to clear endpoint usage log");
|
_logger.LogWarning(ex, "Failed to load playlist items from file for {Playlist}", playlistName);
|
||||||
return StatusCode(500, new { error = ex.Message });
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3935,5 +4512,69 @@ public class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
return (deviceId, client, device, version);
|
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.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<string?> FindSpotifyIdForExternalTrackAsync(Song externalSong)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Get all configured playlists
|
||||||
|
var playlists = _spotifySettings.Playlists;
|
||||||
|
|
||||||
|
// Search through each playlist's matched tracks cache
|
||||||
|
foreach (var playlist in playlists)
|
||||||
|
{
|
||||||
|
var cacheKey = $"spotify:matched:ordered:{playlist.Name}";
|
||||||
|
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(cacheKey);
|
||||||
|
|
||||||
|
if (matchedTracks == null || matchedTracks.Count == 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Look for a match by external ID
|
||||||
|
var match = matchedTracks.FirstOrDefault(t =>
|
||||||
|
t.MatchedSong != null &&
|
||||||
|
t.MatchedSong.ExternalProvider == externalSong.ExternalProvider &&
|
||||||
|
t.MatchedSong.ExternalId == externalSong.ExternalId);
|
||||||
|
|
||||||
|
if (match != null && !string.IsNullOrEmpty(match.SpotifyId))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Found Spotify ID {SpotifyId} for {Provider}/{ExternalId} in playlist {Playlist}",
|
||||||
|
match.SpotifyId, externalSong.ExternalProvider, externalSong.ExternalId, playlist.Name);
|
||||||
|
return match.SpotifyId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("No Spotify ID found for external track {Provider}/{ExternalId}",
|
||||||
|
externalSong.ExternalProvider, externalSong.ExternalId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Error finding Spotify ID for external track");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// force rebuild Sun Jan 25 13:22:47 EST 2026
|
// force rebuild Sun Jan 25 13:22:47 EST 2026
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ public class SubsonicController : ControllerBase
|
|||||||
private readonly SubsonicModelMapper _modelMapper;
|
private readonly SubsonicModelMapper _modelMapper;
|
||||||
private readonly SubsonicProxyService _proxyService;
|
private readonly SubsonicProxyService _proxyService;
|
||||||
private readonly PlaylistSyncService? _playlistSyncService;
|
private readonly PlaylistSyncService? _playlistSyncService;
|
||||||
|
private readonly RedisCacheService _cache;
|
||||||
private readonly ILogger<SubsonicController> _logger;
|
private readonly ILogger<SubsonicController> _logger;
|
||||||
|
|
||||||
public SubsonicController(
|
public SubsonicController(
|
||||||
@@ -39,6 +40,7 @@ public class SubsonicController : ControllerBase
|
|||||||
SubsonicResponseBuilder responseBuilder,
|
SubsonicResponseBuilder responseBuilder,
|
||||||
SubsonicModelMapper modelMapper,
|
SubsonicModelMapper modelMapper,
|
||||||
SubsonicProxyService proxyService,
|
SubsonicProxyService proxyService,
|
||||||
|
RedisCacheService cache,
|
||||||
ILogger<SubsonicController> logger,
|
ILogger<SubsonicController> logger,
|
||||||
PlaylistSyncService? playlistSyncService = null)
|
PlaylistSyncService? playlistSyncService = null)
|
||||||
{
|
{
|
||||||
@@ -51,6 +53,7 @@ public class SubsonicController : ControllerBase
|
|||||||
_modelMapper = modelMapper;
|
_modelMapper = modelMapper;
|
||||||
_proxyService = proxyService;
|
_proxyService = proxyService;
|
||||||
_playlistSyncService = playlistSyncService;
|
_playlistSyncService = playlistSyncService;
|
||||||
|
_cache = cache;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(_subsonicSettings.Url))
|
if (string.IsNullOrWhiteSpace(_subsonicSettings.Url))
|
||||||
@@ -559,6 +562,16 @@ public class SubsonicController : ControllerBase
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// Check cache first (1 hour TTL for playlist images since they can change)
|
||||||
|
var cacheKey = $"playlist:image:{id}";
|
||||||
|
var cachedImage = await _cache.GetAsync<byte[]>(cacheKey);
|
||||||
|
|
||||||
|
if (cachedImage != null)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Serving cached playlist cover art for {Id}", id);
|
||||||
|
return File(cachedImage, "image/jpeg");
|
||||||
|
}
|
||||||
|
|
||||||
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(id);
|
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(id);
|
||||||
var playlist = await _metadataService.GetPlaylistAsync(provider, externalId);
|
var playlist = await _metadataService.GetPlaylistAsync(provider, externalId);
|
||||||
|
|
||||||
@@ -576,6 +589,11 @@ public class SubsonicController : ControllerBase
|
|||||||
|
|
||||||
var imageBytes = await imageResponse.Content.ReadAsByteArrayAsync();
|
var imageBytes = await imageResponse.Content.ReadAsByteArrayAsync();
|
||||||
var contentType = imageResponse.Content.Headers.ContentType?.ToString() ?? "image/jpeg";
|
var contentType = imageResponse.Content.Headers.ContentType?.ToString() ?? "image/jpeg";
|
||||||
|
|
||||||
|
// Cache for 1 hour (playlists can change, so don't cache too long)
|
||||||
|
await _cache.SetAsync(cacheKey, imageBytes, TimeSpan.FromHours(1));
|
||||||
|
_logger.LogDebug("Cached playlist cover art for {Id}", id);
|
||||||
|
|
||||||
return File(imageBytes, contentType);
|
return File(imageBytes, contentType);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -2,239 +2,44 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using Microsoft.AspNetCore.Mvc.Filters;
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using allstarr.Models.Settings;
|
using allstarr.Models.Settings;
|
||||||
using System.Text.Json;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
|
|
||||||
namespace allstarr.Filters;
|
namespace allstarr.Filters;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Authentication filter for Jellyfin API endpoints.
|
/// REMOVED: Authentication filter for Jellyfin API endpoints.
|
||||||
/// Validates client credentials against configured username and API key.
|
///
|
||||||
/// Clients can authenticate via:
|
/// This filter has been removed because Allstarr acts as a TRANSPARENT PROXY.
|
||||||
/// - Authorization header: MediaBrowser Token="apikey"
|
/// Clients authenticate directly with Jellyfin through the proxy, not with the proxy itself.
|
||||||
/// - X-Emby-Token header
|
///
|
||||||
/// - Query parameter: api_key
|
/// Authentication flow:
|
||||||
/// - JSON body (for login endpoints): Username/Pw fields
|
/// 1. Client sends credentials to /Users/AuthenticateByName
|
||||||
|
/// 2. Proxy forwards request to Jellyfin (no validation)
|
||||||
|
/// 3. Jellyfin validates credentials and returns AccessToken
|
||||||
|
/// 4. Client uses AccessToken in subsequent requests
|
||||||
|
/// 5. Proxy forwards token to Jellyfin for validation
|
||||||
|
///
|
||||||
|
/// The proxy NEVER validates credentials or tokens - that's Jellyfin's job.
|
||||||
|
/// The proxy only forwards authentication headers transparently.
|
||||||
|
///
|
||||||
|
/// If you need to restrict access to the proxy itself, use network-level controls
|
||||||
|
/// (firewall, VPN, reverse proxy with auth) instead of application-level auth.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class JellyfinAuthFilter : IAsyncActionFilter
|
public class JellyfinAuthFilter : IAsyncActionFilter
|
||||||
{
|
{
|
||||||
private readonly JellyfinSettings _settings;
|
|
||||||
private readonly ILogger<JellyfinAuthFilter> _logger;
|
private readonly ILogger<JellyfinAuthFilter> _logger;
|
||||||
|
|
||||||
public JellyfinAuthFilter(
|
public JellyfinAuthFilter(ILogger<JellyfinAuthFilter> logger)
|
||||||
IOptions<JellyfinSettings> settings,
|
|
||||||
ILogger<JellyfinAuthFilter> logger)
|
|
||||||
{
|
{
|
||||||
_settings = settings.Value;
|
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||||
{
|
{
|
||||||
// Skip auth if no credentials configured (open mode)
|
// This filter is now a no-op - all authentication is handled by Jellyfin
|
||||||
if (string.IsNullOrEmpty(_settings.ClientUsername) || string.IsNullOrEmpty(_settings.ApiKey))
|
// Keeping the class for backwards compatibility but it does nothing
|
||||||
{
|
|
||||||
_logger.LogDebug("Auth skipped - no client credentials configured");
|
|
||||||
await next();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var request = context.HttpContext.Request;
|
_logger.LogTrace("JellyfinAuthFilter: Transparent proxy mode - no authentication check");
|
||||||
|
|
||||||
// Try to extract credentials from various sources
|
|
||||||
var (username, token) = await ExtractCredentialsAsync(request);
|
|
||||||
|
|
||||||
// Validate credentials
|
|
||||||
if (!ValidateCredentials(username, token))
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Authentication failed for user '{Username}' from {IP}",
|
|
||||||
username ?? "unknown",
|
|
||||||
context.HttpContext.Connection.RemoteIpAddress);
|
|
||||||
|
|
||||||
context.Result = new UnauthorizedObjectResult(new
|
|
||||||
{
|
|
||||||
error = "Invalid credentials",
|
|
||||||
message = "Authentication required. Provide valid username and API key."
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogDebug("Authentication successful for user '{Username}'", username);
|
|
||||||
await next();
|
await next();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<(string? username, string? token)> ExtractCredentialsAsync(HttpRequest request)
|
|
||||||
{
|
|
||||||
string? username = null;
|
|
||||||
string? token = null;
|
|
||||||
|
|
||||||
// 1. Check Authorization header (MediaBrowser format)
|
|
||||||
if (request.Headers.TryGetValue("Authorization", out var authHeader))
|
|
||||||
{
|
|
||||||
var authValue = authHeader.ToString();
|
|
||||||
|
|
||||||
// Parse MediaBrowser auth header: MediaBrowser Client="...", Token="..."
|
|
||||||
if (authValue.StartsWith("MediaBrowser", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
token = ExtractTokenFromMediaBrowser(authValue);
|
|
||||||
username = ExtractUserIdFromMediaBrowser(authValue);
|
|
||||||
}
|
|
||||||
// Basic auth: Basic base64(username:password)
|
|
||||||
else if (authValue.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
(username, token) = ParseBasicAuth(authValue);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Check X-Emby-Token header
|
|
||||||
if (string.IsNullOrEmpty(token) && request.Headers.TryGetValue("X-Emby-Token", out var embyToken))
|
|
||||||
{
|
|
||||||
token = embyToken.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Check X-MediaBrowser-Token header
|
|
||||||
if (string.IsNullOrEmpty(token) && request.Headers.TryGetValue("X-MediaBrowser-Token", out var mbToken))
|
|
||||||
{
|
|
||||||
token = mbToken.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Check X-Emby-Authorization header (alternative format)
|
|
||||||
if (string.IsNullOrEmpty(token) && request.Headers.TryGetValue("X-Emby-Authorization", out var embyAuth))
|
|
||||||
{
|
|
||||||
token = ExtractTokenFromMediaBrowser(embyAuth.ToString());
|
|
||||||
if (string.IsNullOrEmpty(username))
|
|
||||||
{
|
|
||||||
username = ExtractUserIdFromMediaBrowser(embyAuth.ToString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Check query parameters
|
|
||||||
if (string.IsNullOrEmpty(token))
|
|
||||||
{
|
|
||||||
token = request.Query["api_key"].FirstOrDefault()
|
|
||||||
?? request.Query["ApiKey"].FirstOrDefault()
|
|
||||||
?? request.Query["X-Emby-Token"].FirstOrDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(username))
|
|
||||||
{
|
|
||||||
username = request.Query["userId"].FirstOrDefault()
|
|
||||||
?? request.Query["UserId"].FirstOrDefault()
|
|
||||||
?? request.Query["u"].FirstOrDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Check JSON body for login endpoints (Jellyfin: Username/Pw, Navidrome: username/password)
|
|
||||||
if ((string.IsNullOrEmpty(username) || string.IsNullOrEmpty(token)) &&
|
|
||||||
request.ContentType?.Contains("application/json", StringComparison.OrdinalIgnoreCase) == true &&
|
|
||||||
request.ContentLength > 0)
|
|
||||||
{
|
|
||||||
var (bodyUsername, bodyPassword) = await ExtractCredentialsFromBodyAsync(request);
|
|
||||||
if (string.IsNullOrEmpty(username)) username = bodyUsername;
|
|
||||||
if (string.IsNullOrEmpty(token)) token = bodyPassword;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (username, token);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<(string? username, string? password)> ExtractCredentialsFromBodyAsync(HttpRequest request)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
request.EnableBuffering();
|
|
||||||
request.Body.Position = 0;
|
|
||||||
|
|
||||||
using var reader = new StreamReader(request.Body, leaveOpen: true);
|
|
||||||
var body = await reader.ReadToEndAsync();
|
|
||||||
request.Body.Position = 0;
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(body)) return (null, null);
|
|
||||||
|
|
||||||
using var doc = JsonDocument.Parse(body);
|
|
||||||
var root = doc.RootElement;
|
|
||||||
|
|
||||||
// Try Jellyfin format: Username, Pw
|
|
||||||
string? username = null;
|
|
||||||
string? password = null;
|
|
||||||
|
|
||||||
if (root.TryGetProperty("Username", out var usernameProp))
|
|
||||||
username = usernameProp.GetString();
|
|
||||||
else if (root.TryGetProperty("username", out var usernameLowerProp))
|
|
||||||
username = usernameLowerProp.GetString();
|
|
||||||
|
|
||||||
if (root.TryGetProperty("Pw", out var pwProp))
|
|
||||||
password = pwProp.GetString();
|
|
||||||
else if (root.TryGetProperty("pw", out var pwLowerProp))
|
|
||||||
password = pwLowerProp.GetString();
|
|
||||||
else if (root.TryGetProperty("Password", out var passwordProp))
|
|
||||||
password = passwordProp.GetString();
|
|
||||||
else if (root.TryGetProperty("password", out var passwordLowerProp))
|
|
||||||
password = passwordLowerProp.GetString();
|
|
||||||
|
|
||||||
return (username, password);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogDebug(ex, "Failed to parse credentials from request body");
|
|
||||||
return (null, null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private string? ExtractTokenFromMediaBrowser(string header)
|
|
||||||
{
|
|
||||||
var match = TokenRegex().Match(header);
|
|
||||||
return match.Success ? match.Groups[1].Value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private string? ExtractUserIdFromMediaBrowser(string header)
|
|
||||||
{
|
|
||||||
var match = UserIdRegex().Match(header);
|
|
||||||
return match.Success ? match.Groups[1].Value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static (string? username, string? password) ParseBasicAuth(string authHeader)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var base64 = authHeader["Basic ".Length..].Trim();
|
|
||||||
var bytes = Convert.FromBase64String(base64);
|
|
||||||
var credentials = System.Text.Encoding.UTF8.GetString(bytes);
|
|
||||||
var parts = credentials.Split(':', 2);
|
|
||||||
|
|
||||||
return parts.Length == 2 ? (parts[0], parts[1]) : (null, null);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return (null, null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool ValidateCredentials(string? username, string? token)
|
|
||||||
{
|
|
||||||
// Must have token (API key used as password)
|
|
||||||
if (string.IsNullOrEmpty(token))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Token must match API key
|
|
||||||
if (!string.Equals(token, _settings.ApiKey, StringComparison.Ordinal))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If username provided, it must match configured client username
|
|
||||||
if (!string.IsNullOrEmpty(username) &&
|
|
||||||
!string.Equals(username, _settings.ClientUsername, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
[GeneratedRegex(@"Token=""([^""]+)""", RegexOptions.IgnoreCase)]
|
|
||||||
private static partial Regex TokenRegex();
|
|
||||||
|
|
||||||
[GeneratedRegex(@"UserId=""([^""]+)""", RegexOptions.IgnoreCase)]
|
|
||||||
private static partial Regex UserIdRegex();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ public class WebSocketProxyMiddleware
|
|||||||
if (context.Request.Path.StartsWithSegments("/socket", StringComparison.OrdinalIgnoreCase) &&
|
if (context.Request.Path.StartsWithSegments("/socket", StringComparison.OrdinalIgnoreCase) &&
|
||||||
context.WebSockets.IsWebSocketRequest)
|
context.WebSockets.IsWebSocketRequest)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("🔌 WEBSOCKET: WebSocket connection request received from {RemoteIp}",
|
_logger.LogDebug("🔌 WEBSOCKET: WebSocket connection request received from {RemoteIp}",
|
||||||
context.Connection.RemoteIpAddress);
|
context.Connection.RemoteIpAddress);
|
||||||
|
|
||||||
await HandleWebSocketProxyAsync(context);
|
await HandleWebSocketProxyAsync(context);
|
||||||
@@ -142,7 +142,7 @@ public class WebSocketProxyMiddleware
|
|||||||
serverWebSocket.Options.SetRequestHeader("User-Agent", "Allstarr/1.0");
|
serverWebSocket.Options.SetRequestHeader("User-Agent", "Allstarr/1.0");
|
||||||
|
|
||||||
await serverWebSocket.ConnectAsync(new Uri(jellyfinWsUrl), context.RequestAborted);
|
await serverWebSocket.ConnectAsync(new Uri(jellyfinWsUrl), context.RequestAborted);
|
||||||
_logger.LogInformation("✓ WEBSOCKET: Connected to Jellyfin WebSocket");
|
_logger.LogDebug("✓ WEBSOCKET: Connected to Jellyfin WebSocket");
|
||||||
|
|
||||||
// Start bidirectional proxying
|
// Start bidirectional proxying
|
||||||
var clientToServer = ProxyMessagesAsync(clientWebSocket, serverWebSocket, "Client→Server", context.RequestAborted);
|
var clientToServer = ProxyMessagesAsync(clientWebSocket, serverWebSocket, "Client→Server", context.RequestAborted);
|
||||||
@@ -154,9 +154,17 @@ public class WebSocketProxyMiddleware
|
|||||||
_logger.LogDebug("🔌 WEBSOCKET: WebSocket proxy connection closed");
|
_logger.LogDebug("🔌 WEBSOCKET: WebSocket proxy connection closed");
|
||||||
}
|
}
|
||||||
catch (WebSocketException wsEx)
|
catch (WebSocketException wsEx)
|
||||||
|
{
|
||||||
|
// 403 is expected when tokens expire or session ends - don't spam logs
|
||||||
|
if (wsEx.Message.Contains("403"))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("WEBSOCKET: Connection rejected with 403 (token expired or session ended)");
|
||||||
|
}
|
||||||
|
else
|
||||||
{
|
{
|
||||||
_logger.LogWarning(wsEx, "⚠️ WEBSOCKET: WebSocket error: {Message}", wsEx.Message);
|
_logger.LogWarning(wsEx, "⚠️ WEBSOCKET: WebSocket error: {Message}", wsEx.Message);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "❌ WEBSOCKET: Error in WebSocket proxy");
|
_logger.LogError(ex, "❌ WEBSOCKET: Error in WebSocket proxy");
|
||||||
@@ -194,7 +202,7 @@ public class WebSocketProxyMiddleware
|
|||||||
// CRITICAL: Notify session manager that client disconnected
|
// CRITICAL: Notify session manager that client disconnected
|
||||||
if (!string.IsNullOrEmpty(deviceId))
|
if (!string.IsNullOrEmpty(deviceId))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("🧹 WEBSOCKET: Client disconnected, removing session for device {DeviceId}", deviceId);
|
_logger.LogDebug("🧹 WEBSOCKET: Client disconnected, removing session for device {DeviceId}", deviceId);
|
||||||
await _sessionManager.RemoveSessionAsync(deviceId);
|
await _sessionManager.RemoveSessionAsync(deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,8 +243,15 @@ public class WebSocketProxyMiddleware
|
|||||||
{
|
{
|
||||||
var messageBytes = messageBuffer.ToArray();
|
var messageBytes = messageBuffer.ToArray();
|
||||||
|
|
||||||
// Log message for debugging (only in debug mode to avoid spam)
|
// Log message for Server→Client direction to see remote control commands
|
||||||
if (_logger.IsEnabled(LogLevel.Debug))
|
if (direction == "Server→Client")
|
||||||
|
{
|
||||||
|
var messageText = System.Text.Encoding.UTF8.GetString(messageBytes);
|
||||||
|
_logger.LogTrace("📥 WEBSOCKET {Direction}: {Preview}",
|
||||||
|
direction,
|
||||||
|
messageText.Length > 500 ? messageText[..500] + "..." : messageText);
|
||||||
|
}
|
||||||
|
else if (_logger.IsEnabled(LogLevel.Debug))
|
||||||
{
|
{
|
||||||
var messageText = System.Text.Encoding.UTF8.GetString(messageBytes);
|
var messageText = System.Text.Encoding.UTF8.GetString(messageBytes);
|
||||||
_logger.LogDebug("{Direction}: {MessageType} message ({Size} bytes): {Preview}",
|
_logger.LogDebug("{Direction}: {MessageType} message ({Size} bytes): {Preview}",
|
||||||
@@ -267,7 +282,7 @@ public class WebSocketProxyMiddleware
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "⚠️ WEBSOCKET {Direction}: Error proxying messages", direction);
|
_logger.LogDebug(ex, "WEBSOCKET {Direction}: Error proxying messages (connection closed)", direction);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ public class Song
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string? Isrc { get; set; }
|
public string? Isrc { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Spotify track ID (for lyrics and matching)
|
||||||
|
/// </summary>
|
||||||
|
public string? SpotifyId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Full release date (format: YYYY-MM-DD)
|
/// Full release date (format: YYYY-MM-DD)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
21
allstarr/Models/Settings/MusicBrainzSettings.cs
Normal file
21
allstarr/Models/Settings/MusicBrainzSettings.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
namespace allstarr.Models.Settings;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Settings for MusicBrainz API integration.
|
||||||
|
/// </summary>
|
||||||
|
public class MusicBrainzSettings
|
||||||
|
{
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
public string? Username { get; set; }
|
||||||
|
public string? Password { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base URL for MusicBrainz API.
|
||||||
|
/// </summary>
|
||||||
|
public string BaseUrl { get; set; } = "https://musicbrainz.org/ws/2";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rate limit: 1 request per second for unauthenticated, 1 per second for authenticated.
|
||||||
|
/// </summary>
|
||||||
|
public int RateLimitMs { get; set; } = 1000;
|
||||||
|
}
|
||||||
@@ -69,4 +69,11 @@ public class SpotifyApiSettings
|
|||||||
/// Used to track cookie age and warn when it's approaching expiration (~1 year).
|
/// Used to track cookie age and warn when it's approaching expiration (~1 year).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? SessionCookieSetDate { get; set; }
|
public string? SessionCookieSetDate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// URL of the Spotify Lyrics API sidecar service.
|
||||||
|
/// Default: http://spotify-lyrics:8080 (docker-compose service name)
|
||||||
|
/// This service wraps Spotify's color-lyrics API for easier access.
|
||||||
|
/// </summary>
|
||||||
|
public string LyricsApiUrl { get; set; } = "http://spotify-lyrics:8080";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,25 +60,13 @@ public class SpotifyImportSettings
|
|||||||
public bool Enabled { get; set; }
|
public bool Enabled { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Hour when Spotify Import plugin runs (24-hour format, 0-23)
|
/// How often to run track matching in hours.
|
||||||
/// NOTE: This setting is now optional and only used for the sync window check.
|
/// Spotify playlists like Discover Weekly update once per week, Release Radar updates weekly.
|
||||||
/// The fetcher will search backwards from current time for the last 48 hours,
|
/// Most playlists don't change frequently, so running every 24 hours is reasonable.
|
||||||
/// so timezone confusion is avoided.
|
/// Set to 0 to only run once on startup (manual trigger via admin UI still works).
|
||||||
|
/// Default: 24 hours
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int SyncStartHour { get; set; } = 16;
|
public int MatchingIntervalHours { get; set; } = 24;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Minute when Spotify Import plugin runs (0-59)
|
|
||||||
/// NOTE: This setting is now optional and only used for the sync window check.
|
|
||||||
/// </summary>
|
|
||||||
public int SyncStartMinute { get; set; } = 15;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// How many hours to search for missing tracks files after sync start time
|
|
||||||
/// This prevents the fetcher from running too frequently.
|
|
||||||
/// Set to 0 to disable the sync window check and always search on startup.
|
|
||||||
/// </summary>
|
|
||||||
public int SyncWindowHours { get; set; } = 2;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Combined playlist configuration as JSON array.
|
/// Combined playlist configuration as JSON array.
|
||||||
|
|||||||
@@ -22,16 +22,21 @@ static List<string> DecodeSquidWtfUrls()
|
|||||||
{
|
{
|
||||||
var encodedUrls = new[]
|
var encodedUrls = new[]
|
||||||
{
|
{
|
||||||
"aHR0cHM6Ly90cml0b24uc3F1aWQud3Rm", // triton
|
"aHR0cHM6Ly90cml0b24uc3F1aWQud3Rm", // triton.squid.wtf
|
||||||
"aHR0cHM6Ly90aWRhbC1hcGkuYmluaW11bS5vcmc=", // binimum
|
"aHR0cHM6Ly90aWRhbC1hcGkuYmluaW11bS5vcmc=", // tidal-api.binimum.org
|
||||||
"aHR0cHM6Ly90aWRhbC5raW5vcGx1cy5vbmxpbmU=", // kinoplus
|
"aHR0cHM6Ly90aWRhbC5raW5vcGx1cy5vbmxpbmU=", // tidal.kinoplus.online
|
||||||
"aHR0cHM6Ly9oaWZpLXR3by5zcG90aXNhdmVyLm5ldA==", // spoti-2
|
"aHR0cHM6Ly9oaWZpLXR3by5zcG90aXNhdmVyLm5ldA==", // hifi-two.spotisaver.net
|
||||||
"aHR0cHM6Ly9oaWZpLW9uZS5zcG90aXNhdmVyLm5ldA==", // spoti-1
|
"aHR0cHM6Ly9oaWZpLW9uZS5zcG90aXNhdmVyLm5ldA==", // hifi-one.spotisaver.net
|
||||||
"aHR0cHM6Ly93b2xmLnFxZGwuc2l0ZQ==", // wolf
|
"aHR0cHM6Ly93b2xmLnFxZGwuc2l0ZQ==", // wolf.qqdl.site
|
||||||
"aHR0cDovL2h1bmQucXFkbC5zaXRl", // hund
|
"aHR0cDovL2h1bmQucXFkbC5zaXRl", // hund.qqdl.site (http)
|
||||||
"aHR0cHM6Ly9rYXR6ZS5xcWRsLnNpdGU=", // katze
|
"aHR0cHM6Ly9rYXR6ZS5xcWRsLnNpdGU=", // katze.qqdl.site
|
||||||
"aHR0cHM6Ly92b2dlbC5xcWRsLnNpdGU=", // vogel
|
"aHR0cHM6Ly92b2dlbC5xcWRsLnNpdGU=", // vogel.qqdl.site
|
||||||
"aHR0cHM6Ly9tYXVzLnFxZGwuc2l0ZQ==" // maus
|
"aHR0cHM6Ly9tYXVzLnFxZGwuc2l0ZQ==", // maus.qqdl.site
|
||||||
|
"aHR0cHM6Ly9ldS1jZW50cmFsLm1vbm9jaHJvbWUudGY=", // eu-central.monochrome.tf
|
||||||
|
"aHR0cHM6Ly91cy13ZXN0Lm1vbm9jaHJvbWUudGY=", // us-west.monochrome.tf
|
||||||
|
"aHR0cHM6Ly9hcnJhbi5tb25vY2hyb21lLnRm", // arran.monochrome.tf
|
||||||
|
"aHR0cHM6Ly9hcGkubW9ub2Nocm9tZS50Zg==", // api.monochrome.tf
|
||||||
|
"aHR0cHM6Ly9odW5kLnFxZGwuc2l0ZQ==" // hund.qqdl.site (https)
|
||||||
};
|
};
|
||||||
|
|
||||||
return encodedUrls
|
return encodedUrls
|
||||||
@@ -97,6 +102,10 @@ builder.Services.ConfigureAll<HttpClientFactoryOptions>(options =>
|
|||||||
MaxAutomaticRedirections = 5
|
MaxAutomaticRedirections = 5
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Suppress verbose HTTP logging - these are logged at Debug level by default
|
||||||
|
// but we want to reduce noise in production logs
|
||||||
|
options.SuppressHandlerScope = true;
|
||||||
});
|
});
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddSwaggerGen();
|
builder.Services.AddSwaggerGen();
|
||||||
@@ -349,7 +358,7 @@ builder.Services.Configure<SpotifyImportSettings>(options =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Log configuration at startup
|
// Log configuration at startup
|
||||||
Console.WriteLine($"Spotify Import: Enabled={options.Enabled}, SyncHour={options.SyncStartHour}:{options.SyncStartMinute:D2}, WindowHours={options.SyncWindowHours}");
|
Console.WriteLine($"Spotify Import: Enabled={options.Enabled}, MatchingInterval={options.MatchingIntervalHours}h");
|
||||||
Console.WriteLine($"Spotify Import Playlists: {options.Playlists.Count} configured");
|
Console.WriteLine($"Spotify Import Playlists: {options.Playlists.Count} configured");
|
||||||
foreach (var playlist in options.Playlists)
|
foreach (var playlist in options.Playlists)
|
||||||
{
|
{
|
||||||
@@ -375,6 +384,7 @@ else
|
|||||||
|
|
||||||
// Business services - shared across backends
|
// Business services - shared across backends
|
||||||
builder.Services.AddSingleton<RedisCacheService>();
|
builder.Services.AddSingleton<RedisCacheService>();
|
||||||
|
builder.Services.AddSingleton<OdesliService>();
|
||||||
builder.Services.AddSingleton<ILocalLibraryService, LocalLibraryService>();
|
builder.Services.AddSingleton<ILocalLibraryService, LocalLibraryService>();
|
||||||
builder.Services.AddSingleton<LrclibService>();
|
builder.Services.AddSingleton<LrclibService>();
|
||||||
|
|
||||||
@@ -388,6 +398,9 @@ if (backendType == BackendType.Jellyfin)
|
|||||||
builder.Services.AddSingleton<JellyfinSessionManager>();
|
builder.Services.AddSingleton<JellyfinSessionManager>();
|
||||||
builder.Services.AddScoped<JellyfinAuthFilter>();
|
builder.Services.AddScoped<JellyfinAuthFilter>();
|
||||||
builder.Services.AddScoped<allstarr.Filters.ApiKeyAuthFilter>();
|
builder.Services.AddScoped<allstarr.Filters.ApiKeyAuthFilter>();
|
||||||
|
|
||||||
|
// Register JellyfinController as a service for dependency injection
|
||||||
|
builder.Services.AddScoped<allstarr.Controllers.JellyfinController>();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -452,9 +465,13 @@ else if (musicService == MusicService.SquidWTF)
|
|||||||
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
|
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
|
||||||
sp,
|
sp,
|
||||||
sp.GetRequiredService<ILogger<SquidWTFDownloadService>>(),
|
sp.GetRequiredService<ILogger<SquidWTFDownloadService>>(),
|
||||||
|
sp.GetRequiredService<OdesliService>(),
|
||||||
squidWtfApiUrls));
|
squidWtfApiUrls));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register ParallelMetadataService to race all registered providers for faster searches
|
||||||
|
builder.Services.AddSingleton<ParallelMetadataService>();
|
||||||
|
|
||||||
// Startup validation - register validators based on backend
|
// Startup validation - register validators based on backend
|
||||||
if (backendType == BackendType.Jellyfin)
|
if (backendType == BackendType.Jellyfin)
|
||||||
{
|
{
|
||||||
@@ -465,13 +482,19 @@ else
|
|||||||
builder.Services.AddSingleton<IStartupValidator, SubsonicStartupValidator>();
|
builder.Services.AddSingleton<IStartupValidator, SubsonicStartupValidator>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register endpoint benchmark service
|
||||||
|
builder.Services.AddSingleton<EndpointBenchmarkService>();
|
||||||
|
|
||||||
builder.Services.AddSingleton<IStartupValidator, DeezerStartupValidator>();
|
builder.Services.AddSingleton<IStartupValidator, DeezerStartupValidator>();
|
||||||
builder.Services.AddSingleton<IStartupValidator, QobuzStartupValidator>();
|
builder.Services.AddSingleton<IStartupValidator, QobuzStartupValidator>();
|
||||||
builder.Services.AddSingleton<IStartupValidator>(sp =>
|
builder.Services.AddSingleton<IStartupValidator>(sp =>
|
||||||
new SquidWTFStartupValidator(
|
new SquidWTFStartupValidator(
|
||||||
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
|
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
|
||||||
sp.GetRequiredService<IHttpClientFactory>().CreateClient(),
|
sp.GetRequiredService<IHttpClientFactory>().CreateClient(),
|
||||||
squidWtfApiUrls));
|
squidWtfApiUrls,
|
||||||
|
sp.GetRequiredService<EndpointBenchmarkService>(),
|
||||||
|
sp.GetRequiredService<ILogger<SquidWTFStartupValidator>>()));
|
||||||
|
builder.Services.AddSingleton<IStartupValidator, LyricsStartupValidator>();
|
||||||
|
|
||||||
// Register orchestrator as hosted service
|
// Register orchestrator as hosted service
|
||||||
builder.Services.AddHostedService<StartupValidationOrchestrator>();
|
builder.Services.AddHostedService<StartupValidationOrchestrator>();
|
||||||
@@ -479,6 +502,9 @@ builder.Services.AddHostedService<StartupValidationOrchestrator>();
|
|||||||
// Register cache cleanup service (only runs when StorageMode is Cache)
|
// Register cache cleanup service (only runs when StorageMode is Cache)
|
||||||
builder.Services.AddHostedService<CacheCleanupService>();
|
builder.Services.AddHostedService<CacheCleanupService>();
|
||||||
|
|
||||||
|
// Register cache warming service (loads file caches into Redis on startup)
|
||||||
|
builder.Services.AddHostedService<CacheWarmingService>();
|
||||||
|
|
||||||
// Register Spotify API client, lyrics service, and settings for direct API access
|
// Register Spotify API client, lyrics service, and settings for direct API access
|
||||||
// Configure from environment variables with SPOTIFY_API_ prefix
|
// Configure from environment variables with SPOTIFY_API_ prefix
|
||||||
builder.Services.Configure<allstarr.Models.Settings.SpotifyApiSettings>(options =>
|
builder.Services.Configure<allstarr.Models.Settings.SpotifyApiSettings>(options =>
|
||||||
@@ -553,6 +579,40 @@ builder.Services.AddHostedService<allstarr.Services.Spotify.SpotifyMissingTracks
|
|||||||
builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyTrackMatchingService>();
|
builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyTrackMatchingService>();
|
||||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<allstarr.Services.Spotify.SpotifyTrackMatchingService>());
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<allstarr.Services.Spotify.SpotifyTrackMatchingService>());
|
||||||
|
|
||||||
|
// Register lyrics prefetch service (prefetches lyrics for all playlist tracks)
|
||||||
|
// 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 =>
|
||||||
|
{
|
||||||
|
builder.Configuration.GetSection("MusicBrainz").Bind(options);
|
||||||
|
|
||||||
|
// Override from environment variables
|
||||||
|
var enabled = builder.Configuration.GetValue<string>("MusicBrainz:Enabled");
|
||||||
|
if (!string.IsNullOrEmpty(enabled))
|
||||||
|
{
|
||||||
|
options.Enabled = enabled.Equals("true", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
var username = builder.Configuration.GetValue<string>("MusicBrainz:Username");
|
||||||
|
if (!string.IsNullOrEmpty(username))
|
||||||
|
{
|
||||||
|
options.Username = username;
|
||||||
|
}
|
||||||
|
|
||||||
|
var password = builder.Configuration.GetValue<string>("MusicBrainz:Password");
|
||||||
|
if (!string.IsNullOrEmpty(password))
|
||||||
|
{
|
||||||
|
options.Password = password;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
builder.Services.AddSingleton<allstarr.Services.MusicBrainz.MusicBrainzService>();
|
||||||
|
|
||||||
|
// Register genre enrichment service
|
||||||
|
builder.Services.AddSingleton<allstarr.Services.Common.GenreEnrichmentService>();
|
||||||
|
|
||||||
builder.Services.AddCors(options =>
|
builder.Services.AddCors(options =>
|
||||||
{
|
{
|
||||||
options.AddDefaultPolicy(policy =>
|
options.AddDefaultPolicy(policy =>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using allstarr.Models.Search;
|
|||||||
using allstarr.Models.Subsonic;
|
using allstarr.Models.Subsonic;
|
||||||
using allstarr.Services.Local;
|
using allstarr.Services.Local;
|
||||||
using allstarr.Services.Subsonic;
|
using allstarr.Services.Subsonic;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
using TagLib;
|
using TagLib;
|
||||||
using IOFile = System.IO.File;
|
using IOFile = System.IO.File;
|
||||||
|
|
||||||
@@ -27,9 +28,14 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
protected readonly string DownloadPath;
|
protected readonly string DownloadPath;
|
||||||
protected readonly string CachePath;
|
protected readonly string CachePath;
|
||||||
|
|
||||||
protected readonly Dictionary<string, DownloadInfo> ActiveDownloads = new();
|
protected readonly ConcurrentDictionary<string, DownloadInfo> ActiveDownloads = new();
|
||||||
protected readonly SemaphoreSlim DownloadLock = new(1, 1);
|
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>
|
/// <summary>
|
||||||
/// Lazy-loaded PlaylistSyncService to avoid circular dependency
|
/// Lazy-loaded PlaylistSyncService to avoid circular dependency
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -89,23 +95,89 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
|
|
||||||
public async Task<Stream> DownloadAndStreamAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
|
public async Task<Stream> DownloadAndStreamAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
var startTime = DateTime.UtcNow;
|
||||||
|
|
||||||
// Check if already downloaded locally
|
// Check if already downloaded locally
|
||||||
var localPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId);
|
var localPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId);
|
||||||
if (localPath != null && IOFile.Exists(localPath))
|
if (localPath != null && IOFile.Exists(localPath))
|
||||||
{
|
{
|
||||||
Logger.LogInformation("Streaming from local cache: {Path}", localPath);
|
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
|
||||||
|
Logger.LogInformation("Streaming from local cache ({ElapsedMs}ms): {Path}", elapsed, localPath);
|
||||||
|
|
||||||
|
// Update access time for cache cleanup
|
||||||
|
if (SubsonicSettings.StorageMode == StorageMode.Cache)
|
||||||
|
{
|
||||||
|
IOFile.SetLastAccessTime(localPath, DateTime.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start background Odesli conversion for lyrics (if not already cached)
|
||||||
|
StartBackgroundOdesliConversion(externalProvider, externalId);
|
||||||
|
|
||||||
return IOFile.OpenRead(localPath);
|
return IOFile.OpenRead(localPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// For on-demand streaming, download to disk first to ensure complete file
|
// Download to disk first to ensure complete file with metadata
|
||||||
// This is necessary because:
|
// This is necessary because:
|
||||||
// 1. Clients may seek to arbitrary positions (requires full file)
|
// 1. Clients may seek to arbitrary positions (requires full file)
|
||||||
// 2. Metadata embedding requires complete file
|
// 2. Metadata embedding requires complete file
|
||||||
// 3. Caching for future plays
|
// 3. Caching for future plays
|
||||||
Logger.LogInformation("Downloading song for streaming: {Provider}:{ExternalId}", externalProvider, externalId);
|
Logger.LogInformation("Downloading song for streaming: {Provider}:{ExternalId}", externalProvider, externalId);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
localPath = await DownloadSongInternalAsync(externalProvider, externalId, triggerAlbumDownload: true, cancellationToken);
|
localPath = await DownloadSongInternalAsync(externalProvider, externalId, triggerAlbumDownload: true, cancellationToken);
|
||||||
|
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
|
||||||
|
Logger.LogInformation("Download completed, starting stream ({ElapsedMs}ms total): {Path}", elapsed, localPath);
|
||||||
|
|
||||||
|
// Start background Odesli conversion for lyrics (after stream starts)
|
||||||
|
StartBackgroundOdesliConversion(externalProvider, externalId);
|
||||||
|
|
||||||
return IOFile.OpenRead(localPath);
|
return IOFile.OpenRead(localPath);
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
|
||||||
|
Logger.LogWarning("Download cancelled by client after {ElapsedMs}ms for {Provider}:{ExternalId}", elapsed, externalProvider, externalId);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
|
||||||
|
Logger.LogError(ex, "Download failed after {ElapsedMs}ms for {Provider}:{ExternalId}", elapsed, externalProvider, externalId);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts background Odesli conversion for lyrics support.
|
||||||
|
/// This is called AFTER streaming starts so it doesn't block the client.
|
||||||
|
/// </summary>
|
||||||
|
private void StartBackgroundOdesliConversion(string externalProvider, string externalId)
|
||||||
|
{
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Provider-specific conversion (override in subclasses if needed)
|
||||||
|
await ConvertToSpotifyIdAsync(externalProvider, externalId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogDebug(ex, "Background Spotify ID conversion failed for {Provider}:{ExternalId}", externalProvider, externalId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts external track ID to Spotify ID for lyrics support.
|
||||||
|
/// Override in provider-specific services if needed.
|
||||||
|
/// </summary>
|
||||||
|
protected virtual Task ConvertToSpotifyIdAsync(string externalProvider, string externalId)
|
||||||
|
{
|
||||||
|
// Default implementation does nothing
|
||||||
|
// Provider-specific services can override this
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
public DownloadInfo? GetDownloadStatus(string songId)
|
public DownloadInfo? GetDownloadStatus(string songId)
|
||||||
{
|
{
|
||||||
@@ -120,20 +192,13 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check local library
|
// Check local library (works for both cache and permanent storage)
|
||||||
var localPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId);
|
var localPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId);
|
||||||
if (localPath != null && IOFile.Exists(localPath))
|
if (localPath != null && IOFile.Exists(localPath))
|
||||||
{
|
{
|
||||||
return localPath;
|
return localPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check cache directory
|
|
||||||
var cachedPath = GetCachedFilePath(externalProvider, externalId);
|
|
||||||
if (cachedPath != null && IOFile.Exists(cachedPath))
|
|
||||||
{
|
|
||||||
return cachedPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,47 +267,44 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Check if already downloaded (skip for cache mode as we want to check cache folder)
|
// Check if already downloaded (works for both cache and permanent modes)
|
||||||
if (!isCache)
|
|
||||||
{
|
|
||||||
var existingPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId);
|
var existingPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId);
|
||||||
if (existingPath != null && IOFile.Exists(existingPath))
|
if (existingPath != null && IOFile.Exists(existingPath))
|
||||||
{
|
{
|
||||||
Logger.LogInformation("Song already downloaded: {Path}", existingPath);
|
Logger.LogInformation("Song already downloaded: {Path}", existingPath);
|
||||||
|
|
||||||
|
// For cache mode, update file access time for cache cleanup logic
|
||||||
|
if (isCache)
|
||||||
|
{
|
||||||
|
IOFile.SetLastAccessTime(existingPath, DateTime.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
return 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if download in progress
|
// Check if download in progress
|
||||||
if (ActiveDownloads.TryGetValue(songId, out var activeDownload) && activeDownload.Status == DownloadStatus.InProgress)
|
if (ActiveDownloads.TryGetValue(songId, out var activeDownload) && activeDownload.Status == DownloadStatus.InProgress)
|
||||||
{
|
{
|
||||||
Logger.LogInformation("Download already in progress for {SongId}, waiting...", songId);
|
Logger.LogDebug("Download already in progress for {SongId}, waiting for completion...", songId);
|
||||||
// Release lock while waiting
|
// Release lock while waiting
|
||||||
DownloadLock.Release();
|
DownloadLock.Release();
|
||||||
|
|
||||||
|
// Wait for download to complete, checking every 100ms (faster than 500ms)
|
||||||
|
// Also respect cancellation token so client timeouts are handled immediately
|
||||||
while (ActiveDownloads.TryGetValue(songId, out activeDownload) && activeDownload.Status == DownloadStatus.InProgress)
|
while (ActiveDownloads.TryGetValue(songId, out activeDownload) && activeDownload.Status == DownloadStatus.InProgress)
|
||||||
{
|
{
|
||||||
await Task.Delay(500, cancellationToken);
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
await Task.Delay(100, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeDownload?.Status == DownloadStatus.Completed && activeDownload.LocalPath != null)
|
if (activeDownload?.Status == DownloadStatus.Completed && activeDownload.LocalPath != null)
|
||||||
{
|
{
|
||||||
|
Logger.LogDebug("Download completed while waiting, returning path: {Path}", activeDownload.LocalPath);
|
||||||
return activeDownload.LocalPath;
|
return activeDownload.LocalPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Exception(activeDownload?.ErrorMessage ?? "Download failed");
|
// Download failed or was cancelled
|
||||||
|
throw new Exception(activeDownload?.ErrorMessage ?? "Download failed while waiting");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get metadata
|
// Get metadata
|
||||||
@@ -298,6 +360,14 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
|
|
||||||
song.LocalPath = localPath;
|
song.LocalPath = localPath;
|
||||||
|
|
||||||
|
// Clean up completed download from tracking after a short delay
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromMinutes(5)); // Keep for 5 minutes for status checks
|
||||||
|
ActiveDownloads.TryRemove(songId, out _);
|
||||||
|
Logger.LogDebug("Cleaned up completed download tracking for {SongId}", songId);
|
||||||
|
});
|
||||||
|
|
||||||
// Register BEFORE releasing lock to prevent race conditions (both cache and download modes)
|
// Register BEFORE releasing lock to prevent race conditions (both cache and download modes)
|
||||||
await LocalLibraryService.RegisterDownloadedSongAsync(song, localPath);
|
await LocalLibraryService.RegisterDownloadedSongAsync(song, localPath);
|
||||||
|
|
||||||
@@ -360,6 +430,14 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
{
|
{
|
||||||
downloadInfo.Status = DownloadStatus.Failed;
|
downloadInfo.Status = DownloadStatus.Failed;
|
||||||
downloadInfo.ErrorMessage = ex.Message;
|
downloadInfo.ErrorMessage = ex.Message;
|
||||||
|
|
||||||
|
// Clean up failed download from tracking after a short delay
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromMinutes(2)); // Keep for 2 minutes for error reporting
|
||||||
|
ActiveDownloads.TryRemove(songId, out _);
|
||||||
|
Logger.LogDebug("Cleaned up failed download tracking for {SongId}", songId);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Logger.LogError(ex, "Download failed for {SongId}", songId);
|
Logger.LogError(ex, "Download failed for {SongId}", songId);
|
||||||
throw;
|
throw;
|
||||||
@@ -560,29 +638,34 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Rate Limiting
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the cached file path for a given provider and external ID
|
/// Queues a request with rate limiting to prevent overwhelming the API.
|
||||||
/// Returns null if no cached file exists
|
/// Ensures minimum interval between requests.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
protected string? GetCachedFilePath(string provider, string externalId)
|
protected async Task<T> QueueRequestAsync<T>(Func<Task<T>> action)
|
||||||
{
|
{
|
||||||
|
await _requestLock.WaitAsync();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Search for cached files matching the pattern: {provider}_{externalId}.*
|
var now = DateTime.UtcNow;
|
||||||
var pattern = $"{provider}_{externalId}.*";
|
var timeSinceLastRequest = (now - _lastRequestTime).TotalMilliseconds;
|
||||||
var files = Directory.GetFiles(CachePath, pattern, SearchOption.AllDirectories);
|
|
||||||
|
|
||||||
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);
|
_requestLock.Release();
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using allstarr.Models.Settings;
|
using allstarr.Models.Settings;
|
||||||
|
using allstarr.Controllers;
|
||||||
|
|
||||||
namespace allstarr.Services.Common;
|
namespace allstarr.Services.Common;
|
||||||
|
|
||||||
@@ -11,16 +12,19 @@ public class CacheCleanupService : BackgroundService
|
|||||||
{
|
{
|
||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
private readonly SubsonicSettings _subsonicSettings;
|
private readonly SubsonicSettings _subsonicSettings;
|
||||||
|
private readonly IServiceProvider _serviceProvider;
|
||||||
private readonly ILogger<CacheCleanupService> _logger;
|
private readonly ILogger<CacheCleanupService> _logger;
|
||||||
private readonly TimeSpan _cleanupInterval = TimeSpan.FromHours(1);
|
private readonly TimeSpan _cleanupInterval = TimeSpan.FromHours(1);
|
||||||
|
|
||||||
public CacheCleanupService(
|
public CacheCleanupService(
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
IOptions<SubsonicSettings> subsonicSettings,
|
IOptions<SubsonicSettings> subsonicSettings,
|
||||||
|
IServiceProvider serviceProvider,
|
||||||
ILogger<CacheCleanupService> logger)
|
ILogger<CacheCleanupService> logger)
|
||||||
{
|
{
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
_subsonicSettings = subsonicSettings.Value;
|
_subsonicSettings = subsonicSettings.Value;
|
||||||
|
_serviceProvider = serviceProvider;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +45,7 @@ public class CacheCleanupService : BackgroundService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await CleanupOldCachedFilesAsync(stoppingToken);
|
await CleanupOldCachedFilesAsync(stoppingToken);
|
||||||
|
await ProcessPendingDeletionsAsync(stoppingToken);
|
||||||
await Task.Delay(_cleanupInterval, stoppingToken);
|
await Task.Delay(_cleanupInterval, stoppingToken);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
@@ -89,16 +94,16 @@ public class CacheCleanupService : BackgroundService
|
|||||||
{
|
{
|
||||||
var fileInfo = new FileInfo(filePath);
|
var fileInfo = new FileInfo(filePath);
|
||||||
|
|
||||||
// Use last access time to determine if file should be deleted
|
// Use last write time (when file was created/downloaded) to determine if file should be deleted
|
||||||
// This gets updated when a cached file is streamed
|
// LastAccessTime is unreliable on many filesystems (noatime mount option)
|
||||||
if (fileInfo.LastAccessTimeUtc < cutoffTime)
|
if (fileInfo.LastWriteTimeUtc < cutoffTime)
|
||||||
{
|
{
|
||||||
var size = fileInfo.Length;
|
var size = fileInfo.Length;
|
||||||
File.Delete(filePath);
|
File.Delete(filePath);
|
||||||
deletedCount++;
|
deletedCount++;
|
||||||
totalSize += size;
|
totalSize += size;
|
||||||
_logger.LogDebug("Deleted cached file: {Path} (last accessed: {LastAccess})",
|
_logger.LogDebug("Deleted cached file: {Path} (age: {Age:F1} hours)",
|
||||||
filePath, fileInfo.LastAccessTimeUtc);
|
filePath, (DateTime.UtcNow - fileInfo.LastWriteTimeUtc).TotalHours);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -160,4 +165,30 @@ public class CacheCleanupService : BackgroundService
|
|||||||
|
|
||||||
await Task.CompletedTask;
|
await Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Processes pending track deletions from the kept folder.
|
||||||
|
/// </summary>
|
||||||
|
private async Task ProcessPendingDeletionsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Create a scope to get the JellyfinController
|
||||||
|
using var scope = _serviceProvider.CreateScope();
|
||||||
|
var jellyfinController = scope.ServiceProvider.GetService<JellyfinController>();
|
||||||
|
|
||||||
|
if (jellyfinController != null)
|
||||||
|
{
|
||||||
|
await jellyfinController.ProcessPendingDeletionsAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Could not resolve JellyfinController for pending deletions processing");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error processing pending deletions");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
400
allstarr/Services/Common/CacheWarmingService.cs
Normal file
400
allstarr/Services/Common/CacheWarmingService.cs
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using allstarr.Models.Domain;
|
||||||
|
|
||||||
|
namespace allstarr.Services.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Background service that warms up Redis cache from file system on startup.
|
||||||
|
/// Ensures fast access to cached data after container restarts.
|
||||||
|
/// </summary>
|
||||||
|
public class CacheWarmingService : IHostedService
|
||||||
|
{
|
||||||
|
private readonly RedisCacheService _cache;
|
||||||
|
private readonly ILogger<CacheWarmingService> _logger;
|
||||||
|
private readonly IServiceProvider _serviceProvider;
|
||||||
|
private const string GenreCacheDirectory = "/app/cache/genres";
|
||||||
|
private const string PlaylistCacheDirectory = "/app/cache/spotify";
|
||||||
|
private const string MappingsCacheDirectory = "/app/cache/mappings";
|
||||||
|
private const string LyricsCacheDirectory = "/app/cache/lyrics";
|
||||||
|
|
||||||
|
public CacheWarmingService(
|
||||||
|
RedisCacheService cache,
|
||||||
|
IServiceProvider serviceProvider,
|
||||||
|
ILogger<CacheWarmingService> logger)
|
||||||
|
{
|
||||||
|
_cache = cache;
|
||||||
|
_serviceProvider = serviceProvider;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("🔥 Starting cache warming from file system...");
|
||||||
|
|
||||||
|
var startTime = DateTime.UtcNow;
|
||||||
|
var genresWarmed = 0;
|
||||||
|
var playlistsWarmed = 0;
|
||||||
|
var mappingsWarmed = 0;
|
||||||
|
var lyricsWarmed = 0;
|
||||||
|
var lyricsMappingsWarmed = 0;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Warm genre cache
|
||||||
|
genresWarmed = await WarmGenreCacheAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Warm playlist cache
|
||||||
|
playlistsWarmed = await WarmPlaylistCacheAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Warm manual mappings cache
|
||||||
|
mappingsWarmed = await WarmManualMappingsCacheAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Warm lyrics mappings cache
|
||||||
|
lyricsMappingsWarmed = await WarmLyricsMappingsCacheAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Warm lyrics cache
|
||||||
|
lyricsWarmed = await WarmLyricsCacheAsync(cancellationToken);
|
||||||
|
|
||||||
|
var duration = DateTime.UtcNow - startTime;
|
||||||
|
_logger.LogInformation(
|
||||||
|
"✅ Cache warming complete in {Duration:F1}s: {Genres} genres, {Playlists} playlists, {Mappings} manual mappings, {LyricsMappings} lyrics mappings, {Lyrics} lyrics",
|
||||||
|
duration.TotalSeconds, genresWarmed, playlistsWarmed, mappingsWarmed, lyricsMappingsWarmed, lyricsWarmed);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to warm cache from file system");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task StopAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Warms genre cache from file system.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<int> WarmGenreCacheAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(GenreCacheDirectory))
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var files = Directory.GetFiles(GenreCacheDirectory, "*.json");
|
||||||
|
var warmedCount = 0;
|
||||||
|
|
||||||
|
foreach (var file in files)
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
break;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Check if cache is expired (30 days)
|
||||||
|
var fileInfo = new FileInfo(file);
|
||||||
|
if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc > TimeSpan.FromDays(30))
|
||||||
|
{
|
||||||
|
File.Delete(file);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = await File.ReadAllTextAsync(file, cancellationToken);
|
||||||
|
var cacheEntry = JsonSerializer.Deserialize<GenreCacheEntry>(json);
|
||||||
|
|
||||||
|
if (cacheEntry != null && !string.IsNullOrEmpty(cacheEntry.CacheKey))
|
||||||
|
{
|
||||||
|
var redisKey = $"genre:{cacheEntry.CacheKey}";
|
||||||
|
await _cache.SetAsync(redisKey, cacheEntry.Genre, TimeSpan.FromDays(30));
|
||||||
|
warmedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to warm genre cache from file: {File}", file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (warmedCount > 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("🔥 Warmed {Count} genre entries from file cache", warmedCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return warmedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Warms playlist cache from file system.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<int> WarmPlaylistCacheAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(PlaylistCacheDirectory))
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var itemsFiles = Directory.GetFiles(PlaylistCacheDirectory, "*_items.json");
|
||||||
|
var matchedFiles = Directory.GetFiles(PlaylistCacheDirectory, "*_matched.json");
|
||||||
|
var warmedCount = 0;
|
||||||
|
|
||||||
|
// Warm playlist items cache
|
||||||
|
foreach (var file in itemsFiles)
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
break;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Check if cache is expired (24 hours)
|
||||||
|
var fileInfo = new FileInfo(file);
|
||||||
|
if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc > TimeSpan.FromHours(24))
|
||||||
|
{
|
||||||
|
continue; // Don't delete, let the normal flow handle it
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = await File.ReadAllTextAsync(file, cancellationToken);
|
||||||
|
var items = JsonSerializer.Deserialize<List<Dictionary<string, object?>>>(json);
|
||||||
|
|
||||||
|
if (items != null && items.Count > 0)
|
||||||
|
{
|
||||||
|
// Extract playlist name from filename
|
||||||
|
var fileName = Path.GetFileNameWithoutExtension(file);
|
||||||
|
var playlistName = fileName.Replace("_items", "");
|
||||||
|
|
||||||
|
var redisKey = $"spotify:playlist:items:{playlistName}";
|
||||||
|
await _cache.SetAsync(redisKey, items, TimeSpan.FromHours(24));
|
||||||
|
warmedCount++;
|
||||||
|
|
||||||
|
_logger.LogDebug("🔥 Warmed playlist items cache for {Playlist} ({Count} items)",
|
||||||
|
playlistName, items.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to warm playlist items cache from file: {File}", file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warm matched tracks cache
|
||||||
|
foreach (var file in matchedFiles)
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
break;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Check if cache is expired (1 hour)
|
||||||
|
var fileInfo = new FileInfo(file);
|
||||||
|
if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc > TimeSpan.FromHours(1))
|
||||||
|
{
|
||||||
|
continue; // Skip expired matched tracks
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = await File.ReadAllTextAsync(file, cancellationToken);
|
||||||
|
var matchedTracks = JsonSerializer.Deserialize<List<MatchedTrack>>(json);
|
||||||
|
|
||||||
|
if (matchedTracks != null && matchedTracks.Count > 0)
|
||||||
|
{
|
||||||
|
// Extract playlist name from filename
|
||||||
|
var fileName = Path.GetFileNameWithoutExtension(file);
|
||||||
|
var playlistName = fileName.Replace("_matched", "");
|
||||||
|
|
||||||
|
var redisKey = $"spotify:matched:ordered:{playlistName}";
|
||||||
|
await _cache.SetAsync(redisKey, matchedTracks, TimeSpan.FromHours(1));
|
||||||
|
warmedCount++;
|
||||||
|
|
||||||
|
_logger.LogDebug("🔥 Warmed matched tracks cache for {Playlist} ({Count} tracks)",
|
||||||
|
playlistName, matchedTracks.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to warm matched tracks cache from file: {File}", file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (warmedCount > 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("🔥 Warmed {Count} playlist caches from file system", warmedCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return warmedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Warms manual mappings cache from file system.
|
||||||
|
/// Manual mappings NEVER expire - they are permanent user decisions.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<int> WarmManualMappingsCacheAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(MappingsCacheDirectory))
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var files = Directory.GetFiles(MappingsCacheDirectory, "*_mappings.json");
|
||||||
|
var warmedCount = 0;
|
||||||
|
|
||||||
|
foreach (var file in files)
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
break;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = await File.ReadAllTextAsync(file, cancellationToken);
|
||||||
|
var mappings = JsonSerializer.Deserialize<Dictionary<string, ManualMappingEntry>>(json);
|
||||||
|
|
||||||
|
if (mappings != null && mappings.Count > 0)
|
||||||
|
{
|
||||||
|
// Extract playlist name from filename
|
||||||
|
var fileName = Path.GetFileNameWithoutExtension(file);
|
||||||
|
var playlistName = fileName.Replace("_mappings", "");
|
||||||
|
|
||||||
|
foreach (var mapping in mappings.Values)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(mapping.JellyfinId))
|
||||||
|
{
|
||||||
|
// Jellyfin mapping
|
||||||
|
var redisKey = $"spotify:manual-map:{playlistName}:{mapping.SpotifyId}";
|
||||||
|
await _cache.SetAsync(redisKey, mapping.JellyfinId);
|
||||||
|
warmedCount++;
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrEmpty(mapping.ExternalProvider) && !string.IsNullOrEmpty(mapping.ExternalId))
|
||||||
|
{
|
||||||
|
// External mapping
|
||||||
|
var redisKey = $"spotify:external-map:{playlistName}:{mapping.SpotifyId}";
|
||||||
|
var externalMapping = new { provider = mapping.ExternalProvider, id = mapping.ExternalId };
|
||||||
|
await _cache.SetAsync(redisKey, externalMapping);
|
||||||
|
warmedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("🔥 Warmed {Count} manual mappings for {Playlist}",
|
||||||
|
mappings.Count, playlistName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to warm manual mappings from file: {File}", file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (warmedCount > 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("🔥 Warmed {Count} manual mappings from file system", warmedCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return warmedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Warms lyrics mappings cache from file system.
|
||||||
|
/// Lyrics mappings NEVER expire - they are permanent user decisions.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<int> WarmLyricsMappingsCacheAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var mappingsFile = "/app/cache/lyrics_mappings.json";
|
||||||
|
|
||||||
|
if (!File.Exists(mappingsFile))
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = await File.ReadAllTextAsync(mappingsFile, cancellationToken);
|
||||||
|
var mappings = JsonSerializer.Deserialize<List<LyricsMappingEntry>>(json);
|
||||||
|
|
||||||
|
if (mappings != null && mappings.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var mapping in mappings)
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Store in Redis with NO EXPIRATION (permanent)
|
||||||
|
var redisKey = $"lyrics:manual-map:{mapping.Artist}:{mapping.Title}";
|
||||||
|
await _cache.SetStringAsync(redisKey, mapping.LyricsId.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("🔥 Warmed {Count} lyrics mappings from file system", mappings.Count);
|
||||||
|
return mappings.Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to warm lyrics mappings from file: {File}", mappingsFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Warms lyrics cache from file system using the LyricsPrefetchService.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<int> WarmLyricsCacheAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Get the LyricsPrefetchService from DI
|
||||||
|
using var scope = _serviceProvider.CreateScope();
|
||||||
|
var lyricsPrefetchService = scope.ServiceProvider.GetService<allstarr.Services.Lyrics.LyricsPrefetchService>();
|
||||||
|
|
||||||
|
if (lyricsPrefetchService != null)
|
||||||
|
{
|
||||||
|
await lyricsPrefetchService.WarmCacheFromFilesAsync();
|
||||||
|
|
||||||
|
// Count files to return warmed count
|
||||||
|
if (Directory.Exists(LyricsCacheDirectory))
|
||||||
|
{
|
||||||
|
return Directory.GetFiles(LyricsCacheDirectory, "*.json").Length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to warm lyrics cache");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class GenreCacheEntry
|
||||||
|
{
|
||||||
|
public string CacheKey { get; set; } = "";
|
||||||
|
public string Genre { get; set; } = "";
|
||||||
|
public DateTime CachedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private class MatchedTrack
|
||||||
|
{
|
||||||
|
public int Position { get; set; }
|
||||||
|
public string SpotifyId { get; set; } = "";
|
||||||
|
public string SpotifyTitle { get; set; } = "";
|
||||||
|
public string SpotifyArtist { get; set; } = "";
|
||||||
|
public string? Isrc { get; set; }
|
||||||
|
public string MatchType { get; set; } = "";
|
||||||
|
public Song? MatchedSong { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ManualMappingEntry
|
||||||
|
{
|
||||||
|
public string SpotifyId { get; set; } = "";
|
||||||
|
public string? JellyfinId { get; set; }
|
||||||
|
public string? ExternalProvider { get; set; }
|
||||||
|
public string? ExternalId { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private class LyricsMappingEntry
|
||||||
|
{
|
||||||
|
public string Artist { get; set; } = "";
|
||||||
|
public string Title { get; set; } = "";
|
||||||
|
public string? Album { get; set; }
|
||||||
|
public int DurationSeconds { get; set; }
|
||||||
|
public int LyricsId { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
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; }
|
||||||
|
}
|
||||||
@@ -2,12 +2,64 @@ namespace allstarr.Services.Common;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Provides fuzzy string matching for search result scoring.
|
/// Provides fuzzy string matching for search result scoring.
|
||||||
|
/// OPTIMAL ORDER: 1. Strip decorators → 2. Substring matching → 3. Levenshtein → 4. Greedy assignment
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class FuzzyMatcher
|
public static class FuzzyMatcher
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Calculates a similarity score between two strings (0-100).
|
/// STEP 1: Strips common decorators from track titles to improve matching.
|
||||||
/// Higher score means better match.
|
/// Removes: (feat. X), (with Y), (ft. Z), - From "Album", [Remix], etc.
|
||||||
|
/// This MUST be done first to avoid systematic noise in matching.
|
||||||
|
/// </summary>
|
||||||
|
public static string StripDecorators(string title)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(title))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cleaned = title;
|
||||||
|
|
||||||
|
// Remove (feat. ...), (ft. ...), (with ...), (featuring ...)
|
||||||
|
cleaned = System.Text.RegularExpressions.Regex.Replace(
|
||||||
|
cleaned,
|
||||||
|
@"\s*[\(\[]?\s*(feat\.?|ft\.?|with|featuring)\s+[^\)\]]+[\)\]]?",
|
||||||
|
"",
|
||||||
|
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
// Remove - From "Album Name" or - From Album Name
|
||||||
|
cleaned = System.Text.RegularExpressions.Regex.Replace(
|
||||||
|
cleaned,
|
||||||
|
@"\s*-\s*from\s+[""']?[^""']+[""']?",
|
||||||
|
"",
|
||||||
|
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
// Remove - Remastered, - Radio Edit, etc.
|
||||||
|
cleaned = System.Text.RegularExpressions.Regex.Replace(
|
||||||
|
cleaned,
|
||||||
|
@"\s*-\s*(remaster|radio edit|single version|album version|extended|original mix)[^\-]*",
|
||||||
|
"",
|
||||||
|
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
// Remove [Remix], [Remaster], [Live], [Explicit], etc.
|
||||||
|
cleaned = System.Text.RegularExpressions.Regex.Replace(
|
||||||
|
cleaned,
|
||||||
|
@"\s*[\[\(](remix|remaster|live|acoustic|radio edit|explicit|clean|official|audio|video|lyric)[^\]\)]*[\]\)]",
|
||||||
|
"",
|
||||||
|
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
// Remove trailing/leading whitespace and normalize
|
||||||
|
cleaned = cleaned.Trim();
|
||||||
|
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calculates similarity score following OPTIMAL ORDER:
|
||||||
|
/// 1. Strip decorators (already done by caller)
|
||||||
|
/// 2. Substring matching (cheap, high-precision)
|
||||||
|
/// 3. Levenshtein distance (expensive, fuzzy)
|
||||||
|
/// Returns score 0-100.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static int CalculateSimilarity(string query, string target)
|
public static int CalculateSimilarity(string query, string target)
|
||||||
{
|
{
|
||||||
@@ -16,47 +68,115 @@ public static class FuzzyMatcher
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
var queryLower = query.ToLowerInvariant().Trim();
|
var queryNorm = NormalizeForMatching(query);
|
||||||
var targetLower = target.ToLowerInvariant().Trim();
|
var targetNorm = NormalizeForMatching(target);
|
||||||
|
|
||||||
|
// STEP 2: SUBSTRING MATCHING (cheap, high-precision)
|
||||||
|
|
||||||
// Exact match
|
// Exact match
|
||||||
if (queryLower == targetLower)
|
if (queryNorm == targetNorm)
|
||||||
{
|
{
|
||||||
return 100;
|
return 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One string fully contains the other (substring match)
|
||||||
|
// Example: "luther" ⊂ "luther remastered" → instant win
|
||||||
|
if (targetNorm.Contains(queryNorm) || queryNorm.Contains(targetNorm))
|
||||||
|
{
|
||||||
|
return 95;
|
||||||
|
}
|
||||||
|
|
||||||
// Starts with query
|
// Starts with query
|
||||||
if (targetLower.StartsWith(queryLower))
|
if (targetNorm.StartsWith(queryNorm) || queryNorm.StartsWith(targetNorm))
|
||||||
{
|
{
|
||||||
return 90;
|
return 90;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Contains query as whole word
|
// Contains query as whole word
|
||||||
if (targetLower.Contains($" {queryLower} ") ||
|
if (targetNorm.Contains($" {queryNorm} ") ||
|
||||||
targetLower.StartsWith($"{queryLower} ") ||
|
targetNorm.StartsWith($"{queryNorm} ") ||
|
||||||
targetLower.EndsWith($" {queryLower}"))
|
targetNorm.EndsWith($" {queryNorm}") ||
|
||||||
|
queryNorm.Contains($" {targetNorm} ") ||
|
||||||
|
queryNorm.StartsWith($"{targetNorm} ") ||
|
||||||
|
queryNorm.EndsWith($" {targetNorm}"))
|
||||||
{
|
{
|
||||||
return 80;
|
return 85;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Contains query anywhere
|
// STEP 3: LEVENSHTEIN DISTANCE (expensive, fuzzy)
|
||||||
if (targetLower.Contains(queryLower))
|
// Only use this for candidates that survived substring checks
|
||||||
{
|
|
||||||
return 70;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate Levenshtein distance for fuzzy matching
|
var distance = LevenshteinDistance(queryNorm, targetNorm);
|
||||||
var distance = LevenshteinDistance(queryLower, targetLower);
|
var maxLength = Math.Max(queryNorm.Length, targetNorm.Length);
|
||||||
var maxLength = Math.Max(queryLower.Length, targetLower.Length);
|
|
||||||
|
|
||||||
if (maxLength == 0)
|
if (maxLength == 0)
|
||||||
{
|
{
|
||||||
return 100;
|
return 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert distance to similarity score (0-60 range for fuzzy matches)
|
// Normalize distance by length: score = 1 - (distance / max_length)
|
||||||
var similarity = (1.0 - (double)distance / maxLength) * 60;
|
var normalizedSimilarity = 1.0 - ((double)distance / maxLength);
|
||||||
return (int)Math.Max(0, similarity);
|
|
||||||
|
// Convert to 0-80 range (reserve 80-100 for substring matches)
|
||||||
|
var score = (int)(normalizedSimilarity * 80);
|
||||||
|
|
||||||
|
return Math.Max(0, score);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AGGRESSIVE matching that follows optimal order:
|
||||||
|
/// 1. Strip decorators FIRST
|
||||||
|
/// 2. Substring matching
|
||||||
|
/// 3. Levenshtein distance
|
||||||
|
/// Returns the best score.
|
||||||
|
/// </summary>
|
||||||
|
public static int CalculateSimilarityAggressive(string query, string target)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(query) || string.IsNullOrWhiteSpace(target))
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// STEP 1: Strip decorators FIRST (always)
|
||||||
|
var queryStripped = StripDecorators(query);
|
||||||
|
var targetStripped = StripDecorators(target);
|
||||||
|
|
||||||
|
// STEP 2-3: Substring matching + Levenshtein
|
||||||
|
var strippedScore = CalculateSimilarity(queryStripped, targetStripped);
|
||||||
|
|
||||||
|
// Also try without stripping in case decorators are part of the actual title
|
||||||
|
var rawScore = CalculateSimilarity(query, target);
|
||||||
|
|
||||||
|
// Return the best score
|
||||||
|
return Math.Max(rawScore, strippedScore);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Normalizes a string for matching by:
|
||||||
|
/// - Converting to lowercase
|
||||||
|
/// - Normalizing apostrophes (', ', ') to standard '
|
||||||
|
/// - Removing extra whitespace
|
||||||
|
/// </summary>
|
||||||
|
private static string NormalizeForMatching(string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = text.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
|
// Normalize different apostrophe types to standard apostrophe
|
||||||
|
normalized = normalized
|
||||||
|
.Replace("\u2019", "'") // Right single quotation mark (')
|
||||||
|
.Replace("\u2018", "'") // Left single quotation mark (')
|
||||||
|
.Replace("`", "'") // Grave accent
|
||||||
|
.Replace("\u00B4", "'"); // Acute accent (´)
|
||||||
|
|
||||||
|
// Normalize whitespace
|
||||||
|
normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"\s+", " ");
|
||||||
|
|
||||||
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -101,4 +221,54 @@ public static class FuzzyMatcher
|
|||||||
|
|
||||||
return distance[sourceLength, targetLength];
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
228
allstarr/Services/Common/GenreEnrichmentService.cs
Normal file
228
allstarr/Services/Common/GenreEnrichmentService.cs
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
using allstarr.Models.Domain;
|
||||||
|
using allstarr.Services.MusicBrainz;
|
||||||
|
using allstarr.Services.Common;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace allstarr.Services.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for enriching songs and playlists with genre information from MusicBrainz.
|
||||||
|
/// </summary>
|
||||||
|
public class GenreEnrichmentService
|
||||||
|
{
|
||||||
|
private readonly MusicBrainzService _musicBrainz;
|
||||||
|
private readonly RedisCacheService _cache;
|
||||||
|
private readonly ILogger<GenreEnrichmentService> _logger;
|
||||||
|
private const string GenreCachePrefix = "genre:";
|
||||||
|
private const string GenreCacheDirectory = "/app/cache/genres";
|
||||||
|
private static readonly TimeSpan GenreCacheDuration = TimeSpan.FromDays(30);
|
||||||
|
|
||||||
|
public GenreEnrichmentService(
|
||||||
|
MusicBrainzService musicBrainz,
|
||||||
|
RedisCacheService cache,
|
||||||
|
ILogger<GenreEnrichmentService> logger)
|
||||||
|
{
|
||||||
|
_musicBrainz = musicBrainz;
|
||||||
|
_cache = cache;
|
||||||
|
_logger = logger;
|
||||||
|
|
||||||
|
// Ensure cache directory exists
|
||||||
|
Directory.CreateDirectory(GenreCacheDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enriches a song with genre information from MusicBrainz (with caching).
|
||||||
|
/// Updates the song's Genre property with the top genre.
|
||||||
|
/// </summary>
|
||||||
|
public async Task EnrichSongGenreAsync(Song song)
|
||||||
|
{
|
||||||
|
// Skip if song already has a genre
|
||||||
|
if (!string.IsNullOrEmpty(song.Genre))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cacheKey = $"{song.Title}:{song.Artist}";
|
||||||
|
|
||||||
|
// Check Redis cache first
|
||||||
|
var redisCacheKey = $"{GenreCachePrefix}{cacheKey}";
|
||||||
|
var cachedGenre = await _cache.GetAsync<string>(redisCacheKey);
|
||||||
|
|
||||||
|
if (cachedGenre != null)
|
||||||
|
{
|
||||||
|
song.Genre = cachedGenre;
|
||||||
|
_logger.LogDebug("Using Redis cached genre for {Title} - {Artist}: {Genre}",
|
||||||
|
song.Title, song.Artist, cachedGenre);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file cache
|
||||||
|
var fileCachedGenre = await GetFromFileCacheAsync(cacheKey);
|
||||||
|
if (fileCachedGenre != null)
|
||||||
|
{
|
||||||
|
song.Genre = fileCachedGenre;
|
||||||
|
// Restore to Redis cache
|
||||||
|
await _cache.SetAsync(redisCacheKey, fileCachedGenre, GenreCacheDuration);
|
||||||
|
_logger.LogDebug("Using file cached genre for {Title} - {Artist}: {Genre}",
|
||||||
|
song.Title, song.Artist, fileCachedGenre);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch from MusicBrainz
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var genres = await _musicBrainz.GetGenresForSongAsync(song.Title, song.Artist, song.Isrc);
|
||||||
|
|
||||||
|
if (genres.Count > 0)
|
||||||
|
{
|
||||||
|
// Use the top genre
|
||||||
|
song.Genre = genres[0];
|
||||||
|
|
||||||
|
// Cache in both Redis and file
|
||||||
|
await _cache.SetAsync(redisCacheKey, song.Genre, GenreCacheDuration);
|
||||||
|
await SaveToFileCacheAsync(cacheKey, song.Genre);
|
||||||
|
|
||||||
|
_logger.LogInformation("Enriched {Title} - {Artist} with genre: {Genre}",
|
||||||
|
song.Title, song.Artist, song.Genre);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Cache negative result to avoid repeated lookups
|
||||||
|
await SaveToFileCacheAsync(cacheKey, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to enrich genre for {Title} - {Artist}",
|
||||||
|
song.Title, song.Artist);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enriches multiple songs with genre information (batch operation).
|
||||||
|
/// </summary>
|
||||||
|
public async Task EnrichSongsGenresAsync(List<Song> songs)
|
||||||
|
{
|
||||||
|
var tasks = songs
|
||||||
|
.Where(s => string.IsNullOrEmpty(s.Genre))
|
||||||
|
.Select(s => EnrichSongGenreAsync(s));
|
||||||
|
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Aggregates genres from a list of songs to determine playlist genres.
|
||||||
|
/// Returns the top 5 most common genres.
|
||||||
|
/// </summary>
|
||||||
|
public List<string> AggregatePlaylistGenres(List<Song> songs)
|
||||||
|
{
|
||||||
|
var genreCounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var song in songs)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(song.Genre))
|
||||||
|
{
|
||||||
|
if (genreCounts.ContainsKey(song.Genre))
|
||||||
|
{
|
||||||
|
genreCounts[song.Genre]++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
genreCounts[song.Genre] = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return genreCounts
|
||||||
|
.OrderByDescending(kvp => kvp.Value)
|
||||||
|
.Take(5)
|
||||||
|
.Select(kvp => kvp.Key)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets genre from file cache.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<string?> GetFromFileCacheAsync(string cacheKey)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fileName = GetCacheFileName(cacheKey);
|
||||||
|
var filePath = Path.Combine(GenreCacheDirectory, fileName);
|
||||||
|
|
||||||
|
if (!File.Exists(filePath))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if cache is expired (30 days)
|
||||||
|
var fileInfo = new FileInfo(filePath);
|
||||||
|
if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc > GenreCacheDuration)
|
||||||
|
{
|
||||||
|
File.Delete(filePath);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = await File.ReadAllTextAsync(filePath);
|
||||||
|
var cacheEntry = JsonSerializer.Deserialize<GenreCacheEntry>(json);
|
||||||
|
|
||||||
|
return cacheEntry?.Genre;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to read genre from file cache for {Key}", cacheKey);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves genre to file cache.
|
||||||
|
/// </summary>
|
||||||
|
private async Task SaveToFileCacheAsync(string cacheKey, string genre)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fileName = GetCacheFileName(cacheKey);
|
||||||
|
var filePath = Path.Combine(GenreCacheDirectory, fileName);
|
||||||
|
|
||||||
|
var cacheEntry = new GenreCacheEntry
|
||||||
|
{
|
||||||
|
CacheKey = cacheKey,
|
||||||
|
Genre = genre,
|
||||||
|
CachedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(cacheEntry, new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
WriteIndented = true
|
||||||
|
});
|
||||||
|
|
||||||
|
await File.WriteAllTextAsync(filePath, json);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to save genre to file cache for {Key}", cacheKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a safe file name from cache key.
|
||||||
|
/// </summary>
|
||||||
|
private static string GetCacheFileName(string cacheKey)
|
||||||
|
{
|
||||||
|
// Use base64 encoding to create safe file names
|
||||||
|
var bytes = System.Text.Encoding.UTF8.GetBytes(cacheKey);
|
||||||
|
var base64 = Convert.ToBase64String(bytes)
|
||||||
|
.Replace("+", "-")
|
||||||
|
.Replace("/", "_")
|
||||||
|
.Replace("=", "");
|
||||||
|
return $"{base64}.json";
|
||||||
|
}
|
||||||
|
|
||||||
|
private class GenreCacheEntry
|
||||||
|
{
|
||||||
|
public string CacheKey { get; set; } = "";
|
||||||
|
public string Genre { get; set; } = "";
|
||||||
|
public DateTime CachedAt { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
135
allstarr/Services/Common/ParallelMetadataService.cs
Normal file
135
allstarr/Services/Common/ParallelMetadataService.cs
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
using allstarr.Models.Domain;
|
||||||
|
using allstarr.Models.Search;
|
||||||
|
|
||||||
|
namespace allstarr.Services.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Races multiple metadata providers in parallel and returns the fastest result.
|
||||||
|
/// Used for search operations to minimize latency.
|
||||||
|
/// </summary>
|
||||||
|
public class ParallelMetadataService
|
||||||
|
{
|
||||||
|
private readonly IEnumerable<IMusicMetadataService> _providers;
|
||||||
|
private readonly ILogger<ParallelMetadataService> _logger;
|
||||||
|
|
||||||
|
public ParallelMetadataService(
|
||||||
|
IEnumerable<IMusicMetadataService> providers,
|
||||||
|
ILogger<ParallelMetadataService> logger)
|
||||||
|
{
|
||||||
|
_providers = providers;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Races all providers and returns the first successful result.
|
||||||
|
/// Falls back to next provider if first one fails.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20)
|
||||||
|
{
|
||||||
|
if (!_providers.Any())
|
||||||
|
{
|
||||||
|
_logger.LogWarning("No metadata providers available for parallel search");
|
||||||
|
return new SearchResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("🏁 Racing {Count} providers for search: {Query}", _providers.Count(), query);
|
||||||
|
|
||||||
|
// Create tasks for all providers
|
||||||
|
var tasks = _providers.Select(async provider =>
|
||||||
|
{
|
||||||
|
var providerName = provider.GetType().Name;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
var result = await provider.SearchAllAsync(query, songLimit, albumLimit, artistLimit);
|
||||||
|
sw.Stop();
|
||||||
|
|
||||||
|
_logger.LogInformation("✅ {Provider} completed search in {Ms}ms ({Songs} songs, {Albums} albums, {Artists} artists)",
|
||||||
|
providerName, sw.ElapsedMilliseconds, result.Songs.Count, result.Albums.Count, result.Artists.Count);
|
||||||
|
|
||||||
|
return (Success: true, Result: result, Provider: providerName, ElapsedMs: sw.ElapsedMilliseconds);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "❌ {Provider} search failed", providerName);
|
||||||
|
return (Success: false, Result: new SearchResult(), Provider: providerName, ElapsedMs: 0L);
|
||||||
|
}
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
// Wait for first successful result
|
||||||
|
while (tasks.Any())
|
||||||
|
{
|
||||||
|
var completedTask = await Task.WhenAny(tasks);
|
||||||
|
var result = await completedTask;
|
||||||
|
|
||||||
|
if (result.Success && (result.Result.Songs.Any() || result.Result.Albums.Any() || result.Result.Artists.Any()))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("🏆 Using results from {Provider} ({Ms}ms) - fastest with results",
|
||||||
|
result.Provider, result.ElapsedMs);
|
||||||
|
return result.Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove completed task and try next
|
||||||
|
tasks.Remove(completedTask);
|
||||||
|
}
|
||||||
|
|
||||||
|
// All providers failed or returned empty
|
||||||
|
_logger.LogWarning("⚠️ All providers failed or returned empty results for: {Query}", query);
|
||||||
|
return new SearchResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Searches for a specific song by title and artist across all providers in parallel.
|
||||||
|
/// Returns the first successful match.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<Song?> SearchSongAsync(string title, string artist, int limit = 5)
|
||||||
|
{
|
||||||
|
if (!_providers.Any())
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("🏁 Racing {Count} providers for song: {Title} - {Artist}", _providers.Count(), title, artist);
|
||||||
|
|
||||||
|
var tasks = _providers.Select(async provider =>
|
||||||
|
{
|
||||||
|
var providerName = provider.GetType().Name;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
var songs = await provider.SearchSongsAsync($"{title} {artist}", limit);
|
||||||
|
sw.Stop();
|
||||||
|
|
||||||
|
var bestMatch = songs.FirstOrDefault();
|
||||||
|
if (bestMatch != null)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("✅ {Provider} found song in {Ms}ms", providerName, sw.ElapsedMilliseconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (Success: bestMatch != null, Song: bestMatch, Provider: providerName, ElapsedMs: sw.ElapsedMilliseconds);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "❌ {Provider} song search failed", providerName);
|
||||||
|
return (Success: false, Song: (Song?)null, Provider: providerName, ElapsedMs: 0L);
|
||||||
|
}
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
// Wait for first successful result
|
||||||
|
while (tasks.Any())
|
||||||
|
{
|
||||||
|
var completedTask = await Task.WhenAny(tasks);
|
||||||
|
var result = await completedTask;
|
||||||
|
|
||||||
|
if (result.Success && result.Song != null)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("🏆 Using song from {Provider} ({Ms}ms)", result.Provider, result.ElapsedMs);
|
||||||
|
return result.Song;
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.Remove(completedTask);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,13 +57,14 @@ public class RedisCacheService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var value = await _db!.StringGetAsync(key);
|
var value = await _db!.StringGetAsync(key);
|
||||||
|
|
||||||
if (value.HasValue)
|
if (value.HasValue)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Redis cache HIT: {Key}", key);
|
_logger.LogDebug("Redis cache HIT: {Key}", key);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Redis cache MISS: {Key}", key);
|
_logger.LogDebug("Redis cache MISS: {Key}", key);
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
@@ -105,7 +106,7 @@ public class RedisCacheService
|
|||||||
var result = await _db!.StringSetAsync(key, value, expiry);
|
var result = await _db!.StringSetAsync(key, value, expiry);
|
||||||
if (result)
|
if (result)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Redis cache SET: {Key} (TTL: {Expiry})", key, expiry?.ToString() ?? "none");
|
_logger.LogDebug("Redis cache SET: {Key} (TTL: {Expiry})", key, expiry?.ToString() ?? "none");
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -168,4 +169,34 @@ public class RedisCacheService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes all keys matching a pattern (e.g., "search:*").
|
||||||
|
/// WARNING: Use with caution as this scans all keys.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<int> DeleteByPatternAsync(string pattern)
|
||||||
|
{
|
||||||
|
if (!IsEnabled) return 0;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var server = _redis!.GetServer(_redis.GetEndPoints().First());
|
||||||
|
var keys = server.Keys(pattern: pattern).ToArray();
|
||||||
|
|
||||||
|
if (keys.Length == 0)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("No keys found matching pattern: {Pattern}", pattern);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var deleted = await _db!.KeyDeleteAsync(keys);
|
||||||
|
_logger.LogInformation("Deleted {Count} Redis keys matching pattern: {Pattern}", deleted, pattern);
|
||||||
|
return (int)deleted;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Redis DELETE BY PATTERN failed for pattern: {Pattern}", pattern);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
public class DeezerDownloadService : BaseDownloadService
|
||||||
{
|
{
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly SemaphoreSlim _requestLock = new(1, 1);
|
|
||||||
|
|
||||||
private readonly string? _arl;
|
private readonly string? _arl;
|
||||||
private readonly string? _arlFallback;
|
private readonly string? _arlFallback;
|
||||||
@@ -33,9 +32,6 @@ public class DeezerDownloadService : BaseDownloadService
|
|||||||
private string? _apiToken;
|
private string? _apiToken;
|
||||||
private string? _licenseToken;
|
private string? _licenseToken;
|
||||||
|
|
||||||
private DateTime _lastRequestTime = DateTime.MinValue;
|
|
||||||
private readonly int _minRequestIntervalMs = 200;
|
|
||||||
|
|
||||||
private const string DeezerApiBase = "https://api.deezer.com";
|
private const string DeezerApiBase = "https://api.deezer.com";
|
||||||
|
|
||||||
// Deezer's standard Blowfish CBC encryption key for track decryption
|
// 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)
|
// Build organized folder structure: Artist/Album/Track using AlbumArtist (fallback to Artist for singles)
|
||||||
var artistForPath = song.AlbumArtist ?? song.Artist;
|
var artistForPath = song.AlbumArtist ?? song.Artist;
|
||||||
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);
|
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension);
|
||||||
|
|
||||||
// Create directories if they don't exist
|
// Create directories if they don't exist
|
||||||
@@ -494,27 +493,6 @@ public class DeezerDownloadService : BaseDownloadService
|
|||||||
await RetryWithBackoffAsync<bool>(action, maxRetries, initialDelayMs);
|
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
|
#endregion
|
||||||
|
|
||||||
|
|||||||
@@ -168,6 +168,11 @@ public class JellyfinProxyService
|
|||||||
(h.Value.ToString().Contains("image", StringComparison.OrdinalIgnoreCase) ||
|
(h.Value.ToString().Contains("image", StringComparison.OrdinalIgnoreCase) ||
|
||||||
h.Value.ToString().Contains("document", StringComparison.OrdinalIgnoreCase))) == true);
|
h.Value.ToString().Contains("document", StringComparison.OrdinalIgnoreCase))) == true);
|
||||||
|
|
||||||
|
// Check if this is a public endpoint that doesn't require authentication
|
||||||
|
bool isPublicEndpoint = url.Contains("/System/Info/Public", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
url.Contains("/Branding/", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
url.Contains("/Startup/", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
// Forward authentication headers from client if provided
|
// Forward authentication headers from client if provided
|
||||||
if (clientHeaders != null && clientHeaders.Count > 0)
|
if (clientHeaders != null && clientHeaders.Count > 0)
|
||||||
{
|
{
|
||||||
@@ -179,11 +184,27 @@ public class JellyfinProxyService
|
|||||||
var headerValue = header.Value.ToString();
|
var headerValue = header.Value.ToString();
|
||||||
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
|
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
|
||||||
authHeaderAdded = true;
|
authHeaderAdded = true;
|
||||||
_logger.LogInformation("✓ Forwarded X-Emby-Authorization: {Value}", headerValue);
|
_logger.LogTrace("Forwarded X-Emby-Authorization header");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try X-Emby-Token (simpler format used by some clients)
|
||||||
|
if (!authHeaderAdded)
|
||||||
|
{
|
||||||
|
foreach (var header in clientHeaders)
|
||||||
|
{
|
||||||
|
if (header.Key.Equals("X-Emby-Token", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var headerValue = header.Value.ToString();
|
||||||
|
request.Headers.TryAddWithoutValidation("X-Emby-Token", headerValue);
|
||||||
|
authHeaderAdded = true;
|
||||||
|
_logger.LogTrace("Forwarded X-Emby-Token header");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If no X-Emby-Authorization, check if Authorization header contains MediaBrowser format
|
// If no X-Emby-Authorization, check if Authorization header contains MediaBrowser format
|
||||||
// Some clients send it as "Authorization" instead of "X-Emby-Authorization"
|
// Some clients send it as "Authorization" instead of "X-Emby-Authorization"
|
||||||
if (!authHeaderAdded)
|
if (!authHeaderAdded)
|
||||||
@@ -201,37 +222,32 @@ public class JellyfinProxyService
|
|||||||
// Forward as X-Emby-Authorization (Jellyfin's expected header)
|
// Forward as X-Emby-Authorization (Jellyfin's expected header)
|
||||||
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
|
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
|
||||||
authHeaderAdded = true;
|
authHeaderAdded = true;
|
||||||
_logger.LogInformation("✓ Converted Authorization to X-Emby-Authorization: {Value}", headerValue);
|
_logger.LogTrace("Converted Authorization to X-Emby-Authorization");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Standard Bearer token - forward as-is
|
// Standard Bearer token - forward as-is
|
||||||
request.Headers.TryAddWithoutValidation("Authorization", headerValue);
|
request.Headers.TryAddWithoutValidation("Authorization", headerValue);
|
||||||
authHeaderAdded = true;
|
authHeaderAdded = true;
|
||||||
_logger.LogInformation("✓ Forwarded Authorization (Bearer): {Value}", headerValue);
|
_logger.LogTrace("Forwarded Authorization header");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only log warnings for non-browser static requests
|
// Check for api_key query parameter (some clients use this)
|
||||||
if (!authHeaderAdded && !isBrowserStaticRequest)
|
if (!authHeaderAdded && url.Contains("api_key=", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("✗ No auth header found. Available headers: {Headers}",
|
authHeaderAdded = true; // It's in the URL, no need to add header
|
||||||
string.Join(", ", clientHeaders.Select(h => $"{h.Key}={h.Value}")));
|
_logger.LogTrace("Using api_key from query string");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (!isBrowserStaticRequest)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("✗ No client headers provided for {Url}", url);
|
|
||||||
}
|
|
||||||
|
|
||||||
// DO NOT use server API key as fallback - let Jellyfin handle unauthenticated requests
|
// Only log warnings for non-public, non-browser requests without auth
|
||||||
// If client doesn't provide auth, they get what they deserve (401 from Jellyfin)
|
if (!authHeaderAdded && !isBrowserStaticRequest && !isPublicEndpoint)
|
||||||
if (!authHeaderAdded && !isBrowserStaticRequest)
|
|
||||||
{
|
{
|
||||||
_logger.LogInformation("No client auth provided for {Url} - forwarding without auth", url);
|
_logger.LogDebug("No client auth provided for {Url} - Jellyfin will handle authentication", url);
|
||||||
}
|
}
|
||||||
|
|
||||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||||
@@ -248,14 +264,28 @@ public class JellyfinProxyService
|
|||||||
{
|
{
|
||||||
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
|
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Jellyfin returned 401 Unauthorized for {Url} - passing through to client", url);
|
// 401 means token expired or invalid - client needs to re-authenticate
|
||||||
|
_logger.LogInformation("Jellyfin returned 401 Unauthorized for {Url} - client should re-authenticate", url);
|
||||||
}
|
}
|
||||||
else if (!isBrowserStaticRequest) // Don't log 404s for browser static requests
|
else if (!isBrowserStaticRequest && !isPublicEndpoint)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Jellyfin request failed: {StatusCode} for {Url}", response.StatusCode, url);
|
_logger.LogWarning("Jellyfin request failed: {StatusCode} for {Url}", response.StatusCode, url);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return null body with the actual status code
|
// Try to parse error response to pass through to client
|
||||||
|
if (!string.IsNullOrWhiteSpace(content))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var errorDoc = JsonDocument.Parse(content);
|
||||||
|
return (errorDoc, statusCode);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Not valid JSON, return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (null, statusCode);
|
return (null, statusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,8 +327,10 @@ public class JellyfinProxyService
|
|||||||
request.Content = new StringContent(bodyToSend, System.Text.Encoding.UTF8, "application/json");
|
request.Content = new StringContent(bodyToSend, System.Text.Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
bool authHeaderAdded = false;
|
bool authHeaderAdded = false;
|
||||||
|
bool isAuthEndpoint = endpoint.Contains("Authenticate", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
// Forward authentication headers from client (case-insensitive)
|
// Forward authentication headers from client (case-insensitive)
|
||||||
|
// Try X-Emby-Authorization first
|
||||||
foreach (var header in clientHeaders)
|
foreach (var header in clientHeaders)
|
||||||
{
|
{
|
||||||
if (header.Key.Equals("X-Emby-Authorization", StringComparison.OrdinalIgnoreCase))
|
if (header.Key.Equals("X-Emby-Authorization", StringComparison.OrdinalIgnoreCase))
|
||||||
@@ -306,11 +338,28 @@ public class JellyfinProxyService
|
|||||||
var headerValue = header.Value.ToString();
|
var headerValue = header.Value.ToString();
|
||||||
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
|
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
|
||||||
authHeaderAdded = true;
|
authHeaderAdded = true;
|
||||||
_logger.LogDebug("Forwarded X-Emby-Authorization from client");
|
_logger.LogTrace("Forwarded X-Emby-Authorization header");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try X-Emby-Token
|
||||||
|
if (!authHeaderAdded)
|
||||||
|
{
|
||||||
|
foreach (var header in clientHeaders)
|
||||||
|
{
|
||||||
|
if (header.Key.Equals("X-Emby-Token", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var headerValue = header.Value.ToString();
|
||||||
|
request.Headers.TryAddWithoutValidation("X-Emby-Token", headerValue);
|
||||||
|
authHeaderAdded = true;
|
||||||
|
_logger.LogTrace("Forwarded X-Emby-Token header");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try Authorization header
|
||||||
if (!authHeaderAdded)
|
if (!authHeaderAdded)
|
||||||
{
|
{
|
||||||
foreach (var header in clientHeaders)
|
foreach (var header in clientHeaders)
|
||||||
@@ -325,13 +374,13 @@ public class JellyfinProxyService
|
|||||||
{
|
{
|
||||||
// Forward as X-Emby-Authorization
|
// Forward as X-Emby-Authorization
|
||||||
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
|
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
|
||||||
_logger.LogDebug("Converted Authorization to X-Emby-Authorization");
|
_logger.LogTrace("Converted Authorization to X-Emby-Authorization");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Standard Bearer token
|
// Standard Bearer token
|
||||||
request.Headers.TryAddWithoutValidation("Authorization", headerValue);
|
request.Headers.TryAddWithoutValidation("Authorization", headerValue);
|
||||||
_logger.LogDebug("Forwarded Authorization header");
|
_logger.LogTrace("Forwarded Authorization header");
|
||||||
}
|
}
|
||||||
authHeaderAdded = true;
|
authHeaderAdded = true;
|
||||||
break;
|
break;
|
||||||
@@ -339,30 +388,23 @@ public class JellyfinProxyService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DO NOT use server credentials as fallback
|
// For authentication endpoints, credentials are in the body, not headers
|
||||||
// Exception: For auth endpoints, client provides their own credentials in the body
|
// For other endpoints without auth, let Jellyfin reject the request
|
||||||
// For all other endpoints, if client doesn't provide auth, let Jellyfin reject it
|
if (!authHeaderAdded && !isAuthEndpoint)
|
||||||
if (!authHeaderAdded)
|
|
||||||
{
|
{
|
||||||
_logger.LogInformation("No client auth provided for POST {Url} - forwarding without auth", url);
|
_logger.LogDebug("No client auth provided for POST {Url} - Jellyfin will handle authentication", url);
|
||||||
}
|
}
|
||||||
|
|
||||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||||
|
|
||||||
// DO NOT log the body for auth endpoints - it contains passwords!
|
// DO NOT log the body for auth endpoints - it contains passwords!
|
||||||
if (endpoint.Contains("Authenticate", StringComparison.OrdinalIgnoreCase))
|
if (isAuthEndpoint)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("POST to Jellyfin: {Url} (auth request - body not logged)", url);
|
_logger.LogDebug("POST to Jellyfin: {Url} (auth request - body not logged)", url);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogInformation("POST to Jellyfin: {Url}, body length: {Length} bytes", url, bodyToSend.Length);
|
_logger.LogTrace("POST to Jellyfin: {Url}, body length: {Length} bytes", url, bodyToSend.Length);
|
||||||
|
|
||||||
// Log body content for playback endpoints to debug
|
|
||||||
if (endpoint.Contains("Playing", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Sending body to Jellyfin: {Body}", bodyToSend);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var response = await _httpClient.SendAsync(request);
|
var response = await _httpClient.SendAsync(request);
|
||||||
@@ -372,15 +414,39 @@ public class JellyfinProxyService
|
|||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
var errorContent = await response.Content.ReadAsStringAsync();
|
var errorContent = await response.Content.ReadAsStringAsync();
|
||||||
_logger.LogWarning("❌ SESSION: Jellyfin POST request failed: {StatusCode} for {Url}. Response: {Response}",
|
|
||||||
response.StatusCode, url, errorContent);
|
// 401 is expected when tokens expire - don't spam logs
|
||||||
|
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Jellyfin POST returned 401 for {Url} - client should re-authenticate", url);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Jellyfin POST request failed: {StatusCode} for {Url}. Response: {Response}",
|
||||||
|
response.StatusCode, url, errorContent.Length > 200 ? errorContent[..200] + "..." : errorContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse error response as JSON to pass through to client
|
||||||
|
if (!string.IsNullOrWhiteSpace(errorContent))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var errorDoc = JsonDocument.Parse(errorContent);
|
||||||
|
return (errorDoc, statusCode);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Not valid JSON, return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (null, statusCode);
|
return (null, statusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log successful session-related responses
|
// Log successful session-related responses
|
||||||
if (endpoint.Contains("Sessions", StringComparison.OrdinalIgnoreCase))
|
if (endpoint.Contains("Sessions", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("✓ SESSION: Jellyfin responded {StatusCode} for {Endpoint}", statusCode, endpoint);
|
_logger.LogTrace("Jellyfin responded {StatusCode} for {Endpoint}", statusCode, endpoint);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle 204 No Content responses (e.g., /sessions/playing, /sessions/playing/progress)
|
// Handle 204 No Content responses (e.g., /sessions/playing, /sessions/playing/progress)
|
||||||
@@ -397,18 +463,12 @@ public class JellyfinProxyService
|
|||||||
return (null, statusCode);
|
return (null, statusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log response content for session endpoints
|
|
||||||
if (endpoint.Contains("Sessions", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(responseContent))
|
|
||||||
{
|
|
||||||
var preview = responseContent.Length > 200 ? responseContent[..200] + "..." : responseContent;
|
|
||||||
_logger.LogWarning("📥 SESSION: Jellyfin response body: {Body}", preview);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (JsonDocument.Parse(responseContent), statusCode);
|
return (JsonDocument.Parse(responseContent), statusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sends a GET request and returns raw bytes (for images, audio streams).
|
/// Sends a GET request and returns raw bytes (for images, audio streams).
|
||||||
|
/// WARNING: This loads entire response into memory - use StreamAsync for large files!
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<(byte[] Body, string? ContentType)> GetBytesAsync(string endpoint, Dictionary<string, string>? queryParams = null)
|
public async Task<(byte[] Body, string? ContentType)> GetBytesAsync(string endpoint, Dictionary<string, string>? queryParams = null)
|
||||||
{
|
{
|
||||||
@@ -423,9 +483,35 @@ public class JellyfinProxyService
|
|||||||
var body = await response.Content.ReadAsByteArrayAsync();
|
var body = await response.Content.ReadAsByteArrayAsync();
|
||||||
var contentType = response.Content.Headers.ContentType?.ToString();
|
var contentType = response.Content.Headers.ContentType?.ToString();
|
||||||
|
|
||||||
|
// Trigger GC for large files to prevent memory leaks
|
||||||
|
if (body.Length > 1024 * 1024) // 1MB threshold
|
||||||
|
{
|
||||||
|
GC.Collect(2, GCCollectionMode.Optimized, blocking: false);
|
||||||
|
}
|
||||||
|
|
||||||
return (body, contentType);
|
return (body, contentType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Streams content directly without loading into memory (for large files like audio).
|
||||||
|
/// </summary>
|
||||||
|
public async Task<(Stream Stream, string? ContentType, long? ContentLength)> GetStreamAsync(string endpoint, Dictionary<string, string>? queryParams = null)
|
||||||
|
{
|
||||||
|
var url = BuildUrl(endpoint, queryParams);
|
||||||
|
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
|
request.Headers.Add("Authorization", GetAuthorizationHeader());
|
||||||
|
|
||||||
|
var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var stream = await response.Content.ReadAsStreamAsync();
|
||||||
|
var contentType = response.Content.Headers.ContentType?.ToString();
|
||||||
|
var contentLength = response.Content.Headers.ContentLength;
|
||||||
|
|
||||||
|
return (stream, contentType, contentLength);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sends a DELETE request to the Jellyfin server.
|
/// Sends a DELETE request to the Jellyfin server.
|
||||||
/// Forwards client headers for authentication passthrough.
|
/// Forwards client headers for authentication passthrough.
|
||||||
@@ -940,4 +1026,43 @@ public class JellyfinProxyService
|
|||||||
|
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends a GET request to the Jellyfin server using the server's API key for internal operations.
|
||||||
|
/// This should only be used for server-side operations, not for proxying client requests.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<(JsonDocument? Body, int StatusCode)> GetJsonAsyncInternal(string endpoint, Dictionary<string, string>? queryParams = null)
|
||||||
|
{
|
||||||
|
var url = BuildUrl(endpoint, queryParams);
|
||||||
|
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
|
|
||||||
|
// Use server's API key for authentication
|
||||||
|
var authHeader = GetAuthorizationHeader();
|
||||||
|
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", authHeader);
|
||||||
|
|
||||||
|
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||||
|
|
||||||
|
var response = await _httpClient.SendAsync(request);
|
||||||
|
var statusCode = (int)response.StatusCode;
|
||||||
|
var content = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Jellyfin internal request returned {StatusCode} for {Url}: {Content}",
|
||||||
|
statusCode, url, content);
|
||||||
|
return (null, statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var jsonDocument = JsonDocument.Parse(content);
|
||||||
|
return (jsonDocument, statusCode);
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to parse JSON response from {Url}: {Content}", url, content);
|
||||||
|
return (null, statusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,25 +233,74 @@ public class JellyfinResponseBuilder
|
|||||||
{
|
{
|
||||||
// Add " [S]" suffix to external song titles (S = streaming source)
|
// Add " [S]" suffix to external song titles (S = streaming source)
|
||||||
var songTitle = song.Title;
|
var songTitle = song.Title;
|
||||||
|
var artistName = song.Artist;
|
||||||
|
var albumName = song.Album;
|
||||||
|
var artistNames = song.Artists.ToList();
|
||||||
|
|
||||||
if (!song.IsLocal)
|
if (!song.IsLocal)
|
||||||
{
|
{
|
||||||
songTitle = $"{song.Title} [S]";
|
songTitle = $"{song.Title} [S]";
|
||||||
|
|
||||||
|
// Also add [S] to artist and album names for consistency
|
||||||
|
if (!string.IsNullOrEmpty(artistName) && !artistName.EndsWith(" [S]"))
|
||||||
|
{
|
||||||
|
artistName = $"{artistName} [S]";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(albumName) && !albumName.EndsWith(" [S]"))
|
||||||
|
{
|
||||||
|
albumName = $"{albumName} [S]";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add [S] to all artist names in the list
|
||||||
|
artistNames = artistNames.Select(a =>
|
||||||
|
!string.IsNullOrEmpty(a) && !a.EndsWith(" [S]") ? $"{a} [S]" : a
|
||||||
|
).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
var item = new Dictionary<string, object?>
|
var item = new Dictionary<string, object?>
|
||||||
{
|
{
|
||||||
["Id"] = song.Id,
|
|
||||||
["Name"] = songTitle,
|
["Name"] = songTitle,
|
||||||
["ServerId"] = "allstarr",
|
["ServerId"] = "allstarr",
|
||||||
["Type"] = "Audio",
|
["Id"] = song.Id,
|
||||||
["MediaType"] = "Audio",
|
["HasLyrics"] = false, // Could be enhanced to check if lyrics exist
|
||||||
|
["Container"] = "flac",
|
||||||
|
["PremiereDate"] = song.Year.HasValue ? $"{song.Year}-01-01T00:00:00.0000000Z" : null,
|
||||||
|
["RunTimeTicks"] = (song.Duration ?? 0) * TimeSpan.TicksPerSecond,
|
||||||
|
["ProductionYear"] = song.Year,
|
||||||
|
["IndexNumber"] = song.Track,
|
||||||
|
["ParentIndexNumber"] = song.DiscNumber ?? 1,
|
||||||
["IsFolder"] = false,
|
["IsFolder"] = false,
|
||||||
["Album"] = song.Album,
|
["Type"] = "Audio",
|
||||||
["AlbumId"] = song.AlbumId ?? song.Id,
|
["ChannelId"] = (object?)null,
|
||||||
["AlbumArtist"] = song.AlbumArtist ?? song.Artist,
|
["Genres"] = !string.IsNullOrEmpty(song.Genre)
|
||||||
["Artists"] = song.Artists.Count > 0 ? song.Artists.ToArray() : new[] { song.Artist },
|
? new[] { song.Genre }
|
||||||
["ArtistItems"] = song.Artists.Count > 0
|
: new string[0],
|
||||||
? song.Artists.Select((name, index) => new Dictionary<string, object?>
|
["GenreItems"] = !string.IsNullOrEmpty(song.Genre)
|
||||||
|
? new[]
|
||||||
|
{
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["Name"] = song.Genre,
|
||||||
|
["Id"] = $"genre-{song.Genre?.ToLowerInvariant()}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: new Dictionary<string, object?>[0],
|
||||||
|
["ParentLogoItemId"] = song.AlbumId,
|
||||||
|
["ParentBackdropItemId"] = song.AlbumId,
|
||||||
|
["ParentBackdropImageTags"] = new string[0],
|
||||||
|
["UserData"] = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["PlaybackPositionTicks"] = 0,
|
||||||
|
["PlayCount"] = 0,
|
||||||
|
["IsFavorite"] = false,
|
||||||
|
["Played"] = false,
|
||||||
|
["Key"] = $"Audio-{song.Id}",
|
||||||
|
["ItemId"] = song.Id
|
||||||
|
},
|
||||||
|
["Artists"] = artistNames.Count > 0 ? artistNames.ToArray() : new[] { artistName ?? "" },
|
||||||
|
["ArtistItems"] = artistNames.Count > 0
|
||||||
|
? artistNames.Select((name, index) => new Dictionary<string, object?>
|
||||||
{
|
{
|
||||||
["Name"] = name,
|
["Name"] = name,
|
||||||
["Id"] = index == 0 && song.ArtistId != null
|
["Id"] = index == 0 && song.ArtistId != null
|
||||||
@@ -263,30 +312,32 @@ public class JellyfinResponseBuilder
|
|||||||
new Dictionary<string, object?>
|
new Dictionary<string, object?>
|
||||||
{
|
{
|
||||||
["Id"] = song.ArtistId ?? song.Id,
|
["Id"] = song.ArtistId ?? song.Id,
|
||||||
["Name"] = song.Artist
|
["Name"] = artistName ?? ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
["Album"] = albumName,
|
||||||
|
["AlbumId"] = song.AlbumId ?? song.Id,
|
||||||
|
["AlbumPrimaryImageTag"] = song.AlbumId ?? song.Id,
|
||||||
|
["AlbumArtist"] = song.AlbumArtist ?? artistName,
|
||||||
|
["AlbumArtists"] = new[]
|
||||||
|
{
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["Name"] = song.AlbumArtist ?? artistName ?? "",
|
||||||
|
["Id"] = song.ArtistId ?? song.Id
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
["IndexNumber"] = song.Track,
|
|
||||||
["ParentIndexNumber"] = song.DiscNumber ?? 1,
|
|
||||||
["ProductionYear"] = song.Year,
|
|
||||||
["RunTimeTicks"] = (song.Duration ?? 0) * TimeSpan.TicksPerSecond,
|
|
||||||
["ImageTags"] = new Dictionary<string, string>
|
["ImageTags"] = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
["Primary"] = song.Id
|
["Primary"] = song.Id
|
||||||
},
|
},
|
||||||
["BackdropImageTags"] = new string[0],
|
["BackdropImageTags"] = new string[0],
|
||||||
|
["ParentLogoImageTag"] = song.AlbumId ?? song.Id,
|
||||||
["ImageBlurHashes"] = new Dictionary<string, object>(),
|
["ImageBlurHashes"] = new Dictionary<string, object>(),
|
||||||
["LocationType"] = "FileSystem", // External content appears as local files to clients
|
["LocationType"] = "FileSystem",
|
||||||
["Path"] = $"/music/{song.Artist}/{song.Album}/{song.Title}.flac", // Fake path for client compatibility
|
["MediaType"] = "Audio",
|
||||||
["ChannelId"] = (object?)null, // Match Jellyfin structure
|
["NormalizationGain"] = 0.0,
|
||||||
["UserData"] = new Dictionary<string, object>
|
["Path"] = $"/music/{song.Artist}/{song.Album}/{song.Title}.flac",
|
||||||
{
|
|
||||||
["PlaybackPositionTicks"] = 0,
|
|
||||||
["PlayCount"] = 0,
|
|
||||||
["IsFavorite"] = false,
|
|
||||||
["Played"] = false,
|
|
||||||
["Key"] = $"Audio-{song.Id}"
|
|
||||||
},
|
|
||||||
["CanDownload"] = true,
|
["CanDownload"] = true,
|
||||||
["SupportsSync"] = true
|
["SupportsSync"] = true
|
||||||
};
|
};
|
||||||
@@ -305,21 +356,71 @@ public class JellyfinResponseBuilder
|
|||||||
providerIds["ISRC"] = song.Isrc;
|
providerIds["ISRC"] = song.Isrc;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add MediaSources with bitrate for external tracks
|
// Add MediaSources with complete structure matching real Jellyfin
|
||||||
item["MediaSources"] = new[]
|
item["MediaSources"] = new[]
|
||||||
{
|
{
|
||||||
new Dictionary<string, object?>
|
new Dictionary<string, object?>
|
||||||
{
|
{
|
||||||
|
["Protocol"] = "File",
|
||||||
["Id"] = song.Id,
|
["Id"] = song.Id,
|
||||||
|
["Path"] = $"/music/{song.Artist}/{song.Album}/{song.Title}.flac",
|
||||||
["Type"] = "Default",
|
["Type"] = "Default",
|
||||||
["Container"] = "flac",
|
["Container"] = "flac",
|
||||||
["Size"] = (song.Duration ?? 180) * 1337 * 128, // Approximate file size
|
["Size"] = (song.Duration ?? 180) * 1337 * 128,
|
||||||
["Bitrate"] = 1337000, // 1337 kbps in bps
|
["Name"] = song.Title,
|
||||||
["Path"] = $"/music/{song.Artist}/{song.Album}/{song.Title}.flac",
|
["IsRemote"] = false,
|
||||||
["Protocol"] = "File",
|
["ETag"] = song.Id, // Use song ID as ETag
|
||||||
["SupportsDirectStream"] = true,
|
["RunTimeTicks"] = (song.Duration ?? 180) * 10000000L,
|
||||||
|
["ReadAtNativeFramerate"] = false,
|
||||||
|
["IgnoreDts"] = false,
|
||||||
|
["IgnoreIndex"] = false,
|
||||||
|
["GenPtsInput"] = false,
|
||||||
["SupportsTranscoding"] = true,
|
["SupportsTranscoding"] = true,
|
||||||
["SupportsDirectPlay"] = true
|
["SupportsDirectStream"] = true,
|
||||||
|
["SupportsDirectPlay"] = true,
|
||||||
|
["IsInfiniteStream"] = false,
|
||||||
|
["UseMostCompatibleTranscodingProfile"] = false,
|
||||||
|
["RequiresOpening"] = false,
|
||||||
|
["RequiresClosing"] = false,
|
||||||
|
["RequiresLooping"] = false,
|
||||||
|
["SupportsProbing"] = true,
|
||||||
|
["MediaStreams"] = new[]
|
||||||
|
{
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["Codec"] = "flac",
|
||||||
|
["TimeBase"] = "1/44100",
|
||||||
|
["VideoRange"] = "Unknown",
|
||||||
|
["VideoRangeType"] = "Unknown",
|
||||||
|
["AudioSpatialFormat"] = "None",
|
||||||
|
["LocalizedDefault"] = "Default",
|
||||||
|
["LocalizedExternal"] = "External",
|
||||||
|
["DisplayTitle"] = "FLAC - Stereo",
|
||||||
|
["IsInterlaced"] = false,
|
||||||
|
["IsAVC"] = false,
|
||||||
|
["ChannelLayout"] = "stereo",
|
||||||
|
["BitRate"] = 1337000,
|
||||||
|
["BitDepth"] = 16,
|
||||||
|
["Channels"] = 2,
|
||||||
|
["SampleRate"] = 44100,
|
||||||
|
["IsDefault"] = false,
|
||||||
|
["IsForced"] = false,
|
||||||
|
["IsHearingImpaired"] = false,
|
||||||
|
["Type"] = "Audio",
|
||||||
|
["Index"] = 0,
|
||||||
|
["IsExternal"] = false,
|
||||||
|
["IsTextSubtitleStream"] = false,
|
||||||
|
["SupportsExternalStream"] = false,
|
||||||
|
["Level"] = 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
["MediaAttachments"] = new List<object>(),
|
||||||
|
["Formats"] = new List<string>(),
|
||||||
|
["Bitrate"] = 1337000,
|
||||||
|
["RequiredHttpHeaders"] = new Dictionary<string, string>(),
|
||||||
|
["TranscodingSubProtocol"] = "http",
|
||||||
|
["DefaultAudioStreamIndex"] = 0,
|
||||||
|
["HasSegments"] = false
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -329,11 +430,6 @@ public class JellyfinResponseBuilder
|
|||||||
item["MediaSources"] = song.JellyfinMetadata["MediaSources"];
|
item["MediaSources"] = song.JellyfinMetadata["MediaSources"];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(song.Genre))
|
|
||||||
{
|
|
||||||
item["Genres"] = new[] { song.Genre };
|
|
||||||
}
|
|
||||||
|
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,40 +447,68 @@ public class JellyfinResponseBuilder
|
|||||||
|
|
||||||
var item = new Dictionary<string, object?>
|
var item = new Dictionary<string, object?>
|
||||||
{
|
{
|
||||||
["Id"] = album.Id,
|
|
||||||
["Name"] = albumName,
|
["Name"] = albumName,
|
||||||
["ServerId"] = "allstarr",
|
["ServerId"] = "allstarr",
|
||||||
["Type"] = "MusicAlbum",
|
["Id"] = album.Id,
|
||||||
|
["PremiereDate"] = album.Year.HasValue ? $"{album.Year}-01-01T05:00:00.0000000Z" : null,
|
||||||
|
["ChannelId"] = (object?)null,
|
||||||
|
["Genres"] = !string.IsNullOrEmpty(album.Genre)
|
||||||
|
? new[] { album.Genre }
|
||||||
|
: new string[0],
|
||||||
|
["RunTimeTicks"] = 0, // Could calculate from songs
|
||||||
|
["ProductionYear"] = album.Year,
|
||||||
["IsFolder"] = true,
|
["IsFolder"] = true,
|
||||||
["AlbumArtist"] = album.Artist,
|
["Type"] = "MusicAlbum",
|
||||||
["AlbumArtists"] = new[]
|
["GenreItems"] = !string.IsNullOrEmpty(album.Genre)
|
||||||
|
? new[]
|
||||||
{
|
{
|
||||||
new Dictionary<string, object?>
|
new Dictionary<string, object?>
|
||||||
{
|
{
|
||||||
["Id"] = album.ArtistId ?? album.Id,
|
["Name"] = album.Genre,
|
||||||
["Name"] = album.Artist
|
["Id"] = $"genre-{album.Genre?.ToLowerInvariant()}"
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
["ProductionYear"] = album.Year,
|
: new Dictionary<string, object?>[0],
|
||||||
["ChildCount"] = album.SongCount ?? album.Songs.Count,
|
["ParentLogoItemId"] = album.ArtistId ?? album.Id,
|
||||||
["ImageTags"] = new Dictionary<string, string>
|
["ParentBackdropItemId"] = album.ArtistId ?? album.Id,
|
||||||
{
|
["ParentBackdropImageTags"] = new string[0],
|
||||||
["Primary"] = album.Id
|
|
||||||
},
|
|
||||||
["BackdropImageTags"] = new string[0],
|
|
||||||
["ImageBlurHashes"] = new Dictionary<string, object>(),
|
|
||||||
["LocationType"] = "FileSystem",
|
|
||||||
["MediaType"] = (object?)null,
|
|
||||||
["ChannelId"] = (object?)null,
|
|
||||||
["CollectionType"] = (object?)null,
|
|
||||||
["UserData"] = new Dictionary<string, object>
|
["UserData"] = new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
["PlaybackPositionTicks"] = 0,
|
["PlaybackPositionTicks"] = 0,
|
||||||
["PlayCount"] = 0,
|
["PlayCount"] = 0,
|
||||||
["IsFavorite"] = false,
|
["IsFavorite"] = false,
|
||||||
["Played"] = false,
|
["Played"] = false,
|
||||||
["Key"] = album.Id
|
["Key"] = $"{album.Artist}-{album.Title}",
|
||||||
|
["ItemId"] = album.Id
|
||||||
|
},
|
||||||
|
["Artists"] = new[] { album.Artist },
|
||||||
|
["ArtistItems"] = new[]
|
||||||
|
{
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["Name"] = album.Artist,
|
||||||
|
["Id"] = album.ArtistId ?? album.Id
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
["AlbumArtist"] = album.Artist,
|
||||||
|
["AlbumArtists"] = new[]
|
||||||
|
{
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["Name"] = album.Artist,
|
||||||
|
["Id"] = album.ArtistId ?? album.Id
|
||||||
|
}
|
||||||
|
},
|
||||||
|
["ImageTags"] = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["Primary"] = album.Id
|
||||||
|
},
|
||||||
|
["BackdropImageTags"] = new string[0],
|
||||||
|
["ParentLogoImageTag"] = album.ArtistId ?? album.Id,
|
||||||
|
["ImageBlurHashes"] = new Dictionary<string, object>(),
|
||||||
|
["LocationType"] = "FileSystem",
|
||||||
|
["MediaType"] = "Unknown",
|
||||||
|
["ChildCount"] = album.SongCount ?? album.Songs.Count
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add provider IDs for external content
|
// Add provider IDs for external content
|
||||||
@@ -396,11 +520,6 @@ public class JellyfinResponseBuilder
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(album.Genre))
|
|
||||||
{
|
|
||||||
item["Genres"] = new[] { album.Genre };
|
|
||||||
}
|
|
||||||
|
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,30 +537,33 @@ public class JellyfinResponseBuilder
|
|||||||
|
|
||||||
var item = new Dictionary<string, object?>
|
var item = new Dictionary<string, object?>
|
||||||
{
|
{
|
||||||
["Id"] = artist.Id,
|
|
||||||
["Name"] = artistName,
|
["Name"] = artistName,
|
||||||
["ServerId"] = "allstarr",
|
["ServerId"] = "allstarr",
|
||||||
["Type"] = "MusicArtist",
|
["Id"] = artist.Id,
|
||||||
|
["ChannelId"] = (object?)null,
|
||||||
|
["Genres"] = new string[0], // Artists aggregate genres from albums/tracks
|
||||||
|
["RunTimeTicks"] = 0,
|
||||||
["IsFolder"] = true,
|
["IsFolder"] = true,
|
||||||
["AlbumCount"] = artist.AlbumCount ?? 0,
|
["Type"] = "MusicArtist",
|
||||||
["ImageTags"] = new Dictionary<string, string>
|
["GenreItems"] = new Dictionary<string, object?>[0],
|
||||||
{
|
|
||||||
["Primary"] = artist.Id
|
|
||||||
},
|
|
||||||
["BackdropImageTags"] = new string[0],
|
|
||||||
["ImageBlurHashes"] = new Dictionary<string, object>(),
|
|
||||||
["LocationType"] = "FileSystem", // External content appears as local files to clients
|
|
||||||
["MediaType"] = (object?)null, // Match Jellyfin structure
|
|
||||||
["ChannelId"] = (object?)null, // Match Jellyfin structure
|
|
||||||
["CollectionType"] = (object?)null, // Match Jellyfin structure
|
|
||||||
["UserData"] = new Dictionary<string, object>
|
["UserData"] = new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
["PlaybackPositionTicks"] = 0,
|
["PlaybackPositionTicks"] = 0,
|
||||||
["PlayCount"] = 0,
|
["PlayCount"] = 0,
|
||||||
["IsFavorite"] = false,
|
["IsFavorite"] = false,
|
||||||
["Played"] = false,
|
["Played"] = false,
|
||||||
["Key"] = artist.Id
|
["Key"] = $"Artist-{artist.Name}",
|
||||||
}
|
["ItemId"] = artist.Id
|
||||||
|
},
|
||||||
|
["ImageTags"] = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["Primary"] = artist.Id
|
||||||
|
},
|
||||||
|
["BackdropImageTags"] = new string[0],
|
||||||
|
["ImageBlurHashes"] = new Dictionary<string, object>(),
|
||||||
|
["LocationType"] = "FileSystem",
|
||||||
|
["MediaType"] = "Unknown",
|
||||||
|
["AlbumCount"] = artist.AlbumCount ?? 0
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add provider IDs for external content
|
// Add provider IDs for external content
|
||||||
@@ -478,7 +600,7 @@ public class JellyfinResponseBuilder
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Converts an ExternalPlaylist to a Jellyfin album item.
|
/// Converts an ExternalPlaylist to a Jellyfin playlist item.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Dictionary<string, object?> ConvertPlaylistToJellyfinItem(ExternalPlaylist playlist)
|
public Dictionary<string, object?> ConvertPlaylistToJellyfinItem(ExternalPlaylist playlist)
|
||||||
{
|
{
|
||||||
@@ -488,13 +610,24 @@ public class JellyfinResponseBuilder
|
|||||||
|
|
||||||
var item = new Dictionary<string, object?>
|
var item = new Dictionary<string, object?>
|
||||||
{
|
{
|
||||||
["Id"] = playlist.Id,
|
|
||||||
["Name"] = playlist.Name,
|
["Name"] = playlist.Name,
|
||||||
["ServerId"] = "allstarr",
|
["ServerId"] = "allstarr",
|
||||||
["Type"] = "Playlist",
|
["Id"] = playlist.Id,
|
||||||
|
["ChannelId"] = (object?)null,
|
||||||
|
["Genres"] = new string[0], // Playlists aggregate genres from tracks
|
||||||
|
["RunTimeTicks"] = playlist.Duration * TimeSpan.TicksPerSecond,
|
||||||
["IsFolder"] = true,
|
["IsFolder"] = true,
|
||||||
["AlbumArtist"] = curatorName,
|
["Type"] = "Playlist",
|
||||||
["Genres"] = new[] { "Playlist" },
|
["GenreItems"] = new Dictionary<string, object?>[0],
|
||||||
|
["UserData"] = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["PlaybackPositionTicks"] = 0,
|
||||||
|
["PlayCount"] = 0,
|
||||||
|
["IsFavorite"] = false,
|
||||||
|
["Played"] = false,
|
||||||
|
["Key"] = playlist.Id,
|
||||||
|
["ItemId"] = playlist.Id
|
||||||
|
},
|
||||||
["ChildCount"] = playlist.TrackCount,
|
["ChildCount"] = playlist.TrackCount,
|
||||||
["ImageTags"] = new Dictionary<string, string>
|
["ImageTags"] = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
@@ -503,20 +636,10 @@ public class JellyfinResponseBuilder
|
|||||||
["BackdropImageTags"] = new string[0],
|
["BackdropImageTags"] = new string[0],
|
||||||
["ImageBlurHashes"] = new Dictionary<string, object>(),
|
["ImageBlurHashes"] = new Dictionary<string, object>(),
|
||||||
["LocationType"] = "FileSystem",
|
["LocationType"] = "FileSystem",
|
||||||
["MediaType"] = (object?)null,
|
["MediaType"] = "Audio",
|
||||||
["ChannelId"] = (object?)null,
|
|
||||||
["CollectionType"] = (object?)null,
|
|
||||||
["ProviderIds"] = new Dictionary<string, string>
|
["ProviderIds"] = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
[playlist.Provider] = playlist.ExternalId
|
[playlist.Provider] = playlist.ExternalId
|
||||||
},
|
|
||||||
["UserData"] = new Dictionary<string, object>
|
|
||||||
{
|
|
||||||
["PlaybackPositionTicks"] = 0,
|
|
||||||
["PlayCount"] = 0,
|
|
||||||
["IsFavorite"] = false,
|
|
||||||
["Played"] = false,
|
|
||||||
["Key"] = playlist.Id
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -38,12 +38,13 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Ensures a session exists for the given device. Creates one if needed.
|
/// Ensures a session exists for the given device. Creates one if needed.
|
||||||
|
/// Returns false if token is expired (401), indicating client needs to re-authenticate.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<bool> EnsureSessionAsync(string deviceId, string client, string device, string version, IHeaderDictionary headers)
|
public async Task<bool> EnsureSessionAsync(string deviceId, string client, string device, string version, IHeaderDictionary headers)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(deviceId))
|
if (string.IsNullOrEmpty(deviceId))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("⚠️ SESSION: Cannot create session - no device ID");
|
_logger.LogWarning("Cannot create session - no device ID");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,25 +52,37 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
if (_sessions.TryGetValue(deviceId, out var existingSession))
|
if (_sessions.TryGetValue(deviceId, out var existingSession))
|
||||||
{
|
{
|
||||||
existingSession.LastActivity = DateTime.UtcNow;
|
existingSession.LastActivity = DateTime.UtcNow;
|
||||||
_logger.LogDebug("✓ SESSION: Session already exists for device {DeviceId}", deviceId);
|
_logger.LogTrace("Session already exists for device {DeviceId}", deviceId);
|
||||||
|
|
||||||
// Refresh capabilities to keep session alive
|
// Refresh capabilities to keep session alive
|
||||||
await PostCapabilitiesAsync(headers);
|
// If this returns false (401), the token expired and client needs to re-auth
|
||||||
|
var success = await PostCapabilitiesAsync(headers);
|
||||||
|
if (!success)
|
||||||
|
{
|
||||||
|
// Token expired - remove the stale session
|
||||||
|
_logger.LogInformation("Token expired for device {DeviceId} - removing session", deviceId);
|
||||||
|
await RemoveSessionAsync(deviceId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("🔧 SESSION: Creating new session for device: {DeviceId} ({Client} on {Device})", deviceId, client, device);
|
_logger.LogDebug("Creating new session for device: {DeviceId} ({Client} on {Device})", deviceId, client, device);
|
||||||
|
|
||||||
// Log the headers we received for debugging
|
|
||||||
_logger.LogDebug("🔍 SESSION: Headers received for session creation: {Headers}",
|
|
||||||
string.Join(", ", headers.Select(h => $"{h.Key}={h.Value.ToString().Substring(0, Math.Min(30, h.Value.ToString().Length))}...")));
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Post session capabilities to Jellyfin - this creates the session
|
// Post session capabilities to Jellyfin - this creates the session
|
||||||
await PostCapabilitiesAsync(headers);
|
var success = await PostCapabilitiesAsync(headers);
|
||||||
|
|
||||||
_logger.LogInformation("✓ SESSION: Session created for {DeviceId}", deviceId);
|
if (!success)
|
||||||
|
{
|
||||||
|
// Token expired or invalid - client needs to re-authenticate
|
||||||
|
_logger.LogInformation("Failed to create session for {DeviceId} - token may be expired", deviceId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("Session created for {DeviceId}", deviceId);
|
||||||
|
|
||||||
// Track this session
|
// Track this session
|
||||||
_sessions[deviceId] = new SessionInfo
|
_sessions[deviceId] = new SessionInfo
|
||||||
@@ -89,15 +102,16 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "❌ SESSION: Error creating session for {DeviceId}", deviceId);
|
_logger.LogError(ex, "Error creating session for {DeviceId}", deviceId);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Posts session capabilities to Jellyfin.
|
/// Posts session capabilities to Jellyfin.
|
||||||
|
/// Returns true if successful, false if token expired (401).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task PostCapabilitiesAsync(IHeaderDictionary headers)
|
private async Task<bool> PostCapabilitiesAsync(IHeaderDictionary headers)
|
||||||
{
|
{
|
||||||
var capabilities = new
|
var capabilities = new
|
||||||
{
|
{
|
||||||
@@ -118,12 +132,19 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
|
|
||||||
if (statusCode == 204 || statusCode == 200)
|
if (statusCode == 204 || statusCode == 200)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("✓ SESSION: Posted capabilities successfully ({StatusCode})", statusCode);
|
_logger.LogTrace("Posted capabilities successfully ({StatusCode})", statusCode);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else if (statusCode == 401)
|
||||||
|
{
|
||||||
|
// Token expired - this is expected, client needs to re-authenticate
|
||||||
|
_logger.LogDebug("Capabilities returned 401 (token expired) - client should re-authenticate");
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// 401 is common when cached headers have expired - not a critical error
|
_logger.LogDebug("Capabilities post returned {StatusCode}", statusCode);
|
||||||
_logger.LogDebug("SESSION: Capabilities post returned {StatusCode} (may be expected if token expired)", statusCode);
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +164,79 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates the currently playing item for a session (for scrobbling on cleanup).
|
||||||
|
/// </summary>
|
||||||
|
public void UpdatePlayingItem(string deviceId, string? itemId, long? positionTicks)
|
||||||
|
{
|
||||||
|
if (_sessions.TryGetValue(deviceId, out var session))
|
||||||
|
{
|
||||||
|
session.LastPlayingItemId = itemId;
|
||||||
|
session.LastPlayingPositionTicks = positionTicks;
|
||||||
|
session.LastActivity = DateTime.UtcNow;
|
||||||
|
_logger.LogDebug("🎵 SESSION: Updated playing item for {DeviceId}: {ItemId} at {Position}",
|
||||||
|
deviceId, itemId, positionTicks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marks a session as potentially ended (e.g., after playback stops).
|
||||||
|
/// The session will be cleaned up if no new activity occurs within the timeout.
|
||||||
|
/// </summary>
|
||||||
|
public void MarkSessionPotentiallyEnded(string deviceId, TimeSpan timeout)
|
||||||
|
{
|
||||||
|
if (_sessions.TryGetValue(deviceId, out var session))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("⏰ SESSION: Marking session {DeviceId} as potentially ended, will cleanup in {Seconds}s if no activity",
|
||||||
|
deviceId, timeout.TotalSeconds);
|
||||||
|
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
var markedTime = DateTime.UtcNow;
|
||||||
|
await Task.Delay(timeout);
|
||||||
|
|
||||||
|
// Check if there's been activity since we marked it
|
||||||
|
if (_sessions.TryGetValue(deviceId, out var currentSession) &&
|
||||||
|
currentSession.LastActivity <= markedTime)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("🧹 SESSION: Auto-removing inactive session {DeviceId} after playback stop", deviceId);
|
||||||
|
await RemoveSessionAsync(deviceId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogDebug("✓ SESSION: Session {DeviceId} had activity, keeping alive", deviceId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets information about current active sessions for debugging.
|
||||||
|
/// </summary>
|
||||||
|
public object GetSessionsInfo()
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var sessions = _sessions.Values.Select(s => new
|
||||||
|
{
|
||||||
|
DeviceId = s.DeviceId,
|
||||||
|
Client = s.Client,
|
||||||
|
Device = s.Device,
|
||||||
|
Version = s.Version,
|
||||||
|
LastActivity = s.LastActivity,
|
||||||
|
InactiveMinutes = Math.Round((now - s.LastActivity).TotalMinutes, 1),
|
||||||
|
HasWebSocket = s.WebSocket != null,
|
||||||
|
WebSocketState = s.WebSocket?.State.ToString() ?? "None"
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
TotalSessions = sessions.Count,
|
||||||
|
ActiveSessions = sessions.Count(s => s.InactiveMinutes < 2),
|
||||||
|
StaleSessions = sessions.Count(s => s.InactiveMinutes >= 2),
|
||||||
|
Sessions = sessions.OrderBy(s => s.InactiveMinutes)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Removes a session when the client disconnects.
|
/// Removes a session when the client disconnects.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -150,7 +244,7 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
{
|
{
|
||||||
if (_sessions.TryRemove(deviceId, out var session))
|
if (_sessions.TryRemove(deviceId, out var session))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("🗑️ SESSION: Removing session for device {DeviceId}", deviceId);
|
_logger.LogDebug("🗑️ SESSION: Removing session for device {DeviceId}", deviceId);
|
||||||
|
|
||||||
// Close WebSocket if it exists
|
// Close WebSocket if it exists
|
||||||
if (session.WebSocket != null && session.WebSocket.State == WebSocketState.Open)
|
if (session.WebSocket != null && session.WebSocket.State == WebSocketState.Open)
|
||||||
@@ -162,7 +256,7 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "⚠️ WEBSOCKET: Error closing WebSocket for {DeviceId}", deviceId);
|
_logger.LogDebug(ex, "WEBSOCKET: Error closing WebSocket for {DeviceId}", deviceId);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -172,8 +266,21 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Optionally notify Jellyfin that the session is ending
|
// Report playback stopped to Jellyfin if we have a playing item (for scrobbling)
|
||||||
// (Jellyfin will auto-cleanup inactive sessions anyway)
|
if (!string.IsNullOrEmpty(session.LastPlayingItemId))
|
||||||
|
{
|
||||||
|
var stopPayload = new
|
||||||
|
{
|
||||||
|
ItemId = session.LastPlayingItemId,
|
||||||
|
PositionTicks = session.LastPlayingPositionTicks ?? 0
|
||||||
|
};
|
||||||
|
var stopJson = JsonSerializer.Serialize(stopPayload);
|
||||||
|
await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", stopJson, session.Headers);
|
||||||
|
_logger.LogDebug("🛑 SESSION: Reported playback stopped for {DeviceId} (ItemId: {ItemId}, Position: {Position})",
|
||||||
|
deviceId, session.LastPlayingItemId, session.LastPlayingPositionTicks);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify Jellyfin that the session is ending
|
||||||
await _proxyService.PostJsonAsync("Sessions/Logout", "{}", session.Headers);
|
await _proxyService.PostJsonAsync("Sessions/Logout", "{}", session.Headers);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -212,20 +319,23 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
webSocket = new ClientWebSocket();
|
webSocket = new ClientWebSocket();
|
||||||
session.WebSocket = webSocket;
|
session.WebSocket = webSocket;
|
||||||
|
|
||||||
|
// Use stored session headers instead of parameter (parameter might be disposed)
|
||||||
|
var sessionHeaders = session.Headers;
|
||||||
|
|
||||||
// Log available headers for debugging
|
// Log available headers for debugging
|
||||||
_logger.LogDebug("🔍 WEBSOCKET: Available headers for {DeviceId}: {Headers}",
|
_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
|
// Forward authentication headers from the CLIENT - this is critical for session to appear under the right user
|
||||||
bool authFound = false;
|
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());
|
webSocket.Options.SetRequestHeader("X-Emby-Authorization", embyAuth.ToString());
|
||||||
_logger.LogDebug("🔑 WEBSOCKET: Using X-Emby-Authorization for {DeviceId}: {Auth}",
|
_logger.LogDebug("🔑 WEBSOCKET: Using X-Emby-Authorization for {DeviceId}: {Auth}",
|
||||||
deviceId, embyAuth.ToString().Length > 50 ? embyAuth.ToString()[..50] + "..." : embyAuth.ToString());
|
deviceId, embyAuth.ToString().Length > 50 ? embyAuth.ToString()[..50] + "..." : embyAuth.ToString());
|
||||||
authFound = true;
|
authFound = true;
|
||||||
}
|
}
|
||||||
else if (headers.TryGetValue("Authorization", out var auth))
|
else if (sessionHeaders.TryGetValue("Authorization", out var auth))
|
||||||
{
|
{
|
||||||
var authValue = auth.ToString();
|
var authValue = auth.ToString();
|
||||||
if (authValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase))
|
if (authValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase))
|
||||||
@@ -250,11 +360,11 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
if (!string.IsNullOrEmpty(_settings.ApiKey))
|
if (!string.IsNullOrEmpty(_settings.ApiKey))
|
||||||
{
|
{
|
||||||
jellyfinWsUrl += $"?api_key={_settings.ApiKey}";
|
jellyfinWsUrl += $"?api_key={_settings.ApiKey}";
|
||||||
_logger.LogWarning("⚠️ WEBSOCKET: No client auth found in headers, falling back to server API key for {DeviceId}", deviceId);
|
_logger.LogDebug("WEBSOCKET: No client auth found in headers, falling back to server API key for {DeviceId}", deviceId);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogWarning("❌ WEBSOCKET: No authentication available for {DeviceId}!", deviceId);
|
_logger.LogWarning("❌ WEBSOCKET: No authentication available for {DeviceId} - WebSocket will fail", deviceId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,7 +375,7 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
|
|
||||||
// Connect to Jellyfin
|
// Connect to Jellyfin
|
||||||
await webSocket.ConnectAsync(new Uri(jellyfinWsUrl), CancellationToken.None);
|
await webSocket.ConnectAsync(new Uri(jellyfinWsUrl), CancellationToken.None);
|
||||||
_logger.LogInformation("✓ WEBSOCKET: Connected to Jellyfin for device {DeviceId}", deviceId);
|
_logger.LogDebug("✓ WEBSOCKET: Connected to Jellyfin for device {DeviceId}", deviceId);
|
||||||
|
|
||||||
// CRITICAL: Send ForceKeepAlive message to initialize session in Jellyfin
|
// CRITICAL: Send ForceKeepAlive message to initialize session in Jellyfin
|
||||||
// This tells Jellyfin to create/show the session in the dashboard
|
// This tells Jellyfin to create/show the session in the dashboard
|
||||||
@@ -321,8 +431,8 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Log other message types at info level
|
// Log other message types at trace level
|
||||||
_logger.LogInformation("📥 WEBSOCKET: {DeviceId}: {Message}",
|
_logger.LogTrace("📥 WEBSOCKET: {DeviceId}: {Message}",
|
||||||
deviceId, message.Length > 100 ? message[..100] + "..." : message);
|
deviceId, message.Length > 100 ? message[..100] + "..." : message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -344,7 +454,7 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
}
|
}
|
||||||
catch (WebSocketException wsEx)
|
catch (WebSocketException wsEx)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(wsEx, "⚠️ WEBSOCKET: WebSocket error for device {DeviceId}", deviceId);
|
_logger.LogDebug(wsEx, "WEBSOCKET: Connection closed for device {DeviceId}", deviceId);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -380,6 +490,7 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Periodically pings Jellyfin to keep sessions alive.
|
/// Periodically pings Jellyfin to keep sessions alive.
|
||||||
/// Note: This is a backup mechanism. The WebSocket connection is the primary keep-alive.
|
/// Note: This is a backup mechanism. The WebSocket connection is the primary keep-alive.
|
||||||
|
/// Removes sessions with expired tokens (401 responses).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async void KeepSessionsAlive(object? state)
|
private async void KeepSessionsAlive(object? state)
|
||||||
{
|
{
|
||||||
@@ -391,29 +502,45 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogDebug("💓 SESSION: Keeping {Count} sessions alive", activeSessions.Count);
|
_logger.LogTrace("Keeping {Count} sessions alive", activeSessions.Count);
|
||||||
|
|
||||||
|
var expiredSessions = new List<string>();
|
||||||
|
|
||||||
foreach (var session in activeSessions)
|
foreach (var session in activeSessions)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Post capabilities again to keep session alive
|
// Post capabilities again to keep session alive
|
||||||
// Note: This may fail with 401 if the client's token has expired
|
// If this returns false (401), the token has expired
|
||||||
// That's okay - the WebSocket connection keeps the session alive anyway
|
var success = await PostCapabilitiesAsync(session.Headers);
|
||||||
await PostCapabilitiesAsync(session.Headers);
|
|
||||||
|
if (!success)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Token expired for device {DeviceId} during keep-alive - marking for removal", session.DeviceId);
|
||||||
|
expiredSessions.Add(session.DeviceId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogDebug(ex, "SESSION: Error keeping session alive for {DeviceId} (WebSocket still active)", session.DeviceId);
|
_logger.LogDebug(ex, "Error keeping session alive for {DeviceId}", session.DeviceId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up stale sessions (inactive for > 10 minutes)
|
// Remove sessions with expired tokens
|
||||||
var staleSessions = _sessions.Where(kvp => now - kvp.Value.LastActivity > TimeSpan.FromMinutes(10)).ToList();
|
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)
|
foreach (var stale in staleSessions)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("🧹 SESSION: Removing stale session for {DeviceId}", stale.Key);
|
_logger.LogDebug("Removing stale session for {DeviceId} (inactive for {Minutes:F1} minutes)",
|
||||||
_sessions.TryRemove(stale.Key, out _);
|
stale.Key, (now - stale.Value.LastActivity).TotalMinutes);
|
||||||
|
await RemoveSessionAsync(stale.Key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,6 +563,8 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
public DateTime LastActivity { get; set; }
|
public DateTime LastActivity { get; set; }
|
||||||
public required IHeaderDictionary Headers { get; init; }
|
public required IHeaderDictionary Headers { get; init; }
|
||||||
public ClientWebSocket? WebSocket { get; set; }
|
public ClientWebSocket? WebSocket { get; set; }
|
||||||
|
public string? LastPlayingItemId { get; set; }
|
||||||
|
public long? LastPlayingPositionTicks { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
|||||||
@@ -30,9 +30,42 @@ public class LrclibService
|
|||||||
|
|
||||||
public async Task<LyricsInfo?> GetLyricsAsync(string trackName, string[] artistNames, string albumName, int durationSeconds)
|
public async Task<LyricsInfo?> GetLyricsAsync(string trackName, string[] artistNames, string albumName, int durationSeconds)
|
||||||
{
|
{
|
||||||
|
// Validate input parameters
|
||||||
|
if (string.IsNullOrWhiteSpace(trackName) || artistNames == null || artistNames.Length == 0)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Invalid parameters for lyrics search: trackName={TrackName}, artistCount={ArtistCount}",
|
||||||
|
trackName, artistNames?.Length ?? 0);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
var artistName = string.Join(", ", artistNames);
|
var artistName = string.Join(", ", artistNames);
|
||||||
var cacheKey = $"lyrics:{artistName}:{trackName}:{albumName}:{durationSeconds}";
|
var cacheKey = $"lyrics:{artistName}:{trackName}:{albumName}:{durationSeconds}";
|
||||||
|
|
||||||
|
// FIRST: Check for manual lyrics mapping
|
||||||
|
var manualMappingKey = $"lyrics:manual-map:{artistName}:{trackName}";
|
||||||
|
var manualLyricsIdStr = await _cache.GetStringAsync(manualMappingKey);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(manualLyricsIdStr) && int.TryParse(manualLyricsIdStr, out var manualLyricsId) && manualLyricsId > 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("✓ Manual lyrics mapping found for {Artist} - {Track}: Lyrics ID {Id}",
|
||||||
|
artistName, trackName, manualLyricsId);
|
||||||
|
|
||||||
|
// Fetch lyrics by ID
|
||||||
|
var manualLyrics = await GetLyricsByIdAsync(manualLyricsId);
|
||||||
|
if (manualLyrics != null && !string.IsNullOrEmpty(manualLyrics.PlainLyrics))
|
||||||
|
{
|
||||||
|
// Cache the lyrics using the standard cache key
|
||||||
|
await _cache.SetAsync(cacheKey, manualLyrics.PlainLyrics!);
|
||||||
|
return manualLyrics;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Manual lyrics mapping points to invalid ID {Id} for {Artist} - {Track}",
|
||||||
|
manualLyricsId, artistName, trackName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SECOND: Check standard cache
|
||||||
var cached = await _cache.GetStringAsync(cacheKey);
|
var cached = await _cache.GetStringAsync(cacheKey);
|
||||||
if (!string.IsNullOrEmpty(cached))
|
if (!string.IsNullOrEmpty(cached))
|
||||||
{
|
{
|
||||||
@@ -87,7 +120,7 @@ public class LrclibService
|
|||||||
var artistCountBonus = resultArtistCount == expectedArtistCount ? 50.0 : 0.0;
|
var artistCountBonus = resultArtistCount == expectedArtistCount ? 50.0 : 0.0;
|
||||||
|
|
||||||
// Duration match (within 5 seconds is good)
|
// Duration match (within 5 seconds is good)
|
||||||
var durationDiff = Math.Abs(result.Duration - durationSeconds);
|
var durationDiff = result.Duration.HasValue ? Math.Abs(result.Duration.Value - durationSeconds) : 999;
|
||||||
var durationScore = durationDiff <= 5 ? 100.0 : Math.Max(0, 100 - (durationDiff * 2));
|
var durationScore = durationDiff <= 5 ? 100.0 : Math.Max(0, 100 - (durationDiff * 2));
|
||||||
|
|
||||||
// Bonus for having synced lyrics (prefer synced over plain)
|
// Bonus for having synced lyrics (prefer synced over plain)
|
||||||
@@ -118,7 +151,7 @@ public class LrclibService
|
|||||||
TrackName = bestMatch.TrackName ?? trackName,
|
TrackName = bestMatch.TrackName ?? trackName,
|
||||||
ArtistName = bestMatch.ArtistName ?? artistName,
|
ArtistName = bestMatch.ArtistName ?? artistName,
|
||||||
AlbumName = bestMatch.AlbumName ?? albumName,
|
AlbumName = bestMatch.AlbumName ?? albumName,
|
||||||
Duration = (int)Math.Round(bestMatch.Duration),
|
Duration = bestMatch.Duration.HasValue ? (int)Math.Round(bestMatch.Duration.Value) : durationSeconds,
|
||||||
Instrumental = bestMatch.Instrumental,
|
Instrumental = bestMatch.Instrumental,
|
||||||
PlainLyrics = bestMatch.PlainLyrics,
|
PlainLyrics = bestMatch.PlainLyrics,
|
||||||
SyncedLyrics = bestMatch.SyncedLyrics
|
SyncedLyrics = bestMatch.SyncedLyrics
|
||||||
@@ -167,7 +200,7 @@ public class LrclibService
|
|||||||
TrackName = lyrics.TrackName ?? trackName,
|
TrackName = lyrics.TrackName ?? trackName,
|
||||||
ArtistName = lyrics.ArtistName ?? artistName,
|
ArtistName = lyrics.ArtistName ?? artistName,
|
||||||
AlbumName = lyrics.AlbumName ?? albumName,
|
AlbumName = lyrics.AlbumName ?? albumName,
|
||||||
Duration = (int)Math.Round(lyrics.Duration),
|
Duration = lyrics.Duration.HasValue ? (int)Math.Round(lyrics.Duration.Value) : durationSeconds,
|
||||||
Instrumental = lyrics.Instrumental,
|
Instrumental = lyrics.Instrumental,
|
||||||
PlainLyrics = lyrics.PlainLyrics,
|
PlainLyrics = lyrics.PlainLyrics,
|
||||||
SyncedLyrics = lyrics.SyncedLyrics
|
SyncedLyrics = lyrics.SyncedLyrics
|
||||||
@@ -309,7 +342,7 @@ public class LrclibService
|
|||||||
TrackName = lyrics.TrackName ?? trackName,
|
TrackName = lyrics.TrackName ?? trackName,
|
||||||
ArtistName = lyrics.ArtistName ?? artistName,
|
ArtistName = lyrics.ArtistName ?? artistName,
|
||||||
AlbumName = lyrics.AlbumName ?? albumName,
|
AlbumName = lyrics.AlbumName ?? albumName,
|
||||||
Duration = (int)Math.Round(lyrics.Duration),
|
Duration = lyrics.Duration.HasValue ? (int)Math.Round(lyrics.Duration.Value) : durationSeconds,
|
||||||
Instrumental = lyrics.Instrumental,
|
Instrumental = lyrics.Instrumental,
|
||||||
PlainLyrics = lyrics.PlainLyrics,
|
PlainLyrics = lyrics.PlainLyrics,
|
||||||
SyncedLyrics = lyrics.SyncedLyrics
|
SyncedLyrics = lyrics.SyncedLyrics
|
||||||
@@ -365,7 +398,7 @@ public class LrclibService
|
|||||||
TrackName = lyrics.TrackName ?? string.Empty,
|
TrackName = lyrics.TrackName ?? string.Empty,
|
||||||
ArtistName = lyrics.ArtistName ?? string.Empty,
|
ArtistName = lyrics.ArtistName ?? string.Empty,
|
||||||
AlbumName = lyrics.AlbumName ?? string.Empty,
|
AlbumName = lyrics.AlbumName ?? string.Empty,
|
||||||
Duration = (int)Math.Round(lyrics.Duration),
|
Duration = lyrics.Duration.HasValue ? (int)Math.Round(lyrics.Duration.Value) : 0,
|
||||||
Instrumental = lyrics.Instrumental,
|
Instrumental = lyrics.Instrumental,
|
||||||
PlainLyrics = lyrics.PlainLyrics,
|
PlainLyrics = lyrics.PlainLyrics,
|
||||||
SyncedLyrics = lyrics.SyncedLyrics
|
SyncedLyrics = lyrics.SyncedLyrics
|
||||||
@@ -394,7 +427,7 @@ public class LrclibService
|
|||||||
public string? TrackName { get; set; }
|
public string? TrackName { get; set; }
|
||||||
public string? ArtistName { get; set; }
|
public string? ArtistName { get; set; }
|
||||||
public string? AlbumName { get; set; }
|
public string? AlbumName { get; set; }
|
||||||
public double Duration { get; set; }
|
public double? Duration { get; set; }
|
||||||
public bool Instrumental { get; set; }
|
public bool Instrumental { get; set; }
|
||||||
public string? PlainLyrics { get; set; }
|
public string? PlainLyrics { get; set; }
|
||||||
public string? SyncedLyrics { get; set; }
|
public string? SyncedLyrics { get; set; }
|
||||||
|
|||||||
535
allstarr/Services/Lyrics/LyricsPrefetchService.cs
Normal file
535
allstarr/Services/Lyrics/LyricsPrefetchService.cs
Normal file
@@ -0,0 +1,535 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using allstarr.Models.Lyrics;
|
||||||
|
using allstarr.Models.Settings;
|
||||||
|
using allstarr.Services.Common;
|
||||||
|
using allstarr.Services.Jellyfin;
|
||||||
|
using allstarr.Services.Spotify;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace allstarr.Services.Lyrics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Background service that prefetches lyrics for all tracks in injected Spotify playlists.
|
||||||
|
/// Lyrics are cached in Redis and persisted to disk for fast loading on startup.
|
||||||
|
/// </summary>
|
||||||
|
public class LyricsPrefetchService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly SpotifyImportSettings _spotifySettings;
|
||||||
|
private readonly LrclibService _lrclibService;
|
||||||
|
private readonly SpotifyPlaylistFetcher _playlistFetcher;
|
||||||
|
private readonly RedisCacheService _cache;
|
||||||
|
private readonly IServiceProvider _serviceProvider;
|
||||||
|
private readonly ILogger<LyricsPrefetchService> _logger;
|
||||||
|
private readonly string _lyricsCacheDir = "/app/cache/lyrics";
|
||||||
|
private const int DelayBetweenRequestsMs = 500; // 500ms = 2 requests/second to be respectful
|
||||||
|
|
||||||
|
public LyricsPrefetchService(
|
||||||
|
IOptions<SpotifyImportSettings> spotifySettings,
|
||||||
|
LrclibService lrclibService,
|
||||||
|
SpotifyPlaylistFetcher playlistFetcher,
|
||||||
|
RedisCacheService cache,
|
||||||
|
IServiceProvider serviceProvider,
|
||||||
|
ILogger<LyricsPrefetchService> logger)
|
||||||
|
{
|
||||||
|
_spotifySettings = spotifySettings.Value;
|
||||||
|
_lrclibService = lrclibService;
|
||||||
|
_playlistFetcher = playlistFetcher;
|
||||||
|
_cache = cache;
|
||||||
|
_serviceProvider = serviceProvider;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("LyricsPrefetchService: Starting up...");
|
||||||
|
|
||||||
|
if (!_spotifySettings.Enabled)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Spotify playlist injection is DISABLED, lyrics prefetch will not run");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure cache directory exists
|
||||||
|
Directory.CreateDirectory(_lyricsCacheDir);
|
||||||
|
|
||||||
|
// Wait for playlist fetcher to initialize
|
||||||
|
await Task.Delay(TimeSpan.FromMinutes(3), stoppingToken);
|
||||||
|
|
||||||
|
// Run initial prefetch
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Running initial lyrics prefetch on startup");
|
||||||
|
await PrefetchAllPlaylistLyricsAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error during startup lyrics prefetch");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run periodic prefetch (daily)
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromHours(24), stoppingToken);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await PrefetchAllPlaylistLyricsAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error in lyrics prefetch service");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PrefetchAllPlaylistLyricsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("🎵 Starting lyrics prefetch for {Count} playlists", _spotifySettings.Playlists.Count);
|
||||||
|
|
||||||
|
var totalFetched = 0;
|
||||||
|
var totalCached = 0;
|
||||||
|
var totalMissing = 0;
|
||||||
|
|
||||||
|
foreach (var playlist in _spotifySettings.Playlists)
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested) break;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (fetched, cached, missing) = await PrefetchPlaylistLyricsAsync(playlist.Name, cancellationToken);
|
||||||
|
totalFetched += fetched;
|
||||||
|
totalCached += cached;
|
||||||
|
totalMissing += missing;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error prefetching lyrics for playlist {Playlist}", playlist.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("✅ Lyrics prefetch complete: {Fetched} fetched, {Cached} already cached, {Missing} not found",
|
||||||
|
totalFetched, totalCached, totalMissing);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(int Fetched, int Cached, int Missing)> PrefetchPlaylistLyricsAsync(
|
||||||
|
string playlistName,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Prefetching lyrics for playlist: {Playlist}", playlistName);
|
||||||
|
|
||||||
|
var tracks = await _playlistFetcher.GetPlaylistTracksAsync(playlistName);
|
||||||
|
if (tracks.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("No tracks found for playlist {Playlist}", playlistName);
|
||||||
|
return (0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the pre-built playlist items cache which includes Jellyfin item IDs for local tracks
|
||||||
|
var playlistItemsKey = $"spotify:playlist:items:{playlistName}";
|
||||||
|
var playlistItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(playlistItemsKey);
|
||||||
|
|
||||||
|
// Build a map of Spotify ID -> Jellyfin Item ID for quick lookup
|
||||||
|
var spotifyToJellyfinId = new Dictionary<string, string>();
|
||||||
|
if (playlistItems != null)
|
||||||
|
{
|
||||||
|
foreach (var item in playlistItems)
|
||||||
|
{
|
||||||
|
// Check if this is a local Jellyfin track (has Id field, no ProviderIds for external)
|
||||||
|
if (item.TryGetValue("Id", out var idObj) && idObj != null)
|
||||||
|
{
|
||||||
|
var jellyfinId = idObj.ToString();
|
||||||
|
|
||||||
|
// Try to get Spotify provider ID
|
||||||
|
if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null)
|
||||||
|
{
|
||||||
|
var providerIdsJson = JsonSerializer.Serialize(providerIdsObj);
|
||||||
|
using var doc = JsonDocument.Parse(providerIdsJson);
|
||||||
|
if (doc.RootElement.TryGetProperty("Spotify", out var spotifyIdEl))
|
||||||
|
{
|
||||||
|
var spotifyId = spotifyIdEl.GetString();
|
||||||
|
if (!string.IsNullOrEmpty(spotifyId) && !string.IsNullOrEmpty(jellyfinId))
|
||||||
|
{
|
||||||
|
spotifyToJellyfinId[spotifyId] = jellyfinId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("Found {Count} local Jellyfin tracks with Spotify IDs in playlist {Playlist}",
|
||||||
|
spotifyToJellyfinId.Count, playlistName);
|
||||||
|
}
|
||||||
|
|
||||||
|
var fetched = 0;
|
||||||
|
var cached = 0;
|
||||||
|
var missing = 0;
|
||||||
|
|
||||||
|
foreach (var track in tracks)
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested) break;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Check if lyrics are already cached
|
||||||
|
// Use same cache key format as LrclibService: join all artists with ", "
|
||||||
|
var artistName = string.Join(", ", track.Artists);
|
||||||
|
var cacheKey = $"lyrics:{artistName}:{track.Title}:{track.Album}:{track.DurationMs / 1000}";
|
||||||
|
var existingLyrics = await _cache.GetStringAsync(cacheKey);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(existingLyrics))
|
||||||
|
{
|
||||||
|
cached++;
|
||||||
|
_logger.LogDebug("✓ Lyrics already cached for {Artist} - {Track}", track.PrimaryArtist, track.Title);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 1: Check if this track has local Jellyfin lyrics (embedded in file)
|
||||||
|
// Use the Jellyfin item ID from the playlist cache if available
|
||||||
|
if (spotifyToJellyfinId.TryGetValue(track.SpotifyId, out var jellyfinItemId))
|
||||||
|
{
|
||||||
|
var hasLocalLyrics = await CheckForLocalJellyfinLyricsByIdAsync(jellyfinItemId, track.PrimaryArtist, track.Title);
|
||||||
|
if (hasLocalLyrics)
|
||||||
|
{
|
||||||
|
cached++;
|
||||||
|
_logger.LogInformation("✓ Local Jellyfin lyrics found for {Artist} - {Track}, skipping external fetch",
|
||||||
|
track.PrimaryArtist, track.Title);
|
||||||
|
|
||||||
|
// Remove any previously cached LRCLib lyrics for this track
|
||||||
|
var artistNameForRemoval = string.Join(", ", track.Artists);
|
||||||
|
await RemoveCachedLyricsAsync(artistNameForRemoval, track.Title, track.Album, track.DurationMs / 1000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 2: Try Spotify lyrics if we have a Spotify ID
|
||||||
|
LyricsInfo? lyrics = null;
|
||||||
|
if (!string.IsNullOrEmpty(track.SpotifyId))
|
||||||
|
{
|
||||||
|
lyrics = await TryGetSpotifyLyricsAsync(track.SpotifyId, track.Title, track.PrimaryArtist);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 3: Fall back to LRCLib if no Spotify lyrics
|
||||||
|
if (lyrics == null)
|
||||||
|
{
|
||||||
|
lyrics = await _lrclibService.GetLyricsAsync(
|
||||||
|
track.Title,
|
||||||
|
track.Artists.ToArray(),
|
||||||
|
track.Album,
|
||||||
|
track.DurationMs / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lyrics != null)
|
||||||
|
{
|
||||||
|
fetched++;
|
||||||
|
_logger.LogInformation("✓ Fetched lyrics for {Artist} - {Track} (synced: {HasSynced})",
|
||||||
|
track.PrimaryArtist, track.Title, !string.IsNullOrEmpty(lyrics.SyncedLyrics));
|
||||||
|
|
||||||
|
// Save to file cache
|
||||||
|
var artistNameForSave = string.Join(", ", track.Artists);
|
||||||
|
await SaveLyricsToFileAsync(artistNameForSave, track.Title, track.Album, track.DurationMs / 1000, lyrics);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
missing++;
|
||||||
|
_logger.LogDebug("✗ No lyrics found for {Artist} - {Track}", track.PrimaryArtist, track.Title);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
await Task.Delay(DelayBetweenRequestsMs, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to prefetch lyrics for {Artist} - {Track}", track.PrimaryArtist, track.Title);
|
||||||
|
missing++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Playlist {Playlist}: {Fetched} fetched, {Cached} cached, {Missing} missing",
|
||||||
|
playlistName, fetched, cached, missing);
|
||||||
|
|
||||||
|
return (fetched, cached, missing);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveLyricsToFileAsync(string artist, string title, string album, int duration, LyricsInfo lyrics)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fileName = $"{SanitizeFileName(artist)}_{SanitizeFileName(title)}_{duration}.json";
|
||||||
|
var filePath = Path.Combine(_lyricsCacheDir, fileName);
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(lyrics, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
await File.WriteAllTextAsync(filePath, json);
|
||||||
|
|
||||||
|
_logger.LogDebug("💾 Saved lyrics to file: {FileName}", fileName);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to save lyrics to file for {Artist} - {Track}", artist, title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads lyrics from file cache into Redis on startup
|
||||||
|
/// </summary>
|
||||||
|
public async Task WarmCacheFromFilesAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(_lyricsCacheDir))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Lyrics cache directory does not exist, skipping cache warming");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var files = Directory.GetFiles(_lyricsCacheDir, "*.json");
|
||||||
|
if (files.Length == 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("No lyrics cache files found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("🔥 Warming lyrics cache from {Count} files...", files.Length);
|
||||||
|
|
||||||
|
var loaded = 0;
|
||||||
|
foreach (var file in files)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = await File.ReadAllTextAsync(file);
|
||||||
|
var lyrics = JsonSerializer.Deserialize<LyricsInfo>(json);
|
||||||
|
|
||||||
|
if (lyrics != null)
|
||||||
|
{
|
||||||
|
var cacheKey = $"lyrics:{lyrics.ArtistName}:{lyrics.TrackName}:{lyrics.AlbumName}:{lyrics.Duration}";
|
||||||
|
await _cache.SetStringAsync(cacheKey, json, TimeSpan.FromDays(30));
|
||||||
|
loaded++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to load lyrics from file {File}", Path.GetFileName(file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("✅ Warmed {Count} lyrics from file cache", loaded);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error warming lyrics cache from files");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SanitizeFileName(string fileName)
|
||||||
|
{
|
||||||
|
var invalid = Path.GetInvalidFileNameChars();
|
||||||
|
return string.Join("_", fileName.Split(invalid, StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
.Replace(" ", "_")
|
||||||
|
.ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes cached LRCLib lyrics from both Redis and file cache.
|
||||||
|
/// Used when a track has local Jellyfin lyrics, making the LRCLib cache obsolete.
|
||||||
|
/// </summary>
|
||||||
|
private async Task RemoveCachedLyricsAsync(string artist, string title, string album, int duration)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Remove from Redis cache
|
||||||
|
var cacheKey = $"lyrics:{artist}:{title}:{album}:{duration}";
|
||||||
|
await _cache.DeleteAsync(cacheKey);
|
||||||
|
|
||||||
|
// Remove from file cache
|
||||||
|
var fileName = $"{SanitizeFileName(artist)}_{SanitizeFileName(title)}_{duration}.json";
|
||||||
|
var filePath = Path.Combine(_lyricsCacheDir, fileName);
|
||||||
|
|
||||||
|
if (File.Exists(filePath))
|
||||||
|
{
|
||||||
|
File.Delete(filePath);
|
||||||
|
_logger.LogDebug("🗑️ Removed cached LRCLib lyrics file: {FileName}", fileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to remove cached lyrics for {Artist} - {Track}", artist, title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tries to get lyrics from Spotify using the track's Spotify ID.
|
||||||
|
/// Returns null if Spotify API is not enabled or lyrics not found.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<LyricsInfo?> TryGetSpotifyLyricsAsync(string spotifyTrackId, string trackTitle, string artistName)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = _serviceProvider.CreateScope();
|
||||||
|
var spotifyLyricsService = scope.ServiceProvider.GetService<SpotifyLyricsService>();
|
||||||
|
|
||||||
|
if (spotifyLyricsService == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var spotifyLyrics = await spotifyLyricsService.GetLyricsByTrackIdAsync(spotifyTrackId);
|
||||||
|
|
||||||
|
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("✓ Found Spotify lyrics for {Artist} - {Track} ({LineCount} lines)",
|
||||||
|
artistName, trackTitle, spotifyLyrics.Lines.Count);
|
||||||
|
return spotifyLyricsService.ToLyricsInfo(spotifyLyrics);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Error fetching Spotify lyrics for track {SpotifyId}", spotifyTrackId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if a track has embedded lyrics in Jellyfin using the Jellyfin item ID.
|
||||||
|
/// This is the most efficient method as it directly queries the lyrics endpoint.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<bool> CheckForLocalJellyfinLyricsByIdAsync(string jellyfinItemId, string artistName, string trackTitle)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = _serviceProvider.CreateScope();
|
||||||
|
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
|
||||||
|
|
||||||
|
if (proxyService == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Directly check if this track has lyrics using the item ID
|
||||||
|
// Use internal method with server API key since this is a background operation
|
||||||
|
var (lyricsResult, lyricsStatusCode) = await proxyService.GetJsonAsyncInternal(
|
||||||
|
$"Audio/{jellyfinItemId}/Lyrics",
|
||||||
|
null);
|
||||||
|
|
||||||
|
if (lyricsResult != null && lyricsStatusCode == 200)
|
||||||
|
{
|
||||||
|
// Track has embedded lyrics in Jellyfin
|
||||||
|
_logger.LogDebug("Found embedded lyrics in Jellyfin for {Artist} - {Track} (ID: {JellyfinId})",
|
||||||
|
artistName, trackTitle, jellyfinItemId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Error checking Jellyfin lyrics for item {ItemId}", jellyfinItemId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if a track has embedded lyrics in Jellyfin by querying the Jellyfin API.
|
||||||
|
/// This prevents downloading lyrics from LRCLib when the local file already has them.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<bool> CheckForLocalJellyfinLyricsAsync(string spotifyTrackId, string artistName, string trackTitle)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = _serviceProvider.CreateScope();
|
||||||
|
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
|
||||||
|
|
||||||
|
if (proxyService == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for the track in Jellyfin by artist and title
|
||||||
|
// Jellyfin doesn't support anyProviderIdEquals - that's an Emby API parameter
|
||||||
|
var searchTerm = $"{artistName} {trackTitle}";
|
||||||
|
var searchParams = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["searchTerm"] = searchTerm,
|
||||||
|
["includeItemTypes"] = "Audio",
|
||||||
|
["recursive"] = "true",
|
||||||
|
["limit"] = "5" // Get a few results to find best match
|
||||||
|
};
|
||||||
|
|
||||||
|
var (searchResult, statusCode) = await proxyService.GetJsonAsyncInternal("Items", searchParams);
|
||||||
|
|
||||||
|
if (searchResult == null || statusCode != 200)
|
||||||
|
{
|
||||||
|
// Track not found in local library
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we found any items
|
||||||
|
if (!searchResult.RootElement.TryGetProperty("Items", out var items) ||
|
||||||
|
items.GetArrayLength() == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the best matching track by comparing artist and title
|
||||||
|
string? bestMatchId = null;
|
||||||
|
foreach (var item in items.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (!item.TryGetProperty("Name", out var nameEl) ||
|
||||||
|
!item.TryGetProperty("Id", out var idEl))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var itemTitle = nameEl.GetString() ?? "";
|
||||||
|
var itemId = idEl.GetString();
|
||||||
|
|
||||||
|
// Check if title matches (case-insensitive)
|
||||||
|
if (itemTitle.Equals(trackTitle, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// Also check artist if available
|
||||||
|
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
|
||||||
|
{
|
||||||
|
var itemArtist = artistsEl[0].GetString() ?? "";
|
||||||
|
if (itemArtist.Equals(artistName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
bestMatchId = itemId;
|
||||||
|
break; // Exact match found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no exact artist match but title matches, use it as fallback
|
||||||
|
if (bestMatchId == null)
|
||||||
|
{
|
||||||
|
bestMatchId = itemId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(bestMatchId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this track has lyrics
|
||||||
|
// Use internal method with server API key since this is a background operation
|
||||||
|
var (lyricsResult, lyricsStatusCode) = await proxyService.GetJsonAsyncInternal(
|
||||||
|
$"Audio/{bestMatchId}/Lyrics",
|
||||||
|
null);
|
||||||
|
|
||||||
|
if (lyricsResult != null && lyricsStatusCode == 200)
|
||||||
|
{
|
||||||
|
// Track has embedded lyrics in Jellyfin
|
||||||
|
_logger.LogDebug("Found embedded lyrics in Jellyfin for {Artist} - {Track} (Jellyfin ID: {JellyfinId})",
|
||||||
|
artistName, trackTitle, bestMatchId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Error checking for local Jellyfin lyrics for Spotify track {SpotifyId}", spotifyTrackId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
189
allstarr/Services/Lyrics/LyricsStartupValidator.cs
Normal file
189
allstarr/Services/Lyrics/LyricsStartupValidator.cs
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using allstarr.Models.Settings;
|
||||||
|
using allstarr.Services.Validation;
|
||||||
|
|
||||||
|
namespace allstarr.Services.Lyrics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates lyrics services (LRCLib, Spotify Lyrics Sidecar, Spotify API) at startup
|
||||||
|
/// Tests with "22" by Taylor Swift (Spotify ID: 3yII7UwgLF6K5zW3xad3MP)
|
||||||
|
/// </summary>
|
||||||
|
public class LyricsStartupValidator : BaseStartupValidator
|
||||||
|
{
|
||||||
|
private readonly SpotifyApiSettings _spotifySettings;
|
||||||
|
|
||||||
|
// Test song: "22" by Taylor Swift
|
||||||
|
private const string TestSongTitle = "22";
|
||||||
|
private const string TestArtist = "Taylor Swift";
|
||||||
|
private const string TestAlbum = "Red";
|
||||||
|
private const int TestDuration = 232; // seconds
|
||||||
|
private const string TestSpotifyId = "3yII7UwgLF6K5zW3xad3MP";
|
||||||
|
|
||||||
|
public override string ServiceName => "Lyrics Services";
|
||||||
|
|
||||||
|
public LyricsStartupValidator(
|
||||||
|
IOptions<SpotifyApiSettings> spotifySettings,
|
||||||
|
IHttpClientFactory httpClientFactory)
|
||||||
|
: base(httpClientFactory.CreateClient())
|
||||||
|
{
|
||||||
|
_spotifySettings = spotifySettings.Value;
|
||||||
|
_httpClient.Timeout = TimeSpan.FromSeconds(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<ValidationResult> ValidateAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Console.WriteLine();
|
||||||
|
WriteStatus("Lyrics Test Song", $"{TestSongTitle} by {TestArtist}", ConsoleColor.Cyan);
|
||||||
|
WriteDetail($"Spotify ID: {TestSpotifyId}");
|
||||||
|
|
||||||
|
var allSuccess = true;
|
||||||
|
|
||||||
|
// Test 1: LRCLib
|
||||||
|
allSuccess &= await TestLrclibAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Test 2: Spotify Lyrics Sidecar
|
||||||
|
allSuccess &= await TestSpotifyLyricsSidecarAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Test 3: Spotify API (if enabled)
|
||||||
|
if (_spotifySettings.Enabled)
|
||||||
|
{
|
||||||
|
allSuccess &= await TestSpotifyApiAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
WriteStatus("Spotify API", "DISABLED", ConsoleColor.Yellow);
|
||||||
|
WriteDetail("Enable SpotifyApi__Enabled to test Spotify API lyrics");
|
||||||
|
}
|
||||||
|
|
||||||
|
return allSuccess
|
||||||
|
? ValidationResult.Success("Lyrics services validation completed")
|
||||||
|
: ValidationResult.Failure("PARTIAL", "Some lyrics services had issues", ConsoleColor.Yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> TestLrclibAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var url = $"https://lrclib.net/api/get?artist_name={Uri.EscapeDataString(TestArtist)}&track_name={Uri.EscapeDataString(TestSongTitle)}&album_name={Uri.EscapeDataString(TestAlbum)}&duration={TestDuration}";
|
||||||
|
|
||||||
|
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
var doc = JsonDocument.Parse(json);
|
||||||
|
|
||||||
|
var hasSyncedLyrics = doc.RootElement.TryGetProperty("syncedLyrics", out var synced) &&
|
||||||
|
!string.IsNullOrEmpty(synced.GetString());
|
||||||
|
var hasPlainLyrics = doc.RootElement.TryGetProperty("plainLyrics", out var plain) &&
|
||||||
|
!string.IsNullOrEmpty(plain.GetString());
|
||||||
|
|
||||||
|
WriteStatus("LRCLib", "WORKING", ConsoleColor.Green);
|
||||||
|
WriteDetail($"✓ Synced: {hasSyncedLyrics}, Plain: {hasPlainLyrics}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||||
|
{
|
||||||
|
WriteStatus("LRCLib", "NO LYRICS FOUND", ConsoleColor.Yellow);
|
||||||
|
WriteDetail("Service is working but no lyrics available for test song");
|
||||||
|
return true; // Service is working, just no lyrics
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
WriteStatus("LRCLib", $"HTTP {(int)response.StatusCode}", ConsoleColor.Red);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
WriteStatus("LRCLib", "ERROR", ConsoleColor.Red);
|
||||||
|
WriteDetail($"Failed to connect: {ex.Message}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> TestSpotifyLyricsSidecarAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(_spotifySettings.LyricsApiUrl))
|
||||||
|
{
|
||||||
|
WriteStatus("Spotify Lyrics Sidecar", "NOT CONFIGURED", ConsoleColor.Yellow);
|
||||||
|
WriteDetail("Set SpotifyApi__LyricsApiUrl to enable");
|
||||||
|
return true; // Not an error, just not configured
|
||||||
|
}
|
||||||
|
|
||||||
|
var url = $"{_spotifySettings.LyricsApiUrl}/?trackid={TestSpotifyId}&format=id3";
|
||||||
|
|
||||||
|
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
var doc = JsonDocument.Parse(json);
|
||||||
|
|
||||||
|
var hasError = doc.RootElement.TryGetProperty("error", out var error) && error.GetBoolean();
|
||||||
|
|
||||||
|
if (hasError)
|
||||||
|
{
|
||||||
|
var message = doc.RootElement.TryGetProperty("message", out var msg)
|
||||||
|
? msg.GetString()
|
||||||
|
: "Unknown error";
|
||||||
|
WriteStatus("Spotify Lyrics Sidecar", "API ERROR", ConsoleColor.Yellow);
|
||||||
|
WriteDetail($"⚠ {message}");
|
||||||
|
WriteDetail("Check if sp_dc cookie is valid");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var syncType = doc.RootElement.TryGetProperty("syncType", out var st)
|
||||||
|
? st.GetString()
|
||||||
|
: "UNKNOWN";
|
||||||
|
var lineCount = doc.RootElement.TryGetProperty("lines", out var lines)
|
||||||
|
? lines.GetArrayLength()
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
WriteStatus("Spotify Lyrics Sidecar", "WORKING", ConsoleColor.Green);
|
||||||
|
WriteDetail($"✓ Type: {syncType}, Lines: {lineCount}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
WriteStatus("Spotify Lyrics Sidecar", $"HTTP {(int)response.StatusCode}", ConsoleColor.Red);
|
||||||
|
WriteDetail("Check if spotify-lyrics container is running");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
WriteStatus("Spotify Lyrics Sidecar", "ERROR", ConsoleColor.Red);
|
||||||
|
WriteDetail($"Failed to connect: {ex.Message}");
|
||||||
|
WriteDetail("Ensure spotify-lyrics container is running in docker-compose");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> TestSpotifyApiAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(_spotifySettings.ClientId))
|
||||||
|
{
|
||||||
|
WriteStatus("Spotify API", "NOT CONFIGURED", ConsoleColor.Yellow);
|
||||||
|
WriteDetail("Set SpotifyApi__ClientId to enable");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteStatus("Spotify API", "CONFIGURED", ConsoleColor.Green);
|
||||||
|
WriteDetail($"Client ID: {_spotifySettings.ClientId.Substring(0, Math.Min(8, _spotifySettings.ClientId.Length))}...");
|
||||||
|
WriteDetail("Note: Spotify API is used for track matching, not lyrics");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
WriteStatus("Spotify API", "ERROR", ConsoleColor.Red);
|
||||||
|
WriteDetail($"Validation failed: {ex.Message}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,31 +24,25 @@ public class SpotifyLyricsService
|
|||||||
{
|
{
|
||||||
private readonly ILogger<SpotifyLyricsService> _logger;
|
private readonly ILogger<SpotifyLyricsService> _logger;
|
||||||
private readonly SpotifyApiSettings _settings;
|
private readonly SpotifyApiSettings _settings;
|
||||||
private readonly SpotifyApiClient _spotifyClient;
|
|
||||||
private readonly RedisCacheService _cache;
|
private readonly RedisCacheService _cache;
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
|
|
||||||
private const string LyricsApiBase = "https://spclient.wg.spotify.com/color-lyrics/v2/track";
|
|
||||||
|
|
||||||
public SpotifyLyricsService(
|
public SpotifyLyricsService(
|
||||||
ILogger<SpotifyLyricsService> logger,
|
ILogger<SpotifyLyricsService> logger,
|
||||||
IOptions<SpotifyApiSettings> settings,
|
IOptions<SpotifyApiSettings> settings,
|
||||||
SpotifyApiClient spotifyClient,
|
|
||||||
RedisCacheService cache,
|
RedisCacheService cache,
|
||||||
IHttpClientFactory httpClientFactory)
|
IHttpClientFactory httpClientFactory)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_settings = settings.Value;
|
_settings = settings.Value;
|
||||||
_spotifyClient = spotifyClient;
|
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
|
|
||||||
_httpClient = httpClientFactory.CreateClient();
|
_httpClient = httpClientFactory.CreateClient();
|
||||||
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0");
|
_httpClient.Timeout = TimeSpan.FromSeconds(10);
|
||||||
_httpClient.DefaultRequestHeaders.Add("App-Platform", "WebPlayer");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets synchronized lyrics for a Spotify track by its ID.
|
/// Gets synchronized lyrics for a Spotify track by its ID using the sidecar API.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="spotifyTrackId">Spotify track ID (e.g., "3a8mo25v74BMUOJ1IDUEBL")</param>
|
/// <param name="spotifyTrackId">Spotify track ID (e.g., "3a8mo25v74BMUOJ1IDUEBL")</param>
|
||||||
/// <returns>Lyrics info with synced lyrics in LRC format, or null if not available</returns>
|
/// <returns>Lyrics info with synced lyrics in LRC format, or null if not available</returns>
|
||||||
@@ -60,58 +54,37 @@ public class SpotifyLyricsService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(_settings.LyricsApiUrl))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Spotify lyrics API URL not configured");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Normalize track ID (remove URI prefix if present)
|
// Normalize track ID (remove URI prefix if present)
|
||||||
spotifyTrackId = ExtractTrackId(spotifyTrackId);
|
spotifyTrackId = ExtractTrackId(spotifyTrackId);
|
||||||
|
|
||||||
// Check cache
|
// NO CACHING - Spotify lyrics come from local Docker container (fast)
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Get access token
|
var url = $"{_settings.LyricsApiUrl}/?trackid={spotifyTrackId}&format=id3";
|
||||||
var token = await _spotifyClient.GetWebAccessTokenAsync();
|
|
||||||
if (string.IsNullOrEmpty(token))
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Could not get Spotify access token for lyrics");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request lyrics from Spotify's color-lyrics API
|
_logger.LogDebug("Fetching lyrics from sidecar API: {Url}", url);
|
||||||
var url = $"{LyricsApiBase}/{spotifyTrackId}?format=json&vocalRemoval=false&market=from_token";
|
|
||||||
|
|
||||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
var response = await _httpClient.GetAsync(url);
|
||||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
|
||||||
request.Headers.Add("Accept", "application/json");
|
|
||||||
|
|
||||||
var response = await _httpClient.SendAsync(request);
|
|
||||||
|
|
||||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
|
||||||
{
|
|
||||||
_logger.LogDebug("No lyrics found on Spotify for track {TrackId}", spotifyTrackId);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Spotify lyrics API returned {StatusCode} for track {TrackId}",
|
_logger.LogDebug("Sidecar API returned {StatusCode} for track {TrackId}",
|
||||||
response.StatusCode, spotifyTrackId);
|
response.StatusCode, spotifyTrackId);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync();
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
var result = ParseLyricsResponse(json, spotifyTrackId);
|
var result = ParseSidecarResponse(json, spotifyTrackId);
|
||||||
|
|
||||||
if (result != null)
|
if (result != null)
|
||||||
{
|
{
|
||||||
// Cache for 30 days (lyrics don't change)
|
_logger.LogInformation("Got Spotify lyrics from sidecar for track {TrackId} ({LineCount} lines)",
|
||||||
await _cache.SetAsync(cacheKey, result, TimeSpan.FromDays(30));
|
|
||||||
_logger.LogInformation("Cached Spotify lyrics for track {TrackId} ({LineCount} lines)",
|
|
||||||
spotifyTrackId, result.Lines.Count);
|
spotifyTrackId, result.Lines.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,14 +92,15 @@ public class SpotifyLyricsService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error fetching Spotify lyrics for track {TrackId}", spotifyTrackId);
|
_logger.LogWarning(ex, "Error fetching lyrics from sidecar API for track {TrackId}", spotifyTrackId);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Searches for a track on Spotify and returns its lyrics.
|
/// Searches for a track on Spotify and returns its lyrics using the sidecar API.
|
||||||
/// Useful when you have track metadata but not a Spotify ID.
|
/// Useful when you have track metadata but not a Spotify ID.
|
||||||
|
/// Note: This requires the sidecar to handle search, or we skip it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<SpotifyLyricsResult?> SearchAndGetLyricsAsync(
|
public async Task<SpotifyLyricsResult?> SearchAndGetLyricsAsync(
|
||||||
string trackName,
|
string trackName,
|
||||||
@@ -136,87 +110,17 @@ public class SpotifyLyricsService
|
|||||||
{
|
{
|
||||||
if (!_settings.Enabled || string.IsNullOrEmpty(_settings.SessionCookie))
|
if (!_settings.Enabled || string.IsNullOrEmpty(_settings.SessionCookie))
|
||||||
{
|
{
|
||||||
|
_logger.LogDebug("Spotify lyrics search skipped: API not enabled or no session cookie");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
// The sidecar API only supports track ID, not search
|
||||||
{
|
// So we skip Spotify lyrics for search-based requests
|
||||||
var token = await _spotifyClient.GetWebAccessTokenAsync();
|
// LRCLib will be used as fallback
|
||||||
if (string.IsNullOrEmpty(token))
|
_logger.LogDebug("Spotify lyrics search by metadata not supported with sidecar API, skipping");
|
||||||
{
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search for the track
|
|
||||||
var query = $"track:{trackName} artist:{artistName}";
|
|
||||||
if (!string.IsNullOrEmpty(albumName))
|
|
||||||
{
|
|
||||||
query += $" album:{albumName}";
|
|
||||||
}
|
|
||||||
|
|
||||||
var searchUrl = $"https://api.spotify.com/v1/search?q={Uri.EscapeDataString(query)}&type=track&limit=5";
|
|
||||||
|
|
||||||
var request = new HttpRequestMessage(HttpMethod.Get, searchUrl);
|
|
||||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
|
||||||
|
|
||||||
var response = await _httpClient.SendAsync(request);
|
|
||||||
if (!response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync();
|
|
||||||
using var doc = JsonDocument.Parse(json);
|
|
||||||
var root = doc.RootElement;
|
|
||||||
|
|
||||||
if (!root.TryGetProperty("tracks", out var tracks) ||
|
|
||||||
!tracks.TryGetProperty("items", out var items) ||
|
|
||||||
items.GetArrayLength() == 0)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find best match considering duration if provided
|
|
||||||
string? bestMatchId = null;
|
|
||||||
var bestScore = 0;
|
|
||||||
|
|
||||||
foreach (var item in items.EnumerateArray())
|
|
||||||
{
|
|
||||||
var id = item.TryGetProperty("id", out var idProp) ? idProp.GetString() : null;
|
|
||||||
if (string.IsNullOrEmpty(id)) continue;
|
|
||||||
|
|
||||||
var score = 100; // Base score
|
|
||||||
|
|
||||||
// Check duration match
|
|
||||||
if (durationMs.HasValue && item.TryGetProperty("duration_ms", out var durProp))
|
|
||||||
{
|
|
||||||
var trackDuration = durProp.GetInt32();
|
|
||||||
var durationDiff = Math.Abs(trackDuration - durationMs.Value);
|
|
||||||
if (durationDiff < 2000) score += 50; // Within 2 seconds
|
|
||||||
else if (durationDiff < 5000) score += 25; // Within 5 seconds
|
|
||||||
}
|
|
||||||
|
|
||||||
if (score > bestScore)
|
|
||||||
{
|
|
||||||
bestScore = score;
|
|
||||||
bestMatchId = id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(bestMatchId))
|
|
||||||
{
|
|
||||||
return await GetLyricsByTrackIdAsync(bestMatchId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error searching Spotify for lyrics: {Track} - {Artist}", trackName, artistName);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Converts Spotify lyrics to LRCLIB-compatible LyricsInfo format.
|
/// Converts Spotify lyrics to LRCLIB-compatible LyricsInfo format.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -251,29 +155,37 @@ public class SpotifyLyricsService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private SpotifyLyricsResult? ParseLyricsResponse(string json, string trackId)
|
/// <summary>
|
||||||
|
/// Parses the response from the sidecar spotify-lyrics-api service.
|
||||||
|
/// Format: {"error": false, "syncType": "LINE_SYNCED", "lines": [...]}
|
||||||
|
/// </summary>
|
||||||
|
private SpotifyLyricsResult? ParseSidecarResponse(string json, string trackId)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var doc = JsonDocument.Parse(json);
|
using var doc = JsonDocument.Parse(json);
|
||||||
var root = doc.RootElement;
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
// Check for error
|
||||||
|
if (root.TryGetProperty("error", out var error) && error.GetBoolean())
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Sidecar API returned error for track {TrackId}", trackId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
var result = new SpotifyLyricsResult
|
var result = new SpotifyLyricsResult
|
||||||
{
|
{
|
||||||
SpotifyTrackId = trackId
|
SpotifyTrackId = trackId
|
||||||
};
|
};
|
||||||
|
|
||||||
// Parse lyrics lines
|
// Get sync type
|
||||||
if (root.TryGetProperty("lyrics", out var lyrics))
|
if (root.TryGetProperty("syncType", out var syncType))
|
||||||
{
|
|
||||||
// Check sync type
|
|
||||||
if (lyrics.TryGetProperty("syncType", out var syncType))
|
|
||||||
{
|
{
|
||||||
result.SyncType = syncType.GetString() ?? "LINE_SYNCED";
|
result.SyncType = syncType.GetString() ?? "LINE_SYNCED";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse lines
|
// Parse lines
|
||||||
if (lyrics.TryGetProperty("lines", out var lines))
|
if (root.TryGetProperty("lines", out var lines))
|
||||||
{
|
{
|
||||||
foreach (var line in lines.EnumerateArray())
|
foreach (var line in lines.EnumerateArray())
|
||||||
{
|
{
|
||||||
@@ -287,84 +199,19 @@ public class SpotifyLyricsService
|
|||||||
? long.Parse(end.GetString() ?? "0") : 0
|
? long.Parse(end.GetString() ?? "0") : 0
|
||||||
};
|
};
|
||||||
|
|
||||||
// Parse syllables if available (for word-level sync)
|
|
||||||
if (line.TryGetProperty("syllables", out var syllables))
|
|
||||||
{
|
|
||||||
foreach (var syllable in syllables.EnumerateArray())
|
|
||||||
{
|
|
||||||
lyricsLine.Syllables.Add(new SpotifyLyricsSyllable
|
|
||||||
{
|
|
||||||
StartTimeMs = syllable.TryGetProperty("startTimeMs", out var sStart)
|
|
||||||
? long.Parse(sStart.GetString() ?? "0") : 0,
|
|
||||||
Text = syllable.TryGetProperty("charsIndex", out var text)
|
|
||||||
? text.GetString() ?? "" : ""
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result.Lines.Add(lyricsLine);
|
result.Lines.Add(lyricsLine);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse color information
|
|
||||||
if (lyrics.TryGetProperty("colors", out var colors))
|
|
||||||
{
|
|
||||||
result.Colors = new SpotifyLyricsColors
|
|
||||||
{
|
|
||||||
Background = colors.TryGetProperty("background", out var bg)
|
|
||||||
? ParseColorValue(bg) : null,
|
|
||||||
Text = colors.TryGetProperty("text", out var txt)
|
|
||||||
? ParseColorValue(txt) : null,
|
|
||||||
HighlightText = colors.TryGetProperty("highlightText", out var ht)
|
|
||||||
? ParseColorValue(ht) : null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Language
|
|
||||||
if (lyrics.TryGetProperty("language", out var lang))
|
|
||||||
{
|
|
||||||
result.Language = lang.GetString();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Provider info
|
|
||||||
if (lyrics.TryGetProperty("provider", out var provider))
|
|
||||||
{
|
|
||||||
result.Provider = provider.GetString();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Display info
|
|
||||||
if (lyrics.TryGetProperty("providerDisplayName", out var providerDisplay))
|
|
||||||
{
|
|
||||||
result.ProviderDisplayName = providerDisplay.GetString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error parsing Spotify lyrics response");
|
_logger.LogError(ex, "Error parsing sidecar API response");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int? ParseColorValue(JsonElement element)
|
|
||||||
{
|
|
||||||
if (element.ValueKind == JsonValueKind.Number)
|
|
||||||
{
|
|
||||||
return element.GetInt32();
|
|
||||||
}
|
|
||||||
if (element.ValueKind == JsonValueKind.String)
|
|
||||||
{
|
|
||||||
var str = element.GetString();
|
|
||||||
if (!string.IsNullOrEmpty(str) && int.TryParse(str, out var val))
|
|
||||||
{
|
|
||||||
return val;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string ExtractTrackId(string input)
|
private static string ExtractTrackId(string input)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(input)) return input;
|
if (string.IsNullOrEmpty(input)) return input;
|
||||||
|
|||||||
342
allstarr/Services/MusicBrainz/MusicBrainzService.cs
Normal file
342
allstarr/Services/MusicBrainz/MusicBrainzService.cs
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using allstarr.Models.Domain;
|
||||||
|
using allstarr.Models.Settings;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace allstarr.Services.MusicBrainz;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for querying MusicBrainz API for metadata enrichment.
|
||||||
|
/// </summary>
|
||||||
|
public class MusicBrainzService
|
||||||
|
{
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly MusicBrainzSettings _settings;
|
||||||
|
private readonly ILogger<MusicBrainzService> _logger;
|
||||||
|
private DateTime _lastRequestTime = DateTime.MinValue;
|
||||||
|
private readonly SemaphoreSlim _rateLimitSemaphore = new(1, 1);
|
||||||
|
|
||||||
|
public MusicBrainzService(
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
IOptions<MusicBrainzSettings> settings,
|
||||||
|
ILogger<MusicBrainzService> logger)
|
||||||
|
{
|
||||||
|
_httpClient = httpClientFactory.CreateClient();
|
||||||
|
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0.0 (https://github.com/SoPat712/allstarr)");
|
||||||
|
_httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||||
|
|
||||||
|
_settings = settings.Value;
|
||||||
|
_logger = logger;
|
||||||
|
|
||||||
|
// Set up digest authentication if credentials provided
|
||||||
|
if (!string.IsNullOrEmpty(_settings.Username) && !string.IsNullOrEmpty(_settings.Password))
|
||||||
|
{
|
||||||
|
var credentials = Convert.ToBase64String(
|
||||||
|
Encoding.ASCII.GetBytes($"{_settings.Username}:{_settings.Password}"));
|
||||||
|
_httpClient.DefaultRequestHeaders.Authorization =
|
||||||
|
new AuthenticationHeaderValue("Basic", credentials);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a recording by ISRC code.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<MusicBrainzRecording?> LookupByIsrcAsync(string isrc)
|
||||||
|
{
|
||||||
|
if (!_settings.Enabled)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await RateLimitAsync();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var url = $"{_settings.BaseUrl}/isrc/{isrc}?fmt=json&inc=artists+releases+release-groups+genres+tags";
|
||||||
|
_logger.LogDebug("MusicBrainz ISRC lookup: {Url}", url);
|
||||||
|
|
||||||
|
var response = await _httpClient.GetAsync(url);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("MusicBrainz ISRC lookup failed: {StatusCode}", response.StatusCode);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
var result = JsonSerializer.Deserialize<MusicBrainzIsrcResponse>(json, JsonOptions);
|
||||||
|
|
||||||
|
if (result?.Recordings == null || result.Recordings.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("No MusicBrainz recordings found for ISRC: {Isrc}", isrc);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the first recording (ISRCs should be unique)
|
||||||
|
var recording = result.Recordings[0];
|
||||||
|
var genres = recording.Genres?.Select(g => g.Name).Where(n => !string.IsNullOrEmpty(n)).ToList() ?? new List<string?>();
|
||||||
|
_logger.LogInformation("✓ Found MusicBrainz recording for ISRC {Isrc}: {Title} by {Artist} (Genres: {Genres})",
|
||||||
|
isrc, recording.Title, recording.ArtistCredit?[0]?.Name ?? "Unknown", string.Join(", ", genres));
|
||||||
|
|
||||||
|
return recording;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error looking up ISRC {Isrc} in MusicBrainz", isrc);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Searches for recordings by title and artist.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<MusicBrainzRecording>> SearchRecordingsAsync(string title, string artist, int limit = 5)
|
||||||
|
{
|
||||||
|
if (!_settings.Enabled)
|
||||||
|
{
|
||||||
|
return new List<MusicBrainzRecording>();
|
||||||
|
}
|
||||||
|
|
||||||
|
await RateLimitAsync();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Build Lucene query
|
||||||
|
var query = $"recording:\"{title}\" AND artist:\"{artist}\"";
|
||||||
|
var encodedQuery = Uri.EscapeDataString(query);
|
||||||
|
var url = $"{_settings.BaseUrl}/recording?query={encodedQuery}&fmt=json&limit={limit}&inc=genres+tags";
|
||||||
|
|
||||||
|
_logger.LogDebug("MusicBrainz search: {Url}", url);
|
||||||
|
|
||||||
|
var response = await _httpClient.GetAsync(url);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("MusicBrainz search failed: {StatusCode}", response.StatusCode);
|
||||||
|
return new List<MusicBrainzRecording>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
var result = JsonSerializer.Deserialize<MusicBrainzSearchResponse>(json, JsonOptions);
|
||||||
|
|
||||||
|
if (result?.Recordings == null || result.Recordings.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("No MusicBrainz recordings found for: {Title} - {Artist}", title, artist);
|
||||||
|
return new List<MusicBrainzRecording>();
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Found {Count} MusicBrainz recordings for: {Title} - {Artist}",
|
||||||
|
result.Recordings.Count, title, artist);
|
||||||
|
|
||||||
|
return result.Recordings;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error searching MusicBrainz for: {Title} - {Artist}", title, artist);
|
||||||
|
return new List<MusicBrainzRecording>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enriches a song with genre information from MusicBrainz.
|
||||||
|
/// First tries ISRC lookup, then falls back to title/artist search.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<string>> GetGenresForSongAsync(string title, string artist, string? isrc = null)
|
||||||
|
{
|
||||||
|
if (!_settings.Enabled)
|
||||||
|
{
|
||||||
|
return new List<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
MusicBrainzRecording? recording = null;
|
||||||
|
|
||||||
|
// Try ISRC lookup first (most accurate)
|
||||||
|
if (!string.IsNullOrEmpty(isrc))
|
||||||
|
{
|
||||||
|
recording = await LookupByIsrcAsync(isrc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to search if ISRC lookup failed or no ISRC provided
|
||||||
|
if (recording == null)
|
||||||
|
{
|
||||||
|
var recordings = await SearchRecordingsAsync(title, artist, limit: 1);
|
||||||
|
recording = recordings.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recording == null)
|
||||||
|
{
|
||||||
|
return new List<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract genres (prioritize official genres over tags)
|
||||||
|
var genres = new List<string>();
|
||||||
|
|
||||||
|
if (recording.Genres != null && recording.Genres.Count > 0)
|
||||||
|
{
|
||||||
|
// Get top genres by vote count
|
||||||
|
genres.AddRange(recording.Genres
|
||||||
|
.OrderByDescending(g => g.Count)
|
||||||
|
.Take(5)
|
||||||
|
.Select(g => g.Name)
|
||||||
|
.Where(n => !string.IsNullOrEmpty(n))
|
||||||
|
.Select(n => n!)
|
||||||
|
.ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Found {Count} genres for {Title} - {Artist}: {Genres}",
|
||||||
|
genres.Count, title, artist, string.Join(", ", genres));
|
||||||
|
|
||||||
|
return genres;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rate limiting to comply with MusicBrainz API rules (1 request per second).
|
||||||
|
/// </summary>
|
||||||
|
private async Task RateLimitAsync()
|
||||||
|
{
|
||||||
|
await _rateLimitSemaphore.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var timeSinceLastRequest = DateTime.UtcNow - _lastRequestTime;
|
||||||
|
var minInterval = TimeSpan.FromMilliseconds(_settings.RateLimitMs);
|
||||||
|
|
||||||
|
if (timeSinceLastRequest < minInterval)
|
||||||
|
{
|
||||||
|
var delay = minInterval - timeSinceLastRequest;
|
||||||
|
await Task.Delay(delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastRequestTime = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_rateLimitSemaphore.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.KebabCaseLower,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MusicBrainz ISRC lookup response.
|
||||||
|
/// </summary>
|
||||||
|
public class MusicBrainzIsrcResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("recordings")]
|
||||||
|
public List<MusicBrainzRecording>? Recordings { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MusicBrainz search response.
|
||||||
|
/// </summary>
|
||||||
|
public class MusicBrainzSearchResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("recordings")]
|
||||||
|
public List<MusicBrainzRecording>? Recordings { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("count")]
|
||||||
|
public int Count { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MusicBrainz recording.
|
||||||
|
/// </summary>
|
||||||
|
public class MusicBrainzRecording
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
public string? Id { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("title")]
|
||||||
|
public string? Title { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("length")]
|
||||||
|
public int? Length { get; set; } // in milliseconds
|
||||||
|
|
||||||
|
[JsonPropertyName("artist-credit")]
|
||||||
|
public List<MusicBrainzArtistCredit>? ArtistCredit { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("releases")]
|
||||||
|
public List<MusicBrainzRelease>? Releases { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("isrcs")]
|
||||||
|
public List<string>? Isrcs { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("genres")]
|
||||||
|
public List<MusicBrainzGenre>? Genres { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("tags")]
|
||||||
|
public List<MusicBrainzTag>? Tags { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MusicBrainz artist credit.
|
||||||
|
/// </summary>
|
||||||
|
public class MusicBrainzArtistCredit
|
||||||
|
{
|
||||||
|
[JsonPropertyName("name")]
|
||||||
|
public string? Name { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("artist")]
|
||||||
|
public MusicBrainzArtist? Artist { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MusicBrainz artist.
|
||||||
|
/// </summary>
|
||||||
|
public class MusicBrainzArtist
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
public string? Id { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("name")]
|
||||||
|
public string? Name { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MusicBrainz release.
|
||||||
|
/// </summary>
|
||||||
|
public class MusicBrainzRelease
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
public string? Id { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("title")]
|
||||||
|
public string? Title { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("date")]
|
||||||
|
public string? Date { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MusicBrainz genre.
|
||||||
|
/// </summary>
|
||||||
|
public class MusicBrainzGenre
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
public string? Id { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("name")]
|
||||||
|
public string? Name { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("count")]
|
||||||
|
public int Count { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MusicBrainz tag (folksonomy).
|
||||||
|
/// </summary>
|
||||||
|
public class MusicBrainzTag
|
||||||
|
{
|
||||||
|
[JsonPropertyName("name")]
|
||||||
|
public string? Name { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("count")]
|
||||||
|
public int Count { get; set; }
|
||||||
|
}
|
||||||
@@ -110,7 +110,10 @@ public class QobuzDownloadService : BaseDownloadService
|
|||||||
|
|
||||||
// Build organized folder structure using AlbumArtist (fallback to Artist for singles)
|
// Build organized folder structure using AlbumArtist (fallback to Artist for singles)
|
||||||
var artistForPath = song.AlbumArtist ?? song.Artist;
|
var artistForPath = song.AlbumArtist ?? song.Artist;
|
||||||
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 outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension);
|
||||||
|
|
||||||
var albumFolder = Path.GetDirectoryName(outputPath)!;
|
var albumFolder = Path.GetDirectoryName(outputPath)!;
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
|||||||
public async Task TriggerFetchAsync()
|
public async Task TriggerFetchAsync()
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Manual fetch triggered");
|
_logger.LogInformation("Manual fetch triggered");
|
||||||
await FetchMissingTracksAsync(CancellationToken.None, bypassSyncWindowCheck: true);
|
await FetchMissingTracksAsync(CancellationToken.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
@@ -84,19 +84,6 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
|||||||
|
|
||||||
_logger.LogInformation("Spotify Import ENABLED");
|
_logger.LogInformation("Spotify Import ENABLED");
|
||||||
_logger.LogInformation("Configured Playlists: {Count}", _spotifySettings.Value.Playlists.Count);
|
_logger.LogInformation("Configured Playlists: {Count}", _spotifySettings.Value.Playlists.Count);
|
||||||
|
|
||||||
// Log the search schedule
|
|
||||||
var settings = _spotifySettings.Value;
|
|
||||||
var syncTime = DateTime.Today
|
|
||||||
.AddHours(settings.SyncStartHour)
|
|
||||||
.AddMinutes(settings.SyncStartMinute);
|
|
||||||
var syncEndTime = syncTime.AddHours(settings.SyncWindowHours);
|
|
||||||
|
|
||||||
_logger.LogInformation("Search Schedule:");
|
|
||||||
_logger.LogInformation(" Plugin sync time: {Time:HH:mm} UTC (configured)", syncTime);
|
|
||||||
_logger.LogInformation(" Search window: {Start:HH:mm} - {End:HH:mm} UTC ({Hours}h window)",
|
|
||||||
syncTime, syncEndTime, settings.SyncWindowHours);
|
|
||||||
_logger.LogInformation(" Will search for new files once per day after sync window ends");
|
|
||||||
_logger.LogInformation("Background check interval: 5 minutes");
|
_logger.LogInformation("Background check interval: 5 minutes");
|
||||||
|
|
||||||
// Fetch playlist names from Jellyfin
|
// Fetch playlist names from Jellyfin
|
||||||
@@ -109,7 +96,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
|||||||
}
|
}
|
||||||
_logger.LogInformation("========================================");
|
_logger.LogInformation("========================================");
|
||||||
|
|
||||||
// Check if we should run on startup
|
// Run on startup if we don't have cache
|
||||||
if (!_hasRunOnce)
|
if (!_hasRunOnce)
|
||||||
{
|
{
|
||||||
var shouldRun = await ShouldRunOnStartupAsync();
|
var shouldRun = await ShouldRunOnStartupAsync();
|
||||||
@@ -118,7 +105,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
|||||||
_logger.LogInformation("Running initial fetch on startup");
|
_logger.LogInformation("Running initial fetch on startup");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await FetchMissingTracksAsync(stoppingToken, bypassSyncWindowCheck: true);
|
await FetchMissingTracksAsync(stoppingToken);
|
||||||
_hasRunOnce = true;
|
_hasRunOnce = true;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -128,21 +115,20 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Skipping startup fetch - already have current files");
|
_logger.LogInformation("Skipping startup fetch - already have cached files");
|
||||||
_hasRunOnce = true;
|
_hasRunOnce = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Background loop - check for new files every 5 minutes
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Only fetch if we're past today's sync window AND we haven't fetched today yet
|
|
||||||
var shouldFetch = await ShouldFetchNowAsync();
|
var shouldFetch = await ShouldFetchNowAsync();
|
||||||
if (shouldFetch)
|
if (shouldFetch)
|
||||||
{
|
{
|
||||||
await FetchMissingTracksAsync(stoppingToken);
|
await FetchMissingTracksAsync(stoppingToken);
|
||||||
_hasRunOnce = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -156,42 +142,29 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
|||||||
|
|
||||||
private async Task<bool> ShouldFetchNowAsync()
|
private async Task<bool> ShouldFetchNowAsync()
|
||||||
{
|
{
|
||||||
var settings = _spotifySettings.Value;
|
// Check if we have recent cache files (within last 24 hours)
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
|
var cacheThreshold = now.AddHours(-24);
|
||||||
|
|
||||||
// Calculate today's sync window
|
|
||||||
var todaySync = now.Date
|
|
||||||
.AddHours(settings.SyncStartHour)
|
|
||||||
.AddMinutes(settings.SyncStartMinute);
|
|
||||||
var todaySyncEnd = todaySync.AddHours(settings.SyncWindowHours);
|
|
||||||
|
|
||||||
// Only fetch if we're past today's sync window
|
|
||||||
if (now < todaySyncEnd)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we already have today's files
|
|
||||||
foreach (var playlistName in _playlistIdToName.Values)
|
foreach (var playlistName in _playlistIdToName.Values)
|
||||||
{
|
{
|
||||||
var filePath = GetCacheFilePath(playlistName);
|
var filePath = GetCacheFilePath(playlistName);
|
||||||
|
|
||||||
if (File.Exists(filePath))
|
if (!File.Exists(filePath))
|
||||||
{
|
{
|
||||||
var fileTime = File.GetLastWriteTimeUtc(filePath);
|
// Missing cache file for this playlist
|
||||||
|
|
||||||
// If file is from today's sync or later, we already have it
|
|
||||||
if (fileTime >= todaySync)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Missing today's file for this playlist
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// All playlists have today's files
|
var fileTime = File.GetLastWriteTimeUtc(filePath);
|
||||||
|
if (fileTime < cacheThreshold)
|
||||||
|
{
|
||||||
|
// Cache file is older than 24 hours
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All playlists have recent cache files
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,25 +183,6 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
|||||||
{
|
{
|
||||||
_logger.LogInformation("=== STARTUP CACHE CHECK ===");
|
_logger.LogInformation("=== STARTUP CACHE CHECK ===");
|
||||||
|
|
||||||
var settings = _spotifySettings.Value;
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
|
|
||||||
// Calculate today's sync window
|
|
||||||
var todaySync = now.Date
|
|
||||||
.AddHours(settings.SyncStartHour)
|
|
||||||
.AddMinutes(settings.SyncStartMinute);
|
|
||||||
var todaySyncEnd = todaySync.AddHours(settings.SyncWindowHours);
|
|
||||||
|
|
||||||
_logger.LogInformation("Today's sync window: {Start:yyyy-MM-dd HH:mm} - {End:yyyy-MM-dd HH:mm} UTC",
|
|
||||||
todaySync, todaySyncEnd);
|
|
||||||
_logger.LogInformation("Current time: {Now:yyyy-MM-dd HH:mm} UTC", now);
|
|
||||||
|
|
||||||
// If we're still before today's sync window end, we should have yesterday's or today's file
|
|
||||||
// Don't search again until after today's sync window ends
|
|
||||||
if (now < todaySyncEnd)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("We're before today's sync window end - checking if we have recent cache...");
|
|
||||||
|
|
||||||
var allPlaylistsHaveCache = true;
|
var allPlaylistsHaveCache = true;
|
||||||
|
|
||||||
foreach (var playlistName in _playlistIdToName.Values)
|
foreach (var playlistName in _playlistIdToName.Values)
|
||||||
@@ -265,66 +219,8 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
|||||||
if (allPlaylistsHaveCache)
|
if (allPlaylistsHaveCache)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("=== ALL PLAYLISTS HAVE CACHE - SKIPPING STARTUP FETCH ===");
|
_logger.LogInformation("=== ALL PLAYLISTS HAVE CACHE - SKIPPING STARTUP FETCH ===");
|
||||||
_logger.LogInformation("Will search again after {Time:yyyy-MM-dd HH:mm} UTC", todaySyncEnd);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// If we're after today's sync window end, check if we already have today's file
|
|
||||||
if (now >= todaySyncEnd)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("We're after today's sync window end - checking if we already fetched today's files...");
|
|
||||||
|
|
||||||
var allPlaylistsHaveTodaysFile = true;
|
|
||||||
|
|
||||||
foreach (var playlistName in _playlistIdToName.Values)
|
|
||||||
{
|
|
||||||
var filePath = GetCacheFilePath(playlistName);
|
|
||||||
var cacheKey = $"spotify:missing:{playlistName}";
|
|
||||||
|
|
||||||
// Check if file exists and was created today (after sync start)
|
|
||||||
if (File.Exists(filePath))
|
|
||||||
{
|
|
||||||
var fileTime = File.GetLastWriteTimeUtc(filePath);
|
|
||||||
|
|
||||||
// File should be from today's sync window or later
|
|
||||||
if (fileTime >= todaySync)
|
|
||||||
{
|
|
||||||
var fileAge = DateTime.UtcNow - fileTime;
|
|
||||||
_logger.LogInformation(" {Playlist}: Have today's file (created {Time:yyyy-MM-dd HH:mm}, age: {Age:F1}h)",
|
|
||||||
playlistName, fileTime, fileAge.TotalHours);
|
|
||||||
|
|
||||||
// Load into Redis if not already there
|
|
||||||
if (!await _cache.ExistsAsync(cacheKey))
|
|
||||||
{
|
|
||||||
await LoadFromFileCache(playlistName);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogInformation(" {Playlist}: File is old (from {Time:yyyy-MM-dd HH:mm}, before today's sync)",
|
|
||||||
playlistName, fileTime);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogInformation(" {Playlist}: No file found", playlistName);
|
|
||||||
}
|
|
||||||
|
|
||||||
allPlaylistsHaveTodaysFile = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allPlaylistsHaveTodaysFile)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("=== ALL PLAYLISTS HAVE TODAY'S FILES - SKIPPING STARTUP FETCH ===");
|
|
||||||
|
|
||||||
// Calculate when to search next (tomorrow after sync window)
|
|
||||||
var tomorrowSyncEnd = todaySyncEnd.AddDays(1);
|
|
||||||
_logger.LogInformation("Will search again after {Time:yyyy-MM-dd HH:mm} UTC", tomorrowSyncEnd);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("=== WILL FETCH ON STARTUP ===");
|
_logger.LogInformation("=== WILL FETCH ON STARTUP ===");
|
||||||
return true;
|
return true;
|
||||||
@@ -380,32 +276,9 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task FetchMissingTracksAsync(CancellationToken cancellationToken, bool bypassSyncWindowCheck = false)
|
private async Task FetchMissingTracksAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var settings = _spotifySettings.Value;
|
_logger.LogInformation("=== FETCHING MISSING TRACKS ===");
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
var syncStart = now.Date
|
|
||||||
.AddHours(settings.SyncStartHour)
|
|
||||||
.AddMinutes(settings.SyncStartMinute);
|
|
||||||
var syncEnd = syncStart.AddHours(settings.SyncWindowHours);
|
|
||||||
|
|
||||||
// Only run after the sync window has passed (unless bypassing for startup)
|
|
||||||
if (!bypassSyncWindowCheck && now < syncEnd)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Skipping fetch - sync window not passed yet (now: {Now}, window ends: {End})",
|
|
||||||
now, syncEnd);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bypassSyncWindowCheck)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("=== FETCHING MISSING TRACKS (STARTUP MODE) ===");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogInformation("=== FETCHING MISSING TRACKS (SYNC WINDOW PASSED) ===");
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("Processing {Count} playlists", _playlistIdToName.Count);
|
_logger.LogInformation("Processing {Count} playlists", _playlistIdToName.Count);
|
||||||
|
|
||||||
// Track when we find files to optimize search for other playlists
|
// Track when we find files to optimize search for other playlists
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using allstarr.Models.Settings;
|
|||||||
using allstarr.Models.Spotify;
|
using allstarr.Models.Spotify;
|
||||||
using allstarr.Services.Common;
|
using allstarr.Services.Common;
|
||||||
using allstarr.Services.Jellyfin;
|
using allstarr.Services.Jellyfin;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
@@ -26,6 +27,8 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
private readonly IServiceProvider _serviceProvider;
|
private readonly IServiceProvider _serviceProvider;
|
||||||
private const int DelayBetweenSearchesMs = 150; // 150ms = ~6.6 searches/second to avoid rate limiting
|
private const int DelayBetweenSearchesMs = 150; // 150ms = ~6.6 searches/second to avoid rate limiting
|
||||||
private const int BatchSize = 11; // Number of parallel searches (matches SquidWTF provider count)
|
private const int BatchSize = 11; // Number of parallel searches (matches SquidWTF provider count)
|
||||||
|
private DateTime _lastMatchingRun = DateTime.MinValue;
|
||||||
|
private readonly TimeSpan _minimumMatchingInterval = TimeSpan.FromMinutes(5); // Don't run more than once per 5 minutes
|
||||||
|
|
||||||
public SpotifyTrackMatchingService(
|
public SpotifyTrackMatchingService(
|
||||||
IOptions<SpotifyImportSettings> spotifySettings,
|
IOptions<SpotifyImportSettings> spotifySettings,
|
||||||
@@ -41,6 +44,17 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper method to safely check if a dynamic cache result has a value
|
||||||
|
/// Handles the case where JsonElement cannot be compared to null directly
|
||||||
|
/// </summary>
|
||||||
|
private static bool HasValue(object? obj)
|
||||||
|
{
|
||||||
|
if (obj == null) return false;
|
||||||
|
if (obj is JsonElement jsonEl) return jsonEl.ValueKind != JsonValueKind.Null && jsonEl.ValueKind != JsonValueKind.Undefined;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("SpotifyTrackMatchingService: Starting up...");
|
_logger.LogInformation("SpotifyTrackMatchingService: Starting up...");
|
||||||
@@ -72,8 +86,15 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
// Now start the periodic matching loop
|
// Now start the periodic matching loop
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
// Wait 30 minutes before next run
|
// Wait for configured interval before next run (default 24 hours)
|
||||||
await Task.Delay(TimeSpan.FromMinutes(30), stoppingToken);
|
var intervalHours = _spotifySettings.MatchingIntervalHours;
|
||||||
|
if (intervalHours <= 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Periodic matching disabled (MatchingIntervalHours = {Hours}), only startup run will execute", intervalHours);
|
||||||
|
break; // Exit loop - only run once on startup
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromHours(intervalHours), stoppingToken);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -145,7 +166,17 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
|
|
||||||
private async Task MatchAllPlaylistsAsync(CancellationToken cancellationToken)
|
private async Task MatchAllPlaylistsAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
// Check if we've run too recently (cooldown period)
|
||||||
|
var timeSinceLastRun = DateTime.UtcNow - _lastMatchingRun;
|
||||||
|
if (timeSinceLastRun < _minimumMatchingInterval)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Skipping track matching - last run was {Seconds}s ago (minimum interval: {MinSeconds}s)",
|
||||||
|
(int)timeSinceLastRun.TotalSeconds, (int)_minimumMatchingInterval.TotalSeconds);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_logger.LogInformation("=== STARTING TRACK MATCHING ===");
|
_logger.LogInformation("=== STARTING TRACK MATCHING ===");
|
||||||
|
_lastMatchingRun = DateTime.UtcNow;
|
||||||
|
|
||||||
var playlists = _spotifySettings.Playlists;
|
var playlists = _spotifySettings.Playlists;
|
||||||
if (playlists.Count == 0)
|
if (playlists.Count == 0)
|
||||||
@@ -196,6 +227,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
/// New matching mode that uses ISRC when available for exact matches.
|
/// New matching mode that uses ISRC when available for exact matches.
|
||||||
/// Preserves track position for correct playlist ordering.
|
/// Preserves track position for correct playlist ordering.
|
||||||
/// Only matches tracks that aren't already in the Jellyfin playlist.
|
/// Only matches tracks that aren't already in the Jellyfin playlist.
|
||||||
|
/// Uses GREEDY ASSIGNMENT to maximize total matches.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task MatchPlaylistTracksWithIsrcAsync(
|
private async Task MatchPlaylistTracksWithIsrcAsync(
|
||||||
string playlistName,
|
string playlistName,
|
||||||
@@ -233,19 +265,19 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
// CRITICAL: Must include UserId parameter or Jellyfin returns empty results
|
// CRITICAL: Must include UserId parameter or Jellyfin returns empty results
|
||||||
var userId = jellyfinSettings.UserId;
|
var userId = jellyfinSettings.UserId;
|
||||||
var playlistItemsUrl = $"Playlists/{playlistConfig.JellyfinId}/Items";
|
var playlistItemsUrl = $"Playlists/{playlistConfig.JellyfinId}/Items";
|
||||||
|
var queryParams = new Dictionary<string, string>();
|
||||||
if (!string.IsNullOrEmpty(userId))
|
if (!string.IsNullOrEmpty(userId))
|
||||||
{
|
{
|
||||||
playlistItemsUrl += $"?UserId={userId}";
|
queryParams["UserId"] = userId;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogWarning("No UserId configured - may not be able to fetch existing playlist tracks for {Playlist}", playlistName);
|
_logger.LogWarning("No UserId configured - may not be able to fetch existing playlist tracks for {Playlist}", playlistName);
|
||||||
}
|
}
|
||||||
|
|
||||||
var (existingTracksResponse, _) = await proxyService.GetJsonAsync(
|
var (existingTracksResponse, _) = await proxyService.GetJsonAsyncInternal(
|
||||||
playlistItemsUrl,
|
playlistItemsUrl,
|
||||||
null,
|
queryParams);
|
||||||
null);
|
|
||||||
|
|
||||||
if (existingTracksResponse != null &&
|
if (existingTracksResponse != null &&
|
||||||
existingTracksResponse.RootElement.TryGetProperty("Items", out var items))
|
existingTracksResponse.RootElement.TryGetProperty("Items", out var items))
|
||||||
@@ -289,23 +321,56 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Matching {ToMatch}/{Total} tracks for {Playlist} (skipping {Existing} already in Jellyfin, ISRC: {IsrcEnabled})",
|
_logger.LogInformation("Matching {ToMatch}/{Total} tracks for {Playlist} (skipping {Existing} already in Jellyfin, ISRC: {IsrcEnabled}, AGGRESSIVE MODE)",
|
||||||
tracksToMatch.Count, spotifyTracks.Count, playlistName, existingSpotifyIds.Count, _spotifyApiSettings.PreferIsrcMatching);
|
tracksToMatch.Count, spotifyTracks.Count, playlistName, existingSpotifyIds.Count, _spotifyApiSettings.PreferIsrcMatching);
|
||||||
|
|
||||||
// Check cache - use snapshot/timestamp to detect changes
|
// Check cache - use snapshot/timestamp to detect changes
|
||||||
var existingMatched = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
|
var existingMatched = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
|
||||||
if (existingMatched != null && existingMatched.Count >= tracksToMatch.Count)
|
|
||||||
|
// CRITICAL: Skip matching if cache exists and is valid
|
||||||
|
// Only re-match if cache is missing OR if we detect manual mappings that need to be applied
|
||||||
|
if (existingMatched != null && existingMatched.Count > 0)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Playlist {Playlist} already has {Count} matched tracks cached, skipping",
|
// Check if we have NEW manual mappings that aren't in the cache
|
||||||
playlistName, existingMatched.Count);
|
var hasNewManualMappings = false;
|
||||||
|
foreach (var track in tracksToMatch)
|
||||||
|
{
|
||||||
|
// Check if this track has a manual mapping but isn't in the cached results
|
||||||
|
var manualMappingKey = $"spotify:manual-map:{playlistName}:{track.SpotifyId}";
|
||||||
|
var manualMapping = await _cache.GetAsync<string>(manualMappingKey);
|
||||||
|
|
||||||
|
var externalMappingKey = $"spotify:external-map:{playlistName}:{track.SpotifyId}";
|
||||||
|
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||||
|
|
||||||
|
var hasManualMapping = !string.IsNullOrEmpty(manualMapping) || !string.IsNullOrEmpty(externalMappingJson);
|
||||||
|
var isInCache = existingMatched.Any(m => m.SpotifyId == track.SpotifyId);
|
||||||
|
|
||||||
|
// If track has manual mapping but isn't in cache, we need to rebuild
|
||||||
|
if (hasManualMapping && !isInCache)
|
||||||
|
{
|
||||||
|
hasNewManualMappings = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasNewManualMappings)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("✓ Playlist {Playlist} already has {Count} matched tracks cached (skipping {ToMatch} new tracks), no re-matching needed",
|
||||||
|
playlistName, existingMatched.Count, tracksToMatch.Count);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("New manual mappings detected for {Playlist}, rebuilding cache to apply them", playlistName);
|
||||||
|
}
|
||||||
|
|
||||||
var matchedTracks = new List<MatchedTrack>();
|
var matchedTracks = new List<MatchedTrack>();
|
||||||
var isrcMatches = 0;
|
var isrcMatches = 0;
|
||||||
var fuzzyMatches = 0;
|
var fuzzyMatches = 0;
|
||||||
var noMatch = 0;
|
var noMatch = 0;
|
||||||
|
|
||||||
|
// GREEDY ASSIGNMENT: Collect all possible matches first, then assign optimally
|
||||||
|
var allCandidates = new List<(SpotifyPlaylistTrack SpotifyTrack, Song MatchedSong, double Score, string MatchType)>();
|
||||||
|
|
||||||
// Process tracks in batches for parallel searching
|
// Process tracks in batches for parallel searching
|
||||||
var orderedTracks = tracksToMatch.OrderBy(t => t.Position).ToList();
|
var orderedTracks = tracksToMatch.OrderBy(t => t.Position).ToList();
|
||||||
for (int i = 0; i < orderedTracks.Count; i += BatchSize)
|
for (int i = 0; i < orderedTracks.Count; i += BatchSize)
|
||||||
@@ -321,34 +386,86 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Song? matchedSong = null;
|
var candidates = new List<(Song Song, double Score, string MatchType)>();
|
||||||
var matchType = "none";
|
|
||||||
|
|
||||||
// Try ISRC match first if available and enabled
|
// Try ISRC match first if available and enabled
|
||||||
if (_spotifyApiSettings.PreferIsrcMatching && !string.IsNullOrEmpty(spotifyTrack.Isrc))
|
if (_spotifyApiSettings.PreferIsrcMatching && !string.IsNullOrEmpty(spotifyTrack.Isrc))
|
||||||
{
|
{
|
||||||
matchedSong = await TryMatchByIsrcAsync(spotifyTrack.Isrc, metadataService);
|
var isrcSong = await TryMatchByIsrcAsync(spotifyTrack.Isrc, metadataService);
|
||||||
if (matchedSong != null)
|
if (isrcSong != null)
|
||||||
{
|
{
|
||||||
matchType = "isrc";
|
candidates.Add((isrcSong, 100.0, "isrc"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to fuzzy matching
|
// Always try fuzzy matching to get more candidates
|
||||||
if (matchedSong == null)
|
var fuzzySongs = await TryMatchByFuzzyMultipleAsync(
|
||||||
{
|
|
||||||
matchedSong = await TryMatchByFuzzyAsync(
|
|
||||||
spotifyTrack.Title,
|
spotifyTrack.Title,
|
||||||
spotifyTrack.Artists,
|
spotifyTrack.Artists,
|
||||||
metadataService);
|
metadataService);
|
||||||
|
|
||||||
if (matchedSong != null)
|
foreach (var (song, score) in fuzzySongs)
|
||||||
{
|
{
|
||||||
matchType = "fuzzy";
|
candidates.Add((song, score, "fuzzy"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (spotifyTrack, candidates);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
|
||||||
|
spotifyTrack.Title, spotifyTrack.PrimaryArtist);
|
||||||
|
return (spotifyTrack, new List<(Song, double, string)>());
|
||||||
|
}
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
// Wait for all tracks in this batch to complete
|
||||||
|
var batchResults = await Task.WhenAll(batchTasks);
|
||||||
|
|
||||||
|
// Collect all candidates
|
||||||
|
foreach (var (spotifyTrack, candidates) in batchResults)
|
||||||
|
{
|
||||||
|
foreach (var (song, score, matchType) in candidates)
|
||||||
|
{
|
||||||
|
allCandidates.Add((spotifyTrack, song, score, matchType));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matchedSong != null)
|
// Rate limiting between batches
|
||||||
|
if (i + BatchSize < orderedTracks.Count)
|
||||||
|
{
|
||||||
|
await Task.Delay(DelayBetweenSearchesMs, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GREEDY ASSIGNMENT: Assign each Spotify track to its best unique match
|
||||||
|
var usedSongIds = new HashSet<string>();
|
||||||
|
var assignments = new Dictionary<string, (Song Song, double Score, string MatchType)>();
|
||||||
|
|
||||||
|
// Sort candidates by score (highest first)
|
||||||
|
var sortedCandidates = allCandidates
|
||||||
|
.OrderByDescending(c => c.Score)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var (spotifyTrack, song, score, matchType) in sortedCandidates)
|
||||||
|
{
|
||||||
|
// Skip if this Spotify track already has a match
|
||||||
|
if (assignments.ContainsKey(spotifyTrack.SpotifyId))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Skip if this song is already used
|
||||||
|
if (usedSongIds.Contains(song.Id))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Assign this match
|
||||||
|
assignments[spotifyTrack.SpotifyId] = (song, score, matchType);
|
||||||
|
usedSongIds.Add(song.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build final matched tracks list
|
||||||
|
foreach (var spotifyTrack in orderedTracks)
|
||||||
|
{
|
||||||
|
if (assignments.TryGetValue(spotifyTrack.SpotifyId, out var match))
|
||||||
{
|
{
|
||||||
var matched = new MatchedTrack
|
var matched = new MatchedTrack
|
||||||
{
|
{
|
||||||
@@ -357,53 +474,24 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
SpotifyTitle = spotifyTrack.Title,
|
SpotifyTitle = spotifyTrack.Title,
|
||||||
SpotifyArtist = spotifyTrack.PrimaryArtist,
|
SpotifyArtist = spotifyTrack.PrimaryArtist,
|
||||||
Isrc = spotifyTrack.Isrc,
|
Isrc = spotifyTrack.Isrc,
|
||||||
MatchType = matchType,
|
MatchType = match.MatchType,
|
||||||
MatchedSong = matchedSong
|
MatchedSong = match.Song
|
||||||
};
|
};
|
||||||
|
|
||||||
_logger.LogDebug(" #{Position} {Title} - {Artist} → {MatchType} match: {MatchedTitle}",
|
|
||||||
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist,
|
|
||||||
matchType, matchedSong.Title);
|
|
||||||
|
|
||||||
return (matched, matchType);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogDebug(" #{Position} {Title} - {Artist} → no match",
|
|
||||||
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist);
|
|
||||||
return (null, "none");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
|
|
||||||
spotifyTrack.Title, spotifyTrack.PrimaryArtist);
|
|
||||||
return (null, "none");
|
|
||||||
}
|
|
||||||
}).ToList();
|
|
||||||
|
|
||||||
// Wait for all tracks in this batch to complete
|
|
||||||
var batchResults = await Task.WhenAll(batchTasks);
|
|
||||||
|
|
||||||
// Collect results
|
|
||||||
foreach (var (matched, matchType) in batchResults)
|
|
||||||
{
|
|
||||||
if (matched != null)
|
|
||||||
{
|
|
||||||
matchedTracks.Add(matched);
|
matchedTracks.Add(matched);
|
||||||
if (matchType == "isrc") isrcMatches++;
|
|
||||||
else if (matchType == "fuzzy") fuzzyMatches++;
|
if (match.MatchType == "isrc") isrcMatches++;
|
||||||
|
else if (match.MatchType == "fuzzy") fuzzyMatches++;
|
||||||
|
|
||||||
|
_logger.LogDebug(" #{Position} {Title} - {Artist} → {MatchType} match (score: {Score:F1}): {MatchedTitle}",
|
||||||
|
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist,
|
||||||
|
match.MatchType, match.Score, match.Song.Title);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
noMatch++;
|
noMatch++;
|
||||||
}
|
_logger.LogDebug(" #{Position} {Title} - {Artist} → no match",
|
||||||
}
|
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist);
|
||||||
|
|
||||||
// Rate limiting between batches (not between individual tracks)
|
|
||||||
if (i + BatchSize < orderedTracks.Count)
|
|
||||||
{
|
|
||||||
await Task.Delay(DelayBetweenSearchesMs, cancellationToken);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,14 +500,21 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
// Cache matched tracks with position data
|
// Cache matched tracks with position data
|
||||||
await _cache.SetAsync(matchedTracksKey, matchedTracks, TimeSpan.FromHours(1));
|
await _cache.SetAsync(matchedTracksKey, matchedTracks, TimeSpan.FromHours(1));
|
||||||
|
|
||||||
|
// Save matched tracks to file for persistence across restarts
|
||||||
|
await SaveMatchedTracksToFileAsync(playlistName, matchedTracks);
|
||||||
|
|
||||||
// Also update legacy cache for backward compatibility
|
// Also update legacy cache for backward compatibility
|
||||||
var legacyKey = $"spotify:matched:{playlistName}";
|
var legacyKey = $"spotify:matched:{playlistName}";
|
||||||
var legacySongs = matchedTracks.OrderBy(t => t.Position).Select(t => t.MatchedSong).ToList();
|
var legacySongs = matchedTracks.OrderBy(t => t.Position).Select(t => t.MatchedSong).ToList();
|
||||||
await _cache.SetAsync(legacyKey, legacySongs, TimeSpan.FromHours(1));
|
await _cache.SetAsync(legacyKey, legacySongs, TimeSpan.FromHours(1));
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"✓ Cached {Matched}/{Total} tracks for {Playlist} (ISRC: {Isrc}, Fuzzy: {Fuzzy}, No match: {NoMatch})",
|
"✓ Cached {Matched}/{Total} tracks for {Playlist} via GREEDY ASSIGNMENT (ISRC: {Isrc}, Fuzzy: {Fuzzy}, No match: {NoMatch}) - manual mappings will be applied next",
|
||||||
matchedTracks.Count, tracksToMatch.Count, playlistName, isrcMatches, fuzzyMatches, noMatch);
|
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
|
else
|
||||||
{
|
{
|
||||||
@@ -427,6 +522,64 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns multiple candidate matches with scores for greedy assignment.
|
||||||
|
/// FOLLOWS OPTIMAL ORDER:
|
||||||
|
/// 1. Strip decorators (done in FuzzyMatcher)
|
||||||
|
/// 2. Substring matching (done in FuzzyMatcher)
|
||||||
|
/// 3. Levenshtein distance (done in FuzzyMatcher)
|
||||||
|
/// This method just collects candidates; greedy assignment happens later.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<List<(Song Song, double Score)>> TryMatchByFuzzyMultipleAsync(
|
||||||
|
string title,
|
||||||
|
List<string> artists,
|
||||||
|
IMusicMetadataService metadataService)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var primaryArtist = artists.FirstOrDefault() ?? "";
|
||||||
|
|
||||||
|
// STEP 1: Strip decorators FIRST (before searching)
|
||||||
|
var titleStripped = FuzzyMatcher.StripDecorators(title);
|
||||||
|
var query = $"{titleStripped} {primaryArtist}";
|
||||||
|
|
||||||
|
var results = await metadataService.SearchSongsAsync(query, limit: 10);
|
||||||
|
|
||||||
|
if (results.Count == 0) return new List<(Song, double)>();
|
||||||
|
|
||||||
|
// STEP 2-3: Score all results (substring + Levenshtein already in CalculateSimilarityAggressive)
|
||||||
|
var scoredResults = results
|
||||||
|
.Select(song => new
|
||||||
|
{
|
||||||
|
Song = song,
|
||||||
|
// Use aggressive matching which follows optimal order internally
|
||||||
|
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
|
||||||
|
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
||||||
|
})
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Song,
|
||||||
|
x.TitleScore,
|
||||||
|
x.ArtistScore,
|
||||||
|
// Weight: 70% title, 30% artist (prioritize title matching)
|
||||||
|
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
|
||||||
|
})
|
||||||
|
.Where(x =>
|
||||||
|
x.TotalScore >= 40 ||
|
||||||
|
(x.ArtistScore >= 70 && x.TitleScore >= 30) ||
|
||||||
|
x.TitleScore >= 85)
|
||||||
|
.OrderByDescending(x => x.TotalScore)
|
||||||
|
.Select(x => (x.Song, x.TotalScore))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return scoredResults;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new List<(Song, double)>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Attempts to match a track by ISRC using provider search.
|
/// Attempts to match a track by ISRC using provider search.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -456,7 +609,12 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Attempts to match a track by title and artist using fuzzy matching.
|
/// Attempts to match a track by title and artist using AGGRESSIVE fuzzy matching.
|
||||||
|
/// FOLLOWS OPTIMAL ORDER:
|
||||||
|
/// 1. Strip decorators FIRST (before searching)
|
||||||
|
/// 2. Substring matching (in FuzzyMatcher)
|
||||||
|
/// 3. Levenshtein distance (in FuzzyMatcher)
|
||||||
|
/// PRIORITY: Match as many tracks as possible, even with lower confidence.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<Song?> TryMatchByFuzzyAsync(
|
private async Task<Song?> TryMatchByFuzzyAsync(
|
||||||
string title,
|
string title,
|
||||||
@@ -466,30 +624,62 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var primaryArtist = artists.FirstOrDefault() ?? "";
|
var primaryArtist = artists.FirstOrDefault() ?? "";
|
||||||
var query = $"{title} {primaryArtist}";
|
|
||||||
var results = await metadataService.SearchSongsAsync(query, limit: 5);
|
// STEP 1: Strip decorators FIRST (before searching)
|
||||||
|
var titleStripped = FuzzyMatcher.StripDecorators(title);
|
||||||
|
var query = $"{titleStripped} {primaryArtist}";
|
||||||
|
|
||||||
|
var results = await metadataService.SearchSongsAsync(query, limit: 10);
|
||||||
|
|
||||||
if (results.Count == 0) return null;
|
if (results.Count == 0) return null;
|
||||||
|
|
||||||
var bestMatch = results
|
// STEP 2-3: Score all results (substring + Levenshtein in CalculateSimilarityAggressive)
|
||||||
|
var scoredResults = results
|
||||||
.Select(song => new
|
.Select(song => new
|
||||||
{
|
{
|
||||||
Song = song,
|
Song = song,
|
||||||
TitleScore = FuzzyMatcher.CalculateSimilarity(title, song.Title),
|
// Use aggressive matching which follows optimal order internally
|
||||||
ArtistScore = CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
|
||||||
|
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
||||||
})
|
})
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
x.Song,
|
x.Song,
|
||||||
x.TitleScore,
|
x.TitleScore,
|
||||||
x.ArtistScore,
|
x.ArtistScore,
|
||||||
TotalScore = (x.TitleScore * 0.6) + (x.ArtistScore * 0.4)
|
// Weight: 70% title, 30% artist (prioritize title matching)
|
||||||
|
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
|
||||||
})
|
})
|
||||||
.OrderByDescending(x => x.TotalScore)
|
.OrderByDescending(x => x.TotalScore)
|
||||||
.FirstOrDefault();
|
.ToList();
|
||||||
|
|
||||||
if (bestMatch != null && bestMatch.TotalScore >= 60)
|
var bestMatch = scoredResults.FirstOrDefault();
|
||||||
|
|
||||||
|
if (bestMatch == null) return null;
|
||||||
|
|
||||||
|
// AGGRESSIVE: Accept matches with score >= 40 (was 50)
|
||||||
|
if (bestMatch.TotalScore >= 40)
|
||||||
{
|
{
|
||||||
|
_logger.LogDebug("✓ Matched (score: {Score:F1}, title: {TitleScore}, artist: {ArtistScore}): {SpotifyTitle} → {MatchedTitle}",
|
||||||
|
bestMatch.TotalScore, bestMatch.TitleScore, bestMatch.ArtistScore, title, bestMatch.Song.Title);
|
||||||
|
return bestMatch.Song;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SUPER AGGRESSIVE: If artist matches well (70+), accept even lower title scores
|
||||||
|
// This handles cases like "a" → "a-blah" where artist is the same
|
||||||
|
if (bestMatch.ArtistScore >= 70 && bestMatch.TitleScore >= 30)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("✓ Matched via artist priority (artist: {ArtistScore}, title: {TitleScore}): {SpotifyTitle} → {MatchedTitle}",
|
||||||
|
bestMatch.ArtistScore, bestMatch.TitleScore, title, bestMatch.Song.Title);
|
||||||
|
return bestMatch.Song;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ULTRA AGGRESSIVE: If title has high substring match (85+), accept it
|
||||||
|
// This handles "luther" → "luther (feat. sza)"
|
||||||
|
if (bestMatch.TitleScore >= 85)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("✓ Matched via substring (title: {TitleScore}): {SpotifyTitle} → {MatchedTitle}",
|
||||||
|
bestMatch.TitleScore, title, bestMatch.Song.Title);
|
||||||
return bestMatch.Song;
|
return bestMatch.Song;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -554,7 +744,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
Song = song,
|
Song = song,
|
||||||
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, song.Title),
|
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, song.Title),
|
||||||
// Calculate artist score by checking ALL artists match
|
// 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
|
.Select(x => new
|
||||||
{
|
{
|
||||||
@@ -649,4 +839,437 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
|
|
||||||
return avgScore;
|
return avgScore;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pre-builds the playlist items cache for instant serving.
|
||||||
|
/// This combines local Jellyfin tracks with external matched tracks in the correct Spotify order.
|
||||||
|
/// </summary>
|
||||||
|
private async Task PreBuildPlaylistItemsCacheAsync(
|
||||||
|
string playlistName,
|
||||||
|
string? jellyfinPlaylistId,
|
||||||
|
List<SpotifyPlaylistTrack> spotifyTracks,
|
||||||
|
List<MatchedTrack> matchedTracks,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogInformation("🔨 Pre-building playlist items cache for {Playlist}...", playlistName);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(jellyfinPlaylistId))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("No Jellyfin playlist ID configured for {Playlist}, cannot pre-build cache", playlistName);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get existing tracks from Jellyfin playlist
|
||||||
|
using var scope = _serviceProvider.CreateScope();
|
||||||
|
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
|
||||||
|
var responseBuilder = scope.ServiceProvider.GetService<JellyfinResponseBuilder>();
|
||||||
|
var jellyfinSettings = scope.ServiceProvider.GetService<IOptions<JellyfinSettings>>()?.Value;
|
||||||
|
|
||||||
|
if (proxyService == null || responseBuilder == null || jellyfinSettings == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Required services not available for pre-building cache");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var userId = jellyfinSettings.UserId;
|
||||||
|
if (string.IsNullOrEmpty(userId))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("No UserId configured, cannot pre-build playlist cache for {Playlist}", playlistName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create authentication headers for background service call
|
||||||
|
var headers = new HeaderDictionary();
|
||||||
|
if (!string.IsNullOrEmpty(jellyfinSettings.ApiKey))
|
||||||
|
{
|
||||||
|
headers["X-Emby-Authorization"] = $"MediaBrowser Token=\"{jellyfinSettings.ApiKey}\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items?UserId={userId}&Fields=MediaSources";
|
||||||
|
var (existingTracksResponse, statusCode) = await proxyService.GetJsonAsync(playlistItemsUrl, null, headers);
|
||||||
|
|
||||||
|
if (statusCode != 200 || existingTracksResponse == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Failed to fetch Jellyfin playlist items for {Playlist}: HTTP {StatusCode}", playlistName, statusCode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index Jellyfin items by title+artist for matching
|
||||||
|
var jellyfinItemsByName = new Dictionary<string, JsonElement>();
|
||||||
|
|
||||||
|
if (existingTracksResponse.RootElement.TryGetProperty("Items", out var items))
|
||||||
|
{
|
||||||
|
foreach (var item in items.EnumerateArray())
|
||||||
|
{
|
||||||
|
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
|
||||||
|
var artist = "";
|
||||||
|
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
|
||||||
|
{
|
||||||
|
artist = artistsEl[0].GetString() ?? "";
|
||||||
|
}
|
||||||
|
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
|
||||||
|
{
|
||||||
|
artist = albumArtistEl.GetString() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = $"{title}|{artist}".ToLowerInvariant();
|
||||||
|
if (!jellyfinItemsByName.ContainsKey(key))
|
||||||
|
{
|
||||||
|
jellyfinItemsByName[key] = item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the final track list in correct Spotify order
|
||||||
|
var finalItems = new List<Dictionary<string, object?>>();
|
||||||
|
var usedJellyfinItems = new HashSet<string>();
|
||||||
|
var localUsedCount = 0;
|
||||||
|
var externalUsedCount = 0;
|
||||||
|
var manualExternalCount = 0;
|
||||||
|
|
||||||
|
foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position))
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested) break;
|
||||||
|
|
||||||
|
JsonElement? matchedJellyfinItem = null;
|
||||||
|
string? matchedKey = null;
|
||||||
|
|
||||||
|
// FIRST: Check for manual Jellyfin mapping
|
||||||
|
var manualMappingKey = $"spotify:manual-map:{playlistName}:{spotifyTrack.SpotifyId}";
|
||||||
|
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(manualJellyfinId))
|
||||||
|
{
|
||||||
|
// Find the Jellyfin item by ID
|
||||||
|
foreach (var kvp in jellyfinItemsByName)
|
||||||
|
{
|
||||||
|
var item = kvp.Value;
|
||||||
|
if (item.TryGetProperty("Id", out var idEl) && idEl.GetString() == manualJellyfinId)
|
||||||
|
{
|
||||||
|
matchedJellyfinItem = item;
|
||||||
|
matchedKey = kvp.Key;
|
||||||
|
_logger.LogInformation("✓ Using manual Jellyfin mapping for {Title}: Jellyfin ID {Id}",
|
||||||
|
spotifyTrack.Title, manualJellyfinId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchedJellyfinItem.HasValue)
|
||||||
|
{
|
||||||
|
// Use the raw Jellyfin item (preserves ALL metadata)
|
||||||
|
var itemDict = JsonSerializer.Deserialize<Dictionary<string, object?>>(matchedJellyfinItem.Value.GetRawText());
|
||||||
|
if (itemDict != null)
|
||||||
|
{
|
||||||
|
// Add Spotify ID to ProviderIds so lyrics can work for local tracks too
|
||||||
|
if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId))
|
||||||
|
{
|
||||||
|
if (!itemDict.ContainsKey("ProviderIds"))
|
||||||
|
{
|
||||||
|
itemDict["ProviderIds"] = new Dictionary<string, string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var providerIds = itemDict["ProviderIds"] as Dictionary<string, string>;
|
||||||
|
if (providerIds != null && !providerIds.ContainsKey("Spotify"))
|
||||||
|
{
|
||||||
|
providerIds["Spotify"] = spotifyTrack.SpotifyId;
|
||||||
|
_logger.LogDebug("Added Spotify ID {SpotifyId} to local track for lyrics support", spotifyTrack.SpotifyId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finalItems.Add(itemDict);
|
||||||
|
if (matchedKey != null)
|
||||||
|
{
|
||||||
|
usedJellyfinItems.Add(matchedKey);
|
||||||
|
}
|
||||||
|
localUsedCount++;
|
||||||
|
}
|
||||||
|
continue; // Skip to next track
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SECOND: Check for external manual mapping
|
||||||
|
var externalMappingKey = $"spotify:external-map:{playlistName}:{spotifyTrack.SpotifyId}";
|
||||||
|
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(externalMappingJson))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(externalMappingJson);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
string? provider = null;
|
||||||
|
string? externalId = null;
|
||||||
|
|
||||||
|
if (root.TryGetProperty("provider", out var providerEl))
|
||||||
|
{
|
||||||
|
provider = providerEl.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root.TryGetProperty("id", out var idEl))
|
||||||
|
{
|
||||||
|
externalId = idEl.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(provider) && !string.IsNullOrEmpty(externalId))
|
||||||
|
{
|
||||||
|
// Fetch full metadata from the provider instead of using minimal Spotify data
|
||||||
|
Song? externalSong = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var metadataScope = _serviceProvider.CreateScope();
|
||||||
|
var metadataServiceForFetch = metadataScope.ServiceProvider.GetRequiredService<IMusicMetadataService>();
|
||||||
|
externalSong = await metadataServiceForFetch.GetSongAsync(provider, externalId);
|
||||||
|
|
||||||
|
if (externalSong != null)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("✓ Fetched full metadata for manual external mapping: {Title} by {Artist}",
|
||||||
|
externalSong.Title, externalSong.Artist);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Failed to fetch metadata for {Provider} ID {ExternalId}, using fallback",
|
||||||
|
provider, externalId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Error fetching metadata for {Provider} ID {ExternalId}, using fallback",
|
||||||
|
provider, externalId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to minimal metadata if fetch failed
|
||||||
|
if (externalSong == null)
|
||||||
|
{
|
||||||
|
externalSong = new Song
|
||||||
|
{
|
||||||
|
Id = $"ext-{provider}-song-{externalId}",
|
||||||
|
Title = spotifyTrack.Title,
|
||||||
|
Artist = spotifyTrack.PrimaryArtist,
|
||||||
|
Album = spotifyTrack.Album,
|
||||||
|
Duration = spotifyTrack.DurationMs / 1000,
|
||||||
|
Isrc = spotifyTrack.Isrc,
|
||||||
|
IsLocal = false,
|
||||||
|
ExternalProvider = provider,
|
||||||
|
ExternalId = externalId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var matchedTrack = new MatchedTrack
|
||||||
|
{
|
||||||
|
Position = spotifyTrack.Position,
|
||||||
|
SpotifyId = spotifyTrack.SpotifyId,
|
||||||
|
MatchedSong = externalSong
|
||||||
|
};
|
||||||
|
|
||||||
|
matchedTracks.Add(matchedTrack);
|
||||||
|
|
||||||
|
// Convert external song to Jellyfin item format and add to finalItems
|
||||||
|
var externalItem = responseBuilder.ConvertSongToJellyfinItem(externalSong);
|
||||||
|
|
||||||
|
// Add Spotify ID to ProviderIds so lyrics can work
|
||||||
|
if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId))
|
||||||
|
{
|
||||||
|
if (!externalItem.ContainsKey("ProviderIds"))
|
||||||
|
{
|
||||||
|
externalItem["ProviderIds"] = new Dictionary<string, string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var providerIds = externalItem["ProviderIds"] as Dictionary<string, string>;
|
||||||
|
if (providerIds != null && !providerIds.ContainsKey("Spotify"))
|
||||||
|
{
|
||||||
|
providerIds["Spotify"] = spotifyTrack.SpotifyId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finalItems.Add(externalItem);
|
||||||
|
externalUsedCount++;
|
||||||
|
manualExternalCount++;
|
||||||
|
|
||||||
|
_logger.LogInformation("✓ Using manual external mapping for {Title}: {Provider} {ExternalId}",
|
||||||
|
spotifyTrack.Title, provider, externalId);
|
||||||
|
continue; // Skip to next track
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", spotifyTrack.Title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no manual external mapping, try AGGRESSIVE fuzzy matching with local Jellyfin tracks
|
||||||
|
double bestScore = 0;
|
||||||
|
|
||||||
|
foreach (var kvp in jellyfinItemsByName)
|
||||||
|
{
|
||||||
|
if (usedJellyfinItems.Contains(kvp.Key)) continue;
|
||||||
|
|
||||||
|
var item = kvp.Value;
|
||||||
|
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
|
||||||
|
var artist = "";
|
||||||
|
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
|
||||||
|
{
|
||||||
|
artist = artistsEl[0].GetString() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use AGGRESSIVE matching with decorator stripping
|
||||||
|
var titleScore = FuzzyMatcher.CalculateSimilarityAggressive(spotifyTrack.Title, title);
|
||||||
|
var artistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, artist);
|
||||||
|
|
||||||
|
// Weight: 70% title, 30% artist (prioritize title matching)
|
||||||
|
var totalScore = (titleScore * 0.7) + (artistScore * 0.3);
|
||||||
|
|
||||||
|
// AGGRESSIVE: Accept score >= 40 (was 70)
|
||||||
|
// Also accept if artist matches well (70+) and title is decent (30+)
|
||||||
|
var isGoodMatch = totalScore >= 40 || (artistScore >= 70 && titleScore >= 30);
|
||||||
|
|
||||||
|
if (totalScore > bestScore && isGoodMatch)
|
||||||
|
{
|
||||||
|
bestScore = totalScore;
|
||||||
|
matchedJellyfinItem = item;
|
||||||
|
matchedKey = kvp.Key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchedJellyfinItem.HasValue)
|
||||||
|
{
|
||||||
|
// Use the raw Jellyfin item (preserves ALL metadata)
|
||||||
|
var itemDict = JsonSerializer.Deserialize<Dictionary<string, object?>>(matchedJellyfinItem.Value.GetRawText());
|
||||||
|
if (itemDict != null)
|
||||||
|
{
|
||||||
|
// Add Spotify ID to ProviderIds so lyrics can work for fuzzy-matched local tracks too
|
||||||
|
if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId))
|
||||||
|
{
|
||||||
|
if (!itemDict.ContainsKey("ProviderIds"))
|
||||||
|
{
|
||||||
|
itemDict["ProviderIds"] = new Dictionary<string, string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var providerIds = itemDict["ProviderIds"] as Dictionary<string, string>;
|
||||||
|
if (providerIds != null && !providerIds.ContainsKey("Spotify"))
|
||||||
|
{
|
||||||
|
providerIds["Spotify"] = spotifyTrack.SpotifyId;
|
||||||
|
_logger.LogDebug("Added Spotify ID {SpotifyId} to fuzzy-matched local track for lyrics support", spotifyTrack.SpotifyId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finalItems.Add(itemDict);
|
||||||
|
if (matchedKey != null)
|
||||||
|
{
|
||||||
|
usedJellyfinItems.Add(matchedKey);
|
||||||
|
}
|
||||||
|
localUsedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// No local match - try to find external track
|
||||||
|
var matched = matchedTracks.FirstOrDefault(t => t.SpotifyId == spotifyTrack.SpotifyId);
|
||||||
|
if (matched != null && matched.MatchedSong != null)
|
||||||
|
{
|
||||||
|
// Convert external song to Jellyfin item format
|
||||||
|
var externalItem = responseBuilder.ConvertSongToJellyfinItem(matched.MatchedSong);
|
||||||
|
|
||||||
|
// Add Spotify ID to ProviderIds so lyrics can work
|
||||||
|
if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId))
|
||||||
|
{
|
||||||
|
if (!externalItem.ContainsKey("ProviderIds"))
|
||||||
|
{
|
||||||
|
externalItem["ProviderIds"] = new Dictionary<string, string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var providerIds = externalItem["ProviderIds"] as Dictionary<string, string>;
|
||||||
|
if (providerIds != null && !providerIds.ContainsKey("Spotify"))
|
||||||
|
{
|
||||||
|
providerIds["Spotify"] = spotifyTrack.SpotifyId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finalItems.Add(externalItem);
|
||||||
|
externalUsedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalItems.Count > 0)
|
||||||
|
{
|
||||||
|
// Save to Redis cache
|
||||||
|
var cacheKey = $"spotify:playlist:items:{playlistName}";
|
||||||
|
await _cache.SetAsync(cacheKey, finalItems, TimeSpan.FromHours(24));
|
||||||
|
|
||||||
|
// Save to file cache for persistence
|
||||||
|
await SavePlaylistItemsToFileAsync(playlistName, finalItems);
|
||||||
|
|
||||||
|
var manualMappingInfo = "";
|
||||||
|
if (manualExternalCount > 0)
|
||||||
|
{
|
||||||
|
manualMappingInfo = $" [Manual external: {manualExternalCount}]";
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"✅ Pre-built playlist cache for {Playlist}: {Total} tracks ({Local} LOCAL + {External} EXTERNAL){ManualInfo}",
|
||||||
|
playlistName, finalItems.Count, localUsedCount, externalUsedCount, manualMappingInfo);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("No items to cache for {Playlist}", playlistName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to pre-build playlist items cache for {Playlist}", playlistName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves playlist items to file cache for persistence across restarts.
|
||||||
|
/// </summary>
|
||||||
|
private async Task SavePlaylistItemsToFileAsync(string playlistName, List<Dictionary<string, object?>> items)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cacheDir = "/app/cache/spotify";
|
||||||
|
Directory.CreateDirectory(cacheDir);
|
||||||
|
|
||||||
|
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
|
||||||
|
var filePath = Path.Combine(cacheDir, $"{safeName}_items.json");
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
await System.IO.File.WriteAllTextAsync(filePath, json);
|
||||||
|
|
||||||
|
_logger.LogDebug("💾 Saved {Count} playlist items to file cache: {Path}", items.Count, filePath);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to save playlist items to file for {Playlist}", playlistName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves matched tracks to file cache for persistence across restarts.
|
||||||
|
/// </summary>
|
||||||
|
private async Task SaveMatchedTracksToFileAsync(string playlistName, List<MatchedTrack> matchedTracks)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cacheDir = "/app/cache/spotify";
|
||||||
|
Directory.CreateDirectory(cacheDir);
|
||||||
|
|
||||||
|
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
|
||||||
|
var filePath = Path.Combine(cacheDir, $"{safeName}_matched.json");
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(matchedTracks, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
await System.IO.File.WriteAllTextAsync(filePath, json);
|
||||||
|
|
||||||
|
_logger.LogDebug("💾 Saved {Count} matched tracks to file cache: {Path}", matchedTracks.Count, filePath);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to save matched tracks to file for {Playlist}", playlistName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using allstarr.Models.Search;
|
|||||||
using allstarr.Models.Subsonic;
|
using allstarr.Models.Subsonic;
|
||||||
using allstarr.Services.Local;
|
using allstarr.Services.Local;
|
||||||
using allstarr.Services.Common;
|
using allstarr.Services.Common;
|
||||||
|
using allstarr.Services.Lyrics;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using IOFile = System.IO.File;
|
using IOFile = System.IO.File;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@@ -14,21 +15,48 @@ using Microsoft.Extensions.Logging;
|
|||||||
namespace allstarr.Services.SquidWTF;
|
namespace allstarr.Services.SquidWTF;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handles track downloading from tidal.squid.wtf (no encryption, no auth required)
|
/// Handles track downloading from tidal.squid.wtf (no encryption, no auth required).
|
||||||
/// Downloads are direct from Tidal's CDN via the squid.wtf proxy
|
///
|
||||||
|
/// 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>
|
/// </summary>
|
||||||
public class SquidWTFDownloadService : BaseDownloadService
|
public class SquidWTFDownloadService : BaseDownloadService
|
||||||
{
|
{
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly SemaphoreSlim _requestLock = new(1, 1);
|
|
||||||
private readonly SquidWTFSettings _squidwtfSettings;
|
private readonly SquidWTFSettings _squidwtfSettings;
|
||||||
|
private readonly OdesliService _odesliService;
|
||||||
private DateTime _lastRequestTime = DateTime.MinValue;
|
private readonly RoundRobinFallbackHelper _fallbackHelper;
|
||||||
private readonly int _minRequestIntervalMs = 200;
|
private readonly IServiceProvider _serviceProvider;
|
||||||
|
|
||||||
private readonly List<string> _apiUrls;
|
|
||||||
private int _currentUrlIndex = 0;
|
|
||||||
private readonly object _urlIndexLock = new object();
|
|
||||||
|
|
||||||
protected override string ProviderName => "squidwtf";
|
protected override string ProviderName => "squidwtf";
|
||||||
|
|
||||||
@@ -41,59 +69,26 @@ public class SquidWTFDownloadService : BaseDownloadService
|
|||||||
IOptions<SquidWTFSettings> SquidWTFSettings,
|
IOptions<SquidWTFSettings> SquidWTFSettings,
|
||||||
IServiceProvider serviceProvider,
|
IServiceProvider serviceProvider,
|
||||||
ILogger<SquidWTFDownloadService> logger,
|
ILogger<SquidWTFDownloadService> logger,
|
||||||
|
OdesliService odesliService,
|
||||||
List<string> apiUrls)
|
List<string> apiUrls)
|
||||||
: base(configuration, localLibraryService, metadataService, subsonicSettings.Value, serviceProvider, logger)
|
: base(configuration, localLibraryService, metadataService, subsonicSettings.Value, serviceProvider, logger)
|
||||||
{
|
{
|
||||||
_httpClient = httpClientFactory.CreateClient();
|
_httpClient = httpClientFactory.CreateClient();
|
||||||
_squidwtfSettings = SquidWTFSettings.Value;
|
_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
|
#region BaseDownloadService Implementation
|
||||||
|
|
||||||
public override async Task<bool> IsAvailableAsync()
|
public override async Task<bool> IsAvailableAsync()
|
||||||
{
|
{
|
||||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||||
{
|
{
|
||||||
var response = await _httpClient.GetAsync(baseUrl);
|
var response = await _httpClient.GetAsync(baseUrl);
|
||||||
Console.WriteLine($"Response code from is available async: {response.IsSuccessStatusCode}");
|
Console.WriteLine($"Response code from is available async: {response.IsSuccessStatusCode}");
|
||||||
@@ -116,8 +111,8 @@ public class SquidWTFDownloadService : BaseDownloadService
|
|||||||
{
|
{
|
||||||
var downloadInfo = await GetTrackDownloadInfoAsync(trackId, cancellationToken);
|
var downloadInfo = await GetTrackDownloadInfoAsync(trackId, cancellationToken);
|
||||||
|
|
||||||
Logger.LogInformation("Track token obtained: {Url}", downloadInfo.DownloadUrl);
|
Logger.LogInformation("Track download URL obtained from hifi-api: {Url}", downloadInfo.DownloadUrl);
|
||||||
Logger.LogInformation("Using format: {Format}", downloadInfo.MimeType);
|
Logger.LogInformation("Using format: {Format} (Quality: {Quality})", downloadInfo.MimeType, downloadInfo.AudioQuality);
|
||||||
|
|
||||||
// Determine extension from MIME type
|
// Determine extension from MIME type
|
||||||
var extension = downloadInfo.MimeType?.ToLower() switch
|
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)
|
// Build organized folder structure: Artist/Album/Track using AlbumArtist (fallback to Artist for singles)
|
||||||
var artistForPath = song.AlbumArtist ?? song.Artist;
|
var artistForPath = song.AlbumArtist ?? song.Artist;
|
||||||
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);
|
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension);
|
||||||
|
|
||||||
// Create directories if they don't exist
|
// Create directories if they don't exist
|
||||||
@@ -140,10 +138,53 @@ public class SquidWTFDownloadService : BaseDownloadService
|
|||||||
// Resolve unique path if file already exists
|
// Resolve unique path if file already exists
|
||||||
outputPath = PathHelper.ResolveUniquePath(outputPath);
|
outputPath = PathHelper.ResolveUniquePath(outputPath);
|
||||||
|
|
||||||
// Download from Tidal CDN (no authentication needed, token is in URL)
|
// Use round-robin with fallback for downloads to reduce CPU usage
|
||||||
var response = await QueueRequestAsync(async () =>
|
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("User-Agent", "Mozilla/5.0");
|
||||||
request.Headers.Add("Accept", "*/*");
|
request.Headers.Add("Accept", "*/*");
|
||||||
|
|
||||||
@@ -161,7 +202,26 @@ public class SquidWTFDownloadService : BaseDownloadService
|
|||||||
// Close file before writing metadata
|
// Close file before writing metadata
|
||||||
await outputFile.DisposeAsync();
|
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);
|
await WriteMetadataAsync(outputPath, song, cancellationToken);
|
||||||
|
|
||||||
return outputPath;
|
return outputPath;
|
||||||
@@ -171,13 +231,22 @@ public class SquidWTFDownloadService : BaseDownloadService
|
|||||||
|
|
||||||
#region SquidWTF API Methods
|
#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)
|
private async Task<DownloadResult> GetTrackDownloadInfoAsync(string trackId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return await QueueRequestAsync(async () =>
|
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
|
var quality = _squidwtfSettings.Quality?.ToUpperInvariant() switch
|
||||||
{
|
{
|
||||||
"FLAC" => "LOSSLESS",
|
"FLAC" => "LOSSLESS",
|
||||||
@@ -190,7 +259,7 @@ public class SquidWTFDownloadService : BaseDownloadService
|
|||||||
|
|
||||||
var url = $"{baseUrl}/track/?id={trackId}&quality={quality}";
|
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);
|
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
@@ -228,8 +297,7 @@ public class SquidWTFDownloadService : BaseDownloadService
|
|||||||
? audioQualityEl.GetString()
|
? audioQualityEl.GetString()
|
||||||
: "LOSSLESS";
|
: "LOSSLESS";
|
||||||
|
|
||||||
Logger.LogDebug("Decoded manifest - URL: {Url}, MIME: {MimeType}, Quality: {Quality}",
|
Logger.LogInformation("Track download URL obtained from hifi-api: {Url}", downloadUrl);
|
||||||
downloadUrl, mimeType, audioQuality);
|
|
||||||
|
|
||||||
return new DownloadResult
|
return new DownloadResult
|
||||||
{
|
{
|
||||||
@@ -241,29 +309,56 @@ public class SquidWTFDownloadService : BaseDownloadService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Utility Methods
|
#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)
|
||||||
|
{
|
||||||
|
if (externalProvider != "squidwtf")
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var spotifyId = await _odesliService.ConvertTidalToSpotifyIdAsync(externalId, CancellationToken.None);
|
||||||
|
if (!string.IsNullOrEmpty(spotifyId))
|
||||||
|
{
|
||||||
|
Logger.LogDebug("Background Spotify ID obtained for Tidal/{TrackId}: {SpotifyId}", externalId, spotifyId);
|
||||||
|
|
||||||
|
// Immediately prefetch lyrics now that we have the Spotify ID
|
||||||
|
// This ensures lyrics are cached and ready when the client requests them
|
||||||
|
_ = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
await _requestLock.WaitAsync();
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var now = DateTime.UtcNow;
|
using var scope = _serviceProvider.CreateScope();
|
||||||
var timeSinceLastRequest = (now - _lastRequestTime).TotalMilliseconds;
|
var spotifyLyricsService = scope.ServiceProvider.GetService<SpotifyLyricsService>();
|
||||||
|
|
||||||
if (timeSinceLastRequest < _minRequestIntervalMs)
|
if (spotifyLyricsService != null)
|
||||||
{
|
{
|
||||||
await Task.Delay((int)(_minRequestIntervalMs - timeSinceLastRequest));
|
var lyrics = await spotifyLyricsService.GetLyricsByTrackIdAsync(spotifyId);
|
||||||
}
|
if (lyrics != null && lyrics.Lines.Count > 0)
|
||||||
|
|
||||||
_lastRequestTime = DateTime.UtcNow;
|
|
||||||
return await action();
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
{
|
||||||
_requestLock.Release();
|
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;
|
namespace allstarr.Services.SquidWTF;
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
|
|
||||||
public class SquidWTFMetadataService : IMusicMetadataService
|
public class SquidWTFMetadataService : IMusicMetadataService
|
||||||
@@ -21,9 +55,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
private readonly SubsonicSettings _settings;
|
private readonly SubsonicSettings _settings;
|
||||||
private readonly ILogger<SquidWTFMetadataService> _logger;
|
private readonly ILogger<SquidWTFMetadataService> _logger;
|
||||||
private readonly RedisCacheService _cache;
|
private readonly RedisCacheService _cache;
|
||||||
private readonly List<string> _apiUrls;
|
private readonly RoundRobinFallbackHelper _fallbackHelper;
|
||||||
private int _currentUrlIndex = 0;
|
|
||||||
private readonly object _urlIndexLock = new object();
|
|
||||||
|
|
||||||
public SquidWTFMetadataService(
|
public SquidWTFMetadataService(
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
@@ -37,79 +69,33 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
_settings = settings.Value;
|
_settings = settings.Value;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
_apiUrls = apiUrls;
|
_fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF");
|
||||||
|
|
||||||
// Set up default headers
|
// Set up default headers
|
||||||
_httpClient.DefaultRequestHeaders.Add("User-Agent",
|
_httpClient.DefaultRequestHeaders.Add("User-Agent",
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0");
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0");
|
||||||
|
|
||||||
|
// Increase timeout for large artist/album responses (some artists have 100+ albums)
|
||||||
|
_httpClient.Timeout = TimeSpan.FromMinutes(5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <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)
|
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 url = $"{baseUrl}/search/?s={Uri.EscapeDataString(query)}";
|
||||||
var response = await _httpClient.GetAsync(url);
|
var response = await _httpClient.GetAsync(url, ct);
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
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
|
// Check for error in response body
|
||||||
var result = JsonDocument.Parse(json);
|
var result = JsonDocument.Parse(json);
|
||||||
@@ -120,6 +106,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var songs = new List<Song>();
|
var songs = new List<Song>();
|
||||||
|
// Per hifi-api spec: track search returns data.items array
|
||||||
if (result.RootElement.TryGetProperty("data", out var data) &&
|
if (result.RootElement.TryGetProperty("data", out var data) &&
|
||||||
data.TryGetProperty("items", out var items))
|
data.TryGetProperty("items", out var items))
|
||||||
{
|
{
|
||||||
@@ -129,30 +116,36 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
if (count >= limit) break;
|
if (count >= limit) break;
|
||||||
|
|
||||||
var song = ParseTidalTrack(track);
|
var song = ParseTidalTrack(track);
|
||||||
|
if (ShouldIncludeSong(song))
|
||||||
|
{
|
||||||
songs.Add(song);
|
songs.Add(song);
|
||||||
|
}
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return songs;
|
return songs;
|
||||||
}, new List<Song>());
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20)
|
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 url = $"{baseUrl}/search/?al={Uri.EscapeDataString(query)}";
|
||||||
var response = await _httpClient.GetAsync(url);
|
var response = await _httpClient.GetAsync(url, ct);
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
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 result = JsonDocument.Parse(json);
|
||||||
|
|
||||||
var albums = new List<Album>();
|
var albums = new List<Album>();
|
||||||
|
// Per hifi-api spec: album search returns data.albums.items array
|
||||||
if (result.RootElement.TryGetProperty("data", out var data) &&
|
if (result.RootElement.TryGetProperty("data", out var data) &&
|
||||||
data.TryGetProperty("albums", out var albumsObj) &&
|
data.TryGetProperty("albums", out var albumsObj) &&
|
||||||
albumsObj.TryGetProperty("items", out var items))
|
albumsObj.TryGetProperty("items", out var items))
|
||||||
@@ -168,25 +161,31 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
}
|
}
|
||||||
|
|
||||||
return albums;
|
return albums;
|
||||||
}, new List<Album>());
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20)
|
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)}";
|
var url = $"{baseUrl}/search/?a={Uri.EscapeDataString(query)}";
|
||||||
var response = await _httpClient.GetAsync(url);
|
_logger.LogInformation("🔍 SQUIDWTF: Searching artists with URL: {Url}", url);
|
||||||
|
|
||||||
|
var response = await _httpClient.GetAsync(url, ct);
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
return new List<Artist>();
|
_logger.LogWarning("⚠️ SQUIDWTF: Artist search failed with status {StatusCode}", response.StatusCode);
|
||||||
|
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 result = JsonDocument.Parse(json);
|
||||||
|
|
||||||
var artists = new List<Artist>();
|
var artists = new List<Artist>();
|
||||||
|
// Per hifi-api spec: artist search returns data.artists.items array
|
||||||
if (result.RootElement.TryGetProperty("data", out var data) &&
|
if (result.RootElement.TryGetProperty("data", out var data) &&
|
||||||
data.TryGetProperty("artists", out var artistsObj) &&
|
data.TryGetProperty("artists", out var artistsObj) &&
|
||||||
artistsObj.TryGetProperty("items", out var items))
|
artistsObj.TryGetProperty("items", out var items))
|
||||||
@@ -196,19 +195,23 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
{
|
{
|
||||||
if (count >= limit) break;
|
if (count >= limit) break;
|
||||||
|
|
||||||
artists.Add(ParseTidalArtist(artist));
|
var parsedArtist = ParseTidalArtist(artist);
|
||||||
|
artists.Add(parsedArtist);
|
||||||
|
_logger.LogDebug("🎤 SQUIDWTF: Found artist: {Name} (ID: {Id})", parsedArtist.Name, parsedArtist.ExternalId);
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("✓ SQUIDWTF: Artist search returned {Count} results", artists.Count);
|
||||||
return artists;
|
return artists;
|
||||||
}, new List<Artist>());
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<ExternalPlaylist>> SearchPlaylistsAsync(string query, int limit = 20)
|
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 url = $"{baseUrl}/search/?p={Uri.EscapeDataString(query)}";
|
||||||
var response = await _httpClient.GetAsync(url);
|
var response = await _httpClient.GetAsync(url);
|
||||||
if (!response.IsSuccessStatusCode) return new List<ExternalPlaylist>();
|
if (!response.IsSuccessStatusCode) return new List<ExternalPlaylist>();
|
||||||
@@ -217,15 +220,20 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
var result = JsonDocument.Parse(json);
|
var result = JsonDocument.Parse(json);
|
||||||
|
|
||||||
var playlists = new List<ExternalPlaylist>();
|
var playlists = new List<ExternalPlaylist>();
|
||||||
|
// Per hifi-api spec: playlist search returns data.playlists.items array
|
||||||
if (result.RootElement.TryGetProperty("data", out var data) &&
|
if (result.RootElement.TryGetProperty("data", out var data) &&
|
||||||
data.TryGetProperty("playlists", out var playlistObj) &&
|
data.TryGetProperty("playlists", out var playlistObj) &&
|
||||||
playlistObj.TryGetProperty("items", out var items))
|
playlistObj.TryGetProperty("items", out var items))
|
||||||
{
|
{
|
||||||
|
int count = 0;
|
||||||
foreach(var playlist in items.EnumerateArray())
|
foreach(var playlist in items.EnumerateArray())
|
||||||
{
|
{
|
||||||
|
if (count >= limit) break;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
playlists.Add(ParseTidalPlaylist(playlist));
|
playlists.Add(ParseTidalPlaylist(playlist));
|
||||||
|
count++;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -261,8 +269,9 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
{
|
{
|
||||||
if (externalProvider != "squidwtf") return null;
|
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 url = $"{baseUrl}/info/?id={externalId}";
|
||||||
|
|
||||||
var response = await _httpClient.GetAsync(url);
|
var response = await _httpClient.GetAsync(url);
|
||||||
@@ -271,10 +280,16 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
var json = await response.Content.ReadAsStringAsync();
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
var result = JsonDocument.Parse(json);
|
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))
|
if (!result.RootElement.TryGetProperty("data", out var track))
|
||||||
return null;
|
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);
|
}, (Song?)null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,8 +302,9 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
var cached = await _cache.GetAsync<Album>(cacheKey);
|
var cached = await _cache.GetAsync<Album>(cacheKey);
|
||||||
if (cached != null) return cached;
|
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 url = $"{baseUrl}/album/?id={externalId}";
|
||||||
|
|
||||||
var response = await _httpClient.GetAsync(url);
|
var response = await _httpClient.GetAsync(url);
|
||||||
@@ -297,17 +313,18 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
var json = await response.Content.ReadAsStringAsync();
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
var result = JsonDocument.Parse(json);
|
var result = JsonDocument.Parse(json);
|
||||||
|
|
||||||
|
// Response structure: { "data": { album object with "items" array of tracks } }
|
||||||
if (!result.RootElement.TryGetProperty("data", out var albumElement))
|
if (!result.RootElement.TryGetProperty("data", out var albumElement))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var album = ParseTidalAlbum(albumElement);
|
var album = ParseTidalAlbum(albumElement);
|
||||||
|
|
||||||
// Get album tracks
|
// Get album tracks from items array
|
||||||
if (albumElement.TryGetProperty("items", out var tracks))
|
if (albumElement.TryGetProperty("items", out var tracks))
|
||||||
{
|
{
|
||||||
foreach (var trackWrapper in tracks.EnumerateArray())
|
foreach (var trackWrapper in tracks.EnumerateArray())
|
||||||
{
|
{
|
||||||
|
// Each item is wrapped: { "item": { track object } }
|
||||||
if (trackWrapper.TryGetProperty("item", out var track))
|
if (trackWrapper.TryGetProperty("item", out var track))
|
||||||
{
|
{
|
||||||
var song = ParseTidalTrack(track);
|
var song = ParseTidalTrack(track);
|
||||||
@@ -341,8 +358,9 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
return cached;
|
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}";
|
var url = $"{baseUrl}/artist/?f={externalId}";
|
||||||
_logger.LogInformation("Fetching artist from {Url}", url);
|
_logger.LogInformation("Fetching artist from {Url}", url);
|
||||||
|
|
||||||
@@ -360,18 +378,23 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
JsonElement? artistSource = null;
|
JsonElement? artistSource = null;
|
||||||
int albumCount = 0;
|
int albumCount = 0;
|
||||||
|
|
||||||
// Think this can maybe switch to something using ParseTidalAlbum
|
// 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) &&
|
if (result.RootElement.TryGetProperty("albums", out var albums) &&
|
||||||
albums.TryGetProperty("items", out var albumItems) &&
|
albums.TryGetProperty("items", out var albumItems) &&
|
||||||
albumItems.GetArrayLength() > 0)
|
albumItems.GetArrayLength() > 0)
|
||||||
{
|
{
|
||||||
albumCount = albumItems.GetArrayLength();
|
albumCount = albumItems.GetArrayLength();
|
||||||
artistSource = albumItems[0].GetProperty("artist");
|
if (albumItems[0].TryGetProperty("artist", out var artistEl))
|
||||||
|
{
|
||||||
|
artistSource = artistEl;
|
||||||
_logger.LogInformation("Found artist from albums, albumCount={AlbumCount}", albumCount);
|
_logger.LogInformation("Found artist from albums, albumCount={AlbumCount}", albumCount);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Think this can maybe switch to something using ParseTidalTrack
|
// Fallback: try to get artist from tracks[0].artists[0]
|
||||||
else if (result.RootElement.TryGetProperty("tracks", out var tracks) &&
|
if (artistSource == null &&
|
||||||
|
result.RootElement.TryGetProperty("tracks", out var tracks) &&
|
||||||
tracks.GetArrayLength() > 0 &&
|
tracks.GetArrayLength() > 0 &&
|
||||||
tracks[0].TryGetProperty("artists", out var artists) &&
|
tracks[0].TryGetProperty("artists", out var artists) &&
|
||||||
artists.GetArrayLength() > 0)
|
artists.GetArrayLength() > 0)
|
||||||
@@ -382,11 +405,13 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
|
|
||||||
if (artistSource == null)
|
if (artistSource == null)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Could not find artist data in response");
|
_logger.LogDebug("Could not find artist data in response. Response keys: {Keys}",
|
||||||
|
string.Join(", ", result.RootElement.EnumerateObject().Select(p => p.Name)));
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var artistElement = artistSource.Value;
|
var artistElement = artistSource.Value;
|
||||||
|
// Normalize artist data to include album count
|
||||||
var normalizedArtist = new JsonObject
|
var normalizedArtist = new JsonObject
|
||||||
{
|
{
|
||||||
["id"] = artistElement.GetProperty("id").GetInt64(),
|
["id"] = artistElement.GetProperty("id").GetInt64(),
|
||||||
@@ -411,10 +436,11 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
{
|
{
|
||||||
if (externalProvider != "squidwtf") return new List<Album>();
|
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);
|
_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}";
|
var url = $"{baseUrl}/artist/?f={externalId}";
|
||||||
_logger.LogInformation("Fetching artist albums from URL: {Url}", url);
|
_logger.LogInformation("Fetching artist albums from URL: {Url}", url);
|
||||||
var response = await _httpClient.GetAsync(url);
|
var response = await _httpClient.GetAsync(url);
|
||||||
@@ -431,6 +457,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
|
|
||||||
var albums = new List<Album>();
|
var albums = new List<Album>();
|
||||||
|
|
||||||
|
// Response structure: { "albums": { "items": [ album objects ] } }
|
||||||
if (result.RootElement.TryGetProperty("albums", out var albumsObj) &&
|
if (result.RootElement.TryGetProperty("albums", out var albumsObj) &&
|
||||||
albumsObj.TryGetProperty("items", out var items))
|
albumsObj.TryGetProperty("items", out var items))
|
||||||
{
|
{
|
||||||
@@ -456,8 +483,9 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
{
|
{
|
||||||
if (externalProvider != "squidwtf") return null;
|
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 url = $"{baseUrl}/playlist/?id={externalId}";
|
||||||
var response = await _httpClient.GetAsync(url);
|
var response = await _httpClient.GetAsync(url);
|
||||||
if (!response.IsSuccessStatusCode) return null;
|
if (!response.IsSuccessStatusCode) return null;
|
||||||
@@ -465,8 +493,10 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
var json = await response.Content.ReadAsStringAsync();
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
var playlistElement = JsonDocument.Parse(json).RootElement;
|
var playlistElement = JsonDocument.Parse(json).RootElement;
|
||||||
|
|
||||||
|
// Check for error response
|
||||||
if (playlistElement.TryGetProperty("error", out _)) return null;
|
if (playlistElement.TryGetProperty("error", out _)) return null;
|
||||||
|
|
||||||
|
// Response structure: { "playlist": { playlist object }, "items": [ track wrappers ] }
|
||||||
return ParseTidalPlaylist(playlistElement);
|
return ParseTidalPlaylist(playlistElement);
|
||||||
}, (ExternalPlaylist?)null);
|
}, (ExternalPlaylist?)null);
|
||||||
}
|
}
|
||||||
@@ -475,8 +505,9 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
{
|
{
|
||||||
if (externalProvider != "squidwtf") return new List<Song>();
|
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 url = $"{baseUrl}/playlist/?id={externalId}";
|
||||||
var response = await _httpClient.GetAsync(url);
|
var response = await _httpClient.GetAsync(url);
|
||||||
if (!response.IsSuccessStatusCode) return new List<Song>();
|
if (!response.IsSuccessStatusCode) return new List<Song>();
|
||||||
@@ -484,11 +515,13 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
var json = await response.Content.ReadAsStringAsync();
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
var playlistElement = JsonDocument.Parse(json).RootElement;
|
var playlistElement = JsonDocument.Parse(json).RootElement;
|
||||||
|
|
||||||
|
// Check for error response
|
||||||
if (playlistElement.TryGetProperty("error", out _)) return new List<Song>();
|
if (playlistElement.TryGetProperty("error", out _)) return new List<Song>();
|
||||||
|
|
||||||
JsonElement? playlist = null;
|
JsonElement? playlist = null;
|
||||||
JsonElement? tracks = null;
|
JsonElement? tracks = null;
|
||||||
|
|
||||||
|
// Response structure: { "playlist": { playlist object }, "items": [ track wrappers ] }
|
||||||
if (playlistElement.TryGetProperty("playlist", out var playlistEl))
|
if (playlistElement.TryGetProperty("playlist", out var playlistEl))
|
||||||
{
|
{
|
||||||
playlist = playlistEl;
|
playlist = playlistEl;
|
||||||
@@ -511,6 +544,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
int trackIndex = 1;
|
int trackIndex = 1;
|
||||||
foreach (var entry in tracks.Value.EnumerateArray())
|
foreach (var entry in tracks.Value.EnumerateArray())
|
||||||
{
|
{
|
||||||
|
// Each item is wrapped: { "item": { track object } }
|
||||||
if (!entry.TryGetProperty("item", out var track))
|
if (!entry.TryGetProperty("item", out var track))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
@@ -533,6 +567,14 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
|
|
||||||
// --- Parser functions start here ---
|
// --- 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)
|
private Song ParseTidalTrack(JsonElement track, int? fallbackTrackNumber = null)
|
||||||
{
|
{
|
||||||
var externalId = track.GetProperty("id").GetInt64().ToString();
|
var externalId = track.GetProperty("id").GetInt64().ToString();
|
||||||
@@ -622,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)
|
private Song ParseTidalTrackFull(JsonElement track)
|
||||||
{
|
{
|
||||||
var externalId = track.GetProperty("id").GetInt64().ToString();
|
var externalId = track.GetProperty("id").GetInt64().ToString();
|
||||||
@@ -743,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)
|
private Album ParseTidalAlbum(JsonElement album)
|
||||||
{
|
{
|
||||||
var externalId = album.GetProperty("id").GetInt64().ToString();
|
var externalId = album.GetProperty("id").GetInt64().ToString();
|
||||||
@@ -796,8 +852,13 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Think of a way to implement album count when this function is called by search function
|
/// <summary>
|
||||||
// as the API endpoint in search does not include this data
|
/// 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)
|
private Artist ParseTidalArtist(JsonElement artist)
|
||||||
{
|
{
|
||||||
var externalId = artist.GetProperty("id").GetInt64().ToString();
|
var externalId = artist.GetProperty("id").GetInt64().ToString();
|
||||||
@@ -823,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)
|
private ExternalPlaylist ParseTidalPlaylist(JsonElement playlistElement)
|
||||||
{
|
{
|
||||||
JsonElement? playlist = null;
|
JsonElement? playlist = null;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Text.Json;
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using allstarr.Models.Settings;
|
using allstarr.Models.Settings;
|
||||||
using allstarr.Services.Validation;
|
using allstarr.Services.Validation;
|
||||||
|
using allstarr.Services.Common;
|
||||||
|
|
||||||
namespace allstarr.Services.SquidWTF;
|
namespace allstarr.Services.SquidWTF;
|
||||||
|
|
||||||
@@ -12,56 +13,26 @@ namespace allstarr.Services.SquidWTF;
|
|||||||
public class SquidWTFStartupValidator : BaseStartupValidator
|
public class SquidWTFStartupValidator : BaseStartupValidator
|
||||||
{
|
{
|
||||||
private readonly SquidWTFSettings _settings;
|
private readonly SquidWTFSettings _settings;
|
||||||
private readonly List<string> _apiUrls;
|
private readonly RoundRobinFallbackHelper _fallbackHelper;
|
||||||
private int _currentUrlIndex = 0;
|
private readonly EndpointBenchmarkService _benchmarkService;
|
||||||
private readonly object _urlIndexLock = new object();
|
private readonly ILogger<SquidWTFStartupValidator> _logger;
|
||||||
|
|
||||||
public override string ServiceName => "SquidWTF";
|
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)
|
: base(httpClient)
|
||||||
{
|
{
|
||||||
_settings = settings.Value;
|
_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)
|
public override async Task<ValidationResult> ValidateAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -79,8 +50,49 @@ public class SquidWTFStartupValidator : BaseStartupValidator
|
|||||||
|
|
||||||
WriteStatus("SquidWTF Quality", quality, ConsoleColor.Cyan);
|
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
|
// Test connectivity with fallback
|
||||||
var result = await TryWithFallbackAsync(async (baseUrl) =>
|
var result = await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||||
{
|
{
|
||||||
var response = await _httpClient.GetAsync(baseUrl, cancellationToken);
|
var response = await _httpClient.GetAsync(baseUrl, cancellationToken);
|
||||||
|
|
||||||
@@ -107,8 +119,8 @@ public class SquidWTFStartupValidator : BaseStartupValidator
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Test search with a simple query
|
// Test search with "22" by Taylor Swift
|
||||||
var searchUrl = $"{baseUrl}/search/?s=Taylor%20Swift";
|
var searchUrl = $"{baseUrl}/search/?s=22%20Taylor%20Swift";
|
||||||
var searchResponse = await _httpClient.GetAsync(searchUrl, cancellationToken);
|
var searchResponse = await _httpClient.GetAsync(searchUrl, cancellationToken);
|
||||||
|
|
||||||
if (searchResponse.IsSuccessStatusCode)
|
if (searchResponse.IsSuccessStatusCode)
|
||||||
@@ -121,7 +133,36 @@ public class SquidWTFStartupValidator : BaseStartupValidator
|
|||||||
{
|
{
|
||||||
var itemCount = items.GetArrayLength();
|
var itemCount = items.GetArrayLength();
|
||||||
WriteStatus("Search Functionality", "WORKING", ConsoleColor.Green);
|
WriteStatus("Search Functionality", "WORKING", ConsoleColor.Green);
|
||||||
WriteDetail($"Test search returned {itemCount} results");
|
WriteDetail($"Test search for '22' by Taylor Swift returned {itemCount} results");
|
||||||
|
|
||||||
|
// Check if we found the actual song
|
||||||
|
bool foundTaylorSwift22 = false;
|
||||||
|
foreach (var item in items.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (item.TryGetProperty("title", out var title) &&
|
||||||
|
item.TryGetProperty("artists", out var artists) &&
|
||||||
|
artists.GetArrayLength() > 0)
|
||||||
|
{
|
||||||
|
var titleStr = title.GetString() ?? "";
|
||||||
|
var artistName = artists[0].TryGetProperty("name", out var name)
|
||||||
|
? name.GetString() ?? ""
|
||||||
|
: "";
|
||||||
|
|
||||||
|
if (titleStr.Contains("22", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
artistName.Contains("Taylor Swift", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
foundTaylorSwift22 = true;
|
||||||
|
var trackId = item.TryGetProperty("id", out var id) ? id.GetInt64() : 0;
|
||||||
|
WriteDetail($"✓ Found: '{titleStr}' by {artistName} (ID: {trackId})");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!foundTaylorSwift22)
|
||||||
|
{
|
||||||
|
WriteDetail("⚠ Could not find exact match for '22' by Taylor Swift in results");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ public class SubsonicProxyService
|
|||||||
var body = await response.Content.ReadAsByteArrayAsync();
|
var body = await response.Content.ReadAsByteArrayAsync();
|
||||||
var contentType = response.Content.Headers.ContentType?.ToString();
|
var contentType = response.Content.Headers.ContentType?.ToString();
|
||||||
|
|
||||||
|
// Trigger GC for large files to prevent memory leaks
|
||||||
|
if (body.Length > 1024 * 1024) // 1MB threshold
|
||||||
|
{
|
||||||
|
GC.Collect(2, GCCollectionMode.Optimized, blocking: false);
|
||||||
|
}
|
||||||
|
|
||||||
return (body, contentType);
|
return (body, contentType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning",
|
||||||
|
"System.Net.Http.HttpClient.Default.LogicalHandler": "Warning",
|
||||||
|
"System.Net.Http.HttpClient.Default.ClientHandler": "Warning"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"SpotifyImport": {
|
"SpotifyImport": {
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
{
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning",
|
||||||
|
"System.Net.Http.HttpClient.Default.LogicalHandler": "Warning",
|
||||||
|
"System.Net.Http.HttpClient.Default.ClientHandler": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
"Backend": {
|
"Backend": {
|
||||||
"Type": "Subsonic"
|
"Type": "Subsonic"
|
||||||
},
|
},
|
||||||
@@ -24,7 +32,8 @@
|
|||||||
"EnableExternalPlaylists": true
|
"EnableExternalPlaylists": true
|
||||||
},
|
},
|
||||||
"Library": {
|
"Library": {
|
||||||
"DownloadPath": "./downloads"
|
"DownloadPath": "./downloads",
|
||||||
|
"KeptPath": "/app/kept"
|
||||||
},
|
},
|
||||||
"Qobuz": {
|
"Qobuz": {
|
||||||
"UserAuthToken": "your-qobuz-token",
|
"UserAuthToken": "your-qobuz-token",
|
||||||
@@ -48,6 +57,7 @@
|
|||||||
"SyncStartHour": 16,
|
"SyncStartHour": 16,
|
||||||
"SyncStartMinute": 15,
|
"SyncStartMinute": 15,
|
||||||
"SyncWindowHours": 2,
|
"SyncWindowHours": 2,
|
||||||
|
"MatchingIntervalHours": 24,
|
||||||
"Playlists": []
|
"Playlists": []
|
||||||
},
|
},
|
||||||
"SpotifyApi": {
|
"SpotifyApi": {
|
||||||
@@ -58,5 +68,12 @@
|
|||||||
"CacheDurationMinutes": 60,
|
"CacheDurationMinutes": 60,
|
||||||
"RateLimitDelayMs": 100,
|
"RateLimitDelayMs": 100,
|
||||||
"PreferIsrcMatching": true
|
"PreferIsrcMatching": true
|
||||||
|
},
|
||||||
|
"MusicBrainz": {
|
||||||
|
"Enabled": true,
|
||||||
|
"Username": "",
|
||||||
|
"Password": "",
|
||||||
|
"BaseUrl": "https://musicbrainz.org/ws/2",
|
||||||
|
"RateLimitMs": 1000
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -286,6 +286,8 @@
|
|||||||
|
|
||||||
.toast.success { border-color: var(--success); }
|
.toast.success { border-color: var(--success); }
|
||||||
.toast.error { border-color: var(--error); }
|
.toast.error { border-color: var(--error); }
|
||||||
|
.toast.warning { border-color: var(--warning); }
|
||||||
|
.toast.info { border-color: var(--accent); }
|
||||||
|
|
||||||
@keyframes slideIn {
|
@keyframes slideIn {
|
||||||
from { transform: translateX(100%); opacity: 0; }
|
from { transform: translateX(100%); opacity: 0; }
|
||||||
@@ -385,9 +387,9 @@
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
max-width: 500px;
|
max-width: 75%;
|
||||||
width: 90%;
|
width: 75%;
|
||||||
max-height: 80vh;
|
max-height: 65vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,6 +539,7 @@
|
|||||||
<div class="tab" data-tab="jellyfin-playlists">Link Playlists</div>
|
<div class="tab" data-tab="jellyfin-playlists">Link Playlists</div>
|
||||||
<div class="tab" data-tab="playlists">Active Playlists</div>
|
<div class="tab" data-tab="playlists">Active Playlists</div>
|
||||||
<div class="tab" data-tab="config">Configuration</div>
|
<div class="tab" data-tab="config">Configuration</div>
|
||||||
|
<div class="tab" data-tab="endpoints">API Analytics</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Dashboard Tab -->
|
<!-- Dashboard Tab -->
|
||||||
@@ -642,12 +645,18 @@
|
|||||||
|
|
||||||
<!-- Active Playlists Tab -->
|
<!-- Active Playlists Tab -->
|
||||||
<div class="tab-content" id="tab-playlists">
|
<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">
|
<div class="card">
|
||||||
<h2>
|
<h2>
|
||||||
Active Spotify Playlists
|
Active Spotify Playlists
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button onclick="matchAllPlaylists()">Match All Tracks</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()">Refresh All</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>
|
</div>
|
||||||
</h2>
|
</h2>
|
||||||
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
||||||
@@ -673,6 +682,126 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Manual Track Mappings Section -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>
|
||||||
|
Manual Track Mappings
|
||||||
|
<div class="actions">
|
||||||
|
<button onclick="fetchTrackMappings()">Refresh</button>
|
||||||
|
</div>
|
||||||
|
</h2>
|
||||||
|
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
||||||
|
Manual mappings override automatic matching for external providers (SquidWTF, Deezer, Qobuz). For local Jellyfin tracks, use the Spotify Import plugin instead.
|
||||||
|
</p>
|
||||||
|
<div id="mappings-summary" style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
|
||||||
|
<div>
|
||||||
|
<span style="color: var(--text-secondary);">Total:</span>
|
||||||
|
<span style="font-weight: 600; margin-left: 8px;" id="mappings-total">0</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style="color: var(--text-secondary);">External:</span>
|
||||||
|
<span style="font-weight: 600; margin-left: 8px; color: var(--success);" id="mappings-external">0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table class="playlist-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Playlist</th>
|
||||||
|
<th>Spotify ID</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Target</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="mappings-table-body">
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="loading">
|
||||||
|
<span class="spinner"></span> Loading mappings...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Missing Tracks Section -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>
|
||||||
|
Missing Tracks (All Playlists)
|
||||||
|
<div class="actions">
|
||||||
|
<button onclick="fetchMissingTracks()">Refresh</button>
|
||||||
|
</div>
|
||||||
|
</h2>
|
||||||
|
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
||||||
|
Tracks that couldn't be matched locally or externally. Map them manually to add them to your playlists.
|
||||||
|
</p>
|
||||||
|
<div id="missing-summary" style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
|
||||||
|
<div>
|
||||||
|
<span style="color: var(--text-secondary);">Total Missing:</span>
|
||||||
|
<span style="font-weight: 600; margin-left: 8px; color: var(--warning);" id="missing-total">0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table class="playlist-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Playlist</th>
|
||||||
|
<th>Track</th>
|
||||||
|
<th>Artist</th>
|
||||||
|
<th>Album</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="missing-tracks-table-body">
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="loading">
|
||||||
|
<span class="spinner"></span> Loading missing tracks...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Kept Downloads Section -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>
|
||||||
|
Kept Downloads
|
||||||
|
<div class="actions">
|
||||||
|
<button onclick="fetchDownloads()">Refresh</button>
|
||||||
|
</div>
|
||||||
|
</h2>
|
||||||
|
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
||||||
|
Downloaded files stored permanently. Download or delete individual tracks.
|
||||||
|
</p>
|
||||||
|
<div id="downloads-summary" style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
|
||||||
|
<div>
|
||||||
|
<span style="color: var(--text-secondary);">Total Files:</span>
|
||||||
|
<span style="font-weight: 600; margin-left: 8px; color: var(--accent);" id="downloads-count">0</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style="color: var(--text-secondary);">Total Size:</span>
|
||||||
|
<span style="font-weight: 600; margin-left: 8px; color: var(--accent);" id="downloads-size">0 B</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table class="playlist-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Artist</th>
|
||||||
|
<th>Album</th>
|
||||||
|
<th>File</th>
|
||||||
|
<th>Size</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="downloads-table-body">
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="loading">
|
||||||
|
<span class="spinner"></span> Loading downloads...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Configuration Tab -->
|
<!-- Configuration Tab -->
|
||||||
@@ -734,6 +863,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>MusicBrainz Settings</h2>
|
||||||
|
<div class="config-section">
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">Enabled</span>
|
||||||
|
<span class="value" id="config-musicbrainz-enabled">-</span>
|
||||||
|
<button onclick="openEditSetting('MUSICBRAINZ_ENABLED', 'MusicBrainz Enabled', 'select', '', ['true', 'false'])">Edit</button>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">Username</span>
|
||||||
|
<span class="value" id="config-musicbrainz-username">-</span>
|
||||||
|
<button onclick="openEditSetting('MUSICBRAINZ_USERNAME', 'MusicBrainz Username', 'text', 'Your MusicBrainz username')">Update</button>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">Password</span>
|
||||||
|
<span class="value" id="config-musicbrainz-password">-</span>
|
||||||
|
<button onclick="openEditSetting('MUSICBRAINZ_PASSWORD', 'MusicBrainz Password', 'password', 'Your MusicBrainz password')">Update</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Qobuz Settings</h2>
|
<h2>Qobuz Settings</h2>
|
||||||
<div class="config-section">
|
<div class="config-section">
|
||||||
@@ -776,6 +926,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="card">
|
||||||
<h2>Sync Schedule</h2>
|
<h2>Sync Schedule</h2>
|
||||||
<div class="config-section">
|
<div class="config-section">
|
||||||
@@ -792,6 +958,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Configuration Backup</h2>
|
||||||
|
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||||
|
Export your .env configuration for backup or import a previously saved configuration.
|
||||||
|
</p>
|
||||||
|
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
|
||||||
|
<button onclick="exportEnv()">📥 Export .env</button>
|
||||||
|
<button onclick="document.getElementById('import-env-input').click()">📤 Import .env</button>
|
||||||
|
<input type="file" id="import-env-input" accept=".env" style="display:none" onchange="importEnv(event)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card" style="background: rgba(248, 81, 73, 0.1); border-color: var(--error);">
|
<div class="card" style="background: rgba(248, 81, 73, 0.1); border-color: var(--error);">
|
||||||
<h2 style="color: var(--error);">Danger Zone</h2>
|
<h2 style="color: var(--error);">Danger Zone</h2>
|
||||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||||
@@ -803,6 +981,85 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Add Playlist Modal -->
|
<!-- Add Playlist Modal -->
|
||||||
@@ -844,7 +1101,7 @@
|
|||||||
|
|
||||||
<!-- Track List Modal -->
|
<!-- Track List Modal -->
|
||||||
<div class="modal" id="tracks-modal">
|
<div class="modal" id="tracks-modal">
|
||||||
<div class="modal-content" style="max-width: 700px;">
|
<div class="modal-content" style="max-width: 90%; width: 90%;">
|
||||||
<h3 id="tracks-modal-title">Playlist Tracks</h3>
|
<h3 id="tracks-modal-title">Playlist Tracks</h3>
|
||||||
<div class="tracks-list" id="tracks-list">
|
<div class="tracks-list" id="tracks-list">
|
||||||
<div class="loading">
|
<div class="loading">
|
||||||
@@ -860,10 +1117,12 @@
|
|||||||
<!-- Manual Track Mapping Modal -->
|
<!-- Manual Track Mapping Modal -->
|
||||||
<div class="modal" id="manual-map-modal">
|
<div class="modal" id="manual-map-modal">
|
||||||
<div class="modal-content" style="max-width: 600px;">
|
<div class="modal-content" style="max-width: 600px;">
|
||||||
<h3>Map Track to Local File</h3>
|
<h3>Map Track to External Provider</h3>
|
||||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||||
This track is currently using an external provider. Search for and select the local Jellyfin track to use instead.
|
Map this track to an external provider (SquidWTF, Deezer, or Qobuz). For local Jellyfin tracks, use the Spotify Import plugin instead.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!-- Track Info -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Spotify Track (Position <span id="map-position"></span>)</label>
|
<label>Spotify Track (Position <span id="map-position"></span>)</label>
|
||||||
<div style="background: var(--bg-primary); padding: 12px; border-radius: 8px; margin-bottom: 16px;">
|
<div style="background: var(--bg-primary); padding: 12px; border-radius: 8px; margin-bottom: 16px;">
|
||||||
@@ -871,18 +1130,30 @@
|
|||||||
<span style="color: var(--text-secondary);" id="map-spotify-artist"></span>
|
<span style="color: var(--text-secondary);" id="map-spotify-artist"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- External Mapping Section -->
|
||||||
|
<div id="external-mapping-section">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Search Jellyfin Tracks</label>
|
<label>External Provider</label>
|
||||||
<input type="text" id="map-search-query" placeholder="Search by title or artist..." oninput="searchJellyfinTracks()">
|
<select id="map-external-provider" style="width: 100%;">
|
||||||
|
<option value="SquidWTF">SquidWTF</option>
|
||||||
|
<option value="Deezer">Deezer</option>
|
||||||
|
<option value="Qobuz">Qobuz</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div id="map-search-results" style="max-height: 300px; overflow-y: auto; margin-top: 12px;">
|
<div class="form-group">
|
||||||
<p style="text-align: center; color: var(--text-secondary); padding: 20px;">
|
<label>External Provider ID</label>
|
||||||
Type to search for local tracks...
|
<input type="text" id="map-external-id" placeholder="Enter the provider-specific track ID..." oninput="validateExternalMapping()">
|
||||||
</p>
|
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
|
||||||
|
For SquidWTF: Use the track ID from the search results or URL<br>
|
||||||
|
For Deezer: Use the track ID from Deezer URLs<br>
|
||||||
|
For Qobuz: Use the track ID from Qobuz URLs
|
||||||
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<input type="hidden" id="map-playlist-name">
|
<input type="hidden" id="map-playlist-name">
|
||||||
<input type="hidden" id="map-spotify-id">
|
<input type="hidden" id="map-spotify-id">
|
||||||
<input type="hidden" id="map-selected-jellyfin-id">
|
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button onclick="closeModal('manual-map-modal')">Cancel</button>
|
<button onclick="closeModal('manual-map-modal')">Cancel</button>
|
||||||
<button class="primary" onclick="saveManualMapping()" id="map-save-btn" disabled>Save Mapping</button>
|
<button class="primary" onclick="saveManualMapping()" id="map-save-btn" disabled>Save Mapping</button>
|
||||||
@@ -916,6 +1187,45 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Lyrics ID Mapping Modal -->
|
||||||
|
<div class="modal" id="lyrics-map-modal">
|
||||||
|
<div class="modal-content" style="max-width: 600px;">
|
||||||
|
<h3>Map Lyrics ID</h3>
|
||||||
|
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||||
|
Manually map a track to a specific lyrics ID from lrclib.net. You can find lyrics IDs by searching on <a href="https://lrclib.net" target="_blank" style="color: var(--accent);">lrclib.net</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Track Info -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Track</label>
|
||||||
|
<div style="background: var(--bg-primary); padding: 12px; border-radius: 8px; margin-bottom: 16px;">
|
||||||
|
<strong id="lyrics-map-title"></strong><br>
|
||||||
|
<span style="color: var(--text-secondary);" id="lyrics-map-artist"></span><br>
|
||||||
|
<small style="color: var(--text-secondary);" id="lyrics-map-album"></small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lyrics ID Input -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Lyrics ID from lrclib.net</label>
|
||||||
|
<input type="number" id="lyrics-map-id" placeholder="Enter lyrics ID (e.g., 5929990)" min="1">
|
||||||
|
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
|
||||||
|
Search for the track on <a href="https://lrclib.net" target="_blank" style="color: var(--accent);">lrclib.net</a> and copy the ID from the URL or API response
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="hidden" id="lyrics-map-artist-value">
|
||||||
|
<input type="hidden" id="lyrics-map-title-value">
|
||||||
|
<input type="hidden" id="lyrics-map-album-value">
|
||||||
|
<input type="hidden" id="lyrics-map-duration">
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button onclick="closeModal('lyrics-map-modal')">Cancel</button>
|
||||||
|
<button class="primary" onclick="saveLyricsMapping()" id="lyrics-map-save-btn">Save Mapping</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Restart Overlay -->
|
<!-- Restart Overlay -->
|
||||||
<div class="restart-overlay" id="restart-overlay">
|
<div class="restart-overlay" id="restart-overlay">
|
||||||
<div class="spinner-large"></div>
|
<div class="spinner-large"></div>
|
||||||
@@ -971,15 +1281,44 @@
|
|||||||
if (hash) {
|
if (hash) {
|
||||||
switchTab(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
|
// Toast notification
|
||||||
function showToast(message, type = 'success') {
|
function showToast(message, type = 'success', duration = 3000) {
|
||||||
const toast = document.createElement('div');
|
const toast = document.createElement('div');
|
||||||
toast.className = 'toast ' + type;
|
toast.className = 'toast ' + type;
|
||||||
toast.textContent = message;
|
toast.textContent = message;
|
||||||
document.body.appendChild(toast);
|
document.body.appendChild(toast);
|
||||||
setTimeout(() => toast.remove(), 3000);
|
setTimeout(() => toast.remove(), duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Modal helpers
|
// Modal helpers
|
||||||
@@ -1112,7 +1451,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchPlaylists() {
|
async function fetchPlaylists(silent = false) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/playlists');
|
const res = await fetch('/api/admin/playlists');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -1120,7 +1459,9 @@
|
|||||||
const tbody = document.getElementById('playlist-table-body');
|
const tbody = document.getElementById('playlist-table-body');
|
||||||
|
|
||||||
if (data.playlists.length === 0) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1131,9 +1472,21 @@
|
|||||||
const externalMatched = p.externalMatched || 0;
|
const externalMatched = p.externalMatched || 0;
|
||||||
const externalMissing = p.externalMissing || 0;
|
const externalMissing = p.externalMissing || 0;
|
||||||
const totalInJellyfin = p.totalInJellyfin || 0;
|
const totalInJellyfin = p.totalInJellyfin || 0;
|
||||||
|
const totalPlayable = p.totalPlayable || (localCount + externalMatched); // Total tracks that will be served
|
||||||
|
|
||||||
// Build detailed stats string
|
// Debug: Log the raw data
|
||||||
let statsHtml = `<span class="track-count">${spotifyTotal}</span>`;
|
console.log(`Playlist ${p.name}:`, {
|
||||||
|
spotifyTotal,
|
||||||
|
localCount,
|
||||||
|
externalMatched,
|
||||||
|
externalMissing,
|
||||||
|
totalInJellyfin,
|
||||||
|
totalPlayable,
|
||||||
|
rawData: p
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build detailed stats string - show total playable tracks prominently
|
||||||
|
let statsHtml = `<span class="track-count">${totalPlayable}/${spotifyTotal}</span>`;
|
||||||
|
|
||||||
// Show breakdown with color coding
|
// Show breakdown with color coding
|
||||||
let breakdownParts = [];
|
let breakdownParts = [];
|
||||||
@@ -1151,10 +1504,16 @@
|
|||||||
? `<br><small style="color:var(--text-secondary)">${breakdownParts.join(' • ')}</small>`
|
? `<br><small style="color:var(--text-secondary)">${breakdownParts.join(' • ')}</small>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
// Calculate completion percentage
|
// Calculate completion percentage based on playable tracks
|
||||||
const completionPct = spotifyTotal > 0 ? Math.round((totalInJellyfin / spotifyTotal) * 100) : 0;
|
const completionPct = spotifyTotal > 0 ? Math.round((totalPlayable / spotifyTotal) * 100) : 0;
|
||||||
|
const localPct = spotifyTotal > 0 ? Math.round((localCount / spotifyTotal) * 100) : 0;
|
||||||
|
const externalPct = spotifyTotal > 0 ? Math.round((externalMatched / spotifyTotal) * 100) : 0;
|
||||||
|
const missingPct = spotifyTotal > 0 ? Math.round((externalMissing / spotifyTotal) * 100) : 0;
|
||||||
const completionColor = completionPct === 100 ? 'var(--success)' : completionPct >= 80 ? 'var(--accent)' : 'var(--warning)';
|
const completionColor = completionPct === 100 ? 'var(--success)' : completionPct >= 80 ? 'var(--accent)' : 'var(--warning)';
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
console.log(`Progress bar for ${p.name}: local=${localPct}%, external=${externalPct}%, missing=${missingPct}%, total=${completionPct}%`);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>${escapeHtml(p.name)}</strong></td>
|
<td><strong>${escapeHtml(p.name)}</strong></td>
|
||||||
@@ -1162,14 +1521,17 @@
|
|||||||
<td>${statsHtml}${breakdown}</td>
|
<td>${statsHtml}${breakdown}</td>
|
||||||
<td>
|
<td>
|
||||||
<div style="display:flex;align-items:center;gap:8px;">
|
<div style="display:flex;align-items:center;gap:8px;">
|
||||||
<div style="flex:1;background:var(--bg-tertiary);height:6px;border-radius:3px;overflow:hidden;">
|
<div style="flex:1;background:var(--bg-tertiary);height:12px;border-radius:6px;overflow:hidden;display:flex;">
|
||||||
<div style="width:${completionPct}%;height:100%;background:${completionColor};transition:width 0.3s;"></div>
|
<div style="width:${localPct}%;height:100%;background:#10b981;transition:width 0.3s;" title="${localCount} local tracks"></div>
|
||||||
|
<div style="width:${externalPct}%;height:100%;background:#f59e0b;transition:width 0.3s;" title="${externalMatched} external matched tracks"></div>
|
||||||
|
<div style="width:${missingPct}%;height:100%;background:#6b7280;transition:width 0.3s;" title="${externalMissing} missing tracks"></div>
|
||||||
</div>
|
</div>
|
||||||
<span style="font-size:0.85rem;color:${completionColor};font-weight:500;min-width:40px;">${completionPct}%</span>
|
<span style="font-size:0.85rem;color:${completionColor};font-weight:500;min-width:40px;">${completionPct}%</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="cache-age">${p.cacheAge || '-'}</td>
|
<td class="cache-age">${p.cacheAge || '-'}</td>
|
||||||
<td>
|
<td>
|
||||||
|
<button onclick="clearPlaylistCache('${escapeJs(p.name)}')">Clear Cache & Rebuild</button>
|
||||||
<button onclick="matchPlaylistTracks('${escapeJs(p.name)}')">Match Tracks</button>
|
<button onclick="matchPlaylistTracks('${escapeJs(p.name)}')">Match Tracks</button>
|
||||||
<button onclick="viewTracks('${escapeJs(p.name)}')">View</button>
|
<button onclick="viewTracks('${escapeJs(p.name)}')">View</button>
|
||||||
<button class="danger" onclick="removePlaylist('${escapeJs(p.name)}')">Remove</button>
|
<button class="danger" onclick="removePlaylist('${escapeJs(p.name)}')">Remove</button>
|
||||||
@@ -1183,6 +1545,232 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchTrackMappings() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/mappings/tracks');
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
// Update summary (only external now)
|
||||||
|
document.getElementById('mappings-total').textContent = data.externalCount || 0;
|
||||||
|
document.getElementById('mappings-external').textContent = data.externalCount || 0;
|
||||||
|
|
||||||
|
const tbody = document.getElementById('mappings-table-body');
|
||||||
|
|
||||||
|
if (data.mappings.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:40px;">No manual mappings found.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter to only show external mappings
|
||||||
|
const externalMappings = data.mappings.filter(m => m.type === 'external');
|
||||||
|
|
||||||
|
if (externalMappings.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:40px;">No external mappings found. Local Jellyfin mappings should be managed via Spotify Import plugin.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = externalMappings.map((m, index) => {
|
||||||
|
const typeColor = 'var(--success)';
|
||||||
|
const typeBadge = `<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:0.8rem;background:${typeColor}20;color:${typeColor};font-weight:500;">external</span>`;
|
||||||
|
|
||||||
|
const targetDisplay = `<span style="font-family:monospace;font-size:0.85rem;color:var(--success);">${m.externalProvider}/${m.externalId}</span>`;
|
||||||
|
|
||||||
|
const createdDate = m.createdAt ? new Date(m.createdAt).toLocaleString() : '-';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td><strong>${escapeHtml(m.playlist)}</strong></td>
|
||||||
|
<td style="font-family:monospace;font-size:0.85rem;color:var(--text-secondary);">${m.spotifyId}</td>
|
||||||
|
<td>${typeBadge}</td>
|
||||||
|
<td>${targetDisplay}</td>
|
||||||
|
<td style="color:var(--text-secondary);font-size:0.85rem;">${createdDate}</td>
|
||||||
|
<td>
|
||||||
|
<button class="danger delete-mapping-btn" style="padding:4px 12px;font-size:0.8rem;" data-playlist="${escapeHtml(m.playlist)}" data-spotify-id="${m.spotifyId}">Remove</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Add event listeners to all delete buttons
|
||||||
|
document.querySelectorAll('.delete-mapping-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
const playlist = this.getAttribute('data-playlist');
|
||||||
|
const spotifyId = this.getAttribute('data-spotify-id');
|
||||||
|
deleteTrackMapping(playlist, spotifyId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch track mappings:', error);
|
||||||
|
showToast('Failed to fetch track mappings', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTrackMapping(playlist, spotifyId) {
|
||||||
|
if (!confirm(`Remove manual external mapping for ${spotifyId} in playlist "${playlist}"?\n\nThis will:\n• Delete the manual mapping from the cache\n• Allow the track to be matched automatically again\n• The track may be re-matched with potentially better results\n\nThis action cannot be undone.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/mappings/tracks?playlist=${encodeURIComponent(playlist)}&spotifyId=${encodeURIComponent(spotifyId)}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('Mapping removed successfully', 'success');
|
||||||
|
await fetchTrackMappings();
|
||||||
|
} else {
|
||||||
|
const error = await res.json();
|
||||||
|
showToast(error.error || 'Failed to remove mapping', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete mapping:', error);
|
||||||
|
showToast('Failed to remove mapping', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchMissingTracks() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/playlists');
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
const tbody = document.getElementById('missing-tracks-table-body');
|
||||||
|
const missingTracks = [];
|
||||||
|
|
||||||
|
// Collect all missing tracks from all playlists
|
||||||
|
for (const playlist of data.playlists) {
|
||||||
|
if (playlist.externalMissing > 0) {
|
||||||
|
// Fetch tracks for this playlist
|
||||||
|
try {
|
||||||
|
const tracksRes = await fetch(`/api/admin/playlists/${encodeURIComponent(playlist.name)}/tracks`);
|
||||||
|
const tracksData = await tracksRes.json();
|
||||||
|
|
||||||
|
// Filter to only missing tracks (isLocal === null)
|
||||||
|
const missing = tracksData.tracks.filter(t => t.isLocal === null);
|
||||||
|
missing.forEach(t => {
|
||||||
|
missingTracks.push({
|
||||||
|
playlist: playlist.name,
|
||||||
|
...t
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to fetch tracks for ${playlist.name}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update summary
|
||||||
|
document.getElementById('missing-total').textContent = missingTracks.length;
|
||||||
|
|
||||||
|
if (missingTracks.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text-secondary);padding:40px;">🎉 No missing tracks! All tracks are matched.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(artist)}</td>
|
||||||
|
<td style="color:var(--text-secondary);">${t.album ? escapeHtml(t.album) : '-'}</td>
|
||||||
|
<td>
|
||||||
|
<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(artist)}')"
|
||||||
|
style="font-size:0.75rem;padding:4px 8px;background:var(--warning);border-color:var(--warning);">Map to External</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch missing tracks:', error);
|
||||||
|
showToast('Failed to fetch missing tracks', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDownloads() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/downloads');
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
const tbody = document.getElementById('downloads-table-body');
|
||||||
|
|
||||||
|
// Update summary
|
||||||
|
document.getElementById('downloads-count').textContent = data.count;
|
||||||
|
document.getElementById('downloads-size').textContent = data.totalSizeFormatted;
|
||||||
|
|
||||||
|
if (data.count === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text-secondary);padding:40px;">No downloaded files found.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = data.files.map(f => {
|
||||||
|
return `
|
||||||
|
<tr data-path="${escapeHtml(f.path)}">
|
||||||
|
<td><strong>${escapeHtml(f.artist)}</strong></td>
|
||||||
|
<td>${escapeHtml(f.album)}</td>
|
||||||
|
<td style="font-family:monospace;font-size:0.85rem;">${escapeHtml(f.fileName)}</td>
|
||||||
|
<td style="color:var(--text-secondary);">${f.sizeFormatted}</td>
|
||||||
|
<td>
|
||||||
|
<button onclick="downloadFile('${escapeJs(f.path)}')"
|
||||||
|
style="margin-right:4px;font-size:0.75rem;padding:4px 8px;background:var(--accent);border-color:var(--accent);">Download</button>
|
||||||
|
<button onclick="deleteDownload('${escapeJs(f.path)}')"
|
||||||
|
class="danger" style="font-size:0.75rem;padding:4px 8px;">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch downloads:', error);
|
||||||
|
showToast('Failed to fetch downloads', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFile(path) {
|
||||||
|
try {
|
||||||
|
window.open(`/api/admin/downloads/file?path=${encodeURIComponent(path)}`, '_blank');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to download file:', error);
|
||||||
|
showToast('Failed to download file', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteDownload(path) {
|
||||||
|
if (!confirm(`Delete this file?\n\n${path}\n\nThis action cannot be undone.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/downloads?path=${encodeURIComponent(path)}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('File deleted successfully', 'success');
|
||||||
|
|
||||||
|
// Remove the row immediately for live update
|
||||||
|
const escapedPath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||||
|
const row = document.querySelector(`tr[data-path="${escapedPath}"]`);
|
||||||
|
if (row) {
|
||||||
|
row.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh to update counts
|
||||||
|
await fetchDownloads();
|
||||||
|
} else {
|
||||||
|
const error = await res.json();
|
||||||
|
showToast(error.error || 'Failed to delete file', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete file:', error);
|
||||||
|
showToast('Failed to delete file', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchConfig() {
|
async function fetchConfig() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/config');
|
const res = await fetch('/api/admin/config');
|
||||||
@@ -1209,6 +1797,11 @@
|
|||||||
// SquidWTF settings
|
// SquidWTF settings
|
||||||
document.getElementById('config-squid-quality').textContent = data.squidWtf.quality;
|
document.getElementById('config-squid-quality').textContent = data.squidWtf.quality;
|
||||||
|
|
||||||
|
// MusicBrainz settings
|
||||||
|
document.getElementById('config-musicbrainz-enabled').textContent = data.musicBrainz.enabled ? 'Yes' : 'No';
|
||||||
|
document.getElementById('config-musicbrainz-username').textContent = data.musicBrainz.username || '(not set)';
|
||||||
|
document.getElementById('config-musicbrainz-password').textContent = data.musicBrainz.password || '(not set)';
|
||||||
|
|
||||||
// Qobuz settings
|
// Qobuz settings
|
||||||
document.getElementById('config-qobuz-token').textContent = data.qobuz.userAuthToken || '(not set)';
|
document.getElementById('config-qobuz-token').textContent = data.qobuz.userAuthToken || '(not set)';
|
||||||
document.getElementById('config-qobuz-quality').textContent = data.qobuz.quality || 'FLAC';
|
document.getElementById('config-qobuz-quality').textContent = data.qobuz.quality || 'FLAC';
|
||||||
@@ -1219,6 +1812,10 @@
|
|||||||
document.getElementById('config-jellyfin-user-id').textContent = data.jellyfin.userId || '(not set)';
|
document.getElementById('config-jellyfin-user-id').textContent = data.jellyfin.userId || '(not set)';
|
||||||
document.getElementById('config-jellyfin-library-id').textContent = data.jellyfin.libraryId || '-';
|
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
|
// Sync settings
|
||||||
const syncHour = data.spotifyImport.syncStartHour;
|
const syncHour = data.spotifyImport.syncStartHour;
|
||||||
const syncMin = data.spotifyImport.syncStartMinute;
|
const syncMin = data.spotifyImport.syncStartMinute;
|
||||||
@@ -1422,8 +2019,40 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function clearPlaylistCache(name) {
|
||||||
|
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();
|
||||||
|
|
||||||
|
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();
|
||||||
|
// 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) {
|
async function matchPlaylistTracks(name) {
|
||||||
try {
|
try {
|
||||||
|
// Show warning banner
|
||||||
|
document.getElementById('matching-warning-banner').style.display = 'block';
|
||||||
|
|
||||||
showToast(`Matching tracks for ${name}...`, 'success');
|
showToast(`Matching tracks for ${name}...`, 'success');
|
||||||
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(name)}/match`, { method: 'POST' });
|
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(name)}/match`, { method: 'POST' });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -1431,12 +2060,18 @@
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
showToast(`✓ ${data.message}`, 'success');
|
showToast(`✓ ${data.message}`, 'success');
|
||||||
// Refresh the playlists table after a delay to show updated counts
|
// 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 {
|
} else {
|
||||||
showToast(data.error || 'Failed to match tracks', 'error');
|
showToast(data.error || 'Failed to match tracks', 'error');
|
||||||
|
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast('Failed to match tracks', 'error');
|
showToast('Failed to match tracks', 'error');
|
||||||
|
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1444,6 +2079,9 @@
|
|||||||
if (!confirm('Match tracks for ALL playlists? This may take a few minutes.')) return;
|
if (!confirm('Match tracks for ALL playlists? This may take a few minutes.')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Show warning banner
|
||||||
|
document.getElementById('matching-warning-banner').style.display = 'block';
|
||||||
|
|
||||||
showToast('Matching tracks for all playlists...', 'success');
|
showToast('Matching tracks for all playlists...', 'success');
|
||||||
const res = await fetch('/api/admin/playlists/match-all', { method: 'POST' });
|
const res = await fetch('/api/admin/playlists/match-all', { method: 'POST' });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -1451,15 +2089,97 @@
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
showToast(`✓ ${data.message}`, 'success');
|
showToast(`✓ ${data.message}`, 'success');
|
||||||
// Refresh the playlists table after a delay to show updated counts
|
// 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';
|
||||||
|
}, 2000);
|
||||||
} else {
|
} else {
|
||||||
showToast(data.error || 'Failed to match tracks', 'error');
|
showToast(data.error || 'Failed to match tracks', 'error');
|
||||||
|
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast('Failed to match tracks', 'error');
|
showToast('Failed to match tracks', 'error');
|
||||||
|
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
// 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) {
|
||||||
|
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 match tracks', 'error');
|
||||||
|
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||||
|
}
|
||||||
|
} catch (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
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/squidwtf-base-url');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.baseUrl) {
|
||||||
|
// Use the HiFi API search endpoint: /search/?s=query
|
||||||
|
const searchUrl = `${data.baseUrl}/search/?s=${encodeURIComponent(query)}`;
|
||||||
|
window.open(searchUrl, '_blank');
|
||||||
|
} else {
|
||||||
|
showToast('Failed to get search URL', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Failed to get search URL', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function capitalizeProvider(provider) {
|
||||||
|
// Capitalize provider names for display
|
||||||
|
const providerMap = {
|
||||||
|
'squidwtf': 'SquidWTF',
|
||||||
|
'deezer': 'Deezer',
|
||||||
|
'qobuz': 'Qobuz'
|
||||||
|
};
|
||||||
|
return providerMap[provider?.toLowerCase()] || provider;
|
||||||
|
}
|
||||||
|
|
||||||
async function clearCache() {
|
async function clearCache() {
|
||||||
if (!confirm('Clear all cached playlist data?')) return;
|
if (!confirm('Clear all cached playlist data?')) return;
|
||||||
|
|
||||||
@@ -1473,6 +2193,61 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function exportEnv() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/export-env');
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Export failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `.env.backup.${new Date().toISOString().split('T')[0]}`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
|
||||||
|
showToast('.env file exported successfully', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Failed to export .env file', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importEnv(event) {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (!confirm('Import this .env file? This will replace your current configuration.\n\nA backup will be created automatically.\n\nYou will need to restart the container for changes to take effect.')) {
|
||||||
|
event.target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const res = await fetch('/api/admin/import-env', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast(data.message, 'success');
|
||||||
|
} else {
|
||||||
|
showToast(data.error || 'Failed to import .env file', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Failed to import .env file', 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
event.target.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
async function restartContainer() {
|
async function restartContainer() {
|
||||||
if (!confirm('Restart the container to apply configuration changes?\n\nThe dashboard will be temporarily unavailable.')) {
|
if (!confirm('Restart the container to apply configuration changes?\n\nThe dashboard will be temporarily unavailable.')) {
|
||||||
return;
|
return;
|
||||||
@@ -1609,36 +2384,111 @@
|
|||||||
document.getElementById('tracks-list').innerHTML = data.tracks.map(t => {
|
document.getElementById('tracks-list').innerHTML = data.tracks.map(t => {
|
||||||
let statusBadge = '';
|
let statusBadge = '';
|
||||||
let mapButton = '';
|
let mapButton = '';
|
||||||
|
let lyricsBadge = '';
|
||||||
|
|
||||||
|
// Add lyrics status badge
|
||||||
|
if (t.hasLyrics) {
|
||||||
|
lyricsBadge = '<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:4px;background:#3b82f6;color:white;"><span class="status-dot" style="background:white;"></span>Lyrics</span>';
|
||||||
|
}
|
||||||
|
|
||||||
if (t.isLocal === true) {
|
if (t.isLocal === true) {
|
||||||
statusBadge = '<span class="status-badge success" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>Local</span>';
|
statusBadge = '<span class="status-badge success" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>Local</span>';
|
||||||
|
// Add manual mapping indicator for local tracks
|
||||||
|
if (t.isManualMapping && t.manualMappingType === 'jellyfin') {
|
||||||
|
statusBadge += '<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:4px;background:var(--info);color:white;"><span class="status-dot" style="background:white;"></span>Manual</span>';
|
||||||
|
}
|
||||||
} else if (t.isLocal === false) {
|
} else if (t.isLocal === false) {
|
||||||
statusBadge = '<span class="status-badge warning" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>External</span>';
|
const provider = capitalizeProvider(t.externalProvider) || 'External';
|
||||||
// Add manual map button for external tracks
|
statusBadge = `<span class="status-badge warning" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>${escapeHtml(provider)}</span>`;
|
||||||
// Use JSON.stringify to properly escape strings for JavaScript
|
// Add manual mapping indicator for external tracks
|
||||||
const escapedName = JSON.stringify(name);
|
if (t.isManualMapping && t.manualMappingType === 'external') {
|
||||||
const escapedTitle = JSON.stringify(t.title || '');
|
statusBadge += '<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:4px;background:var(--info);color:white;"><span class="status-dot" style="background:white;"></span>Manual</span>';
|
||||||
// Safely get first artist, defaulting to empty string
|
}
|
||||||
|
// Add both mapping buttons for external tracks using data attributes
|
||||||
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
|
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
|
||||||
const escapedArtist = JSON.stringify(firstArtist);
|
mapButton = `<button class="small map-track-btn"
|
||||||
const escapedSpotifyId = JSON.stringify(t.spotifyId || '');
|
data-playlist-name="${escapeHtml(name)}"
|
||||||
mapButton = `<button class="small" onclick="openManualMap(${escapedName}, ${t.position}, ${escapedTitle}, ${escapedArtist}, ${escapedSpotifyId})" style="margin-left:8px;font-size:0.75rem;padding:4px 8px;">Map to Local</button>`;
|
data-position="${t.position}"
|
||||||
|
data-title="${escapeHtml(t.title || '')}"
|
||||||
|
data-artist="${escapeHtml(firstArtist)}"
|
||||||
|
data-spotify-id="${escapeHtml(t.spotifyId || '')}"
|
||||||
|
style="margin-left:8px;font-size:0.75rem;padding:4px 8px;">Map to Local</button>
|
||||||
|
<button class="small map-external-btn"
|
||||||
|
data-playlist-name="${escapeHtml(name)}"
|
||||||
|
data-position="${t.position}"
|
||||||
|
data-title="${escapeHtml(t.title || '')}"
|
||||||
|
data-artist="${escapeHtml(firstArtist)}"
|
||||||
|
data-spotify-id="${escapeHtml(t.spotifyId || '')}"
|
||||||
|
style="margin-left:4px;font-size:0.75rem;padding:4px 8px;background:var(--warning);border-color:var(--warning);">Map to External</button>`;
|
||||||
|
} else {
|
||||||
|
// isLocal is null/undefined - track is missing (not found locally or externally)
|
||||||
|
statusBadge = '<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;background:var(--bg-tertiary);color:var(--text-secondary);"><span class="status-dot" style="background:var(--text-secondary);"></span>Missing</span>';
|
||||||
|
// Add both mapping buttons for missing tracks
|
||||||
|
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
|
||||||
|
mapButton = `<button class="small map-track-btn"
|
||||||
|
data-playlist-name="${escapeHtml(name)}"
|
||||||
|
data-position="${t.position}"
|
||||||
|
data-title="${escapeHtml(t.title || '')}"
|
||||||
|
data-artist="${escapeHtml(firstArtist)}"
|
||||||
|
data-spotify-id="${escapeHtml(t.spotifyId || '')}"
|
||||||
|
style="margin-left:8px;font-size:0.75rem;padding:4px 8px;">Map to Local</button>
|
||||||
|
<button class="small map-external-btn"
|
||||||
|
data-playlist-name="${escapeHtml(name)}"
|
||||||
|
data-position="${t.position}"
|
||||||
|
data-title="${escapeHtml(t.title || '')}"
|
||||||
|
data-artist="${escapeHtml(firstArtist)}"
|
||||||
|
data-spotify-id="${escapeHtml(t.spotifyId || '')}"
|
||||||
|
style="margin-left:4px;font-size:0.75rem;padding:4px 8px;background:var(--warning);border-color:var(--warning);">Map to External</button>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build search link with track name and artist
|
||||||
|
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
|
||||||
|
const searchLinkText = `${t.title} - ${firstArtist}`;
|
||||||
|
const durationSeconds = Math.floor((t.durationMs || 0) / 1000);
|
||||||
|
|
||||||
|
// Add lyrics mapping button
|
||||||
|
const lyricsMapButton = `<button class="small" onclick="openLyricsMap('${escapeJs(firstArtist)}', '${escapeJs(t.title)}', '${escapeJs(t.album || '')}', ${durationSeconds})" style="margin-left:4px;font-size:0.75rem;padding:4px 8px;background:#3b82f6;border-color:#3b82f6;color:white;">Map Lyrics ID</button>`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="track-item">
|
<div class="track-item" data-position="${t.position}">
|
||||||
<span class="track-position">${t.position + 1}</span>
|
<span class="track-position">${t.position + 1}</span>
|
||||||
<div class="track-info">
|
<div class="track-info">
|
||||||
<h4>${escapeHtml(t.title)}${statusBadge}${mapButton}</h4>
|
<h4>${escapeHtml(t.title)}${statusBadge}${lyricsBadge}${mapButton}${lyricsMapButton}</h4>
|
||||||
<span class="artists">${escapeHtml((t.artists || []).join(', '))}</span>
|
<span class="artists">${escapeHtml((t.artists || []).join(', '))}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="track-meta">
|
<div class="track-meta">
|
||||||
${t.album ? escapeHtml(t.album) : ''}
|
${t.album ? escapeHtml(t.album) : ''}
|
||||||
${t.isrc ? '<br><small>ISRC: ' + t.isrc + '</small>' : ''}
|
${t.isrc ? '<br><small>ISRC: ' + t.isrc + '</small>' : ''}
|
||||||
|
${t.isLocal === false && t.searchQuery && t.externalProvider ? '<br><small style="color:var(--accent)"><a href="#" onclick="searchProvider(\'' + escapeJs(t.searchQuery) + '\', \'' + escapeJs(t.externalProvider) + '\'); return false;" style="color:var(--accent);text-decoration:underline;">🔍 Search: ' + escapeHtml(searchLinkText) + '</a></small>' : ''}
|
||||||
|
${t.isLocal === null && t.searchQuery ? '<br><small style="color:var(--text-secondary)"><a href="#" onclick="searchProvider(\'' + escapeJs(t.searchQuery) + '\', \'squidwtf\'); return false;" style="color:var(--text-secondary);text-decoration:underline;">🔍 Search: ' + escapeHtml(searchLinkText) + '</a></small>' : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
|
// Add event listeners to map buttons
|
||||||
|
document.querySelectorAll('.map-track-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
const playlistName = this.getAttribute('data-playlist-name');
|
||||||
|
const position = parseInt(this.getAttribute('data-position'));
|
||||||
|
const title = this.getAttribute('data-title');
|
||||||
|
const artist = this.getAttribute('data-artist');
|
||||||
|
const spotifyId = this.getAttribute('data-spotify-id');
|
||||||
|
openManualMap(playlistName, position, title, artist, spotifyId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add event listeners to external map buttons
|
||||||
|
document.querySelectorAll('.map-external-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
const playlistName = this.getAttribute('data-playlist-name');
|
||||||
|
const position = parseInt(this.getAttribute('data-position'));
|
||||||
|
const title = this.getAttribute('data-title');
|
||||||
|
const artist = this.getAttribute('data-artist');
|
||||||
|
const spotifyId = this.getAttribute('data-spotify-id');
|
||||||
|
openExternalMap(playlistName, position, title, artist, spotifyId);
|
||||||
|
});
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Failed to load tracks</p>';
|
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Failed to load tracks</p>';
|
||||||
}
|
}
|
||||||
@@ -1727,28 +2577,17 @@
|
|||||||
// Manual track mapping
|
// Manual track mapping
|
||||||
let searchTimeout = null;
|
let searchTimeout = null;
|
||||||
|
|
||||||
function openManualMap(playlistName, position, title, artist, spotifyId) {
|
|
||||||
document.getElementById('map-playlist-name').value = playlistName;
|
|
||||||
document.getElementById('map-position').textContent = position + 1;
|
|
||||||
document.getElementById('map-spotify-title').textContent = title;
|
|
||||||
document.getElementById('map-spotify-artist').textContent = artist;
|
|
||||||
document.getElementById('map-spotify-id').value = spotifyId;
|
|
||||||
document.getElementById('map-search-query').value = '';
|
|
||||||
document.getElementById('map-selected-jellyfin-id').value = '';
|
|
||||||
document.getElementById('map-save-btn').disabled = true;
|
|
||||||
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">Type to search for local tracks...</p>';
|
|
||||||
|
|
||||||
openModal('manual-map-modal');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function searchJellyfinTracks() {
|
async function searchJellyfinTracks() {
|
||||||
const query = document.getElementById('map-search-query').value.trim();
|
const query = document.getElementById('map-search-query').value.trim();
|
||||||
|
|
||||||
if (!query) {
|
if (!query) {
|
||||||
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">Type to search for local tracks...</p>';
|
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">Type to search for local tracks or paste a Jellyfin URL...</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear URL input when searching
|
||||||
|
document.getElementById('map-jellyfin-url').value = '';
|
||||||
|
|
||||||
// Debounce search
|
// Debounce search
|
||||||
clearTimeout(searchTimeout);
|
clearTimeout(searchTimeout);
|
||||||
searchTimeout = setTimeout(async () => {
|
searchTimeout = setTimeout(async () => {
|
||||||
@@ -1780,50 +2619,251 @@
|
|||||||
}, 300);
|
}, 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function extractJellyfinId() {
|
||||||
|
const url = document.getElementById('map-jellyfin-url').value.trim();
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">Type to search for local tracks or paste a Jellyfin URL...</p>';
|
||||||
|
document.getElementById('map-selected-jellyfin-id').value = '';
|
||||||
|
document.getElementById('map-save-btn').disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear search input when using URL
|
||||||
|
document.getElementById('map-search-query').value = '';
|
||||||
|
|
||||||
|
// Extract ID from URL patterns:
|
||||||
|
// https://jellyfin.example.com/web/#/details?id=XXXXX&serverId=...
|
||||||
|
// https://jellyfin.example.com/web/index.html#!/details?id=XXXXX
|
||||||
|
let jellyfinId = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const idMatch = url.match(/[?&]id=([a-f0-9]+)/i);
|
||||||
|
if (idMatch) {
|
||||||
|
jellyfinId = idMatch[1];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Invalid URL format
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!jellyfinId) {
|
||||||
|
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--error); padding: 20px;">Could not extract track ID from URL. Make sure it contains "?id=..."</p>';
|
||||||
|
document.getElementById('map-selected-jellyfin-id').value = '';
|
||||||
|
document.getElementById('map-save-btn').disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch track details to show preview
|
||||||
|
document.getElementById('map-search-results').innerHTML = '<div class="loading"><span class="spinner"></span> Loading track details...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/jellyfin/track/' + jellyfinId);
|
||||||
|
const track = await res.json();
|
||||||
|
|
||||||
|
if (res.ok && track.id) {
|
||||||
|
document.getElementById('map-selected-jellyfin-id').value = track.id;
|
||||||
|
document.getElementById('map-save-btn').disabled = false;
|
||||||
|
|
||||||
|
document.getElementById('map-search-results').innerHTML = `
|
||||||
|
<div class="track-item" style="border: 2px solid var(--accent); background: var(--bg-tertiary);">
|
||||||
|
<div class="track-info">
|
||||||
|
<h4>${escapeHtml(track.title)}</h4>
|
||||||
|
<span class="artists">${escapeHtml(track.artist)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="track-meta">
|
||||||
|
${track.album ? escapeHtml(track.album) : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p style="text-align: center; color: var(--success); padding: 12px; margin-top: 8px;">
|
||||||
|
✓ Track loaded from URL. Click "Save Mapping" to confirm.
|
||||||
|
</p>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--error); padding: 20px;">Track not found in Jellyfin</p>';
|
||||||
|
document.getElementById('map-selected-jellyfin-id').value = '';
|
||||||
|
document.getElementById('map-save-btn').disabled = true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--error); padding: 20px;">Failed to load track details</p>';
|
||||||
|
document.getElementById('map-selected-jellyfin-id').value = '';
|
||||||
|
document.getElementById('map-save-btn').disabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function selectJellyfinTrack(jellyfinId, element) {
|
function selectJellyfinTrack(jellyfinId, element) {
|
||||||
// Remove selection from all tracks
|
// Remove selection from all tracks
|
||||||
document.querySelectorAll('#map-search-results .track-item').forEach(el => {
|
document.querySelectorAll('#map-search-results .track-item').forEach(el => {
|
||||||
el.style.border = '2px solid transparent';
|
el.style.border = '2px solid transparent';
|
||||||
|
el.style.background = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
// Highlight selected track
|
// Highlight selected track
|
||||||
element.style.border = '2px solid var(--primary)';
|
element.style.border = '2px solid var(--accent)';
|
||||||
|
element.style.background = 'var(--bg-tertiary)';
|
||||||
|
|
||||||
// Store selected ID and enable save button
|
// Store selected ID and enable save button
|
||||||
document.getElementById('map-selected-jellyfin-id').value = jellyfinId;
|
document.getElementById('map-selected-jellyfin-id').value = jellyfinId;
|
||||||
document.getElementById('map-save-btn').disabled = false;
|
document.getElementById('map-save-btn').disabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate external mapping input
|
||||||
|
function validateExternalMapping() {
|
||||||
|
const externalId = document.getElementById('map-external-id').value.trim();
|
||||||
|
const saveBtn = document.getElementById('map-save-btn');
|
||||||
|
|
||||||
|
// Enable save button if external ID is provided
|
||||||
|
saveBtn.disabled = !externalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open manual mapping modal (external only)
|
||||||
|
function openManualMap(playlistName, position, title, artist, spotifyId) {
|
||||||
|
document.getElementById('map-playlist-name').value = playlistName;
|
||||||
|
document.getElementById('map-position').textContent = position + 1;
|
||||||
|
document.getElementById('map-spotify-title').textContent = title;
|
||||||
|
document.getElementById('map-spotify-artist').textContent = artist;
|
||||||
|
document.getElementById('map-spotify-id').value = spotifyId;
|
||||||
|
|
||||||
|
// Reset fields
|
||||||
|
document.getElementById('map-external-id').value = '';
|
||||||
|
document.getElementById('map-external-provider').value = 'SquidWTF';
|
||||||
|
document.getElementById('map-save-btn').disabled = true;
|
||||||
|
|
||||||
|
openModal('manual-map-modal');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alias for backward compatibility
|
||||||
|
function openExternalMap(playlistName, position, title, artist, spotifyId) {
|
||||||
|
openManualMap(playlistName, position, title, artist, spotifyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save manual mapping (external only)
|
||||||
async function saveManualMapping() {
|
async function saveManualMapping() {
|
||||||
const playlistName = document.getElementById('map-playlist-name').value;
|
const playlistName = document.getElementById('map-playlist-name').value;
|
||||||
const spotifyId = document.getElementById('map-spotify-id').value;
|
const spotifyId = document.getElementById('map-spotify-id').value;
|
||||||
const jellyfinId = document.getElementById('map-selected-jellyfin-id').value;
|
const position = parseInt(document.getElementById('map-position').textContent) - 1; // Convert back to 0-indexed
|
||||||
|
|
||||||
if (!jellyfinId) {
|
const externalProvider = document.getElementById('map-external-provider').value;
|
||||||
showToast('Please select a track', 'error');
|
const externalId = document.getElementById('map-external-id').value.trim();
|
||||||
|
|
||||||
|
if (!externalId) {
|
||||||
|
showToast('Please enter an external provider ID', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
spotifyId,
|
||||||
|
externalProvider,
|
||||||
|
externalId
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
const saveBtn = document.getElementById('map-save-btn');
|
||||||
|
const originalText = saveBtn.textContent;
|
||||||
|
saveBtn.textContent = 'Saving...';
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout
|
||||||
|
|
||||||
const res = await fetch('/api/admin/playlists/' + encodeURIComponent(playlistName) + '/map', {
|
const res = await fetch('/api/admin/playlists/' + encodeURIComponent(playlistName) + '/map', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ spotifyId, jellyfinId })
|
body: JSON.stringify(requestBody),
|
||||||
|
signal: controller.signal
|
||||||
});
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
showToast('Track mapped successfully! Refresh the playlist to see changes.', 'success');
|
showToast(`✓ Track mapped to ${requestBody.externalProvider} - rebuilding playlist...`, 'success');
|
||||||
closeModal('manual-map-modal');
|
closeModal('manual-map-modal');
|
||||||
// Refresh the tracks view
|
|
||||||
viewTracks(playlistName);
|
// Show rebuilding indicator
|
||||||
|
showPlaylistRebuildingIndicator(playlistName);
|
||||||
|
|
||||||
|
// Show detailed info toast after a moment
|
||||||
|
setTimeout(() => {
|
||||||
|
showToast(`🔄 Rebuilding playlist with your ${requestBody.externalProvider} mapping...`, 'info', 8000);
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// Update the track in the UI without refreshing
|
||||||
|
const trackItem = document.querySelector(`.track-item[data-position="${position}"]`);
|
||||||
|
if (trackItem) {
|
||||||
|
const titleEl = trackItem.querySelector('.track-info h4');
|
||||||
|
if (titleEl) {
|
||||||
|
// Update status badge to show provider
|
||||||
|
const currentTitle = titleEl.textContent.split(' - ')[0]; // Remove old status
|
||||||
|
const capitalizedProvider = capitalizeProvider(requestBody.externalProvider);
|
||||||
|
const newStatusBadge = `<span class="status-badge warning" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>${escapeHtml(capitalizedProvider)}</span>`;
|
||||||
|
titleEl.innerHTML = escapeHtml(currentTitle) + newStatusBadge;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove search link since it's now mapped
|
||||||
|
const searchLink = trackItem.querySelector('.track-meta a');
|
||||||
|
if (searchLink) {
|
||||||
|
searchLink.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also refresh the playlist counts in the background
|
||||||
|
fetchPlaylists();
|
||||||
} else {
|
} else {
|
||||||
showToast(data.error || 'Failed to save mapping', 'error');
|
showToast(data.error || 'Failed to save mapping', 'error');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
showToast('Request timed out - mapping may still be processing', 'warning');
|
||||||
|
} else {
|
||||||
showToast('Failed to save mapping', 'error');
|
showToast('Failed to save mapping', 'error');
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
// Reset button state
|
||||||
|
saveBtn.textContent = originalText;
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPlaylistRebuildingIndicator(playlistName) {
|
||||||
|
// Find the playlist in the UI and show rebuilding state
|
||||||
|
const playlistCards = document.querySelectorAll('.playlist-card');
|
||||||
|
for (const card of playlistCards) {
|
||||||
|
const nameEl = card.querySelector('h3');
|
||||||
|
if (nameEl && nameEl.textContent.trim() === playlistName) {
|
||||||
|
// Add rebuilding indicator
|
||||||
|
const existingIndicator = card.querySelector('.rebuilding-indicator');
|
||||||
|
if (!existingIndicator) {
|
||||||
|
const indicator = document.createElement('div');
|
||||||
|
indicator.className = 'rebuilding-indicator';
|
||||||
|
indicator.style.cssText = `
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background: var(--warning);
|
||||||
|
color: white;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
z-index: 10;
|
||||||
|
`;
|
||||||
|
indicator.innerHTML = '<span class="spinner" style="width: 10px; height: 10px;"></span>Rebuilding...';
|
||||||
|
card.style.position = 'relative';
|
||||||
|
card.appendChild(indicator);
|
||||||
|
|
||||||
|
// Auto-remove after 30 seconds and refresh
|
||||||
|
setTimeout(() => {
|
||||||
|
indicator.remove();
|
||||||
|
fetchPlaylists(); // Refresh to get updated counts
|
||||||
|
}, 30000);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeJs(text) {
|
function escapeJs(text) {
|
||||||
@@ -1831,18 +2871,184 @@
|
|||||||
return text.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"');
|
return text.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lyrics ID mapping functions
|
||||||
|
function openLyricsMap(artist, title, album, durationSeconds) {
|
||||||
|
document.getElementById('lyrics-map-artist').textContent = artist;
|
||||||
|
document.getElementById('lyrics-map-title').textContent = title;
|
||||||
|
document.getElementById('lyrics-map-album').textContent = album || '(No album)';
|
||||||
|
document.getElementById('lyrics-map-artist-value').value = artist;
|
||||||
|
document.getElementById('lyrics-map-title-value').value = title;
|
||||||
|
document.getElementById('lyrics-map-album-value').value = album || '';
|
||||||
|
document.getElementById('lyrics-map-duration').value = durationSeconds;
|
||||||
|
document.getElementById('lyrics-map-id').value = '';
|
||||||
|
|
||||||
|
openModal('lyrics-map-modal');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveLyricsMapping() {
|
||||||
|
const artist = document.getElementById('lyrics-map-artist-value').value;
|
||||||
|
const title = document.getElementById('lyrics-map-title-value').value;
|
||||||
|
const album = document.getElementById('lyrics-map-album-value').value;
|
||||||
|
const durationSeconds = parseInt(document.getElementById('lyrics-map-duration').value);
|
||||||
|
const lyricsId = parseInt(document.getElementById('lyrics-map-id').value);
|
||||||
|
|
||||||
|
if (!lyricsId || lyricsId <= 0) {
|
||||||
|
showToast('Please enter a valid lyrics ID', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveBtn = document.getElementById('lyrics-map-save-btn');
|
||||||
|
const originalText = saveBtn.textContent;
|
||||||
|
saveBtn.textContent = 'Saving...';
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/lyrics/map', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
artist,
|
||||||
|
title,
|
||||||
|
album,
|
||||||
|
durationSeconds,
|
||||||
|
lyricsId
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
if (data.cached && data.lyrics) {
|
||||||
|
showToast(`✓ Lyrics mapped and cached: ${data.lyrics.trackName} by ${data.lyrics.artistName}`, 'success', 5000);
|
||||||
|
} else {
|
||||||
|
showToast('✓ Lyrics mapping saved successfully', 'success');
|
||||||
|
}
|
||||||
|
closeModal('lyrics-map-modal');
|
||||||
|
} else {
|
||||||
|
showToast(data.error || 'Failed to save lyrics mapping', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Failed to save lyrics mapping', 'error');
|
||||||
|
} finally {
|
||||||
|
saveBtn.textContent = originalText;
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initial load
|
// Initial load
|
||||||
fetchStatus();
|
fetchStatus();
|
||||||
fetchPlaylists();
|
fetchPlaylists();
|
||||||
|
fetchTrackMappings();
|
||||||
|
fetchMissingTracks();
|
||||||
|
fetchDownloads();
|
||||||
fetchJellyfinUsers();
|
fetchJellyfinUsers();
|
||||||
fetchJellyfinPlaylists();
|
fetchJellyfinPlaylists();
|
||||||
fetchConfig();
|
fetchConfig();
|
||||||
|
fetchEndpointUsage();
|
||||||
|
|
||||||
// Auto-refresh every 30 seconds
|
// Auto-refresh every 30 seconds
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
fetchStatus();
|
fetchStatus();
|
||||||
fetchPlaylists();
|
fetchPlaylists();
|
||||||
|
fetchTrackMappings();
|
||||||
|
fetchMissingTracks();
|
||||||
|
fetchDownloads();
|
||||||
|
|
||||||
|
// Refresh endpoint usage if on that tab
|
||||||
|
const endpointsTab = document.getElementById('tab-endpoints');
|
||||||
|
if (endpointsTab && endpointsTab.classList.contains('active')) {
|
||||||
|
fetchEndpointUsage();
|
||||||
|
}
|
||||||
}, 30000);
|
}, 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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
BIN
allstarr/wwwroot/placeholder.png
Normal file
BIN
allstarr/wwwroot/placeholder.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
@@ -17,6 +17,17 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- allstarr-network
|
- allstarr-network
|
||||||
|
|
||||||
|
spotify-lyrics:
|
||||||
|
image: akashrchandran/spotify-lyrics-api:latest
|
||||||
|
container_name: allstarr-spotify-lyrics
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8365:8080"
|
||||||
|
environment:
|
||||||
|
- SP_DC=${SPOTIFY_API_SESSION_COOKIE:-}
|
||||||
|
networks:
|
||||||
|
- allstarr-network
|
||||||
|
|
||||||
allstarr:
|
allstarr:
|
||||||
# Use pre-built image from GitHub Container Registry
|
# Use pre-built image from GitHub Container Registry
|
||||||
# For latest stable: ghcr.io/sopat712/allstarr:latest
|
# For latest stable: ghcr.io/sopat712/allstarr:latest
|
||||||
@@ -40,6 +51,8 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
spotify-lyrics:
|
||||||
|
condition: service_started
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -98,6 +111,8 @@ services:
|
|||||||
- SpotifyApi__CacheDurationMinutes=${SPOTIFY_API_CACHE_DURATION_MINUTES:-60}
|
- SpotifyApi__CacheDurationMinutes=${SPOTIFY_API_CACHE_DURATION_MINUTES:-60}
|
||||||
- SpotifyApi__RateLimitDelayMs=${SPOTIFY_API_RATE_LIMIT_DELAY_MS:-100}
|
- SpotifyApi__RateLimitDelayMs=${SPOTIFY_API_RATE_LIMIT_DELAY_MS:-100}
|
||||||
- SpotifyApi__PreferIsrcMatching=${SPOTIFY_API_PREFER_ISRC_MATCHING:-true}
|
- SpotifyApi__PreferIsrcMatching=${SPOTIFY_API_PREFER_ISRC_MATCHING:-true}
|
||||||
|
# Spotify Lyrics API sidecar service URL (internal)
|
||||||
|
- SpotifyApi__LyricsApiUrl=${SPOTIFY_LYRICS_API_URL:-http://spotify-lyrics:8080}
|
||||||
|
|
||||||
# ===== SHARED =====
|
# ===== SHARED =====
|
||||||
- Library__DownloadPath=/app/downloads
|
- Library__DownloadPath=/app/downloads
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>WebSocket Test</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>Jellyfin WebSocket Test</h1>
|
|
||||||
<div id="status">Connecting...</div>
|
|
||||||
<div id="messages"></div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Replace with your actual token and device ID
|
|
||||||
const token = "4d19e81402394d40a7e787222606b3c2";
|
|
||||||
const deviceId = "test-device-123";
|
|
||||||
|
|
||||||
// Connect to your proxy
|
|
||||||
const wsUrl = `ws://jfm.joshpatra.me/socket?api_key=${token}&deviceId=${deviceId}`;
|
|
||||||
|
|
||||||
console.log("Connecting to:", wsUrl);
|
|
||||||
document.getElementById('status').textContent = `Connecting to: ${wsUrl}`;
|
|
||||||
|
|
||||||
const ws = new WebSocket(wsUrl);
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
console.log("✓ WebSocket connected!");
|
|
||||||
document.getElementById('status').textContent = "✓ Connected!";
|
|
||||||
document.getElementById('status').style.color = "green";
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
|
||||||
console.log("Message received:", event.data);
|
|
||||||
const msgDiv = document.createElement('div');
|
|
||||||
msgDiv.textContent = `[${new Date().toLocaleTimeString()}] ${event.data}`;
|
|
||||||
document.getElementById('messages').appendChild(msgDiv);
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onerror = (error) => {
|
|
||||||
console.error("WebSocket error:", error);
|
|
||||||
document.getElementById('status').textContent = "✗ Error!";
|
|
||||||
document.getElementById('status').style.color = "red";
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = (event) => {
|
|
||||||
console.log("WebSocket closed:", event.code, event.reason);
|
|
||||||
document.getElementById('status').textContent = `✗ Closed (${event.code})`;
|
|
||||||
document.getElementById('status').style.color = "orange";
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Reference in New Issue
Block a user