mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
Compare commits
143 Commits
beta
...
6eaeee9a67
| Author | SHA1 | Date | |
|---|---|---|---|
|
6eaeee9a67
|
|||
|
9dd49a2f43
|
|||
|
2bc2816191
|
|||
|
1a2e160279
|
|||
|
4111b5228d
|
|||
|
82b480c47e
|
|||
|
0d246a8e74
|
|||
|
fc3a8134ca
|
|||
|
2f91457e52
|
|||
|
77774120bf
|
|||
|
936fa27aa7
|
|||
|
b90ce423d7
|
|||
|
5f038965a2
|
|||
|
229fa0bf65
|
|||
|
1aec76c3dd
|
|||
|
96b06d5d4f
|
|||
|
747d310375
|
|||
|
fc78a095a9
|
|||
|
65ca80f9a0
|
|||
|
8e6eb5cc4a
|
|||
|
1326f1b3ab
|
|||
|
0011538966
|
|||
|
5acdacf132
|
|||
|
cef836da43
|
|||
|
26c9a72def
|
|||
|
f5124bdda2
|
|||
|
f7f57e711c
|
|||
|
76f633afce
|
|||
|
24df910ffa
|
|||
|
eb46692b25
|
|||
|
c54a32ccfc
|
|||
|
c0c7668cc4
|
|||
|
e860bbe0ee
|
|||
|
df3cc51e17
|
|||
|
027aeab969
|
|||
|
449bcc2561
|
|||
|
8da0bef481
|
|||
|
ae8afa20f8
|
|||
|
da1d28d292
|
|||
|
7e0ea501fc
|
|||
|
bb976fed4f
|
|||
|
df77b16640
|
|||
|
74ae85338c
|
|||
|
72b7198f1d
|
|||
|
b24dfb5b6a
|
|||
|
85f8e1cc5f
|
|||
|
74bd64c949
|
|||
|
1afa68064e
|
|||
|
5251c7ef6d
|
|||
|
63ab25ca91
|
|||
|
628f845e77
|
|||
|
8ef5ee7d8f
|
|||
|
fb3ea1b876
|
|||
|
3f3e1b708d
|
|||
|
bc4faead74
|
|||
|
6ffa2a3277
|
|||
|
c3c01b5559
|
|||
|
47d59ec0f5
|
|||
|
e7f72cd87a
|
|||
|
6d15d02f16
|
|||
|
3137cc4657
|
|||
|
18e700d6a4
|
|||
|
2420cd9a23
|
|||
|
65d6eb041a
|
|||
|
103808f079
|
|||
|
cd29e0de6c
|
|||
|
bd480be382
|
|||
|
293f6f5cc4
|
|||
|
e9b893eb3e
|
|||
|
51694a395d
|
|||
|
32166061ef
|
|||
|
a8845a9ef3
|
|||
|
e873cfe3bf
|
|||
|
43718eaefc
|
|||
|
5f9451f5b4
|
|||
|
2c3ef5c360
|
|||
|
4ba2245876
|
|||
|
c117fa41f6
|
|||
|
2b078453b2
|
|||
|
0ee1883ccb
|
|||
|
8912758b5e
|
|||
|
35d5249843
|
|||
|
62bfb367bc
|
|||
|
6f91361966
|
|||
|
d4036095f1
|
|||
|
6620b39357
|
|||
|
dcaa89171a
|
|||
|
1889dc6e19
|
|||
|
615ad58bc6
|
|||
|
6176777d0f
|
|||
|
a339574f05
|
|||
|
67b4fac64c
|
|||
|
ada6653bd1
|
|||
|
df8dbfc5e1
|
|||
|
e8d3fc4d17
|
|||
|
649351f68b
|
|||
|
3487f79b5e
|
|||
|
3a3f572ead
|
|||
|
d7f15fc3ab
|
|||
|
e43f5cd427
|
|||
|
1b79138923
|
|||
|
dda9736f8d
|
|||
|
9493cb48a5
|
|||
|
6c2453896f
|
|||
|
40594dea7e
|
|||
|
a06bf42887
|
|||
|
6713007650
|
|||
|
e7724c2cc0
|
|||
|
3358fe019d
|
|||
|
9efc54857f
|
|||
|
fcdf47984c
|
|||
|
040a5451a1
|
|||
|
8d76e97449
|
|||
|
a86a8013e6
|
|||
|
4c557a0325
|
|||
|
8540a22846
|
|||
|
36a224bd45
|
|||
|
3af3ebb52b
|
|||
|
614adb9892
|
|||
|
f434f13a19
|
|||
|
625a75f8f9
|
|||
|
d600c5e456
|
|||
|
e23e22a736
|
|||
|
ba0fe35e72
|
|||
|
6e9fe0e69e
|
|||
|
cba955c427
|
|||
|
192173ea64
|
|||
|
c33180abd7
|
|||
|
680454e76e
|
|||
|
34bfc20d28
|
|||
|
489159b424
|
|||
|
2bb754b245
|
|||
|
8d8c0892a2
|
|||
|
e12851e9ca
|
|||
|
f8969bea8d
|
|||
|
ceaa17f018
|
|||
|
9aa7ceb138
|
|||
|
72b1ebc2eb
|
|||
|
48a0351862
|
|||
|
4b95f9910c
|
|||
|
80424a867d
|
|||
|
4afd769602
|
|||
|
b47a5f9063
|
41
.env.example
41
.env.example
@@ -6,6 +6,10 @@ BACKEND_TYPE=Subsonic
|
||||
# Enable Redis caching for metadata and images (default: true)
|
||||
REDIS_ENABLED=true
|
||||
|
||||
# Redis data persistence directory (default: ./redis-data)
|
||||
# Redis will save snapshots and append-only logs here to persist cache across restarts
|
||||
REDIS_DATA_PATH=./redis-data
|
||||
|
||||
# ===== SUBSONIC/NAVIDROME CONFIGURATION =====
|
||||
# Server URL (required if using Subsonic backend)
|
||||
SUBSONIC_URL=http://localhost:4533
|
||||
@@ -30,6 +34,12 @@ MUSIC_SERVICE=SquidWTF
|
||||
# Path where downloaded songs will be stored on the host (only applies if STORAGE_MODE=Permanent)
|
||||
DOWNLOAD_PATH=./downloads
|
||||
|
||||
# Path where favorited external tracks are permanently kept
|
||||
KEPT_PATH=./kept
|
||||
|
||||
# Path for cache files (Spotify missing tracks, etc.)
|
||||
CACHE_PATH=./cache
|
||||
|
||||
# ===== SQUIDWTF CONFIGURATION =====
|
||||
# Different quality options for SquidWTF. Only FLAC supported right now
|
||||
SQUIDWTF_QUALITY=FLAC
|
||||
@@ -95,3 +105,34 @@ STORAGE_MODE=Permanent
|
||||
# Based on last access time (updated each time the file is streamed)
|
||||
# Cache location: /tmp/allstarr-cache (or $TMPDIR/allstarr-cache if TMPDIR is set)
|
||||
CACHE_DURATION_HOURS=1
|
||||
|
||||
# ===== SPOTIFY PLAYLIST INJECTION (JELLYFIN ONLY) =====
|
||||
# REQUIRES: Jellyfin Spotify Import Plugin (https://github.com/Viperinius/jellyfin-plugin-spotify-import)
|
||||
# This feature intercepts Spotify Import plugin playlists (Release Radar, Discover Weekly) and fills them
|
||||
# with tracks auto-matched from external providers (SquidWTF, Deezer, Qobuz)
|
||||
# Uses JELLYFIN_URL and JELLYFIN_API_KEY configured above (no separate credentials needed)
|
||||
|
||||
# Enable Spotify playlist injection (optional, default: false)
|
||||
SPOTIFY_IMPORT_ENABLED=false
|
||||
|
||||
# Sync schedule: When does the Spotify Import plugin run?
|
||||
# Set these to match your plugin's sync schedule in Jellyfin
|
||||
# Example: If plugin runs daily at 4:15 PM, set HOUR=16 and MINUTE=15
|
||||
SPOTIFY_IMPORT_SYNC_START_HOUR=16
|
||||
SPOTIFY_IMPORT_SYNC_START_MINUTE=15
|
||||
|
||||
# Sync window: How long to search for missing tracks files (in hours)
|
||||
# The fetcher will check every 5 minutes within this window
|
||||
# Example: If plugin runs at 4:15 PM and window is 2 hours, checks from 4:00 PM to 6:00 PM
|
||||
SPOTIFY_IMPORT_SYNC_WINDOW_HOURS=2
|
||||
|
||||
# Playlist IDs to inject (comma-separated)
|
||||
# Get IDs from Jellyfin playlist URLs: https://jellyfin.example.com/web/#/details?id=PLAYLIST_ID
|
||||
# Example: SPOTIFY_IMPORT_PLAYLIST_IDS=4383a46d8bcac3be2ef9385053ea18df,ba50e26c867ec9d57ab2f7bf24cfd6b0
|
||||
SPOTIFY_IMPORT_PLAYLIST_IDS=
|
||||
|
||||
# Playlist names (comma-separated, must match Spotify Import plugin format)
|
||||
# IMPORTANT: Use the exact playlist names as they appear in Jellyfin
|
||||
# Must be in same order as SPOTIFY_IMPORT_PLAYLIST_IDS
|
||||
# Example: SPOTIFY_IMPORT_PLAYLIST_NAMES=Discover Weekly,Release Radar
|
||||
SPOTIFY_IMPORT_PLAYLIST_NAMES=
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -74,6 +74,12 @@ obj/
|
||||
downloads/
|
||||
!downloads/.gitkeep
|
||||
|
||||
# Kept music files (favorited external tracks)
|
||||
kept/
|
||||
|
||||
# Cache files (Spotify missing tracks, etc.)
|
||||
cache/
|
||||
|
||||
# Docker volumes
|
||||
redis-data/
|
||||
|
||||
@@ -84,3 +90,6 @@ apis/*.json
|
||||
|
||||
# Original source code for reference
|
||||
originals/
|
||||
|
||||
# Sample missing playlists for Spotify integration testing
|
||||
sampleMissingPlaylists/
|
||||
162
README.md
162
README.md
@@ -16,13 +16,18 @@ Please report all bugs as soon as possible, as the Jellyfin addition is entirely
|
||||
Using Docker (recommended):
|
||||
|
||||
```bash
|
||||
# 1. Pull the latest image
|
||||
docker-compose pull
|
||||
# 1. Download the docker-compose.yml file and the .env.example file to a folder on the machine you have Docker
|
||||
|
||||
curl -O https://raw.githubusercontent.com/SoPat712/allstarr/refs/heads/main/docker-compose.yml \
|
||||
-O https://raw.githubusercontent.com/SoPat712/allstarr/refs/heads/main/.env.example
|
||||
|
||||
# 2. Configure environment
|
||||
cp .env.example .env
|
||||
vi .env # Edit with your settings
|
||||
|
||||
# 3. Pull the latest image
|
||||
docker-compose pull
|
||||
|
||||
# 3. Start services
|
||||
docker-compose up -d
|
||||
|
||||
@@ -35,7 +40,7 @@ The proxy will be available at `http://localhost:5274`.
|
||||
|
||||
### Nginx Proxy Setup (Required)
|
||||
|
||||
This service only exposes ports internally. You **must** use nginx to proxy to it:
|
||||
This service only exposes ports internally. You can use nginx to proxy to it, however PLEASE take significant precautions before exposing this! Everyone decides their own level of risk, but this is currently untested, potentially dangerous software, with almost unfettered access to your Jellyfin server. My recommendation is use Tailscale or something similar!
|
||||
|
||||
```nginx
|
||||
server {
|
||||
@@ -78,6 +83,7 @@ This project brings together all the music streaming providers into one unified
|
||||
- **Transparent Proxy**: Sits between your music clients and media server
|
||||
- **Automatic Search**: Searches streaming providers when songs aren't local
|
||||
- **On-the-Fly Downloads**: Songs download and cache for future use
|
||||
- **Favorite to Keep**: When you favorite an external track, it's automatically copied to a permanent `/kept` folder separate from the cache
|
||||
- **External Playlist Support**: Search and download playlists from Deezer, Qobuz, and SquidWTF with M3U generation
|
||||
- **Hi-Res Audio**: SquidWTF supports up to 24-bit/192kHz FLAC
|
||||
- **Full Metadata**: Downloaded files include complete ID3 tags (title, artist, album, track number, year, genre, BPM, ISRC, etc.) and cover art
|
||||
@@ -85,6 +91,7 @@ This project brings together all the music streaming providers into one unified
|
||||
- **Artist Deduplication**: Merges local and streaming artists to avoid duplicates
|
||||
- **Album Enrichment**: Adds missing tracks to local albums from streaming providers
|
||||
- **Cover Art Proxy**: Serves cover art for external content
|
||||
- **Spotify Playlist Injection** (Jellyfin only): Intercepts Spotify Import plugin playlists (Release Radar, Discover Weekly) and fills them with tracks auto-matched from streaming providers
|
||||
|
||||
## Supported Backends
|
||||
|
||||
@@ -243,6 +250,10 @@ Choose your preferred provider via the `MUSIC_SERVICE` environment variable. Add
|
||||
|---------|-------------|
|
||||
| `SquidWTF:Quality` | Preferred audio quality: `FLAC`, `MP3_320`, `MP3_128`. If not specified, the highest available quality for your account will be used |
|
||||
|
||||
**Load Balancing & Reliability:**
|
||||
|
||||
SquidWTF uses a round-robin load balancing strategy across multiple backup API endpoints to distribute requests evenly and prevent overwhelming any single provider. Each request automatically rotates to the next endpoint in the pool, with automatic fallback to other endpoints if one fails. This ensures high availability and prevents rate limiting by distributing load across multiple providers.
|
||||
|
||||
### Deezer Settings
|
||||
|
||||
| Setting | Description |
|
||||
@@ -282,6 +293,151 @@ Subsonic__EnableExternalPlaylists=false
|
||||
|
||||
> **Note**: Due to client-side filtering, playlists from streaming providers may not appear in the "Playlists" tab of some clients, but will show up in global search results.
|
||||
|
||||
### Spotify Playlist Injection (Jellyfin Only)
|
||||
|
||||
Allstarr 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.
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
1. **Install the Jellyfin Spotify Import Plugin**
|
||||
- Navigate to Jellyfin Dashboard → Plugins → Catalog
|
||||
- Search for "Spotify Import" by Viperinius
|
||||
- Install and restart Jellyfin
|
||||
- Plugin repository: [Viperinius/jellyfin-plugin-spotify-import](https://github.com/Viperinius/jellyfin-plugin-spotify-import)
|
||||
|
||||
2. **Configure the Spotify Import Plugin**
|
||||
- Go to Jellyfin Dashboard → Plugins → Spotify Import
|
||||
- Connect your Spotify account
|
||||
- Select which playlists to sync (e.g., Release Radar, Discover Weekly)
|
||||
- Set a daily sync schedule (e.g., 4:15 PM daily)
|
||||
- The plugin will create playlists in Jellyfin and generate "missing tracks" files for songs not in your library
|
||||
|
||||
3. **Configure Allstarr**
|
||||
- Allstarr needs to know when the plugin runs and which playlists to intercept
|
||||
- Uses your existing `JELLYFIN_URL` and `JELLYFIN_API_KEY` settings (no additional credentials needed)
|
||||
|
||||
#### Configuration
|
||||
|
||||
| Setting | Description |
|
||||
|---------|-------------|
|
||||
| `SpotifyImport:Enabled` | Enable Spotify playlist injection (default: `false`) |
|
||||
| `SpotifyImport:SyncStartHour` | Hour when the Spotify Import plugin runs (24-hour format, 0-23) |
|
||||
| `SpotifyImport:SyncStartMinute` | Minute when the plugin runs (0-59) |
|
||||
| `SpotifyImport:SyncWindowHours` | Hours to search for missing tracks files after sync time (default: 2) |
|
||||
| `SpotifyImport:PlaylistIds` | Comma-separated Jellyfin playlist IDs to intercept |
|
||||
| `SpotifyImport:PlaylistNames` | Comma-separated playlist names (must match order of IDs) |
|
||||
|
||||
**Environment variables example:**
|
||||
```bash
|
||||
# Enable the feature
|
||||
SPOTIFY_IMPORT_ENABLED=true
|
||||
|
||||
# Sync window settings (optional - used to prevent fetching too frequently)
|
||||
# The fetcher searches backwards from current time for the last 48 hours
|
||||
SPOTIFY_IMPORT_SYNC_START_HOUR=16
|
||||
SPOTIFY_IMPORT_SYNC_START_MINUTE=15
|
||||
SPOTIFY_IMPORT_SYNC_WINDOW_HOURS=2
|
||||
|
||||
# Get playlist IDs from Jellyfin URLs: https://jellyfin.example.com/web/#/details?id=PLAYLIST_ID
|
||||
SPOTIFY_IMPORT_PLAYLIST_IDS=ba50e26c867ec9d57ab2f7bf24cfd6b0,4383a46d8bcac3be2ef9385053ea18df
|
||||
|
||||
# Names must match exactly as they appear in Jellyfin (used to find missing tracks files)
|
||||
SPOTIFY_IMPORT_PLAYLIST_NAMES=Release Radar,Discover Weekly
|
||||
```
|
||||
|
||||
#### How It Works
|
||||
|
||||
1. **Spotify Import Plugin Runs** (e.g., daily at 4:15 PM)
|
||||
- Plugin fetches your Spotify playlists
|
||||
- Creates/updates playlists in Jellyfin with tracks already in your library
|
||||
- Generates "missing tracks" JSON files for songs not found locally
|
||||
- Files are named like: `Release Radar_missing_2026-02-01_16-15.json`
|
||||
|
||||
2. **Allstarr Fetches Missing Tracks** (within sync window)
|
||||
- Searches for missing tracks files from the Jellyfin plugin
|
||||
- Searches **+24 hours forward first** (newest files), then **-48 hours backward** if not found
|
||||
- This efficiently finds the most recent file regardless of timezone differences
|
||||
- Example: Server time 12 PM EST, file timestamped 9 PM UTC (same day) → Found in forward search
|
||||
- Caches the list of missing tracks in Redis + file cache
|
||||
- Runs automatically on startup (if needed) and every 5 minutes during the sync window
|
||||
|
||||
3. **Allstarr Matches Tracks** (2 minutes after startup, then every 30 minutes)
|
||||
- For each missing track, searches your streaming provider (SquidWTF, Deezer, or Qobuz)
|
||||
- Uses fuzzy matching to find the best match (title + artist similarity)
|
||||
- Rate-limited to avoid overwhelming the service (150ms delay between searches)
|
||||
- Caches matched results for 1 hour
|
||||
|
||||
4. **You Open the Playlist in Jellyfin**
|
||||
- Allstarr intercepts the request
|
||||
- Returns a merged list: local tracks + matched streaming tracks
|
||||
- Loads instantly from cache (no searching needed!)
|
||||
|
||||
5. **You Play a Track**
|
||||
- If it's a local track, streams from Jellyfin normally
|
||||
- If it's a matched track, downloads from streaming provider on-demand
|
||||
- Downloaded tracks are saved to your library for future use
|
||||
|
||||
#### Manual Triggers
|
||||
|
||||
You can manually trigger syncing and matching via API:
|
||||
|
||||
```bash
|
||||
# Fetch missing tracks from Jellyfin plugin
|
||||
curl "https://your-jellyfin-proxy.com/spotify/sync?api_key=YOUR_API_KEY"
|
||||
|
||||
# Trigger track matching (searches streaming provider)
|
||||
curl "https://your-jellyfin-proxy.com/spotify/match?api_key=YOUR_API_KEY"
|
||||
|
||||
# Clear cache to force re-matching
|
||||
curl "https://your-jellyfin-proxy.com/spotify/clear-cache?api_key=YOUR_API_KEY"
|
||||
```
|
||||
|
||||
#### Startup Behavior
|
||||
|
||||
When Allstarr starts with Spotify Import enabled:
|
||||
|
||||
**Smart Cache Check:**
|
||||
- Checks if today's sync window has passed (e.g., if sync is at 4 PM + 2 hour window = 6 PM)
|
||||
- If before 6 PM and yesterday's cache exists → **Skips fetch** (cache is still current)
|
||||
- If after 6 PM or no cache exists → **Fetches missing tracks** from Jellyfin plugin
|
||||
|
||||
**Track Matching:**
|
||||
- **T+2min**: Matches tracks with streaming provider (with rate limiting)
|
||||
- Only matches playlists that don't already have cached matches
|
||||
- **Result**: Playlists load instantly when you open them!
|
||||
|
||||
**Example Timeline:**
|
||||
- Plugin runs daily at 4:15 PM, creates files at ~4:16 PM
|
||||
- You restart Allstarr at 12:00 PM (noon) the next day
|
||||
- Startup check: "Today's sync window ends at 6 PM, and I have yesterday's 4:16 PM file"
|
||||
- **Decision**: Skip fetch, use existing cache
|
||||
- At 6:01 PM: Next scheduled check will search for new files
|
||||
|
||||
#### Troubleshooting
|
||||
|
||||
**Playlists are empty:**
|
||||
- Check that the Spotify Import plugin is running and creating playlists
|
||||
- Verify `SPOTIFY_IMPORT_PLAYLIST_IDS` match your Jellyfin playlist IDs
|
||||
- Check logs: `docker-compose logs -f allstarr | grep -i spotify`
|
||||
|
||||
**Tracks aren't matching:**
|
||||
- Ensure your streaming provider is configured (`MUSIC_SERVICE`, credentials)
|
||||
- Check that playlist names in `SPOTIFY_IMPORT_PLAYLIST_NAMES` match exactly
|
||||
- Manually trigger matching: `curl "https://your-proxy.com/spotify/match?api_key=KEY"`
|
||||
|
||||
**Sync timing issues:**
|
||||
- Set `SPOTIFY_IMPORT_SYNC_START_HOUR/MINUTE` to match your plugin schedule
|
||||
- Increase `SPOTIFY_IMPORT_SYNC_WINDOW_HOURS` if files aren't being found
|
||||
- Check Jellyfin plugin logs to confirm when it runs
|
||||
|
||||
#### Notes
|
||||
|
||||
- This feature uses your existing `JELLYFIN_URL` and `JELLYFIN_API_KEY` settings
|
||||
- Matched tracks are cached for 1 hour to avoid repeated searches
|
||||
- Missing tracks cache persists across restarts (stored in Redis + file cache)
|
||||
- Rate limiting prevents overwhelming your streaming provider (150ms between searches)
|
||||
- Only works with Jellyfin backend (not Subsonic/Navidrome)
|
||||
|
||||
### Getting Credentials
|
||||
|
||||
#### Deezer ARL Token
|
||||
|
||||
@@ -63,11 +63,12 @@ public class JellyfinProxyServiceTests
|
||||
SetupMockResponse(HttpStatusCode.OK, jsonResponse, "application/json");
|
||||
|
||||
// Act
|
||||
var result = await _service.GetJsonAsync("Items");
|
||||
var (body, statusCode) = await _service.GetJsonAsync("Items");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result.RootElement.TryGetProperty("Items", out var items));
|
||||
Assert.NotNull(body);
|
||||
Assert.Equal(200, statusCode);
|
||||
Assert.True(body.RootElement.TryGetProperty("Items", out var items));
|
||||
Assert.Equal(1, items.GetArrayLength());
|
||||
}
|
||||
|
||||
@@ -78,14 +79,15 @@ public class JellyfinProxyServiceTests
|
||||
SetupMockResponse(HttpStatusCode.InternalServerError, "", "text/plain");
|
||||
|
||||
// Act
|
||||
var result = await _service.GetJsonAsync("Items");
|
||||
var (body, statusCode) = await _service.GetJsonAsync("Items");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
Assert.Null(body);
|
||||
Assert.Equal(500, statusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetJsonAsync_IncludesAuthHeader()
|
||||
public async Task GetJsonAsync_WithoutClientHeaders_SendsNoAuth()
|
||||
{
|
||||
// Arrange
|
||||
HttpRequestMessage? captured = null;
|
||||
@@ -102,13 +104,10 @@ public class JellyfinProxyServiceTests
|
||||
// Act
|
||||
await _service.GetJsonAsync("Items");
|
||||
|
||||
// Assert
|
||||
// Assert - Should NOT include auth when no client headers provided
|
||||
Assert.NotNull(captured);
|
||||
Assert.True(captured!.Headers.Contains("Authorization"));
|
||||
var authHeader = captured.Headers.GetValues("Authorization").First();
|
||||
Assert.Contains("MediaBrowser", authHeader);
|
||||
Assert.Contains(_settings.ApiKey!, authHeader);
|
||||
Assert.Contains(_settings.ClientName!, authHeader);
|
||||
Assert.False(captured!.Headers.Contains("Authorization"));
|
||||
Assert.False(captured.Headers.Contains("X-Emby-Authorization"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -210,12 +209,13 @@ public class JellyfinProxyServiceTests
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _service.GetItemAsync("abc-123");
|
||||
var (body, statusCode) = await _service.GetItemAsync("abc-123");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(captured);
|
||||
Assert.Contains("/Items/abc-123", captured!.RequestUri!.ToString());
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(body);
|
||||
Assert.Equal(200, statusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -10,6 +10,7 @@ using allstarr.Services.Local;
|
||||
using allstarr.Services.Jellyfin;
|
||||
using allstarr.Services.Subsonic;
|
||||
using allstarr.Services.Lyrics;
|
||||
using allstarr.Filters;
|
||||
|
||||
namespace allstarr.Controllers;
|
||||
|
||||
@@ -22,6 +23,7 @@ namespace allstarr.Controllers;
|
||||
public class JellyfinController : ControllerBase
|
||||
{
|
||||
private readonly JellyfinSettings _settings;
|
||||
private readonly SpotifyImportSettings _spotifySettings;
|
||||
private readonly IMusicMetadataService _metadataService;
|
||||
private readonly ILocalLibraryService _localLibraryService;
|
||||
private readonly IDownloadService _downloadService;
|
||||
@@ -29,20 +31,24 @@ public class JellyfinController : ControllerBase
|
||||
private readonly JellyfinModelMapper _modelMapper;
|
||||
private readonly JellyfinProxyService _proxyService;
|
||||
private readonly PlaylistSyncService? _playlistSyncService;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly ILogger<JellyfinController> _logger;
|
||||
|
||||
public JellyfinController(
|
||||
IOptions<JellyfinSettings> settings,
|
||||
IOptions<SpotifyImportSettings> spotifySettings,
|
||||
IMusicMetadataService metadataService,
|
||||
ILocalLibraryService localLibraryService,
|
||||
IDownloadService downloadService,
|
||||
JellyfinResponseBuilder responseBuilder,
|
||||
JellyfinModelMapper modelMapper,
|
||||
JellyfinProxyService proxyService,
|
||||
RedisCacheService cache,
|
||||
ILogger<JellyfinController> logger,
|
||||
PlaylistSyncService? playlistSyncService = null)
|
||||
{
|
||||
_settings = settings.Value;
|
||||
_spotifySettings = spotifySettings.Value;
|
||||
_metadataService = metadataService;
|
||||
_localLibraryService = localLibraryService;
|
||||
_downloadService = downloadService;
|
||||
@@ -50,6 +56,7 @@ public class JellyfinController : ControllerBase
|
||||
_modelMapper = modelMapper;
|
||||
_proxyService = proxyService;
|
||||
_playlistSyncService = playlistSyncService;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_settings.Url))
|
||||
@@ -106,12 +113,25 @@ public class JellyfinController : ControllerBase
|
||||
endpoint = $"{endpoint}{Request.QueryString.Value}";
|
||||
}
|
||||
|
||||
var browseResult = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
|
||||
var (browseResult, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
|
||||
|
||||
if (browseResult == null)
|
||||
{
|
||||
_logger.LogInformation("Jellyfin returned null - likely 401 Unauthorized, returning 401 to client");
|
||||
return Unauthorized(new { error = "Authentication required" });
|
||||
if (statusCode == 401)
|
||||
{
|
||||
_logger.LogInformation("Jellyfin returned 401 Unauthorized, returning 401 to client");
|
||||
return Unauthorized(new { error = "Authentication required" });
|
||||
}
|
||||
|
||||
_logger.LogInformation("Jellyfin returned {StatusCode}, returning empty result", statusCode);
|
||||
return new JsonResult(new { Items = Array.Empty<object>(), TotalRecordCount = 0, StartIndex = startIndex });
|
||||
}
|
||||
|
||||
// Update Spotify playlist counts if enabled and response contains playlists
|
||||
if (_spotifySettings.Enabled && browseResult.RootElement.TryGetProperty("Items", out var _))
|
||||
{
|
||||
_logger.LogInformation("Browse result has Items, checking for Spotify playlists to update counts");
|
||||
browseResult = await UpdateSpotifyPlaylistCounts(browseResult);
|
||||
}
|
||||
|
||||
var result = JsonSerializer.Deserialize<object>(browseResult.RootElement.GetRawText());
|
||||
@@ -154,7 +174,7 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
await Task.WhenAll(jellyfinTask, externalTask, playlistTask);
|
||||
|
||||
var jellyfinResult = await jellyfinTask;
|
||||
var (jellyfinResult, _) = await jellyfinTask;
|
||||
var externalResult = await externalTask;
|
||||
var playlistResult = await playlistTask;
|
||||
|
||||
@@ -169,31 +189,28 @@ public class JellyfinController : ControllerBase
|
||||
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult);
|
||||
|
||||
// Score and filter Jellyfin results by relevance
|
||||
var scoredLocalSongs = ScoreSearchResults(cleanQuery, localSongs, s => s.Title, s => s.Artist, isExternal: false);
|
||||
var scoredLocalAlbums = ScoreSearchResults(cleanQuery, localAlbums, a => a.Title, a => a.Artist, isExternal: false);
|
||||
var scoredLocalArtists = ScoreSearchResults(cleanQuery, localArtists, a => a.Name, _ => null, isExternal: false);
|
||||
var scoredLocalSongs = ScoreSearchResults(cleanQuery, localSongs, s => s.Title, s => s.Artist, s => s.Album, isExternal: false);
|
||||
var scoredLocalAlbums = ScoreSearchResults(cleanQuery, localAlbums, a => a.Title, a => a.Artist, _ => null, isExternal: false);
|
||||
var scoredLocalArtists = ScoreSearchResults(cleanQuery, localArtists, a => a.Name, _ => null, _ => null, isExternal: false);
|
||||
|
||||
// Score external results with a small boost
|
||||
var scoredExternalSongs = ScoreSearchResults(cleanQuery, externalResult.Songs, s => s.Title, s => s.Artist, isExternal: true);
|
||||
var scoredExternalAlbums = ScoreSearchResults(cleanQuery, externalResult.Albums, a => a.Title, a => a.Artist, isExternal: true);
|
||||
var scoredExternalArtists = ScoreSearchResults(cleanQuery, externalResult.Artists, a => a.Name, _ => null, isExternal: true);
|
||||
var scoredExternalSongs = ScoreSearchResults(cleanQuery, externalResult.Songs, s => s.Title, s => s.Artist, s => s.Album, isExternal: true);
|
||||
var scoredExternalAlbums = ScoreSearchResults(cleanQuery, externalResult.Albums, a => a.Title, a => a.Artist, _ => null, isExternal: true);
|
||||
var scoredExternalArtists = ScoreSearchResults(cleanQuery, externalResult.Artists, a => a.Name, _ => null, _ => null, isExternal: true);
|
||||
|
||||
// Merge and sort by score (only include items with score >= 40)
|
||||
// Merge and sort by score (no filtering - just reorder by relevance)
|
||||
var allSongs = scoredLocalSongs.Concat(scoredExternalSongs)
|
||||
.Where(x => x.Score >= 40)
|
||||
.OrderByDescending(x => x.Score)
|
||||
.Select(x => x.Item)
|
||||
.ToList();
|
||||
|
||||
var allAlbums = scoredLocalAlbums.Concat(scoredExternalAlbums)
|
||||
.Where(x => x.Score >= 40)
|
||||
.OrderByDescending(x => x.Score)
|
||||
.Select(x => x.Item)
|
||||
.ToList();
|
||||
|
||||
// Dedupe artists by name, keeping highest scored version
|
||||
var artistScores = scoredLocalArtists.Concat(scoredExternalArtists)
|
||||
.Where(x => x.Score >= 40)
|
||||
.GroupBy(x => x.Item.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(g => g.OrderByDescending(x => x.Score).First())
|
||||
.OrderByDescending(x => x.Score)
|
||||
@@ -210,7 +227,6 @@ public class JellyfinController : ControllerBase
|
||||
{
|
||||
var scoredPlaylists = playlistResult
|
||||
.Select(p => new { Playlist = p, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, p.Name) })
|
||||
.Where(x => x.Score >= 40)
|
||||
.OrderByDescending(x => x.Score)
|
||||
.Select(x => _responseBuilder.ConvertPlaylistToJellyfinItem(x.Playlist))
|
||||
.ToList();
|
||||
@@ -305,7 +321,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
|
||||
// Proxy to Jellyfin for local content
|
||||
var result = await _proxyService.GetItemsAsync(
|
||||
var (result, statusCode) = await _proxyService.GetItemsAsync(
|
||||
parentId: parentId,
|
||||
includeItemTypes: ParseItemTypes(includeItemTypes),
|
||||
sortBy: sortBy,
|
||||
@@ -313,12 +329,7 @@ public class JellyfinController : ControllerBase
|
||||
startIndex: startIndex,
|
||||
clientHeaders: Request.Headers);
|
||||
|
||||
if (result == null)
|
||||
{
|
||||
return _responseBuilder.CreateError(404, "Parent not found");
|
||||
}
|
||||
|
||||
return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText()));
|
||||
return HandleProxyResponse(result, statusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -350,7 +361,7 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
await Task.WhenAll(jellyfinTask, externalTask);
|
||||
|
||||
var jellyfinResult = await jellyfinTask;
|
||||
var (jellyfinResult, _) = await jellyfinTask;
|
||||
var externalResult = await externalTask;
|
||||
|
||||
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult);
|
||||
@@ -406,13 +417,9 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
|
||||
// Proxy to Jellyfin
|
||||
var result = await _proxyService.GetItemAsync(itemId, Request.Headers);
|
||||
if (result == null)
|
||||
{
|
||||
return _responseBuilder.CreateError(404, "Item not found");
|
||||
}
|
||||
var (result, statusCode) = await _proxyService.GetItemAsync(itemId, Request.Headers);
|
||||
|
||||
return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText()));
|
||||
return HandleProxyResponse(result, statusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -524,7 +531,7 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
await Task.WhenAll(jellyfinTask, externalTask);
|
||||
|
||||
var jellyfinResult = await jellyfinTask;
|
||||
var (jellyfinResult, _) = await jellyfinTask;
|
||||
var externalArtists = await externalTask;
|
||||
|
||||
_logger.LogInformation("Artist search results: Jellyfin={JellyfinCount}, External={ExternalCount}",
|
||||
@@ -574,19 +581,14 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
|
||||
// No search term - just proxy to Jellyfin
|
||||
var result = await _proxyService.GetArtistsAsync(searchTerm, limit, startIndex, Request.Headers);
|
||||
var (result, statusCode) = await _proxyService.GetArtistsAsync(searchTerm, limit, startIndex, Request.Headers);
|
||||
|
||||
if (result == null)
|
||||
return HandleProxyResponse(result, statusCode, new
|
||||
{
|
||||
return new JsonResult(new Dictionary<string, object>
|
||||
{
|
||||
["Items"] = Array.Empty<object>(),
|
||||
["TotalRecordCount"] = 0,
|
||||
["StartIndex"] = startIndex
|
||||
});
|
||||
}
|
||||
|
||||
return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText()));
|
||||
Items = Array.Empty<object>(),
|
||||
TotalRecordCount = 0,
|
||||
StartIndex = startIndex
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -617,10 +619,10 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
|
||||
// Get local artist from Jellyfin
|
||||
var jellyfinArtist = await _proxyService.GetArtistAsync(artistIdOrName, Request.Headers);
|
||||
var (jellyfinArtist, statusCode) = await _proxyService.GetArtistAsync(artistIdOrName, Request.Headers);
|
||||
if (jellyfinArtist == null)
|
||||
{
|
||||
return _responseBuilder.CreateError(404, "Artist not found");
|
||||
return HandleProxyResponse(null, statusCode);
|
||||
}
|
||||
|
||||
var artistData = _modelMapper.ParseArtist(jellyfinArtist.RootElement);
|
||||
@@ -628,7 +630,7 @@ public class JellyfinController : ControllerBase
|
||||
var localArtistId = artistData.Id;
|
||||
|
||||
// Get local albums
|
||||
var localAlbumsResult = await _proxyService.GetItemsAsync(
|
||||
var (localAlbumsResult, _) = await _proxyService.GetItemsAsync(
|
||||
parentId: null,
|
||||
includeItemTypes: new[] { "MusicAlbum" },
|
||||
sortBy: "SortName",
|
||||
@@ -778,6 +780,23 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
var contentType = response.Content.Headers.ContentType?.ToString() ?? "audio/mpeg";
|
||||
|
||||
// Forward caching headers for client-side caching
|
||||
if (response.Headers.ETag != null)
|
||||
{
|
||||
Response.Headers["ETag"] = response.Headers.ETag.ToString();
|
||||
}
|
||||
|
||||
if (response.Content.Headers.LastModified.HasValue)
|
||||
{
|
||||
Response.Headers["Last-Modified"] = response.Content.Headers.LastModified.Value.ToString("R");
|
||||
}
|
||||
|
||||
if (response.Headers.CacheControl != null)
|
||||
{
|
||||
Response.Headers["Cache-Control"] = response.Headers.CacheControl.ToString();
|
||||
}
|
||||
|
||||
// Forward range headers for seeking
|
||||
if (response.Content.Headers.ContentRange != null)
|
||||
{
|
||||
Response.Headers["Content-Range"] = response.Content.Headers.ContentRange.ToString();
|
||||
@@ -956,6 +975,24 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
|
||||
|
||||
// For local tracks, check if Jellyfin already has embedded lyrics
|
||||
if (!isExternal)
|
||||
{
|
||||
_logger.LogInformation("Checking Jellyfin for embedded lyrics for local track: {ItemId}", itemId);
|
||||
|
||||
// Try to get lyrics from Jellyfin first (it reads embedded lyrics from files)
|
||||
var (jellyfinLyrics, statusCode) = await _proxyService.GetJsonAsync($"Audio/{itemId}/Lyrics", null, Request.Headers);
|
||||
|
||||
if (jellyfinLyrics != null && statusCode == 200)
|
||||
{
|
||||
_logger.LogInformation("✓ Found embedded lyrics in Jellyfin for track {ItemId}", itemId);
|
||||
return new JsonResult(JsonSerializer.Deserialize<object>(jellyfinLyrics.RootElement.GetRawText()));
|
||||
}
|
||||
|
||||
_logger.LogInformation("No embedded lyrics found in Jellyfin, falling back to LRCLIB search");
|
||||
}
|
||||
|
||||
// For external tracks or when Jellyfin doesn't have lyrics, search LRCLIB
|
||||
Song? song = null;
|
||||
|
||||
if (isExternal)
|
||||
@@ -965,7 +1002,7 @@ public class JellyfinController : ControllerBase
|
||||
else
|
||||
{
|
||||
// For local songs, get metadata from Jellyfin
|
||||
var item = await _proxyService.GetItemAsync(itemId, Request.Headers);
|
||||
var (item, _) = await _proxyService.GetItemAsync(itemId, Request.Headers);
|
||||
if (item != null && item.RootElement.TryGetProperty("Type", out var typeEl) &&
|
||||
typeEl.GetString() == "Audio")
|
||||
{
|
||||
@@ -985,6 +1022,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
|
||||
// Try to get lyrics from LRCLIB
|
||||
_logger.LogInformation("Searching LRCLIB for lyrics: {Artist} - {Title}", song.Artist, song.Title);
|
||||
var lyricsService = HttpContext.RequestServices.GetService<LrclibService>();
|
||||
if (lyricsService == null)
|
||||
{
|
||||
@@ -1006,15 +1044,21 @@ public class JellyfinController : ControllerBase
|
||||
var lyricsText = lyrics.SyncedLyrics ?? lyrics.PlainLyrics ?? "";
|
||||
var isSynced = !string.IsNullOrEmpty(lyrics.SyncedLyrics);
|
||||
|
||||
_logger.LogInformation("Lyrics for {Artist} - {Track}: synced={HasSynced}, plainLength={PlainLen}, syncedLength={SyncLen}",
|
||||
song.Artist, song.Title, isSynced, lyrics.PlainLyrics?.Length ?? 0, lyrics.SyncedLyrics?.Length ?? 0);
|
||||
|
||||
// Parse LRC format into individual lines for Jellyfin
|
||||
var lyricLines = new List<object>();
|
||||
var lyricLines = new List<Dictionary<string, object>>();
|
||||
|
||||
if (isSynced && !string.IsNullOrEmpty(lyrics.SyncedLyrics))
|
||||
{
|
||||
_logger.LogInformation("Parsing synced lyrics (LRC format)");
|
||||
// Parse LRC format: [mm:ss.xx] text
|
||||
// Skip ID tags like [ar:Artist], [ti:Title], etc.
|
||||
var lines = lyrics.SyncedLyrics.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (var line in lines)
|
||||
{
|
||||
// Match timestamp format [mm:ss.xx] or [mm:ss.xxx]
|
||||
var match = System.Text.RegularExpressions.Regex.Match(line, @"^\[(\d+):(\d+)\.(\d+)\]\s*(.*)$");
|
||||
if (match.Success)
|
||||
{
|
||||
@@ -1027,21 +1071,40 @@ public class JellyfinController : ControllerBase
|
||||
var totalMilliseconds = (minutes * 60 + seconds) * 1000 + centiseconds * 10;
|
||||
var ticks = totalMilliseconds * 10000L;
|
||||
|
||||
lyricLines.Add(new
|
||||
// For synced lyrics, include Start timestamp
|
||||
lyricLines.Add(new Dictionary<string, object>
|
||||
{
|
||||
Start = ticks,
|
||||
Text = text
|
||||
["Text"] = text,
|
||||
["Start"] = ticks
|
||||
});
|
||||
}
|
||||
// Skip ID tags like [ar:Artist], [ti:Title], [length:2:23], etc.
|
||||
}
|
||||
_logger.LogInformation("Parsed {Count} synced lyric lines (skipped ID tags)", lyricLines.Count);
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(lyricsText))
|
||||
{
|
||||
_logger.LogInformation("Splitting plain lyrics into lines (no timestamps)");
|
||||
// Plain lyrics - split by newlines and return each line separately
|
||||
// IMPORTANT: Do NOT include "Start" field at all for unsynced lyrics
|
||||
// Including it (even as null) causes clients to treat it as synced with timestamp 0:00
|
||||
var lines = lyricsText.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (var line in lines)
|
||||
{
|
||||
lyricLines.Add(new Dictionary<string, object>
|
||||
{
|
||||
["Text"] = line.Trim()
|
||||
});
|
||||
}
|
||||
_logger.LogInformation("Split into {Count} plain lyric lines", lyricLines.Count);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Plain lyrics - return as single block
|
||||
lyricLines.Add(new
|
||||
_logger.LogWarning("No lyrics text available");
|
||||
// No lyrics at all
|
||||
lyricLines.Add(new Dictionary<string, object>
|
||||
{
|
||||
Start = (long?)null,
|
||||
Text = lyricsText
|
||||
["Text"] = ""
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1058,6 +1121,17 @@ public class JellyfinController : ControllerBase
|
||||
Lyrics = lyricLines
|
||||
};
|
||||
|
||||
_logger.LogInformation("Returning lyrics response: {LineCount} lines, synced={IsSynced}", lyricLines.Count, isSynced);
|
||||
|
||||
// Log a sample of the response for debugging
|
||||
if (lyricLines.Count > 0)
|
||||
{
|
||||
var sampleLine = lyricLines[0];
|
||||
var hasStart = sampleLine.ContainsKey("Start");
|
||||
_logger.LogInformation("Sample line: Text='{Text}', HasStart={HasStart}",
|
||||
sampleLine.GetValueOrDefault("Text"), hasStart);
|
||||
}
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
@@ -1067,10 +1141,21 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
/// <summary>
|
||||
/// Marks an item as favorite. For playlists, triggers a full download.
|
||||
/// Supports both /Users/{userId}/FavoriteItems/{itemId} and /UserFavoriteItems/{itemId}?userId=xxx
|
||||
/// </summary>
|
||||
[HttpPost("Users/{userId}/FavoriteItems/{itemId}")]
|
||||
public async Task<IActionResult> MarkFavorite(string userId, string itemId)
|
||||
[HttpPost("UserFavoriteItems/{itemId}")]
|
||||
public async Task<IActionResult> MarkFavorite(string itemId, string? userId = null)
|
||||
{
|
||||
// Get userId from query string if not in path
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
{
|
||||
userId = Request.Query["userId"].ToString();
|
||||
}
|
||||
|
||||
_logger.LogInformation("MarkFavorite called: userId={UserId}, itemId={ItemId}, route={Route}",
|
||||
userId, itemId, Request.Path);
|
||||
|
||||
// Check if this is an external playlist - trigger download
|
||||
if (PlaylistIdHelper.IsExternalPlaylist(itemId))
|
||||
{
|
||||
@@ -1094,97 +1179,102 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
});
|
||||
|
||||
return Ok(new { IsFavorite = true });
|
||||
// Return a minimal UserItemDataDto response
|
||||
return Ok(new
|
||||
{
|
||||
IsFavorite = true,
|
||||
ItemId = itemId
|
||||
});
|
||||
}
|
||||
|
||||
// Check if this is an external song/album
|
||||
var (isExternal, _, _) = _localLibraryService.ParseSongId(itemId);
|
||||
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
|
||||
if (isExternal)
|
||||
{
|
||||
// External items don't exist in Jellyfin, so we can't favorite them there
|
||||
// Just return success - the client will show it as favorited
|
||||
_logger.LogDebug("Favoriting external item {ItemId} (not synced to Jellyfin)", itemId);
|
||||
return Ok(new { IsFavorite = true });
|
||||
_logger.LogInformation("Favoriting external item {ItemId}, copying to kept folder", itemId);
|
||||
|
||||
// Copy the track to kept folder in background
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await CopyExternalTrackToKeptAsync(itemId, provider!, externalId!);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to copy external track {ItemId} to kept folder", itemId);
|
||||
}
|
||||
});
|
||||
|
||||
// Return a minimal UserItemDataDto response
|
||||
return Ok(new
|
||||
{
|
||||
IsFavorite = true,
|
||||
ItemId = itemId
|
||||
});
|
||||
}
|
||||
|
||||
// For local Jellyfin items, proxy the request through
|
||||
var endpoint = $"Users/{userId}/FavoriteItems/{itemId}";
|
||||
|
||||
try
|
||||
// Use the official Jellyfin endpoint format
|
||||
var endpoint = $"UserFavoriteItems/{itemId}";
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, $"{_settings.Url?.TrimEnd('/')}/{endpoint}");
|
||||
|
||||
// Forward client authentication
|
||||
if (Request.Headers.TryGetValue("X-Emby-Authorization", out var embyAuth))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", embyAuth.ToString());
|
||||
}
|
||||
else if (Request.Headers.TryGetValue("Authorization", out var auth))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("Authorization", auth.ToString());
|
||||
}
|
||||
|
||||
var response = await _proxyService.HttpClient.SendAsync(request);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return Ok(new { IsFavorite = true });
|
||||
}
|
||||
|
||||
_logger.LogWarning("Failed to favorite item in Jellyfin: {StatusCode}", response.StatusCode);
|
||||
return _responseBuilder.CreateError((int)response.StatusCode, "Failed to mark favorite");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error favoriting item {ItemId}", itemId);
|
||||
return _responseBuilder.CreateError(500, "Failed to mark favorite");
|
||||
endpoint = $"{endpoint}?userId={userId}";
|
||||
}
|
||||
|
||||
_logger.LogInformation("Proxying favorite request to Jellyfin: {Endpoint}", endpoint);
|
||||
|
||||
var (result, statusCode) = await _proxyService.PostJsonAsync(endpoint, "{}", Request.Headers);
|
||||
|
||||
return HandleProxyResponse(result, statusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes an item from favorites.
|
||||
/// Supports both /Users/{userId}/FavoriteItems/{itemId} and /UserFavoriteItems/{itemId}?userId=xxx
|
||||
/// </summary>
|
||||
[HttpDelete("Users/{userId}/FavoriteItems/{itemId}")]
|
||||
public async Task<IActionResult> UnmarkFavorite(string userId, string itemId)
|
||||
[HttpDelete("UserFavoriteItems/{itemId}")]
|
||||
public async Task<IActionResult> UnmarkFavorite(string itemId, string? userId = null)
|
||||
{
|
||||
// External items can't be unfavorited
|
||||
// Get userId from query string if not in path
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
{
|
||||
userId = Request.Query["userId"].ToString();
|
||||
}
|
||||
|
||||
_logger.LogInformation("UnmarkFavorite called: userId={UserId}, itemId={ItemId}, route={Route}",
|
||||
userId, itemId, Request.Path);
|
||||
|
||||
// External items can't be unfavorited (they're not really favorited in Jellyfin)
|
||||
var (isExternal, _, _) = _localLibraryService.ParseSongId(itemId);
|
||||
if (isExternal || PlaylistIdHelper.IsExternalPlaylist(itemId))
|
||||
{
|
||||
return Ok(new { IsFavorite = false });
|
||||
_logger.LogInformation("Unfavoriting external item {ItemId} - returning success", itemId);
|
||||
return Ok(new
|
||||
{
|
||||
IsFavorite = false,
|
||||
ItemId = itemId
|
||||
});
|
||||
}
|
||||
|
||||
// Proxy to Jellyfin to unfavorite
|
||||
var url = $"Users/{userId}/FavoriteItems/{itemId}";
|
||||
|
||||
try
|
||||
// Use the official Jellyfin endpoint format
|
||||
var endpoint = $"UserFavoriteItems/{itemId}";
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Delete, $"{_settings.Url?.TrimEnd('/')}/{url}");
|
||||
|
||||
// Forward client authentication
|
||||
if (Request.Headers.TryGetValue("X-Emby-Authorization", out var embyAuth))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", embyAuth.ToString());
|
||||
}
|
||||
else if (Request.Headers.TryGetValue("Authorization", out var auth))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("Authorization", auth.ToString());
|
||||
}
|
||||
|
||||
var response = await _proxyService.HttpClient.SendAsync(request);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return Ok(new { IsFavorite = false });
|
||||
}
|
||||
|
||||
return _responseBuilder.CreateError(500, "Failed to unfavorite item");
|
||||
endpoint = $"{endpoint}?userId={userId}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
_logger.LogInformation("Proxying unfavorite request to Jellyfin: {Endpoint}", endpoint);
|
||||
|
||||
var (result, statusCode) = await _proxyService.DeleteAsync(endpoint, Request.Headers);
|
||||
|
||||
return HandleProxyResponse(result, statusCode, new
|
||||
{
|
||||
_logger.LogError(ex, "Error unfavoriting item {ItemId}", itemId);
|
||||
return _responseBuilder.CreateError(500, "Failed to unfavorite item");
|
||||
}
|
||||
IsFavorite = false,
|
||||
ItemId = itemId
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -1238,10 +1328,51 @@ public class JellyfinController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(playlistId);
|
||||
var tracks = await _metadataService.GetPlaylistTracksAsync(provider, externalId);
|
||||
_logger.LogInformation("=== GetPlaylistTracks called === PlaylistId: {PlaylistId}", playlistId);
|
||||
|
||||
return _responseBuilder.CreateItemsResponse(tracks);
|
||||
// Check if this is an external playlist (Deezer/Qobuz) first
|
||||
if (PlaylistIdHelper.IsExternalPlaylist(playlistId))
|
||||
{
|
||||
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(playlistId);
|
||||
var tracks = await _metadataService.GetPlaylistTracksAsync(provider, externalId);
|
||||
return _responseBuilder.CreateItemsResponse(tracks);
|
||||
}
|
||||
|
||||
// Check if this is a Spotify playlist (by ID)
|
||||
_logger.LogInformation("Spotify Import Enabled: {Enabled}, Configured IDs: {Count}",
|
||||
_spotifySettings.Enabled, _spotifySettings.PlaylistIds.Count);
|
||||
|
||||
if (_spotifySettings.Enabled &&
|
||||
_spotifySettings.PlaylistIds.Any(id => id.Equals(playlistId, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
// Get playlist info from Jellyfin to get the name for matching missing tracks
|
||||
_logger.LogInformation("Fetching playlist info from Jellyfin for ID: {PlaylistId}", playlistId);
|
||||
var (playlistInfo, _) = await _proxyService.GetJsonAsync($"Items/{playlistId}", null, Request.Headers);
|
||||
|
||||
if (playlistInfo != null && playlistInfo.RootElement.TryGetProperty("Name", out var nameElement))
|
||||
{
|
||||
var playlistName = nameElement.GetString() ?? "";
|
||||
_logger.LogInformation("✓ MATCHED! Intercepting Spotify playlist: {PlaylistName} (ID: {PlaylistId})",
|
||||
playlistName, playlistId);
|
||||
return await GetSpotifyPlaylistTracksAsync(playlistName, playlistId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Could not get playlist name from Jellyfin for ID: {PlaylistId}", playlistId);
|
||||
}
|
||||
}
|
||||
|
||||
// Regular Jellyfin playlist - proxy through
|
||||
var endpoint = $"Playlists/{playlistId}/Items";
|
||||
if (Request.QueryString.HasValue)
|
||||
{
|
||||
endpoint = $"{endpoint}{Request.QueryString.Value}";
|
||||
}
|
||||
|
||||
_logger.LogInformation("Proxying to Jellyfin: {Endpoint}", endpoint);
|
||||
var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
|
||||
|
||||
return HandleProxyResponse(result, statusCode);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1309,15 +1440,51 @@ public class JellyfinController : ControllerBase
|
||||
// DO NOT log request body or detailed headers - contains password
|
||||
|
||||
// Forward to Jellyfin server with client headers
|
||||
var result = await _proxyService.PostJsonAsync("Users/AuthenticateByName", body, Request.Headers);
|
||||
var (result, statusCode) = await _proxyService.PostJsonAsync("Users/AuthenticateByName", body, Request.Headers);
|
||||
|
||||
if (result == null)
|
||||
{
|
||||
_logger.LogWarning("Authentication failed - no response from Jellyfin");
|
||||
return Unauthorized(new { error = "Authentication failed" });
|
||||
_logger.LogWarning("Authentication failed - status {StatusCode}", statusCode);
|
||||
if (statusCode == 401)
|
||||
{
|
||||
return Unauthorized(new { error = "Invalid username or password" });
|
||||
}
|
||||
return StatusCode(statusCode, new { error = "Authentication failed" });
|
||||
}
|
||||
|
||||
_logger.LogInformation("Authentication successful");
|
||||
|
||||
// Post session capabilities immediately after authentication
|
||||
// This ensures Jellyfin creates a session that will show up in the dashboard
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("🔧 Posting session capabilities after authentication");
|
||||
var capabilities = new
|
||||
{
|
||||
PlayableMediaTypes = new[] { "Audio" },
|
||||
SupportedCommands = Array.Empty<string>(),
|
||||
SupportsMediaControl = false,
|
||||
SupportsPersistentIdentifier = true,
|
||||
SupportsSync = false
|
||||
};
|
||||
|
||||
var capabilitiesJson = JsonSerializer.Serialize(capabilities);
|
||||
var (capResult, capStatus) = await _proxyService.PostJsonAsync("Sessions/Capabilities/Full", capabilitiesJson, Request.Headers);
|
||||
|
||||
if (capStatus == 204 || capStatus == 200)
|
||||
{
|
||||
_logger.LogInformation("✓ Session capabilities posted after auth ({StatusCode})", capStatus);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("⚠ Session capabilities returned {StatusCode} after auth", capStatus);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to post session capabilities after auth, continuing anyway");
|
||||
}
|
||||
|
||||
return Content(result.RootElement.GetRawText(), "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -1421,18 +1588,9 @@ public class JellyfinController : ControllerBase
|
||||
queryParams["userId"] = userId;
|
||||
}
|
||||
|
||||
var result = await _proxyService.GetJsonAsync(endpoint, queryParams, Request.Headers);
|
||||
var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, queryParams, Request.Headers);
|
||||
|
||||
if (result == null)
|
||||
{
|
||||
return _responseBuilder.CreateJsonResponse(new
|
||||
{
|
||||
Items = Array.Empty<object>(),
|
||||
TotalRecordCount = 0
|
||||
});
|
||||
}
|
||||
|
||||
return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText()));
|
||||
return HandleProxyResponse(result, statusCode, new { Items = Array.Empty<object>(), TotalRecordCount = 0 });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1535,22 +1693,492 @@ public class JellyfinController : ControllerBase
|
||||
queryParams["userId"] = userId;
|
||||
}
|
||||
|
||||
var result = await _proxyService.GetJsonAsync($"Songs/{itemId}/InstantMix", queryParams, Request.Headers);
|
||||
var (result, statusCode) = await _proxyService.GetJsonAsync($"Songs/{itemId}/InstantMix", queryParams, Request.Headers);
|
||||
|
||||
if (result == null)
|
||||
{
|
||||
return _responseBuilder.CreateJsonResponse(new
|
||||
{
|
||||
Items = Array.Empty<object>(),
|
||||
TotalRecordCount = 0
|
||||
});
|
||||
}
|
||||
|
||||
return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText()));
|
||||
return HandleProxyResponse(result, statusCode, new { Items = Array.Empty<object>(), TotalRecordCount = 0 });
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Playback Session Reporting
|
||||
|
||||
#region Session Management
|
||||
|
||||
/// <summary>
|
||||
/// Reports session capabilities. Required for Jellyfin to track active sessions.
|
||||
/// Handles both POST (with body) and GET (query params only) methods.
|
||||
/// </summary>
|
||||
[HttpPost("Sessions/Capabilities")]
|
||||
[HttpPost("Sessions/Capabilities/Full")]
|
||||
[HttpGet("Sessions/Capabilities")]
|
||||
[HttpGet("Sessions/Capabilities/Full")]
|
||||
public async Task<IActionResult> ReportCapabilities()
|
||||
{
|
||||
try
|
||||
{
|
||||
var method = Request.Method;
|
||||
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : "";
|
||||
|
||||
_logger.LogInformation("📡 Session capabilities reported - Method: {Method}, Query: {Query}", method, queryString);
|
||||
_logger.LogInformation("Headers: {Headers}",
|
||||
string.Join(", ", Request.Headers.Where(h => h.Key.Contains("Auth", StringComparison.OrdinalIgnoreCase) || h.Key.Contains("Device", StringComparison.OrdinalIgnoreCase) || h.Key.Contains("Client", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(h => $"{h.Key}={h.Value}")));
|
||||
|
||||
// Forward to Jellyfin with query string and headers
|
||||
var endpoint = $"Sessions/Capabilities{queryString}";
|
||||
|
||||
// Read body if present (POST requests)
|
||||
string body = "{}";
|
||||
if (method == "POST" && Request.ContentLength > 0)
|
||||
{
|
||||
Request.EnableBuffering();
|
||||
using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true))
|
||||
{
|
||||
body = await reader.ReadToEndAsync();
|
||||
}
|
||||
Request.Body.Position = 0;
|
||||
_logger.LogInformation("Capabilities body: {Body}", body);
|
||||
}
|
||||
|
||||
var (result, statusCode) = await _proxyService.PostJsonAsync(endpoint, body, Request.Headers);
|
||||
|
||||
if (statusCode == 204 || statusCode == 200)
|
||||
{
|
||||
_logger.LogInformation("✓ Session capabilities forwarded to Jellyfin ({StatusCode})", statusCode);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("⚠ Jellyfin returned {StatusCode} for capabilities", statusCode);
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to report session capabilities");
|
||||
return StatusCode(500);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports playback start. Handles both local and external tracks.
|
||||
/// For local tracks, forwards to Jellyfin. For external tracks, logs locally.
|
||||
/// Also ensures session is initialized if this is the first report from a device.
|
||||
/// </summary>
|
||||
[HttpPost("Sessions/Playing")]
|
||||
public async Task<IActionResult> ReportPlaybackStart()
|
||||
{
|
||||
try
|
||||
{
|
||||
Request.EnableBuffering();
|
||||
string body;
|
||||
using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true))
|
||||
{
|
||||
body = await reader.ReadToEndAsync();
|
||||
}
|
||||
Request.Body.Position = 0;
|
||||
|
||||
_logger.LogInformation("📻 Playback START reported - Body: {Body}", body);
|
||||
_logger.LogInformation("Auth headers: {Headers}",
|
||||
string.Join(", ", Request.Headers.Where(h => h.Key.Contains("Auth", StringComparison.OrdinalIgnoreCase)).Select(h => $"{h.Key}={h.Value}")));
|
||||
|
||||
// Extract device info from auth headers for session initialization
|
||||
string? deviceId = null;
|
||||
string? client = null;
|
||||
string? device = null;
|
||||
string? version = null;
|
||||
|
||||
if (Request.Headers.TryGetValue("Authorization", out var authHeader))
|
||||
{
|
||||
var authStr = authHeader.ToString();
|
||||
// Parse: MediaBrowser Client="...", Device="...", DeviceId="...", Version="..."
|
||||
var parts = authStr.Replace("MediaBrowser ", "").Split(',');
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var kv = part.Trim().Split('=');
|
||||
if (kv.Length == 2)
|
||||
{
|
||||
var key = kv[0].Trim();
|
||||
var value = kv[1].Trim('"');
|
||||
if (key == "DeviceId") deviceId = value;
|
||||
else if (key == "Client") client = value;
|
||||
else if (key == "Device") device = value;
|
||||
else if (key == "Version") version = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure session capabilities are posted to Jellyfin (if not already done)
|
||||
// Jellyfin automatically creates a session when the client authenticates, but we need to
|
||||
// post capabilities so the session shows up in the dashboard with proper device info
|
||||
if (!string.IsNullOrEmpty(deviceId))
|
||||
{
|
||||
_logger.LogInformation("🔧 Ensuring session exists for device: {DeviceId} ({Client} {Version})", deviceId, client, version);
|
||||
|
||||
// First, check if a session exists for this device
|
||||
try
|
||||
{
|
||||
var (sessionsResult, sessionsStatus) = await _proxyService.GetJsonAsync($"Sessions?deviceId={deviceId}", null, Request.Headers);
|
||||
if (sessionsResult != null && sessionsStatus == 200)
|
||||
{
|
||||
var sessions = sessionsResult.RootElement;
|
||||
_logger.LogInformation("📊 Jellyfin sessions for device {DeviceId}: {Sessions}", deviceId, sessions.GetRawText());
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("⚠ Could not query sessions ({StatusCode})", sessionsStatus);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to query sessions");
|
||||
}
|
||||
|
||||
// Post capabilities - Jellyfin will match this to the authenticated session by device ID
|
||||
// The query parameters tell Jellyfin what this device can do
|
||||
var capabilitiesEndpoint = $"Sessions/Capabilities/Full";
|
||||
try
|
||||
{
|
||||
// Send full capabilities as JSON body (more reliable than query params)
|
||||
var capabilities = new
|
||||
{
|
||||
PlayableMediaTypes = new[] { "Audio" },
|
||||
SupportedCommands = Array.Empty<string>(),
|
||||
SupportsMediaControl = false,
|
||||
SupportsPersistentIdentifier = true,
|
||||
SupportsSync = false,
|
||||
DeviceProfile = (object?)null // Let Jellyfin use defaults
|
||||
};
|
||||
|
||||
var capabilitiesJson = JsonSerializer.Serialize(capabilities);
|
||||
_logger.LogInformation("📤 Posting capabilities: {Json}", capabilitiesJson);
|
||||
var (capResult, capStatus) = await _proxyService.PostJsonAsync(capabilitiesEndpoint, capabilitiesJson, Request.Headers);
|
||||
_logger.LogInformation("✓ Session capabilities posted ({StatusCode})", capStatus);
|
||||
|
||||
// Check sessions again after posting capabilities
|
||||
var (sessionsResult2, sessionsStatus2) = await _proxyService.GetJsonAsync($"Sessions?deviceId={deviceId}", null, Request.Headers);
|
||||
if (sessionsResult2 != null && sessionsStatus2 == 200)
|
||||
{
|
||||
var sessions2 = sessionsResult2.RootElement;
|
||||
_logger.LogInformation("📊 Jellyfin sessions AFTER capabilities: {Sessions}", sessions2.GetRawText());
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to post session capabilities, continuing anyway");
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the body to check if it's an external track
|
||||
var doc = JsonDocument.Parse(body);
|
||||
string? itemId = null;
|
||||
string? itemName = null;
|
||||
|
||||
if (doc.RootElement.TryGetProperty("ItemId", out var itemIdProp))
|
||||
{
|
||||
itemId = itemIdProp.GetString();
|
||||
}
|
||||
|
||||
if (doc.RootElement.TryGetProperty("ItemName", out var itemNameProp))
|
||||
{
|
||||
itemName = itemNameProp.GetString();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(itemId))
|
||||
{
|
||||
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
|
||||
|
||||
if (isExternal)
|
||||
{
|
||||
_logger.LogInformation("🎵 External track playback started: {Name} ({Provider}/{ExternalId})",
|
||||
itemName ?? "Unknown", provider, externalId);
|
||||
// For external tracks, we can't report to Jellyfin since it doesn't know about them
|
||||
// Just return success so the client is happy
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
_logger.LogInformation("🎵 Local track playback started: {Name} (ID: {ItemId})",
|
||||
itemName ?? "Unknown", itemId);
|
||||
}
|
||||
|
||||
// For local tracks, forward to Jellyfin with client auth
|
||||
_logger.LogInformation("Forwarding playback start to Jellyfin...");
|
||||
|
||||
// Fetch full item details to include in playback report
|
||||
// This makes the session show up properly in Jellyfin dashboard with "Now Playing"
|
||||
try
|
||||
{
|
||||
var (itemResult, itemStatus) = await _proxyService.GetJsonAsync($"Items/{itemId}", null, Request.Headers);
|
||||
if (itemResult != null && itemStatus == 200)
|
||||
{
|
||||
var item = itemResult.RootElement;
|
||||
_logger.LogInformation("📦 Fetched item details for playback report");
|
||||
|
||||
// Build enhanced playback start info with full item details
|
||||
var enhancedBody = new
|
||||
{
|
||||
ItemId = itemId,
|
||||
PositionTicks = doc.RootElement.TryGetProperty("PositionTicks", out var posProp) ? posProp.GetInt64() : 0,
|
||||
// Include the full item so Jellyfin can display "Now Playing"
|
||||
NowPlayingItem = item.Clone()
|
||||
};
|
||||
|
||||
var enhancedJson = JsonSerializer.Serialize(enhancedBody);
|
||||
_logger.LogInformation("📤 Sending enhanced playback start with item details");
|
||||
|
||||
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", enhancedJson, Request.Headers);
|
||||
|
||||
if (statusCode == 204 || statusCode == 200)
|
||||
{
|
||||
_logger.LogInformation("✓ Enhanced playback start forwarded to Jellyfin ({StatusCode})", statusCode);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("⚠️ Enhanced playback start returned status {StatusCode}", statusCode);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("⚠️ Could not fetch item details ({StatusCode}), sending basic playback start", itemStatus);
|
||||
// Fall back to basic playback start
|
||||
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", body, Request.Headers);
|
||||
if (statusCode == 204 || statusCode == 200)
|
||||
{
|
||||
_logger.LogInformation("✓ Basic playback start forwarded to Jellyfin ({StatusCode})", statusCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to send enhanced playback start, trying basic");
|
||||
// Fall back to basic playback start
|
||||
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", body, Request.Headers);
|
||||
if (statusCode == 204 || statusCode == 200)
|
||||
{
|
||||
_logger.LogInformation("✓ Basic playback start forwarded to Jellyfin ({StatusCode})", statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to report playback start");
|
||||
return NoContent(); // Return success anyway to not break playback
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports playback progress. Handles both local and external tracks.
|
||||
/// </summary>
|
||||
[HttpPost("Sessions/Playing/Progress")]
|
||||
public async Task<IActionResult> ReportPlaybackProgress()
|
||||
{
|
||||
try
|
||||
{
|
||||
Request.EnableBuffering();
|
||||
string body;
|
||||
using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true))
|
||||
{
|
||||
body = await reader.ReadToEndAsync();
|
||||
}
|
||||
Request.Body.Position = 0;
|
||||
|
||||
// Parse the body to check if it's an external track
|
||||
var doc = JsonDocument.Parse(body);
|
||||
if (doc.RootElement.TryGetProperty("ItemId", out var itemIdProp))
|
||||
{
|
||||
var itemId = itemIdProp.GetString();
|
||||
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId ?? "");
|
||||
|
||||
if (isExternal)
|
||||
{
|
||||
// For external tracks, just acknowledge
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
// For local tracks, forward to Jellyfin
|
||||
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing/Progress", body, Request.Headers);
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to report playback progress");
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports playback stopped. Handles both local and external tracks.
|
||||
/// </summary>
|
||||
[HttpPost("Sessions/Playing/Stopped")]
|
||||
public async Task<IActionResult> ReportPlaybackStopped()
|
||||
{
|
||||
try
|
||||
{
|
||||
Request.EnableBuffering();
|
||||
string body;
|
||||
using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true))
|
||||
{
|
||||
body = await reader.ReadToEndAsync();
|
||||
}
|
||||
Request.Body.Position = 0;
|
||||
|
||||
_logger.LogInformation("⏹️ Playback STOPPED reported");
|
||||
|
||||
// Parse the body to check if it's an external track
|
||||
var doc = JsonDocument.Parse(body);
|
||||
string? itemId = null;
|
||||
string? itemName = null;
|
||||
long? positionTicks = null;
|
||||
|
||||
if (doc.RootElement.TryGetProperty("ItemId", out var itemIdProp))
|
||||
{
|
||||
itemId = itemIdProp.GetString();
|
||||
}
|
||||
|
||||
if (doc.RootElement.TryGetProperty("ItemName", out var itemNameProp))
|
||||
{
|
||||
itemName = itemNameProp.GetString();
|
||||
}
|
||||
|
||||
if (doc.RootElement.TryGetProperty("PositionTicks", out var posProp))
|
||||
{
|
||||
positionTicks = posProp.GetInt64();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(itemId))
|
||||
{
|
||||
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
|
||||
|
||||
if (isExternal)
|
||||
{
|
||||
var position = positionTicks.HasValue
|
||||
? TimeSpan.FromTicks(positionTicks.Value).ToString(@"mm\:ss")
|
||||
: "unknown";
|
||||
_logger.LogInformation("🎵 External track playback stopped: {Name} at {Position} ({Provider}/{ExternalId})",
|
||||
itemName ?? "Unknown", position, provider, externalId);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
_logger.LogInformation("🎵 Local track playback stopped: {Name} (ID: {ItemId})",
|
||||
itemName ?? "Unknown", itemId);
|
||||
}
|
||||
|
||||
// For local tracks, forward to Jellyfin
|
||||
_logger.LogInformation("Forwarding playback stop to Jellyfin...");
|
||||
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", body, Request.Headers);
|
||||
|
||||
if (statusCode == 204 || statusCode == 200)
|
||||
{
|
||||
_logger.LogInformation("✓ Playback stop forwarded to Jellyfin ({StatusCode})", statusCode);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Playback stop forward failed with status {StatusCode}", statusCode);
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to report playback stopped");
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pings a playback session to keep it alive.
|
||||
/// </summary>
|
||||
[HttpPost("Sessions/Playing/Ping")]
|
||||
public async Task<IActionResult> PingPlaybackSession([FromQuery] string playSessionId)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Playback session ping: {SessionId}", playSessionId);
|
||||
|
||||
// Forward to Jellyfin
|
||||
var endpoint = $"Sessions/Playing/Ping?playSessionId={Uri.EscapeDataString(playSessionId)}";
|
||||
var (result, statusCode) = await _proxyService.PostJsonAsync(endpoint, "{}", Request.Headers);
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to ping playback session");
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Catch-all for any other session-related requests.
|
||||
/// <summary>
|
||||
/// Catch-all proxy for any other session-related endpoints we haven't explicitly implemented.
|
||||
/// This ensures all session management calls get proxied to Jellyfin.
|
||||
/// Examples: GET /Sessions, POST /Sessions/Logout, etc.
|
||||
/// </summary>
|
||||
[HttpGet("Sessions")]
|
||||
[HttpPost("Sessions")]
|
||||
[HttpGet("Sessions/{**path}")]
|
||||
[HttpPost("Sessions/{**path}")]
|
||||
[HttpPut("Sessions/{**path}")]
|
||||
[HttpDelete("Sessions/{**path}")]
|
||||
public async Task<IActionResult> ProxySessionRequest(string? path = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var method = Request.Method;
|
||||
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : "";
|
||||
var endpoint = string.IsNullOrEmpty(path) ? $"Sessions{queryString}" : $"Sessions/{path}{queryString}";
|
||||
|
||||
_logger.LogInformation("🔄 Proxying session request: {Method} {Endpoint}", method, endpoint);
|
||||
_logger.LogDebug("Session proxy headers: {Headers}",
|
||||
string.Join(", ", Request.Headers.Where(h => h.Key.Contains("Auth", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(h => $"{h.Key}={h.Value}")));
|
||||
|
||||
// Read body if present
|
||||
string body = "{}";
|
||||
if ((method == "POST" || method == "PUT") && Request.ContentLength > 0)
|
||||
{
|
||||
Request.EnableBuffering();
|
||||
using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true))
|
||||
{
|
||||
body = await reader.ReadToEndAsync();
|
||||
}
|
||||
Request.Body.Position = 0;
|
||||
_logger.LogDebug("Session proxy body: {Body}", body);
|
||||
}
|
||||
|
||||
// Forward to Jellyfin
|
||||
var (result, statusCode) = method switch
|
||||
{
|
||||
"GET" => await _proxyService.GetJsonAsync(endpoint, null, Request.Headers),
|
||||
"POST" => await _proxyService.PostJsonAsync(endpoint, body, Request.Headers),
|
||||
"PUT" => await _proxyService.PostJsonAsync(endpoint, body, Request.Headers), // Use POST for PUT
|
||||
"DELETE" => await _proxyService.PostJsonAsync(endpoint, body, Request.Headers), // Use POST for DELETE
|
||||
_ => (null, 405)
|
||||
};
|
||||
|
||||
if (result != null)
|
||||
{
|
||||
_logger.LogInformation("✓ Session request proxied successfully ({StatusCode})", statusCode);
|
||||
return new JsonResult(result.RootElement.Clone());
|
||||
}
|
||||
|
||||
_logger.LogInformation("✓ Session request proxied ({StatusCode}, no body)", statusCode);
|
||||
return StatusCode(statusCode);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to proxy session request: {Path}", path);
|
||||
return StatusCode(500);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion // Session Management
|
||||
|
||||
#endregion // Playback Session Reporting
|
||||
|
||||
#region System & Proxy
|
||||
|
||||
/// <summary>
|
||||
@@ -1585,11 +2213,88 @@ public class JellyfinController : ControllerBase
|
||||
/// <summary>
|
||||
/// Catch-all endpoint that proxies unhandled requests to Jellyfin transparently.
|
||||
/// This route has the lowest priority and should only match requests that don't have SearchTerm.
|
||||
/// Blocks dangerous admin endpoints for security.
|
||||
/// </summary>
|
||||
[HttpGet("{**path}", Order = 100)]
|
||||
[HttpPost("{**path}", Order = 100)]
|
||||
public async Task<IActionResult> ProxyRequest(string path)
|
||||
{
|
||||
// Log session-related requests prominently to debug missing capabilities call
|
||||
if (path.Contains("session", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.Contains("capabilit", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogWarning("🔍 SESSION/CAPABILITY REQUEST: {Method} /{Path}{Query}", Request.Method, path, Request.QueryString);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("ProxyRequest: {Method} /{Path}", Request.Method, path);
|
||||
}
|
||||
|
||||
// Log endpoint usage to file for analysis
|
||||
await LogEndpointUsageAsync(path, Request.Method);
|
||||
|
||||
// Block dangerous admin endpoints
|
||||
var blockedPrefixes = new[]
|
||||
{
|
||||
"system/restart", // Server restart
|
||||
"system/shutdown", // Server shutdown
|
||||
"system/configuration", // System configuration changes
|
||||
"system/logs", // Server logs access
|
||||
"system/activitylog", // Activity log access
|
||||
"plugins/", // Plugin management (install/uninstall/configure)
|
||||
"scheduledtasks/", // Scheduled task management
|
||||
"startup/", // Initial server setup
|
||||
"users/new", // User creation
|
||||
"library/refresh", // Library scan (expensive operation)
|
||||
"library/virtualfolders", // Library folder management
|
||||
"branding/", // Branding configuration
|
||||
"displaypreferences/", // Display preferences (if not user-specific)
|
||||
"notifications/admin" // Admin notifications
|
||||
};
|
||||
|
||||
// Check if path matches any blocked prefix
|
||||
if (blockedPrefixes.Any(prefix =>
|
||||
path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
_logger.LogWarning("BLOCKED: Access denied to admin endpoint: {Path} from {IP}",
|
||||
path,
|
||||
HttpContext.Connection.RemoteIpAddress);
|
||||
return StatusCode(403, new
|
||||
{
|
||||
error = "Access to administrative endpoints is not allowed through this proxy",
|
||||
path = path
|
||||
});
|
||||
}
|
||||
|
||||
// Intercept Spotify playlist requests by ID
|
||||
if (_spotifySettings.Enabled &&
|
||||
path.StartsWith("playlists/", StringComparison.OrdinalIgnoreCase) &&
|
||||
path.Contains("/items", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Extract playlist ID from path: playlists/{id}/items
|
||||
var parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length >= 2 && parts[0].Equals("playlists", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var playlistId = parts[1];
|
||||
|
||||
_logger.LogInformation("=== PLAYLIST REQUEST ===");
|
||||
_logger.LogInformation("Playlist ID: {PlaylistId}", playlistId);
|
||||
_logger.LogInformation("Spotify Enabled: {Enabled}", _spotifySettings.Enabled);
|
||||
_logger.LogInformation("Configured IDs: {Ids}", string.Join(", ", _spotifySettings.PlaylistIds));
|
||||
_logger.LogInformation("Is configured: {IsConfigured}", _spotifySettings.PlaylistIds.Contains(playlistId, StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
// Check if this playlist ID is configured for Spotify injection
|
||||
if (_spotifySettings.PlaylistIds.Any(id => id.Equals(playlistId, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
_logger.LogInformation("========================================");
|
||||
_logger.LogInformation("=== INTERCEPTING SPOTIFY PLAYLIST ===");
|
||||
_logger.LogInformation("Playlist ID: {PlaylistId}", playlistId);
|
||||
_logger.LogInformation("========================================");
|
||||
return await GetPlaylistTracks(playlistId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle non-JSON responses (robots.txt, etc.)
|
||||
if (path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.EndsWith(".xml", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -1659,6 +2364,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
|
||||
JsonDocument? result;
|
||||
int statusCode;
|
||||
|
||||
if (HttpContext.Request.Method == HttpMethod.Post.Method)
|
||||
{
|
||||
@@ -1700,22 +2406,59 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
result = await _proxyService.PostJsonAsync(fullPath, body, Request.Headers);
|
||||
(result, statusCode) = await _proxyService.PostJsonAsync(fullPath, body, Request.Headers);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Forward GET requests transparently with authentication headers and query string
|
||||
result = await _proxyService.GetJsonAsync(fullPath, null, Request.Headers);
|
||||
(result, statusCode) = await _proxyService.GetJsonAsync(fullPath, null, Request.Headers);
|
||||
}
|
||||
|
||||
// Handle different status codes
|
||||
if (result == null)
|
||||
{
|
||||
// Return 204 No Content for successful requests with no body
|
||||
// (e.g., /sessions/playing, /sessions/playing/progress)
|
||||
// No body - return the status code from Jellyfin
|
||||
if (statusCode == 204)
|
||||
{
|
||||
return NoContent();
|
||||
}
|
||||
else if (statusCode == 401)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
else if (statusCode == 403)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
else if (statusCode == 404)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
else if (statusCode >= 400 && statusCode < 500)
|
||||
{
|
||||
return StatusCode(statusCode);
|
||||
}
|
||||
else if (statusCode >= 500)
|
||||
{
|
||||
return StatusCode(statusCode);
|
||||
}
|
||||
|
||||
// Default to 204 for 2xx responses with no body
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText()));
|
||||
// Modify response if it contains Spotify playlists to update ChildCount
|
||||
// Only check for Items if the response is an object (not a string or array)
|
||||
if (_spotifySettings.Enabled &&
|
||||
result.RootElement.ValueKind == JsonValueKind.Object &&
|
||||
result.RootElement.TryGetProperty("Items", out var items))
|
||||
{
|
||||
_logger.LogInformation("Response has Items property, checking for Spotify playlists to update counts");
|
||||
result = await UpdateSpotifyPlaylistCounts(result);
|
||||
}
|
||||
|
||||
// Return the raw JSON element directly to avoid deserialization issues with simple types
|
||||
return new JsonResult(result.RootElement.Clone());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1728,6 +2471,178 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
#region Helpers
|
||||
|
||||
/// <summary>
|
||||
/// Helper to handle proxy responses with proper status code handling.
|
||||
/// </summary>
|
||||
private IActionResult HandleProxyResponse(JsonDocument? result, int statusCode, object? fallbackValue = null)
|
||||
{
|
||||
if (result != null)
|
||||
{
|
||||
return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText()));
|
||||
}
|
||||
|
||||
// Handle error status codes
|
||||
if (statusCode == 401)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
else if (statusCode == 403)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
else if (statusCode == 404)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
else if (statusCode >= 400)
|
||||
{
|
||||
return StatusCode(statusCode);
|
||||
}
|
||||
|
||||
// Success with no body - return fallback or empty
|
||||
if (fallbackValue != null)
|
||||
{
|
||||
return new JsonResult(fallbackValue);
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates ChildCount for Spotify playlists in the response to show total tracks (local + matched).
|
||||
/// </summary>
|
||||
private async Task<JsonDocument> UpdateSpotifyPlaylistCounts(JsonDocument response)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!response.RootElement.TryGetProperty("Items", out var items))
|
||||
{
|
||||
return response;
|
||||
}
|
||||
|
||||
var itemsArray = items.EnumerateArray().ToList();
|
||||
var modified = false;
|
||||
var updatedItems = new List<Dictionary<string, object>>();
|
||||
|
||||
_logger.LogInformation("Checking {Count} items for Spotify playlists", itemsArray.Count);
|
||||
|
||||
foreach (var item in itemsArray)
|
||||
{
|
||||
var itemDict = JsonSerializer.Deserialize<Dictionary<string, object>>(item.GetRawText());
|
||||
if (itemDict == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this is a Spotify playlist
|
||||
if (item.TryGetProperty("Id", out var idProp))
|
||||
{
|
||||
var playlistId = idProp.GetString();
|
||||
_logger.LogDebug("Checking item with ID: {Id}", playlistId);
|
||||
|
||||
if (!string.IsNullOrEmpty(playlistId) &&
|
||||
_spotifySettings.PlaylistIds.Any(id => id.Equals(playlistId, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
_logger.LogInformation("Found Spotify playlist: {Id}", playlistId);
|
||||
|
||||
// This is a Spotify playlist - get the actual track count
|
||||
var playlistIndex = _spotifySettings.PlaylistIds.FindIndex(id =>
|
||||
id.Equals(playlistId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (playlistIndex >= 0 && playlistIndex < _spotifySettings.PlaylistNames.Count)
|
||||
{
|
||||
var playlistName = _spotifySettings.PlaylistNames[playlistIndex];
|
||||
var missingTracksKey = $"spotify:missing:{playlistName}";
|
||||
var missingTracks = await _cache.GetAsync<List<allstarr.Models.Spotify.MissingTrack>>(missingTracksKey);
|
||||
|
||||
_logger.LogInformation("Cache lookup for {Key}: {Count} tracks",
|
||||
missingTracksKey, missingTracks?.Count ?? 0);
|
||||
|
||||
// Fallback to file cache
|
||||
if (missingTracks == null || missingTracks.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("Trying file cache for {Name}", playlistName);
|
||||
missingTracks = await LoadMissingTracksFromFile(playlistName);
|
||||
_logger.LogInformation("File cache result: {Count} tracks", missingTracks?.Count ?? 0);
|
||||
}
|
||||
|
||||
if (missingTracks != null && missingTracks.Count > 0)
|
||||
{
|
||||
// Update ChildCount to show the number of tracks we'll provide
|
||||
itemDict["ChildCount"] = missingTracks.Count;
|
||||
modified = true;
|
||||
_logger.LogInformation("✓ Updated ChildCount for Spotify playlist {Name} to {Count}",
|
||||
playlistName, missingTracks.Count);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("No missing tracks found for {Name}", playlistName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updatedItems.Add(itemDict);
|
||||
}
|
||||
|
||||
if (!modified)
|
||||
{
|
||||
_logger.LogInformation("No Spotify playlists found to update");
|
||||
return response;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Modified {Count} Spotify playlists, rebuilding response",
|
||||
updatedItems.Count(i => i.ContainsKey("ChildCount")));
|
||||
|
||||
// Rebuild the response with updated items
|
||||
var responseDict = JsonSerializer.Deserialize<Dictionary<string, object>>(response.RootElement.GetRawText());
|
||||
if (responseDict != null)
|
||||
{
|
||||
responseDict["Items"] = updatedItems;
|
||||
var updatedJson = JsonSerializer.Serialize(responseDict);
|
||||
return JsonDocument.Parse(updatedJson);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to update Spotify playlist counts");
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logs endpoint usage to a file for analysis.
|
||||
/// Creates a CSV file with timestamp, method, path, and query string.
|
||||
/// </summary>
|
||||
private async Task LogEndpointUsageAsync(string path, string method)
|
||||
{
|
||||
try
|
||||
{
|
||||
var logDir = "/app/cache/endpoint-usage";
|
||||
Directory.CreateDirectory(logDir);
|
||||
|
||||
var logFile = Path.Combine(logDir, "endpoints.csv");
|
||||
var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss");
|
||||
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : "";
|
||||
|
||||
// Sanitize path and query for CSV (remove commas, quotes, newlines)
|
||||
var sanitizedPath = path.Replace(",", ";").Replace("\"", "'").Replace("\n", " ").Replace("\r", " ");
|
||||
var sanitizedQuery = queryString.Replace(",", ";").Replace("\"", "'").Replace("\n", " ").Replace("\r", " ");
|
||||
|
||||
var logLine = $"{timestamp},{method},{sanitizedPath},{sanitizedQuery}\n";
|
||||
|
||||
// Append to file (thread-safe)
|
||||
await System.IO.File.AppendAllTextAsync(logFile, logLine);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Don't let logging failures break the request
|
||||
_logger.LogDebug(ex, "Failed to log endpoint usage");
|
||||
}
|
||||
}
|
||||
|
||||
private static string[]? ParseItemTypes(string? includeItemTypes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(includeItemTypes))
|
||||
@@ -1761,28 +2676,52 @@ public class JellyfinController : ControllerBase
|
||||
private static List<(T Item, int Score)> ScoreSearchResults<T>(
|
||||
string query,
|
||||
List<T> items,
|
||||
Func<T, string> primaryField,
|
||||
Func<T, string?> secondaryField,
|
||||
Func<T, string> titleField,
|
||||
Func<T, string?> artistField,
|
||||
Func<T, string?> albumField,
|
||||
bool isExternal = false)
|
||||
{
|
||||
return items.Select(item =>
|
||||
{
|
||||
var primary = primaryField(item) ?? "";
|
||||
var secondary = secondaryField(item) ?? "";
|
||||
var title = titleField(item) ?? "";
|
||||
var artist = artistField(item) ?? "";
|
||||
var album = albumField(item) ?? "";
|
||||
|
||||
// Score against primary field (title/name)
|
||||
var primaryScore = FuzzyMatcher.CalculateSimilarity(query, primary);
|
||||
// Token-based fuzzy matching: split query and fields into words
|
||||
var queryTokens = query.ToLower()
|
||||
.Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries)
|
||||
.ToList();
|
||||
|
||||
// Score against secondary field (artist) if provided
|
||||
var secondaryScore = string.IsNullOrEmpty(secondary)
|
||||
? 0
|
||||
: FuzzyMatcher.CalculateSimilarity(query, secondary);
|
||||
var fieldText = $"{title} {artist} {album}".ToLower();
|
||||
var fieldTokens = fieldText
|
||||
.Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries)
|
||||
.ToList();
|
||||
|
||||
// Use the better of the two scores
|
||||
var baseScore = Math.Max(primaryScore, secondaryScore);
|
||||
if (queryTokens.Count == 0) return (item, 0);
|
||||
|
||||
// Count how many query tokens match field tokens (with fuzzy tolerance)
|
||||
var matchedTokens = 0;
|
||||
foreach (var queryToken in queryTokens)
|
||||
{
|
||||
// Check if any field token matches this query token
|
||||
var hasMatch = fieldTokens.Any(fieldToken =>
|
||||
{
|
||||
// Exact match or substring match
|
||||
if (fieldToken.Contains(queryToken) || queryToken.Contains(fieldToken))
|
||||
return true;
|
||||
|
||||
// Fuzzy match with Levenshtein distance
|
||||
var similarity = FuzzyMatcher.CalculateSimilarity(queryToken, fieldToken);
|
||||
return similarity >= 70; // 70% similarity threshold for individual words
|
||||
});
|
||||
|
||||
if (hasMatch) matchedTokens++;
|
||||
}
|
||||
|
||||
// Score = percentage of query tokens that matched
|
||||
var baseScore = (matchedTokens * 100) / queryTokens.Count;
|
||||
|
||||
// Give external results a small boost (+5 points) to prioritize the larger catalog
|
||||
// This means external results will rank slightly higher when scores are close
|
||||
var finalScore = isExternal ? Math.Min(100, baseScore + 5) : baseScore;
|
||||
|
||||
return (item, finalScore);
|
||||
@@ -1790,5 +2729,645 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Spotify Playlist Injection
|
||||
|
||||
/// <summary>
|
||||
/// Gets tracks for a Spotify playlist by matching missing tracks against external providers
|
||||
/// and merging with existing local tracks from Jellyfin.
|
||||
/// </summary>
|
||||
private async Task<IActionResult> GetSpotifyPlaylistTracksAsync(string spotifyPlaylistName, string playlistId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cacheKey = $"spotify:matched:{spotifyPlaylistName}";
|
||||
var cachedTracks = await _cache.GetAsync<List<Song>>(cacheKey);
|
||||
|
||||
if (cachedTracks != null)
|
||||
{
|
||||
_logger.LogDebug("Returning {Count} cached matched tracks for {Playlist}",
|
||||
cachedTracks.Count, spotifyPlaylistName);
|
||||
return _responseBuilder.CreateItemsResponse(cachedTracks);
|
||||
}
|
||||
|
||||
// Get existing Jellyfin playlist items (tracks the plugin already found)
|
||||
var (existingTracksResponse, _) = await _proxyService.GetJsonAsync(
|
||||
$"Playlists/{playlistId}/Items",
|
||||
null,
|
||||
Request.Headers);
|
||||
|
||||
var existingTracks = new List<Song>();
|
||||
var existingSpotifyIds = new HashSet<string>();
|
||||
|
||||
if (existingTracksResponse != null &&
|
||||
existingTracksResponse.RootElement.TryGetProperty("Items", out var items))
|
||||
{
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
var song = _modelMapper.ParseSong(item);
|
||||
existingTracks.Add(song);
|
||||
|
||||
// Track Spotify IDs to avoid duplicates
|
||||
if (item.TryGetProperty("ProviderIds", out var providerIds) &&
|
||||
providerIds.TryGetProperty("Spotify", out var spotifyId))
|
||||
{
|
||||
existingSpotifyIds.Add(spotifyId.GetString() ?? "");
|
||||
}
|
||||
}
|
||||
_logger.LogInformation("Found {Count} existing tracks in Jellyfin playlist", existingTracks.Count);
|
||||
}
|
||||
|
||||
var missingTracksKey = $"spotify:missing:{spotifyPlaylistName}";
|
||||
var missingTracks = await _cache.GetAsync<List<allstarr.Models.Spotify.MissingTrack>>(missingTracksKey);
|
||||
|
||||
// Fallback to file cache if Redis is empty
|
||||
if (missingTracks == null || missingTracks.Count == 0)
|
||||
{
|
||||
missingTracks = await LoadMissingTracksFromFile(spotifyPlaylistName);
|
||||
|
||||
// If we loaded from file, restore to Redis with no expiration
|
||||
if (missingTracks != null && missingTracks.Count > 0)
|
||||
{
|
||||
await _cache.SetAsync(missingTracksKey, missingTracks, TimeSpan.FromDays(365));
|
||||
_logger.LogInformation("Restored {Count} missing tracks from file cache for {Playlist} (no expiration)",
|
||||
missingTracks.Count, spotifyPlaylistName);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingTracks == null || missingTracks.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("No missing tracks found for {Playlist}, returning {Count} existing tracks",
|
||||
spotifyPlaylistName, existingTracks.Count);
|
||||
return _responseBuilder.CreateItemsResponse(existingTracks);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Matching {Count} missing tracks for {Playlist}",
|
||||
missingTracks.Count, spotifyPlaylistName);
|
||||
|
||||
// Match missing tracks sequentially with rate limiting (excluding ones we already have locally)
|
||||
var matchedBySpotifyId = new Dictionary<string, Song>();
|
||||
var tracksToMatch = missingTracks
|
||||
.Where(track => !existingSpotifyIds.Contains(track.SpotifyId))
|
||||
.ToList();
|
||||
|
||||
foreach (var track in tracksToMatch)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Search with just title and artist for better matching
|
||||
var query = $"{track.Title} {track.PrimaryArtist}";
|
||||
var results = await _metadataService.SearchSongsAsync(query, limit: 5);
|
||||
|
||||
if (results.Count > 0)
|
||||
{
|
||||
// Fuzzy match to find best result
|
||||
// Check that ALL artists match (not just some)
|
||||
var bestMatch = results
|
||||
.Select(song => new
|
||||
{
|
||||
Song = song,
|
||||
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, song.Title),
|
||||
// Calculate artist score by checking ALL artists match
|
||||
ArtistScore = CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors)
|
||||
})
|
||||
.Select(x => new
|
||||
{
|
||||
x.Song,
|
||||
x.TitleScore,
|
||||
x.ArtistScore,
|
||||
TotalScore = (x.TitleScore * 0.6) + (x.ArtistScore * 0.4) // Weight title more
|
||||
})
|
||||
.OrderByDescending(x => x.TotalScore)
|
||||
.FirstOrDefault();
|
||||
|
||||
// Only add if match is good enough (>60% combined score)
|
||||
if (bestMatch != null && bestMatch.TotalScore >= 60)
|
||||
{
|
||||
_logger.LogDebug("Matched '{Title}' by {Artist} -> '{MatchTitle}' by {MatchArtist} (score: {Score:F1})",
|
||||
track.Title, track.PrimaryArtist,
|
||||
bestMatch.Song.Title, bestMatch.Song.Artist,
|
||||
bestMatch.TotalScore);
|
||||
matchedBySpotifyId[track.SpotifyId] = bestMatch.Song;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("No good match for '{Title}' by {Artist} (best score: {Score:F1})",
|
||||
track.Title, track.PrimaryArtist, bestMatch?.TotalScore ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Rate limiting: small delay between searches to avoid overwhelming the service
|
||||
await Task.Delay(100); // 100ms delay = max 10 searches/second
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
|
||||
track.Title, track.PrimaryArtist);
|
||||
}
|
||||
}
|
||||
|
||||
// Build final track list in Spotify playlist order
|
||||
var finalTracks = new List<Song>();
|
||||
foreach (var missingTrack in missingTracks)
|
||||
{
|
||||
// Check if we have it locally first
|
||||
var existingTrack = existingTracks.FirstOrDefault(t =>
|
||||
t.Title.Equals(missingTrack.Title, StringComparison.OrdinalIgnoreCase) &&
|
||||
t.Artist.Equals(missingTrack.PrimaryArtist, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (existingTrack != null)
|
||||
{
|
||||
finalTracks.Add(existingTrack);
|
||||
}
|
||||
else if (matchedBySpotifyId.TryGetValue(missingTrack.SpotifyId, out var matchedTrack))
|
||||
{
|
||||
finalTracks.Add(matchedTrack);
|
||||
}
|
||||
// Skip tracks we couldn't match
|
||||
}
|
||||
|
||||
await _cache.SetAsync(cacheKey, finalTracks, TimeSpan.FromHours(1));
|
||||
|
||||
_logger.LogInformation("Final playlist: {Total} tracks ({Existing} local, {Matched} matched, {Missing} missing)",
|
||||
finalTracks.Count,
|
||||
existingTracks.Count,
|
||||
matchedBySpotifyId.Count,
|
||||
missingTracks.Count - existingTracks.Count - matchedBySpotifyId.Count);
|
||||
|
||||
return _responseBuilder.CreateItemsResponse(finalTracks);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting Spotify playlist tracks {PlaylistName}", spotifyPlaylistName);
|
||||
return _responseBuilder.CreateError(500, "Failed to get Spotify playlist tracks");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies an external track to the kept folder when favorited.
|
||||
/// </summary>
|
||||
private async Task CopyExternalTrackToKeptAsync(string itemId, string provider, string externalId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get the song metadata
|
||||
var song = await _metadataService.GetSongAsync(provider, externalId);
|
||||
if (song == null)
|
||||
{
|
||||
_logger.LogWarning("Could not find song metadata for {ItemId}", itemId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Trigger download first
|
||||
_logger.LogInformation("Downloading track for kept folder: {ItemId}", itemId);
|
||||
string downloadPath;
|
||||
|
||||
try
|
||||
{
|
||||
downloadPath = await _downloadService.DownloadSongAsync(provider, externalId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to download track {ItemId}", itemId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create kept folder structure: /app/kept/Artist/Album/
|
||||
var keptBasePath = "/app/kept";
|
||||
var keptArtistPath = Path.Combine(keptBasePath, PathHelper.SanitizeFileName(song.Artist));
|
||||
var keptAlbumPath = Path.Combine(keptArtistPath, PathHelper.SanitizeFileName(song.Album));
|
||||
|
||||
Directory.CreateDirectory(keptAlbumPath);
|
||||
|
||||
// Copy file to kept folder
|
||||
var fileName = Path.GetFileName(downloadPath);
|
||||
var keptFilePath = Path.Combine(keptAlbumPath, fileName);
|
||||
|
||||
if (System.IO.File.Exists(keptFilePath))
|
||||
{
|
||||
_logger.LogInformation("Track already exists in kept folder: {Path}", keptFilePath);
|
||||
return;
|
||||
}
|
||||
|
||||
System.IO.File.Copy(downloadPath, keptFilePath, overwrite: false);
|
||||
_logger.LogInformation("✓ Copied favorited track to kept folder: {Path}", keptFilePath);
|
||||
|
||||
// Also copy cover art if it exists
|
||||
var coverPath = Path.Combine(Path.GetDirectoryName(downloadPath)!, "cover.jpg");
|
||||
if (System.IO.File.Exists(coverPath))
|
||||
{
|
||||
var keptCoverPath = Path.Combine(keptAlbumPath, "cover.jpg");
|
||||
if (!System.IO.File.Exists(keptCoverPath))
|
||||
{
|
||||
System.IO.File.Copy(coverPath, keptCoverPath, overwrite: false);
|
||||
_logger.LogDebug("Copied cover art to kept folder");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error copying external track {ItemId} to kept folder", itemId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads missing tracks from file cache as fallback when Redis is empty.
|
||||
/// </summary>
|
||||
private async Task<List<allstarr.Models.Spotify.MissingTrack>?> LoadMissingTracksFromFile(string playlistName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
|
||||
var filePath = Path.Combine("/app/cache/spotify", $"{safeName}_missing.json");
|
||||
|
||||
if (!System.IO.File.Exists(filePath))
|
||||
{
|
||||
_logger.LogDebug("No file cache found for {Playlist} at {Path}", playlistName, filePath);
|
||||
return null;
|
||||
}
|
||||
|
||||
// No expiration check - cache persists until next Jellyfin job generates new file
|
||||
var fileAge = DateTime.UtcNow - System.IO.File.GetLastWriteTimeUtc(filePath);
|
||||
_logger.LogDebug("File cache for {Playlist} age: {Age:F1}h (no expiration)", playlistName, fileAge.TotalHours);
|
||||
|
||||
var json = await System.IO.File.ReadAllTextAsync(filePath);
|
||||
var tracks = JsonSerializer.Deserialize<List<allstarr.Models.Spotify.MissingTrack>>(json);
|
||||
|
||||
_logger.LogInformation("Loaded {Count} missing tracks from file cache for {Playlist} (age: {Age:F1}h)",
|
||||
tracks?.Count ?? 0, playlistName, fileAge.TotalHours);
|
||||
|
||||
return tracks;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load missing tracks from file for {Playlist}", playlistName);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manual trigger endpoint to force fetch Spotify missing tracks.
|
||||
/// GET /spotify/sync?api_key=YOUR_KEY
|
||||
/// </summary>
|
||||
[HttpGet("spotify/sync", Order = 1)]
|
||||
[ServiceFilter(typeof(ApiKeyAuthFilter))]
|
||||
public async Task<IActionResult> TriggerSpotifySync()
|
||||
{
|
||||
if (!_spotifySettings.Enabled)
|
||||
{
|
||||
return BadRequest(new { error = "Spotify Import is not enabled" });
|
||||
}
|
||||
|
||||
_logger.LogInformation("Manual Spotify sync triggered");
|
||||
|
||||
var results = new Dictionary<string, object>();
|
||||
|
||||
for (int i = 0; i < _spotifySettings.PlaylistIds.Count; i++)
|
||||
{
|
||||
var playlistId = _spotifySettings.PlaylistIds[i];
|
||||
|
||||
try
|
||||
{
|
||||
// Use configured name if available, otherwise use ID
|
||||
var playlistName = i < _spotifySettings.PlaylistNames.Count
|
||||
? _spotifySettings.PlaylistNames[i]
|
||||
: playlistId;
|
||||
|
||||
_logger.LogInformation("Fetching missing tracks for {Playlist} (ID: {Id})", playlistName, playlistId);
|
||||
|
||||
// Try to fetch the missing tracks file - search last 24 hours
|
||||
var now = DateTime.UtcNow;
|
||||
var searchStart = now.AddHours(-24);
|
||||
|
||||
var httpClient = new HttpClient();
|
||||
var found = false;
|
||||
|
||||
// Search every minute for the last 24 hours (1440 attempts max)
|
||||
for (var time = searchStart; time <= now; time = time.AddMinutes(1))
|
||||
{
|
||||
var filename = $"{playlistName}_missing_{time:yyyy-MM-dd_HH-mm}.json";
|
||||
var url = $"{_settings.Url}/Viperinius.Plugin.SpotifyImport/MissingTracksFile" +
|
||||
$"?name={Uri.EscapeDataString(filename)}&api_key={_settings.ApiKey}";
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Trying {Filename}", filename);
|
||||
var response = await httpClient.GetAsync(url);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var tracks = ParseMissingTracksJson(json);
|
||||
|
||||
if (tracks.Count > 0)
|
||||
{
|
||||
var cacheKey = $"spotify:missing:{playlistName}";
|
||||
await _cache.SetAsync(cacheKey, tracks, TimeSpan.FromHours(24));
|
||||
|
||||
results[playlistName] = new {
|
||||
status = "success",
|
||||
tracks = tracks.Count,
|
||||
filename = filename
|
||||
};
|
||||
|
||||
_logger.LogInformation("✓ Cached {Count} missing tracks for {Playlist} from {Filename}",
|
||||
tracks.Count, playlistName, filename);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to fetch {Filename}", filename);
|
||||
}
|
||||
}
|
||||
|
||||
if (!found)
|
||||
{
|
||||
results[playlistName] = new { status = "not_found", message = "No missing tracks file found" };
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error syncing playlist {PlaylistId}", playlistId);
|
||||
results[playlistId] = new { status = "error", message = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
await matchingService.TriggerMatchingAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error during manual track matching");
|
||||
}
|
||||
});
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
status = "started",
|
||||
message = "Track matching started in background. Check logs for progress.",
|
||||
playlists = _spotifySettings.PlaylistNames.Count > 0
|
||||
? _spotifySettings.PlaylistNames
|
||||
: _spotifySettings.PlaylistIds
|
||||
});
|
||||
}
|
||||
|
||||
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 playlistName in _spotifySettings.PlaylistNames)
|
||||
{
|
||||
var matchedKey = $"spotify:matched:{playlistName}";
|
||||
await _cache.DeleteAsync(matchedKey);
|
||||
cleared.Add(playlistName);
|
||||
_logger.LogInformation("Cleared cache for {Playlist}", playlistName);
|
||||
}
|
||||
|
||||
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>
|
||||
/// Clears the endpoint usage log file.
|
||||
/// DELETE /debug/endpoint-usage?api_key=YOUR_KEY
|
||||
/// </summary>
|
||||
[HttpDelete("debug/endpoint-usage")]
|
||||
[ServiceFilter(typeof(ApiKeyAuthFilter))]
|
||||
public IActionResult ClearEndpointUsage()
|
||||
{
|
||||
try
|
||||
{
|
||||
var logFile = "/app/cache/endpoint-usage/endpoints.csv";
|
||||
|
||||
if (System.IO.File.Exists(logFile))
|
||||
{
|
||||
System.IO.File.Delete(logFile);
|
||||
return Ok(new { status = "success", message = "Endpoint usage log cleared" });
|
||||
}
|
||||
|
||||
return Ok(new { status = "success", message = "No log file to clear" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to clear endpoint usage log");
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Calculates artist match score ensuring ALL artists are present.
|
||||
/// Penalizes if artist counts don't match or if any artist is missing.
|
||||
/// </summary>
|
||||
private 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 =>
|
||||
FuzzyMatcher.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 =>
|
||||
FuzzyMatcher.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;
|
||||
}
|
||||
}
|
||||
// force rebuild Sun Jan 25 13:22:47 EST 2026
|
||||
|
||||
52
allstarr/Filters/ApiKeyAuthFilter.cs
Normal file
52
allstarr/Filters/ApiKeyAuthFilter.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Models.Settings;
|
||||
|
||||
namespace allstarr.Filters;
|
||||
|
||||
/// <summary>
|
||||
/// Simple API key authentication filter for admin endpoints.
|
||||
/// Validates against Jellyfin API key via query parameter or header.
|
||||
/// </summary>
|
||||
public class ApiKeyAuthFilter : IAsyncActionFilter
|
||||
{
|
||||
private readonly JellyfinSettings _settings;
|
||||
private readonly ILogger<ApiKeyAuthFilter> _logger;
|
||||
|
||||
public ApiKeyAuthFilter(
|
||||
IOptions<JellyfinSettings> settings,
|
||||
ILogger<ApiKeyAuthFilter> logger)
|
||||
{
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||
{
|
||||
var request = context.HttpContext.Request;
|
||||
|
||||
// Extract API key from query parameter or header
|
||||
var apiKey = request.Query["api_key"].FirstOrDefault()
|
||||
?? request.Headers["X-Api-Key"].FirstOrDefault()
|
||||
?? request.Headers["X-Emby-Token"].FirstOrDefault();
|
||||
|
||||
// Validate API key
|
||||
if (string.IsNullOrEmpty(apiKey) || !string.Equals(apiKey, _settings.ApiKey, StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogWarning("Unauthorized access attempt to {Path} from {IP}",
|
||||
request.Path,
|
||||
context.HttpContext.Connection.RemoteIpAddress);
|
||||
|
||||
context.Result = new UnauthorizedObjectResult(new
|
||||
{
|
||||
error = "Unauthorized",
|
||||
message = "Valid API key required. Provide via ?api_key=YOUR_KEY or X-Api-Key header."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("API key authentication successful for {Path}", request.Path);
|
||||
await next();
|
||||
}
|
||||
}
|
||||
220
allstarr/Middleware/WebSocketProxyMiddleware.cs
Normal file
220
allstarr/Middleware/WebSocketProxyMiddleware.cs
Normal file
@@ -0,0 +1,220 @@
|
||||
using System.Net.WebSockets;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Models.Settings;
|
||||
|
||||
namespace allstarr.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Middleware that proxies WebSocket connections to Jellyfin server.
|
||||
/// This enables real-time features like session tracking, remote control, and live updates.
|
||||
/// </summary>
|
||||
public class WebSocketProxyMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly JellyfinSettings _settings;
|
||||
private readonly ILogger<WebSocketProxyMiddleware> _logger;
|
||||
|
||||
public WebSocketProxyMiddleware(
|
||||
RequestDelegate next,
|
||||
IOptions<JellyfinSettings> settings,
|
||||
ILogger<WebSocketProxyMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
|
||||
_logger.LogInformation("🔧 WebSocketProxyMiddleware initialized - Jellyfin URL: {Url}", _settings.Url);
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
// Log ALL requests to /socket path for debugging
|
||||
if (context.Request.Path.StartsWithSegments("/socket", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogInformation("📡 Request to /socket path - IsWebSocketRequest: {IsWs}, Method: {Method}, Headers: {Headers}",
|
||||
context.WebSockets.IsWebSocketRequest,
|
||||
context.Request.Method,
|
||||
string.Join(", ", context.Request.Headers.Select(h => $"{h.Key}={h.Value}")));
|
||||
}
|
||||
|
||||
// Check if this is a WebSocket request to /socket
|
||||
if (context.Request.Path.StartsWithSegments("/socket", StringComparison.OrdinalIgnoreCase) &&
|
||||
context.WebSockets.IsWebSocketRequest)
|
||||
{
|
||||
_logger.LogInformation("🔌 WebSocket connection request received from {RemoteIp}",
|
||||
context.Connection.RemoteIpAddress);
|
||||
|
||||
await HandleWebSocketProxyAsync(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// Not a WebSocket request, pass to next middleware
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
private async Task HandleWebSocketProxyAsync(HttpContext context)
|
||||
{
|
||||
ClientWebSocket? serverWebSocket = null;
|
||||
WebSocket? clientWebSocket = null;
|
||||
|
||||
try
|
||||
{
|
||||
// Accept the WebSocket connection from the client
|
||||
clientWebSocket = await context.WebSockets.AcceptWebSocketAsync();
|
||||
_logger.LogInformation("✓ Client WebSocket accepted");
|
||||
|
||||
// Build Jellyfin WebSocket URL
|
||||
var jellyfinUrl = _settings.Url?.TrimEnd('/') ?? "";
|
||||
var wsScheme = jellyfinUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ? "wss://" : "ws://";
|
||||
var jellyfinHost = jellyfinUrl.Replace("https://", "").Replace("http://", "");
|
||||
var jellyfinWsUrl = $"{wsScheme}{jellyfinHost}/socket";
|
||||
|
||||
// Add query parameters if present (e.g., ?api_key=xxx or ?deviceId=xxx)
|
||||
if (context.Request.QueryString.HasValue)
|
||||
{
|
||||
jellyfinWsUrl += context.Request.QueryString.Value;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Connecting to Jellyfin WebSocket: {Url}", jellyfinWsUrl);
|
||||
|
||||
// Connect to Jellyfin WebSocket
|
||||
serverWebSocket = new ClientWebSocket();
|
||||
|
||||
// Forward authentication headers
|
||||
if (context.Request.Headers.TryGetValue("Authorization", out var authHeader))
|
||||
{
|
||||
serverWebSocket.Options.SetRequestHeader("Authorization", authHeader.ToString());
|
||||
_logger.LogDebug("Forwarded Authorization header");
|
||||
}
|
||||
else if (context.Request.Headers.TryGetValue("X-Emby-Authorization", out var embyAuthHeader))
|
||||
{
|
||||
serverWebSocket.Options.SetRequestHeader("X-Emby-Authorization", embyAuthHeader.ToString());
|
||||
_logger.LogDebug("Forwarded X-Emby-Authorization header");
|
||||
}
|
||||
|
||||
// Set user agent
|
||||
serverWebSocket.Options.SetRequestHeader("User-Agent", "Allstarr/1.0");
|
||||
|
||||
await serverWebSocket.ConnectAsync(new Uri(jellyfinWsUrl), context.RequestAborted);
|
||||
_logger.LogInformation("✓ Connected to Jellyfin WebSocket");
|
||||
|
||||
// Start bidirectional proxying
|
||||
var clientToServer = ProxyMessagesAsync(clientWebSocket, serverWebSocket, "Client→Server", context.RequestAborted);
|
||||
var serverToClient = ProxyMessagesAsync(serverWebSocket, clientWebSocket, "Server→Client", context.RequestAborted);
|
||||
|
||||
// Wait for either direction to complete
|
||||
await Task.WhenAny(clientToServer, serverToClient);
|
||||
|
||||
_logger.LogInformation("WebSocket proxy connection closed");
|
||||
}
|
||||
catch (WebSocketException wsEx)
|
||||
{
|
||||
_logger.LogWarning(wsEx, "WebSocket error: {Message}", wsEx.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in WebSocket proxy");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Clean up connections
|
||||
if (clientWebSocket?.State == WebSocketState.Open)
|
||||
{
|
||||
try
|
||||
{
|
||||
await clientWebSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Proxy closing", CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error closing client WebSocket");
|
||||
}
|
||||
}
|
||||
|
||||
if (serverWebSocket?.State == WebSocketState.Open)
|
||||
{
|
||||
try
|
||||
{
|
||||
await serverWebSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Proxy closing", CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error closing server WebSocket");
|
||||
}
|
||||
}
|
||||
|
||||
clientWebSocket?.Dispose();
|
||||
serverWebSocket?.Dispose();
|
||||
|
||||
_logger.LogInformation("WebSocket connections cleaned up");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProxyMessagesAsync(
|
||||
WebSocket source,
|
||||
WebSocket destination,
|
||||
string direction,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var buffer = new byte[1024 * 4]; // 4KB buffer
|
||||
var messageBuffer = new List<byte>();
|
||||
|
||||
try
|
||||
{
|
||||
while (source.State == WebSocketState.Open && destination.State == WebSocketState.Open)
|
||||
{
|
||||
var result = await source.ReceiveAsync(new ArraySegment<byte>(buffer), cancellationToken);
|
||||
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
_logger.LogInformation("{Direction}: Close message received", direction);
|
||||
await destination.CloseAsync(
|
||||
result.CloseStatus ?? WebSocketCloseStatus.NormalClosure,
|
||||
result.CloseStatusDescription,
|
||||
cancellationToken);
|
||||
break;
|
||||
}
|
||||
|
||||
// Accumulate message fragments
|
||||
messageBuffer.AddRange(buffer.Take(result.Count));
|
||||
|
||||
// If this is the end of the message, forward it
|
||||
if (result.EndOfMessage)
|
||||
{
|
||||
var messageBytes = messageBuffer.ToArray();
|
||||
|
||||
// Log message for debugging (only in debug mode to avoid spam)
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
var messageText = System.Text.Encoding.UTF8.GetString(messageBytes);
|
||||
_logger.LogDebug("{Direction}: {MessageType} message ({Size} bytes): {Preview}",
|
||||
direction,
|
||||
result.MessageType,
|
||||
messageBytes.Length,
|
||||
messageText.Length > 200 ? messageText[..200] + "..." : messageText);
|
||||
}
|
||||
|
||||
// Forward the complete message
|
||||
await destination.SendAsync(
|
||||
new ArraySegment<byte>(messageBytes),
|
||||
result.MessageType,
|
||||
true,
|
||||
cancellationToken);
|
||||
|
||||
messageBuffer.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogInformation("{Direction}: Operation cancelled", direction);
|
||||
}
|
||||
catch (WebSocketException wsEx) when (wsEx.WebSocketErrorCode == WebSocketError.ConnectionClosedPrematurely)
|
||||
{
|
||||
_logger.LogInformation("{Direction}: Connection closed prematurely", direction);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "{Direction}: Error proxying messages", direction);
|
||||
}
|
||||
}
|
||||
}
|
||||
50
allstarr/Models/Settings/SpotifyImportSettings.cs
Normal file
50
allstarr/Models/Settings/SpotifyImportSettings.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
namespace allstarr.Models.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for Spotify playlist injection feature.
|
||||
/// Requires Jellyfin Spotify Import Plugin: https://github.com/Viperinius/jellyfin-plugin-spotify-import
|
||||
/// Uses JellyfinSettings.Url and JellyfinSettings.ApiKey for API access.
|
||||
/// </summary>
|
||||
public class SpotifyImportSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Enable Spotify playlist injection feature
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Hour when Spotify Import plugin runs (24-hour format, 0-23)
|
||||
/// NOTE: This setting is now optional and only used for the sync window check.
|
||||
/// The fetcher will search backwards from current time for the last 48 hours,
|
||||
/// so timezone confusion is avoided.
|
||||
/// </summary>
|
||||
public int SyncStartHour { get; set; } = 16;
|
||||
|
||||
/// <summary>
|
||||
/// Minute when Spotify Import plugin runs (0-59)
|
||||
/// NOTE: This setting is now optional and only used for the sync window check.
|
||||
/// </summary>
|
||||
public int SyncStartMinute { get; set; } = 15;
|
||||
|
||||
/// <summary>
|
||||
/// How many hours to search for missing tracks files after sync start time
|
||||
/// This prevents the fetcher from running too frequently.
|
||||
/// Set to 0 to disable the sync window check and always search on startup.
|
||||
/// </summary>
|
||||
public int SyncWindowHours { get; set; } = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Comma-separated list of Jellyfin playlist IDs to inject
|
||||
/// Example: "4383a46d8bcac3be2ef9385053ea18df,ba50e26c867ec9d57ab2f7bf24cfd6b0"
|
||||
/// Get IDs from Jellyfin playlist URLs
|
||||
/// </summary>
|
||||
public List<string> PlaylistIds { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Comma-separated list of playlist names (must match Spotify Import plugin format)
|
||||
/// Example: "Discover_Weekly,Release_Radar"
|
||||
/// Must be in same order as PlaylistIds
|
||||
/// Plugin replaces spaces with underscores in filenames
|
||||
/// </summary>
|
||||
public List<string> PlaylistNames { get; set; } = new();
|
||||
}
|
||||
12
allstarr/Models/Spotify/MissingTrack.cs
Normal file
12
allstarr/Models/Spotify/MissingTrack.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace allstarr.Models.Spotify;
|
||||
|
||||
public class MissingTrack
|
||||
{
|
||||
public string SpotifyId { get; set; } = string.Empty;
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Album { get; set; } = string.Empty;
|
||||
public List<string> Artists { get; set; } = new();
|
||||
|
||||
public string PrimaryArtist => Artists.FirstOrDefault() ?? "";
|
||||
public string AllArtists => string.Join(", ", Artists);
|
||||
}
|
||||
@@ -22,12 +22,16 @@ static List<string> DecodeSquidWtfUrls()
|
||||
{
|
||||
var encodedUrls = new[]
|
||||
{
|
||||
"aHR0cHM6Ly90cml0b24uc3F1aWQud3Rm", // triton
|
||||
"aHR0cHM6Ly93b2xmLnFxZGwuc2l0ZQ==", // wolf
|
||||
"aHR0cDovL2h1bmQucXFkbC5zaXRl", // hund
|
||||
"aHR0cHM6Ly9tYXVzLnFxZGwuc2l0ZQ==", // maus
|
||||
"aHR0cHM6Ly92b2dlbC5xcWRsLnNpdGU=", // vogel
|
||||
"aHR0cHM6Ly9rYXR6ZS5xcWRsLnNpdGU=" // katze
|
||||
"aHR0cHM6Ly90cml0b24uc3F1aWQud3Rm", // triton
|
||||
"aHR0cHM6Ly90aWRhbC1hcGkuYmluaW11bS5vcmc=", // binimum
|
||||
"aHR0cHM6Ly90aWRhbC5raW5vcGx1cy5vbmxpbmU=", // kinoplus
|
||||
"aHR0cHM6Ly9oaWZpLXR3by5zcG90aXNhdmVyLm5ldA==", // spoti-2
|
||||
"aHR0cHM6Ly9oaWZpLW9uZS5zcG90aXNhdmVyLm5ldA==", // spoti-1
|
||||
"aHR0cHM6Ly93b2xmLnFxZGwuc2l0ZQ==", // wolf
|
||||
"aHR0cDovL2h1bmQucXFkbC5zaXRl", // hund
|
||||
"aHR0cHM6Ly9rYXR6ZS5xcWRsLnNpdGU=", // katze
|
||||
"aHR0cHM6Ly92b2dlbC5xcWRsLnNpdGU=", // vogel
|
||||
"aHR0cHM6Ly9tYXVzLnFxZGwuc2l0ZQ==" // maus
|
||||
};
|
||||
|
||||
return encodedUrls
|
||||
@@ -108,6 +112,42 @@ builder.Services.Configure<SquidWTFSettings>(
|
||||
builder.Configuration.GetSection("SquidWTF"));
|
||||
builder.Services.Configure<RedisSettings>(
|
||||
builder.Configuration.GetSection("Redis"));
|
||||
// Configure Spotify Import settings with custom playlist parsing from env var
|
||||
builder.Services.Configure<SpotifyImportSettings>(options =>
|
||||
{
|
||||
builder.Configuration.GetSection("SpotifyImport").Bind(options);
|
||||
|
||||
// Parse SPOTIFY_IMPORT_PLAYLIST_IDS env var (comma-separated) into PlaylistIds array
|
||||
var playlistIdsEnv = builder.Configuration.GetValue<string>("SpotifyImport:PlaylistIds");
|
||||
if (!string.IsNullOrWhiteSpace(playlistIdsEnv) && options.PlaylistIds.Count == 0)
|
||||
{
|
||||
options.PlaylistIds = playlistIdsEnv
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(id => id.Trim())
|
||||
.Where(id => !string.IsNullOrEmpty(id))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// Parse SPOTIFY_IMPORT_PLAYLIST_NAMES env var (comma-separated) into PlaylistNames array
|
||||
var playlistNamesEnv = builder.Configuration.GetValue<string>("SpotifyImport:PlaylistNames");
|
||||
if (!string.IsNullOrWhiteSpace(playlistNamesEnv) && options.PlaylistNames.Count == 0)
|
||||
{
|
||||
options.PlaylistNames = playlistNamesEnv
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(name => name.Trim())
|
||||
.Where(name => !string.IsNullOrEmpty(name))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// Log configuration at startup
|
||||
Console.WriteLine($"Spotify Import: Enabled={options.Enabled}, SyncHour={options.SyncStartHour}:{options.SyncStartMinute:D2}, WindowHours={options.SyncWindowHours}");
|
||||
Console.WriteLine($"Spotify Import Playlist IDs: {options.PlaylistIds.Count} configured");
|
||||
for (int i = 0; i < options.PlaylistIds.Count; i++)
|
||||
{
|
||||
var name = i < options.PlaylistNames.Count ? options.PlaylistNames[i] : options.PlaylistIds[i];
|
||||
Console.WriteLine($" - {name} (ID: {options.PlaylistIds[i]})");
|
||||
}
|
||||
});
|
||||
|
||||
// Get shared settings from the active backend config
|
||||
MusicService musicService;
|
||||
@@ -138,6 +178,7 @@ if (backendType == BackendType.Jellyfin)
|
||||
builder.Services.AddSingleton<JellyfinModelMapper>();
|
||||
builder.Services.AddScoped<JellyfinProxyService>();
|
||||
builder.Services.AddScoped<JellyfinAuthFilter>();
|
||||
builder.Services.AddScoped<allstarr.Filters.ApiKeyAuthFilter>();
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -229,6 +270,12 @@ builder.Services.AddHostedService<StartupValidationOrchestrator>();
|
||||
// Register cache cleanup service (only runs when StorageMode is Cache)
|
||||
builder.Services.AddHostedService<CacheCleanupService>();
|
||||
|
||||
// Register Spotify missing tracks fetcher (only runs when SpotifyImport is enabled)
|
||||
builder.Services.AddHostedService<allstarr.Services.Spotify.SpotifyMissingTracksFetcher>();
|
||||
|
||||
// Register Spotify track matching service (pre-matches tracks with rate limiting)
|
||||
builder.Services.AddHostedService<allstarr.Services.Spotify.SpotifyTrackMatchingService>();
|
||||
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddDefaultPolicy(policy =>
|
||||
@@ -248,6 +295,15 @@ app.UseExceptionHandler(_ => { }); // Global exception handler
|
||||
// Enable response compression EARLY in the pipeline
|
||||
app.UseResponseCompression();
|
||||
|
||||
// Enable WebSocket support
|
||||
app.UseWebSockets(new WebSocketOptions
|
||||
{
|
||||
KeepAliveInterval = TimeSpan.FromSeconds(120)
|
||||
});
|
||||
|
||||
// Add WebSocket proxy middleware (BEFORE routing)
|
||||
app.UseMiddleware<WebSocketProxyMiddleware>();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
|
||||
@@ -103,8 +103,9 @@ public class JellyfinProxyService
|
||||
/// <summary>
|
||||
/// Sends a GET request to the Jellyfin server.
|
||||
/// If endpoint already contains query parameters, they will be preserved and merged with queryParams.
|
||||
/// Returns the response body and HTTP status code.
|
||||
/// </summary>
|
||||
public async Task<JsonDocument?> GetJsonAsync(string endpoint, Dictionary<string, string>? queryParams = null, IHeaderDictionary? clientHeaders = null)
|
||||
public async Task<(JsonDocument? Body, int StatusCode)> GetJsonAsync(string endpoint, Dictionary<string, string>? queryParams = null, IHeaderDictionary? clientHeaders = null)
|
||||
{
|
||||
// If endpoint contains query string, parse and merge with queryParams
|
||||
if (endpoint.Contains('?'))
|
||||
@@ -141,12 +142,21 @@ public class JellyfinProxyService
|
||||
return await GetJsonAsyncInternal(finalUrl, clientHeaders);
|
||||
}
|
||||
|
||||
private async Task<JsonDocument?> GetJsonAsyncInternal(string url, IHeaderDictionary? clientHeaders)
|
||||
private async Task<(JsonDocument? Body, int StatusCode)> GetJsonAsyncInternal(string url, IHeaderDictionary? clientHeaders)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
|
||||
bool authHeaderAdded = false;
|
||||
|
||||
// Check if this is a browser request for static assets (favicon, etc.)
|
||||
bool isBrowserStaticRequest = url.Contains("/favicon.ico", StringComparison.OrdinalIgnoreCase) ||
|
||||
url.Contains("/web/", StringComparison.OrdinalIgnoreCase) ||
|
||||
(clientHeaders?.Any(h => h.Key.Equals("User-Agent", StringComparison.OrdinalIgnoreCase) &&
|
||||
h.Value.ToString().Contains("Mozilla", StringComparison.OrdinalIgnoreCase)) == true &&
|
||||
clientHeaders?.Any(h => h.Key.Equals("sec-fetch-dest", StringComparison.OrdinalIgnoreCase) &&
|
||||
(h.Value.ToString().Contains("image", StringComparison.OrdinalIgnoreCase) ||
|
||||
h.Value.ToString().Contains("document", StringComparison.OrdinalIgnoreCase))) == true);
|
||||
|
||||
// Forward authentication headers from client if provided
|
||||
if (clientHeaders != null && clientHeaders.Count > 0)
|
||||
{
|
||||
@@ -194,35 +204,31 @@ public class JellyfinProxyService
|
||||
}
|
||||
}
|
||||
|
||||
if (!authHeaderAdded)
|
||||
// Only log warnings for non-browser static requests
|
||||
if (!authHeaderAdded && !isBrowserStaticRequest)
|
||||
{
|
||||
_logger.LogWarning("✗ No auth header found. Available headers: {Headers}",
|
||||
string.Join(", ", clientHeaders.Select(h => $"{h.Key}={h.Value}")));
|
||||
}
|
||||
}
|
||||
else
|
||||
else if (!isBrowserStaticRequest)
|
||||
{
|
||||
_logger.LogWarning("✗ No client headers provided for {Url}", url);
|
||||
}
|
||||
|
||||
// Use API key if no valid client auth was found
|
||||
if (!authHeaderAdded)
|
||||
// DO NOT use server API key as fallback - let Jellyfin handle unauthenticated requests
|
||||
// If client doesn't provide auth, they get what they deserve (401 from Jellyfin)
|
||||
if (!authHeaderAdded && !isBrowserStaticRequest)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_settings.ApiKey))
|
||||
{
|
||||
request.Headers.Add("Authorization", GetAuthorizationHeader());
|
||||
_logger.LogInformation("→ Using API key for {Url}", url);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("✗ No authentication available for {Url} - request will fail", url);
|
||||
}
|
||||
_logger.LogInformation("No client auth provided for {Url} - forwarding without auth", url);
|
||||
}
|
||||
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
var statusCode = (int)response.StatusCode;
|
||||
|
||||
// Always parse the response, even for errors
|
||||
// The caller needs to see 401s so the client can re-authenticate
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
@@ -233,24 +239,24 @@ public class JellyfinProxyService
|
||||
{
|
||||
_logger.LogWarning("Jellyfin returned 401 Unauthorized for {Url} - passing through to client", url);
|
||||
}
|
||||
else
|
||||
else if (!isBrowserStaticRequest) // Don't log 404s for browser static requests
|
||||
{
|
||||
_logger.LogWarning("Jellyfin request failed: {StatusCode} for {Url}", response.StatusCode, url);
|
||||
}
|
||||
|
||||
// Return null so caller knows request failed
|
||||
// TODO: We should return the status code too so caller can pass it through
|
||||
return null;
|
||||
// Return null body with the actual status code
|
||||
return (null, statusCode);
|
||||
}
|
||||
|
||||
return JsonDocument.Parse(content);
|
||||
return (JsonDocument.Parse(content), statusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a POST request to the Jellyfin server with JSON body.
|
||||
/// Forwards client headers for authentication passthrough.
|
||||
/// Returns the response body and HTTP status code.
|
||||
/// </summary>
|
||||
public async Task<JsonDocument?> PostJsonAsync(string endpoint, string body, IHeaderDictionary clientHeaders)
|
||||
public async Task<(JsonDocument? Body, int StatusCode)> PostJsonAsync(string endpoint, string body, IHeaderDictionary clientHeaders)
|
||||
{
|
||||
var url = BuildUrl(endpoint, null);
|
||||
|
||||
@@ -297,8 +303,10 @@ public class JellyfinProxyService
|
||||
{
|
||||
if (header.Key.Equals("X-Emby-Authorization", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", header.Value.ToString());
|
||||
var headerValue = header.Value.ToString();
|
||||
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
|
||||
authHeaderAdded = true;
|
||||
_logger.LogDebug("Forwarded X-Emby-Authorization from client");
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -309,21 +317,34 @@ public class JellyfinProxyService
|
||||
{
|
||||
if (header.Key.Equals("Authorization", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("Authorization", header.Value.ToString());
|
||||
var headerValue = header.Value.ToString();
|
||||
|
||||
// Check if it's MediaBrowser/Jellyfin format
|
||||
if (headerValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase) ||
|
||||
headerValue.Contains("Client=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Forward as X-Emby-Authorization
|
||||
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
|
||||
_logger.LogDebug("Converted Authorization to X-Emby-Authorization");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Standard Bearer token
|
||||
request.Headers.TryAddWithoutValidation("Authorization", headerValue);
|
||||
_logger.LogDebug("Forwarded Authorization header");
|
||||
}
|
||||
authHeaderAdded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For login requests without auth headers, provide a minimal client auth header
|
||||
// DO NOT use server credentials as fallback
|
||||
// Exception: For auth endpoints, client provides their own credentials in the body
|
||||
// For all other endpoints, if client doesn't provide auth, let Jellyfin reject it
|
||||
if (!authHeaderAdded)
|
||||
{
|
||||
var clientAuthHeader = $"MediaBrowser Client=\"{_settings.ClientName}\", " +
|
||||
$"Device=\"{_settings.DeviceName}\", " +
|
||||
$"DeviceId=\"{_settings.DeviceId}\", " +
|
||||
$"Version=\"{_settings.ClientVersion}\"";
|
||||
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", clientAuthHeader);
|
||||
_logger.LogInformation("No client auth provided for POST {Url} - forwarding without auth", url);
|
||||
}
|
||||
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
@@ -346,18 +367,20 @@ public class JellyfinProxyService
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
var statusCode = (int)response.StatusCode;
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogWarning("Jellyfin POST request failed: {StatusCode} for {Url}. Response: {Response}",
|
||||
response.StatusCode, url, errorContent);
|
||||
return null;
|
||||
return (null, statusCode);
|
||||
}
|
||||
|
||||
// Handle 204 No Content responses (e.g., /sessions/playing, /sessions/playing/progress)
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
|
||||
{
|
||||
return null;
|
||||
return (null, statusCode);
|
||||
}
|
||||
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
@@ -365,10 +388,10 @@ public class JellyfinProxyService
|
||||
// Handle empty responses
|
||||
if (string.IsNullOrWhiteSpace(responseContent))
|
||||
{
|
||||
return null;
|
||||
return (null, statusCode);
|
||||
}
|
||||
|
||||
return JsonDocument.Parse(responseContent);
|
||||
return (JsonDocument.Parse(responseContent), statusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -390,6 +413,98 @@ public class JellyfinProxyService
|
||||
return (body, contentType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a DELETE request to the Jellyfin server.
|
||||
/// Forwards client headers for authentication passthrough.
|
||||
/// Returns the response body and HTTP status code.
|
||||
/// </summary>
|
||||
public async Task<(JsonDocument? Body, int StatusCode)> DeleteAsync(string endpoint, IHeaderDictionary clientHeaders)
|
||||
{
|
||||
var url = BuildUrl(endpoint, null);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Delete, url);
|
||||
|
||||
bool authHeaderAdded = false;
|
||||
|
||||
// Forward authentication headers from client (case-insensitive)
|
||||
foreach (var header in clientHeaders)
|
||||
{
|
||||
if (header.Key.Equals("X-Emby-Authorization", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var headerValue = header.Value.ToString();
|
||||
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
|
||||
authHeaderAdded = true;
|
||||
_logger.LogDebug("Forwarded X-Emby-Authorization from client");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!authHeaderAdded)
|
||||
{
|
||||
foreach (var header in clientHeaders)
|
||||
{
|
||||
if (header.Key.Equals("Authorization", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var headerValue = header.Value.ToString();
|
||||
|
||||
// Check if it's MediaBrowser/Jellyfin format
|
||||
if (headerValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase) ||
|
||||
headerValue.Contains("Client=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Forward as X-Emby-Authorization
|
||||
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
|
||||
_logger.LogDebug("Converted Authorization to X-Emby-Authorization");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Standard Bearer token
|
||||
request.Headers.TryAddWithoutValidation("Authorization", headerValue);
|
||||
_logger.LogDebug("Forwarded Authorization header");
|
||||
}
|
||||
authHeaderAdded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!authHeaderAdded)
|
||||
{
|
||||
_logger.LogInformation("No client auth provided for DELETE {Url} - forwarding without auth", url);
|
||||
}
|
||||
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
|
||||
_logger.LogInformation("DELETE to Jellyfin: {Url}", url);
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
var statusCode = (int)response.StatusCode;
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogWarning("Jellyfin DELETE request failed: {StatusCode} for {Url}. Response: {Response}",
|
||||
response.StatusCode, url, errorContent);
|
||||
return (null, statusCode);
|
||||
}
|
||||
|
||||
// Handle 204 No Content responses
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
|
||||
{
|
||||
return (null, statusCode);
|
||||
}
|
||||
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// Handle empty responses
|
||||
if (string.IsNullOrWhiteSpace(responseContent))
|
||||
{
|
||||
return (null, statusCode);
|
||||
}
|
||||
|
||||
return (JsonDocument.Parse(responseContent), statusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Safely sends a GET request to the Jellyfin server, returning null on failure.
|
||||
/// </summary>
|
||||
@@ -413,7 +528,7 @@ public class JellyfinProxyService
|
||||
/// Searches for items in Jellyfin.
|
||||
/// Uses configured or auto-detected LibraryId to filter search to music library only.
|
||||
/// </summary>
|
||||
public async Task<JsonDocument?> SearchAsync(
|
||||
public async Task<(JsonDocument? Body, int StatusCode)> SearchAsync(
|
||||
string searchTerm,
|
||||
string[]? includeItemTypes = null,
|
||||
int limit = 20,
|
||||
@@ -451,7 +566,7 @@ public class JellyfinProxyService
|
||||
/// <summary>
|
||||
/// Gets items from a specific parent (album, artist, playlist).
|
||||
/// </summary>
|
||||
public async Task<JsonDocument?> GetItemsAsync(
|
||||
public async Task<(JsonDocument? Body, int StatusCode)> GetItemsAsync(
|
||||
string? parentId = null,
|
||||
string[]? includeItemTypes = null,
|
||||
string? sortBy = null,
|
||||
@@ -507,7 +622,7 @@ public class JellyfinProxyService
|
||||
/// <summary>
|
||||
/// Gets a single item by ID.
|
||||
/// </summary>
|
||||
public async Task<JsonDocument?> GetItemAsync(string itemId, IHeaderDictionary? clientHeaders = null)
|
||||
public async Task<(JsonDocument? Body, int StatusCode)> GetItemAsync(string itemId, IHeaderDictionary? clientHeaders = null)
|
||||
{
|
||||
var queryParams = new Dictionary<string, string>();
|
||||
|
||||
@@ -522,7 +637,7 @@ public class JellyfinProxyService
|
||||
/// <summary>
|
||||
/// Gets artists from the library.
|
||||
/// </summary>
|
||||
public async Task<JsonDocument?> GetArtistsAsync(
|
||||
public async Task<(JsonDocument? Body, int StatusCode)> GetArtistsAsync(
|
||||
string? searchTerm = null,
|
||||
int? limit = null,
|
||||
int? startIndex = null,
|
||||
@@ -559,7 +674,7 @@ public class JellyfinProxyService
|
||||
/// <summary>
|
||||
/// Gets an artist by name or ID.
|
||||
/// </summary>
|
||||
public async Task<JsonDocument?> GetArtistAsync(string artistIdOrName, IHeaderDictionary? clientHeaders = null)
|
||||
public async Task<(JsonDocument? Body, int StatusCode)> GetArtistAsync(string artistIdOrName, IHeaderDictionary? clientHeaders = null)
|
||||
{
|
||||
var queryParams = new Dictionary<string, string>();
|
||||
|
||||
@@ -720,8 +835,8 @@ public class JellyfinProxyService
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await GetJsonAsync("System/Info/Public");
|
||||
if (result == null)
|
||||
var (result, statusCode) = await GetJsonAsync("System/Info/Public");
|
||||
if (result == null || statusCode != 200)
|
||||
{
|
||||
return (false, null, null);
|
||||
}
|
||||
@@ -755,7 +870,7 @@ public class JellyfinProxyService
|
||||
queryParams["userId"] = _settings.UserId;
|
||||
}
|
||||
|
||||
var result = await GetJsonAsync("Library/MediaFolders", queryParams);
|
||||
var (result, statusCode) = await GetJsonAsync("Library/MediaFolders", queryParams);
|
||||
if (result == null)
|
||||
{
|
||||
return null;
|
||||
|
||||
@@ -289,6 +289,24 @@ public class JellyfinResponseBuilder
|
||||
var providerIds = (Dictionary<string, string>)item["ProviderIds"]!;
|
||||
providerIds["ISRC"] = song.Isrc;
|
||||
}
|
||||
|
||||
// Add MediaSources with bitrate for external tracks
|
||||
item["MediaSources"] = new[]
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["Id"] = song.Id,
|
||||
["Type"] = "Default",
|
||||
["Container"] = "flac",
|
||||
["Size"] = (song.Duration ?? 180) * 1337 * 128, // Approximate file size
|
||||
["Bitrate"] = 1337000, // 1337 kbps in bps
|
||||
["Path"] = $"/music/{song.Artist}/{song.Album}/{song.Title}.flac",
|
||||
["Protocol"] = "File",
|
||||
["SupportsDirectStream"] = true,
|
||||
["SupportsTranscoding"] = true,
|
||||
["SupportsDirectPlay"] = true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(song.Genre))
|
||||
@@ -304,11 +322,11 @@ public class JellyfinResponseBuilder
|
||||
/// </summary>
|
||||
public Dictionary<string, object?> ConvertAlbumToJellyfinItem(Album album)
|
||||
{
|
||||
// Add " - SW" suffix to external album names
|
||||
// Add " - S" suffix to external album names (S = SquidWTF)
|
||||
var albumName = album.Title;
|
||||
if (!album.IsLocal)
|
||||
{
|
||||
albumName = $"{album.Title} - SW";
|
||||
albumName = $"{album.Title} - S";
|
||||
}
|
||||
|
||||
var item = new Dictionary<string, object?>
|
||||
@@ -371,11 +389,11 @@ public class JellyfinResponseBuilder
|
||||
/// </summary>
|
||||
public Dictionary<string, object?> ConvertArtistToJellyfinItem(Artist artist)
|
||||
{
|
||||
// Add " - SW" suffix to external artist names
|
||||
// Add " - S" suffix to external artist names (S = SquidWTF)
|
||||
var artistName = artist.Name;
|
||||
if (!artist.IsLocal)
|
||||
{
|
||||
artistName = $"{artist.Name} - SW";
|
||||
artistName = $"{artist.Name} - S";
|
||||
}
|
||||
|
||||
var item = new Dictionary<string, object?>
|
||||
|
||||
@@ -42,25 +42,100 @@ public class LrclibService
|
||||
|
||||
try
|
||||
{
|
||||
var url = $"{BaseUrl}/get?" +
|
||||
$"track_name={Uri.EscapeDataString(trackName)}&" +
|
||||
$"artist_name={Uri.EscapeDataString(artistName)}&" +
|
||||
$"album_name={Uri.EscapeDataString(albumName)}&" +
|
||||
$"duration={durationSeconds}";
|
||||
// First try search API for fuzzy matching (more forgiving)
|
||||
var searchUrl = $"{BaseUrl}/search?" +
|
||||
$"track_name={Uri.EscapeDataString(trackName)}&" +
|
||||
$"artist_name={Uri.EscapeDataString(artistName)}";
|
||||
|
||||
_logger.LogDebug("Fetching lyrics from LRCLIB: {Url}", url);
|
||||
_logger.LogInformation("Searching LRCLIB: {Url}", searchUrl);
|
||||
|
||||
var response = await _httpClient.GetAsync(url);
|
||||
var searchResponse = await _httpClient.GetAsync(searchUrl);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
if (searchResponse.IsSuccessStatusCode)
|
||||
{
|
||||
var searchJson = await searchResponse.Content.ReadAsStringAsync();
|
||||
var searchResults = JsonSerializer.Deserialize<List<LrclibResponse>>(searchJson, JsonOptions);
|
||||
|
||||
if (searchResults != null && searchResults.Count > 0)
|
||||
{
|
||||
// Find best match by comparing track name, artist, and duration
|
||||
LrclibResponse? bestMatch = null;
|
||||
double bestScore = 0;
|
||||
|
||||
foreach (var result in searchResults)
|
||||
{
|
||||
// Calculate similarity scores
|
||||
var trackScore = CalculateSimilarity(trackName, result.TrackName ?? "");
|
||||
var artistScore = CalculateSimilarity(artistName, result.ArtistName ?? "");
|
||||
|
||||
// Duration match (within 5 seconds is good)
|
||||
var durationDiff = Math.Abs(result.Duration - durationSeconds);
|
||||
var durationScore = durationDiff <= 5 ? 100.0 : Math.Max(0, 100 - (durationDiff * 2));
|
||||
|
||||
// Bonus for having synced lyrics (prefer synced over plain)
|
||||
var syncedBonus = !string.IsNullOrEmpty(result.SyncedLyrics) ? 20.0 : 0.0;
|
||||
|
||||
// Weighted score: track name most important, then artist, then duration, plus synced bonus
|
||||
var totalScore = (trackScore * 0.5) + (artistScore * 0.3) + (durationScore * 0.2) + syncedBonus;
|
||||
|
||||
_logger.LogDebug("Candidate: {Track} by {Artist} - Score: {Score:F1} (track:{TrackScore:F1}, artist:{ArtistScore:F1}, duration:{DurationScore:F1}, synced:{Synced})",
|
||||
result.TrackName, result.ArtistName, totalScore, trackScore, artistScore, durationScore, !string.IsNullOrEmpty(result.SyncedLyrics));
|
||||
|
||||
if (totalScore > bestScore)
|
||||
{
|
||||
bestScore = totalScore;
|
||||
bestMatch = result;
|
||||
}
|
||||
}
|
||||
|
||||
// Only use result if score is good enough (>60%)
|
||||
if (bestMatch != null && bestScore >= 60)
|
||||
{
|
||||
_logger.LogInformation("✓ Found lyrics via search for {Artist} - {Track} (ID: {Id}, score: {Score:F1}, synced: {HasSynced})",
|
||||
artistName, trackName, bestMatch.Id, bestScore, !string.IsNullOrEmpty(bestMatch.SyncedLyrics));
|
||||
|
||||
var result = new LyricsInfo
|
||||
{
|
||||
Id = bestMatch.Id,
|
||||
TrackName = bestMatch.TrackName ?? trackName,
|
||||
ArtistName = bestMatch.ArtistName ?? artistName,
|
||||
AlbumName = bestMatch.AlbumName ?? albumName,
|
||||
Duration = (int)Math.Round(bestMatch.Duration),
|
||||
Instrumental = bestMatch.Instrumental,
|
||||
PlainLyrics = bestMatch.PlainLyrics,
|
||||
SyncedLyrics = bestMatch.SyncedLyrics
|
||||
};
|
||||
|
||||
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(result, JsonOptions), TimeSpan.FromDays(30));
|
||||
return result;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Best match score too low ({Score:F1}), trying exact match", bestScore);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to exact match API if search didn't find good results
|
||||
var exactUrl = $"{BaseUrl}/get?" +
|
||||
$"track_name={Uri.EscapeDataString(trackName)}&" +
|
||||
$"artist_name={Uri.EscapeDataString(artistName)}&" +
|
||||
$"album_name={Uri.EscapeDataString(albumName)}&" +
|
||||
$"duration={durationSeconds}";
|
||||
|
||||
_logger.LogDebug("Trying exact match from LRCLIB: {Url}", exactUrl);
|
||||
|
||||
var exactResponse = await _httpClient.GetAsync(exactUrl);
|
||||
|
||||
if (exactResponse.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
_logger.LogDebug("Lyrics not found for {Artist} - {Track}", artistName, trackName);
|
||||
return null;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
exactResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var json = await exactResponse.Content.ReadAsStringAsync();
|
||||
var lyrics = JsonSerializer.Deserialize<LrclibResponse>(json, JsonOptions);
|
||||
|
||||
if (lyrics == null)
|
||||
@@ -68,7 +143,7 @@ public class LrclibService
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = new LyricsInfo
|
||||
var exactResult = new LyricsInfo
|
||||
{
|
||||
Id = lyrics.Id,
|
||||
TrackName = lyrics.TrackName ?? trackName,
|
||||
@@ -80,11 +155,11 @@ public class LrclibService
|
||||
SyncedLyrics = lyrics.SyncedLyrics
|
||||
};
|
||||
|
||||
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(result, JsonOptions), TimeSpan.FromDays(30));
|
||||
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(exactResult, JsonOptions), TimeSpan.FromDays(30));
|
||||
|
||||
_logger.LogInformation("Retrieved lyrics for {Artist} - {Track} (ID: {Id})", artistName, trackName, lyrics.Id);
|
||||
_logger.LogInformation("Retrieved lyrics via exact match for {Artist} - {Track} (ID: {Id})", artistName, trackName, lyrics.Id);
|
||||
|
||||
return result;
|
||||
return exactResult;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
@@ -98,6 +173,28 @@ public class LrclibService
|
||||
}
|
||||
}
|
||||
|
||||
private static double CalculateSimilarity(string str1, string str2)
|
||||
{
|
||||
if (string.IsNullOrEmpty(str1) || string.IsNullOrEmpty(str2))
|
||||
return 0;
|
||||
|
||||
str1 = str1.ToLowerInvariant();
|
||||
str2 = str2.ToLowerInvariant();
|
||||
|
||||
if (str1 == str2)
|
||||
return 100;
|
||||
|
||||
// Simple token-based matching
|
||||
var tokens1 = str1.Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
var tokens2 = str2.Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
if (tokens1.Length == 0 || tokens2.Length == 0)
|
||||
return 0;
|
||||
|
||||
var matchedTokens = tokens1.Count(t1 => tokens2.Any(t2 => t2.Contains(t1) || t1.Contains(t2)));
|
||||
return (matchedTokens * 100.0) / Math.Max(tokens1.Length, tokens2.Length);
|
||||
}
|
||||
|
||||
public async Task<LyricsInfo?> GetLyricsCachedAsync(string trackName, string artistName, string albumName, int durationSeconds)
|
||||
{
|
||||
try
|
||||
|
||||
519
allstarr/Services/Spotify/SpotifyMissingTracksFetcher.cs
Normal file
519
allstarr/Services/Spotify/SpotifyMissingTracksFetcher.cs
Normal file
@@ -0,0 +1,519 @@
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Models.Spotify;
|
||||
using allstarr.Services.Common;
|
||||
using allstarr.Services.Jellyfin;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace allstarr.Services.Spotify;
|
||||
|
||||
public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
{
|
||||
private readonly IOptions<SpotifyImportSettings> _spotifySettings;
|
||||
private readonly IOptions<JellyfinSettings> _jellyfinSettings;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly ILogger<SpotifyMissingTracksFetcher> _logger;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private bool _hasRunOnce = false;
|
||||
private Dictionary<string, string> _playlistIdToName = new();
|
||||
private const string CacheDirectory = "/app/cache/spotify";
|
||||
|
||||
public SpotifyMissingTracksFetcher(
|
||||
IOptions<SpotifyImportSettings> spotifySettings,
|
||||
IOptions<JellyfinSettings> jellyfinSettings,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
RedisCacheService cache,
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<SpotifyMissingTracksFetcher> logger)
|
||||
{
|
||||
_spotifySettings = spotifySettings;
|
||||
_jellyfinSettings = jellyfinSettings;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_cache = cache;
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("========================================");
|
||||
_logger.LogInformation("SpotifyMissingTracksFetcher: Starting up...");
|
||||
|
||||
// Ensure cache directory exists
|
||||
Directory.CreateDirectory(CacheDirectory);
|
||||
|
||||
if (!_spotifySettings.Value.Enabled)
|
||||
{
|
||||
_logger.LogInformation("Spotify playlist injection is DISABLED");
|
||||
_logger.LogInformation("========================================");
|
||||
return;
|
||||
}
|
||||
|
||||
var jellyfinUrl = _jellyfinSettings.Value.Url;
|
||||
var apiKey = _jellyfinSettings.Value.ApiKey;
|
||||
|
||||
if (string.IsNullOrEmpty(jellyfinUrl) || string.IsNullOrEmpty(apiKey))
|
||||
{
|
||||
_logger.LogWarning("Jellyfin URL or API key not configured, Spotify playlist injection disabled");
|
||||
_logger.LogInformation("========================================");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Spotify Import ENABLED");
|
||||
_logger.LogInformation("Configured Playlist IDs: {Count}", _spotifySettings.Value.PlaylistIds.Count);
|
||||
|
||||
// Fetch playlist names from Jellyfin
|
||||
await LoadPlaylistNamesAsync();
|
||||
|
||||
foreach (var kvp in _playlistIdToName)
|
||||
{
|
||||
_logger.LogInformation(" - {Name} (ID: {Id})", kvp.Value, kvp.Key);
|
||||
}
|
||||
_logger.LogInformation("========================================");
|
||||
|
||||
// Check if we should run on startup
|
||||
if (!_hasRunOnce)
|
||||
{
|
||||
var shouldRun = await ShouldRunOnStartupAsync();
|
||||
if (shouldRun)
|
||||
{
|
||||
_logger.LogInformation("Running initial fetch on startup");
|
||||
try
|
||||
{
|
||||
await FetchMissingTracksAsync(stoppingToken, bypassSyncWindowCheck: true);
|
||||
_hasRunOnce = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error during startup fetch");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Skipping startup fetch - existing cache is still current");
|
||||
_hasRunOnce = true;
|
||||
}
|
||||
}
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await FetchMissingTracksAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching Spotify missing tracks");
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadPlaylistNamesAsync()
|
||||
{
|
||||
_playlistIdToName.Clear();
|
||||
|
||||
// Use configured playlist names instead of fetching from API
|
||||
for (int i = 0; i < _spotifySettings.Value.PlaylistIds.Count; i++)
|
||||
{
|
||||
var playlistId = _spotifySettings.Value.PlaylistIds[i];
|
||||
var playlistName = i < _spotifySettings.Value.PlaylistNames.Count
|
||||
? _spotifySettings.Value.PlaylistNames[i]
|
||||
: playlistId; // Fallback to ID if name not configured
|
||||
|
||||
_playlistIdToName[playlistId] = playlistName;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> ShouldRunOnStartupAsync()
|
||||
{
|
||||
_logger.LogInformation("=== STARTUP CACHE CHECK ===");
|
||||
|
||||
var settings = _spotifySettings.Value;
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Calculate when today's sync window ends
|
||||
var todaySync = now.Date
|
||||
.AddHours(settings.SyncStartHour)
|
||||
.AddMinutes(settings.SyncStartMinute);
|
||||
var todaySyncEnd = todaySync.AddHours(settings.SyncWindowHours);
|
||||
|
||||
// If we haven't reached today's sync window end yet, check if we have yesterday's file
|
||||
if (now < todaySyncEnd)
|
||||
{
|
||||
_logger.LogInformation("Today's sync window hasn't ended yet (ends at {End})", todaySyncEnd);
|
||||
_logger.LogInformation("Checking if we have a recent cache file...");
|
||||
|
||||
// Check if we have any cache (file or Redis) for all playlists
|
||||
var allPlaylistsHaveCache = true;
|
||||
|
||||
foreach (var playlistName in _playlistIdToName.Values)
|
||||
{
|
||||
var filePath = GetCacheFilePath(playlistName);
|
||||
var cacheKey = $"spotify:missing:{playlistName}";
|
||||
|
||||
// Check file cache
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
var fileAge = DateTime.UtcNow - File.GetLastWriteTimeUtc(filePath);
|
||||
_logger.LogInformation(" {Playlist}: Found file cache (age: {Age:F1}h)", playlistName, fileAge.TotalHours);
|
||||
|
||||
// Load into Redis if not already there
|
||||
if (!await _cache.ExistsAsync(cacheKey))
|
||||
{
|
||||
await LoadFromFileCache(playlistName);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check Redis cache
|
||||
if (await _cache.ExistsAsync(cacheKey))
|
||||
{
|
||||
_logger.LogInformation(" {Playlist}: Found in Redis cache", playlistName);
|
||||
continue;
|
||||
}
|
||||
|
||||
// No cache found for this playlist
|
||||
_logger.LogInformation(" {Playlist}: No cache found", playlistName);
|
||||
allPlaylistsHaveCache = false;
|
||||
}
|
||||
|
||||
if (allPlaylistsHaveCache)
|
||||
{
|
||||
_logger.LogInformation("=== ALL PLAYLISTS HAVE CACHE - SKIPPING STARTUP FETCH ===");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Today's sync window has passed (ended at {End})", todaySyncEnd);
|
||||
_logger.LogInformation("Will search for new files");
|
||||
}
|
||||
|
||||
_logger.LogInformation("=== WILL FETCH ON STARTUP ===");
|
||||
return true;
|
||||
}
|
||||
|
||||
private string GetCacheFilePath(string playlistName)
|
||||
{
|
||||
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
|
||||
return Path.Combine(CacheDirectory, $"{safeName}_missing.json");
|
||||
}
|
||||
|
||||
private async Task LoadFromFileCache(string playlistName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var filePath = GetCacheFilePath(playlistName);
|
||||
if (!File.Exists(filePath))
|
||||
return;
|
||||
|
||||
var json = await File.ReadAllTextAsync(filePath);
|
||||
var tracks = JsonSerializer.Deserialize<List<MissingTrack>>(json);
|
||||
|
||||
if (tracks != null && tracks.Count > 0)
|
||||
{
|
||||
var cacheKey = $"spotify:missing:{playlistName}";
|
||||
var fileAge = DateTime.UtcNow - File.GetLastWriteTimeUtc(filePath);
|
||||
|
||||
// No expiration - cache persists until next Jellyfin job generates new file
|
||||
await _cache.SetAsync(cacheKey, tracks, TimeSpan.FromDays(365));
|
||||
_logger.LogInformation("Loaded {Count} tracks from file cache for {Playlist} (age: {Age:F1}h, no expiration)",
|
||||
tracks.Count, playlistName, fileAge.TotalHours);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load file cache for {Playlist}", playlistName);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveToFileCache(string playlistName, List<MissingTrack> tracks)
|
||||
{
|
||||
try
|
||||
{
|
||||
var filePath = GetCacheFilePath(playlistName);
|
||||
var json = JsonSerializer.Serialize(tracks, new JsonSerializerOptions { WriteIndented = true });
|
||||
await File.WriteAllTextAsync(filePath, json);
|
||||
_logger.LogInformation("Saved {Count} tracks to file cache for {Playlist}",
|
||||
tracks.Count, playlistName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save file cache for {Playlist}", playlistName);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task FetchMissingTracksAsync(CancellationToken cancellationToken, bool bypassSyncWindowCheck = false)
|
||||
{
|
||||
var settings = _spotifySettings.Value;
|
||||
var now = DateTime.UtcNow;
|
||||
var syncStart = now.Date
|
||||
.AddHours(settings.SyncStartHour)
|
||||
.AddMinutes(settings.SyncStartMinute);
|
||||
var syncEnd = syncStart.AddHours(settings.SyncWindowHours);
|
||||
|
||||
// Only run after the sync window has passed (unless bypassing for startup)
|
||||
if (!bypassSyncWindowCheck && now < syncEnd)
|
||||
{
|
||||
_logger.LogInformation("Skipping fetch - sync window not passed yet (now: {Now}, window ends: {End})",
|
||||
now, syncEnd);
|
||||
return;
|
||||
}
|
||||
|
||||
if (bypassSyncWindowCheck)
|
||||
{
|
||||
_logger.LogInformation("=== FETCHING MISSING TRACKS (STARTUP MODE) ===");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("=== FETCHING MISSING TRACKS (SYNC WINDOW PASSED) ===");
|
||||
}
|
||||
|
||||
_logger.LogInformation("Processing {Count} playlists", _playlistIdToName.Count);
|
||||
foreach (var kvp in _playlistIdToName)
|
||||
{
|
||||
_logger.LogInformation("Fetching playlist: {Name}", kvp.Value);
|
||||
await FetchPlaylistMissingTracksAsync(kvp.Value, cancellationToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation("=== FINISHED FETCHING MISSING TRACKS ===");
|
||||
}
|
||||
|
||||
private async Task FetchPlaylistMissingTracksAsync(
|
||||
string playlistName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var cacheKey = $"spotify:missing:{playlistName}";
|
||||
|
||||
// Check if we have existing cache and when it was last updated
|
||||
var existingTracks = await _cache.GetAsync<List<MissingTrack>>(cacheKey);
|
||||
var existingFileTime = DateTime.MinValue;
|
||||
var filePath = GetCacheFilePath(playlistName);
|
||||
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
existingFileTime = File.GetLastWriteTimeUtc(filePath);
|
||||
_logger.LogInformation(" Existing cache file from: {Time} ({Age:F1}h ago)",
|
||||
existingFileTime, (DateTime.UtcNow - existingFileTime).TotalHours);
|
||||
}
|
||||
|
||||
if (existingTracks != null && existingTracks.Count > 0)
|
||||
{
|
||||
_logger.LogInformation(" Current cache has {Count} tracks, will search for newer file", existingTracks.Count);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(" No existing cache, will search for missing tracks file");
|
||||
}
|
||||
|
||||
var settings = _spotifySettings.Value;
|
||||
var jellyfinUrl = _jellyfinSettings.Value.Url;
|
||||
var apiKey = _jellyfinSettings.Value.ApiKey;
|
||||
|
||||
if (string.IsNullOrEmpty(jellyfinUrl) || string.IsNullOrEmpty(apiKey))
|
||||
{
|
||||
_logger.LogWarning(" Jellyfin URL or API key not configured, skipping fetch");
|
||||
return;
|
||||
}
|
||||
|
||||
var httpClient = _httpClientFactory.CreateClient();
|
||||
|
||||
// Search forward first (newest files), then backwards to handle timezone differences
|
||||
// We want the file with the furthest forward timestamp (most recent)
|
||||
var now = DateTime.UtcNow;
|
||||
_logger.LogInformation(" Searching +24h forward, then -48h backward from {Now}", now);
|
||||
|
||||
var found = false;
|
||||
DateTime? foundFileTime = null;
|
||||
|
||||
// First search forward 24 hours (most likely to find newest files with timezone ahead)
|
||||
_logger.LogInformation(" Phase 1: Searching forward 24 hours...");
|
||||
for (var minutesAhead = 1; minutesAhead <= 1440; minutesAhead++)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) break;
|
||||
|
||||
var time = now.AddMinutes(minutesAhead);
|
||||
var result = await TryFetchMissingTracksFile(playlistName, time, jellyfinUrl, apiKey, httpClient, cancellationToken, existingFileTime);
|
||||
if (result.found)
|
||||
{
|
||||
found = true;
|
||||
foundFileTime = result.fileTime;
|
||||
if (foundFileTime.HasValue)
|
||||
{
|
||||
_logger.LogInformation(" ✓ Found file from {Time} (+{Offset:F1}h ahead)",
|
||||
foundFileTime.Value, (foundFileTime.Value - now).TotalHours);
|
||||
}
|
||||
break; // Found newest file, stop searching
|
||||
}
|
||||
|
||||
// Small delay every 60 requests to avoid rate limiting
|
||||
if (minutesAhead % 60 == 0)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
// If not found forward, search backwards 48 hours
|
||||
if (!found)
|
||||
{
|
||||
_logger.LogInformation(" Phase 2: Searching backward 48 hours...");
|
||||
for (var minutesBehind = 0; minutesBehind <= 2880; minutesBehind++)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) break;
|
||||
|
||||
var time = now.AddMinutes(-minutesBehind);
|
||||
var result = await TryFetchMissingTracksFile(playlistName, time, jellyfinUrl, apiKey, httpClient, cancellationToken, existingFileTime);
|
||||
if (result.found)
|
||||
{
|
||||
found = true;
|
||||
foundFileTime = result.fileTime;
|
||||
if (foundFileTime.HasValue)
|
||||
{
|
||||
_logger.LogInformation(" ✓ Found file from {Time} (-{Offset:F1}h ago)",
|
||||
foundFileTime.Value, (now - foundFileTime.Value).TotalHours);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Small delay every 60 requests to avoid rate limiting
|
||||
if (minutesBehind > 0 && minutesBehind % 60 == 0)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!found)
|
||||
{
|
||||
_logger.LogWarning(" ✗ Could not find new missing tracks file (searched +24h forward, -48h backward)");
|
||||
|
||||
// Keep the existing cache - don't let it expire
|
||||
if (existingTracks != null && existingTracks.Count > 0)
|
||||
{
|
||||
_logger.LogInformation(" ✓ Keeping existing cache with {Count} tracks (no expiration)", existingTracks.Count);
|
||||
// Re-save with no expiration to ensure it persists
|
||||
await _cache.SetAsync(cacheKey, existingTracks, TimeSpan.FromDays(365)); // Effectively no expiration
|
||||
}
|
||||
else if (File.Exists(filePath))
|
||||
{
|
||||
// Load from file if Redis cache is empty
|
||||
_logger.LogInformation(" 📦 Loading existing file cache to keep playlist populated");
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(filePath, cancellationToken);
|
||||
var tracks = JsonSerializer.Deserialize<List<MissingTrack>>(json);
|
||||
|
||||
if (tracks != null && tracks.Count > 0)
|
||||
{
|
||||
await _cache.SetAsync(cacheKey, tracks, TimeSpan.FromDays(365)); // No expiration
|
||||
_logger.LogInformation(" ✓ Loaded {Count} tracks from file cache (no expiration)", tracks.Count);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, " Failed to reload cache from file for {Playlist}", playlistName);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(" No existing cache to keep - playlist will be empty until tracks are found");
|
||||
}
|
||||
}
|
||||
else if (foundFileTime.HasValue)
|
||||
{
|
||||
_logger.LogInformation(" ✓ Updated cache with newer file from {Time}", foundFileTime.Value);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(bool found, DateTime? fileTime)> TryFetchMissingTracksFile(
|
||||
string playlistName,
|
||||
DateTime time,
|
||||
string jellyfinUrl,
|
||||
string apiKey,
|
||||
HttpClient httpClient,
|
||||
CancellationToken cancellationToken,
|
||||
DateTime existingFileTime)
|
||||
{
|
||||
var filename = $"{playlistName}_missing_{time:yyyy-MM-dd_HH-mm}.json";
|
||||
var url = $"{jellyfinUrl}/Viperinius.Plugin.SpotifyImport/MissingTracksFile" +
|
||||
$"?name={Uri.EscapeDataString(filename)}&api_key={apiKey}";
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Trying {Filename}", filename);
|
||||
var response = await httpClient.GetAsync(url, cancellationToken);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var tracks = ParseMissingTracks(json);
|
||||
|
||||
if (tracks.Count > 0)
|
||||
{
|
||||
// Check if this file is newer than what we already have
|
||||
if (time <= existingFileTime)
|
||||
{
|
||||
_logger.LogDebug(" Skipping {Filename} - not newer than existing cache", filename);
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
var cacheKey = $"spotify:missing:{playlistName}";
|
||||
|
||||
// Save to both Redis and file with extended TTL until next job runs
|
||||
// Set to 365 days (effectively no expiration) - will be replaced when Jellyfin generates new file
|
||||
await _cache.SetAsync(cacheKey, tracks, TimeSpan.FromDays(365));
|
||||
await SaveToFileCache(playlistName, tracks);
|
||||
|
||||
_logger.LogInformation(
|
||||
"✓ Cached {Count} missing tracks for {Playlist} from {Filename} (no expiration until next Jellyfin job)",
|
||||
tracks.Count, playlistName, filename);
|
||||
return (true, time);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to fetch {Filename}", filename);
|
||||
}
|
||||
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
private List<MissingTrack> ParseMissingTracks(string json)
|
||||
{
|
||||
var tracks = new List<MissingTrack>();
|
||||
|
||||
try
|
||||
{
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
foreach (var item in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
var track = new 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;
|
||||
}
|
||||
}
|
||||
259
allstarr/Services/Spotify/SpotifyTrackMatchingService.cs
Normal file
259
allstarr/Services/Spotify/SpotifyTrackMatchingService.cs
Normal file
@@ -0,0 +1,259 @@
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Models.Spotify;
|
||||
using allstarr.Services.Common;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace allstarr.Services.Spotify;
|
||||
|
||||
/// <summary>
|
||||
/// Background service that pre-matches Spotify missing tracks with external providers.
|
||||
/// Runs after SpotifyMissingTracksFetcher completes to avoid rate limiting during playlist loading.
|
||||
/// </summary>
|
||||
public class SpotifyTrackMatchingService : BackgroundService
|
||||
{
|
||||
private readonly IOptions<SpotifyImportSettings> _spotifySettings;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly ILogger<SpotifyTrackMatchingService> _logger;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private const int DelayBetweenSearchesMs = 150; // 150ms = ~6.6 searches/second to avoid rate limiting
|
||||
|
||||
public SpotifyTrackMatchingService(
|
||||
IOptions<SpotifyImportSettings> spotifySettings,
|
||||
RedisCacheService cache,
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<SpotifyTrackMatchingService> logger)
|
||||
{
|
||||
_spotifySettings = spotifySettings;
|
||||
_cache = cache;
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("SpotifyTrackMatchingService: Starting up...");
|
||||
|
||||
if (!_spotifySettings.Value.Enabled)
|
||||
{
|
||||
_logger.LogInformation("Spotify playlist injection is DISABLED, matching service will not run");
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait a bit for the fetcher to run first
|
||||
await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
|
||||
|
||||
// Run once on startup to match any existing missing tracks
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Running initial track matching on startup");
|
||||
await MatchAllPlaylistsAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error during startup track matching");
|
||||
}
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await MatchAllPlaylistsAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in track matching service");
|
||||
}
|
||||
|
||||
// Run every 30 minutes to catch new missing tracks
|
||||
await Task.Delay(TimeSpan.FromMinutes(30), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Public method to trigger matching manually (called from controller).
|
||||
/// </summary>
|
||||
public async Task TriggerMatchingAsync()
|
||||
{
|
||||
_logger.LogInformation("Manual track matching triggered");
|
||||
await MatchAllPlaylistsAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private async Task MatchAllPlaylistsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("=== STARTING TRACK MATCHING ===");
|
||||
|
||||
var playlistNames = _spotifySettings.Value.PlaylistNames;
|
||||
if (playlistNames.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("No playlists configured for matching");
|
||||
return;
|
||||
}
|
||||
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var metadataService = scope.ServiceProvider.GetRequiredService<IMusicMetadataService>();
|
||||
|
||||
foreach (var playlistName in playlistNames)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) break;
|
||||
|
||||
try
|
||||
{
|
||||
await MatchPlaylistTracksAsync(playlistName, metadataService, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error matching tracks for playlist {Playlist}", playlistName);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("=== FINISHED TRACK MATCHING ===");
|
||||
}
|
||||
|
||||
private async Task MatchPlaylistTracksAsync(
|
||||
string playlistName,
|
||||
IMusicMetadataService metadataService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var missingTracksKey = $"spotify:missing:{playlistName}";
|
||||
var matchedTracksKey = $"spotify:matched:{playlistName}";
|
||||
|
||||
// Check if we already have matched tracks cached
|
||||
var existingMatched = await _cache.GetAsync<List<Song>>(matchedTracksKey);
|
||||
if (existingMatched != null && existingMatched.Count > 0)
|
||||
{
|
||||
_logger.LogInformation("Playlist {Playlist} already has {Count} matched tracks cached, skipping",
|
||||
playlistName, existingMatched.Count);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get missing tracks
|
||||
var missingTracks = await _cache.GetAsync<List<MissingTrack>>(missingTracksKey);
|
||||
if (missingTracks == null || missingTracks.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("No missing tracks found for {Playlist}, skipping matching", playlistName);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Matching {Count} tracks for {Playlist} (with rate limiting)",
|
||||
missingTracks.Count, playlistName);
|
||||
|
||||
var matchedSongs = new List<Song>();
|
||||
var matchCount = 0;
|
||||
|
||||
foreach (var track in missingTracks)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) break;
|
||||
|
||||
try
|
||||
{
|
||||
var query = $"{track.Title} {track.PrimaryArtist}";
|
||||
var results = await metadataService.SearchSongsAsync(query, limit: 5);
|
||||
|
||||
if (results.Count > 0)
|
||||
{
|
||||
// Fuzzy match to find best result
|
||||
// Check that ALL artists match (not just some)
|
||||
var bestMatch = results
|
||||
.Select(song => new
|
||||
{
|
||||
Song = song,
|
||||
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, song.Title),
|
||||
// Calculate artist score by checking ALL artists match
|
||||
ArtistScore = CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors)
|
||||
})
|
||||
.Select(x => new
|
||||
{
|
||||
x.Song,
|
||||
x.TitleScore,
|
||||
x.ArtistScore,
|
||||
TotalScore = (x.TitleScore * 0.6) + (x.ArtistScore * 0.4)
|
||||
})
|
||||
.OrderByDescending(x => x.TotalScore)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (bestMatch != null && bestMatch.TotalScore >= 60)
|
||||
{
|
||||
matchedSongs.Add(bestMatch.Song);
|
||||
matchCount++;
|
||||
|
||||
if (matchCount % 10 == 0)
|
||||
{
|
||||
_logger.LogInformation("Matched {Count}/{Total} tracks for {Playlist}",
|
||||
matchCount, missingTracks.Count, playlistName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rate limiting: delay between searches
|
||||
await Task.Delay(DelayBetweenSearchesMs, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
|
||||
track.Title, track.PrimaryArtist);
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedSongs.Count > 0)
|
||||
{
|
||||
// Cache matched tracks for 1 hour
|
||||
await _cache.SetAsync(matchedTracksKey, matchedSongs, TimeSpan.FromHours(1));
|
||||
_logger.LogInformation("✓ Cached {Matched}/{Total} matched tracks for {Playlist}",
|
||||
matchedSongs.Count, missingTracks.Count, playlistName);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("No tracks matched for {Playlist}", playlistName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates artist match score ensuring ALL artists are present.
|
||||
/// Penalizes if artist counts don't match or if any artist is missing.
|
||||
/// </summary>
|
||||
private 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 =>
|
||||
FuzzyMatcher.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 =>
|
||||
FuzzyMatcher.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;
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ public class SquidWTFDownloadService : BaseDownloadService
|
||||
|
||||
private readonly List<string> _apiUrls;
|
||||
private int _currentUrlIndex = 0;
|
||||
private readonly object _urlIndexLock = new object();
|
||||
|
||||
protected override string ProviderName => "squidwtf";
|
||||
|
||||
@@ -48,23 +49,39 @@ public class SquidWTFDownloadService : BaseDownloadService
|
||||
_apiUrls = apiUrls;
|
||||
}
|
||||
|
||||
/// <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
|
||||
{
|
||||
var baseUrl = _apiUrls[_currentUrlIndex];
|
||||
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...", _apiUrls[_currentUrlIndex]);
|
||||
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
|
||||
Logger.LogWarning(ex, "Request failed with endpoint {Endpoint}, trying next...", baseUrl);
|
||||
|
||||
if (attempt == _apiUrls.Count - 1)
|
||||
{
|
||||
Logger.LogError("All SquidWTF endpoints failed");
|
||||
Logger.LogError("All {Count} SquidWTF endpoints failed", _apiUrls.Count);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly List<string> _apiUrls;
|
||||
private int _currentUrlIndex = 0;
|
||||
private readonly object _urlIndexLock = new object();
|
||||
|
||||
public SquidWTFMetadataService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
@@ -43,25 +44,52 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0");
|
||||
}
|
||||
|
||||
private string GetCurrentBaseUrl() => _apiUrls[_currentUrlIndex];
|
||||
/// <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
|
||||
{
|
||||
var baseUrl = _apiUrls[_currentUrlIndex];
|
||||
_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...", _apiUrls[_currentUrlIndex]);
|
||||
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
|
||||
_logger.LogWarning(ex, "Request failed with endpoint {Endpoint}, trying next...", baseUrl);
|
||||
|
||||
if (attempt == _apiUrls.Count - 1)
|
||||
{
|
||||
_logger.LogError("All SquidWTF endpoints failed");
|
||||
_logger.LogError("All {Count} SquidWTF endpoints failed", _apiUrls.Count);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
@@ -78,11 +106,18 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return new List<Song>();
|
||||
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// Check for error in response body
|
||||
var result = JsonDocument.Parse(json);
|
||||
if (result.RootElement.TryGetProperty("detail", out _) ||
|
||||
result.RootElement.TryGetProperty("error", out _))
|
||||
{
|
||||
throw new HttpRequestException("API returned error response");
|
||||
}
|
||||
|
||||
var songs = new List<Song>();
|
||||
if (result.RootElement.TryGetProperty("data", out var data) &&
|
||||
@@ -188,7 +223,15 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
{
|
||||
foreach(var playlist in items.EnumerateArray())
|
||||
{
|
||||
playlists.Add(ParseTidalPlaylist(playlist));
|
||||
try
|
||||
{
|
||||
playlists.Add(ParseTidalPlaylist(playlist));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to parse playlist, skipping");
|
||||
// Skip this playlist and continue with others
|
||||
}
|
||||
}
|
||||
}
|
||||
return playlists;
|
||||
|
||||
@@ -14,6 +14,7 @@ public class SquidWTFStartupValidator : BaseStartupValidator
|
||||
private readonly SquidWTFSettings _settings;
|
||||
private readonly List<string> _apiUrls;
|
||||
private int _currentUrlIndex = 0;
|
||||
private readonly object _urlIndexLock = new object();
|
||||
|
||||
public override string ServiceName => "SquidWTF";
|
||||
|
||||
@@ -24,22 +25,37 @@ public class SquidWTFStartupValidator : BaseStartupValidator
|
||||
_apiUrls = apiUrls;
|
||||
}
|
||||
|
||||
/// <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
|
||||
{
|
||||
var baseUrl = _apiUrls[_currentUrlIndex];
|
||||
return await action(baseUrl);
|
||||
}
|
||||
catch
|
||||
{
|
||||
WriteDetail($"Endpoint {_apiUrls[_currentUrlIndex]} failed, trying next...");
|
||||
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
|
||||
WriteDetail($"Endpoint {baseUrl} failed, trying next...");
|
||||
|
||||
if (attempt == _apiUrls.Count - 1)
|
||||
{
|
||||
WriteDetail($"All {_apiUrls.Count} endpoints failed");
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,5 +4,23 @@
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"SpotifyImport": {
|
||||
"Enabled": false,
|
||||
"SyncStartHour": 16,
|
||||
"SyncStartMinute": 15,
|
||||
"SyncWindowHours": 2,
|
||||
"Playlists": [
|
||||
{
|
||||
"Name": "Release Radar",
|
||||
"SpotifyName": "Release Radar",
|
||||
"Enabled": true
|
||||
},
|
||||
{
|
||||
"Name": "Discover Weekly",
|
||||
"SpotifyName": "Discover Weekly",
|
||||
"Enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"Type": "Subsonic"
|
||||
},
|
||||
"Subsonic": {
|
||||
"Url": "https://navidrome.local.bransonb.com",
|
||||
"Url": "http://localhost:4533",
|
||||
"MusicService": "SquidWTF",
|
||||
"ExplicitFilter": "All",
|
||||
"DownloadMode": "Track",
|
||||
@@ -42,5 +42,23 @@
|
||||
"Redis": {
|
||||
"Enabled": true,
|
||||
"ConnectionString": "localhost:6379"
|
||||
},
|
||||
"SpotifyImport": {
|
||||
"Enabled": false,
|
||||
"SyncStartHour": 16,
|
||||
"SyncStartMinute": 15,
|
||||
"SyncWindowHours": 2,
|
||||
"Playlists": [
|
||||
{
|
||||
"Name": "Release Radar",
|
||||
"SpotifyName": "Release Radar",
|
||||
"Enabled": true
|
||||
},
|
||||
{
|
||||
"Name": "Discover Weekly",
|
||||
"SpotifyName": "Discover Weekly",
|
||||
"Enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,14 @@ services:
|
||||
# Redis is only accessible internally - no external port exposure
|
||||
expose:
|
||||
- "6379"
|
||||
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||
command: redis-server --maxmemory 1gb --maxmemory-policy allkeys-lru --save 60 1 --appendonly yes
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
volumes:
|
||||
- ${REDIS_DATA_PATH:-./redis-data}:/data
|
||||
networks:
|
||||
- allstarr-network
|
||||
|
||||
@@ -74,6 +76,14 @@ services:
|
||||
- Jellyfin__CacheDurationHours=${CACHE_DURATION_HOURS:-1}
|
||||
- Jellyfin__EnableExternalPlaylists=${ENABLE_EXTERNAL_PLAYLISTS:-true}
|
||||
|
||||
# ===== SPOTIFY PLAYLIST INJECTION (JELLYFIN ONLY) =====
|
||||
- SpotifyImport__Enabled=${SPOTIFY_IMPORT_ENABLED:-false}
|
||||
- SpotifyImport__SyncStartHour=${SPOTIFY_IMPORT_SYNC_START_HOUR:-16}
|
||||
- SpotifyImport__SyncStartMinute=${SPOTIFY_IMPORT_SYNC_START_MINUTE:-15}
|
||||
- SpotifyImport__SyncWindowHours=${SPOTIFY_IMPORT_SYNC_WINDOW_HOURS:-2}
|
||||
- SpotifyImport__PlaylistIds=${SPOTIFY_IMPORT_PLAYLIST_IDS:-}
|
||||
- SpotifyImport__PlaylistNames=${SPOTIFY_IMPORT_PLAYLIST_NAMES:-}
|
||||
|
||||
# ===== SHARED =====
|
||||
- Library__DownloadPath=/app/downloads
|
||||
- SquidWTF__Quality=${SQUIDWTF_QUALITY:-FLAC}
|
||||
@@ -85,6 +95,8 @@ services:
|
||||
- Qobuz__Quality=${QOBUZ_QUALITY:-FLAC}
|
||||
volumes:
|
||||
- ${DOWNLOAD_PATH:-./downloads}:/app/downloads
|
||||
- ${KEPT_PATH:-./kept}:/app/kept
|
||||
- ${CACHE_PATH:-./cache}:/app/cache
|
||||
|
||||
networks:
|
||||
allstarr-network:
|
||||
|
||||
Reference in New Issue
Block a user