mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
Compare commits
52 Commits
main
...
422d12370e
| Author | SHA1 | Date | |
|---|---|---|---|
|
422d12370e
|
|||
|
6ea2331127
|
|||
|
425dc2e135
|
|||
|
df6c500ef5
|
|||
|
cb4a156507
|
|||
|
554ac90fee
|
|||
|
ae3b26d46a
|
|||
|
d43494dc42
|
|||
|
70a56e758a
|
|||
|
13205087ff
|
|||
|
3c4e368912
|
|||
|
018c1d5d93
|
|||
|
6d38dd2615
|
|||
|
a2917f14fa
|
|||
|
8ee3ce96d3
|
|||
|
46a9f945e6
|
|||
|
5e90cd8ea8
|
|||
|
a540f1f766
|
|||
|
0cfefd667f
|
|||
|
93b55599ee
|
|||
|
2a66eba58d
|
|||
|
b9acc502af
|
|||
|
aae7dfe59e
|
|||
|
4472f71265
|
|||
|
fa176bad0f
|
|||
|
b3fe044405
|
|||
|
5a0ae860e3
|
|||
|
a307ef8975
|
|||
|
4d21291598
|
|||
|
7c19f199f1
|
|||
|
629bf38ea7
|
|||
|
e23e22a736
|
|||
|
ba0fe35e72
|
|||
|
6e9fe0e69e
|
|||
|
cba955c427
|
|||
|
192173ea64
|
|||
|
c33180abd7
|
|||
|
680454e76e
|
|||
|
34bfc20d28
|
|||
|
489159b424
|
|||
|
2bb754b245
|
|||
|
8d8c0892a2
|
|||
|
e12851e9ca
|
|||
|
f8969bea8d
|
|||
|
ceaa17f018
|
|||
|
9aa7ceb138
|
|||
|
72b1ebc2eb
|
|||
|
48a0351862
|
|||
|
4b95f9910c
|
|||
|
80424a867d
|
|||
|
4afd769602
|
|||
|
b47a5f9063
|
92
.env.example
92
.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,85 @@ 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
|
||||
|
||||
# Matching interval: How often to run track matching (in hours)
|
||||
# Spotify playlists like Discover Weekly update once per week, Release Radar updates weekly
|
||||
# Most playlists don't change frequently, so running once per day is reasonable
|
||||
# Set to 0 to only run once on startup (manual trigger via admin UI still works)
|
||||
# Default: 24 hours
|
||||
SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS=24
|
||||
|
||||
# Playlists configuration (JSON ARRAY FORMAT - managed by web UI)
|
||||
# Format: [["PlaylistName","SpotifyPlaylistId","first|last"],...]
|
||||
# - PlaylistName: Name as it appears in Jellyfin
|
||||
# - SpotifyPlaylistId: Get from Spotify URL (e.g., 37i9dQZF1DXcBWIGoYBM5M)
|
||||
# Accepts: spotify:playlist:ID, full URL, or just the ID
|
||||
# - first|last: Where to position local tracks (first=local tracks first, last=external tracks first)
|
||||
#
|
||||
# Example:
|
||||
# SPOTIFY_IMPORT_PLAYLISTS=[["Discover Weekly","37i9dQZEVXcV6s7Dm7RXsU","first"],["Release Radar","37i9dQZEVXbng2vDHnfQlC","first"]]
|
||||
#
|
||||
# RECOMMENDED: Use the web UI (Link Playlists tab) to manage playlists instead of editing this manually
|
||||
SPOTIFY_IMPORT_PLAYLISTS=[]
|
||||
|
||||
# ===== SPOTIFY DIRECT API (RECOMMENDED - ENABLES TRACK ORDERING & LYRICS) =====
|
||||
# This is the preferred method for Spotify playlist integration.
|
||||
# Provides: Correct track ordering, ISRC-based exact matching, synchronized lyrics
|
||||
# Does NOT require the Jellyfin Spotify Import plugin (can work standalone)
|
||||
|
||||
# Enable direct Spotify API access (default: false)
|
||||
SPOTIFY_API_ENABLED=false
|
||||
|
||||
# Spotify Client ID from https://developer.spotify.com/dashboard
|
||||
# Create an app in the Spotify Developer Dashboard to get this
|
||||
SPOTIFY_API_CLIENT_ID=
|
||||
|
||||
# Spotify Client Secret (optional - only needed for certain OAuth flows)
|
||||
SPOTIFY_API_CLIENT_SECRET=
|
||||
|
||||
# Spotify session cookie (sp_dc) - REQUIRED for editorial playlists
|
||||
# Editorial playlists (Release Radar, Discover Weekly, etc.) require authentication
|
||||
# via session cookie because they're not accessible through the official API.
|
||||
#
|
||||
# To get your sp_dc cookie:
|
||||
# 1. Open https://open.spotify.com in your browser and log in
|
||||
# 2. Open DevTools (F12) → Application → Cookies → https://open.spotify.com
|
||||
# 3. Find the cookie named "sp_dc" and copy its value
|
||||
# 4. Note: This cookie expires periodically (typically every few months)
|
||||
SPOTIFY_API_SESSION_COOKIE=
|
||||
|
||||
# Date when the session cookie was set (ISO 8601 format)
|
||||
# Automatically set by the web UI when you update the cookie
|
||||
# Used to track cookie age and warn when approaching expiration (~1 year)
|
||||
SPOTIFY_API_SESSION_COOKIE_SET_DATE=
|
||||
|
||||
# Cache duration for playlist data in minutes (default: 60)
|
||||
# Release Radar updates weekly, Discover Weekly updates Mondays
|
||||
SPOTIFY_API_CACHE_DURATION_MINUTES=60
|
||||
|
||||
# Rate limit delay between API requests in milliseconds (default: 100)
|
||||
SPOTIFY_API_RATE_LIMIT_DELAY_MS=100
|
||||
|
||||
# Prefer ISRC matching over fuzzy title/artist matching (default: true)
|
||||
# ISRC provides exact track identification across different streaming services
|
||||
SPOTIFY_API_PREFER_ISRC_MATCHING=true
|
||||
|
||||
16
.gitignore
vendored
16
.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/
|
||||
|
||||
@@ -82,5 +88,15 @@ apis/*.md
|
||||
apis/*.json
|
||||
!apis/jellyfin-openapi-stable.json
|
||||
|
||||
# Log files for debugging
|
||||
apis/*.log
|
||||
|
||||
# Endpoint usage tracking
|
||||
apis/endpoint-usage.json
|
||||
/app/cache/endpoint-usage/
|
||||
|
||||
# Original source code for reference
|
||||
originals/
|
||||
|
||||
# Sample missing playlists for Spotify integration testing
|
||||
sampleMissingPlaylists/
|
||||
@@ -24,7 +24,8 @@ RUN mkdir -p /app/downloads
|
||||
|
||||
COPY --from=build /app/publish .
|
||||
|
||||
# Only expose the main proxy port (8080)
|
||||
# Admin UI runs on 5275 but is NOT exposed - access via docker exec or SSH tunnel
|
||||
EXPOSE 8080
|
||||
ENV ASPNETCORE_URLS=http://+:8080
|
||||
|
||||
ENTRYPOINT ["dotnet", "allstarr.dll"]
|
||||
|
||||
205
README.md
205
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
|
||||
|
||||
@@ -33,9 +38,49 @@ docker-compose logs -f
|
||||
|
||||
The proxy will be available at `http://localhost:5274`.
|
||||
|
||||
## Web Dashboard
|
||||
|
||||
Allstarr includes a web-based dashboard for easy configuration and playlist management, accessible at `http://localhost:5275` (internal port, not exposed through reverse proxy).
|
||||
|
||||
### Features
|
||||
|
||||
- **Real-time Status**: Monitor Spotify authentication, cookie age, and playlist sync status
|
||||
- **Playlist Management**: Link Jellyfin playlists to Spotify playlists with a few clicks
|
||||
- **Configuration Editor**: Update settings without manually editing .env files
|
||||
- **Track Viewer**: Browse tracks in your configured playlists
|
||||
- **Cache Management**: Clear cached data and restart the container
|
||||
|
||||
### Quick Setup with Web UI
|
||||
|
||||
1. **Access the dashboard** at `http://localhost:5275`
|
||||
2. **Configure Spotify** (Configuration tab):
|
||||
- Enable Spotify API
|
||||
- Add your `sp_dc` cookie from Spotify (see instructions in UI)
|
||||
- The cookie age is automatically tracked
|
||||
3. **Link playlists** (Link Playlists tab):
|
||||
- View all your Jellyfin playlists
|
||||
- Click "Link to Spotify" on any playlist
|
||||
- Paste the Spotify playlist ID, URL, or `spotify:playlist:` URI
|
||||
- Accepts formats like:
|
||||
- `37i9dQZF1DXcBWIGoYBM5M` (just the ID)
|
||||
- `spotify:playlist:37i9dQZF1DXcBWIGoYBM5M` (Spotify URI)
|
||||
- `https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M` (full URL)
|
||||
4. **Restart** to apply changes (button in Configuration tab)
|
||||
|
||||
### Why Two Playlist Tabs?
|
||||
|
||||
- **Link Playlists**: Shows all Jellyfin playlists and lets you connect them to Spotify
|
||||
- **Active Playlists**: Shows which Spotify playlists are currently being monitored and filled with tracks
|
||||
|
||||
### Configuration Persistence
|
||||
|
||||
The web UI updates your `.env` file directly. Changes persist across container restarts, but require a restart to take effect. In development mode, the `.env` file is in your project root. In Docker, it's at `/app/.env`.
|
||||
|
||||
**Recommended workflow**: Use the `sp_dc` cookie method (simpler and more reliable than the Jellyfin Spotify Import plugin).
|
||||
|
||||
### 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 +123,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 +131,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 +290,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 +333,154 @@ 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 configurable interval)
|
||||
- For each missing track, searches your streaming provider (SquidWTF, Deezer, or Qobuz)
|
||||
- Uses fuzzy matching to find the best match (title + artist similarity)
|
||||
- Rate-limited to avoid overwhelming the service (150ms delay between searches)
|
||||
- Caches matched results for 1 hour
|
||||
- **Pre-builds playlist items cache** for instant serving (no "on the fly" building)
|
||||
- Default interval: 24 hours (configurable via `SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS`)
|
||||
- Set to 0 to only run once on startup (manual trigger via admin UI still works)
|
||||
|
||||
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]
|
||||
|
||||
334
allstarr.Tests/JellyfinResponseStructureTests.cs
Normal file
334
allstarr.Tests/JellyfinResponseStructureTests.cs
Normal file
@@ -0,0 +1,334 @@
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Services.Jellyfin;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests to verify Jellyfin response structure matches real API responses.
|
||||
/// </summary>
|
||||
public class JellyfinResponseStructureTests
|
||||
{
|
||||
private readonly JellyfinResponseBuilder _builder;
|
||||
|
||||
public JellyfinResponseStructureTests()
|
||||
{
|
||||
_builder = new JellyfinResponseBuilder();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Track_Response_Should_Have_All_Required_Fields()
|
||||
{
|
||||
// Arrange
|
||||
var song = new Song
|
||||
{
|
||||
Id = "test-id",
|
||||
Title = "Test Song",
|
||||
Artist = "Test Artist",
|
||||
ArtistId = "artist-id",
|
||||
Album = "Test Album",
|
||||
AlbumId = "album-id",
|
||||
Duration = 180,
|
||||
Year = 2024,
|
||||
Track = 1,
|
||||
Genre = "Pop",
|
||||
IsLocal = false,
|
||||
ExternalProvider = "Deezer",
|
||||
ExternalId = "123456"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _builder.ConvertSongToJellyfinItem(song);
|
||||
|
||||
// Assert - Required top-level fields
|
||||
Assert.NotNull(result["Name"]);
|
||||
Assert.NotNull(result["ServerId"]);
|
||||
Assert.NotNull(result["Id"]);
|
||||
Assert.NotNull(result["Type"]);
|
||||
Assert.Equal("Audio", result["Type"]);
|
||||
Assert.NotNull(result["MediaType"]);
|
||||
Assert.Equal("Audio", result["MediaType"]);
|
||||
|
||||
// Assert - Metadata fields
|
||||
Assert.NotNull(result["Container"]);
|
||||
Assert.Equal("flac", result["Container"]);
|
||||
Assert.NotNull(result["HasLyrics"]);
|
||||
Assert.False((bool)result["HasLyrics"]!);
|
||||
|
||||
// Assert - Genres (must be array, never null)
|
||||
Assert.NotNull(result["Genres"]);
|
||||
Assert.IsType<string[]>(result["Genres"]);
|
||||
Assert.NotNull(result["GenreItems"]);
|
||||
Assert.IsAssignableFrom<System.Collections.IEnumerable>(result["GenreItems"]);
|
||||
|
||||
// Assert - UserData
|
||||
Assert.NotNull(result["UserData"]);
|
||||
var userData = result["UserData"] as Dictionary<string, object>;
|
||||
Assert.NotNull(userData);
|
||||
Assert.Contains("ItemId", userData.Keys);
|
||||
Assert.Contains("Key", userData.Keys);
|
||||
|
||||
// Assert - Image fields
|
||||
Assert.NotNull(result["ImageTags"]);
|
||||
Assert.NotNull(result["BackdropImageTags"]);
|
||||
Assert.NotNull(result["ImageBlurHashes"]);
|
||||
|
||||
// Assert - Location
|
||||
Assert.NotNull(result["LocationType"]);
|
||||
Assert.Equal("FileSystem", result["LocationType"]);
|
||||
|
||||
// Assert - Parent references
|
||||
Assert.NotNull(result["ParentLogoItemId"]);
|
||||
Assert.NotNull(result["ParentBackdropItemId"]);
|
||||
Assert.NotNull(result["ParentBackdropImageTags"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Track_MediaSources_Should_Have_Complete_Structure()
|
||||
{
|
||||
// Arrange
|
||||
var song = new Song
|
||||
{
|
||||
Id = "test-id",
|
||||
Title = "Test Song",
|
||||
Artist = "Test Artist",
|
||||
Album = "Test Album",
|
||||
Duration = 180,
|
||||
IsLocal = false,
|
||||
ExternalProvider = "Deezer",
|
||||
ExternalId = "123456"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _builder.ConvertSongToJellyfinItem(song);
|
||||
|
||||
// Assert - MediaSources exists
|
||||
Assert.NotNull(result["MediaSources"]);
|
||||
var mediaSources = result["MediaSources"] as object[];
|
||||
Assert.NotNull(mediaSources);
|
||||
Assert.Single(mediaSources);
|
||||
|
||||
var mediaSource = mediaSources[0] as Dictionary<string, object?>;
|
||||
Assert.NotNull(mediaSource);
|
||||
|
||||
// Assert - Required MediaSource fields
|
||||
Assert.Contains("Protocol", mediaSource.Keys);
|
||||
Assert.Contains("Id", mediaSource.Keys);
|
||||
Assert.Contains("Path", mediaSource.Keys);
|
||||
Assert.Contains("Type", mediaSource.Keys);
|
||||
Assert.Contains("Container", mediaSource.Keys);
|
||||
Assert.Contains("Bitrate", mediaSource.Keys);
|
||||
Assert.Contains("ETag", mediaSource.Keys);
|
||||
Assert.Contains("RunTimeTicks", mediaSource.Keys);
|
||||
|
||||
// Assert - Boolean flags
|
||||
Assert.Contains("IsRemote", mediaSource.Keys);
|
||||
Assert.Contains("IsInfiniteStream", mediaSource.Keys);
|
||||
Assert.Contains("RequiresOpening", mediaSource.Keys);
|
||||
Assert.Contains("RequiresClosing", mediaSource.Keys);
|
||||
Assert.Contains("RequiresLooping", mediaSource.Keys);
|
||||
Assert.Contains("SupportsProbing", mediaSource.Keys);
|
||||
Assert.Contains("SupportsTranscoding", mediaSource.Keys);
|
||||
Assert.Contains("SupportsDirectStream", mediaSource.Keys);
|
||||
Assert.Contains("SupportsDirectPlay", mediaSource.Keys);
|
||||
Assert.Contains("ReadAtNativeFramerate", mediaSource.Keys);
|
||||
Assert.Contains("IgnoreDts", mediaSource.Keys);
|
||||
Assert.Contains("IgnoreIndex", mediaSource.Keys);
|
||||
Assert.Contains("GenPtsInput", mediaSource.Keys);
|
||||
Assert.Contains("UseMostCompatibleTranscodingProfile", mediaSource.Keys);
|
||||
Assert.Contains("HasSegments", mediaSource.Keys);
|
||||
|
||||
// Assert - Arrays (must not be null)
|
||||
Assert.Contains("MediaStreams", mediaSource.Keys);
|
||||
Assert.NotNull(mediaSource["MediaStreams"]);
|
||||
Assert.Contains("MediaAttachments", mediaSource.Keys);
|
||||
Assert.NotNull(mediaSource["MediaAttachments"]);
|
||||
Assert.Contains("Formats", mediaSource.Keys);
|
||||
Assert.NotNull(mediaSource["Formats"]);
|
||||
Assert.Contains("RequiredHttpHeaders", mediaSource.Keys);
|
||||
Assert.NotNull(mediaSource["RequiredHttpHeaders"]);
|
||||
|
||||
// Assert - Other fields
|
||||
Assert.Contains("TranscodingSubProtocol", mediaSource.Keys);
|
||||
Assert.Contains("DefaultAudioStreamIndex", mediaSource.Keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Track_MediaStreams_Should_Have_Complete_Audio_Stream()
|
||||
{
|
||||
// Arrange
|
||||
var song = new Song
|
||||
{
|
||||
Id = "test-id",
|
||||
Title = "Test Song",
|
||||
Artist = "Test Artist",
|
||||
IsLocal = false,
|
||||
ExternalProvider = "Deezer"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _builder.ConvertSongToJellyfinItem(song);
|
||||
var mediaSources = result["MediaSources"] as object[];
|
||||
var mediaSource = mediaSources![0] as Dictionary<string, object?>;
|
||||
var mediaStreams = mediaSource!["MediaStreams"] as object[];
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(mediaStreams);
|
||||
Assert.Single(mediaStreams);
|
||||
|
||||
var audioStream = mediaStreams[0] as Dictionary<string, object?>;
|
||||
Assert.NotNull(audioStream);
|
||||
|
||||
// Assert - Required audio stream fields
|
||||
Assert.Contains("Codec", audioStream.Keys);
|
||||
Assert.Equal("flac", audioStream["Codec"]);
|
||||
Assert.Contains("Type", audioStream.Keys);
|
||||
Assert.Equal("Audio", audioStream["Type"]);
|
||||
Assert.Contains("BitRate", audioStream.Keys);
|
||||
Assert.Contains("Channels", audioStream.Keys);
|
||||
Assert.Contains("SampleRate", audioStream.Keys);
|
||||
Assert.Contains("BitDepth", audioStream.Keys);
|
||||
Assert.Contains("ChannelLayout", audioStream.Keys);
|
||||
Assert.Contains("TimeBase", audioStream.Keys);
|
||||
Assert.Contains("DisplayTitle", audioStream.Keys);
|
||||
|
||||
// Assert - Video-related fields (required even for audio)
|
||||
Assert.Contains("VideoRange", audioStream.Keys);
|
||||
Assert.Contains("VideoRangeType", audioStream.Keys);
|
||||
Assert.Contains("AudioSpatialFormat", audioStream.Keys);
|
||||
|
||||
// Assert - Localization
|
||||
Assert.Contains("LocalizedDefault", audioStream.Keys);
|
||||
Assert.Contains("LocalizedExternal", audioStream.Keys);
|
||||
|
||||
// Assert - Boolean flags
|
||||
Assert.Contains("IsInterlaced", audioStream.Keys);
|
||||
Assert.Contains("IsAVC", audioStream.Keys);
|
||||
Assert.Contains("IsDefault", audioStream.Keys);
|
||||
Assert.Contains("IsForced", audioStream.Keys);
|
||||
Assert.Contains("IsHearingImpaired", audioStream.Keys);
|
||||
Assert.Contains("IsExternal", audioStream.Keys);
|
||||
Assert.Contains("IsTextSubtitleStream", audioStream.Keys);
|
||||
Assert.Contains("SupportsExternalStream", audioStream.Keys);
|
||||
|
||||
// Assert - Index and Level
|
||||
Assert.Contains("Index", audioStream.Keys);
|
||||
Assert.Contains("Level", audioStream.Keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Album_Response_Should_Have_All_Required_Fields()
|
||||
{
|
||||
// Arrange
|
||||
var album = new Album
|
||||
{
|
||||
Id = "album-id",
|
||||
Title = "Test Album",
|
||||
Artist = "Test Artist",
|
||||
Year = 2024,
|
||||
Genre = "Rock",
|
||||
IsLocal = false,
|
||||
ExternalProvider = "Deezer"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _builder.ConvertAlbumToJellyfinItem(album);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result["Name"]);
|
||||
Assert.NotNull(result["ServerId"]);
|
||||
Assert.NotNull(result["Id"]);
|
||||
Assert.NotNull(result["Type"]);
|
||||
Assert.Equal("MusicAlbum", result["Type"]);
|
||||
Assert.True((bool)result["IsFolder"]!);
|
||||
Assert.NotNull(result["MediaType"]);
|
||||
Assert.Equal("Unknown", result["MediaType"]);
|
||||
|
||||
// Assert - Genres
|
||||
Assert.NotNull(result["Genres"]);
|
||||
Assert.IsType<string[]>(result["Genres"]);
|
||||
Assert.NotNull(result["GenreItems"]);
|
||||
|
||||
// Assert - Artists
|
||||
Assert.NotNull(result["Artists"]);
|
||||
Assert.NotNull(result["ArtistItems"]);
|
||||
Assert.NotNull(result["AlbumArtist"]);
|
||||
Assert.NotNull(result["AlbumArtists"]);
|
||||
|
||||
// Assert - Parent references
|
||||
Assert.NotNull(result["ParentLogoItemId"]);
|
||||
Assert.NotNull(result["ParentBackdropItemId"]);
|
||||
Assert.NotNull(result["ParentLogoImageTag"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Artist_Response_Should_Have_All_Required_Fields()
|
||||
{
|
||||
// Arrange
|
||||
var artist = new Artist
|
||||
{
|
||||
Id = "artist-id",
|
||||
Name = "Test Artist",
|
||||
AlbumCount = 5,
|
||||
IsLocal = false,
|
||||
ExternalProvider = "Deezer"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _builder.ConvertArtistToJellyfinItem(artist);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result["Name"]);
|
||||
Assert.NotNull(result["ServerId"]);
|
||||
Assert.NotNull(result["Id"]);
|
||||
Assert.NotNull(result["Type"]);
|
||||
Assert.Equal("MusicArtist", result["Type"]);
|
||||
Assert.True((bool)result["IsFolder"]!);
|
||||
Assert.NotNull(result["MediaType"]);
|
||||
Assert.Equal("Unknown", result["MediaType"]);
|
||||
|
||||
// Assert - Genres (empty array for artists)
|
||||
Assert.NotNull(result["Genres"]);
|
||||
Assert.IsType<string[]>(result["Genres"]);
|
||||
Assert.NotNull(result["GenreItems"]);
|
||||
|
||||
// Assert - Album count
|
||||
Assert.NotNull(result["AlbumCount"]);
|
||||
Assert.Equal(5, result["AlbumCount"]);
|
||||
|
||||
// Assert - RunTimeTicks
|
||||
Assert.NotNull(result["RunTimeTicks"]);
|
||||
Assert.Equal(0, result["RunTimeTicks"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void All_Entities_Should_Have_UserData_With_ItemId()
|
||||
{
|
||||
// Arrange
|
||||
var song = new Song { Id = "song-id", Title = "Test", Artist = "Test" };
|
||||
var album = new Album { Id = "album-id", Title = "Test", Artist = "Test" };
|
||||
var artist = new Artist { Id = "artist-id", Name = "Test" };
|
||||
|
||||
// Act
|
||||
var songResult = _builder.ConvertSongToJellyfinItem(song);
|
||||
var albumResult = _builder.ConvertAlbumToJellyfinItem(album);
|
||||
var artistResult = _builder.ConvertArtistToJellyfinItem(artist);
|
||||
|
||||
// Assert
|
||||
var songUserData = songResult["UserData"] as Dictionary<string, object>;
|
||||
Assert.NotNull(songUserData);
|
||||
Assert.Contains("ItemId", songUserData.Keys);
|
||||
Assert.Equal("song-id", songUserData["ItemId"]);
|
||||
|
||||
var albumUserData = albumResult["UserData"] as Dictionary<string, object>;
|
||||
Assert.NotNull(albumUserData);
|
||||
Assert.Contains("ItemId", albumUserData.Keys);
|
||||
Assert.Equal("album-id", albumUserData["ItemId"]);
|
||||
|
||||
var artistUserData = artistResult["UserData"] as Dictionary<string, object>;
|
||||
Assert.NotNull(artistUserData);
|
||||
Assert.Contains("ItemId", artistUserData.Keys);
|
||||
Assert.Equal("artist-id", artistUserData["ItemId"]);
|
||||
}
|
||||
}
|
||||
2989
allstarr/Controllers/AdminController.cs
Normal file
2989
allstarr/Controllers/AdminController.cs
Normal file
@@ -0,0 +1,2989 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Models.Spotify;
|
||||
using allstarr.Services.Spotify;
|
||||
using allstarr.Services.Jellyfin;
|
||||
using allstarr.Services.Common;
|
||||
using allstarr.Services;
|
||||
using allstarr.Filters;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Runtime;
|
||||
|
||||
namespace allstarr.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Admin API controller for the web dashboard.
|
||||
/// Provides endpoints for viewing status, playlists, and modifying configuration.
|
||||
/// Only accessible on internal admin port (5275) - not exposed through reverse proxy.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/admin")]
|
||||
[ServiceFilter(typeof(AdminPortFilter))]
|
||||
public class AdminController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<AdminController> _logger;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly SpotifyApiSettings _spotifyApiSettings;
|
||||
private readonly SpotifyImportSettings _spotifyImportSettings;
|
||||
private readonly JellyfinSettings _jellyfinSettings;
|
||||
private readonly DeezerSettings _deezerSettings;
|
||||
private readonly QobuzSettings _qobuzSettings;
|
||||
private readonly SquidWTFSettings _squidWtfSettings;
|
||||
private readonly MusicBrainzSettings _musicBrainzSettings;
|
||||
private readonly SpotifyApiClient _spotifyClient;
|
||||
private readonly SpotifyPlaylistFetcher _playlistFetcher;
|
||||
private readonly SpotifyTrackMatchingService? _matchingService;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly HttpClient _jellyfinHttpClient;
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly string _envFilePath;
|
||||
private readonly List<string> _squidWtfApiUrls;
|
||||
private static int _urlIndex = 0;
|
||||
private static readonly object _urlIndexLock = new();
|
||||
private const string CacheDirectory = "/app/cache/spotify";
|
||||
|
||||
public AdminController(
|
||||
ILogger<AdminController> logger,
|
||||
IConfiguration configuration,
|
||||
IWebHostEnvironment environment,
|
||||
IOptions<SpotifyApiSettings> spotifyApiSettings,
|
||||
IOptions<SpotifyImportSettings> spotifyImportSettings,
|
||||
IOptions<JellyfinSettings> jellyfinSettings,
|
||||
IOptions<DeezerSettings> deezerSettings,
|
||||
IOptions<QobuzSettings> qobuzSettings,
|
||||
IOptions<SquidWTFSettings> squidWtfSettings,
|
||||
IOptions<MusicBrainzSettings> musicBrainzSettings,
|
||||
SpotifyApiClient spotifyClient,
|
||||
SpotifyPlaylistFetcher playlistFetcher,
|
||||
RedisCacheService cache,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IServiceProvider serviceProvider,
|
||||
SpotifyTrackMatchingService? matchingService = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_configuration = configuration;
|
||||
_environment = environment;
|
||||
_spotifyApiSettings = spotifyApiSettings.Value;
|
||||
_spotifyImportSettings = spotifyImportSettings.Value;
|
||||
_jellyfinSettings = jellyfinSettings.Value;
|
||||
_deezerSettings = deezerSettings.Value;
|
||||
_qobuzSettings = qobuzSettings.Value;
|
||||
_squidWtfSettings = squidWtfSettings.Value;
|
||||
_musicBrainzSettings = musicBrainzSettings.Value;
|
||||
_spotifyClient = spotifyClient;
|
||||
_playlistFetcher = playlistFetcher;
|
||||
_matchingService = matchingService;
|
||||
_cache = cache;
|
||||
_jellyfinHttpClient = httpClientFactory.CreateClient();
|
||||
_serviceProvider = serviceProvider;
|
||||
|
||||
// Decode SquidWTF base URLs
|
||||
_squidWtfApiUrls = DecodeSquidWtfUrls();
|
||||
|
||||
// .env file path is always /app/.env in Docker (mounted from host)
|
||||
// In development, it's in the parent directory of ContentRootPath
|
||||
_envFilePath = _environment.IsDevelopment()
|
||||
? Path.Combine(_environment.ContentRootPath, "..", ".env")
|
||||
: "/app/.env";
|
||||
|
||||
_logger.LogInformation("Admin controller initialized. .env path: {EnvFilePath}", _envFilePath);
|
||||
}
|
||||
|
||||
private static List<string> DecodeSquidWtfUrls()
|
||||
{
|
||||
var encodedUrls = new[]
|
||||
{
|
||||
"aHR0cHM6Ly90cml0b24uc3F1aWQud3Rm", // triton
|
||||
"aHR0cHM6Ly90aWRhbC1hcGkuYmluaW11bS5vcmc=", // binimum
|
||||
"aHR0cHM6Ly90aWRhbC5raW5vcGx1cy5vbmxpbmU=", // kinoplus
|
||||
"aHR0cHM6Ly9oaWZpLXR3by5zcG90aXNhdmVyLm5ldA==", // spoti-2
|
||||
"aHR0cHM6Ly9oaWZpLW9uZS5zcG90aXNhdmVyLm5ldA==", // spoti-1
|
||||
"aHR0cHM6Ly93b2xmLnFxZGwuc2l0ZQ==", // wolf
|
||||
"aHR0cDovL2h1bmQucXFkbC5zaXRl", // hund
|
||||
"aHR0cHM6Ly9rYXR6ZS5xcWRsLnNpdGU=", // katze
|
||||
"aHR0cHM6Ly92b2dlbC5xcWRsLnNpdGU=", // vogel
|
||||
"aHR0cHM6Ly9tYXVzLnFxZGwuc2l0ZQ==" // maus
|
||||
};
|
||||
|
||||
return encodedUrls
|
||||
.Select(encoded => System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(encoded)))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper method to safely check if a dynamic cache result has a value
|
||||
/// Handles the case where JsonElement cannot be compared to null directly
|
||||
/// </summary>
|
||||
private static bool HasValue(object? obj)
|
||||
{
|
||||
if (obj == null) return false;
|
||||
if (obj is JsonElement jsonEl) return jsonEl.ValueKind != JsonValueKind.Null && jsonEl.ValueKind != JsonValueKind.Undefined;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get current system status and configuration
|
||||
/// </summary>
|
||||
[HttpGet("status")]
|
||||
public IActionResult GetStatus()
|
||||
{
|
||||
// Determine Spotify auth status based on configuration only
|
||||
// DO NOT call Spotify API here - this endpoint is polled frequently
|
||||
var spotifyAuthStatus = "not_configured";
|
||||
string? spotifyUser = null;
|
||||
|
||||
if (_spotifyApiSettings.Enabled && !string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie))
|
||||
{
|
||||
// If cookie is set, assume it's working until proven otherwise
|
||||
// Actual validation happens when playlists are fetched
|
||||
spotifyAuthStatus = "configured";
|
||||
spotifyUser = "(cookie set)";
|
||||
}
|
||||
else if (_spotifyApiSettings.Enabled)
|
||||
{
|
||||
spotifyAuthStatus = "missing_cookie";
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
version = "1.0.0",
|
||||
backendType = _configuration.GetValue<string>("Backend:Type") ?? "Jellyfin",
|
||||
jellyfinUrl = _jellyfinSettings.Url,
|
||||
spotify = new
|
||||
{
|
||||
apiEnabled = _spotifyApiSettings.Enabled,
|
||||
authStatus = spotifyAuthStatus,
|
||||
user = spotifyUser,
|
||||
hasCookie = !string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie),
|
||||
cookieSetDate = _spotifyApiSettings.SessionCookieSetDate,
|
||||
cacheDurationMinutes = _spotifyApiSettings.CacheDurationMinutes,
|
||||
preferIsrcMatching = _spotifyApiSettings.PreferIsrcMatching
|
||||
},
|
||||
spotifyImport = new
|
||||
{
|
||||
enabled = _spotifyImportSettings.Enabled,
|
||||
syncTime = $"{_spotifyImportSettings.SyncStartHour:D2}:{_spotifyImportSettings.SyncStartMinute:D2}",
|
||||
syncWindowHours = _spotifyImportSettings.SyncWindowHours,
|
||||
playlistCount = _spotifyImportSettings.Playlists.Count
|
||||
},
|
||||
deezer = new
|
||||
{
|
||||
hasArl = !string.IsNullOrEmpty(_deezerSettings.Arl),
|
||||
quality = _deezerSettings.Quality ?? "FLAC"
|
||||
},
|
||||
qobuz = new
|
||||
{
|
||||
hasToken = !string.IsNullOrEmpty(_qobuzSettings.UserAuthToken),
|
||||
quality = _qobuzSettings.Quality ?? "FLAC"
|
||||
},
|
||||
squidWtf = new
|
||||
{
|
||||
quality = _squidWtfSettings.Quality ?? "LOSSLESS"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a random SquidWTF base URL for searching (round-robin)
|
||||
/// </summary>
|
||||
[HttpGet("squidwtf-base-url")]
|
||||
public IActionResult GetSquidWtfBaseUrl()
|
||||
{
|
||||
if (_squidWtfApiUrls.Count == 0)
|
||||
{
|
||||
return NotFound(new { error = "No SquidWTF base URLs configured" });
|
||||
}
|
||||
|
||||
string baseUrl;
|
||||
lock (_urlIndexLock)
|
||||
{
|
||||
baseUrl = _squidWtfApiUrls[_urlIndex];
|
||||
_urlIndex = (_urlIndex + 1) % _squidWtfApiUrls.Count;
|
||||
}
|
||||
|
||||
return Ok(new { baseUrl });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get list of configured playlists with their current data
|
||||
/// </summary>
|
||||
[HttpGet("playlists")]
|
||||
public async Task<IActionResult> GetPlaylists()
|
||||
{
|
||||
var playlists = new List<object>();
|
||||
|
||||
// Read playlists directly from .env file to get the latest configuration
|
||||
// (IOptions is cached and doesn't reload after .env changes)
|
||||
var configuredPlaylists = await ReadPlaylistsFromEnvFile();
|
||||
|
||||
foreach (var config in configuredPlaylists)
|
||||
{
|
||||
var playlistInfo = new Dictionary<string, object?>
|
||||
{
|
||||
["name"] = config.Name,
|
||||
["id"] = config.Id,
|
||||
["jellyfinId"] = config.JellyfinId,
|
||||
["localTracksPosition"] = config.LocalTracksPosition.ToString(),
|
||||
["trackCount"] = 0,
|
||||
["localTracks"] = 0,
|
||||
["externalTracks"] = 0,
|
||||
["lastFetched"] = null as DateTime?,
|
||||
["cacheAge"] = null as string
|
||||
};
|
||||
|
||||
// Get Spotify playlist track count from cache
|
||||
var cacheFilePath = Path.Combine(CacheDirectory, $"{SanitizeFileName(config.Name)}_spotify.json");
|
||||
int spotifyTrackCount = 0;
|
||||
|
||||
if (System.IO.File.Exists(cacheFilePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = await System.IO.File.ReadAllTextAsync(cacheFilePath);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("tracks", out var tracks))
|
||||
{
|
||||
spotifyTrackCount = tracks.GetArrayLength();
|
||||
playlistInfo["trackCount"] = spotifyTrackCount;
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("fetchedAt", out var fetchedAt))
|
||||
{
|
||||
var fetchedTime = fetchedAt.GetDateTime();
|
||||
playlistInfo["lastFetched"] = fetchedTime;
|
||||
var age = DateTime.UtcNow - fetchedTime;
|
||||
playlistInfo["cacheAge"] = age.TotalHours < 1
|
||||
? $"{age.TotalMinutes:F0}m"
|
||||
: $"{age.TotalHours:F1}h";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to read cache for playlist {Name}", config.Name);
|
||||
}
|
||||
}
|
||||
|
||||
// Get current Jellyfin playlist track count
|
||||
if (!string.IsNullOrEmpty(config.JellyfinId))
|
||||
{
|
||||
try
|
||||
{
|
||||
// Jellyfin requires UserId parameter to fetch playlist items
|
||||
var userId = _jellyfinSettings.UserId;
|
||||
|
||||
// If no user configured, try to get the first user
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
{
|
||||
var usersResponse = await _jellyfinHttpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get, $"{_jellyfinSettings.Url}/Users")
|
||||
{
|
||||
Headers = { { "X-Emby-Authorization", GetJellyfinAuthHeader() } }
|
||||
});
|
||||
|
||||
if (usersResponse.IsSuccessStatusCode)
|
||||
{
|
||||
var usersJson = await usersResponse.Content.ReadAsStringAsync();
|
||||
using var usersDoc = JsonDocument.Parse(usersJson);
|
||||
if (usersDoc.RootElement.GetArrayLength() > 0)
|
||||
{
|
||||
userId = usersDoc.RootElement[0].GetProperty("Id").GetString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
{
|
||||
_logger.LogWarning("No user ID available to fetch playlist items for {Name}", config.Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
var url = $"{_jellyfinSettings.Url}/Playlists/{config.JellyfinId}/Items?UserId={userId}&Fields=Path";
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
||||
|
||||
_logger.LogDebug("Fetching Jellyfin playlist items for {Name} from {Url}", config.Name, url);
|
||||
|
||||
var response = await _jellyfinHttpClient.SendAsync(request);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var jellyfinJson = await response.Content.ReadAsStringAsync();
|
||||
using var jellyfinDoc = JsonDocument.Parse(jellyfinJson);
|
||||
|
||||
if (jellyfinDoc.RootElement.TryGetProperty("Items", out var items))
|
||||
{
|
||||
// Get Spotify tracks to match against
|
||||
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name);
|
||||
|
||||
// Try to use the pre-built playlist cache first (includes manual mappings!)
|
||||
var playlistItemsCacheKey = $"spotify:playlist:items:{config.Name}";
|
||||
|
||||
List<Dictionary<string, object?>>? cachedPlaylistItems = null;
|
||||
try
|
||||
{
|
||||
cachedPlaylistItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(playlistItemsCacheKey);
|
||||
}
|
||||
catch (Exception cacheEx)
|
||||
{
|
||||
_logger.LogWarning(cacheEx, "Failed to deserialize playlist cache for {Playlist}", config.Name);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Checking cache for {Playlist}: {CacheKey}, Found: {Found}, Count: {Count}",
|
||||
config.Name, playlistItemsCacheKey, cachedPlaylistItems != null, cachedPlaylistItems?.Count ?? 0);
|
||||
|
||||
if (cachedPlaylistItems != null && cachedPlaylistItems.Count > 0)
|
||||
{
|
||||
// Use the pre-built cache which respects manual mappings
|
||||
var localCount = 0;
|
||||
var externalCount = 0;
|
||||
|
||||
foreach (var item in cachedPlaylistItems)
|
||||
{
|
||||
// Check if it's external by looking for ProviderIds (external songs have this)
|
||||
var isExternal = false;
|
||||
if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null)
|
||||
{
|
||||
// Has ProviderIds = external track
|
||||
isExternal = true;
|
||||
}
|
||||
|
||||
if (isExternal)
|
||||
{
|
||||
externalCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
localCount++;
|
||||
}
|
||||
}
|
||||
|
||||
var externalMissingCount = spotifyTracks.Count - cachedPlaylistItems.Count;
|
||||
if (externalMissingCount < 0) externalMissingCount = 0;
|
||||
|
||||
playlistInfo["localTracks"] = localCount;
|
||||
playlistInfo["externalMatched"] = externalCount;
|
||||
playlistInfo["externalMissing"] = externalMissingCount;
|
||||
playlistInfo["externalTotal"] = externalCount + externalMissingCount;
|
||||
playlistInfo["totalInJellyfin"] = cachedPlaylistItems.Count;
|
||||
playlistInfo["totalPlayable"] = localCount + externalCount; // Total tracks that will be served
|
||||
|
||||
_logger.LogInformation("Playlist {Name} (from cache): {Total} Spotify tracks, {Local} local, {ExtMatched} external matched, {ExtMissing} external missing, {Playable} total playable",
|
||||
config.Name, spotifyTracks.Count, localCount, externalCount, externalMissingCount, localCount + externalCount);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback: Build list of local tracks from Jellyfin (match by name only)
|
||||
var localTracks = new List<(string Title, string Artist)>();
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
|
||||
var artist = "";
|
||||
|
||||
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
|
||||
{
|
||||
artist = artistsEl[0].GetString() ?? "";
|
||||
}
|
||||
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
|
||||
{
|
||||
artist = albumArtistEl.GetString() ?? "";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(title))
|
||||
{
|
||||
localTracks.Add((title, artist));
|
||||
}
|
||||
}
|
||||
|
||||
// Get matched external tracks cache once
|
||||
var matchedTracksKey = $"spotify:matched:ordered:{config.Name}";
|
||||
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
|
||||
var matchedSpotifyIds = new HashSet<string>(
|
||||
matchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>()
|
||||
);
|
||||
|
||||
var localCount = 0;
|
||||
var externalMatchedCount = 0;
|
||||
var externalMissingCount = 0;
|
||||
|
||||
// Match each Spotify track to determine if it's local, external, or missing
|
||||
foreach (var track in spotifyTracks)
|
||||
{
|
||||
var isLocal = false;
|
||||
|
||||
if (localTracks.Count > 0)
|
||||
{
|
||||
var bestMatch = localTracks
|
||||
.Select(local => new
|
||||
{
|
||||
Local = local,
|
||||
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, local.Title),
|
||||
ArtistScore = FuzzyMatcher.CalculateSimilarity(track.PrimaryArtist, local.Artist)
|
||||
})
|
||||
.Select(x => new
|
||||
{
|
||||
x.Local,
|
||||
x.TitleScore,
|
||||
x.ArtistScore,
|
||||
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
|
||||
})
|
||||
.OrderByDescending(x => x.TotalScore)
|
||||
.FirstOrDefault();
|
||||
|
||||
// Use 70% threshold (same as playback matching)
|
||||
if (bestMatch != null && bestMatch.TotalScore >= 70)
|
||||
{
|
||||
isLocal = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isLocal)
|
||||
{
|
||||
localCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Check if external track is matched
|
||||
if (matchedSpotifyIds.Contains(track.SpotifyId))
|
||||
{
|
||||
externalMatchedCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
externalMissingCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
playlistInfo["localTracks"] = localCount;
|
||||
playlistInfo["externalMatched"] = externalMatchedCount;
|
||||
playlistInfo["externalMissing"] = externalMissingCount;
|
||||
playlistInfo["externalTotal"] = externalMatchedCount + externalMissingCount;
|
||||
playlistInfo["totalInJellyfin"] = localCount + externalMatchedCount;
|
||||
playlistInfo["totalPlayable"] = localCount + externalMatchedCount; // Total tracks that will be served
|
||||
|
||||
_logger.LogDebug("Playlist {Name} (fallback): {Total} Spotify tracks, {Local} local, {ExtMatched} external matched, {ExtMissing} external missing, {Playable} total playable",
|
||||
config.Name, spotifyTracks.Count, localCount, externalMatchedCount, externalMissingCount, localCount + externalMatchedCount);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("No Items property in Jellyfin response for {Name}", config.Name);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Failed to get Jellyfin playlist {Name}: {StatusCode}",
|
||||
config.Name, response.StatusCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to get Jellyfin playlist tracks for {Name}", config.Name);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Playlist {Name} has no JellyfinId configured", config.Name);
|
||||
}
|
||||
|
||||
// Get lyrics completion status
|
||||
try
|
||||
{
|
||||
var tracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name);
|
||||
if (tracks.Count > 0)
|
||||
{
|
||||
var lyricsWithCount = 0;
|
||||
var lyricsWithoutCount = 0;
|
||||
|
||||
foreach (var track in tracks)
|
||||
{
|
||||
var cacheKey = $"lyrics:{track.PrimaryArtist}:{track.Title}:{track.Album}:{track.DurationMs / 1000}";
|
||||
var existingLyrics = await _cache.GetStringAsync(cacheKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(existingLyrics))
|
||||
{
|
||||
lyricsWithCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
lyricsWithoutCount++;
|
||||
}
|
||||
}
|
||||
|
||||
playlistInfo["lyricsTotal"] = tracks.Count;
|
||||
playlistInfo["lyricsCached"] = lyricsWithCount;
|
||||
playlistInfo["lyricsMissing"] = lyricsWithoutCount;
|
||||
playlistInfo["lyricsPercentage"] = tracks.Count > 0
|
||||
? (int)Math.Round((double)lyricsWithCount / tracks.Count * 100)
|
||||
: 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
playlistInfo["lyricsTotal"] = 0;
|
||||
playlistInfo["lyricsCached"] = 0;
|
||||
playlistInfo["lyricsMissing"] = 0;
|
||||
playlistInfo["lyricsPercentage"] = 0;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to get lyrics completion for playlist {Name}", config.Name);
|
||||
playlistInfo["lyricsTotal"] = 0;
|
||||
playlistInfo["lyricsCached"] = 0;
|
||||
playlistInfo["lyricsMissing"] = 0;
|
||||
playlistInfo["lyricsPercentage"] = 0;
|
||||
}
|
||||
|
||||
playlists.Add(playlistInfo);
|
||||
}
|
||||
|
||||
return Ok(new { playlists });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get tracks for a specific playlist with local/external status
|
||||
/// </summary>
|
||||
[HttpGet("playlists/{name}/tracks")]
|
||||
public async Task<IActionResult> GetPlaylistTracks(string name)
|
||||
{
|
||||
var decodedName = Uri.UnescapeDataString(name);
|
||||
|
||||
// Get Spotify tracks
|
||||
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(decodedName);
|
||||
|
||||
// Get the playlist config to find Jellyfin ID
|
||||
var playlistConfig = _spotifyImportSettings.Playlists
|
||||
.FirstOrDefault(p => p.Name.Equals(decodedName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var tracksWithStatus = new List<object>();
|
||||
|
||||
if (!string.IsNullOrEmpty(playlistConfig?.JellyfinId))
|
||||
{
|
||||
// Get existing tracks from Jellyfin to determine local/external status
|
||||
var userId = _jellyfinSettings.UserId;
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
try
|
||||
{
|
||||
var url = $"{_jellyfinSettings.Url}/Playlists/{playlistConfig.JellyfinId}/Items?UserId={userId}";
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
||||
|
||||
var response = await _jellyfinHttpClient.SendAsync(request);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
// Build list of local tracks (match by name only - no Spotify IDs!)
|
||||
var localTracks = new List<(string Title, string Artist)>();
|
||||
if (doc.RootElement.TryGetProperty("Items", out var items))
|
||||
{
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
|
||||
var artist = "";
|
||||
|
||||
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
|
||||
{
|
||||
artist = artistsEl[0].GetString() ?? "";
|
||||
}
|
||||
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
|
||||
{
|
||||
artist = albumArtistEl.GetString() ?? "";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(title))
|
||||
{
|
||||
localTracks.Add((title, artist));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Found {Count} local tracks in Jellyfin playlist {Playlist}",
|
||||
localTracks.Count, decodedName);
|
||||
|
||||
// Get matched external tracks cache
|
||||
var matchedTracksKey = $"spotify:matched:ordered:{decodedName}";
|
||||
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
|
||||
var matchedSpotifyIds = new HashSet<string>(
|
||||
matchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>()
|
||||
);
|
||||
|
||||
// Match Spotify tracks to local tracks by name (fuzzy matching)
|
||||
foreach (var track in spotifyTracks)
|
||||
{
|
||||
bool? isLocal = null;
|
||||
string? externalProvider = null;
|
||||
|
||||
// FIRST: Check for manual mapping (same as SpotifyTrackMatchingService)
|
||||
var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}";
|
||||
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
|
||||
bool isManualMapping = false;
|
||||
string? manualMappingType = null;
|
||||
string? manualMappingId = null;
|
||||
|
||||
if (!string.IsNullOrEmpty(manualJellyfinId))
|
||||
{
|
||||
// Manual Jellyfin mapping exists - this track is definitely local
|
||||
isLocal = true;
|
||||
isManualMapping = true;
|
||||
manualMappingType = "jellyfin";
|
||||
manualMappingId = manualJellyfinId;
|
||||
_logger.LogDebug("✓ Manual Jellyfin mapping found for {Title}: Jellyfin ID {Id}",
|
||||
track.Title, manualJellyfinId);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Check for external manual mapping
|
||||
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
|
||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(externalMappingJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var extDoc = JsonDocument.Parse(externalMappingJson);
|
||||
var extRoot = extDoc.RootElement;
|
||||
|
||||
string? provider = null;
|
||||
string? externalId = null;
|
||||
|
||||
if (extRoot.TryGetProperty("provider", out var providerEl))
|
||||
{
|
||||
provider = providerEl.GetString();
|
||||
}
|
||||
|
||||
if (extRoot.TryGetProperty("id", out var idEl))
|
||||
{
|
||||
externalId = idEl.GetString();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(provider) && !string.IsNullOrEmpty(externalId))
|
||||
{
|
||||
// External manual mapping exists
|
||||
isLocal = false;
|
||||
externalProvider = provider;
|
||||
isManualMapping = true;
|
||||
manualMappingType = "external";
|
||||
manualMappingId = externalId;
|
||||
_logger.LogDebug("✓ Manual external mapping found for {Title}: {Provider} {ExternalId}",
|
||||
track.Title, provider, externalId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", track.Title);
|
||||
}
|
||||
}
|
||||
else if (localTracks.Count > 0)
|
||||
{
|
||||
// SECOND: No manual mapping, try fuzzy matching
|
||||
var bestMatch = localTracks
|
||||
.Select(local => new
|
||||
{
|
||||
Local = local,
|
||||
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, local.Title),
|
||||
ArtistScore = FuzzyMatcher.CalculateSimilarity(track.PrimaryArtist, local.Artist)
|
||||
})
|
||||
.Select(x => new
|
||||
{
|
||||
x.Local,
|
||||
x.TitleScore,
|
||||
x.ArtistScore,
|
||||
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
|
||||
})
|
||||
.OrderByDescending(x => x.TotalScore)
|
||||
.FirstOrDefault();
|
||||
|
||||
// Use 70% threshold (same as playback matching)
|
||||
if (bestMatch != null && bestMatch.TotalScore >= 70)
|
||||
{
|
||||
isLocal = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If not local, check if it's externally matched or missing
|
||||
if (isLocal != true)
|
||||
{
|
||||
// Check if there's a manual external mapping
|
||||
if (isManualMapping && manualMappingType == "external")
|
||||
{
|
||||
// Track has manual external mapping - it's available externally
|
||||
isLocal = false;
|
||||
// externalProvider already set above
|
||||
}
|
||||
else if (matchedSpotifyIds.Contains(track.SpotifyId))
|
||||
{
|
||||
// Track is externally matched (search succeeded)
|
||||
isLocal = false;
|
||||
externalProvider = "SquidWTF"; // Default to SquidWTF for external matches
|
||||
}
|
||||
else
|
||||
{
|
||||
// Track is missing (search failed)
|
||||
isLocal = null;
|
||||
externalProvider = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Check lyrics status (only from our cache - lrclib/Spotify)
|
||||
// Note: For local tracks, Jellyfin may have embedded lyrics that we don't check here
|
||||
// Those will be served directly by Jellyfin when requested
|
||||
var cacheKey = $"lyrics:{track.PrimaryArtist}:{track.Title}:{track.Album}:{track.DurationMs / 1000}";
|
||||
var existingLyrics = await _cache.GetStringAsync(cacheKey);
|
||||
var hasLyrics = !string.IsNullOrEmpty(existingLyrics);
|
||||
|
||||
tracksWithStatus.Add(new
|
||||
{
|
||||
position = track.Position,
|
||||
title = track.Title,
|
||||
artists = track.Artists,
|
||||
album = track.Album,
|
||||
isrc = track.Isrc,
|
||||
spotifyId = track.SpotifyId,
|
||||
durationMs = track.DurationMs,
|
||||
albumArtUrl = track.AlbumArtUrl,
|
||||
isLocal = isLocal,
|
||||
externalProvider = externalProvider,
|
||||
searchQuery = isLocal != true ? $"{track.Title} {track.PrimaryArtist}" : null, // Set for both external and missing
|
||||
isManualMapping = isManualMapping,
|
||||
manualMappingType = manualMappingType,
|
||||
manualMappingId = manualMappingId,
|
||||
hasLyrics = hasLyrics
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
name = decodedName,
|
||||
trackCount = spotifyTracks.Count,
|
||||
tracks = tracksWithStatus
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to get local track status for {Playlist}", decodedName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, we couldn't get local tracks from Jellyfin
|
||||
// Just return tracks with basic external/missing status based on cache
|
||||
var fallbackMatchedTracksKey = $"spotify:matched:ordered:{decodedName}";
|
||||
var fallbackMatchedTracks = await _cache.GetAsync<List<MatchedTrack>>(fallbackMatchedTracksKey);
|
||||
var fallbackMatchedSpotifyIds = new HashSet<string>(
|
||||
fallbackMatchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>()
|
||||
);
|
||||
|
||||
foreach (var track in spotifyTracks)
|
||||
{
|
||||
bool? isLocal = null;
|
||||
string? externalProvider = null;
|
||||
|
||||
// Check for manual mappings
|
||||
var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}";
|
||||
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(manualJellyfinId))
|
||||
{
|
||||
isLocal = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Check for external manual mapping
|
||||
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
|
||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(externalMappingJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var extDoc = JsonDocument.Parse(externalMappingJson);
|
||||
var extRoot = extDoc.RootElement;
|
||||
|
||||
string? provider = null;
|
||||
|
||||
if (extRoot.TryGetProperty("provider", out var providerEl))
|
||||
{
|
||||
provider = providerEl.GetString();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(provider))
|
||||
{
|
||||
isLocal = false;
|
||||
externalProvider = provider;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", track.Title);
|
||||
}
|
||||
}
|
||||
else if (fallbackMatchedSpotifyIds.Contains(track.SpotifyId))
|
||||
{
|
||||
// Track is externally matched (search succeeded)
|
||||
isLocal = false;
|
||||
externalProvider = "SquidWTF"; // Default to SquidWTF for external matches
|
||||
}
|
||||
else
|
||||
{
|
||||
// Track is missing (search failed)
|
||||
isLocal = null;
|
||||
externalProvider = null;
|
||||
}
|
||||
}
|
||||
|
||||
tracksWithStatus.Add(new
|
||||
{
|
||||
position = track.Position,
|
||||
title = track.Title,
|
||||
artists = track.Artists,
|
||||
album = track.Album,
|
||||
isrc = track.Isrc,
|
||||
spotifyId = track.SpotifyId,
|
||||
durationMs = track.DurationMs,
|
||||
albumArtUrl = track.AlbumArtUrl,
|
||||
isLocal = isLocal,
|
||||
externalProvider = externalProvider,
|
||||
searchQuery = isLocal != true ? $"{track.Title} {track.PrimaryArtist}" : null // Set for both external and missing
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
name = decodedName,
|
||||
trackCount = spotifyTracks.Count,
|
||||
tracks = tracksWithStatus
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trigger a manual refresh of all playlists
|
||||
/// </summary>
|
||||
[HttpPost("playlists/refresh")]
|
||||
public async Task<IActionResult> RefreshPlaylists()
|
||||
{
|
||||
_logger.LogInformation("Manual playlist refresh triggered from admin UI");
|
||||
await _playlistFetcher.TriggerFetchAsync();
|
||||
return Ok(new { message = "Playlist refresh triggered", timestamp = DateTime.UtcNow });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trigger track matching for a specific playlist
|
||||
/// </summary>
|
||||
[HttpPost("playlists/{name}/match")]
|
||||
public async Task<IActionResult> MatchPlaylistTracks(string name)
|
||||
{
|
||||
var decodedName = Uri.UnescapeDataString(name);
|
||||
_logger.LogInformation("Manual track matching triggered for playlist: {Name}", decodedName);
|
||||
|
||||
if (_matchingService == null)
|
||||
{
|
||||
return BadRequest(new { error = "Track matching service is not available" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _matchingService.TriggerMatchingForPlaylistAsync(decodedName);
|
||||
return Ok(new { message = $"Track matching triggered for {decodedName}", timestamp = DateTime.UtcNow });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to trigger track matching for {Name}", decodedName);
|
||||
return StatusCode(500, new { error = "Failed to trigger track matching", details = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Search Jellyfin library for tracks (for manual mapping)
|
||||
/// </summary>
|
||||
[HttpGet("jellyfin/search")]
|
||||
public async Task<IActionResult> SearchJellyfinTracks([FromQuery] string query)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return BadRequest(new { error = "Query is required" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var userId = _jellyfinSettings.UserId;
|
||||
|
||||
// Build URL with UserId if available
|
||||
var url = $"{_jellyfinSettings.Url}/Items?searchTerm={Uri.EscapeDataString(query)}&includeItemTypes=Audio&recursive=true&limit=20";
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
url += $"&UserId={userId}";
|
||||
}
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
||||
|
||||
_logger.LogDebug("Searching Jellyfin: {Url}", url);
|
||||
|
||||
var response = await _jellyfinHttpClient.SendAsync(request);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogWarning("Jellyfin search failed: {StatusCode} - {Error}", response.StatusCode, errorBody);
|
||||
return StatusCode((int)response.StatusCode, new { error = "Failed to search Jellyfin" });
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
var tracks = new List<object>();
|
||||
if (doc.RootElement.TryGetProperty("Items", out var items))
|
||||
{
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
// Verify it's actually an Audio item
|
||||
var type = item.TryGetProperty("Type", out var typeEl) ? typeEl.GetString() : "";
|
||||
if (type != "Audio")
|
||||
{
|
||||
_logger.LogDebug("Skipping non-audio item: {Type}", type);
|
||||
continue;
|
||||
}
|
||||
|
||||
var id = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() : "";
|
||||
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : "";
|
||||
var album = item.TryGetProperty("Album", out var albumEl) ? albumEl.GetString() : "";
|
||||
var artist = "";
|
||||
|
||||
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
|
||||
{
|
||||
artist = artistsEl[0].GetString() ?? "";
|
||||
}
|
||||
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
|
||||
{
|
||||
artist = albumArtistEl.GetString() ?? "";
|
||||
}
|
||||
|
||||
tracks.Add(new { id, title, artist, album });
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(new { tracks });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to search Jellyfin tracks");
|
||||
return StatusCode(500, new { error = "Search failed" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get track details by Jellyfin ID (for URL-based mapping)
|
||||
/// </summary>
|
||||
[HttpGet("jellyfin/track/{id}")]
|
||||
public async Task<IActionResult> GetJellyfinTrack(string id)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
return BadRequest(new { error = "Track ID is required" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var userId = _jellyfinSettings.UserId;
|
||||
|
||||
var url = $"{_jellyfinSettings.Url}/Items/{id}";
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
url += $"?UserId={userId}";
|
||||
}
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
||||
|
||||
_logger.LogDebug("Fetching Jellyfin track {Id} from {Url}", id, url);
|
||||
|
||||
var response = await _jellyfinHttpClient.SendAsync(request);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogWarning("Failed to fetch Jellyfin track {Id}: {StatusCode} - {Error}",
|
||||
id, response.StatusCode, errorBody);
|
||||
return StatusCode((int)response.StatusCode, new { error = "Track not found in Jellyfin" });
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
var item = doc.RootElement;
|
||||
|
||||
// Verify it's an Audio item
|
||||
var type = item.TryGetProperty("Type", out var typeEl) ? typeEl.GetString() : "";
|
||||
if (type != "Audio")
|
||||
{
|
||||
_logger.LogWarning("Item {Id} is not an Audio track, it's a {Type}", id, type);
|
||||
return BadRequest(new { error = $"Item is not an audio track (it's a {type})" });
|
||||
}
|
||||
|
||||
var trackId = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() : "";
|
||||
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : "";
|
||||
var album = item.TryGetProperty("Album", out var albumEl) ? albumEl.GetString() : "";
|
||||
var artist = "";
|
||||
|
||||
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
|
||||
{
|
||||
artist = artistsEl[0].GetString() ?? "";
|
||||
}
|
||||
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
|
||||
{
|
||||
artist = albumArtistEl.GetString() ?? "";
|
||||
}
|
||||
|
||||
_logger.LogInformation("Found Jellyfin track: {Title} by {Artist}", title, artist);
|
||||
|
||||
return Ok(new { id = trackId, title, artist, album });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get Jellyfin track {Id}", id);
|
||||
return StatusCode(500, new { error = "Failed to get track details" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save manual track mapping
|
||||
/// </summary>
|
||||
[HttpPost("playlists/{name}/map")]
|
||||
public async Task<IActionResult> SaveManualMapping(string name, [FromBody] ManualMappingRequest request)
|
||||
{
|
||||
var decodedName = Uri.UnescapeDataString(name);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.SpotifyId))
|
||||
{
|
||||
return BadRequest(new { error = "SpotifyId is required" });
|
||||
}
|
||||
|
||||
// Validate that either Jellyfin mapping or external mapping is provided
|
||||
var hasJellyfinMapping = !string.IsNullOrWhiteSpace(request.JellyfinId);
|
||||
var hasExternalMapping = !string.IsNullOrWhiteSpace(request.ExternalProvider) && !string.IsNullOrWhiteSpace(request.ExternalId);
|
||||
|
||||
if (!hasJellyfinMapping && !hasExternalMapping)
|
||||
{
|
||||
return BadRequest(new { error = "Either JellyfinId or (ExternalProvider + ExternalId) is required" });
|
||||
}
|
||||
|
||||
if (hasJellyfinMapping && hasExternalMapping)
|
||||
{
|
||||
return BadRequest(new { error = "Cannot specify both Jellyfin and external mapping for the same track" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (hasJellyfinMapping)
|
||||
{
|
||||
// Store Jellyfin mapping in cache (NO EXPIRATION - manual mappings are permanent)
|
||||
var mappingKey = $"spotify:manual-map:{decodedName}:{request.SpotifyId}";
|
||||
await _cache.SetAsync(mappingKey, request.JellyfinId!);
|
||||
|
||||
// Also save to file for persistence across restarts
|
||||
await SaveManualMappingToFileAsync(decodedName, request.SpotifyId, request.JellyfinId!, null, null);
|
||||
|
||||
_logger.LogInformation("Manual Jellyfin mapping saved: {Playlist} - Spotify {SpotifyId} → Jellyfin {JellyfinId}",
|
||||
decodedName, request.SpotifyId, request.JellyfinId);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Store external mapping in cache (NO EXPIRATION - manual mappings are permanent)
|
||||
var externalMappingKey = $"spotify:external-map:{decodedName}:{request.SpotifyId}";
|
||||
var normalizedProvider = request.ExternalProvider!.ToLowerInvariant(); // Normalize to lowercase
|
||||
var externalMapping = new { provider = normalizedProvider, id = request.ExternalId };
|
||||
await _cache.SetAsync(externalMappingKey, externalMapping);
|
||||
|
||||
// Also save to file for persistence across restarts
|
||||
await SaveManualMappingToFileAsync(decodedName, request.SpotifyId, null, normalizedProvider, request.ExternalId!);
|
||||
|
||||
_logger.LogInformation("Manual external mapping saved: {Playlist} - Spotify {SpotifyId} → {Provider} {ExternalId}",
|
||||
decodedName, request.SpotifyId, normalizedProvider, request.ExternalId);
|
||||
}
|
||||
|
||||
// Clear all related caches to force rebuild
|
||||
var matchedCacheKey = $"spotify:matched:{decodedName}";
|
||||
var orderedCacheKey = $"spotify:matched:ordered:{decodedName}";
|
||||
var playlistItemsKey = $"spotify:playlist:items:{decodedName}";
|
||||
|
||||
await _cache.DeleteAsync(matchedCacheKey);
|
||||
await _cache.DeleteAsync(orderedCacheKey);
|
||||
await _cache.DeleteAsync(playlistItemsKey);
|
||||
|
||||
// Also delete file caches to force rebuild
|
||||
try
|
||||
{
|
||||
var cacheDir = "/app/cache/spotify";
|
||||
var safeName = string.Join("_", decodedName.Split(Path.GetInvalidFileNameChars()));
|
||||
var matchedFile = Path.Combine(cacheDir, $"{safeName}_matched.json");
|
||||
var itemsFile = Path.Combine(cacheDir, $"{safeName}_items.json");
|
||||
|
||||
if (System.IO.File.Exists(matchedFile))
|
||||
{
|
||||
System.IO.File.Delete(matchedFile);
|
||||
_logger.LogDebug("Deleted matched tracks file cache for {Playlist}", decodedName);
|
||||
}
|
||||
|
||||
if (System.IO.File.Exists(itemsFile))
|
||||
{
|
||||
System.IO.File.Delete(itemsFile);
|
||||
_logger.LogDebug("Deleted playlist items file cache for {Playlist}", decodedName);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to delete file caches for {Playlist}", decodedName);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Cleared playlist caches for {Playlist} to force rebuild", decodedName);
|
||||
|
||||
// Fetch the mapped track details to return to the UI
|
||||
string? trackTitle = null;
|
||||
string? trackArtist = null;
|
||||
string? trackAlbum = null;
|
||||
bool isLocalMapping = hasJellyfinMapping;
|
||||
|
||||
if (hasJellyfinMapping)
|
||||
{
|
||||
// Fetch Jellyfin track details
|
||||
try
|
||||
{
|
||||
var userId = _jellyfinSettings.UserId;
|
||||
var trackUrl = $"{_jellyfinSettings.Url}/Items/{request.JellyfinId}";
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
trackUrl += $"?UserId={userId}";
|
||||
}
|
||||
|
||||
var trackRequest = new HttpRequestMessage(HttpMethod.Get, trackUrl);
|
||||
trackRequest.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
||||
|
||||
var response = await _jellyfinHttpClient.SendAsync(trackRequest);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var trackData = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(trackData);
|
||||
var track = doc.RootElement;
|
||||
|
||||
trackTitle = track.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : null;
|
||||
trackArtist = track.TryGetProperty("AlbumArtist", out var artistEl) ? artistEl.GetString() :
|
||||
(track.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0
|
||||
? artistsEl[0].GetString() : null);
|
||||
trackAlbum = track.TryGetProperty("Album", out var albumEl) ? albumEl.GetString() : null;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Failed to fetch Jellyfin track {Id}: {StatusCode}", request.JellyfinId, response.StatusCode);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch mapped track details, but mapping was saved");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fetch external provider track details
|
||||
try
|
||||
{
|
||||
var metadataService = HttpContext.RequestServices.GetRequiredService<IMusicMetadataService>();
|
||||
var normalizedProvider = request.ExternalProvider!.ToLowerInvariant(); // Normalize to lowercase
|
||||
var externalSong = await metadataService.GetSongAsync(normalizedProvider, request.ExternalId!);
|
||||
|
||||
if (externalSong != null)
|
||||
{
|
||||
trackTitle = externalSong.Title;
|
||||
trackArtist = externalSong.Artist;
|
||||
trackAlbum = externalSong.Album;
|
||||
_logger.LogInformation("✓ Fetched external track metadata: {Title} by {Artist}", trackTitle, trackArtist);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Failed to fetch external track metadata for {Provider} ID {Id}",
|
||||
normalizedProvider, request.ExternalId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch external track metadata, but mapping was saved");
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger immediate playlist rebuild with the new mapping
|
||||
if (_matchingService != null)
|
||||
{
|
||||
_logger.LogInformation("Triggering immediate playlist rebuild for {Playlist} with new manual mapping", decodedName);
|
||||
|
||||
// Run rebuild in background with timeout to avoid blocking the response
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2)); // 2 minute timeout
|
||||
await _matchingService.TriggerMatchingForPlaylistAsync(decodedName);
|
||||
_logger.LogInformation("✓ Playlist {Playlist} rebuilt successfully with manual mapping", decodedName);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning("Playlist rebuild for {Playlist} timed out after 2 minutes", decodedName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to rebuild playlist {Playlist} after manual mapping", decodedName);
|
||||
}
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Matching service not available - playlist will rebuild on next scheduled run");
|
||||
}
|
||||
|
||||
// Return success with track details if available
|
||||
var mappedTrack = new
|
||||
{
|
||||
id = hasJellyfinMapping ? request.JellyfinId : request.ExternalId,
|
||||
title = trackTitle ?? "Unknown",
|
||||
artist = trackArtist ?? "Unknown",
|
||||
album = trackAlbum ?? "Unknown",
|
||||
isLocal = isLocalMapping,
|
||||
externalProvider = hasExternalMapping ? request.ExternalProvider!.ToLowerInvariant() : null
|
||||
};
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
message = "Mapping saved and playlist rebuild triggered",
|
||||
track = mappedTrack,
|
||||
rebuildTriggered = _matchingService != null
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save manual mapping");
|
||||
return StatusCode(500, new { error = "Failed to save mapping" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trigger track matching for all playlists
|
||||
/// </summary>
|
||||
[HttpPost("playlists/match-all")]
|
||||
public async Task<IActionResult> MatchAllPlaylistTracks()
|
||||
{
|
||||
_logger.LogInformation("Manual track matching triggered for all playlists");
|
||||
|
||||
if (_matchingService == null)
|
||||
{
|
||||
return BadRequest(new { error = "Track matching service is not available" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _matchingService.TriggerMatchingAsync();
|
||||
return Ok(new { message = "Track matching triggered for all playlists", timestamp = DateTime.UtcNow });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to trigger track matching for all playlists");
|
||||
return StatusCode(500, new { error = "Failed to trigger track matching", details = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get current configuration (safe values only)
|
||||
/// </summary>
|
||||
[HttpGet("config")]
|
||||
public IActionResult GetConfig()
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
spotifyApi = new
|
||||
{
|
||||
enabled = _spotifyApiSettings.Enabled,
|
||||
sessionCookie = MaskValue(_spotifyApiSettings.SessionCookie, showLast: 8),
|
||||
sessionCookieSetDate = _spotifyApiSettings.SessionCookieSetDate,
|
||||
cacheDurationMinutes = _spotifyApiSettings.CacheDurationMinutes,
|
||||
rateLimitDelayMs = _spotifyApiSettings.RateLimitDelayMs,
|
||||
preferIsrcMatching = _spotifyApiSettings.PreferIsrcMatching
|
||||
},
|
||||
spotifyImport = new
|
||||
{
|
||||
enabled = _spotifyImportSettings.Enabled,
|
||||
syncStartHour = _spotifyImportSettings.SyncStartHour,
|
||||
syncStartMinute = _spotifyImportSettings.SyncStartMinute,
|
||||
syncWindowHours = _spotifyImportSettings.SyncWindowHours,
|
||||
playlists = _spotifyImportSettings.Playlists.Select(p => new
|
||||
{
|
||||
name = p.Name,
|
||||
id = p.Id,
|
||||
localTracksPosition = p.LocalTracksPosition.ToString()
|
||||
})
|
||||
},
|
||||
jellyfin = new
|
||||
{
|
||||
url = _jellyfinSettings.Url,
|
||||
apiKey = MaskValue(_jellyfinSettings.ApiKey),
|
||||
userId = _jellyfinSettings.UserId ?? "(not set)",
|
||||
libraryId = _jellyfinSettings.LibraryId
|
||||
},
|
||||
deezer = new
|
||||
{
|
||||
arl = MaskValue(_deezerSettings.Arl, showLast: 8),
|
||||
arlFallback = MaskValue(_deezerSettings.ArlFallback, showLast: 8),
|
||||
quality = _deezerSettings.Quality ?? "FLAC"
|
||||
},
|
||||
qobuz = new
|
||||
{
|
||||
userAuthToken = MaskValue(_qobuzSettings.UserAuthToken, showLast: 8),
|
||||
userId = _qobuzSettings.UserId,
|
||||
quality = _qobuzSettings.Quality ?? "FLAC"
|
||||
},
|
||||
squidWtf = new
|
||||
{
|
||||
quality = _squidWtfSettings.Quality ?? "LOSSLESS"
|
||||
},
|
||||
musicBrainz = new
|
||||
{
|
||||
enabled = _musicBrainzSettings.Enabled,
|
||||
username = _musicBrainzSettings.Username ?? "(not set)",
|
||||
password = MaskValue(_musicBrainzSettings.Password),
|
||||
baseUrl = _musicBrainzSettings.BaseUrl,
|
||||
rateLimitMs = _musicBrainzSettings.RateLimitMs
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update configuration by modifying .env file
|
||||
/// </summary>
|
||||
[HttpPost("config")]
|
||||
public async Task<IActionResult> UpdateConfig([FromBody] ConfigUpdateRequest request)
|
||||
{
|
||||
if (request == null || request.Updates == null || request.Updates.Count == 0)
|
||||
{
|
||||
return BadRequest(new { error = "No updates provided" });
|
||||
}
|
||||
|
||||
_logger.LogInformation("Config update requested: {Count} changes", request.Updates.Count);
|
||||
|
||||
try
|
||||
{
|
||||
// Check if .env file exists
|
||||
if (!System.IO.File.Exists(_envFilePath))
|
||||
{
|
||||
_logger.LogWarning(".env file not found at {Path}, creating new file", _envFilePath);
|
||||
}
|
||||
|
||||
// Read current .env file or create new one
|
||||
var envContent = new Dictionary<string, string>();
|
||||
|
||||
if (System.IO.File.Exists(_envFilePath))
|
||||
{
|
||||
var lines = await System.IO.File.ReadAllLinesAsync(_envFilePath);
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith('#'))
|
||||
continue;
|
||||
|
||||
var eqIndex = line.IndexOf('=');
|
||||
if (eqIndex > 0)
|
||||
{
|
||||
var key = line[..eqIndex].Trim();
|
||||
var value = line[(eqIndex + 1)..].Trim();
|
||||
envContent[key] = value;
|
||||
}
|
||||
}
|
||||
_logger.LogInformation("Loaded {Count} existing env vars from {Path}", envContent.Count, _envFilePath);
|
||||
}
|
||||
|
||||
// Apply updates with validation
|
||||
var appliedUpdates = new List<string>();
|
||||
foreach (var (key, value) in request.Updates)
|
||||
{
|
||||
// Validate key format
|
||||
if (!IsValidEnvKey(key))
|
||||
{
|
||||
_logger.LogWarning("Invalid env key rejected: {Key}", key);
|
||||
return BadRequest(new { error = $"Invalid environment variable key: {key}" });
|
||||
}
|
||||
|
||||
envContent[key] = value;
|
||||
appliedUpdates.Add(key);
|
||||
_logger.LogInformation(" Setting {Key} = {Value}", key,
|
||||
key.Contains("COOKIE") || key.Contains("TOKEN") || key.Contains("KEY") || key.Contains("ARL")
|
||||
? "***" + (value.Length > 8 ? value[^8..] : "")
|
||||
: value);
|
||||
|
||||
// Auto-set cookie date when Spotify session cookie is updated
|
||||
if (key == "SPOTIFY_API_SESSION_COOKIE" && !string.IsNullOrEmpty(value))
|
||||
{
|
||||
var dateKey = "SPOTIFY_API_SESSION_COOKIE_SET_DATE";
|
||||
var dateValue = DateTime.UtcNow.ToString("o"); // ISO 8601 format
|
||||
envContent[dateKey] = dateValue;
|
||||
appliedUpdates.Add(dateKey);
|
||||
_logger.LogInformation(" Auto-setting {Key} to {Value}", dateKey, dateValue);
|
||||
}
|
||||
}
|
||||
|
||||
// Write back to .env file
|
||||
var newContent = string.Join("\n", envContent.Select(kv => $"{kv.Key}={kv.Value}"));
|
||||
await System.IO.File.WriteAllTextAsync(_envFilePath, newContent + "\n");
|
||||
|
||||
_logger.LogInformation("Config file updated successfully at {Path}", _envFilePath);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
message = "Configuration updated. Restart container to apply changes.",
|
||||
updatedKeys = appliedUpdates,
|
||||
requiresRestart = true,
|
||||
envFilePath = _envFilePath
|
||||
});
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Permission denied writing to .env file at {Path}", _envFilePath);
|
||||
return StatusCode(500, new {
|
||||
error = "Permission denied",
|
||||
details = "Cannot write to .env file. Check file permissions and volume mount.",
|
||||
path = _envFilePath
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update configuration at {Path}", _envFilePath);
|
||||
return StatusCode(500, new {
|
||||
error = "Failed to update configuration",
|
||||
details = ex.Message,
|
||||
path = _envFilePath
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a new playlist to the configuration
|
||||
/// </summary>
|
||||
[HttpPost("playlists")]
|
||||
public async Task<IActionResult> AddPlaylist([FromBody] AddPlaylistRequest request)
|
||||
{
|
||||
if (string.IsNullOrEmpty(request.Name) || string.IsNullOrEmpty(request.SpotifyId))
|
||||
{
|
||||
return BadRequest(new { error = "Name and SpotifyId are required" });
|
||||
}
|
||||
|
||||
_logger.LogInformation("Adding playlist: {Name} ({SpotifyId})", request.Name, request.SpotifyId);
|
||||
|
||||
// Get current playlists
|
||||
var currentPlaylists = _spotifyImportSettings.Playlists.ToList();
|
||||
|
||||
// Check for duplicates
|
||||
if (currentPlaylists.Any(p => p.Id == request.SpotifyId || p.Name == request.Name))
|
||||
{
|
||||
return BadRequest(new { error = "Playlist with this name or ID already exists" });
|
||||
}
|
||||
|
||||
// Add new playlist
|
||||
currentPlaylists.Add(new SpotifyPlaylistConfig
|
||||
{
|
||||
Name = request.Name,
|
||||
Id = request.SpotifyId,
|
||||
LocalTracksPosition = request.LocalTracksPosition == "last"
|
||||
? LocalTracksPosition.Last
|
||||
: LocalTracksPosition.First
|
||||
});
|
||||
|
||||
// Convert to JSON format for env var
|
||||
var playlistsJson = JsonSerializer.Serialize(
|
||||
currentPlaylists.Select(p => new[] { p.Name, p.Id, p.LocalTracksPosition.ToString().ToLower() }).ToArray()
|
||||
);
|
||||
|
||||
// Update .env file
|
||||
var updateRequest = new ConfigUpdateRequest
|
||||
{
|
||||
Updates = new Dictionary<string, string>
|
||||
{
|
||||
["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson
|
||||
}
|
||||
};
|
||||
|
||||
return await UpdateConfig(updateRequest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove a playlist from the configuration
|
||||
/// </summary>
|
||||
[HttpDelete("playlists/{name}")]
|
||||
public async Task<IActionResult> RemovePlaylist(string name)
|
||||
{
|
||||
var decodedName = Uri.UnescapeDataString(name);
|
||||
_logger.LogInformation("Removing playlist: {Name}", decodedName);
|
||||
|
||||
// Read current playlists from .env file (not stale in-memory config)
|
||||
var currentPlaylists = await ReadPlaylistsFromEnvFile();
|
||||
var playlist = currentPlaylists.FirstOrDefault(p => p.Name == decodedName);
|
||||
|
||||
if (playlist == null)
|
||||
{
|
||||
return NotFound(new { error = "Playlist not found" });
|
||||
}
|
||||
|
||||
currentPlaylists.Remove(playlist);
|
||||
|
||||
// Convert to JSON format for env var: [["Name","SpotifyId","JellyfinId","first|last"],...]
|
||||
var playlistsJson = JsonSerializer.Serialize(
|
||||
currentPlaylists.Select(p => new[] { p.Name, p.Id, p.JellyfinId, p.LocalTracksPosition.ToString().ToLower() }).ToArray()
|
||||
);
|
||||
|
||||
// Update .env file
|
||||
var updateRequest = new ConfigUpdateRequest
|
||||
{
|
||||
Updates = new Dictionary<string, string>
|
||||
{
|
||||
["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson
|
||||
}
|
||||
};
|
||||
|
||||
return await UpdateConfig(updateRequest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear all cached data
|
||||
/// </summary>
|
||||
[HttpPost("cache/clear")]
|
||||
public async Task<IActionResult> ClearCache()
|
||||
{
|
||||
_logger.LogInformation("Cache clear requested from admin UI");
|
||||
|
||||
var clearedFiles = 0;
|
||||
var clearedRedisKeys = 0;
|
||||
|
||||
// Clear file cache
|
||||
if (Directory.Exists(CacheDirectory))
|
||||
{
|
||||
foreach (var file in Directory.GetFiles(CacheDirectory, "*.json"))
|
||||
{
|
||||
try
|
||||
{
|
||||
System.IO.File.Delete(file);
|
||||
clearedFiles++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to delete cache file {File}", file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear ALL Redis cache keys for Spotify playlists
|
||||
// This includes matched tracks, ordered tracks, missing tracks, playlist items, etc.
|
||||
foreach (var playlist in _spotifyImportSettings.Playlists)
|
||||
{
|
||||
var keysToDelete = new[]
|
||||
{
|
||||
$"spotify:playlist:{playlist.Name}",
|
||||
$"spotify:missing:{playlist.Name}",
|
||||
$"spotify:matched:{playlist.Name}",
|
||||
$"spotify:matched:ordered:{playlist.Name}",
|
||||
$"spotify:playlist:items:{playlist.Name}" // NEW: Clear file-backed playlist items cache
|
||||
};
|
||||
|
||||
foreach (var key in keysToDelete)
|
||||
{
|
||||
if (await _cache.DeleteAsync(key))
|
||||
{
|
||||
clearedRedisKeys++;
|
||||
_logger.LogInformation("Cleared Redis cache key: {Key}", key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all search cache keys (pattern-based deletion)
|
||||
var searchKeysDeleted = await _cache.DeleteByPatternAsync("search:*");
|
||||
clearedRedisKeys += searchKeysDeleted;
|
||||
|
||||
// Clear all image cache keys (pattern-based deletion)
|
||||
var imageKeysDeleted = await _cache.DeleteByPatternAsync("image:*");
|
||||
clearedRedisKeys += imageKeysDeleted;
|
||||
|
||||
_logger.LogInformation("Cache cleared: {Files} files, {RedisKeys} Redis keys (including {SearchKeys} search keys, {ImageKeys} image keys)",
|
||||
clearedFiles, clearedRedisKeys, searchKeysDeleted, imageKeysDeleted);
|
||||
|
||||
return Ok(new {
|
||||
message = "Cache cleared successfully",
|
||||
filesDeleted = clearedFiles,
|
||||
redisKeysDeleted = clearedRedisKeys
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restart the allstarr container to apply configuration changes
|
||||
/// </summary>
|
||||
[HttpPost("restart")]
|
||||
public async Task<IActionResult> RestartContainer()
|
||||
{
|
||||
_logger.LogInformation("Container restart requested from admin UI");
|
||||
|
||||
try
|
||||
{
|
||||
// Use Docker socket to restart the container
|
||||
var socketPath = "/var/run/docker.sock";
|
||||
|
||||
if (!System.IO.File.Exists(socketPath))
|
||||
{
|
||||
_logger.LogWarning("Docker socket not available at {Path}", socketPath);
|
||||
return StatusCode(503, new {
|
||||
error = "Docker socket not available",
|
||||
message = "Please restart manually: docker-compose restart allstarr"
|
||||
});
|
||||
}
|
||||
|
||||
// Get container ID from hostname (Docker sets hostname to container ID by default)
|
||||
// Or use the well-known container name
|
||||
var containerId = Environment.MachineName;
|
||||
var containerName = "allstarr";
|
||||
|
||||
_logger.LogInformation("Attempting to restart container {ContainerId} / {ContainerName}", containerId, containerName);
|
||||
|
||||
// Create Unix socket HTTP client
|
||||
var handler = new SocketsHttpHandler
|
||||
{
|
||||
ConnectCallback = async (context, cancellationToken) =>
|
||||
{
|
||||
var socket = new System.Net.Sockets.Socket(
|
||||
System.Net.Sockets.AddressFamily.Unix,
|
||||
System.Net.Sockets.SocketType.Stream,
|
||||
System.Net.Sockets.ProtocolType.Unspecified);
|
||||
|
||||
var endpoint = new System.Net.Sockets.UnixDomainSocketEndPoint(socketPath);
|
||||
await socket.ConnectAsync(endpoint, cancellationToken);
|
||||
|
||||
return new System.Net.Sockets.NetworkStream(socket, ownsSocket: true);
|
||||
}
|
||||
};
|
||||
|
||||
using var dockerClient = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("http://localhost")
|
||||
};
|
||||
|
||||
// Try to restart by container name first, then by ID
|
||||
var response = await dockerClient.PostAsync($"/containers/{containerName}/restart?t=5", null);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
// Try by container ID
|
||||
response = await dockerClient.PostAsync($"/containers/{containerId}/restart?t=5", null);
|
||||
}
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogInformation("Container restart initiated successfully");
|
||||
return Ok(new { message = "Restarting container...", success = true });
|
||||
}
|
||||
else
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogError("Failed to restart container: {StatusCode} - {Body}", response.StatusCode, errorBody);
|
||||
return StatusCode((int)response.StatusCode, new {
|
||||
error = "Failed to restart container",
|
||||
message = "Please restart manually: docker-compose restart allstarr"
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error restarting container");
|
||||
return StatusCode(500, new {
|
||||
error = "Failed to restart container",
|
||||
details = ex.Message,
|
||||
message = "Please restart manually: docker-compose restart allstarr"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialize cookie date to current date if cookie exists but date is not set
|
||||
/// </summary>
|
||||
[HttpPost("config/init-cookie-date")]
|
||||
public async Task<IActionResult> InitCookieDate()
|
||||
{
|
||||
// Only init if cookie exists but date is not set
|
||||
if (string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie))
|
||||
{
|
||||
return BadRequest(new { error = "No cookie set" });
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(_spotifyApiSettings.SessionCookieSetDate))
|
||||
{
|
||||
return Ok(new { message = "Cookie date already set", date = _spotifyApiSettings.SessionCookieSetDate });
|
||||
}
|
||||
|
||||
_logger.LogInformation("Initializing cookie date to current date (cookie existed without date tracking)");
|
||||
|
||||
var updateRequest = new ConfigUpdateRequest
|
||||
{
|
||||
Updates = new Dictionary<string, string>
|
||||
{
|
||||
["SPOTIFY_API_SESSION_COOKIE_SET_DATE"] = DateTime.UtcNow.ToString("o")
|
||||
}
|
||||
};
|
||||
|
||||
return await UpdateConfig(updateRequest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all Jellyfin users
|
||||
/// </summary>
|
||||
[HttpGet("jellyfin/users")]
|
||||
public async Task<IActionResult> GetJellyfinUsers()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_jellyfinSettings.Url) || string.IsNullOrEmpty(_jellyfinSettings.ApiKey))
|
||||
{
|
||||
return BadRequest(new { error = "Jellyfin URL or API key not configured" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var url = $"{_jellyfinSettings.Url}/Users";
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
||||
|
||||
var response = await _jellyfinHttpClient.SendAsync(request);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogError("Failed to fetch Jellyfin users: {StatusCode} - {Body}", response.StatusCode, errorBody);
|
||||
return StatusCode((int)response.StatusCode, new { error = "Failed to fetch users from Jellyfin" });
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
var users = new List<object>();
|
||||
|
||||
foreach (var user in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
var id = user.GetProperty("Id").GetString();
|
||||
var name = user.GetProperty("Name").GetString();
|
||||
|
||||
users.Add(new { id, name });
|
||||
}
|
||||
|
||||
return Ok(new { users });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching Jellyfin users");
|
||||
return StatusCode(500, new { error = "Failed to fetch users", details = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all Jellyfin libraries (virtual folders)
|
||||
/// </summary>
|
||||
[HttpGet("jellyfin/libraries")]
|
||||
public async Task<IActionResult> GetJellyfinLibraries()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_jellyfinSettings.Url) || string.IsNullOrEmpty(_jellyfinSettings.ApiKey))
|
||||
{
|
||||
return BadRequest(new { error = "Jellyfin URL or API key not configured" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var url = $"{_jellyfinSettings.Url}/Library/VirtualFolders";
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
||||
|
||||
var response = await _jellyfinHttpClient.SendAsync(request);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogError("Failed to fetch Jellyfin libraries: {StatusCode} - {Body}", response.StatusCode, errorBody);
|
||||
return StatusCode((int)response.StatusCode, new { error = "Failed to fetch libraries from Jellyfin" });
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
var libraries = new List<object>();
|
||||
|
||||
foreach (var lib in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
var name = lib.GetProperty("Name").GetString();
|
||||
var itemId = lib.TryGetProperty("ItemId", out var id) ? id.GetString() : null;
|
||||
var collectionType = lib.TryGetProperty("CollectionType", out var ct) ? ct.GetString() : null;
|
||||
|
||||
libraries.Add(new { id = itemId, name, collectionType });
|
||||
}
|
||||
|
||||
return Ok(new { libraries });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching Jellyfin libraries");
|
||||
return StatusCode(500, new { error = "Failed to fetch libraries", details = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all playlists from Jellyfin
|
||||
/// </summary>
|
||||
[HttpGet("jellyfin/playlists")]
|
||||
public async Task<IActionResult> GetJellyfinPlaylists([FromQuery] string? userId = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_jellyfinSettings.Url) || string.IsNullOrEmpty(_jellyfinSettings.ApiKey))
|
||||
{
|
||||
return BadRequest(new { error = "Jellyfin URL or API key not configured" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Build URL with optional userId filter
|
||||
var url = $"{_jellyfinSettings.Url}/Items?IncludeItemTypes=Playlist&Recursive=true&Fields=ProviderIds,ChildCount,RecursiveItemCount,SongCount";
|
||||
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
url += $"&UserId={userId}";
|
||||
}
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
||||
|
||||
var response = await _jellyfinHttpClient.SendAsync(request);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogError("Failed to fetch Jellyfin playlists: {StatusCode} - {Body}", response.StatusCode, errorBody);
|
||||
return StatusCode((int)response.StatusCode, new { error = "Failed to fetch playlists from Jellyfin" });
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
var playlists = new List<object>();
|
||||
|
||||
// Read current playlists from .env file for accurate linked status
|
||||
var configuredPlaylists = await ReadPlaylistsFromEnvFile();
|
||||
|
||||
if (doc.RootElement.TryGetProperty("Items", out var items))
|
||||
{
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
var id = item.GetProperty("Id").GetString();
|
||||
var name = item.GetProperty("Name").GetString();
|
||||
|
||||
// Try multiple fields for track count - Jellyfin may use different fields
|
||||
var childCount = 0;
|
||||
if (item.TryGetProperty("ChildCount", out var cc) && cc.ValueKind == JsonValueKind.Number)
|
||||
childCount = cc.GetInt32();
|
||||
else if (item.TryGetProperty("SongCount", out var sc) && sc.ValueKind == JsonValueKind.Number)
|
||||
childCount = sc.GetInt32();
|
||||
else if (item.TryGetProperty("RecursiveItemCount", out var ric) && ric.ValueKind == JsonValueKind.Number)
|
||||
childCount = ric.GetInt32();
|
||||
|
||||
// Check if this playlist is configured in allstarr by Jellyfin ID
|
||||
var configuredPlaylist = configuredPlaylists
|
||||
.FirstOrDefault(p => p.JellyfinId.Equals(id, StringComparison.OrdinalIgnoreCase));
|
||||
var isConfigured = configuredPlaylist != null;
|
||||
var linkedSpotifyId = configuredPlaylist?.Id;
|
||||
|
||||
// Fetch track details to categorize local vs external
|
||||
var trackStats = await GetPlaylistTrackStats(id!);
|
||||
|
||||
playlists.Add(new
|
||||
{
|
||||
id,
|
||||
name,
|
||||
trackCount = childCount,
|
||||
linkedSpotifyId,
|
||||
isConfigured,
|
||||
localTracks = trackStats.LocalTracks,
|
||||
externalTracks = trackStats.ExternalTracks,
|
||||
externalAvailable = trackStats.ExternalAvailable
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(new { playlists });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching Jellyfin playlists");
|
||||
return StatusCode(500, new { error = "Failed to fetch playlists", details = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get track statistics for a playlist (local vs external)
|
||||
/// </summary>
|
||||
private async Task<(int LocalTracks, int ExternalTracks, int ExternalAvailable)> GetPlaylistTrackStats(string playlistId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Jellyfin requires a UserId to fetch playlist items
|
||||
// We'll use the first available user if not specified
|
||||
var userId = _jellyfinSettings.UserId;
|
||||
|
||||
// If no user configured, try to get the first user
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
{
|
||||
var usersResponse = await _jellyfinHttpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get, $"{_jellyfinSettings.Url}/Users")
|
||||
{
|
||||
Headers = { { "X-Emby-Authorization", GetJellyfinAuthHeader() } }
|
||||
});
|
||||
|
||||
if (usersResponse.IsSuccessStatusCode)
|
||||
{
|
||||
var usersJson = await usersResponse.Content.ReadAsStringAsync();
|
||||
using var usersDoc = JsonDocument.Parse(usersJson);
|
||||
if (usersDoc.RootElement.GetArrayLength() > 0)
|
||||
{
|
||||
userId = usersDoc.RootElement[0].GetProperty("Id").GetString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
{
|
||||
_logger.LogWarning("No user ID available to fetch playlist items for {PlaylistId}", playlistId);
|
||||
return (0, 0, 0);
|
||||
}
|
||||
|
||||
var url = $"{_jellyfinSettings.Url}/Playlists/{playlistId}/Items?UserId={userId}&Fields=Path";
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
||||
|
||||
var response = await _jellyfinHttpClient.SendAsync(request);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("Failed to fetch playlist items for {PlaylistId}: {StatusCode}", playlistId, response.StatusCode);
|
||||
return (0, 0, 0);
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
var localTracks = 0;
|
||||
var externalTracks = 0;
|
||||
var externalAvailable = 0;
|
||||
|
||||
if (doc.RootElement.TryGetProperty("Items", out var items))
|
||||
{
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
// Simpler detection: Check if Path exists and is not empty
|
||||
// External tracks from allstarr won't have a Path property
|
||||
var hasPath = item.TryGetProperty("Path", out var pathProp) &&
|
||||
pathProp.ValueKind == JsonValueKind.String &&
|
||||
!string.IsNullOrEmpty(pathProp.GetString());
|
||||
|
||||
if (hasPath)
|
||||
{
|
||||
var pathStr = pathProp.GetString()!;
|
||||
// Check if it's a real file path (not a URL)
|
||||
if (pathStr.StartsWith("/") || pathStr.Contains(":\\"))
|
||||
{
|
||||
localTracks++;
|
||||
}
|
||||
else
|
||||
{
|
||||
// It's a URL or external source
|
||||
externalTracks++;
|
||||
externalAvailable++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No path means it's external
|
||||
externalTracks++;
|
||||
externalAvailable++;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("Playlist {PlaylistId} stats: {Local} local, {External} external",
|
||||
playlistId, localTracks, externalTracks);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("No Items property in playlist response for {PlaylistId}", playlistId);
|
||||
}
|
||||
|
||||
return (localTracks, externalTracks, externalAvailable);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get track stats for playlist {PlaylistId}", playlistId);
|
||||
return (0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Link a Jellyfin playlist to a Spotify playlist
|
||||
/// </summary>
|
||||
[HttpPost("jellyfin/playlists/{jellyfinPlaylistId}/link")]
|
||||
public async Task<IActionResult> LinkPlaylist(string jellyfinPlaylistId, [FromBody] LinkPlaylistRequest request)
|
||||
{
|
||||
if (string.IsNullOrEmpty(request.SpotifyPlaylistId))
|
||||
{
|
||||
return BadRequest(new { error = "SpotifyPlaylistId is required" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(request.Name))
|
||||
{
|
||||
return BadRequest(new { error = "Name is required" });
|
||||
}
|
||||
|
||||
_logger.LogInformation("Linking Jellyfin playlist {JellyfinId} to Spotify playlist {SpotifyId} with name {Name}",
|
||||
jellyfinPlaylistId, request.SpotifyPlaylistId, request.Name);
|
||||
|
||||
// Read current playlists from .env file (not in-memory config which is stale)
|
||||
var currentPlaylists = await ReadPlaylistsFromEnvFile();
|
||||
|
||||
// Check if already configured by Jellyfin ID
|
||||
var existingByJellyfinId = currentPlaylists
|
||||
.FirstOrDefault(p => p.JellyfinId.Equals(jellyfinPlaylistId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (existingByJellyfinId != null)
|
||||
{
|
||||
return BadRequest(new { error = $"This Jellyfin playlist is already linked to '{existingByJellyfinId.Name}'" });
|
||||
}
|
||||
|
||||
// Check if already configured by name
|
||||
var existingByName = currentPlaylists
|
||||
.FirstOrDefault(p => p.Name.Equals(request.Name, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (existingByName != null)
|
||||
{
|
||||
return BadRequest(new { error = $"Playlist name '{request.Name}' is already configured" });
|
||||
}
|
||||
|
||||
// Add the playlist to configuration
|
||||
currentPlaylists.Add(new SpotifyPlaylistConfig
|
||||
{
|
||||
Name = request.Name,
|
||||
Id = request.SpotifyPlaylistId,
|
||||
JellyfinId = jellyfinPlaylistId,
|
||||
LocalTracksPosition = LocalTracksPosition.First // Use Spotify order
|
||||
});
|
||||
|
||||
// Convert to JSON format for env var: [["Name","SpotifyId","JellyfinId","first|last"],...]
|
||||
var playlistsJson = JsonSerializer.Serialize(
|
||||
currentPlaylists.Select(p => new[] { p.Name, p.Id, p.JellyfinId, p.LocalTracksPosition.ToString().ToLower() }).ToArray()
|
||||
);
|
||||
|
||||
// Update .env file
|
||||
var updateRequest = new ConfigUpdateRequest
|
||||
{
|
||||
Updates = new Dictionary<string, string>
|
||||
{
|
||||
["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson
|
||||
}
|
||||
};
|
||||
|
||||
return await UpdateConfig(updateRequest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unlink a playlist (remove from configuration)
|
||||
/// </summary>
|
||||
[HttpDelete("jellyfin/playlists/{name}/unlink")]
|
||||
public async Task<IActionResult> UnlinkPlaylist(string name)
|
||||
{
|
||||
var decodedName = Uri.UnescapeDataString(name);
|
||||
return await RemovePlaylist(decodedName);
|
||||
}
|
||||
|
||||
private string GetJellyfinAuthHeader()
|
||||
{
|
||||
return $"MediaBrowser Client=\"Allstarr\", Device=\"Server\", DeviceId=\"allstarr-admin\", Version=\"1.0.0\", Token=\"{_jellyfinSettings.ApiKey}\"";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read current playlists from .env file (not stale in-memory config)
|
||||
/// </summary>
|
||||
private async Task<List<SpotifyPlaylistConfig>> ReadPlaylistsFromEnvFile()
|
||||
{
|
||||
var playlists = new List<SpotifyPlaylistConfig>();
|
||||
|
||||
if (!System.IO.File.Exists(_envFilePath))
|
||||
{
|
||||
return playlists;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var lines = await System.IO.File.ReadAllLinesAsync(_envFilePath);
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (line.TrimStart().StartsWith("SPOTIFY_IMPORT_PLAYLISTS="))
|
||||
{
|
||||
var value = line.Substring(line.IndexOf('=') + 1).Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value) || value == "[]")
|
||||
{
|
||||
return playlists;
|
||||
}
|
||||
|
||||
// Parse JSON array format: [["Name","SpotifyId","JellyfinId","first|last"],...]
|
||||
var playlistArrays = JsonSerializer.Deserialize<string[][]>(value);
|
||||
if (playlistArrays != null)
|
||||
{
|
||||
foreach (var arr in playlistArrays)
|
||||
{
|
||||
if (arr.Length >= 2)
|
||||
{
|
||||
playlists.Add(new SpotifyPlaylistConfig
|
||||
{
|
||||
Name = arr[0].Trim(),
|
||||
Id = arr[1].Trim(),
|
||||
JellyfinId = arr.Length >= 3 ? arr[2].Trim() : "",
|
||||
LocalTracksPosition = arr.Length >= 4 &&
|
||||
arr[3].Trim().Equals("last", StringComparison.OrdinalIgnoreCase)
|
||||
? LocalTracksPosition.Last
|
||||
: LocalTracksPosition.First
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to read playlists from .env file");
|
||||
}
|
||||
|
||||
return playlists;
|
||||
}
|
||||
|
||||
private static string MaskValue(string? value, int showLast = 0)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return "(not set)";
|
||||
if (value.Length <= showLast) return "***";
|
||||
return showLast > 0 ? "***" + value[^showLast..] : value[..8] + "...";
|
||||
}
|
||||
|
||||
private static string SanitizeFileName(string name)
|
||||
{
|
||||
return string.Join("_", name.Split(Path.GetInvalidFileNameChars()));
|
||||
}
|
||||
|
||||
private static bool IsValidEnvKey(string key)
|
||||
{
|
||||
// Only allow alphanumeric, underscore, and must start with letter/underscore
|
||||
return Regex.IsMatch(key, @"^[A-Z_][A-Z0-9_]*$", RegexOptions.IgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export .env file for backup/transfer
|
||||
/// </summary>
|
||||
[HttpGet("export-env")]
|
||||
public IActionResult ExportEnv()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!System.IO.File.Exists(_envFilePath))
|
||||
{
|
||||
return NotFound(new { error = ".env file not found" });
|
||||
}
|
||||
|
||||
var envContent = System.IO.File.ReadAllText(_envFilePath);
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(envContent);
|
||||
|
||||
return File(bytes, "text/plain", ".env");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to export .env file");
|
||||
return StatusCode(500, new { error = "Failed to export .env file", details = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Import .env file from upload
|
||||
/// </summary>
|
||||
[HttpPost("import-env")]
|
||||
public async Task<IActionResult> ImportEnv([FromForm] IFormFile file)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
{
|
||||
return BadRequest(new { error = "No file provided" });
|
||||
}
|
||||
|
||||
if (!file.FileName.EndsWith(".env"))
|
||||
{
|
||||
return BadRequest(new { error = "File must be a .env file" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Read uploaded file
|
||||
using var reader = new StreamReader(file.OpenReadStream());
|
||||
var content = await reader.ReadToEndAsync();
|
||||
|
||||
// Validate it's a valid .env file (basic check)
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return BadRequest(new { error = ".env file is empty" });
|
||||
}
|
||||
|
||||
// Backup existing .env
|
||||
if (System.IO.File.Exists(_envFilePath))
|
||||
{
|
||||
var backupPath = $"{_envFilePath}.backup.{DateTime.UtcNow:yyyyMMddHHmmss}";
|
||||
System.IO.File.Copy(_envFilePath, backupPath, true);
|
||||
_logger.LogInformation("Backed up existing .env to {BackupPath}", backupPath);
|
||||
}
|
||||
|
||||
// Write new .env file
|
||||
await System.IO.File.WriteAllTextAsync(_envFilePath, content);
|
||||
|
||||
_logger.LogInformation(".env file imported successfully");
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
message = ".env file imported successfully. Restart the application for changes to take effect."
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to import .env file");
|
||||
return StatusCode(500, new { error = "Failed to import .env file", details = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets detailed memory usage statistics for debugging.
|
||||
/// </summary>
|
||||
[HttpGet("memory-stats")]
|
||||
public IActionResult GetMemoryStats()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get memory stats BEFORE GC
|
||||
var memoryBeforeGC = GC.GetTotalMemory(false);
|
||||
var gen0Before = GC.CollectionCount(0);
|
||||
var gen1Before = GC.CollectionCount(1);
|
||||
var gen2Before = GC.CollectionCount(2);
|
||||
|
||||
// Force garbage collection to get accurate numbers
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect();
|
||||
|
||||
var memoryAfterGC = GC.GetTotalMemory(false);
|
||||
var gen0After = GC.CollectionCount(0);
|
||||
var gen1After = GC.CollectionCount(1);
|
||||
var gen2After = GC.CollectionCount(2);
|
||||
|
||||
// Get process memory info
|
||||
var process = System.Diagnostics.Process.GetCurrentProcess();
|
||||
|
||||
return Ok(new {
|
||||
Timestamp = DateTime.UtcNow,
|
||||
BeforeGC = new {
|
||||
GCMemoryBytes = memoryBeforeGC,
|
||||
GCMemoryMB = Math.Round(memoryBeforeGC / (1024.0 * 1024.0), 2)
|
||||
},
|
||||
AfterGC = new {
|
||||
GCMemoryBytes = memoryAfterGC,
|
||||
GCMemoryMB = Math.Round(memoryAfterGC / (1024.0 * 1024.0), 2)
|
||||
},
|
||||
MemoryFreedMB = Math.Round((memoryBeforeGC - memoryAfterGC) / (1024.0 * 1024.0), 2),
|
||||
ProcessWorkingSetBytes = process.WorkingSet64,
|
||||
ProcessWorkingSetMB = Math.Round(process.WorkingSet64 / (1024.0 * 1024.0), 2),
|
||||
ProcessPrivateMemoryBytes = process.PrivateMemorySize64,
|
||||
ProcessPrivateMemoryMB = Math.Round(process.PrivateMemorySize64 / (1024.0 * 1024.0), 2),
|
||||
ProcessVirtualMemoryBytes = process.VirtualMemorySize64,
|
||||
ProcessVirtualMemoryMB = Math.Round(process.VirtualMemorySize64 / (1024.0 * 1024.0), 2),
|
||||
GCCollections = new {
|
||||
Gen0Before = gen0Before,
|
||||
Gen0After = gen0After,
|
||||
Gen0Triggered = gen0After - gen0Before,
|
||||
Gen1Before = gen1Before,
|
||||
Gen1After = gen1After,
|
||||
Gen1Triggered = gen1After - gen1Before,
|
||||
Gen2Before = gen2Before,
|
||||
Gen2After = gen2After,
|
||||
Gen2Triggered = gen2After - gen2Before
|
||||
},
|
||||
GCMode = GCSettings.IsServerGC ? "Server" : "Workstation",
|
||||
GCLatencyMode = GCSettings.LatencyMode.ToString()
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forces garbage collection to free up memory (emergency use only).
|
||||
/// </summary>
|
||||
[HttpPost("force-gc")]
|
||||
public IActionResult ForceGarbageCollection()
|
||||
{
|
||||
try
|
||||
{
|
||||
var memoryBefore = GC.GetTotalMemory(false);
|
||||
var processBefore = System.Diagnostics.Process.GetCurrentProcess().WorkingSet64;
|
||||
|
||||
// Force full garbage collection
|
||||
GC.Collect(2, GCCollectionMode.Forced);
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect(2, GCCollectionMode.Forced);
|
||||
|
||||
var memoryAfter = GC.GetTotalMemory(false);
|
||||
var processAfter = System.Diagnostics.Process.GetCurrentProcess().WorkingSet64;
|
||||
|
||||
return Ok(new {
|
||||
Timestamp = DateTime.UtcNow,
|
||||
MemoryFreedMB = Math.Round((memoryBefore - memoryAfter) / (1024.0 * 1024.0), 2),
|
||||
ProcessMemoryFreedMB = Math.Round((processBefore - processAfter) / (1024.0 * 1024.0), 2),
|
||||
BeforeGCMB = Math.Round(memoryBefore / (1024.0 * 1024.0), 2),
|
||||
AfterGCMB = Math.Round(memoryAfter / (1024.0 * 1024.0), 2),
|
||||
BeforeProcessMB = Math.Round(processBefore / (1024.0 * 1024.0), 2),
|
||||
AfterProcessMB = Math.Round(processAfter / (1024.0 * 1024.0), 2)
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets current active sessions for debugging.
|
||||
/// </summary>
|
||||
[HttpGet("sessions")]
|
||||
public IActionResult GetActiveSessions()
|
||||
{
|
||||
try
|
||||
{
|
||||
var sessionManager = HttpContext.RequestServices.GetService<JellyfinSessionManager>();
|
||||
if (sessionManager == null)
|
||||
{
|
||||
return BadRequest(new { error = "Session manager not available" });
|
||||
}
|
||||
|
||||
var sessionInfo = sessionManager.GetSessionsInfo();
|
||||
return Ok(sessionInfo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper method to trigger GC after large file operations to prevent memory leaks.
|
||||
/// </summary>
|
||||
private static void TriggerGCAfterLargeOperation(int sizeInBytes)
|
||||
{
|
||||
// Only trigger GC for files larger than 1MB to avoid performance impact
|
||||
if (sizeInBytes > 1024 * 1024)
|
||||
{
|
||||
// Suggest GC collection for large objects (they go to LOH and aren't collected as frequently)
|
||||
GC.Collect(2, GCCollectionMode.Optimized, blocking: false);
|
||||
}
|
||||
}
|
||||
|
||||
#region Spotify Admin Endpoints
|
||||
|
||||
/// <summary>
|
||||
/// Manual trigger endpoint to force fetch Spotify missing tracks.
|
||||
/// </summary>
|
||||
[HttpGet("spotify/sync")]
|
||||
public async Task<IActionResult> TriggerSpotifySync([FromServices] IEnumerable<IHostedService> hostedServices)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_spotifyImportSettings.Enabled)
|
||||
{
|
||||
return BadRequest(new { error = "Spotify Import is not enabled" });
|
||||
}
|
||||
|
||||
_logger.LogInformation("Manual Spotify sync triggered via admin endpoint");
|
||||
|
||||
// Find the SpotifyMissingTracksFetcher service
|
||||
var fetcherService = hostedServices
|
||||
.OfType<allstarr.Services.Spotify.SpotifyMissingTracksFetcher>()
|
||||
.FirstOrDefault();
|
||||
|
||||
if (fetcherService == null)
|
||||
{
|
||||
return BadRequest(new { error = "SpotifyMissingTracksFetcher service not found" });
|
||||
}
|
||||
|
||||
// Trigger the sync in background
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// Use reflection to call the private ExecuteOnceAsync method
|
||||
var method = fetcherService.GetType().GetMethod("ExecuteOnceAsync",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
|
||||
if (method != null)
|
||||
{
|
||||
await (Task)method.Invoke(fetcherService, new object[] { CancellationToken.None })!;
|
||||
_logger.LogInformation("Manual Spotify sync completed successfully");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError("Could not find ExecuteOnceAsync method on SpotifyMissingTracksFetcher");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error during manual Spotify sync");
|
||||
}
|
||||
});
|
||||
|
||||
return Ok(new {
|
||||
message = "Spotify sync started in background",
|
||||
timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error triggering Spotify sync");
|
||||
return StatusCode(500, new { error = "Internal server error" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manual trigger endpoint to force Spotify track matching.
|
||||
/// </summary>
|
||||
[HttpGet("spotify/match")]
|
||||
public async Task<IActionResult> TriggerSpotifyMatch([FromServices] IEnumerable<IHostedService> hostedServices)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_spotifyApiSettings.Enabled)
|
||||
{
|
||||
return BadRequest(new { error = "Spotify API is not enabled" });
|
||||
}
|
||||
|
||||
_logger.LogInformation("Manual Spotify track matching triggered via admin endpoint");
|
||||
|
||||
// Find the SpotifyTrackMatchingService
|
||||
var matchingService = hostedServices
|
||||
.OfType<allstarr.Services.Spotify.SpotifyTrackMatchingService>()
|
||||
.FirstOrDefault();
|
||||
|
||||
if (matchingService == null)
|
||||
{
|
||||
return BadRequest(new { error = "SpotifyTrackMatchingService not found" });
|
||||
}
|
||||
|
||||
// Trigger matching in background
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// Use reflection to call the private ExecuteOnceAsync method
|
||||
var method = matchingService.GetType().GetMethod("ExecuteOnceAsync",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
|
||||
if (method != null)
|
||||
{
|
||||
await (Task)method.Invoke(matchingService, new object[] { CancellationToken.None })!;
|
||||
_logger.LogInformation("Manual Spotify track matching completed successfully");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError("Could not find ExecuteOnceAsync method on SpotifyTrackMatchingService");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error during manual Spotify track matching");
|
||||
}
|
||||
});
|
||||
|
||||
return Ok(new {
|
||||
message = "Spotify track matching started in background",
|
||||
timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error triggering Spotify track matching");
|
||||
return StatusCode(500, new { error = "Internal server error" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear Spotify playlist cache to force re-matching.
|
||||
/// </summary>
|
||||
[HttpPost("spotify/clear-cache")]
|
||||
public async Task<IActionResult> ClearSpotifyCache()
|
||||
{
|
||||
try
|
||||
{
|
||||
var clearedKeys = new List<string>();
|
||||
|
||||
// Clear Redis cache for all configured playlists
|
||||
foreach (var playlist in _spotifyImportSettings.Playlists)
|
||||
{
|
||||
var keys = new[]
|
||||
{
|
||||
$"spotify:playlist:{playlist.Name}",
|
||||
$"spotify:playlist:items:{playlist.Name}",
|
||||
$"spotify:matched:{playlist.Name}"
|
||||
};
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
await _cache.DeleteAsync(key);
|
||||
clearedKeys.Add(key);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Cleared Spotify cache for {Count} keys via admin endpoint", clearedKeys.Count);
|
||||
|
||||
return Ok(new {
|
||||
message = "Spotify cache cleared successfully",
|
||||
clearedKeys = clearedKeys,
|
||||
timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error clearing Spotify cache");
|
||||
return StatusCode(500, new { error = "Internal server error" });
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Debug Endpoints
|
||||
|
||||
/// <summary>
|
||||
/// Gets endpoint usage statistics from the log file.
|
||||
/// </summary>
|
||||
[HttpGet("debug/endpoint-usage")]
|
||||
public async Task<IActionResult> GetEndpointUsage(
|
||||
[FromQuery] int top = 100,
|
||||
[FromQuery] string? since = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var logFile = "/app/cache/endpoint-usage/endpoints.csv";
|
||||
|
||||
if (!System.IO.File.Exists(logFile))
|
||||
{
|
||||
return Ok(new {
|
||||
message = "No endpoint usage data available",
|
||||
endpoints = new object[0]
|
||||
});
|
||||
}
|
||||
|
||||
var lines = await System.IO.File.ReadAllLinesAsync(logFile);
|
||||
var usage = new Dictionary<string, int>();
|
||||
DateTime? sinceDate = null;
|
||||
|
||||
if (!string.IsNullOrEmpty(since) && DateTime.TryParse(since, out var parsedDate))
|
||||
{
|
||||
sinceDate = parsedDate;
|
||||
}
|
||||
|
||||
foreach (var line in lines.Skip(1)) // Skip header
|
||||
{
|
||||
var parts = line.Split(',');
|
||||
if (parts.Length >= 3)
|
||||
{
|
||||
var timestamp = parts[0];
|
||||
var endpoint = parts[1];
|
||||
|
||||
// Filter by date if specified
|
||||
if (sinceDate.HasValue && DateTime.TryParse(timestamp, out var logDate))
|
||||
{
|
||||
if (logDate < sinceDate.Value)
|
||||
continue;
|
||||
}
|
||||
|
||||
usage[endpoint] = usage.GetValueOrDefault(endpoint, 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
var topEndpoints = usage
|
||||
.OrderByDescending(kv => kv.Value)
|
||||
.Take(top)
|
||||
.Select(kv => new { endpoint = kv.Key, count = kv.Value })
|
||||
.ToArray();
|
||||
|
||||
return Ok(new {
|
||||
totalEndpoints = usage.Count,
|
||||
totalRequests = usage.Values.Sum(),
|
||||
since = since,
|
||||
top = top,
|
||||
endpoints = topEndpoints
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting endpoint usage");
|
||||
return StatusCode(500, new { error = "Internal server error" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the endpoint usage log file.
|
||||
/// </summary>
|
||||
[HttpDelete("debug/endpoint-usage")]
|
||||
public IActionResult ClearEndpointUsage()
|
||||
{
|
||||
try
|
||||
{
|
||||
var logFile = "/app/cache/endpoint-usage/endpoints.csv";
|
||||
|
||||
if (System.IO.File.Exists(logFile))
|
||||
{
|
||||
System.IO.File.Delete(logFile);
|
||||
_logger.LogInformation("Cleared endpoint usage log via admin endpoint");
|
||||
|
||||
return Ok(new {
|
||||
message = "Endpoint usage log cleared successfully",
|
||||
timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
return Ok(new {
|
||||
message = "No endpoint usage log file found",
|
||||
timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error clearing endpoint usage log");
|
||||
return StatusCode(500, new { error = "Internal server error" });
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Private Helper Methods
|
||||
|
||||
/// <summary>
|
||||
/// Saves a manual mapping to file for persistence across restarts.
|
||||
/// Manual mappings NEVER expire - they are permanent user decisions.
|
||||
/// </summary>
|
||||
private async Task SaveManualMappingToFileAsync(
|
||||
string playlistName,
|
||||
string spotifyId,
|
||||
string? jellyfinId,
|
||||
string? externalProvider,
|
||||
string? externalId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var mappingsDir = "/app/cache/mappings";
|
||||
Directory.CreateDirectory(mappingsDir);
|
||||
|
||||
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
|
||||
var filePath = Path.Combine(mappingsDir, $"{safeName}_mappings.json");
|
||||
|
||||
// Load existing mappings
|
||||
var mappings = new Dictionary<string, ManualMappingEntry>();
|
||||
if (System.IO.File.Exists(filePath))
|
||||
{
|
||||
var json = await System.IO.File.ReadAllTextAsync(filePath);
|
||||
mappings = JsonSerializer.Deserialize<Dictionary<string, ManualMappingEntry>>(json)
|
||||
?? new Dictionary<string, ManualMappingEntry>();
|
||||
}
|
||||
|
||||
// Add or update mapping
|
||||
mappings[spotifyId] = new ManualMappingEntry
|
||||
{
|
||||
SpotifyId = spotifyId,
|
||||
JellyfinId = jellyfinId,
|
||||
ExternalProvider = externalProvider,
|
||||
ExternalId = externalId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
// Save back to file
|
||||
var updatedJson = JsonSerializer.Serialize(mappings, new JsonSerializerOptions { WriteIndented = true });
|
||||
await System.IO.File.WriteAllTextAsync(filePath, updatedJson);
|
||||
|
||||
_logger.LogDebug("💾 Saved manual mapping to file: {Playlist} - {SpotifyId}", playlistName, spotifyId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save manual mapping to file for {Playlist}", playlistName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save lyrics mapping to file for persistence across restarts.
|
||||
/// Lyrics mappings NEVER expire - they are permanent user decisions.
|
||||
/// </summary>
|
||||
private async Task SaveLyricsMappingToFileAsync(
|
||||
string artist,
|
||||
string title,
|
||||
string album,
|
||||
int durationSeconds,
|
||||
int lyricsId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var mappingsFile = "/app/cache/lyrics_mappings.json";
|
||||
|
||||
// Load existing mappings
|
||||
var mappings = new List<LyricsMappingEntry>();
|
||||
if (System.IO.File.Exists(mappingsFile))
|
||||
{
|
||||
var json = await System.IO.File.ReadAllTextAsync(mappingsFile);
|
||||
mappings = JsonSerializer.Deserialize<List<LyricsMappingEntry>>(json)
|
||||
?? new List<LyricsMappingEntry>();
|
||||
}
|
||||
|
||||
// Remove any existing mapping for this track
|
||||
mappings.RemoveAll(m =>
|
||||
m.Artist.Equals(artist, StringComparison.OrdinalIgnoreCase) &&
|
||||
m.Title.Equals(title, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
// Add new mapping
|
||||
mappings.Add(new LyricsMappingEntry
|
||||
{
|
||||
Artist = artist,
|
||||
Title = title,
|
||||
Album = album,
|
||||
DurationSeconds = durationSeconds,
|
||||
LyricsId = lyricsId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
// Save back to file
|
||||
var updatedJson = JsonSerializer.Serialize(mappings, new JsonSerializerOptions { WriteIndented = true });
|
||||
await System.IO.File.WriteAllTextAsync(mappingsFile, updatedJson);
|
||||
|
||||
_logger.LogDebug("💾 Saved lyrics mapping to file: {Artist} - {Title} → Lyrics ID {LyricsId}",
|
||||
artist, title, lyricsId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save lyrics mapping to file for {Artist} - {Title}", artist, title);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save manual lyrics ID mapping for a track
|
||||
/// </summary>
|
||||
[HttpPost("lyrics/map")]
|
||||
public async Task<IActionResult> SaveLyricsMapping([FromBody] LyricsMappingRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Artist) || string.IsNullOrWhiteSpace(request.Title))
|
||||
{
|
||||
return BadRequest(new { error = "Artist and Title are required" });
|
||||
}
|
||||
|
||||
if (request.LyricsId <= 0)
|
||||
{
|
||||
return BadRequest(new { error = "Valid LyricsId is required" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Store lyrics mapping in cache (NO EXPIRATION - manual mappings are permanent)
|
||||
var mappingKey = $"lyrics:manual-map:{request.Artist}:{request.Title}";
|
||||
await _cache.SetStringAsync(mappingKey, request.LyricsId.ToString());
|
||||
|
||||
// Also save to file for persistence across restarts
|
||||
await SaveLyricsMappingToFileAsync(request.Artist, request.Title, request.Album ?? "", request.DurationSeconds, request.LyricsId);
|
||||
|
||||
_logger.LogInformation("Manual lyrics mapping saved: {Artist} - {Title} → Lyrics ID {LyricsId}",
|
||||
request.Artist, request.Title, request.LyricsId);
|
||||
|
||||
// Optionally fetch and cache the lyrics immediately
|
||||
try
|
||||
{
|
||||
var lyricsService = _serviceProvider.GetService<allstarr.Services.Lyrics.LrclibService>();
|
||||
if (lyricsService != null)
|
||||
{
|
||||
var lyricsInfo = await lyricsService.GetLyricsByIdAsync(request.LyricsId);
|
||||
if (lyricsInfo != null && !string.IsNullOrEmpty(lyricsInfo.PlainLyrics))
|
||||
{
|
||||
// Cache the lyrics using the standard cache key
|
||||
var lyricsCacheKey = $"lyrics:{request.Artist}:{request.Title}:{request.Album ?? ""}:{request.DurationSeconds}";
|
||||
await _cache.SetAsync(lyricsCacheKey, lyricsInfo.PlainLyrics);
|
||||
_logger.LogInformation("✓ Fetched and cached lyrics for {Artist} - {Title}", request.Artist, request.Title);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
message = "Lyrics mapping saved and lyrics cached successfully",
|
||||
lyricsId = request.LyricsId,
|
||||
cached = true,
|
||||
lyrics = new
|
||||
{
|
||||
id = lyricsInfo.Id,
|
||||
trackName = lyricsInfo.TrackName,
|
||||
artistName = lyricsInfo.ArtistName,
|
||||
albumName = lyricsInfo.AlbumName,
|
||||
duration = lyricsInfo.Duration,
|
||||
instrumental = lyricsInfo.Instrumental
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch lyrics after mapping, but mapping was saved");
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
message = "Lyrics mapping saved successfully",
|
||||
lyricsId = request.LyricsId,
|
||||
cached = false
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save lyrics mapping");
|
||||
return StatusCode(500, new { error = "Failed to save lyrics mapping" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get manual lyrics mappings
|
||||
/// </summary>
|
||||
[HttpGet("lyrics/mappings")]
|
||||
public async Task<IActionResult> GetLyricsMappings()
|
||||
{
|
||||
try
|
||||
{
|
||||
var mappingsFile = "/app/cache/lyrics_mappings.json";
|
||||
|
||||
if (!System.IO.File.Exists(mappingsFile))
|
||||
{
|
||||
return Ok(new { mappings = new List<object>() });
|
||||
}
|
||||
|
||||
var json = await System.IO.File.ReadAllTextAsync(mappingsFile);
|
||||
var mappings = JsonSerializer.Deserialize<List<LyricsMappingEntry>>(json) ?? new List<LyricsMappingEntry>();
|
||||
|
||||
return Ok(new { mappings });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get lyrics mappings");
|
||||
return StatusCode(500, new { error = "Failed to get lyrics mappings" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prefetch lyrics for a specific playlist
|
||||
/// </summary>
|
||||
[HttpPost("playlists/{name}/prefetch-lyrics")]
|
||||
public async Task<IActionResult> PrefetchPlaylistLyrics(string name)
|
||||
{
|
||||
var decodedName = Uri.UnescapeDataString(name);
|
||||
|
||||
try
|
||||
{
|
||||
var lyricsPrefetchService = _serviceProvider.GetService<allstarr.Services.Lyrics.LyricsPrefetchService>();
|
||||
|
||||
if (lyricsPrefetchService == null)
|
||||
{
|
||||
return StatusCode(500, new { error = "Lyrics prefetch service not available" });
|
||||
}
|
||||
|
||||
_logger.LogInformation("Starting lyrics prefetch for playlist: {Playlist}", decodedName);
|
||||
|
||||
var (fetched, cached, missing) = await lyricsPrefetchService.PrefetchPlaylistLyricsAsync(
|
||||
decodedName,
|
||||
HttpContext.RequestAborted);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
message = "Lyrics prefetch complete",
|
||||
playlist = decodedName,
|
||||
fetched,
|
||||
cached,
|
||||
missing,
|
||||
total = fetched + cached + missing
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to prefetch lyrics for playlist {Playlist}", decodedName);
|
||||
return StatusCode(500, new { error = $"Failed to prefetch lyrics: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
public class ManualMappingRequest
|
||||
{
|
||||
public string SpotifyId { get; set; } = "";
|
||||
public string? JellyfinId { get; set; }
|
||||
public string? ExternalProvider { get; set; }
|
||||
public string? ExternalId { get; set; }
|
||||
}
|
||||
|
||||
public class LyricsMappingRequest
|
||||
{
|
||||
public string Artist { get; set; } = "";
|
||||
public string Title { get; set; } = "";
|
||||
public string? Album { get; set; }
|
||||
public int DurationSeconds { get; set; }
|
||||
public int LyricsId { get; set; }
|
||||
}
|
||||
|
||||
public class ManualMappingEntry
|
||||
{
|
||||
public string SpotifyId { get; set; } = "";
|
||||
public string? JellyfinId { get; set; }
|
||||
public string? ExternalProvider { get; set; }
|
||||
public string? ExternalId { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class LyricsMappingEntry
|
||||
{
|
||||
public string Artist { get; set; } = "";
|
||||
public string Title { get; set; } = "";
|
||||
public string? Album { get; set; }
|
||||
public int DurationSeconds { get; set; }
|
||||
public int LyricsId { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class ConfigUpdateRequest
|
||||
{
|
||||
public Dictionary<string, string> Updates { get; set; } = new();
|
||||
}
|
||||
|
||||
public class AddPlaylistRequest
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string SpotifyId { get; set; } = string.Empty;
|
||||
public string LocalTracksPosition { get; set; } = "first";
|
||||
}
|
||||
|
||||
public class LinkPlaylistRequest
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string SpotifyPlaylistId { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -2,14 +2,18 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text.Json;
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Models.Lyrics;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Models.Subsonic;
|
||||
using allstarr.Models.Spotify;
|
||||
using allstarr.Services;
|
||||
using allstarr.Services.Common;
|
||||
using allstarr.Services.Local;
|
||||
using allstarr.Services.Jellyfin;
|
||||
using allstarr.Services.Subsonic;
|
||||
using allstarr.Services.Lyrics;
|
||||
using allstarr.Services.Spotify;
|
||||
using allstarr.Filters;
|
||||
|
||||
namespace allstarr.Controllers;
|
||||
|
||||
@@ -22,34 +26,58 @@ namespace allstarr.Controllers;
|
||||
public class JellyfinController : ControllerBase
|
||||
{
|
||||
private readonly JellyfinSettings _settings;
|
||||
private readonly SpotifyImportSettings _spotifySettings;
|
||||
private readonly SpotifyApiSettings _spotifyApiSettings;
|
||||
private readonly IMusicMetadataService _metadataService;
|
||||
private readonly ParallelMetadataService? _parallelMetadataService;
|
||||
private readonly ILocalLibraryService _localLibraryService;
|
||||
private readonly IDownloadService _downloadService;
|
||||
private readonly JellyfinResponseBuilder _responseBuilder;
|
||||
private readonly JellyfinModelMapper _modelMapper;
|
||||
private readonly JellyfinProxyService _proxyService;
|
||||
private readonly JellyfinSessionManager _sessionManager;
|
||||
private readonly PlaylistSyncService? _playlistSyncService;
|
||||
private readonly SpotifyPlaylistFetcher? _spotifyPlaylistFetcher;
|
||||
private readonly SpotifyLyricsService? _spotifyLyricsService;
|
||||
private readonly LrclibService? _lrclibService;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly ILogger<JellyfinController> _logger;
|
||||
|
||||
public JellyfinController(
|
||||
IOptions<JellyfinSettings> settings,
|
||||
IOptions<SpotifyImportSettings> spotifySettings,
|
||||
IOptions<SpotifyApiSettings> spotifyApiSettings,
|
||||
IMusicMetadataService metadataService,
|
||||
ILocalLibraryService localLibraryService,
|
||||
IDownloadService downloadService,
|
||||
JellyfinResponseBuilder responseBuilder,
|
||||
JellyfinModelMapper modelMapper,
|
||||
JellyfinProxyService proxyService,
|
||||
JellyfinSessionManager sessionManager,
|
||||
RedisCacheService cache,
|
||||
ILogger<JellyfinController> logger,
|
||||
PlaylistSyncService? playlistSyncService = null)
|
||||
ParallelMetadataService? parallelMetadataService = null,
|
||||
PlaylistSyncService? playlistSyncService = null,
|
||||
SpotifyPlaylistFetcher? spotifyPlaylistFetcher = null,
|
||||
SpotifyLyricsService? spotifyLyricsService = null,
|
||||
LrclibService? lrclibService = null)
|
||||
{
|
||||
_settings = settings.Value;
|
||||
_spotifySettings = spotifySettings.Value;
|
||||
_spotifyApiSettings = spotifyApiSettings.Value;
|
||||
_metadataService = metadataService;
|
||||
_parallelMetadataService = parallelMetadataService;
|
||||
_localLibraryService = localLibraryService;
|
||||
_downloadService = downloadService;
|
||||
_responseBuilder = responseBuilder;
|
||||
_modelMapper = modelMapper;
|
||||
_proxyService = proxyService;
|
||||
_sessionManager = sessionManager;
|
||||
_playlistSyncService = playlistSyncService;
|
||||
_spotifyPlaylistFetcher = spotifyPlaylistFetcher;
|
||||
_spotifyLyricsService = spotifyLyricsService;
|
||||
_lrclibService = lrclibService;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_settings.Url))
|
||||
@@ -80,6 +108,20 @@ public class JellyfinController : ControllerBase
|
||||
_logger.LogInformation("=== SEARCHITEMS V2 CALLED === searchTerm={SearchTerm}, includeItemTypes={ItemTypes}, parentId={ParentId}, artistIds={ArtistIds}, userId={UserId}",
|
||||
searchTerm, includeItemTypes, parentId, artistIds, userId);
|
||||
|
||||
// Cache search results in Redis only (no file persistence, 15 min TTL)
|
||||
// Only cache actual searches, not browse operations
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm) && string.IsNullOrWhiteSpace(artistIds))
|
||||
{
|
||||
var cacheKey = $"search:{searchTerm?.ToLowerInvariant()}:{includeItemTypes}:{limit}:{startIndex}";
|
||||
var cachedResult = await _cache.GetAsync<object>(cacheKey);
|
||||
|
||||
if (cachedResult != null)
|
||||
{
|
||||
_logger.LogDebug("✅ Returning cached search results for '{SearchTerm}'", searchTerm);
|
||||
return new JsonResult(cachedResult);
|
||||
}
|
||||
}
|
||||
|
||||
// If filtering by artist, handle external artists
|
||||
if (!string.IsNullOrWhiteSpace(artistIds))
|
||||
{
|
||||
@@ -101,17 +143,76 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
// Build the full endpoint path with query string
|
||||
var endpoint = userId != null ? $"Users/{userId}/Items" : "Items";
|
||||
if (Request.QueryString.HasValue)
|
||||
|
||||
// Ensure MediaSources is included in Fields parameter for bitrate info
|
||||
var queryString = Request.QueryString.Value ?? "";
|
||||
|
||||
if (!string.IsNullOrEmpty(queryString))
|
||||
{
|
||||
endpoint = $"{endpoint}{Request.QueryString.Value}";
|
||||
// Parse query string to modify Fields parameter
|
||||
var queryParams = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString);
|
||||
|
||||
if (queryParams.ContainsKey("Fields"))
|
||||
{
|
||||
var fieldsValue = queryParams["Fields"].ToString();
|
||||
if (!fieldsValue.Contains("MediaSources", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Append MediaSources to existing Fields
|
||||
var newFields = string.IsNullOrEmpty(fieldsValue)
|
||||
? "MediaSources"
|
||||
: $"{fieldsValue},MediaSources";
|
||||
|
||||
// Rebuild query string with updated Fields
|
||||
var newQueryParams = new Dictionary<string, string>();
|
||||
foreach (var kvp in queryParams)
|
||||
{
|
||||
if (kvp.Key == "Fields")
|
||||
{
|
||||
newQueryParams[kvp.Key] = newFields;
|
||||
}
|
||||
else
|
||||
{
|
||||
newQueryParams[kvp.Key] = kvp.Value.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
queryString = "?" + string.Join("&", newQueryParams.Select(kvp =>
|
||||
$"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value)}"));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No Fields parameter, add it
|
||||
queryString = $"{queryString}&Fields=MediaSources";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No query string at all
|
||||
queryString = "?Fields=MediaSources";
|
||||
}
|
||||
|
||||
var browseResult = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
|
||||
endpoint = $"{endpoint}{queryString}";
|
||||
|
||||
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());
|
||||
@@ -146,7 +247,11 @@ public class JellyfinController : ControllerBase
|
||||
// Run local and external searches in parallel
|
||||
var itemTypes = ParseItemTypes(includeItemTypes);
|
||||
var jellyfinTask = _proxyService.SearchAsync(cleanQuery, itemTypes, limit, recursive, Request.Headers);
|
||||
var externalTask = _metadataService.SearchAllAsync(cleanQuery, limit, limit, limit);
|
||||
|
||||
// Use parallel metadata service if available (races providers), otherwise use primary
|
||||
var externalTask = _parallelMetadataService != null
|
||||
? _parallelMetadataService.SearchAllAsync(cleanQuery, limit, limit, limit)
|
||||
: _metadataService.SearchAllAsync(cleanQuery, limit, limit, limit);
|
||||
|
||||
var playlistTask = _settings.EnableExternalPlaylists
|
||||
? _metadataService.SearchPlaylistsAsync(cleanQuery, limit)
|
||||
@@ -154,7 +259,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 +274,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 +312,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();
|
||||
@@ -221,6 +322,39 @@ public class JellyfinController : ControllerBase
|
||||
_logger.LogInformation("Scored and filtered results: Songs={Songs}, Albums={Albums}, Artists={Artists}",
|
||||
mergedSongs.Count, mergedAlbums.Count, mergedArtists.Count);
|
||||
|
||||
// Pre-fetch lyrics for top 3 songs in background (don't await)
|
||||
if (_lrclibService != null && mergedSongs.Count > 0)
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var top3 = mergedSongs.Take(3).ToList();
|
||||
_logger.LogDebug("🎵 Pre-fetching lyrics for top {Count} search results", top3.Count);
|
||||
|
||||
foreach (var songItem in top3)
|
||||
{
|
||||
if (songItem.TryGetValue("Name", out var nameObj) && nameObj is JsonElement nameEl &&
|
||||
songItem.TryGetValue("Artists", out var artistsObj) && artistsObj is JsonElement artistsEl &&
|
||||
artistsEl.GetArrayLength() > 0)
|
||||
{
|
||||
var title = nameEl.GetString() ?? "";
|
||||
var artist = artistsEl[0].GetString() ?? "";
|
||||
|
||||
if (!string.IsNullOrEmpty(title) && !string.IsNullOrEmpty(artist))
|
||||
{
|
||||
await _lrclibService.GetLyricsAsync(title, artist, "", 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to pre-fetch lyrics for search results");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by item types if specified
|
||||
var items = new List<Dictionary<string, object?>>();
|
||||
|
||||
@@ -257,6 +391,14 @@ public class JellyfinController : ControllerBase
|
||||
StartIndex = startIndex
|
||||
};
|
||||
|
||||
// Cache search results in Redis (15 min TTL, no file persistence)
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm) && string.IsNullOrWhiteSpace(artistIds))
|
||||
{
|
||||
var cacheKey = $"search:{searchTerm?.ToLowerInvariant()}:{includeItemTypes}:{limit}:{startIndex}";
|
||||
await _cache.SetAsync(cacheKey, response, TimeSpan.FromMinutes(15));
|
||||
_logger.LogDebug("💾 Cached search results for '{SearchTerm}' (15 min TTL)", searchTerm);
|
||||
}
|
||||
|
||||
_logger.LogInformation("About to serialize response...");
|
||||
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(response, new System.Text.Json.JsonSerializerOptions
|
||||
@@ -305,7 +447,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 +455,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 +487,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 +543,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>
|
||||
@@ -465,9 +598,13 @@ public class JellyfinController : ControllerBase
|
||||
{
|
||||
var itemTypes = ParseItemTypes(includeItemTypes);
|
||||
|
||||
_logger.LogInformation("GetExternalChildItems: provider={Provider}, externalId={ExternalId}, itemTypes={ItemTypes}",
|
||||
provider, externalId, string.Join(",", itemTypes ?? Array.Empty<string>()));
|
||||
|
||||
// Check if asking for audio (album tracks)
|
||||
if (itemTypes?.Contains("Audio") == true)
|
||||
{
|
||||
_logger.LogDebug("Fetching album tracks for {Provider}/{ExternalId}", provider, externalId);
|
||||
var album = await _metadataService.GetAlbumAsync(provider, externalId);
|
||||
if (album == null)
|
||||
{
|
||||
@@ -478,9 +615,12 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
|
||||
// Otherwise assume it's artist albums
|
||||
_logger.LogDebug("Fetching artist albums for {Provider}/{ExternalId}", provider, externalId);
|
||||
var albums = await _metadataService.GetArtistAlbumsAsync(provider, externalId);
|
||||
var artist = await _metadataService.GetArtistAsync(provider, externalId);
|
||||
|
||||
_logger.LogInformation("Found {Count} albums for artist {ArtistName}", albums.Count, artist?.Name ?? "unknown");
|
||||
|
||||
// Fill artist info
|
||||
if (artist != null)
|
||||
{
|
||||
@@ -524,7 +664,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 +714,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 +752,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 +763,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 +913,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();
|
||||
@@ -845,12 +997,34 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Universal audio endpoint that redirects to the stream endpoint.
|
||||
/// Universal audio endpoint - handles transcoding, format negotiation, and adaptive streaming.
|
||||
/// This is the primary endpoint used by Jellyfin Web and most clients.
|
||||
/// </summary>
|
||||
[HttpGet("Audio/{itemId}/universal")]
|
||||
public Task<IActionResult> UniversalAudio(string itemId)
|
||||
[HttpHead("Audio/{itemId}/universal")]
|
||||
public async Task<IActionResult> UniversalAudio(string itemId)
|
||||
{
|
||||
return StreamAudio(itemId);
|
||||
if (string.IsNullOrWhiteSpace(itemId))
|
||||
{
|
||||
return BadRequest(new { error = "Missing item ID" });
|
||||
}
|
||||
|
||||
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
|
||||
|
||||
if (!isExternal)
|
||||
{
|
||||
// For local content, proxy the universal endpoint with all query parameters
|
||||
var fullPath = $"Audio/{itemId}/universal";
|
||||
if (Request.QueryString.HasValue)
|
||||
{
|
||||
fullPath = $"{fullPath}{Request.QueryString.Value}";
|
||||
}
|
||||
|
||||
return await ProxyJellyfinStream(fullPath, itemId);
|
||||
}
|
||||
|
||||
// For external content, use simple streaming (no transcoding support yet)
|
||||
return await StreamExternalContent(provider!, externalId!);
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -884,24 +1058,19 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
if (!isExternal)
|
||||
{
|
||||
// Redirect to Jellyfin directly for local content images
|
||||
var queryString = new List<string>();
|
||||
if (maxWidth.HasValue) queryString.Add($"maxWidth={maxWidth.Value}");
|
||||
if (maxHeight.HasValue) queryString.Add($"maxHeight={maxHeight.Value}");
|
||||
// Proxy image from Jellyfin for local content
|
||||
var (imageBytes, contentType) = await _proxyService.GetImageAsync(
|
||||
itemId,
|
||||
imageType,
|
||||
maxWidth,
|
||||
maxHeight);
|
||||
|
||||
var path = $"Items/{itemId}/Images/{imageType}";
|
||||
if (imageIndex > 0)
|
||||
if (imageBytes == null || contentType == null)
|
||||
{
|
||||
path = $"Items/{itemId}/Images/{imageType}/{imageIndex}";
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (queryString.Any())
|
||||
{
|
||||
path = $"{path}?{string.Join("&", queryString)}";
|
||||
}
|
||||
|
||||
var jellyfinUrl = $"{_settings.Url?.TrimEnd('/')}/{path}";
|
||||
return Redirect(jellyfinUrl);
|
||||
return File(imageBytes, contentType);
|
||||
}
|
||||
|
||||
// Get external cover art URL
|
||||
@@ -944,6 +1113,7 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
/// <summary>
|
||||
/// Gets lyrics for an item.
|
||||
/// Priority: 1. Jellyfin embedded lyrics, 2. Spotify synced lyrics, 3. LRCLIB
|
||||
/// </summary>
|
||||
[HttpGet("Audio/{itemId}/Lyrics")]
|
||||
[HttpGet("Items/{itemId}/Lyrics")]
|
||||
@@ -956,16 +1126,36 @@ 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, trying Spotify/LRCLIB");
|
||||
}
|
||||
|
||||
// Get song metadata for lyrics search
|
||||
Song? song = null;
|
||||
string? spotifyTrackId = null;
|
||||
|
||||
if (isExternal)
|
||||
{
|
||||
song = await _metadataService.GetSongAsync(provider!, externalId!);
|
||||
// For Deezer tracks, we'll search Spotify by metadata
|
||||
}
|
||||
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")
|
||||
{
|
||||
@@ -976,6 +1166,15 @@ public class JellyfinController : ControllerBase
|
||||
Album = item.RootElement.TryGetProperty("Album", out var album) ? album.GetString() ?? "" : "",
|
||||
Duration = item.RootElement.TryGetProperty("RunTimeTicks", out var ticks) ? (int)(ticks.GetInt64() / 10000000) : 0
|
||||
};
|
||||
|
||||
// Check for Spotify ID in provider IDs
|
||||
if (item.RootElement.TryGetProperty("ProviderIds", out var providerIds))
|
||||
{
|
||||
if (providerIds.TryGetProperty("Spotify", out var spotifyId))
|
||||
{
|
||||
spotifyTrackId = spotifyId.GetString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -984,18 +1183,66 @@ public class JellyfinController : ControllerBase
|
||||
return NotFound(new { error = "Song not found" });
|
||||
}
|
||||
|
||||
// Try to get lyrics from LRCLIB
|
||||
var lyricsService = HttpContext.RequestServices.GetService<LrclibService>();
|
||||
if (lyricsService == null)
|
||||
// Strip [S] suffix from title, artist, and album for lyrics search
|
||||
// The [S] tag is added to external tracks but shouldn't be used in lyrics queries
|
||||
var searchTitle = song.Title.Replace(" [S]", "").Trim();
|
||||
var searchArtist = song.Artist?.Replace(" [S]", "").Trim() ?? "";
|
||||
var searchAlbum = song.Album?.Replace(" [S]", "").Trim() ?? "";
|
||||
var searchArtists = song.Artists.Select(a => a.Replace(" [S]", "").Trim()).ToList();
|
||||
|
||||
if (searchArtists.Count == 0 && !string.IsNullOrEmpty(searchArtist))
|
||||
{
|
||||
return NotFound(new { error = "Lyrics service not available" });
|
||||
searchArtists.Add(searchArtist);
|
||||
}
|
||||
|
||||
var lyrics = await lyricsService.GetLyricsAsync(
|
||||
song.Title,
|
||||
song.Artist ?? "",
|
||||
song.Album ?? "",
|
||||
song.Duration ?? 0);
|
||||
LyricsInfo? lyrics = null;
|
||||
|
||||
// Try Spotify lyrics first (better synced lyrics quality)
|
||||
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled)
|
||||
{
|
||||
_logger.LogInformation("Trying Spotify lyrics for: {Artist} - {Title}", searchArtist, searchTitle);
|
||||
|
||||
SpotifyLyricsResult? spotifyLyrics = null;
|
||||
|
||||
// If we have a Spotify track ID, use it directly
|
||||
if (!string.IsNullOrEmpty(spotifyTrackId))
|
||||
{
|
||||
spotifyLyrics = await _spotifyLyricsService.GetLyricsByTrackIdAsync(spotifyTrackId);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Search by metadata (without [S] tags)
|
||||
spotifyLyrics = await _spotifyLyricsService.SearchAndGetLyricsAsync(
|
||||
searchTitle,
|
||||
searchArtists.Count > 0 ? searchArtists[0] : searchArtist,
|
||||
searchAlbum,
|
||||
song.Duration.HasValue ? song.Duration.Value * 1000 : null);
|
||||
}
|
||||
|
||||
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
|
||||
{
|
||||
_logger.LogInformation("Found Spotify lyrics for {Artist} - {Title} ({LineCount} lines, type: {SyncType})",
|
||||
searchArtist, searchTitle, spotifyLyrics.Lines.Count, spotifyLyrics.SyncType);
|
||||
lyrics = _spotifyLyricsService.ToLyricsInfo(spotifyLyrics);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to LRCLIB if no Spotify lyrics
|
||||
if (lyrics == null)
|
||||
{
|
||||
_logger.LogInformation("Searching LRCLIB for lyrics: {Artists} - {Title}",
|
||||
string.Join(", ", searchArtists),
|
||||
searchTitle);
|
||||
var lrclibService = HttpContext.RequestServices.GetService<LrclibService>();
|
||||
if (lrclibService != null)
|
||||
{
|
||||
lyrics = await lrclibService.GetLyricsAsync(
|
||||
searchTitle,
|
||||
searchArtists.ToArray(),
|
||||
searchAlbum,
|
||||
song.Duration ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
if (lyrics == null)
|
||||
{
|
||||
@@ -1006,15 +1253,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 +1280,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 +1330,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 +1350,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 +1388,116 @@ 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
|
||||
var (isExternal, _, _) = _localLibraryService.ParseSongId(itemId);
|
||||
// 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 - remove from kept folder if it exists
|
||||
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
|
||||
if (isExternal || PlaylistIdHelper.IsExternalPlaylist(itemId))
|
||||
{
|
||||
return Ok(new { IsFavorite = false });
|
||||
_logger.LogInformation("Unfavoriting external item {ItemId} - removing from kept folder", itemId);
|
||||
|
||||
// Remove from kept folder in background
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await RemoveExternalTrackFromKeptAsync(itemId, provider!, externalId!);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to remove external track {ItemId} from kept folder", itemId);
|
||||
}
|
||||
});
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
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 +1551,50 @@ 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 Playlists: {Count}",
|
||||
_spotifySettings.Enabled, _spotifySettings.Playlists.Count);
|
||||
|
||||
if (_spotifySettings.Enabled && _spotifySettings.IsSpotifyPlaylist(playlistId))
|
||||
{
|
||||
// Get playlist info from Jellyfin to get the name for matching missing tracks
|
||||
_logger.LogInformation("Fetching playlist info from Jellyfin for ID: {PlaylistId}", playlistId);
|
||||
var (playlistInfo, _) = await _proxyService.GetJsonAsync($"Items/{playlistId}", null, Request.Headers);
|
||||
|
||||
if (playlistInfo != null && playlistInfo.RootElement.TryGetProperty("Name", out var nameElement))
|
||||
{
|
||||
var playlistName = nameElement.GetString() ?? "";
|
||||
_logger.LogInformation("✓ MATCHED! Intercepting Spotify playlist: {PlaylistName} (ID: {PlaylistId})",
|
||||
playlistName, playlistId);
|
||||
return await GetSpotifyPlaylistTracksAsync(playlistName, playlistId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Could not get playlist name from Jellyfin for ID: {PlaylistId}", playlistId);
|
||||
}
|
||||
}
|
||||
|
||||
// Regular Jellyfin playlist - proxy through
|
||||
var endpoint = $"Playlists/{playlistId}/Items";
|
||||
if (Request.QueryString.HasValue)
|
||||
{
|
||||
endpoint = $"{endpoint}{Request.QueryString.Value}";
|
||||
}
|
||||
|
||||
_logger.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 +1662,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 +1810,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 +1915,488 @@ 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");
|
||||
|
||||
// Parse the body to check if it's an external track
|
||||
var doc = JsonDocument.Parse(body);
|
||||
string? itemId = null;
|
||||
string? itemName = null;
|
||||
long? positionTicks = null;
|
||||
|
||||
if (doc.RootElement.TryGetProperty("ItemId", out var itemIdProp))
|
||||
{
|
||||
itemId = itemIdProp.GetString();
|
||||
}
|
||||
|
||||
if (doc.RootElement.TryGetProperty("ItemName", out var itemNameProp))
|
||||
{
|
||||
itemName = itemNameProp.GetString();
|
||||
}
|
||||
|
||||
if (doc.RootElement.TryGetProperty("PositionTicks", out var posProp))
|
||||
{
|
||||
positionTicks = posProp.GetInt64();
|
||||
}
|
||||
|
||||
// Track the playing item for scrobbling on session cleanup
|
||||
var (deviceId, client, device, version) = ExtractDeviceInfo(Request.Headers);
|
||||
if (!string.IsNullOrEmpty(deviceId) && !string.IsNullOrEmpty(itemId))
|
||||
{
|
||||
_sessionManager.UpdatePlayingItem(deviceId, itemId, positionTicks);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(itemId))
|
||||
{
|
||||
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
|
||||
|
||||
if (isExternal)
|
||||
{
|
||||
_logger.LogInformation("🎵 External track playback started: {Name} ({Provider}/{ExternalId})",
|
||||
itemName ?? "Unknown", provider, externalId);
|
||||
// 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 playback start to Jellyfin FIRST
|
||||
_logger.LogInformation("Forwarding playback start to Jellyfin...");
|
||||
|
||||
// Fetch full item details to include in playback report
|
||||
try
|
||||
{
|
||||
var (itemResult, itemStatus) = await _proxyService.GetJsonAsync($"Items/{itemId}", null, Request.Headers);
|
||||
if (itemResult != null && itemStatus == 200)
|
||||
{
|
||||
var item = itemResult.RootElement;
|
||||
_logger.LogInformation("📦 Fetched item details for playback report");
|
||||
|
||||
// Build playback start info - Jellyfin will fetch item details itself
|
||||
var playbackStart = new
|
||||
{
|
||||
ItemId = itemId,
|
||||
PositionTicks = positionTicks ?? 0,
|
||||
// Let Jellyfin fetch the item details - don't include NowPlayingItem
|
||||
};
|
||||
|
||||
var playbackJson = JsonSerializer.Serialize(playbackStart);
|
||||
_logger.LogInformation("📤 Sending playback start: {Json}", playbackJson);
|
||||
|
||||
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", playbackJson, Request.Headers);
|
||||
|
||||
if (statusCode == 204 || statusCode == 200)
|
||||
{
|
||||
_logger.LogInformation("✓ Playback start forwarded to Jellyfin ({StatusCode})", statusCode);
|
||||
|
||||
// NOW ensure session exists with capabilities (after playback is reported)
|
||||
if (!string.IsNullOrEmpty(deviceId))
|
||||
{
|
||||
var sessionCreated = await _sessionManager.EnsureSessionAsync(deviceId, client ?? "Unknown", device ?? "Unknown", version ?? "1.0", Request.Headers);
|
||||
if (sessionCreated)
|
||||
{
|
||||
_logger.LogWarning("✓ SESSION: Session ensured for device {DeviceId} after playback start", deviceId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("⚠️ SESSION: Failed to ensure session for device {DeviceId}", deviceId);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("⚠️ SESSION: No device ID found in headers for playback start");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("⚠️ Playback start returned status {StatusCode}", statusCode);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("⚠️ Could not fetch item details ({StatusCode}), sending basic playback start", itemStatus);
|
||||
// Fall back to basic playback start
|
||||
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", body, Request.Headers);
|
||||
if (statusCode == 204 || statusCode == 200)
|
||||
{
|
||||
_logger.LogInformation("✓ Basic playback start forwarded to Jellyfin ({StatusCode})", statusCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to send playback start, trying basic");
|
||||
// Fall back to basic playback start
|
||||
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", body, Request.Headers);
|
||||
if (statusCode == 204 || statusCode == 200)
|
||||
{
|
||||
_logger.LogInformation("✓ Basic playback start forwarded to Jellyfin ({StatusCode})", statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.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;
|
||||
|
||||
// Update session activity
|
||||
var (deviceId, _, _, _) = ExtractDeviceInfo(Request.Headers);
|
||||
if (!string.IsNullOrEmpty(deviceId))
|
||||
{
|
||||
_sessionManager.UpdateActivity(deviceId);
|
||||
}
|
||||
|
||||
// Parse the body to check if it's an external track
|
||||
var doc = JsonDocument.Parse(body);
|
||||
string? itemId = null;
|
||||
long? positionTicks = null;
|
||||
|
||||
if (doc.RootElement.TryGetProperty("ItemId", out var itemIdProp))
|
||||
{
|
||||
itemId = itemIdProp.GetString();
|
||||
}
|
||||
if (doc.RootElement.TryGetProperty("PositionTicks", out var posProp))
|
||||
{
|
||||
positionTicks = posProp.GetInt64();
|
||||
}
|
||||
|
||||
// Track the playing item for scrobbling on session cleanup
|
||||
if (!string.IsNullOrEmpty(deviceId) && !string.IsNullOrEmpty(itemId))
|
||||
{
|
||||
_sessionManager.UpdatePlayingItem(deviceId, itemId, positionTicks);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(itemId))
|
||||
{
|
||||
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
|
||||
|
||||
if (isExternal)
|
||||
{
|
||||
// For external tracks, just acknowledge (no logging to avoid spam)
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// Log progress for local tracks (only every ~10 seconds to avoid spam)
|
||||
if (positionTicks.HasValue)
|
||||
{
|
||||
var position = TimeSpan.FromTicks(positionTicks.Value);
|
||||
// Only log at 10-second intervals
|
||||
if (position.Seconds % 10 == 0 && position.Milliseconds < 500)
|
||||
{
|
||||
_logger.LogInformation("▶️ Progress: {Position:mm\\:ss} for item {ItemId}", position, itemId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For local tracks, forward to Jellyfin
|
||||
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing/Progress", body, Request.Headers);
|
||||
|
||||
if (statusCode != 204 && statusCode != 200)
|
||||
{
|
||||
_logger.LogWarning("⚠️ Progress report returned {StatusCode} for item {ItemId}", statusCode, itemId);
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.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;
|
||||
string? deviceId = null;
|
||||
|
||||
if (doc.RootElement.TryGetProperty("ItemId", out var itemIdProp))
|
||||
{
|
||||
itemId = itemIdProp.GetString();
|
||||
}
|
||||
|
||||
if (doc.RootElement.TryGetProperty("ItemName", out var itemNameProp))
|
||||
{
|
||||
itemName = itemNameProp.GetString();
|
||||
}
|
||||
|
||||
if (doc.RootElement.TryGetProperty("PositionTicks", out var posProp))
|
||||
{
|
||||
positionTicks = posProp.GetInt64();
|
||||
}
|
||||
|
||||
// Try to get device ID from headers for session management
|
||||
if (Request.Headers.TryGetValue("X-Emby-Device-Id", out var deviceIdHeader))
|
||||
{
|
||||
deviceId = deviceIdHeader.FirstOrDefault();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(itemId))
|
||||
{
|
||||
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
|
||||
|
||||
if (isExternal)
|
||||
{
|
||||
var position = positionTicks.HasValue
|
||||
? TimeSpan.FromTicks(positionTicks.Value).ToString(@"mm\:ss")
|
||||
: "unknown";
|
||||
_logger.LogInformation("🎵 External track playback stopped: {Name} at {Position} ({Provider}/{ExternalId})",
|
||||
itemName ?? "Unknown", position, provider, externalId);
|
||||
|
||||
// Mark session as potentially ended after playback stops
|
||||
// Wait 50 seconds for next song to start before cleaning up
|
||||
if (!string.IsNullOrEmpty(deviceId))
|
||||
{
|
||||
_sessionManager.MarkSessionPotentiallyEnded(deviceId, TimeSpan.FromSeconds(50));
|
||||
}
|
||||
|
||||
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,14 +2431,100 @@ 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)
|
||||
{
|
||||
// Handle non-JSON responses (robots.txt, etc.)
|
||||
if (path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.EndsWith(".xml", StringComparison.OrdinalIgnoreCase))
|
||||
// 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 Playlists: {Playlists}", string.Join(", ", _spotifySettings.Playlists.Select(p => $"{p.Name}:{p.Id}")));
|
||||
_logger.LogInformation("Is configured: {IsConfigured}", _spotifySettings.IsSpotifyPlaylist(playlistId));
|
||||
|
||||
// Check if this playlist ID is configured for Spotify injection
|
||||
if (_spotifySettings.IsSpotifyPlaylist(playlistId))
|
||||
{
|
||||
_logger.LogInformation("========================================");
|
||||
_logger.LogInformation("=== INTERCEPTING SPOTIFY PLAYLIST ===");
|
||||
_logger.LogInformation("Playlist ID: {PlaylistId}", playlistId);
|
||||
_logger.LogInformation("========================================");
|
||||
return await GetPlaylistTracks(playlistId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle non-JSON responses (images, robots.txt, etc.)
|
||||
if (path.Contains("/Images/", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.EndsWith(".xml", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.EndsWith(".png", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.EndsWith(".gif", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.EndsWith(".webp", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.EndsWith(".m3u8", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.EndsWith(".m3u", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.EndsWith(".ts", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var fullPath = path;
|
||||
if (Request.QueryString.HasValue)
|
||||
@@ -1604,14 +2536,42 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _proxyService.HttpClient.GetAsync(url);
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
var contentType = response.Content.Headers.ContentType?.ToString() ?? "text/plain";
|
||||
return Content(content, contentType);
|
||||
// Forward authentication headers for image requests
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
|
||||
// Forward auth headers from client
|
||||
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))
|
||||
{
|
||||
var authValue = auth.ToString();
|
||||
if (authValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase) ||
|
||||
authValue.Contains("Token=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", authValue);
|
||||
}
|
||||
else
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("Authorization", authValue);
|
||||
}
|
||||
}
|
||||
|
||||
var response = await _proxyService.HttpClient.SendAsync(request);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return StatusCode((int)response.StatusCode);
|
||||
}
|
||||
|
||||
var contentBytes = await response.Content.ReadAsByteArrayAsync();
|
||||
var contentType = response.Content.Headers.ContentType?.ToString() ?? "application/octet-stream";
|
||||
return File(contentBytes, contentType);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to proxy non-JSON request for {Path}", path);
|
||||
_logger.LogWarning(ex, "Failed to proxy binary request for {Path}", path);
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
@@ -1659,6 +2619,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
|
||||
JsonDocument? result;
|
||||
int statusCode;
|
||||
|
||||
if (HttpContext.Request.Method == HttpMethod.Post.Method)
|
||||
{
|
||||
@@ -1700,22 +2661,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 +2726,253 @@ 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.IsSpotifyPlaylist(playlistId))
|
||||
{
|
||||
_logger.LogInformation("Found Spotify playlist: {Id}", playlistId);
|
||||
|
||||
// This is a Spotify playlist - get the actual track count
|
||||
var playlistConfig = _spotifySettings.GetPlaylistByJellyfinId(playlistId);
|
||||
|
||||
if (playlistConfig != null)
|
||||
{
|
||||
_logger.LogInformation("Found playlist config for Jellyfin ID {JellyfinId}: {Name} (Spotify ID: {SpotifyId})",
|
||||
playlistId, playlistConfig.Name, playlistConfig.Id);
|
||||
var playlistName = playlistConfig.Name;
|
||||
|
||||
// Get matched external tracks (tracks that were successfully downloaded/matched)
|
||||
var matchedTracksKey = $"spotify:matched:ordered:{playlistName}";
|
||||
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
|
||||
|
||||
_logger.LogDebug("Cache lookup for {Key}: {Count} matched tracks",
|
||||
matchedTracksKey, matchedTracks?.Count ?? 0);
|
||||
|
||||
// Fallback to legacy cache format
|
||||
if (matchedTracks == null || matchedTracks.Count == 0)
|
||||
{
|
||||
var legacyKey = $"spotify:matched:{playlistName}";
|
||||
var legacySongs = await _cache.GetAsync<List<Song>>(legacyKey);
|
||||
if (legacySongs != null && legacySongs.Count > 0)
|
||||
{
|
||||
matchedTracks = legacySongs.Select((s, i) => new MatchedTrack
|
||||
{
|
||||
Position = i,
|
||||
MatchedSong = s
|
||||
}).ToList();
|
||||
_logger.LogDebug("Loaded {Count} tracks from legacy cache", matchedTracks.Count);
|
||||
}
|
||||
}
|
||||
|
||||
// Try loading from file cache if Redis is empty
|
||||
if (matchedTracks == null || matchedTracks.Count == 0)
|
||||
{
|
||||
var fileItems = await LoadPlaylistItemsFromFile(playlistName);
|
||||
if (fileItems != null && fileItems.Count > 0)
|
||||
{
|
||||
_logger.LogInformation("💿 Loaded {Count} playlist items from file cache for count update", fileItems.Count);
|
||||
// Use file cache count directly
|
||||
itemDict["ChildCount"] = fileItems.Count;
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Only fetch from Jellyfin if we didn't get count from file cache
|
||||
if (!itemDict.ContainsKey("ChildCount") ||
|
||||
(itemDict["ChildCount"] is JsonElement childCountElement && childCountElement.GetInt32() == 0) ||
|
||||
(itemDict["ChildCount"] is int childCountInt && childCountInt == 0))
|
||||
{
|
||||
// Get local tracks count from Jellyfin
|
||||
var localTracksCount = 0;
|
||||
try
|
||||
{
|
||||
// Include UserId parameter to avoid 401 Unauthorized
|
||||
var userId = _settings.UserId;
|
||||
var playlistItemsUrl = $"Playlists/{playlistId}/Items";
|
||||
var queryParams = new Dictionary<string, string>();
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
queryParams["UserId"] = userId;
|
||||
}
|
||||
|
||||
var (localTracksResponse, _) = await _proxyService.GetJsonAsyncInternal(
|
||||
playlistItemsUrl,
|
||||
queryParams);
|
||||
|
||||
if (localTracksResponse != null &&
|
||||
localTracksResponse.RootElement.TryGetProperty("Items", out var localItems))
|
||||
{
|
||||
localTracksCount = localItems.GetArrayLength();
|
||||
_logger.LogInformation("Found {Count} total items in Jellyfin playlist {Name}",
|
||||
localTracksCount, playlistName);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to get local tracks count for {Name}", playlistName);
|
||||
}
|
||||
|
||||
// Count external matched tracks (not local)
|
||||
var externalMatchedCount = 0;
|
||||
if (matchedTracks != null)
|
||||
{
|
||||
externalMatchedCount = matchedTracks.Count(t => t.MatchedSong != null && !t.MatchedSong.IsLocal);
|
||||
}
|
||||
|
||||
// Total available tracks = local tracks in Jellyfin + external matched tracks
|
||||
// This represents what users will actually hear when playing the playlist
|
||||
var totalAvailableCount = localTracksCount + externalMatchedCount;
|
||||
|
||||
if (totalAvailableCount > 0)
|
||||
{
|
||||
// Update ChildCount to show actual available tracks
|
||||
itemDict["ChildCount"] = totalAvailableCount;
|
||||
modified = true;
|
||||
_logger.LogInformation("✓ Updated ChildCount for Spotify playlist {Name} to {Total} ({Local} local + {External} external)",
|
||||
playlistName, totalAvailableCount, localTracksCount, externalMatchedCount);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("No tracks found for {Name} ({Local} local + {External} external = {Total} total)",
|
||||
playlistName, localTracksCount, externalMatchedCount, totalAvailableCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("No playlist config found for Jellyfin ID {JellyfinId} - skipping count update", playlistId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updatedItems.Add(itemDict);
|
||||
}
|
||||
|
||||
if (!modified)
|
||||
{
|
||||
_logger.LogInformation("No Spotify playlists found to update");
|
||||
return response;
|
||||
}
|
||||
|
||||
_logger.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 +3006,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 +3059,1068 @@ 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.
|
||||
///
|
||||
/// Supports two modes:
|
||||
/// 1. Direct Spotify API (new): Uses SpotifyPlaylistFetcher for ordered tracks with ISRC matching
|
||||
/// 2. Jellyfin Plugin (legacy): Uses MissingTrack data from Jellyfin Spotify Import plugin
|
||||
/// </summary>
|
||||
private async Task<IActionResult> GetSpotifyPlaylistTracksAsync(string spotifyPlaylistName, string playlistId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Try ordered cache first (from direct Spotify API mode)
|
||||
if (_spotifyApiSettings.Enabled && _spotifyPlaylistFetcher != null)
|
||||
{
|
||||
var orderedResult = await GetSpotifyPlaylistTracksOrderedAsync(spotifyPlaylistName, playlistId);
|
||||
if (orderedResult != null) return orderedResult;
|
||||
}
|
||||
|
||||
// Fall back to legacy unordered mode
|
||||
return await GetSpotifyPlaylistTracksLegacyAsync(spotifyPlaylistName, playlistId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting Spotify playlist tracks {PlaylistName}", spotifyPlaylistName);
|
||||
return _responseBuilder.CreateError(500, "Failed to get Spotify playlist tracks");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// New mode: Gets playlist tracks with correct ordering using direct Spotify API data.
|
||||
/// </summary>
|
||||
private async Task<IActionResult?> GetSpotifyPlaylistTracksOrderedAsync(string spotifyPlaylistName, string playlistId)
|
||||
{
|
||||
// Check Redis cache first for fast serving
|
||||
var cacheKey = $"spotify:playlist:items:{spotifyPlaylistName}";
|
||||
var cachedItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(cacheKey);
|
||||
|
||||
if (cachedItems != null && cachedItems.Count > 0)
|
||||
{
|
||||
_logger.LogInformation("✅ Loaded {Count} playlist items from Redis cache for {Playlist}",
|
||||
cachedItems.Count, spotifyPlaylistName);
|
||||
|
||||
return new JsonResult(new
|
||||
{
|
||||
Items = cachedItems,
|
||||
TotalRecordCount = cachedItems.Count,
|
||||
StartIndex = 0
|
||||
});
|
||||
}
|
||||
|
||||
// Check file cache as fallback
|
||||
var fileItems = await LoadPlaylistItemsFromFile(spotifyPlaylistName);
|
||||
if (fileItems != null && fileItems.Count > 0)
|
||||
{
|
||||
_logger.LogInformation("✅ Loaded {Count} playlist items from file cache for {Playlist}",
|
||||
fileItems.Count, spotifyPlaylistName);
|
||||
|
||||
// Restore to Redis cache
|
||||
await _cache.SetAsync(cacheKey, fileItems, TimeSpan.FromHours(24));
|
||||
|
||||
return new JsonResult(new
|
||||
{
|
||||
Items = fileItems,
|
||||
TotalRecordCount = fileItems.Count,
|
||||
StartIndex = 0
|
||||
});
|
||||
}
|
||||
|
||||
// Check for ordered matched tracks from SpotifyTrackMatchingService
|
||||
var orderedCacheKey = $"spotify:matched:ordered:{spotifyPlaylistName}";
|
||||
var orderedTracks = await _cache.GetAsync<List<MatchedTrack>>(orderedCacheKey);
|
||||
|
||||
if (orderedTracks == null || orderedTracks.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No ordered matched tracks in cache for {Playlist}, checking if we can fetch",
|
||||
spotifyPlaylistName);
|
||||
return null; // Fall back to legacy mode
|
||||
}
|
||||
|
||||
_logger.LogDebug("Using {Count} ordered matched tracks for {Playlist}",
|
||||
orderedTracks.Count, spotifyPlaylistName);
|
||||
|
||||
// Get existing Jellyfin playlist items (RAW - don't convert!)
|
||||
// CRITICAL: Must include UserId parameter or Jellyfin returns empty results
|
||||
var userId = _settings.UserId;
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
{
|
||||
_logger.LogError("❌ JELLYFIN_USER_ID is NOT configured! Cannot fetch playlist tracks. Set it in .env or admin UI.");
|
||||
return null; // Fall back to legacy mode
|
||||
}
|
||||
|
||||
// Request MediaSources field to get bitrate info
|
||||
var playlistItemsUrl = $"Playlists/{playlistId}/Items?UserId={userId}&Fields=MediaSources";
|
||||
|
||||
_logger.LogInformation("🔍 Fetching existing tracks from Jellyfin playlist {PlaylistId} with UserId {UserId}",
|
||||
playlistId, userId);
|
||||
|
||||
var (existingTracksResponse, statusCode) = await _proxyService.GetJsonAsync(
|
||||
playlistItemsUrl,
|
||||
null,
|
||||
Request.Headers);
|
||||
|
||||
if (statusCode != 200)
|
||||
{
|
||||
_logger.LogError("❌ Failed to fetch Jellyfin playlist items: HTTP {StatusCode}. Check JELLYFIN_USER_ID is correct.", statusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Keep raw Jellyfin items - don't convert to Song objects!
|
||||
var jellyfinItems = new List<JsonElement>();
|
||||
var jellyfinItemsByName = new Dictionary<string, JsonElement>();
|
||||
|
||||
if (existingTracksResponse != null &&
|
||||
existingTracksResponse.RootElement.TryGetProperty("Items", out var items))
|
||||
{
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
jellyfinItems.Add(item);
|
||||
|
||||
// Index by title+artist for matching
|
||||
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
|
||||
var artist = "";
|
||||
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
|
||||
{
|
||||
artist = artistsEl[0].GetString() ?? "";
|
||||
}
|
||||
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
|
||||
{
|
||||
artist = albumArtistEl.GetString() ?? "";
|
||||
}
|
||||
|
||||
var key = $"{title}|{artist}".ToLowerInvariant();
|
||||
if (!jellyfinItemsByName.ContainsKey(key))
|
||||
{
|
||||
jellyfinItemsByName[key] = item;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("✅ Found {Count} existing LOCAL tracks in Jellyfin playlist", jellyfinItems.Count);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("⚠️ No existing tracks found in Jellyfin playlist {PlaylistId} - playlist may be empty", playlistId);
|
||||
}
|
||||
|
||||
// Get the full playlist from Spotify to know the correct order
|
||||
var spotifyTracks = await _spotifyPlaylistFetcher!.GetPlaylistTracksAsync(spotifyPlaylistName);
|
||||
if (spotifyTracks.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("Could not get Spotify playlist tracks for {Playlist}", spotifyPlaylistName);
|
||||
return null; // Fall back to legacy
|
||||
}
|
||||
|
||||
// Build the final track list in correct Spotify order
|
||||
var finalItems = new List<Dictionary<string, object?>>();
|
||||
var usedJellyfinItems = new HashSet<string>();
|
||||
var localUsedCount = 0;
|
||||
var externalUsedCount = 0;
|
||||
|
||||
_logger.LogInformation("🔍 Building playlist in Spotify order with {SpotifyCount} positions...", spotifyTracks.Count);
|
||||
|
||||
foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position))
|
||||
{
|
||||
// Try to find matching Jellyfin item by fuzzy matching
|
||||
JsonElement? matchedJellyfinItem = null;
|
||||
string? matchedKey = null;
|
||||
double bestScore = 0;
|
||||
|
||||
foreach (var kvp in jellyfinItemsByName)
|
||||
{
|
||||
if (usedJellyfinItems.Contains(kvp.Key)) continue;
|
||||
|
||||
var item = kvp.Value;
|
||||
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
|
||||
var artist = "";
|
||||
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
|
||||
{
|
||||
artist = artistsEl[0].GetString() ?? "";
|
||||
}
|
||||
|
||||
var titleScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.Title, title);
|
||||
var artistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, artist);
|
||||
var totalScore = (titleScore * 0.7) + (artistScore * 0.3);
|
||||
|
||||
if (totalScore > bestScore && totalScore >= 70)
|
||||
{
|
||||
bestScore = totalScore;
|
||||
matchedJellyfinItem = item;
|
||||
matchedKey = kvp.Key;
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedJellyfinItem.HasValue && matchedKey != null)
|
||||
{
|
||||
// Use the raw Jellyfin item (preserves ALL metadata including MediaSources!)
|
||||
var itemDict = JsonSerializer.Deserialize<Dictionary<string, object?>>(matchedJellyfinItem.Value.GetRawText());
|
||||
if (itemDict != null)
|
||||
{
|
||||
finalItems.Add(itemDict);
|
||||
usedJellyfinItems.Add(matchedKey);
|
||||
localUsedCount++;
|
||||
_logger.LogDebug("✅ Position #{Pos}: '{Title}' → LOCAL (score: {Score:F1}%)",
|
||||
spotifyTrack.Position, spotifyTrack.Title, bestScore);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No local match - try to find external track
|
||||
var matched = orderedTracks?.FirstOrDefault(t => t.SpotifyId == spotifyTrack.SpotifyId);
|
||||
if (matched != null && matched.MatchedSong != null)
|
||||
{
|
||||
// Convert external song to Jellyfin item format
|
||||
var externalItem = _responseBuilder.ConvertSongToJellyfinItem(matched.MatchedSong);
|
||||
finalItems.Add(externalItem);
|
||||
externalUsedCount++;
|
||||
_logger.LogDebug("📥 Position #{Pos}: '{Title}' → EXTERNAL: {Provider}/{Id}",
|
||||
spotifyTrack.Position, spotifyTrack.Title,
|
||||
matched.MatchedSong.ExternalProvider, matched.MatchedSong.ExternalId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("❌ Position #{Pos}: '{Title}' → NO MATCH",
|
||||
spotifyTrack.Position, spotifyTrack.Title);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"🎵 Final playlist '{Playlist}': {Total} tracks ({Local} LOCAL + {External} EXTERNAL)",
|
||||
spotifyPlaylistName, finalItems.Count, localUsedCount, externalUsedCount);
|
||||
|
||||
// Save to file cache for persistence across restarts
|
||||
await SavePlaylistItemsToFile(spotifyPlaylistName, finalItems);
|
||||
|
||||
// Also cache in Redis for fast serving (reuse the same cache key from top of method)
|
||||
await _cache.SetAsync(cacheKey, finalItems, TimeSpan.FromHours(24));
|
||||
|
||||
// Return raw Jellyfin response format
|
||||
return new JsonResult(new
|
||||
{
|
||||
Items = finalItems,
|
||||
TotalRecordCount = finalItems.Count,
|
||||
StartIndex = 0
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Legacy mode: Gets playlist tracks without ordering (from Jellyfin Spotify Import plugin).
|
||||
/// </summary>
|
||||
private async Task<IActionResult> GetSpotifyPlaylistTracksLegacyAsync(string spotifyPlaylistName, string playlistId)
|
||||
{
|
||||
var cacheKey = $"spotify:matched:{spotifyPlaylistName}";
|
||||
var cachedTracks = await _cache.GetAsync<List<Song>>(cacheKey);
|
||||
|
||||
if (cachedTracks != null && cachedTracks.Count > 0)
|
||||
{
|
||||
_logger.LogDebug("Returning {Count} cached matched tracks from Redis for {Playlist}",
|
||||
cachedTracks.Count, spotifyPlaylistName);
|
||||
return _responseBuilder.CreateItemsResponse(cachedTracks);
|
||||
}
|
||||
|
||||
// Try file cache if Redis is empty
|
||||
if (cachedTracks == null || cachedTracks.Count == 0)
|
||||
{
|
||||
cachedTracks = await LoadMatchedTracksFromFile(spotifyPlaylistName);
|
||||
if (cachedTracks != null && cachedTracks.Count > 0)
|
||||
{
|
||||
// Restore to Redis with 1 hour TTL
|
||||
await _cache.SetAsync(cacheKey, cachedTracks, TimeSpan.FromHours(1));
|
||||
_logger.LogInformation("Loaded {Count} matched tracks from file cache for {Playlist}",
|
||||
cachedTracks.Count, spotifyPlaylistName);
|
||||
return _responseBuilder.CreateItemsResponse(cachedTracks);
|
||||
}
|
||||
}
|
||||
|
||||
// Get existing Jellyfin playlist items (tracks the plugin already found)
|
||||
// CRITICAL: Must include UserId parameter or Jellyfin returns empty results
|
||||
var userId = _settings.UserId;
|
||||
var playlistItemsUrl = $"Playlists/{playlistId}/Items";
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
playlistItemsUrl += $"?UserId={userId}";
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("No UserId configured - may not be able to fetch existing playlist tracks");
|
||||
}
|
||||
|
||||
var (existingTracksResponse, _) = await _proxyService.GetJsonAsync(
|
||||
playlistItemsUrl,
|
||||
null,
|
||||
Request.Headers);
|
||||
|
||||
var existingTracks = new List<Song>();
|
||||
var existingSpotifyIds = new HashSet<string>();
|
||||
|
||||
if (existingTracksResponse != null &&
|
||||
existingTracksResponse.RootElement.TryGetProperty("Items", out var items))
|
||||
{
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
var song = _modelMapper.ParseSong(item);
|
||||
existingTracks.Add(song);
|
||||
|
||||
// Track Spotify IDs to avoid duplicates
|
||||
if (item.TryGetProperty("ProviderIds", out var providerIds) &&
|
||||
providerIds.TryGetProperty("Spotify", out var spotifyId))
|
||||
{
|
||||
existingSpotifyIds.Add(spotifyId.GetString() ?? "");
|
||||
}
|
||||
}
|
||||
_logger.LogInformation("Found {Count} existing tracks in Jellyfin playlist", existingTracks.Count);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("No existing tracks found in Jellyfin playlist - may need UserId parameter");
|
||||
}
|
||||
|
||||
var missingTracksKey = $"spotify:missing:{spotifyPlaylistName}";
|
||||
var missingTracks = await _cache.GetAsync<List<MissingTrack>>(missingTracksKey);
|
||||
|
||||
// Fallback to file cache if Redis is empty
|
||||
if (missingTracks == null || missingTracks.Count == 0)
|
||||
{
|
||||
missingTracks = await LoadMissingTracksFromFile(spotifyPlaylistName);
|
||||
|
||||
// If we loaded from file, restore to Redis with no expiration
|
||||
if (missingTracks != null && missingTracks.Count > 0)
|
||||
{
|
||||
await _cache.SetAsync(missingTracksKey, missingTracks, TimeSpan.FromDays(365));
|
||||
_logger.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 based on playlist configuration
|
||||
// Local tracks position is configurable per-playlist
|
||||
var playlistConfig = _spotifySettings.GetPlaylistByJellyfinId(playlistId);
|
||||
var localTracksPosition = playlistConfig?.LocalTracksPosition ?? LocalTracksPosition.First;
|
||||
|
||||
var finalTracks = new List<Song>();
|
||||
if (localTracksPosition == LocalTracksPosition.First)
|
||||
{
|
||||
// Local tracks first, external tracks at the end
|
||||
finalTracks.AddRange(existingTracks);
|
||||
finalTracks.AddRange(matchedBySpotifyId.Values);
|
||||
}
|
||||
else
|
||||
{
|
||||
// External tracks first, local tracks at the end
|
||||
finalTracks.AddRange(matchedBySpotifyId.Values);
|
||||
finalTracks.AddRange(existingTracks);
|
||||
}
|
||||
|
||||
await _cache.SetAsync(cacheKey, finalTracks, TimeSpan.FromHours(1));
|
||||
|
||||
// Also save to file cache for persistence across restarts
|
||||
await SaveMatchedTracksToFile(spotifyPlaylistName, finalTracks);
|
||||
|
||||
_logger.LogInformation("Final playlist: {Total} tracks ({Existing} local + {Matched} external, LocalTracksPosition: {Position})",
|
||||
finalTracks.Count,
|
||||
existingTracks.Count,
|
||||
matchedBySpotifyId.Count,
|
||||
localTracksPosition);
|
||||
|
||||
return _responseBuilder.CreateItemsResponse(finalTracks);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies an external track to the kept folder when favorited.
|
||||
/// </summary>
|
||||
private async Task CopyExternalTrackToKeptAsync(string itemId, string provider, string externalId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Check if already favorited (persistent tracking)
|
||||
if (await IsTrackFavoritedAsync(itemId))
|
||||
{
|
||||
_logger.LogInformation("Track already favorited (persistent): {ItemId}", itemId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the song metadata first to build paths
|
||||
var song = await _metadataService.GetSongAsync(provider, externalId);
|
||||
if (song == null)
|
||||
{
|
||||
_logger.LogWarning("Could not find song metadata for {ItemId}", itemId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build kept folder path: /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));
|
||||
|
||||
// Check if track already exists in kept folder
|
||||
if (Directory.Exists(keptAlbumPath))
|
||||
{
|
||||
var sanitizedTitle = PathHelper.SanitizeFileName(song.Title);
|
||||
var existingFiles = Directory.GetFiles(keptAlbumPath, $"*{sanitizedTitle}*");
|
||||
if (existingFiles.Length > 0)
|
||||
{
|
||||
_logger.LogInformation("Track already exists in kept folder: {Path}", existingFiles[0]);
|
||||
// Mark as favorited even if we didn't download it
|
||||
await MarkTrackAsFavoritedAsync(itemId, song);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Look for the track in cache folder first
|
||||
var cacheBasePath = "/tmp/allstarr-cache";
|
||||
var cacheArtistPath = Path.Combine(cacheBasePath, PathHelper.SanitizeFileName(song.Artist));
|
||||
var cacheAlbumPath = Path.Combine(cacheArtistPath, PathHelper.SanitizeFileName(song.Album));
|
||||
|
||||
string? sourceFilePath = null;
|
||||
|
||||
if (Directory.Exists(cacheAlbumPath))
|
||||
{
|
||||
var sanitizedTitle = PathHelper.SanitizeFileName(song.Title);
|
||||
var cacheFiles = Directory.GetFiles(cacheAlbumPath, $"*{sanitizedTitle}*");
|
||||
if (cacheFiles.Length > 0)
|
||||
{
|
||||
sourceFilePath = cacheFiles[0];
|
||||
_logger.LogInformation("Found track in cache folder: {Path}", sourceFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
// If not in cache, download it first
|
||||
if (sourceFilePath == null)
|
||||
{
|
||||
_logger.LogInformation("Track not in cache, downloading: {ItemId}", itemId);
|
||||
try
|
||||
{
|
||||
sourceFilePath = await _downloadService.DownloadSongAsync(provider, externalId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to download track {ItemId}", itemId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Create the kept folder structure
|
||||
Directory.CreateDirectory(keptAlbumPath);
|
||||
|
||||
// Copy file to kept folder
|
||||
var fileName = Path.GetFileName(sourceFilePath);
|
||||
var keptFilePath = Path.Combine(keptAlbumPath, fileName);
|
||||
|
||||
// Double-check in case of race condition (multiple favorite clicks)
|
||||
if (System.IO.File.Exists(keptFilePath))
|
||||
{
|
||||
_logger.LogInformation("Track already exists in kept folder (race condition): {Path}", keptFilePath);
|
||||
await MarkTrackAsFavoritedAsync(itemId, song);
|
||||
return;
|
||||
}
|
||||
|
||||
System.IO.File.Copy(sourceFilePath, keptFilePath, overwrite: false);
|
||||
_logger.LogInformation("✓ Copied track to kept folder: {Path}", keptFilePath);
|
||||
|
||||
// Also copy cover art if it exists
|
||||
var sourceCoverPath = Path.Combine(Path.GetDirectoryName(sourceFilePath)!, "cover.jpg");
|
||||
if (System.IO.File.Exists(sourceCoverPath))
|
||||
{
|
||||
var keptCoverPath = Path.Combine(keptAlbumPath, "cover.jpg");
|
||||
if (!System.IO.File.Exists(keptCoverPath))
|
||||
{
|
||||
System.IO.File.Copy(sourceCoverPath, keptCoverPath, overwrite: false);
|
||||
_logger.LogDebug("Copied cover art to kept folder");
|
||||
}
|
||||
}
|
||||
|
||||
// Mark as favorited in persistent storage
|
||||
await MarkTrackAsFavoritedAsync(itemId, song);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error copying external track {ItemId} to kept folder", itemId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes an external track from the kept folder when unfavorited.
|
||||
/// </summary>
|
||||
private async Task RemoveExternalTrackFromKeptAsync(string itemId, string provider, string externalId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Mark for deletion instead of immediate deletion
|
||||
await MarkTrackForDeletionAsync(itemId);
|
||||
_logger.LogInformation("✓ Marked track for deletion: {ItemId}", itemId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error marking external track {ItemId} for deletion", itemId);
|
||||
}
|
||||
}
|
||||
|
||||
#region Persistent Favorites Tracking
|
||||
|
||||
private readonly string _favoritesFilePath = "/app/cache/favorites.json";
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a track is already favorited (persistent across restarts).
|
||||
/// </summary>
|
||||
private async Task<bool> IsTrackFavoritedAsync(string itemId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!System.IO.File.Exists(_favoritesFilePath))
|
||||
return false;
|
||||
|
||||
var json = await System.IO.File.ReadAllTextAsync(_favoritesFilePath);
|
||||
var favorites = JsonSerializer.Deserialize<Dictionary<string, FavoriteTrackInfo>>(json) ?? new();
|
||||
|
||||
return favorites.ContainsKey(itemId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to check favorite status for {ItemId}", itemId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a track as favorited in persistent storage.
|
||||
/// </summary>
|
||||
private async Task MarkTrackAsFavoritedAsync(string itemId, Song song)
|
||||
{
|
||||
try
|
||||
{
|
||||
var favorites = new Dictionary<string, FavoriteTrackInfo>();
|
||||
|
||||
if (System.IO.File.Exists(_favoritesFilePath))
|
||||
{
|
||||
var json = await System.IO.File.ReadAllTextAsync(_favoritesFilePath);
|
||||
favorites = JsonSerializer.Deserialize<Dictionary<string, FavoriteTrackInfo>>(json) ?? new();
|
||||
}
|
||||
|
||||
favorites[itemId] = new FavoriteTrackInfo
|
||||
{
|
||||
ItemId = itemId,
|
||||
Title = song.Title,
|
||||
Artist = song.Artist,
|
||||
Album = song.Album,
|
||||
FavoritedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
// Ensure cache directory exists
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(_favoritesFilePath)!);
|
||||
|
||||
var updatedJson = JsonSerializer.Serialize(favorites, new JsonSerializerOptions { WriteIndented = true });
|
||||
await System.IO.File.WriteAllTextAsync(_favoritesFilePath, updatedJson);
|
||||
|
||||
_logger.LogDebug("Marked track as favorited: {ItemId}", itemId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to mark track as favorited: {ItemId}", itemId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a track from persistent favorites storage.
|
||||
/// </summary>
|
||||
private async Task UnmarkTrackAsFavoritedAsync(string itemId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!System.IO.File.Exists(_favoritesFilePath))
|
||||
return;
|
||||
|
||||
var json = await System.IO.File.ReadAllTextAsync(_favoritesFilePath);
|
||||
var favorites = JsonSerializer.Deserialize<Dictionary<string, FavoriteTrackInfo>>(json) ?? new();
|
||||
|
||||
if (favorites.Remove(itemId))
|
||||
{
|
||||
var updatedJson = JsonSerializer.Serialize(favorites, new JsonSerializerOptions { WriteIndented = true });
|
||||
await System.IO.File.WriteAllTextAsync(_favoritesFilePath, updatedJson);
|
||||
_logger.LogDebug("Removed track from favorites: {ItemId}", itemId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to remove track from favorites: {ItemId}", itemId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a track for deletion (delayed deletion for safety).
|
||||
/// </summary>
|
||||
private async Task MarkTrackForDeletionAsync(string itemId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var deletionFilePath = "/app/cache/pending_deletions.json";
|
||||
var pendingDeletions = new Dictionary<string, DateTime>();
|
||||
|
||||
if (System.IO.File.Exists(deletionFilePath))
|
||||
{
|
||||
var json = await System.IO.File.ReadAllTextAsync(deletionFilePath);
|
||||
pendingDeletions = JsonSerializer.Deserialize<Dictionary<string, DateTime>>(json) ?? new();
|
||||
}
|
||||
|
||||
// Mark for deletion 24 hours from now
|
||||
pendingDeletions[itemId] = DateTime.UtcNow.AddHours(24);
|
||||
|
||||
// Ensure cache directory exists
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(deletionFilePath)!);
|
||||
|
||||
var updatedJson = JsonSerializer.Serialize(pendingDeletions, new JsonSerializerOptions { WriteIndented = true });
|
||||
await System.IO.File.WriteAllTextAsync(deletionFilePath, updatedJson);
|
||||
|
||||
// Also remove from favorites immediately
|
||||
await UnmarkTrackAsFavoritedAsync(itemId);
|
||||
|
||||
_logger.LogDebug("Marked track for deletion in 24 hours: {ItemId}", itemId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to mark track for deletion: {ItemId}", itemId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a favorited track for persistent storage.
|
||||
/// </summary>
|
||||
private class FavoriteTrackInfo
|
||||
{
|
||||
public string ItemId { get; set; } = "";
|
||||
public string Title { get; set; } = "";
|
||||
public string Artist { get; set; } = "";
|
||||
public string Album { get; set; } = "";
|
||||
public DateTime FavoritedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes pending deletions (called by cleanup service).
|
||||
/// </summary>
|
||||
public async Task ProcessPendingDeletionsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var deletionFilePath = "/app/cache/pending_deletions.json";
|
||||
if (!System.IO.File.Exists(deletionFilePath))
|
||||
return;
|
||||
|
||||
var json = await System.IO.File.ReadAllTextAsync(deletionFilePath);
|
||||
var pendingDeletions = JsonSerializer.Deserialize<Dictionary<string, DateTime>>(json) ?? new();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var toDelete = pendingDeletions.Where(kvp => kvp.Value <= now).ToList();
|
||||
var remaining = pendingDeletions.Where(kvp => kvp.Value > now).ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
|
||||
|
||||
foreach (var (itemId, _) in toDelete)
|
||||
{
|
||||
await ActuallyDeleteTrackAsync(itemId);
|
||||
}
|
||||
|
||||
if (toDelete.Count > 0)
|
||||
{
|
||||
// Update pending deletions file
|
||||
var updatedJson = JsonSerializer.Serialize(remaining, new JsonSerializerOptions { WriteIndented = true });
|
||||
await System.IO.File.WriteAllTextAsync(deletionFilePath, updatedJson);
|
||||
|
||||
_logger.LogInformation("Processed {Count} pending deletions", toDelete.Count);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error processing pending deletions");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Actually deletes a track from the kept folder.
|
||||
/// </summary>
|
||||
private async Task ActuallyDeleteTrackAsync(string itemId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
|
||||
if (!isExternal) return;
|
||||
|
||||
var song = await _metadataService.GetSongAsync(provider!, externalId!);
|
||||
if (song == null) return;
|
||||
|
||||
var keptBasePath = "/app/kept";
|
||||
var keptArtistPath = Path.Combine(keptBasePath, PathHelper.SanitizeFileName(song.Artist));
|
||||
var keptAlbumPath = Path.Combine(keptArtistPath, PathHelper.SanitizeFileName(song.Album));
|
||||
|
||||
if (!Directory.Exists(keptAlbumPath)) return;
|
||||
|
||||
var sanitizedTitle = PathHelper.SanitizeFileName(song.Title);
|
||||
var trackFiles = Directory.GetFiles(keptAlbumPath, $"*{sanitizedTitle}*");
|
||||
|
||||
foreach (var trackFile in trackFiles)
|
||||
{
|
||||
System.IO.File.Delete(trackFile);
|
||||
_logger.LogInformation("✓ Deleted track from kept folder: {Path}", trackFile);
|
||||
}
|
||||
|
||||
// Clean up empty directories
|
||||
if (Directory.GetFiles(keptAlbumPath).Length == 0 && Directory.GetDirectories(keptAlbumPath).Length == 0)
|
||||
{
|
||||
Directory.Delete(keptAlbumPath);
|
||||
|
||||
if (Directory.Exists(keptArtistPath) &&
|
||||
Directory.GetFiles(keptArtistPath).Length == 0 &&
|
||||
Directory.GetDirectories(keptArtistPath).Length == 0)
|
||||
{
|
||||
Directory.Delete(keptArtistPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to delete track {ItemId}", itemId);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
/// Loads matched/combined tracks from file cache as fallback when Redis is empty.
|
||||
/// </summary>
|
||||
private async Task<List<Song>?> LoadMatchedTracksFromFile(string playlistName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
|
||||
var filePath = Path.Combine("/app/cache/spotify", $"{safeName}_matched.json");
|
||||
|
||||
if (!System.IO.File.Exists(filePath))
|
||||
{
|
||||
_logger.LogDebug("No matched tracks file cache found for {Playlist} at {Path}", playlistName, filePath);
|
||||
return null;
|
||||
}
|
||||
|
||||
var fileAge = DateTime.UtcNow - System.IO.File.GetLastWriteTimeUtc(filePath);
|
||||
|
||||
// Check if cache is too old (more than 24 hours)
|
||||
if (fileAge.TotalHours > 24)
|
||||
{
|
||||
_logger.LogInformation("Matched tracks file cache for {Playlist} is too old ({Age:F1}h), will rebuild",
|
||||
playlistName, fileAge.TotalHours);
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Matched tracks file cache for {Playlist} age: {Age:F1}h", playlistName, fileAge.TotalHours);
|
||||
|
||||
var json = await System.IO.File.ReadAllTextAsync(filePath);
|
||||
var tracks = JsonSerializer.Deserialize<List<Song>>(json);
|
||||
|
||||
_logger.LogInformation("Loaded {Count} matched tracks from file cache for {Playlist} (age: {Age:F1}h)",
|
||||
tracks?.Count ?? 0, playlistName, fileAge.TotalHours);
|
||||
|
||||
return tracks;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load matched tracks from file for {Playlist}", playlistName);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves matched/combined tracks to file cache for persistence across restarts.
|
||||
/// </summary>
|
||||
private async Task SaveMatchedTracksToFile(string playlistName, List<Song> tracks)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cacheDir = "/app/cache/spotify";
|
||||
Directory.CreateDirectory(cacheDir);
|
||||
|
||||
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
|
||||
var filePath = Path.Combine(cacheDir, $"{safeName}_matched.json");
|
||||
|
||||
var json = JsonSerializer.Serialize(tracks, new JsonSerializerOptions { WriteIndented = true });
|
||||
await System.IO.File.WriteAllTextAsync(filePath, json);
|
||||
|
||||
_logger.LogInformation("Saved {Count} matched tracks to file cache for {Playlist}",
|
||||
tracks.Count, playlistName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save matched tracks to file for {Playlist}", playlistName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves playlist items (raw Jellyfin JSON) to file cache for persistence across restarts.
|
||||
/// </summary>
|
||||
private async Task SavePlaylistItemsToFile(string playlistName, List<Dictionary<string, object?>> items)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cacheDir = "/app/cache/spotify";
|
||||
Directory.CreateDirectory(cacheDir);
|
||||
|
||||
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
|
||||
var filePath = Path.Combine(cacheDir, $"{safeName}_items.json");
|
||||
|
||||
var json = JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true });
|
||||
await System.IO.File.WriteAllTextAsync(filePath, json);
|
||||
|
||||
_logger.LogInformation("💾 Saved {Count} playlist items to file cache for {Playlist}",
|
||||
items.Count, playlistName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save playlist items to file for {Playlist}", playlistName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads playlist items (raw Jellyfin JSON) from file cache.
|
||||
/// </summary>
|
||||
private async Task<List<Dictionary<string, object?>>?> LoadPlaylistItemsFromFile(string playlistName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
|
||||
var filePath = Path.Combine("/app/cache/spotify", $"{safeName}_items.json");
|
||||
|
||||
if (!System.IO.File.Exists(filePath))
|
||||
{
|
||||
_logger.LogDebug("No playlist items file cache found for {Playlist} at {Path}", playlistName, filePath);
|
||||
return null;
|
||||
}
|
||||
|
||||
var fileAge = DateTime.UtcNow - System.IO.File.GetLastWriteTimeUtc(filePath);
|
||||
|
||||
// Check if cache is too old (more than 24 hours)
|
||||
if (fileAge.TotalHours > 24)
|
||||
{
|
||||
_logger.LogInformation("Playlist items file cache for {Playlist} is too old ({Age:F1}h), will rebuild",
|
||||
playlistName, fileAge.TotalHours);
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Playlist items file cache for {Playlist} age: {Age:F1}h", playlistName, fileAge.TotalHours);
|
||||
|
||||
var json = await System.IO.File.ReadAllTextAsync(filePath);
|
||||
var items = JsonSerializer.Deserialize<List<Dictionary<string, object?>>>(json);
|
||||
|
||||
_logger.LogInformation("💿 Loaded {Count} playlist items from file cache for {Playlist} (age: {Age:F1}h)",
|
||||
items?.Count ?? 0, playlistName, fileAge.TotalHours);
|
||||
|
||||
return items;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load playlist items from file for {Playlist}", playlistName);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
#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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts device information from Authorization header.
|
||||
/// </summary>
|
||||
private (string? deviceId, string? client, string? device, string? version) ExtractDeviceInfo(IHeaderDictionary headers)
|
||||
{
|
||||
string? deviceId = null;
|
||||
string? client = null;
|
||||
string? device = null;
|
||||
string? version = null;
|
||||
|
||||
// Check X-Emby-Authorization FIRST (most Jellyfin clients use this)
|
||||
// Then fall back to Authorization header
|
||||
string? authStr = null;
|
||||
if (headers.TryGetValue("X-Emby-Authorization", out var embyAuthHeader))
|
||||
{
|
||||
authStr = embyAuthHeader.ToString();
|
||||
}
|
||||
else if (headers.TryGetValue("Authorization", out var authHeader))
|
||||
{
|
||||
authStr = authHeader.ToString();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(authStr))
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (deviceId, client, device, version);
|
||||
}
|
||||
}
|
||||
// force rebuild Sun Jan 25 13:22:47 EST 2026
|
||||
|
||||
28
allstarr/Filters/AdminPortFilter.cs
Normal file
28
allstarr/Filters/AdminPortFilter.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
namespace allstarr.Filters;
|
||||
|
||||
/// <summary>
|
||||
/// Filter that restricts access to admin endpoints to only the admin port (5275).
|
||||
/// This prevents the admin API from being accessed through the main proxy port.
|
||||
/// </summary>
|
||||
public class AdminPortFilter : IActionFilter
|
||||
{
|
||||
private const int AdminPort = 5275;
|
||||
|
||||
public void OnActionExecuting(ActionExecutingContext context)
|
||||
{
|
||||
var requestPort = context.HttpContext.Connection.LocalPort;
|
||||
|
||||
if (requestPort != AdminPort)
|
||||
{
|
||||
context.Result = new NotFoundResult();
|
||||
}
|
||||
}
|
||||
|
||||
public void OnActionExecuted(ActionExecutedContext context)
|
||||
{
|
||||
// No action needed after execution
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
76
allstarr/Middleware/AdminStaticFilesMiddleware.cs
Normal file
76
allstarr/Middleware/AdminStaticFilesMiddleware.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using Microsoft.AspNetCore.StaticFiles;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
|
||||
namespace allstarr.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Middleware that only serves static files on the admin port (5275).
|
||||
/// This keeps the admin UI isolated from the main proxy port.
|
||||
/// </summary>
|
||||
public class AdminStaticFilesMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly IWebHostEnvironment _env;
|
||||
private const int AdminPort = 5275;
|
||||
|
||||
public AdminStaticFilesMiddleware(
|
||||
RequestDelegate next,
|
||||
IWebHostEnvironment env)
|
||||
{
|
||||
_next = next;
|
||||
_env = env;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
var port = context.Connection.LocalPort;
|
||||
|
||||
if (port == AdminPort)
|
||||
{
|
||||
var path = context.Request.Path.Value ?? "/";
|
||||
|
||||
// Serve index.html for root path
|
||||
if (path == "/" || path == "/index.html")
|
||||
{
|
||||
var indexPath = Path.Combine(_env.WebRootPath, "index.html");
|
||||
if (File.Exists(indexPath))
|
||||
{
|
||||
context.Response.ContentType = "text/html";
|
||||
await context.Response.SendFileAsync(indexPath);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to serve static file from wwwroot
|
||||
var filePath = Path.Combine(_env.WebRootPath, path.TrimStart('/'));
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
var contentType = GetContentType(filePath);
|
||||
context.Response.ContentType = contentType;
|
||||
await context.Response.SendFileAsync(filePath);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Not admin port or file not found - continue pipeline
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
private static string GetContentType(string filePath)
|
||||
{
|
||||
var ext = Path.GetExtension(filePath).ToLowerInvariant();
|
||||
return ext switch
|
||||
{
|
||||
".html" => "text/html",
|
||||
".css" => "text/css",
|
||||
".js" => "application/javascript",
|
||||
".json" => "application/json",
|
||||
".png" => "image/png",
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".gif" => "image/gif",
|
||||
".svg" => "image/svg+xml",
|
||||
".ico" => "image/x-icon",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
}
|
||||
}
|
||||
280
allstarr/Middleware/WebSocketProxyMiddleware.cs
Normal file
280
allstarr/Middleware/WebSocketProxyMiddleware.cs
Normal file
@@ -0,0 +1,280 @@
|
||||
using System.Net.WebSockets;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Services.Jellyfin;
|
||||
|
||||
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;
|
||||
private readonly JellyfinSessionManager _sessionManager;
|
||||
|
||||
public WebSocketProxyMiddleware(
|
||||
RequestDelegate next,
|
||||
IOptions<JellyfinSettings> settings,
|
||||
ILogger<WebSocketProxyMiddleware> logger,
|
||||
JellyfinSessionManager sessionManager)
|
||||
{
|
||||
_next = next;
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
_sessionManager = sessionManager;
|
||||
|
||||
_logger.LogDebug("🔧 WEBSOCKET: WebSocketProxyMiddleware initialized - Jellyfin URL: {Url}", _settings.Url);
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
// Log ALL requests for debugging
|
||||
var path = context.Request.Path.Value ?? "";
|
||||
var isWebSocket = context.WebSockets.IsWebSocketRequest;
|
||||
|
||||
// Log any request that might be WebSocket-related
|
||||
if (path.Contains("socket", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.Contains("ws", StringComparison.OrdinalIgnoreCase) ||
|
||||
isWebSocket ||
|
||||
context.Request.Headers.ContainsKey("Upgrade"))
|
||||
{
|
||||
_logger.LogDebug("🔍 WEBSOCKET: Potential WebSocket request: Path={Path}, IsWs={IsWs}, Method={Method}, Upgrade={Upgrade}, Connection={Connection}",
|
||||
path,
|
||||
isWebSocket,
|
||||
context.Request.Method,
|
||||
context.Request.Headers["Upgrade"].ToString(),
|
||||
context.Request.Headers["Connection"].ToString());
|
||||
}
|
||||
|
||||
// Check if this is a WebSocket request to /socket
|
||||
if (context.Request.Path.StartsWithSegments("/socket", StringComparison.OrdinalIgnoreCase) &&
|
||||
context.WebSockets.IsWebSocketRequest)
|
||||
{
|
||||
_logger.LogInformation("🔌 WEBSOCKET: 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;
|
||||
string? deviceId = null;
|
||||
|
||||
try
|
||||
{
|
||||
// Extract device ID from query string or headers for session tracking
|
||||
deviceId = context.Request.Query["deviceId"].ToString();
|
||||
if (string.IsNullOrEmpty(deviceId))
|
||||
{
|
||||
// Try to extract from X-Emby-Authorization header
|
||||
if (context.Request.Headers.TryGetValue("X-Emby-Authorization", out var authHeader))
|
||||
{
|
||||
var authValue = authHeader.ToString();
|
||||
var deviceIdMatch = System.Text.RegularExpressions.Regex.Match(authValue, @"DeviceId=""([^""]+)""");
|
||||
if (deviceIdMatch.Success)
|
||||
{
|
||||
deviceId = deviceIdMatch.Groups[1].Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(deviceId))
|
||||
{
|
||||
_logger.LogDebug("🔍 WEBSOCKET: Client WebSocket for device {DeviceId}", deviceId);
|
||||
}
|
||||
|
||||
// Accept the WebSocket connection from the client
|
||||
clientWebSocket = await context.WebSockets.AcceptWebSocketAsync();
|
||||
_logger.LogDebug("✓ WEBSOCKET: 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.LogDebug("🔗 WEBSOCKET: Connecting to Jellyfin WebSocket: {Url}", jellyfinWsUrl);
|
||||
|
||||
// Connect to Jellyfin WebSocket
|
||||
serverWebSocket = new ClientWebSocket();
|
||||
|
||||
// Forward authentication headers - check X-Emby-Authorization FIRST
|
||||
// Most Jellyfin clients use X-Emby-Authorization, not Authorization
|
||||
if (context.Request.Headers.TryGetValue("X-Emby-Authorization", out var embyAuthHeader))
|
||||
{
|
||||
serverWebSocket.Options.SetRequestHeader("X-Emby-Authorization", embyAuthHeader.ToString());
|
||||
_logger.LogDebug("🔑 WEBSOCKET: Forwarded X-Emby-Authorization header");
|
||||
}
|
||||
else if (context.Request.Headers.TryGetValue("Authorization", out var authHeader2))
|
||||
{
|
||||
var authValue = authHeader2.ToString();
|
||||
// If it's a MediaBrowser auth header, use X-Emby-Authorization
|
||||
if (authValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
serverWebSocket.Options.SetRequestHeader("X-Emby-Authorization", authValue);
|
||||
_logger.LogDebug("🔑 WEBSOCKET: Converted Authorization to X-Emby-Authorization header");
|
||||
}
|
||||
else
|
||||
{
|
||||
serverWebSocket.Options.SetRequestHeader("Authorization", authValue);
|
||||
_logger.LogDebug("🔑 WEBSOCKET: Forwarded Authorization header");
|
||||
}
|
||||
}
|
||||
|
||||
// Set user agent
|
||||
serverWebSocket.Options.SetRequestHeader("User-Agent", "Allstarr/1.0");
|
||||
|
||||
await serverWebSocket.ConnectAsync(new Uri(jellyfinWsUrl), context.RequestAborted);
|
||||
_logger.LogInformation("✓ WEBSOCKET: 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.LogDebug("🔌 WEBSOCKET: WebSocket proxy connection closed");
|
||||
}
|
||||
catch (WebSocketException wsEx)
|
||||
{
|
||||
_logger.LogWarning(wsEx, "⚠️ WEBSOCKET: WebSocket error: {Message}", wsEx.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "❌ WEBSOCKET: 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();
|
||||
|
||||
// CRITICAL: Notify session manager that client disconnected
|
||||
if (!string.IsNullOrEmpty(deviceId))
|
||||
{
|
||||
_logger.LogInformation("🧹 WEBSOCKET: Client disconnected, removing session for device {DeviceId}", deviceId);
|
||||
await _sessionManager.RemoveSessionAsync(deviceId);
|
||||
}
|
||||
|
||||
_logger.LogDebug("🧹 WEBSOCKET: 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.LogDebug("🔌 WEBSOCKET {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 Server→Client direction to see remote control commands
|
||||
if (direction == "Server→Client")
|
||||
{
|
||||
var messageText = System.Text.Encoding.UTF8.GetString(messageBytes);
|
||||
_logger.LogInformation("📥 WEBSOCKET {Direction}: {Preview}",
|
||||
direction,
|
||||
messageText.Length > 500 ? messageText[..500] + "..." : messageText);
|
||||
}
|
||||
else 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.LogDebug("⚠️ WEBSOCKET {Direction}: Operation cancelled", direction);
|
||||
}
|
||||
catch (WebSocketException wsEx) when (wsEx.WebSocketErrorCode == WebSocketError.ConnectionClosedPrematurely)
|
||||
{
|
||||
_logger.LogDebug("⚠️ WEBSOCKET {Direction}: Connection closed prematurely", direction);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "⚠️ WEBSOCKET {Direction}: Error proxying messages", direction);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,11 @@ public class Song
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Artist { get; set; } = string.Empty;
|
||||
public string? ArtistId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// All artists for this track (main + featured). For display in Jellyfin clients.
|
||||
/// </summary>
|
||||
public List<string> Artists { get; set; } = new();
|
||||
public string Album { get; set; } = string.Empty;
|
||||
public string? AlbumId { get; set; }
|
||||
public int? Duration { get; set; } // In seconds
|
||||
@@ -94,4 +99,10 @@ public class Song
|
||||
/// 0 = Naturally clean, 1 = Explicit, 2 = Not applicable, 3 = Clean/edited version, 6/7 = Unknown
|
||||
/// </summary>
|
||||
public int? ExplicitContentLyrics { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Raw Jellyfin metadata (MediaSources, etc.) for local tracks
|
||||
/// Preserved to maintain bitrate and other technical details
|
||||
/// </summary>
|
||||
public Dictionary<string, object?>? JellyfinMetadata { get; set; }
|
||||
}
|
||||
|
||||
21
allstarr/Models/Settings/MusicBrainzSettings.cs
Normal file
21
allstarr/Models/Settings/MusicBrainzSettings.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
namespace allstarr.Models.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Settings for MusicBrainz API integration.
|
||||
/// </summary>
|
||||
public class MusicBrainzSettings
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
public string? Username { get; set; }
|
||||
public string? Password { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Base URL for MusicBrainz API.
|
||||
/// </summary>
|
||||
public string BaseUrl { get; set; } = "https://musicbrainz.org/ws/2";
|
||||
|
||||
/// <summary>
|
||||
/// Rate limit: 1 request per second for unauthenticated, 1 per second for authenticated.
|
||||
/// </summary>
|
||||
public int RateLimitMs { get; set; } = 1000;
|
||||
}
|
||||
72
allstarr/Models/Settings/SpotifyApiSettings.cs
Normal file
72
allstarr/Models/Settings/SpotifyApiSettings.cs
Normal file
@@ -0,0 +1,72 @@
|
||||
namespace allstarr.Models.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for direct Spotify API access.
|
||||
/// This enables fetching playlist data directly from Spotify rather than relying on the Jellyfin plugin.
|
||||
///
|
||||
/// Benefits over Jellyfin plugin approach:
|
||||
/// - Track ordering is preserved (critical for playlists like Release Radar)
|
||||
/// - ISRC codes available for exact matching
|
||||
/// - Real-time data without waiting for plugin sync
|
||||
/// - Full track metadata (duration, release date, etc.)
|
||||
/// </summary>
|
||||
public class SpotifyApiSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Enable direct Spotify API integration.
|
||||
/// When enabled, playlists will be fetched directly from Spotify instead of the Jellyfin plugin.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Spotify Client ID from https://developer.spotify.com/dashboard
|
||||
/// Used for OAuth token refresh and API access.
|
||||
/// </summary>
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Spotify Client Secret from https://developer.spotify.com/dashboard
|
||||
/// Optional - only needed for certain OAuth flows.
|
||||
/// </summary>
|
||||
public string ClientSecret { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Spotify session cookie (sp_dc).
|
||||
/// Required for accessing editorial/personalized playlists like Release Radar and Discover Weekly.
|
||||
/// These playlists are not available via the official API.
|
||||
///
|
||||
/// To get this cookie:
|
||||
/// 1. Log into open.spotify.com in your browser
|
||||
/// 2. Open DevTools (F12) > Application > Cookies > https://open.spotify.com
|
||||
/// 3. Copy the value of the "sp_dc" cookie
|
||||
///
|
||||
/// Note: This cookie expires periodically and will need to be refreshed.
|
||||
/// </summary>
|
||||
public string SessionCookie { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Cache duration in minutes for playlist data.
|
||||
/// Playlists like Release Radar only update weekly, so caching is beneficial.
|
||||
/// Default: 60 minutes
|
||||
/// </summary>
|
||||
public int CacheDurationMinutes { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Rate limit delay between Spotify API requests in milliseconds.
|
||||
/// Default: 100ms (Spotify allows ~100 requests per minute)
|
||||
/// </summary>
|
||||
public int RateLimitDelayMs { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to prefer ISRC matching over fuzzy title/artist matching when ISRC is available.
|
||||
/// ISRC provides exact track identification across services.
|
||||
/// Default: true
|
||||
/// </summary>
|
||||
public bool PreferIsrcMatching { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// ISO date string of when the session cookie was last set/updated.
|
||||
/// Used to track cookie age and warn when it's approaching expiration (~1 year).
|
||||
/// </summary>
|
||||
public string? SessionCookieSetDate { get; set; }
|
||||
}
|
||||
144
allstarr/Models/Settings/SpotifyImportSettings.cs
Normal file
144
allstarr/Models/Settings/SpotifyImportSettings.cs
Normal file
@@ -0,0 +1,144 @@
|
||||
namespace allstarr.Models.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Where to position local tracks relative to external matched tracks in Spotify playlists.
|
||||
/// </summary>
|
||||
public enum LocalTracksPosition
|
||||
{
|
||||
/// <summary>
|
||||
/// Local tracks appear first, external tracks appended at the end (default)
|
||||
/// </summary>
|
||||
First,
|
||||
|
||||
/// <summary>
|
||||
/// External tracks appear first, local tracks appended at the end
|
||||
/// </summary>
|
||||
Last
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for a single Spotify Import playlist.
|
||||
/// </summary>
|
||||
public class SpotifyPlaylistConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Playlist name as it appears in Jellyfin/Spotify Import plugin
|
||||
/// Example: "Discover Weekly", "Release Radar"
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Spotify playlist ID (get from Spotify playlist URL)
|
||||
/// Example: "37i9dQZF1DXcBWIGoYBM5M" (from open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M)
|
||||
/// Required for personalized playlists like Discover Weekly, Release Radar, etc.
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Jellyfin playlist ID (internal Jellyfin GUID)
|
||||
/// Example: "4383a46d8bcac3be2ef9385053ea18df"
|
||||
/// This is the ID Jellyfin uses when requesting playlist tracks
|
||||
/// </summary>
|
||||
public string JellyfinId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Where to position local tracks: "first" or "last"
|
||||
/// </summary>
|
||||
public LocalTracksPosition LocalTracksPosition { get; set; } = LocalTracksPosition.First;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// How often to run track matching in hours.
|
||||
/// Spotify playlists like Discover Weekly update once per week, Release Radar updates weekly.
|
||||
/// Most playlists don't change frequently, so running every 24 hours is reasonable.
|
||||
/// Set to 0 to only run once on startup (manual trigger via admin UI still works).
|
||||
/// Default: 24 hours
|
||||
/// </summary>
|
||||
public int MatchingIntervalHours { get; set; } = 24;
|
||||
|
||||
/// <summary>
|
||||
/// Combined playlist configuration as JSON array.
|
||||
/// Format: [["Name","Id","first|last"],...]
|
||||
/// Example: [["Discover Weekly","abc123","first"],["Release Radar","def456","last"]]
|
||||
/// </summary>
|
||||
public List<SpotifyPlaylistConfig> Playlists { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Legacy: Comma-separated list of Jellyfin playlist IDs to inject
|
||||
/// Deprecated: Use Playlists instead
|
||||
/// </summary>
|
||||
[Obsolete("Use Playlists instead")]
|
||||
public List<string> PlaylistIds { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Legacy: Comma-separated list of playlist names
|
||||
/// Deprecated: Use Playlists instead
|
||||
/// </summary>
|
||||
[Obsolete("Use Playlists instead")]
|
||||
public List<string> PlaylistNames { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Legacy: Comma-separated list of local track positions ("first" or "last")
|
||||
/// Deprecated: Use Playlists instead
|
||||
/// Example: "first,last,first,first" (one per playlist)
|
||||
/// </summary>
|
||||
[Obsolete("Use Playlists instead")]
|
||||
public List<string> PlaylistLocalTracksPositions { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the playlist configuration by Jellyfin playlist ID.
|
||||
/// </summary>
|
||||
public SpotifyPlaylistConfig? GetPlaylistById(string playlistId) =>
|
||||
Playlists.FirstOrDefault(p => p.Id.Equals(playlistId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>
|
||||
/// Gets the playlist configuration by Jellyfin playlist ID.
|
||||
/// </summary>
|
||||
public SpotifyPlaylistConfig? GetPlaylistByJellyfinId(string jellyfinPlaylistId) =>
|
||||
Playlists.FirstOrDefault(p => p.JellyfinId.Equals(jellyfinPlaylistId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>
|
||||
/// Gets the playlist configuration by name.
|
||||
/// </summary>
|
||||
public SpotifyPlaylistConfig? GetPlaylistByName(string name) =>
|
||||
Playlists.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a Jellyfin playlist ID is configured for Spotify import.
|
||||
/// </summary>
|
||||
public bool IsSpotifyPlaylist(string jellyfinPlaylistId) =>
|
||||
Playlists.Any(p => p.JellyfinId.Equals(jellyfinPlaylistId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
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);
|
||||
}
|
||||
231
allstarr/Models/Spotify/SpotifyPlaylistTrack.cs
Normal file
231
allstarr/Models/Spotify/SpotifyPlaylistTrack.cs
Normal file
@@ -0,0 +1,231 @@
|
||||
using allstarr.Models.Domain;
|
||||
|
||||
namespace allstarr.Models.Spotify;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a track from a Spotify playlist with full metadata including position.
|
||||
/// This model preserves track ordering which is critical for playlists like Release Radar.
|
||||
/// </summary>
|
||||
public class SpotifyPlaylistTrack
|
||||
{
|
||||
/// <summary>
|
||||
/// Spotify track ID (e.g., "3a8mo25v74BMUOJ1IDUEBL")
|
||||
/// </summary>
|
||||
public string SpotifyId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Track's position in the playlist (0-based index).
|
||||
/// This is critical for maintaining correct playlist order.
|
||||
/// </summary>
|
||||
public int Position { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Track title
|
||||
/// </summary>
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Album name
|
||||
/// </summary>
|
||||
public string Album { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Album Spotify ID
|
||||
/// </summary>
|
||||
public string AlbumId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// List of artist names
|
||||
/// </summary>
|
||||
public List<string> Artists { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// List of artist Spotify IDs
|
||||
/// </summary>
|
||||
public List<string> ArtistIds { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// ISRC (International Standard Recording Code) for exact track identification.
|
||||
/// This enables precise matching across different streaming services.
|
||||
/// </summary>
|
||||
public string? Isrc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Track duration in milliseconds
|
||||
/// </summary>
|
||||
public int DurationMs { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the track contains explicit content
|
||||
/// </summary>
|
||||
public bool Explicit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Track's popularity score (0-100)
|
||||
/// </summary>
|
||||
public int Popularity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Preview URL for 30-second audio clip (may be null)
|
||||
/// </summary>
|
||||
public string? PreviewUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Album artwork URL (largest available)
|
||||
/// </summary>
|
||||
public string? AlbumArtUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Release date of the album (format varies: YYYY, YYYY-MM, or YYYY-MM-DD)
|
||||
/// </summary>
|
||||
public string? ReleaseDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When this track was added to the playlist
|
||||
/// </summary>
|
||||
public DateTime? AddedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Disc number within the album
|
||||
/// </summary>
|
||||
public int DiscNumber { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Track number within the disc
|
||||
/// </summary>
|
||||
public int TrackNumber { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Primary (first) artist name
|
||||
/// </summary>
|
||||
public string PrimaryArtist => Artists.FirstOrDefault() ?? string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// All artists as a comma-separated string
|
||||
/// </summary>
|
||||
public string AllArtists => string.Join(", ", Artists);
|
||||
|
||||
/// <summary>
|
||||
/// Track duration as TimeSpan
|
||||
/// </summary>
|
||||
public TimeSpan Duration => TimeSpan.FromMilliseconds(DurationMs);
|
||||
|
||||
/// <summary>
|
||||
/// Converts to the legacy MissingTrack format for compatibility with existing matching logic.
|
||||
/// </summary>
|
||||
public MissingTrack ToMissingTrack() => new()
|
||||
{
|
||||
SpotifyId = SpotifyId,
|
||||
Title = Title,
|
||||
Album = Album,
|
||||
Artists = Artists
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a Spotify playlist with its tracks in order.
|
||||
/// </summary>
|
||||
public class SpotifyPlaylist
|
||||
{
|
||||
/// <summary>
|
||||
/// Spotify playlist ID
|
||||
/// </summary>
|
||||
public string SpotifyId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Playlist name
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Playlist description
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Playlist owner's display name
|
||||
/// </summary>
|
||||
public string? OwnerName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Playlist owner's Spotify ID
|
||||
/// </summary>
|
||||
public string? OwnerId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of tracks in the playlist
|
||||
/// </summary>
|
||||
public int TotalTracks { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Playlist cover image URL
|
||||
/// </summary>
|
||||
public string? ImageUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is a collaborative playlist
|
||||
/// </summary>
|
||||
public bool Collaborative { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this playlist is public
|
||||
/// </summary>
|
||||
public bool Public { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Tracks in the playlist, ordered by position
|
||||
/// </summary>
|
||||
public List<SpotifyPlaylistTrack> Tracks { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// When this data was fetched from Spotify
|
||||
/// </summary>
|
||||
public DateTime FetchedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot ID for change detection (Spotify's playlist version identifier)
|
||||
/// </summary>
|
||||
public string? SnapshotId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a Spotify track that has been matched to an external provider track.
|
||||
/// Preserves position for correct playlist ordering.
|
||||
/// </summary>
|
||||
public class MatchedTrack
|
||||
{
|
||||
/// <summary>
|
||||
/// Position in the original Spotify playlist (0-based)
|
||||
/// </summary>
|
||||
public int Position { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Original Spotify track ID
|
||||
/// </summary>
|
||||
public string SpotifyId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Original Spotify track title (for debugging/logging)
|
||||
/// </summary>
|
||||
public string SpotifyTitle { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Original Spotify artist (for debugging/logging)
|
||||
/// </summary>
|
||||
public string SpotifyArtist { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// ISRC used for matching (if available)
|
||||
/// </summary>
|
||||
public string? Isrc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// How the match was made: "isrc" or "fuzzy"
|
||||
/// </summary>
|
||||
public string MatchType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The matched song from the external provider
|
||||
/// </summary>
|
||||
public Song MatchedSong { get; set; } = null!;
|
||||
}
|
||||
@@ -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
|
||||
@@ -39,11 +43,18 @@ static List<string> DecodeSquidWtfUrls()
|
||||
var backendType = builder.Configuration.GetValue<BackendType>("Backend:Type");
|
||||
|
||||
// Configure Kestrel for large responses over VPN/Tailscale
|
||||
// Also configure admin port on 5275 (internal only, not exposed)
|
||||
builder.WebHost.ConfigureKestrel(serverOptions =>
|
||||
{
|
||||
serverOptions.Limits.MaxResponseBufferSize = null; // Disable response buffering limit
|
||||
serverOptions.Limits.MaxRequestBodySize = null; // Allow large request bodies
|
||||
serverOptions.Limits.MinResponseDataRate = null; // Disable minimum data rate for slow connections
|
||||
|
||||
// Main proxy port (exposed)
|
||||
serverOptions.ListenAnyIP(8080);
|
||||
|
||||
// Admin UI port (internal only - do NOT expose through reverse proxy)
|
||||
serverOptions.ListenAnyIP(5275);
|
||||
});
|
||||
|
||||
// Add response compression for large JSON responses (helps with Tailscale/VPN MTU issues)
|
||||
@@ -86,6 +97,10 @@ builder.Services.ConfigureAll<HttpClientFactoryOptions>(options =>
|
||||
MaxAutomaticRedirections = 5
|
||||
};
|
||||
});
|
||||
|
||||
// Suppress verbose HTTP logging - these are logged at Debug level by default
|
||||
// but we want to reduce noise in production logs
|
||||
options.SuppressHandlerScope = true;
|
||||
});
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
@@ -95,6 +110,9 @@ builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
|
||||
builder.Services.AddProblemDetails();
|
||||
|
||||
// Admin port filter (restricts admin API to port 5275)
|
||||
builder.Services.AddScoped<allstarr.Filters.AdminPortFilter>();
|
||||
|
||||
// Configuration - register both settings, active one determined by backend type
|
||||
builder.Services.Configure<SubsonicSettings>(
|
||||
builder.Configuration.GetSection("Subsonic"));
|
||||
@@ -108,6 +126,240 @@ 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);
|
||||
|
||||
// Debug: Check what Bind() populated
|
||||
Console.WriteLine($"DEBUG: After Bind(), Playlists.Count = {options.Playlists.Count}");
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
Console.WriteLine($"DEBUG: After Bind(), PlaylistIds.Count = {options.PlaylistIds.Count}");
|
||||
Console.WriteLine($"DEBUG: After Bind(), PlaylistNames.Count = {options.PlaylistNames.Count}");
|
||||
#pragma warning restore CS0618
|
||||
|
||||
// Parse SPOTIFY_IMPORT_PLAYLISTS env var (JSON array format)
|
||||
// Format: [["Name","SpotifyId","JellyfinId","first|last"],["Name2","SpotifyId2","JellyfinId2","first|last"]]
|
||||
var playlistsEnv = builder.Configuration.GetValue<string>("SpotifyImport:Playlists");
|
||||
if (!string.IsNullOrWhiteSpace(playlistsEnv))
|
||||
{
|
||||
Console.WriteLine($"Found SPOTIFY_IMPORT_PLAYLISTS env var: {playlistsEnv.Length} chars");
|
||||
try
|
||||
{
|
||||
// Parse as JSON array of arrays
|
||||
var playlistArrays = System.Text.Json.JsonSerializer.Deserialize<string[][]>(playlistsEnv);
|
||||
if (playlistArrays != null && playlistArrays.Length > 0)
|
||||
{
|
||||
// Clear any playlists that Bind() may have incorrectly populated
|
||||
options.Playlists.Clear();
|
||||
|
||||
Console.WriteLine($"Parsed {playlistArrays.Length} playlists from JSON format");
|
||||
foreach (var arr in playlistArrays)
|
||||
{
|
||||
if (arr.Length >= 2)
|
||||
{
|
||||
var config = new SpotifyPlaylistConfig
|
||||
{
|
||||
Name = arr[0].Trim(),
|
||||
Id = arr[1].Trim(),
|
||||
JellyfinId = arr.Length >= 3 ? arr[2].Trim() : "",
|
||||
LocalTracksPosition = arr.Length >= 4 &&
|
||||
arr[3].Trim().Equals("last", StringComparison.OrdinalIgnoreCase)
|
||||
? LocalTracksPosition.Last
|
||||
: LocalTracksPosition.First
|
||||
};
|
||||
options.Playlists.Add(config);
|
||||
Console.WriteLine($" Added: {config.Name} (Spotify: {config.Id}, Jellyfin: {config.JellyfinId}, Position: {config.LocalTracksPosition})");
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("JSON format was empty or invalid, will try legacy format");
|
||||
}
|
||||
}
|
||||
catch (System.Text.Json.JsonException ex)
|
||||
{
|
||||
Console.WriteLine($"Warning: Failed to parse SPOTIFY_IMPORT_PLAYLISTS: {ex.Message}");
|
||||
Console.WriteLine("Expected format: [[\"Name\",\"SpotifyId\",\"JellyfinId\",\"first|last\"],[\"Name2\",\"SpotifyId2\",\"JellyfinId2\",\"first|last\"]]");
|
||||
Console.WriteLine("Will try legacy format instead");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("No SPOTIFY_IMPORT_PLAYLISTS env var found, will try legacy format");
|
||||
}
|
||||
|
||||
// Legacy support: Parse old SPOTIFY_IMPORT_PLAYLIST_IDS/NAMES env vars
|
||||
// Only used if new Playlists format is not configured
|
||||
// Check if we have legacy env vars to parse
|
||||
var playlistIdsEnv = builder.Configuration.GetValue<string>("SpotifyImport:PlaylistIds");
|
||||
var playlistNamesEnv = builder.Configuration.GetValue<string>("SpotifyImport:PlaylistNames");
|
||||
var hasLegacyConfig = !string.IsNullOrWhiteSpace(playlistIdsEnv) || !string.IsNullOrWhiteSpace(playlistNamesEnv);
|
||||
|
||||
if (hasLegacyConfig && options.Playlists.Count == 0)
|
||||
{
|
||||
Console.WriteLine("Parsing legacy Spotify playlist format...");
|
||||
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
|
||||
// Clear any auto-bound values from the Bind() call above
|
||||
// The auto-binder doesn't handle comma-separated strings correctly
|
||||
options.PlaylistIds.Clear();
|
||||
options.PlaylistNames.Clear();
|
||||
options.PlaylistLocalTracksPositions.Clear();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(playlistIdsEnv))
|
||||
{
|
||||
options.PlaylistIds = playlistIdsEnv
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(id => id.Trim())
|
||||
.Where(id => !string.IsNullOrEmpty(id))
|
||||
.ToList();
|
||||
Console.WriteLine($" Parsed {options.PlaylistIds.Count} playlist IDs from env var");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(playlistNamesEnv))
|
||||
{
|
||||
options.PlaylistNames = playlistNamesEnv
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(name => name.Trim())
|
||||
.Where(name => !string.IsNullOrEmpty(name))
|
||||
.ToList();
|
||||
Console.WriteLine($" Parsed {options.PlaylistNames.Count} playlist names from env var");
|
||||
}
|
||||
|
||||
var playlistPositionsEnv = builder.Configuration.GetValue<string>("SpotifyImport:PlaylistLocalTracksPositions");
|
||||
if (!string.IsNullOrWhiteSpace(playlistPositionsEnv))
|
||||
{
|
||||
options.PlaylistLocalTracksPositions = playlistPositionsEnv
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(pos => pos.Trim())
|
||||
.Where(pos => !string.IsNullOrEmpty(pos))
|
||||
.ToList();
|
||||
Console.WriteLine($" Parsed {options.PlaylistLocalTracksPositions.Count} playlist positions from env var");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(" No playlist positions env var found, will use defaults");
|
||||
}
|
||||
|
||||
// Convert legacy format to new Playlists array
|
||||
Console.WriteLine($" Converting {options.PlaylistIds.Count} playlists to new format...");
|
||||
for (int i = 0; i < options.PlaylistIds.Count; i++)
|
||||
{
|
||||
var name = i < options.PlaylistNames.Count ? options.PlaylistNames[i] : options.PlaylistIds[i];
|
||||
var position = LocalTracksPosition.First; // Default
|
||||
|
||||
// Parse position if provided
|
||||
if (i < options.PlaylistLocalTracksPositions.Count)
|
||||
{
|
||||
var posStr = options.PlaylistLocalTracksPositions[i];
|
||||
if (posStr.Equals("last", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
position = LocalTracksPosition.Last;
|
||||
}
|
||||
}
|
||||
|
||||
options.Playlists.Add(new SpotifyPlaylistConfig
|
||||
{
|
||||
Name = name,
|
||||
Id = options.PlaylistIds[i],
|
||||
LocalTracksPosition = position
|
||||
});
|
||||
Console.WriteLine($" [{i}] {name} (ID: {options.PlaylistIds[i]}, Position: {position})");
|
||||
}
|
||||
#pragma warning restore CS0618
|
||||
}
|
||||
else if (hasLegacyConfig && options.Playlists.Count > 0)
|
||||
{
|
||||
// Bind() incorrectly populated Playlists from legacy env vars
|
||||
// Clear it and re-parse properly
|
||||
Console.WriteLine($"DEBUG: Bind() incorrectly populated {options.Playlists.Count} playlists, clearing and re-parsing...");
|
||||
options.Playlists.Clear();
|
||||
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
options.PlaylistIds.Clear();
|
||||
options.PlaylistNames.Clear();
|
||||
options.PlaylistLocalTracksPositions.Clear();
|
||||
|
||||
Console.WriteLine("Parsing legacy Spotify playlist format...");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(playlistIdsEnv))
|
||||
{
|
||||
options.PlaylistIds = playlistIdsEnv
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(id => id.Trim())
|
||||
.Where(id => !string.IsNullOrEmpty(id))
|
||||
.ToList();
|
||||
Console.WriteLine($" Parsed {options.PlaylistIds.Count} playlist IDs from env var");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(playlistNamesEnv))
|
||||
{
|
||||
options.PlaylistNames = playlistNamesEnv
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(name => name.Trim())
|
||||
.Where(name => !string.IsNullOrEmpty(name))
|
||||
.ToList();
|
||||
Console.WriteLine($" Parsed {options.PlaylistNames.Count} playlist names from env var");
|
||||
}
|
||||
|
||||
var playlistPositionsEnv = builder.Configuration.GetValue<string>("SpotifyImport:PlaylistLocalTracksPositions");
|
||||
if (!string.IsNullOrWhiteSpace(playlistPositionsEnv))
|
||||
{
|
||||
options.PlaylistLocalTracksPositions = playlistPositionsEnv
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(pos => pos.Trim())
|
||||
.Where(pos => !string.IsNullOrEmpty(pos))
|
||||
.ToList();
|
||||
Console.WriteLine($" Parsed {options.PlaylistLocalTracksPositions.Count} playlist positions from env var");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(" No playlist positions env var found, will use defaults");
|
||||
}
|
||||
|
||||
// Convert legacy format to new Playlists array
|
||||
Console.WriteLine($" Converting {options.PlaylistIds.Count} playlists to new format...");
|
||||
for (int i = 0; i < options.PlaylistIds.Count; i++)
|
||||
{
|
||||
var name = i < options.PlaylistNames.Count ? options.PlaylistNames[i] : options.PlaylistIds[i];
|
||||
var position = LocalTracksPosition.First; // Default
|
||||
|
||||
// Parse position if provided
|
||||
if (i < options.PlaylistLocalTracksPositions.Count)
|
||||
{
|
||||
var posStr = options.PlaylistLocalTracksPositions[i];
|
||||
if (posStr.Equals("last", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
position = LocalTracksPosition.Last;
|
||||
}
|
||||
}
|
||||
|
||||
options.Playlists.Add(new SpotifyPlaylistConfig
|
||||
{
|
||||
Name = name,
|
||||
Id = options.PlaylistIds[i],
|
||||
LocalTracksPosition = position
|
||||
});
|
||||
Console.WriteLine($" [{i}] {name} (ID: {options.PlaylistIds[i]}, Position: {position})");
|
||||
}
|
||||
#pragma warning restore CS0618
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Using new Playlists format: {options.Playlists.Count} playlists configured");
|
||||
}
|
||||
|
||||
// Log configuration at startup
|
||||
Console.WriteLine($"Spotify Import: Enabled={options.Enabled}, SyncHour={options.SyncStartHour}:{options.SyncStartMinute:D2}, WindowHours={options.SyncWindowHours}");
|
||||
Console.WriteLine($"Spotify Import Playlists: {options.Playlists.Count} configured");
|
||||
foreach (var playlist in options.Playlists)
|
||||
{
|
||||
Console.WriteLine($" - {playlist.Name} (ID: {playlist.Id}, LocalTracks: {playlist.LocalTracksPosition})");
|
||||
}
|
||||
});
|
||||
|
||||
// Get shared settings from the active backend config
|
||||
MusicService musicService;
|
||||
@@ -137,7 +389,12 @@ if (backendType == BackendType.Jellyfin)
|
||||
builder.Services.AddSingleton<JellyfinResponseBuilder>();
|
||||
builder.Services.AddSingleton<JellyfinModelMapper>();
|
||||
builder.Services.AddScoped<JellyfinProxyService>();
|
||||
builder.Services.AddSingleton<JellyfinSessionManager>();
|
||||
builder.Services.AddScoped<JellyfinAuthFilter>();
|
||||
builder.Services.AddScoped<allstarr.Filters.ApiKeyAuthFilter>();
|
||||
|
||||
// Register JellyfinController as a service for dependency injection
|
||||
builder.Services.AddScoped<allstarr.Controllers.JellyfinController>();
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -205,6 +462,9 @@ else if (musicService == MusicService.SquidWTF)
|
||||
squidWtfApiUrls));
|
||||
}
|
||||
|
||||
// Register ParallelMetadataService to race all registered providers for faster searches
|
||||
builder.Services.AddSingleton<ParallelMetadataService>();
|
||||
|
||||
// Startup validation - register validators based on backend
|
||||
if (backendType == BackendType.Jellyfin)
|
||||
{
|
||||
@@ -229,6 +489,116 @@ builder.Services.AddHostedService<StartupValidationOrchestrator>();
|
||||
// Register cache cleanup service (only runs when StorageMode is Cache)
|
||||
builder.Services.AddHostedService<CacheCleanupService>();
|
||||
|
||||
// Register cache warming service (loads file caches into Redis on startup)
|
||||
builder.Services.AddHostedService<CacheWarmingService>();
|
||||
|
||||
// Register Spotify API client, lyrics service, and settings for direct API access
|
||||
// Configure from environment variables with SPOTIFY_API_ prefix
|
||||
builder.Services.Configure<allstarr.Models.Settings.SpotifyApiSettings>(options =>
|
||||
{
|
||||
builder.Configuration.GetSection("SpotifyApi").Bind(options);
|
||||
|
||||
// Override from environment variables
|
||||
var enabled = builder.Configuration.GetValue<string>("SpotifyApi:Enabled");
|
||||
if (!string.IsNullOrEmpty(enabled))
|
||||
{
|
||||
options.Enabled = enabled.Equals("true", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
var clientId = builder.Configuration.GetValue<string>("SpotifyApi:ClientId");
|
||||
if (!string.IsNullOrEmpty(clientId))
|
||||
{
|
||||
options.ClientId = clientId;
|
||||
}
|
||||
|
||||
var clientSecret = builder.Configuration.GetValue<string>("SpotifyApi:ClientSecret");
|
||||
if (!string.IsNullOrEmpty(clientSecret))
|
||||
{
|
||||
options.ClientSecret = clientSecret;
|
||||
}
|
||||
|
||||
var sessionCookie = builder.Configuration.GetValue<string>("SpotifyApi:SessionCookie");
|
||||
if (!string.IsNullOrEmpty(sessionCookie))
|
||||
{
|
||||
options.SessionCookie = sessionCookie;
|
||||
}
|
||||
|
||||
var sessionCookieSetDate = builder.Configuration.GetValue<string>("SpotifyApi:SessionCookieSetDate");
|
||||
if (!string.IsNullOrEmpty(sessionCookieSetDate))
|
||||
{
|
||||
options.SessionCookieSetDate = sessionCookieSetDate;
|
||||
}
|
||||
|
||||
var cacheDuration = builder.Configuration.GetValue<int?>("SpotifyApi:CacheDurationMinutes");
|
||||
if (cacheDuration.HasValue)
|
||||
{
|
||||
options.CacheDurationMinutes = cacheDuration.Value;
|
||||
}
|
||||
|
||||
var preferIsrc = builder.Configuration.GetValue<string>("SpotifyApi:PreferIsrcMatching");
|
||||
if (!string.IsNullOrEmpty(preferIsrc))
|
||||
{
|
||||
options.PreferIsrcMatching = preferIsrc.Equals("true", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
// Log configuration (mask sensitive values)
|
||||
Console.WriteLine($"SpotifyApi Configuration:");
|
||||
Console.WriteLine($" Enabled: {options.Enabled}");
|
||||
Console.WriteLine($" ClientId: {(string.IsNullOrEmpty(options.ClientId) ? "(not set)" : options.ClientId[..8] + "...")}");
|
||||
Console.WriteLine($" SessionCookie: {(string.IsNullOrEmpty(options.SessionCookie) ? "(not set)" : "***" + options.SessionCookie[^8..])}");
|
||||
Console.WriteLine($" SessionCookieSetDate: {options.SessionCookieSetDate ?? "(not set)"}");
|
||||
Console.WriteLine($" CacheDurationMinutes: {options.CacheDurationMinutes}");
|
||||
Console.WriteLine($" PreferIsrcMatching: {options.PreferIsrcMatching}");
|
||||
});
|
||||
builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyApiClient>();
|
||||
|
||||
// Register Spotify lyrics service (uses Spotify's color-lyrics API)
|
||||
builder.Services.AddSingleton<allstarr.Services.Lyrics.SpotifyLyricsService>();
|
||||
|
||||
// Register Spotify playlist fetcher (uses direct Spotify API when SpotifyApi is enabled)
|
||||
builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyPlaylistFetcher>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<allstarr.Services.Spotify.SpotifyPlaylistFetcher>());
|
||||
|
||||
// Register Spotify missing tracks fetcher (legacy - only runs when SpotifyImport is enabled and SpotifyApi is disabled)
|
||||
builder.Services.AddHostedService<allstarr.Services.Spotify.SpotifyMissingTracksFetcher>();
|
||||
|
||||
// Register Spotify track matching service (pre-matches tracks with rate limiting)
|
||||
builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyTrackMatchingService>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<allstarr.Services.Spotify.SpotifyTrackMatchingService>());
|
||||
|
||||
// Register lyrics prefetch service (prefetches lyrics for all playlist tracks)
|
||||
builder.Services.AddSingleton<allstarr.Services.Lyrics.LyricsPrefetchService>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<allstarr.Services.Lyrics.LyricsPrefetchService>());
|
||||
|
||||
// Register MusicBrainz service for metadata enrichment
|
||||
builder.Services.Configure<allstarr.Models.Settings.MusicBrainzSettings>(options =>
|
||||
{
|
||||
builder.Configuration.GetSection("MusicBrainz").Bind(options);
|
||||
|
||||
// Override from environment variables
|
||||
var enabled = builder.Configuration.GetValue<string>("MusicBrainz:Enabled");
|
||||
if (!string.IsNullOrEmpty(enabled))
|
||||
{
|
||||
options.Enabled = enabled.Equals("true", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
var username = builder.Configuration.GetValue<string>("MusicBrainz:Username");
|
||||
if (!string.IsNullOrEmpty(username))
|
||||
{
|
||||
options.Username = username;
|
||||
}
|
||||
|
||||
var password = builder.Configuration.GetValue<string>("MusicBrainz:Password");
|
||||
if (!string.IsNullOrEmpty(password))
|
||||
{
|
||||
options.Password = password;
|
||||
}
|
||||
});
|
||||
builder.Services.AddSingleton<allstarr.Services.MusicBrainz.MusicBrainzService>();
|
||||
|
||||
// Register genre enrichment service
|
||||
builder.Services.AddSingleton<allstarr.Services.Common.GenreEnrichmentService>();
|
||||
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddDefaultPolicy(policy =>
|
||||
@@ -248,6 +618,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();
|
||||
@@ -256,6 +635,9 @@ if (app.Environment.IsDevelopment())
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
// Serve static files only on admin port (5275)
|
||||
app.UseMiddleware<allstarr.Middleware.AdminStaticFilesMiddleware>();
|
||||
|
||||
app.UseAuthorization();
|
||||
|
||||
app.UseCors();
|
||||
@@ -285,6 +667,9 @@ class BackendControllerFeatureProvider : Microsoft.AspNetCore.Mvc.Controllers.Co
|
||||
var isController = base.IsController(typeInfo);
|
||||
if (!isController) return false;
|
||||
|
||||
// AdminController should always be registered (for web UI)
|
||||
if (typeInfo.Name == "AdminController") return true;
|
||||
|
||||
// Only register the controller matching the configured backend type
|
||||
return _backendType switch
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ using allstarr.Models.Search;
|
||||
using allstarr.Models.Subsonic;
|
||||
using allstarr.Services.Local;
|
||||
using allstarr.Services.Subsonic;
|
||||
using System.Collections.Concurrent;
|
||||
using TagLib;
|
||||
using IOFile = System.IO.File;
|
||||
|
||||
@@ -27,7 +28,7 @@ public abstract class BaseDownloadService : IDownloadService
|
||||
protected readonly string DownloadPath;
|
||||
protected readonly string CachePath;
|
||||
|
||||
protected readonly Dictionary<string, DownloadInfo> ActiveDownloads = new();
|
||||
protected readonly ConcurrentDictionary<string, DownloadInfo> ActiveDownloads = new();
|
||||
protected readonly SemaphoreSlim DownloadLock = new(1, 1);
|
||||
|
||||
/// <summary>
|
||||
@@ -298,6 +299,14 @@ public abstract class BaseDownloadService : IDownloadService
|
||||
|
||||
song.LocalPath = localPath;
|
||||
|
||||
// Clean up completed download from tracking after a short delay
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMinutes(5)); // Keep for 5 minutes for status checks
|
||||
ActiveDownloads.TryRemove(songId, out _);
|
||||
Logger.LogDebug("Cleaned up completed download tracking for {SongId}", songId);
|
||||
});
|
||||
|
||||
// Register BEFORE releasing lock to prevent race conditions (both cache and download modes)
|
||||
await LocalLibraryService.RegisterDownloadedSongAsync(song, localPath);
|
||||
|
||||
@@ -360,6 +369,14 @@ public abstract class BaseDownloadService : IDownloadService
|
||||
{
|
||||
downloadInfo.Status = DownloadStatus.Failed;
|
||||
downloadInfo.ErrorMessage = ex.Message;
|
||||
|
||||
// Clean up failed download from tracking after a short delay
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMinutes(2)); // Keep for 2 minutes for error reporting
|
||||
ActiveDownloads.TryRemove(songId, out _);
|
||||
Logger.LogDebug("Cleaned up failed download tracking for {SongId}", songId);
|
||||
});
|
||||
}
|
||||
Logger.LogError(ex, "Download failed for {SongId}", songId);
|
||||
throw;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Controllers;
|
||||
|
||||
namespace allstarr.Services.Common;
|
||||
|
||||
@@ -11,16 +12,19 @@ public class CacheCleanupService : BackgroundService
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly SubsonicSettings _subsonicSettings;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<CacheCleanupService> _logger;
|
||||
private readonly TimeSpan _cleanupInterval = TimeSpan.FromHours(1);
|
||||
|
||||
public CacheCleanupService(
|
||||
IConfiguration configuration,
|
||||
IOptions<SubsonicSettings> subsonicSettings,
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<CacheCleanupService> logger)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_subsonicSettings = subsonicSettings.Value;
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -41,6 +45,7 @@ public class CacheCleanupService : BackgroundService
|
||||
try
|
||||
{
|
||||
await CleanupOldCachedFilesAsync(stoppingToken);
|
||||
await ProcessPendingDeletionsAsync(stoppingToken);
|
||||
await Task.Delay(_cleanupInterval, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
@@ -160,4 +165,30 @@ public class CacheCleanupService : BackgroundService
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes pending track deletions from the kept folder.
|
||||
/// </summary>
|
||||
private async Task ProcessPendingDeletionsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Create a scope to get the JellyfinController
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var jellyfinController = scope.ServiceProvider.GetService<JellyfinController>();
|
||||
|
||||
if (jellyfinController != null)
|
||||
{
|
||||
await jellyfinController.ProcessPendingDeletionsAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Could not resolve JellyfinController for pending deletions processing");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error processing pending deletions");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
400
allstarr/Services/Common/CacheWarmingService.cs
Normal file
400
allstarr/Services/Common/CacheWarmingService.cs
Normal file
@@ -0,0 +1,400 @@
|
||||
using System.Text.Json;
|
||||
using allstarr.Models.Domain;
|
||||
|
||||
namespace allstarr.Services.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Background service that warms up Redis cache from file system on startup.
|
||||
/// Ensures fast access to cached data after container restarts.
|
||||
/// </summary>
|
||||
public class CacheWarmingService : IHostedService
|
||||
{
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly ILogger<CacheWarmingService> _logger;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private const string GenreCacheDirectory = "/app/cache/genres";
|
||||
private const string PlaylistCacheDirectory = "/app/cache/spotify";
|
||||
private const string MappingsCacheDirectory = "/app/cache/mappings";
|
||||
private const string LyricsCacheDirectory = "/app/cache/lyrics";
|
||||
|
||||
public CacheWarmingService(
|
||||
RedisCacheService cache,
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<CacheWarmingService> logger)
|
||||
{
|
||||
_cache = cache;
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("🔥 Starting cache warming from file system...");
|
||||
|
||||
var startTime = DateTime.UtcNow;
|
||||
var genresWarmed = 0;
|
||||
var playlistsWarmed = 0;
|
||||
var mappingsWarmed = 0;
|
||||
var lyricsWarmed = 0;
|
||||
var lyricsMappingsWarmed = 0;
|
||||
|
||||
try
|
||||
{
|
||||
// Warm genre cache
|
||||
genresWarmed = await WarmGenreCacheAsync(cancellationToken);
|
||||
|
||||
// Warm playlist cache
|
||||
playlistsWarmed = await WarmPlaylistCacheAsync(cancellationToken);
|
||||
|
||||
// Warm manual mappings cache
|
||||
mappingsWarmed = await WarmManualMappingsCacheAsync(cancellationToken);
|
||||
|
||||
// Warm lyrics mappings cache
|
||||
lyricsMappingsWarmed = await WarmLyricsMappingsCacheAsync(cancellationToken);
|
||||
|
||||
// Warm lyrics cache
|
||||
lyricsWarmed = await WarmLyricsCacheAsync(cancellationToken);
|
||||
|
||||
var duration = DateTime.UtcNow - startTime;
|
||||
_logger.LogInformation(
|
||||
"✅ Cache warming complete in {Duration:F1}s: {Genres} genres, {Playlists} playlists, {Mappings} manual mappings, {LyricsMappings} lyrics mappings, {Lyrics} lyrics",
|
||||
duration.TotalSeconds, genresWarmed, playlistsWarmed, mappingsWarmed, lyricsMappingsWarmed, lyricsWarmed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to warm cache from file system");
|
||||
}
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Warms genre cache from file system.
|
||||
/// </summary>
|
||||
private async Task<int> WarmGenreCacheAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Directory.Exists(GenreCacheDirectory))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var files = Directory.GetFiles(GenreCacheDirectory, "*.json");
|
||||
var warmedCount = 0;
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
try
|
||||
{
|
||||
// Check if cache is expired (30 days)
|
||||
var fileInfo = new FileInfo(file);
|
||||
if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc > TimeSpan.FromDays(30))
|
||||
{
|
||||
File.Delete(file);
|
||||
continue;
|
||||
}
|
||||
|
||||
var json = await File.ReadAllTextAsync(file, cancellationToken);
|
||||
var cacheEntry = JsonSerializer.Deserialize<GenreCacheEntry>(json);
|
||||
|
||||
if (cacheEntry != null && !string.IsNullOrEmpty(cacheEntry.CacheKey))
|
||||
{
|
||||
var redisKey = $"genre:{cacheEntry.CacheKey}";
|
||||
await _cache.SetAsync(redisKey, cacheEntry.Genre, TimeSpan.FromDays(30));
|
||||
warmedCount++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to warm genre cache from file: {File}", file);
|
||||
}
|
||||
}
|
||||
|
||||
if (warmedCount > 0)
|
||||
{
|
||||
_logger.LogInformation("🔥 Warmed {Count} genre entries from file cache", warmedCount);
|
||||
}
|
||||
|
||||
return warmedCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Warms playlist cache from file system.
|
||||
/// </summary>
|
||||
private async Task<int> WarmPlaylistCacheAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Directory.Exists(PlaylistCacheDirectory))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var itemsFiles = Directory.GetFiles(PlaylistCacheDirectory, "*_items.json");
|
||||
var matchedFiles = Directory.GetFiles(PlaylistCacheDirectory, "*_matched.json");
|
||||
var warmedCount = 0;
|
||||
|
||||
// Warm playlist items cache
|
||||
foreach (var file in itemsFiles)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
try
|
||||
{
|
||||
// Check if cache is expired (24 hours)
|
||||
var fileInfo = new FileInfo(file);
|
||||
if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc > TimeSpan.FromHours(24))
|
||||
{
|
||||
continue; // Don't delete, let the normal flow handle it
|
||||
}
|
||||
|
||||
var json = await File.ReadAllTextAsync(file, cancellationToken);
|
||||
var items = JsonSerializer.Deserialize<List<Dictionary<string, object?>>>(json);
|
||||
|
||||
if (items != null && items.Count > 0)
|
||||
{
|
||||
// Extract playlist name from filename
|
||||
var fileName = Path.GetFileNameWithoutExtension(file);
|
||||
var playlistName = fileName.Replace("_items", "");
|
||||
|
||||
var redisKey = $"spotify:playlist:items:{playlistName}";
|
||||
await _cache.SetAsync(redisKey, items, TimeSpan.FromHours(24));
|
||||
warmedCount++;
|
||||
|
||||
_logger.LogDebug("🔥 Warmed playlist items cache for {Playlist} ({Count} items)",
|
||||
playlistName, items.Count);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to warm playlist items cache from file: {File}", file);
|
||||
}
|
||||
}
|
||||
|
||||
// Warm matched tracks cache
|
||||
foreach (var file in matchedFiles)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
try
|
||||
{
|
||||
// Check if cache is expired (1 hour)
|
||||
var fileInfo = new FileInfo(file);
|
||||
if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc > TimeSpan.FromHours(1))
|
||||
{
|
||||
continue; // Skip expired matched tracks
|
||||
}
|
||||
|
||||
var json = await File.ReadAllTextAsync(file, cancellationToken);
|
||||
var matchedTracks = JsonSerializer.Deserialize<List<MatchedTrack>>(json);
|
||||
|
||||
if (matchedTracks != null && matchedTracks.Count > 0)
|
||||
{
|
||||
// Extract playlist name from filename
|
||||
var fileName = Path.GetFileNameWithoutExtension(file);
|
||||
var playlistName = fileName.Replace("_matched", "");
|
||||
|
||||
var redisKey = $"spotify:matched:ordered:{playlistName}";
|
||||
await _cache.SetAsync(redisKey, matchedTracks, TimeSpan.FromHours(1));
|
||||
warmedCount++;
|
||||
|
||||
_logger.LogDebug("🔥 Warmed matched tracks cache for {Playlist} ({Count} tracks)",
|
||||
playlistName, matchedTracks.Count);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to warm matched tracks cache from file: {File}", file);
|
||||
}
|
||||
}
|
||||
|
||||
if (warmedCount > 0)
|
||||
{
|
||||
_logger.LogInformation("🔥 Warmed {Count} playlist caches from file system", warmedCount);
|
||||
}
|
||||
|
||||
return warmedCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Warms manual mappings cache from file system.
|
||||
/// Manual mappings NEVER expire - they are permanent user decisions.
|
||||
/// </summary>
|
||||
private async Task<int> WarmManualMappingsCacheAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Directory.Exists(MappingsCacheDirectory))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var files = Directory.GetFiles(MappingsCacheDirectory, "*_mappings.json");
|
||||
var warmedCount = 0;
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(file, cancellationToken);
|
||||
var mappings = JsonSerializer.Deserialize<Dictionary<string, ManualMappingEntry>>(json);
|
||||
|
||||
if (mappings != null && mappings.Count > 0)
|
||||
{
|
||||
// Extract playlist name from filename
|
||||
var fileName = Path.GetFileNameWithoutExtension(file);
|
||||
var playlistName = fileName.Replace("_mappings", "");
|
||||
|
||||
foreach (var mapping in mappings.Values)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(mapping.JellyfinId))
|
||||
{
|
||||
// Jellyfin mapping
|
||||
var redisKey = $"spotify:manual-map:{playlistName}:{mapping.SpotifyId}";
|
||||
await _cache.SetAsync(redisKey, mapping.JellyfinId);
|
||||
warmedCount++;
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(mapping.ExternalProvider) && !string.IsNullOrEmpty(mapping.ExternalId))
|
||||
{
|
||||
// External mapping
|
||||
var redisKey = $"spotify:external-map:{playlistName}:{mapping.SpotifyId}";
|
||||
var externalMapping = new { provider = mapping.ExternalProvider, id = mapping.ExternalId };
|
||||
await _cache.SetAsync(redisKey, externalMapping);
|
||||
warmedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("🔥 Warmed {Count} manual mappings for {Playlist}",
|
||||
mappings.Count, playlistName);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to warm manual mappings from file: {File}", file);
|
||||
}
|
||||
}
|
||||
|
||||
if (warmedCount > 0)
|
||||
{
|
||||
_logger.LogInformation("🔥 Warmed {Count} manual mappings from file system", warmedCount);
|
||||
}
|
||||
|
||||
return warmedCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Warms lyrics mappings cache from file system.
|
||||
/// Lyrics mappings NEVER expire - they are permanent user decisions.
|
||||
/// </summary>
|
||||
private async Task<int> WarmLyricsMappingsCacheAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var mappingsFile = "/app/cache/lyrics_mappings.json";
|
||||
|
||||
if (!File.Exists(mappingsFile))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(mappingsFile, cancellationToken);
|
||||
var mappings = JsonSerializer.Deserialize<List<LyricsMappingEntry>>(json);
|
||||
|
||||
if (mappings != null && mappings.Count > 0)
|
||||
{
|
||||
foreach (var mapping in mappings)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
// Store in Redis with NO EXPIRATION (permanent)
|
||||
var redisKey = $"lyrics:manual-map:{mapping.Artist}:{mapping.Title}";
|
||||
await _cache.SetStringAsync(redisKey, mapping.LyricsId.ToString());
|
||||
}
|
||||
|
||||
_logger.LogInformation("🔥 Warmed {Count} lyrics mappings from file system", mappings.Count);
|
||||
return mappings.Count;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to warm lyrics mappings from file: {File}", mappingsFile);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Warms lyrics cache from file system using the LyricsPrefetchService.
|
||||
/// </summary>
|
||||
private async Task<int> WarmLyricsCacheAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get the LyricsPrefetchService from DI
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var lyricsPrefetchService = scope.ServiceProvider.GetService<allstarr.Services.Lyrics.LyricsPrefetchService>();
|
||||
|
||||
if (lyricsPrefetchService != null)
|
||||
{
|
||||
await lyricsPrefetchService.WarmCacheFromFilesAsync();
|
||||
|
||||
// Count files to return warmed count
|
||||
if (Directory.Exists(LyricsCacheDirectory))
|
||||
{
|
||||
return Directory.GetFiles(LyricsCacheDirectory, "*.json").Length;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to warm lyrics cache");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private class GenreCacheEntry
|
||||
{
|
||||
public string CacheKey { get; set; } = "";
|
||||
public string Genre { get; set; } = "";
|
||||
public DateTime CachedAt { get; set; }
|
||||
}
|
||||
|
||||
private class MatchedTrack
|
||||
{
|
||||
public int Position { get; set; }
|
||||
public string SpotifyId { get; set; } = "";
|
||||
public string SpotifyTitle { get; set; } = "";
|
||||
public string SpotifyArtist { get; set; } = "";
|
||||
public string? Isrc { get; set; }
|
||||
public string MatchType { get; set; } = "";
|
||||
public Song? MatchedSong { get; set; }
|
||||
}
|
||||
|
||||
private class ManualMappingEntry
|
||||
{
|
||||
public string SpotifyId { get; set; } = "";
|
||||
public string? JellyfinId { get; set; }
|
||||
public string? ExternalProvider { get; set; }
|
||||
public string? ExternalId { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
private class LyricsMappingEntry
|
||||
{
|
||||
public string Artist { get; set; } = "";
|
||||
public string Title { get; set; } = "";
|
||||
public string? Album { get; set; }
|
||||
public int DurationSeconds { get; set; }
|
||||
public int LyricsId { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,8 @@ public static class FuzzyMatcher
|
||||
return 0;
|
||||
}
|
||||
|
||||
var queryLower = query.ToLowerInvariant().Trim();
|
||||
var targetLower = target.ToLowerInvariant().Trim();
|
||||
var queryLower = NormalizeForMatching(query);
|
||||
var targetLower = NormalizeForMatching(target);
|
||||
|
||||
// Exact match
|
||||
if (queryLower == targetLower)
|
||||
@@ -59,6 +59,34 @@ public static class FuzzyMatcher
|
||||
return (int)Math.Max(0, similarity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a string for matching by:
|
||||
/// - Converting to lowercase
|
||||
/// - Normalizing apostrophes (', ', ') to standard '
|
||||
/// - Removing extra whitespace
|
||||
/// </summary>
|
||||
private static string NormalizeForMatching(string text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var normalized = text.ToLowerInvariant().Trim();
|
||||
|
||||
// Normalize different apostrophe types to standard apostrophe
|
||||
normalized = normalized
|
||||
.Replace("\u2019", "'") // Right single quotation mark (')
|
||||
.Replace("\u2018", "'") // Left single quotation mark (')
|
||||
.Replace("`", "'") // Grave accent
|
||||
.Replace("\u00B4", "'"); // Acute accent (´)
|
||||
|
||||
// Normalize whitespace
|
||||
normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"\s+", " ");
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates Levenshtein distance between two strings.
|
||||
/// </summary>
|
||||
|
||||
228
allstarr/Services/Common/GenreEnrichmentService.cs
Normal file
228
allstarr/Services/Common/GenreEnrichmentService.cs
Normal file
@@ -0,0 +1,228 @@
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Services.MusicBrainz;
|
||||
using allstarr.Services.Common;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace allstarr.Services.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Service for enriching songs and playlists with genre information from MusicBrainz.
|
||||
/// </summary>
|
||||
public class GenreEnrichmentService
|
||||
{
|
||||
private readonly MusicBrainzService _musicBrainz;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly ILogger<GenreEnrichmentService> _logger;
|
||||
private const string GenreCachePrefix = "genre:";
|
||||
private const string GenreCacheDirectory = "/app/cache/genres";
|
||||
private static readonly TimeSpan GenreCacheDuration = TimeSpan.FromDays(30);
|
||||
|
||||
public GenreEnrichmentService(
|
||||
MusicBrainzService musicBrainz,
|
||||
RedisCacheService cache,
|
||||
ILogger<GenreEnrichmentService> logger)
|
||||
{
|
||||
_musicBrainz = musicBrainz;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
|
||||
// Ensure cache directory exists
|
||||
Directory.CreateDirectory(GenreCacheDirectory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enriches a song with genre information from MusicBrainz (with caching).
|
||||
/// Updates the song's Genre property with the top genre.
|
||||
/// </summary>
|
||||
public async Task EnrichSongGenreAsync(Song song)
|
||||
{
|
||||
// Skip if song already has a genre
|
||||
if (!string.IsNullOrEmpty(song.Genre))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var cacheKey = $"{song.Title}:{song.Artist}";
|
||||
|
||||
// Check Redis cache first
|
||||
var redisCacheKey = $"{GenreCachePrefix}{cacheKey}";
|
||||
var cachedGenre = await _cache.GetAsync<string>(redisCacheKey);
|
||||
|
||||
if (cachedGenre != null)
|
||||
{
|
||||
song.Genre = cachedGenre;
|
||||
_logger.LogDebug("Using Redis cached genre for {Title} - {Artist}: {Genre}",
|
||||
song.Title, song.Artist, cachedGenre);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check file cache
|
||||
var fileCachedGenre = await GetFromFileCacheAsync(cacheKey);
|
||||
if (fileCachedGenre != null)
|
||||
{
|
||||
song.Genre = fileCachedGenre;
|
||||
// Restore to Redis cache
|
||||
await _cache.SetAsync(redisCacheKey, fileCachedGenre, GenreCacheDuration);
|
||||
_logger.LogDebug("Using file cached genre for {Title} - {Artist}: {Genre}",
|
||||
song.Title, song.Artist, fileCachedGenre);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch from MusicBrainz
|
||||
try
|
||||
{
|
||||
var genres = await _musicBrainz.GetGenresForSongAsync(song.Title, song.Artist, song.Isrc);
|
||||
|
||||
if (genres.Count > 0)
|
||||
{
|
||||
// Use the top genre
|
||||
song.Genre = genres[0];
|
||||
|
||||
// Cache in both Redis and file
|
||||
await _cache.SetAsync(redisCacheKey, song.Genre, GenreCacheDuration);
|
||||
await SaveToFileCacheAsync(cacheKey, song.Genre);
|
||||
|
||||
_logger.LogInformation("Enriched {Title} - {Artist} with genre: {Genre}",
|
||||
song.Title, song.Artist, song.Genre);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Cache negative result to avoid repeated lookups
|
||||
await SaveToFileCacheAsync(cacheKey, "");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to enrich genre for {Title} - {Artist}",
|
||||
song.Title, song.Artist);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enriches multiple songs with genre information (batch operation).
|
||||
/// </summary>
|
||||
public async Task EnrichSongsGenresAsync(List<Song> songs)
|
||||
{
|
||||
var tasks = songs
|
||||
.Where(s => string.IsNullOrEmpty(s.Genre))
|
||||
.Select(s => EnrichSongGenreAsync(s));
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregates genres from a list of songs to determine playlist genres.
|
||||
/// Returns the top 5 most common genres.
|
||||
/// </summary>
|
||||
public List<string> AggregatePlaylistGenres(List<Song> songs)
|
||||
{
|
||||
var genreCounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var song in songs)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(song.Genre))
|
||||
{
|
||||
if (genreCounts.ContainsKey(song.Genre))
|
||||
{
|
||||
genreCounts[song.Genre]++;
|
||||
}
|
||||
else
|
||||
{
|
||||
genreCounts[song.Genre] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return genreCounts
|
||||
.OrderByDescending(kvp => kvp.Value)
|
||||
.Take(5)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets genre from file cache.
|
||||
/// </summary>
|
||||
private async Task<string?> GetFromFileCacheAsync(string cacheKey)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fileName = GetCacheFileName(cacheKey);
|
||||
var filePath = Path.Combine(GenreCacheDirectory, fileName);
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if cache is expired (30 days)
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc > GenreCacheDuration)
|
||||
{
|
||||
File.Delete(filePath);
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await File.ReadAllTextAsync(filePath);
|
||||
var cacheEntry = JsonSerializer.Deserialize<GenreCacheEntry>(json);
|
||||
|
||||
return cacheEntry?.Genre;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to read genre from file cache for {Key}", cacheKey);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves genre to file cache.
|
||||
/// </summary>
|
||||
private async Task SaveToFileCacheAsync(string cacheKey, string genre)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fileName = GetCacheFileName(cacheKey);
|
||||
var filePath = Path.Combine(GenreCacheDirectory, fileName);
|
||||
|
||||
var cacheEntry = new GenreCacheEntry
|
||||
{
|
||||
CacheKey = cacheKey,
|
||||
Genre = genre,
|
||||
CachedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(cacheEntry, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
});
|
||||
|
||||
await File.WriteAllTextAsync(filePath, json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to save genre to file cache for {Key}", cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a safe file name from cache key.
|
||||
/// </summary>
|
||||
private static string GetCacheFileName(string cacheKey)
|
||||
{
|
||||
// Use base64 encoding to create safe file names
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(cacheKey);
|
||||
var base64 = Convert.ToBase64String(bytes)
|
||||
.Replace("+", "-")
|
||||
.Replace("/", "_")
|
||||
.Replace("=", "");
|
||||
return $"{base64}.json";
|
||||
}
|
||||
|
||||
private class GenreCacheEntry
|
||||
{
|
||||
public string CacheKey { get; set; } = "";
|
||||
public string Genre { get; set; } = "";
|
||||
public DateTime CachedAt { get; set; }
|
||||
}
|
||||
}
|
||||
135
allstarr/Services/Common/ParallelMetadataService.cs
Normal file
135
allstarr/Services/Common/ParallelMetadataService.cs
Normal file
@@ -0,0 +1,135 @@
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Models.Search;
|
||||
|
||||
namespace allstarr.Services.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Races multiple metadata providers in parallel and returns the fastest result.
|
||||
/// Used for search operations to minimize latency.
|
||||
/// </summary>
|
||||
public class ParallelMetadataService
|
||||
{
|
||||
private readonly IEnumerable<IMusicMetadataService> _providers;
|
||||
private readonly ILogger<ParallelMetadataService> _logger;
|
||||
|
||||
public ParallelMetadataService(
|
||||
IEnumerable<IMusicMetadataService> providers,
|
||||
ILogger<ParallelMetadataService> logger)
|
||||
{
|
||||
_providers = providers;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Races all providers and returns the first successful result.
|
||||
/// Falls back to next provider if first one fails.
|
||||
/// </summary>
|
||||
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20)
|
||||
{
|
||||
if (!_providers.Any())
|
||||
{
|
||||
_logger.LogWarning("No metadata providers available for parallel search");
|
||||
return new SearchResult();
|
||||
}
|
||||
|
||||
_logger.LogDebug("🏁 Racing {Count} providers for search: {Query}", _providers.Count(), query);
|
||||
|
||||
// Create tasks for all providers
|
||||
var tasks = _providers.Select(async provider =>
|
||||
{
|
||||
var providerName = provider.GetType().Name;
|
||||
try
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var result = await provider.SearchAllAsync(query, songLimit, albumLimit, artistLimit);
|
||||
sw.Stop();
|
||||
|
||||
_logger.LogInformation("✅ {Provider} completed search in {Ms}ms ({Songs} songs, {Albums} albums, {Artists} artists)",
|
||||
providerName, sw.ElapsedMilliseconds, result.Songs.Count, result.Albums.Count, result.Artists.Count);
|
||||
|
||||
return (Success: true, Result: result, Provider: providerName, ElapsedMs: sw.ElapsedMilliseconds);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "❌ {Provider} search failed", providerName);
|
||||
return (Success: false, Result: new SearchResult(), Provider: providerName, ElapsedMs: 0L);
|
||||
}
|
||||
}).ToList();
|
||||
|
||||
// Wait for first successful result
|
||||
while (tasks.Any())
|
||||
{
|
||||
var completedTask = await Task.WhenAny(tasks);
|
||||
var result = await completedTask;
|
||||
|
||||
if (result.Success && (result.Result.Songs.Any() || result.Result.Albums.Any() || result.Result.Artists.Any()))
|
||||
{
|
||||
_logger.LogInformation("🏆 Using results from {Provider} ({Ms}ms) - fastest with results",
|
||||
result.Provider, result.ElapsedMs);
|
||||
return result.Result;
|
||||
}
|
||||
|
||||
// Remove completed task and try next
|
||||
tasks.Remove(completedTask);
|
||||
}
|
||||
|
||||
// All providers failed or returned empty
|
||||
_logger.LogWarning("⚠️ All providers failed or returned empty results for: {Query}", query);
|
||||
return new SearchResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches for a specific song by title and artist across all providers in parallel.
|
||||
/// Returns the first successful match.
|
||||
/// </summary>
|
||||
public async Task<Song?> SearchSongAsync(string title, string artist, int limit = 5)
|
||||
{
|
||||
if (!_providers.Any())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogDebug("🏁 Racing {Count} providers for song: {Title} - {Artist}", _providers.Count(), title, artist);
|
||||
|
||||
var tasks = _providers.Select(async provider =>
|
||||
{
|
||||
var providerName = provider.GetType().Name;
|
||||
try
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var songs = await provider.SearchSongsAsync($"{title} {artist}", limit);
|
||||
sw.Stop();
|
||||
|
||||
var bestMatch = songs.FirstOrDefault();
|
||||
if (bestMatch != null)
|
||||
{
|
||||
_logger.LogInformation("✅ {Provider} found song in {Ms}ms", providerName, sw.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
return (Success: bestMatch != null, Song: bestMatch, Provider: providerName, ElapsedMs: sw.ElapsedMilliseconds);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "❌ {Provider} song search failed", providerName);
|
||||
return (Success: false, Song: (Song?)null, Provider: providerName, ElapsedMs: 0L);
|
||||
}
|
||||
}).ToList();
|
||||
|
||||
// Wait for first successful result
|
||||
while (tasks.Any())
|
||||
{
|
||||
var completedTask = await Task.WhenAny(tasks);
|
||||
var result = await completedTask;
|
||||
|
||||
if (result.Success && result.Song != null)
|
||||
{
|
||||
_logger.LogInformation("🏆 Using song from {Provider} ({Ms}ms)", result.Provider, result.ElapsedMs);
|
||||
return result.Song;
|
||||
}
|
||||
|
||||
tasks.Remove(completedTask);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -57,13 +57,14 @@ public class RedisCacheService
|
||||
try
|
||||
{
|
||||
var value = await _db!.StringGetAsync(key);
|
||||
|
||||
if (value.HasValue)
|
||||
{
|
||||
_logger.LogInformation("Redis cache HIT: {Key}", key);
|
||||
_logger.LogDebug("Redis cache HIT: {Key}", key);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Redis cache MISS: {Key}", key);
|
||||
_logger.LogDebug("Redis cache MISS: {Key}", key);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -105,7 +106,7 @@ public class RedisCacheService
|
||||
var result = await _db!.StringSetAsync(key, value, expiry);
|
||||
if (result)
|
||||
{
|
||||
_logger.LogInformation("Redis cache SET: {Key} (TTL: {Expiry})", key, expiry?.ToString() ?? "none");
|
||||
_logger.LogDebug("Redis cache SET: {Key} (TTL: {Expiry})", key, expiry?.ToString() ?? "none");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -168,4 +169,34 @@ public class RedisCacheService
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all keys matching a pattern (e.g., "search:*").
|
||||
/// WARNING: Use with caution as this scans all keys.
|
||||
/// </summary>
|
||||
public async Task<int> DeleteByPatternAsync(string pattern)
|
||||
{
|
||||
if (!IsEnabled) return 0;
|
||||
|
||||
try
|
||||
{
|
||||
var server = _redis!.GetServer(_redis.GetEndPoints().First());
|
||||
var keys = server.Keys(pattern: pattern).ToArray();
|
||||
|
||||
if (keys.Length == 0)
|
||||
{
|
||||
_logger.LogDebug("No keys found matching pattern: {Pattern}", pattern);
|
||||
return 0;
|
||||
}
|
||||
|
||||
var deleted = await _db!.KeyDeleteAsync(keys);
|
||||
_logger.LogInformation("Deleted {Count} Redis keys matching pattern: {Pattern}", deleted, pattern);
|
||||
return (int)deleted;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Redis DELETE BY PATTERN failed for pattern: {Pattern}", pattern);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,6 +187,14 @@ public class JellyfinModelMapper
|
||||
// Cover art URL construction
|
||||
song.CoverArtUrl = $"/Items/{id}/Images/Primary";
|
||||
|
||||
// Preserve Jellyfin metadata (MediaSources, etc.) for local tracks
|
||||
// This ensures bitrate and other technical details are maintained
|
||||
song.JellyfinMetadata = new Dictionary<string, object?>();
|
||||
if (item.TryGetProperty("MediaSources", out var mediaSources))
|
||||
{
|
||||
song.JellyfinMetadata["MediaSources"] = JsonSerializer.Deserialize<object>(mediaSources.GetRawText());
|
||||
}
|
||||
|
||||
return song;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,32 @@ 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);
|
||||
|
||||
// Forward client IP address to Jellyfin so it can identify the real client
|
||||
if (_httpContextAccessor.HttpContext != null)
|
||||
{
|
||||
var clientIp = _httpContextAccessor.HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
if (!string.IsNullOrEmpty(clientIp))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("X-Forwarded-For", clientIp);
|
||||
request.Headers.TryAddWithoutValidation("X-Real-IP", clientIp);
|
||||
}
|
||||
}
|
||||
|
||||
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 +215,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,56 +250,45 @@ 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);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, url);
|
||||
|
||||
// Handle special case for playback endpoints - Jellyfin expects wrapped body
|
||||
var bodyToSend = body;
|
||||
if (!string.IsNullOrWhiteSpace(body))
|
||||
// Forward client IP address to Jellyfin so it can identify the real client
|
||||
if (_httpContextAccessor.HttpContext != null)
|
||||
{
|
||||
// Check if this is a playback progress endpoint
|
||||
if (endpoint.Contains("Sessions/Playing/Progress", StringComparison.OrdinalIgnoreCase))
|
||||
var clientIp = _httpContextAccessor.HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
if (!string.IsNullOrEmpty(clientIp))
|
||||
{
|
||||
// Wrap the body in playbackProgressInfo field
|
||||
bodyToSend = $"{{\"playbackProgressInfo\":{body}}}";
|
||||
_logger.LogDebug("Wrapped body for playback progress endpoint");
|
||||
}
|
||||
else if (endpoint.Contains("Sessions/Playing/Stopped", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Wrap the body in playbackStopInfo field
|
||||
bodyToSend = $"{{\"playbackStopInfo\":{body}}}";
|
||||
_logger.LogDebug("Wrapped body for playback stopped endpoint");
|
||||
}
|
||||
else if (endpoint.Contains("Sessions/Playing", StringComparison.OrdinalIgnoreCase) &&
|
||||
!endpoint.Contains("Progress", StringComparison.OrdinalIgnoreCase) &&
|
||||
!endpoint.Contains("Stopped", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Wrap the body in playbackStartInfo field for /Sessions/Playing
|
||||
bodyToSend = $"{{\"playbackStartInfo\":{body}}}";
|
||||
_logger.LogDebug("Wrapped body for playback start endpoint");
|
||||
request.Headers.TryAddWithoutValidation("X-Forwarded-For", clientIp);
|
||||
request.Headers.TryAddWithoutValidation("X-Real-IP", clientIp);
|
||||
}
|
||||
}
|
||||
else
|
||||
|
||||
// Handle special case for playback endpoints
|
||||
// NOTE: Jellyfin API expects PlaybackStartInfo/PlaybackProgressInfo/PlaybackStopInfo
|
||||
// DIRECTLY as the body, NOT wrapped in a field. Do NOT wrap the body.
|
||||
var bodyToSend = body;
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
{
|
||||
bodyToSend = "{}";
|
||||
_logger.LogWarning("POST body was empty for {Url}, sending empty JSON object", url);
|
||||
@@ -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,26 @@ 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}",
|
||||
_logger.LogWarning("❌ SESSION: Jellyfin POST request failed: {StatusCode} for {Url}. Response: {Response}",
|
||||
response.StatusCode, url, errorContent);
|
||||
return null;
|
||||
return (null, statusCode);
|
||||
}
|
||||
|
||||
// Log successful session-related responses
|
||||
if (endpoint.Contains("Sessions", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogWarning("✓ SESSION: Jellyfin responded {StatusCode} for {Endpoint}", statusCode, endpoint);
|
||||
}
|
||||
|
||||
// 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,14 +394,22 @@ public class JellyfinProxyService
|
||||
// Handle empty responses
|
||||
if (string.IsNullOrWhiteSpace(responseContent))
|
||||
{
|
||||
return null;
|
||||
return (null, statusCode);
|
||||
}
|
||||
|
||||
return JsonDocument.Parse(responseContent);
|
||||
// Log response content for session endpoints
|
||||
if (endpoint.Contains("Sessions", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(responseContent))
|
||||
{
|
||||
var preview = responseContent.Length > 200 ? responseContent[..200] + "..." : responseContent;
|
||||
_logger.LogWarning("📥 SESSION: Jellyfin response body: {Body}", preview);
|
||||
}
|
||||
|
||||
return (JsonDocument.Parse(responseContent), statusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a GET request and returns raw bytes (for images, audio streams).
|
||||
/// WARNING: This loads entire response into memory - use StreamAsync for large files!
|
||||
/// </summary>
|
||||
public async Task<(byte[] Body, string? ContentType)> GetBytesAsync(string endpoint, Dictionary<string, string>? queryParams = null)
|
||||
{
|
||||
@@ -387,9 +424,138 @@ public class JellyfinProxyService
|
||||
var body = await response.Content.ReadAsByteArrayAsync();
|
||||
var contentType = response.Content.Headers.ContentType?.ToString();
|
||||
|
||||
// Trigger GC for large files to prevent memory leaks
|
||||
if (body.Length > 1024 * 1024) // 1MB threshold
|
||||
{
|
||||
GC.Collect(2, GCCollectionMode.Optimized, blocking: false);
|
||||
}
|
||||
|
||||
return (body, contentType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Streams content directly without loading into memory (for large files like audio).
|
||||
/// </summary>
|
||||
public async Task<(Stream Stream, string? ContentType, long? ContentLength)> GetStreamAsync(string endpoint, Dictionary<string, string>? queryParams = null)
|
||||
{
|
||||
var url = BuildUrl(endpoint, queryParams);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Add("Authorization", GetAuthorizationHeader());
|
||||
|
||||
var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var stream = await response.Content.ReadAsStreamAsync();
|
||||
var contentType = response.Content.Headers.ContentType?.ToString();
|
||||
var contentLength = response.Content.Headers.ContentLength;
|
||||
|
||||
return (stream, contentType, contentLength);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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);
|
||||
|
||||
// Forward client IP address to Jellyfin so it can identify the real client
|
||||
if (_httpContextAccessor.HttpContext != null)
|
||||
{
|
||||
var clientIp = _httpContextAccessor.HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
if (!string.IsNullOrEmpty(clientIp))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("X-Forwarded-For", clientIp);
|
||||
request.Headers.TryAddWithoutValidation("X-Real-IP", clientIp);
|
||||
}
|
||||
}
|
||||
|
||||
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 +579,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 +617,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 +673,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 +688,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 +725,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 +886,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 +921,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;
|
||||
@@ -801,4 +967,43 @@ public class JellyfinProxyService
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a GET request to the Jellyfin server using the server's API key for internal operations.
|
||||
/// This should only be used for server-side operations, not for proxying client requests.
|
||||
/// </summary>
|
||||
public async Task<(JsonDocument? Body, int StatusCode)> GetJsonAsyncInternal(string endpoint, Dictionary<string, string>? queryParams = null)
|
||||
{
|
||||
var url = BuildUrl(endpoint, queryParams);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
|
||||
// Use server's API key for authentication
|
||||
var authHeader = GetAuthorizationHeader();
|
||||
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", authHeader);
|
||||
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
var statusCode = (int)response.StatusCode;
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("Jellyfin internal request returned {StatusCode} for {Url}: {Content}",
|
||||
statusCode, url, content);
|
||||
return (null, statusCode);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var jsonDocument = JsonDocument.Parse(content);
|
||||
return (jsonDocument, statusCode);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse JSON response from {Url}: {Content}", url, content);
|
||||
return (null, statusCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ public class JellyfinResponseBuilder
|
||||
["Type"] = "Audio",
|
||||
["Album"] = song.Album,
|
||||
["AlbumArtist"] = song.Artist,
|
||||
["Artists"] = new[] { song.Artist },
|
||||
["Artists"] = song.Artists.Count > 0 ? song.Artists.ToArray() : new[] { song.Artist },
|
||||
["RunTimeTicks"] = (song.Duration ?? 0) * TimeSpan.TicksPerSecond,
|
||||
["ImageTags"] = new Dictionary<string, string>
|
||||
{
|
||||
@@ -231,47 +231,113 @@ public class JellyfinResponseBuilder
|
||||
/// </summary>
|
||||
public Dictionary<string, object?> ConvertSongToJellyfinItem(Song song)
|
||||
{
|
||||
// Add " [S]" suffix to external song titles (S = streaming source)
|
||||
var songTitle = song.Title;
|
||||
var artistName = song.Artist;
|
||||
var albumName = song.Album;
|
||||
var artistNames = song.Artists.ToList();
|
||||
|
||||
if (!song.IsLocal)
|
||||
{
|
||||
songTitle = $"{song.Title} [S]";
|
||||
|
||||
// Also add [S] to artist and album names for consistency
|
||||
if (!string.IsNullOrEmpty(artistName) && !artistName.EndsWith(" [S]"))
|
||||
{
|
||||
artistName = $"{artistName} [S]";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(albumName) && !albumName.EndsWith(" [S]"))
|
||||
{
|
||||
albumName = $"{albumName} [S]";
|
||||
}
|
||||
|
||||
// Add [S] to all artist names in the list
|
||||
artistNames = artistNames.Select(a =>
|
||||
!string.IsNullOrEmpty(a) && !a.EndsWith(" [S]") ? $"{a} [S]" : a
|
||||
).ToList();
|
||||
}
|
||||
|
||||
var item = new Dictionary<string, object?>
|
||||
{
|
||||
["Id"] = song.Id,
|
||||
["Name"] = song.Title,
|
||||
["Name"] = songTitle,
|
||||
["ServerId"] = "allstarr",
|
||||
["Type"] = "Audio",
|
||||
["MediaType"] = "Audio",
|
||||
["IsFolder"] = false,
|
||||
["Album"] = song.Album,
|
||||
["AlbumId"] = song.AlbumId ?? song.Id,
|
||||
["AlbumArtist"] = song.AlbumArtist ?? song.Artist,
|
||||
["Artists"] = new[] { song.Artist },
|
||||
["ArtistItems"] = new[]
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["Id"] = song.ArtistId ?? song.Id,
|
||||
["Name"] = song.Artist
|
||||
}
|
||||
},
|
||||
["Id"] = song.Id,
|
||||
["HasLyrics"] = false, // Could be enhanced to check if lyrics exist
|
||||
["Container"] = "flac",
|
||||
["PremiereDate"] = song.Year.HasValue ? $"{song.Year}-01-01T00:00:00.0000000Z" : null,
|
||||
["RunTimeTicks"] = (song.Duration ?? 0) * TimeSpan.TicksPerSecond,
|
||||
["ProductionYear"] = song.Year,
|
||||
["IndexNumber"] = song.Track,
|
||||
["ParentIndexNumber"] = song.DiscNumber ?? 1,
|
||||
["ProductionYear"] = song.Year,
|
||||
["RunTimeTicks"] = (song.Duration ?? 0) * TimeSpan.TicksPerSecond,
|
||||
["ImageTags"] = new Dictionary<string, string>
|
||||
{
|
||||
["Primary"] = song.Id
|
||||
},
|
||||
["BackdropImageTags"] = new string[0],
|
||||
["ImageBlurHashes"] = new Dictionary<string, object>(),
|
||||
["LocationType"] = "FileSystem", // External content appears as local files to clients
|
||||
["Path"] = $"/music/{song.Artist}/{song.Album}/{song.Title}.flac", // Fake path for client compatibility
|
||||
["ChannelId"] = (object?)null, // Match Jellyfin structure
|
||||
["IsFolder"] = false,
|
||||
["Type"] = "Audio",
|
||||
["ChannelId"] = (object?)null,
|
||||
["Genres"] = !string.IsNullOrEmpty(song.Genre)
|
||||
? new[] { song.Genre }
|
||||
: new string[0],
|
||||
["GenreItems"] = !string.IsNullOrEmpty(song.Genre)
|
||||
? new[]
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["Name"] = song.Genre,
|
||||
["Id"] = $"genre-{song.Genre?.ToLowerInvariant()}"
|
||||
}
|
||||
}
|
||||
: new Dictionary<string, object?>[0],
|
||||
["ParentLogoItemId"] = song.AlbumId,
|
||||
["ParentBackdropItemId"] = song.AlbumId,
|
||||
["ParentBackdropImageTags"] = new string[0],
|
||||
["UserData"] = new Dictionary<string, object>
|
||||
{
|
||||
["PlaybackPositionTicks"] = 0,
|
||||
["PlayCount"] = 0,
|
||||
["IsFavorite"] = false,
|
||||
["Played"] = false,
|
||||
["Key"] = $"Audio-{song.Id}"
|
||||
["Key"] = $"Audio-{song.Id}",
|
||||
["ItemId"] = song.Id
|
||||
},
|
||||
["Artists"] = artistNames.Count > 0 ? artistNames.ToArray() : new[] { artistName ?? "" },
|
||||
["ArtistItems"] = artistNames.Count > 0
|
||||
? artistNames.Select((name, index) => new Dictionary<string, object?>
|
||||
{
|
||||
["Name"] = name,
|
||||
["Id"] = index == 0 && song.ArtistId != null
|
||||
? song.ArtistId
|
||||
: $"{song.Id}-artist-{index}"
|
||||
}).ToArray()
|
||||
: new[]
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["Id"] = song.ArtistId ?? song.Id,
|
||||
["Name"] = artistName ?? ""
|
||||
}
|
||||
},
|
||||
["Album"] = albumName,
|
||||
["AlbumId"] = song.AlbumId ?? song.Id,
|
||||
["AlbumPrimaryImageTag"] = song.AlbumId ?? song.Id,
|
||||
["AlbumArtist"] = song.AlbumArtist ?? artistName,
|
||||
["AlbumArtists"] = new[]
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["Name"] = song.AlbumArtist ?? artistName ?? "",
|
||||
["Id"] = song.ArtistId ?? song.Id
|
||||
}
|
||||
},
|
||||
["ImageTags"] = new Dictionary<string, string>
|
||||
{
|
||||
["Primary"] = song.Id
|
||||
},
|
||||
["BackdropImageTags"] = new string[0],
|
||||
["ParentLogoImageTag"] = song.AlbumId ?? song.Id,
|
||||
["ImageBlurHashes"] = new Dictionary<string, object>(),
|
||||
["LocationType"] = "FileSystem",
|
||||
["MediaType"] = "Audio",
|
||||
["NormalizationGain"] = 0.0,
|
||||
["Path"] = $"/music/{song.Artist}/{song.Album}/{song.Title}.flac",
|
||||
["CanDownload"] = true,
|
||||
["SupportsSync"] = true
|
||||
};
|
||||
@@ -289,11 +355,79 @@ public class JellyfinResponseBuilder
|
||||
var providerIds = (Dictionary<string, string>)item["ProviderIds"]!;
|
||||
providerIds["ISRC"] = song.Isrc;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(song.Genre))
|
||||
// Add MediaSources with complete structure matching real Jellyfin
|
||||
item["MediaSources"] = new[]
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["Protocol"] = "File",
|
||||
["Id"] = song.Id,
|
||||
["Path"] = $"/music/{song.Artist}/{song.Album}/{song.Title}.flac",
|
||||
["Type"] = "Default",
|
||||
["Container"] = "flac",
|
||||
["Size"] = (song.Duration ?? 180) * 1337 * 128,
|
||||
["Name"] = song.Title,
|
||||
["IsRemote"] = false,
|
||||
["ETag"] = song.Id, // Use song ID as ETag
|
||||
["RunTimeTicks"] = (song.Duration ?? 180) * 10000000L,
|
||||
["ReadAtNativeFramerate"] = false,
|
||||
["IgnoreDts"] = false,
|
||||
["IgnoreIndex"] = false,
|
||||
["GenPtsInput"] = false,
|
||||
["SupportsTranscoding"] = true,
|
||||
["SupportsDirectStream"] = true,
|
||||
["SupportsDirectPlay"] = true,
|
||||
["IsInfiniteStream"] = false,
|
||||
["UseMostCompatibleTranscodingProfile"] = false,
|
||||
["RequiresOpening"] = false,
|
||||
["RequiresClosing"] = false,
|
||||
["RequiresLooping"] = false,
|
||||
["SupportsProbing"] = true,
|
||||
["MediaStreams"] = new[]
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["Codec"] = "flac",
|
||||
["TimeBase"] = "1/44100",
|
||||
["VideoRange"] = "Unknown",
|
||||
["VideoRangeType"] = "Unknown",
|
||||
["AudioSpatialFormat"] = "None",
|
||||
["LocalizedDefault"] = "Default",
|
||||
["LocalizedExternal"] = "External",
|
||||
["DisplayTitle"] = "FLAC - Stereo",
|
||||
["IsInterlaced"] = false,
|
||||
["IsAVC"] = false,
|
||||
["ChannelLayout"] = "stereo",
|
||||
["BitRate"] = 1337000,
|
||||
["BitDepth"] = 16,
|
||||
["Channels"] = 2,
|
||||
["SampleRate"] = 44100,
|
||||
["IsDefault"] = false,
|
||||
["IsForced"] = false,
|
||||
["IsHearingImpaired"] = false,
|
||||
["Type"] = "Audio",
|
||||
["Index"] = 0,
|
||||
["IsExternal"] = false,
|
||||
["IsTextSubtitleStream"] = false,
|
||||
["SupportsExternalStream"] = false,
|
||||
["Level"] = 0
|
||||
}
|
||||
},
|
||||
["MediaAttachments"] = new List<object>(),
|
||||
["Formats"] = new List<string>(),
|
||||
["Bitrate"] = 1337000,
|
||||
["RequiredHttpHeaders"] = new Dictionary<string, string>(),
|
||||
["TranscodingSubProtocol"] = "http",
|
||||
["DefaultAudioStreamIndex"] = 0,
|
||||
["HasSegments"] = false
|
||||
}
|
||||
};
|
||||
}
|
||||
else if (song.IsLocal && song.JellyfinMetadata != null && song.JellyfinMetadata.ContainsKey("MediaSources"))
|
||||
{
|
||||
item["Genres"] = new[] { song.Genre };
|
||||
// Use preserved Jellyfin metadata for local tracks to maintain bitrate info
|
||||
item["MediaSources"] = song.JellyfinMetadata["MediaSources"];
|
||||
}
|
||||
|
||||
return item;
|
||||
@@ -304,49 +438,77 @@ 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 = streaming source)
|
||||
var albumName = album.Title;
|
||||
if (!album.IsLocal)
|
||||
{
|
||||
albumName = $"{album.Title} - SW";
|
||||
albumName = $"{album.Title} [S]";
|
||||
}
|
||||
|
||||
var item = new Dictionary<string, object?>
|
||||
{
|
||||
["Id"] = album.Id,
|
||||
["Name"] = albumName,
|
||||
["ServerId"] = "allstarr",
|
||||
["Type"] = "MusicAlbum",
|
||||
["IsFolder"] = true,
|
||||
["AlbumArtist"] = album.Artist,
|
||||
["AlbumArtists"] = new[]
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["Id"] = album.ArtistId ?? album.Id,
|
||||
["Name"] = album.Artist
|
||||
}
|
||||
},
|
||||
["ProductionYear"] = album.Year,
|
||||
["ChildCount"] = album.SongCount ?? album.Songs.Count,
|
||||
["ImageTags"] = new Dictionary<string, string>
|
||||
{
|
||||
["Primary"] = album.Id
|
||||
},
|
||||
["BackdropImageTags"] = new string[0],
|
||||
["ImageBlurHashes"] = new Dictionary<string, object>(),
|
||||
["LocationType"] = "FileSystem",
|
||||
["MediaType"] = (object?)null,
|
||||
["Id"] = album.Id,
|
||||
["PremiereDate"] = album.Year.HasValue ? $"{album.Year}-01-01T05:00:00.0000000Z" : null,
|
||||
["ChannelId"] = (object?)null,
|
||||
["CollectionType"] = (object?)null,
|
||||
["Genres"] = !string.IsNullOrEmpty(album.Genre)
|
||||
? new[] { album.Genre }
|
||||
: new string[0],
|
||||
["RunTimeTicks"] = 0, // Could calculate from songs
|
||||
["ProductionYear"] = album.Year,
|
||||
["IsFolder"] = true,
|
||||
["Type"] = "MusicAlbum",
|
||||
["GenreItems"] = !string.IsNullOrEmpty(album.Genre)
|
||||
? new[]
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["Name"] = album.Genre,
|
||||
["Id"] = $"genre-{album.Genre?.ToLowerInvariant()}"
|
||||
}
|
||||
}
|
||||
: new Dictionary<string, object?>[0],
|
||||
["ParentLogoItemId"] = album.ArtistId ?? album.Id,
|
||||
["ParentBackdropItemId"] = album.ArtistId ?? album.Id,
|
||||
["ParentBackdropImageTags"] = new string[0],
|
||||
["UserData"] = new Dictionary<string, object>
|
||||
{
|
||||
["PlaybackPositionTicks"] = 0,
|
||||
["PlayCount"] = 0,
|
||||
["IsFavorite"] = false,
|
||||
["Played"] = false,
|
||||
["Key"] = album.Id
|
||||
}
|
||||
["Key"] = $"{album.Artist}-{album.Title}",
|
||||
["ItemId"] = album.Id
|
||||
},
|
||||
["Artists"] = new[] { album.Artist },
|
||||
["ArtistItems"] = new[]
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["Name"] = album.Artist,
|
||||
["Id"] = album.ArtistId ?? album.Id
|
||||
}
|
||||
},
|
||||
["AlbumArtist"] = album.Artist,
|
||||
["AlbumArtists"] = new[]
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["Name"] = album.Artist,
|
||||
["Id"] = album.ArtistId ?? album.Id
|
||||
}
|
||||
},
|
||||
["ImageTags"] = new Dictionary<string, string>
|
||||
{
|
||||
["Primary"] = album.Id
|
||||
},
|
||||
["BackdropImageTags"] = new string[0],
|
||||
["ParentLogoImageTag"] = album.ArtistId ?? album.Id,
|
||||
["ImageBlurHashes"] = new Dictionary<string, object>(),
|
||||
["LocationType"] = "FileSystem",
|
||||
["MediaType"] = "Unknown",
|
||||
["ChildCount"] = album.SongCount ?? album.Songs.Count
|
||||
};
|
||||
|
||||
// Add provider IDs for external content
|
||||
@@ -358,11 +520,6 @@ public class JellyfinResponseBuilder
|
||||
};
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(album.Genre))
|
||||
{
|
||||
item["Genres"] = new[] { album.Genre };
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
@@ -371,39 +528,42 @@ 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 = streaming source)
|
||||
var artistName = artist.Name;
|
||||
if (!artist.IsLocal)
|
||||
{
|
||||
artistName = $"{artist.Name} - SW";
|
||||
artistName = $"{artist.Name} [S]";
|
||||
}
|
||||
|
||||
var item = new Dictionary<string, object?>
|
||||
{
|
||||
["Id"] = artist.Id,
|
||||
["Name"] = artistName,
|
||||
["ServerId"] = "allstarr",
|
||||
["Type"] = "MusicArtist",
|
||||
["Id"] = artist.Id,
|
||||
["ChannelId"] = (object?)null,
|
||||
["Genres"] = new string[0], // Artists aggregate genres from albums/tracks
|
||||
["RunTimeTicks"] = 0,
|
||||
["IsFolder"] = true,
|
||||
["AlbumCount"] = artist.AlbumCount ?? 0,
|
||||
["ImageTags"] = new Dictionary<string, string>
|
||||
{
|
||||
["Primary"] = artist.Id
|
||||
},
|
||||
["BackdropImageTags"] = new string[0],
|
||||
["ImageBlurHashes"] = new Dictionary<string, object>(),
|
||||
["LocationType"] = "FileSystem", // External content appears as local files to clients
|
||||
["MediaType"] = (object?)null, // Match Jellyfin structure
|
||||
["ChannelId"] = (object?)null, // Match Jellyfin structure
|
||||
["CollectionType"] = (object?)null, // Match Jellyfin structure
|
||||
["Type"] = "MusicArtist",
|
||||
["GenreItems"] = new Dictionary<string, object?>[0],
|
||||
["UserData"] = new Dictionary<string, object>
|
||||
{
|
||||
["PlaybackPositionTicks"] = 0,
|
||||
["PlayCount"] = 0,
|
||||
["IsFavorite"] = false,
|
||||
["Played"] = false,
|
||||
["Key"] = artist.Id
|
||||
}
|
||||
["Key"] = $"Artist-{artist.Name}",
|
||||
["ItemId"] = artist.Id
|
||||
},
|
||||
["ImageTags"] = new Dictionary<string, string>
|
||||
{
|
||||
["Primary"] = artist.Id
|
||||
},
|
||||
["BackdropImageTags"] = new string[0],
|
||||
["ImageBlurHashes"] = new Dictionary<string, object>(),
|
||||
["LocationType"] = "FileSystem",
|
||||
["MediaType"] = "Unknown",
|
||||
["AlbumCount"] = artist.AlbumCount ?? 0
|
||||
};
|
||||
|
||||
// Add provider IDs for external content
|
||||
@@ -440,7 +600,7 @@ public class JellyfinResponseBuilder
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an ExternalPlaylist to a Jellyfin album item.
|
||||
/// Converts an ExternalPlaylist to a Jellyfin playlist item.
|
||||
/// </summary>
|
||||
public Dictionary<string, object?> ConvertPlaylistToJellyfinItem(ExternalPlaylist playlist)
|
||||
{
|
||||
@@ -450,13 +610,24 @@ public class JellyfinResponseBuilder
|
||||
|
||||
var item = new Dictionary<string, object?>
|
||||
{
|
||||
["Id"] = playlist.Id,
|
||||
["Name"] = playlist.Name,
|
||||
["ServerId"] = "allstarr",
|
||||
["Type"] = "Playlist",
|
||||
["Id"] = playlist.Id,
|
||||
["ChannelId"] = (object?)null,
|
||||
["Genres"] = new string[0], // Playlists aggregate genres from tracks
|
||||
["RunTimeTicks"] = playlist.Duration * TimeSpan.TicksPerSecond,
|
||||
["IsFolder"] = true,
|
||||
["AlbumArtist"] = curatorName,
|
||||
["Genres"] = new[] { "Playlist" },
|
||||
["Type"] = "Playlist",
|
||||
["GenreItems"] = new Dictionary<string, object?>[0],
|
||||
["UserData"] = new Dictionary<string, object>
|
||||
{
|
||||
["PlaybackPositionTicks"] = 0,
|
||||
["PlayCount"] = 0,
|
||||
["IsFavorite"] = false,
|
||||
["Played"] = false,
|
||||
["Key"] = playlist.Id,
|
||||
["ItemId"] = playlist.Id
|
||||
},
|
||||
["ChildCount"] = playlist.TrackCount,
|
||||
["ImageTags"] = new Dictionary<string, string>
|
||||
{
|
||||
@@ -465,20 +636,10 @@ public class JellyfinResponseBuilder
|
||||
["BackdropImageTags"] = new string[0],
|
||||
["ImageBlurHashes"] = new Dictionary<string, object>(),
|
||||
["LocationType"] = "FileSystem",
|
||||
["MediaType"] = (object?)null,
|
||||
["ChannelId"] = (object?)null,
|
||||
["CollectionType"] = (object?)null,
|
||||
["MediaType"] = "Audio",
|
||||
["ProviderIds"] = new Dictionary<string, string>
|
||||
{
|
||||
[playlist.Provider] = playlist.ExternalId
|
||||
},
|
||||
["UserData"] = new Dictionary<string, object>
|
||||
{
|
||||
["PlaybackPositionTicks"] = 0,
|
||||
["PlayCount"] = 0,
|
||||
["IsFavorite"] = false,
|
||||
["Played"] = false,
|
||||
["Key"] = playlist.Id
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
552
allstarr/Services/Jellyfin/JellyfinSessionManager.cs
Normal file
552
allstarr/Services/Jellyfin/JellyfinSessionManager.cs
Normal file
@@ -0,0 +1,552 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Models.Settings;
|
||||
|
||||
namespace allstarr.Services.Jellyfin;
|
||||
|
||||
/// <summary>
|
||||
/// Manages Jellyfin sessions for connected clients.
|
||||
/// Creates sessions on first playback and keeps them alive with periodic pings.
|
||||
/// Also maintains server-side WebSocket connections to Jellyfin on behalf of clients.
|
||||
/// </summary>
|
||||
public class JellyfinSessionManager : IDisposable
|
||||
{
|
||||
private readonly JellyfinProxyService _proxyService;
|
||||
private readonly JellyfinSettings _settings;
|
||||
private readonly ILogger<JellyfinSessionManager> _logger;
|
||||
private readonly ConcurrentDictionary<string, SessionInfo> _sessions = new();
|
||||
private readonly Timer _keepAliveTimer;
|
||||
|
||||
public JellyfinSessionManager(
|
||||
JellyfinProxyService proxyService,
|
||||
IOptions<JellyfinSettings> settings,
|
||||
ILogger<JellyfinSessionManager> logger)
|
||||
{
|
||||
_proxyService = proxyService;
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
|
||||
// Keep sessions alive every 10 seconds (Jellyfin considers sessions stale after ~15 seconds of inactivity)
|
||||
_keepAliveTimer = new Timer(KeepSessionsAlive, null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10));
|
||||
|
||||
_logger.LogDebug("🔧 SESSION: JellyfinSessionManager initialized with 10-second keep-alive and WebSocket support");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures a session exists for the given device. Creates one if needed.
|
||||
/// </summary>
|
||||
public async Task<bool> EnsureSessionAsync(string deviceId, string client, string device, string version, IHeaderDictionary headers)
|
||||
{
|
||||
if (string.IsNullOrEmpty(deviceId))
|
||||
{
|
||||
_logger.LogWarning("⚠️ SESSION: Cannot create session - no device ID");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if we already have this session tracked
|
||||
if (_sessions.TryGetValue(deviceId, out var existingSession))
|
||||
{
|
||||
existingSession.LastActivity = DateTime.UtcNow;
|
||||
_logger.LogDebug("✓ SESSION: Session already exists for device {DeviceId}", deviceId);
|
||||
|
||||
// Refresh capabilities to keep session alive
|
||||
await PostCapabilitiesAsync(headers);
|
||||
return true;
|
||||
}
|
||||
|
||||
_logger.LogInformation("🔧 SESSION: Creating new session for device: {DeviceId} ({Client} on {Device})", deviceId, client, device);
|
||||
|
||||
// Log the headers we received for debugging
|
||||
_logger.LogDebug("🔍 SESSION: Headers received for session creation: {Headers}",
|
||||
string.Join(", ", headers.Select(h => $"{h.Key}={h.Value.ToString().Substring(0, Math.Min(30, h.Value.ToString().Length))}...")));
|
||||
|
||||
try
|
||||
{
|
||||
// Post session capabilities to Jellyfin - this creates the session
|
||||
await PostCapabilitiesAsync(headers);
|
||||
|
||||
_logger.LogInformation("✓ SESSION: Session created for {DeviceId}", deviceId);
|
||||
|
||||
// Track this session
|
||||
_sessions[deviceId] = new SessionInfo
|
||||
{
|
||||
DeviceId = deviceId,
|
||||
Client = client,
|
||||
Device = device,
|
||||
Version = version,
|
||||
LastActivity = DateTime.UtcNow,
|
||||
Headers = CloneHeaders(headers)
|
||||
};
|
||||
|
||||
// Start a WebSocket connection to Jellyfin on behalf of this client
|
||||
_ = Task.Run(() => MaintainWebSocketForSessionAsync(deviceId, headers));
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "❌ SESSION: Error creating session for {DeviceId}", deviceId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Posts session capabilities to Jellyfin.
|
||||
/// </summary>
|
||||
private async Task PostCapabilitiesAsync(IHeaderDictionary headers)
|
||||
{
|
||||
var capabilities = new
|
||||
{
|
||||
PlayableMediaTypes = new[] { "Audio" },
|
||||
SupportedCommands = new[]
|
||||
{
|
||||
"Play",
|
||||
"Playstate",
|
||||
"PlayNext"
|
||||
},
|
||||
SupportsMediaControl = true,
|
||||
SupportsPersistentIdentifier = true,
|
||||
SupportsSync = false
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(capabilities);
|
||||
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Capabilities/Full", json, headers);
|
||||
|
||||
if (statusCode == 204 || statusCode == 200)
|
||||
{
|
||||
_logger.LogDebug("✓ SESSION: Posted capabilities successfully ({StatusCode})", statusCode);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 401 is common when cached headers have expired - not a critical error
|
||||
_logger.LogDebug("SESSION: Capabilities post returned {StatusCode} (may be expected if token expired)", statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates session activity timestamp.
|
||||
/// </summary>
|
||||
public void UpdateActivity(string deviceId)
|
||||
{
|
||||
if (_sessions.TryGetValue(deviceId, out var session))
|
||||
{
|
||||
session.LastActivity = DateTime.UtcNow;
|
||||
_logger.LogDebug("🔄 SESSION: Updated activity for {DeviceId}", deviceId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("⚠️ SESSION: Cannot update activity - device {DeviceId} not found", deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the currently playing item for a session (for scrobbling on cleanup).
|
||||
/// </summary>
|
||||
public void UpdatePlayingItem(string deviceId, string? itemId, long? positionTicks)
|
||||
{
|
||||
if (_sessions.TryGetValue(deviceId, out var session))
|
||||
{
|
||||
session.LastPlayingItemId = itemId;
|
||||
session.LastPlayingPositionTicks = positionTicks;
|
||||
session.LastActivity = DateTime.UtcNow;
|
||||
_logger.LogDebug("🎵 SESSION: Updated playing item for {DeviceId}: {ItemId} at {Position}",
|
||||
deviceId, itemId, positionTicks);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a session as potentially ended (e.g., after playback stops).
|
||||
/// The session will be cleaned up if no new activity occurs within the timeout.
|
||||
/// </summary>
|
||||
public void MarkSessionPotentiallyEnded(string deviceId, TimeSpan timeout)
|
||||
{
|
||||
if (_sessions.TryGetValue(deviceId, out var session))
|
||||
{
|
||||
_logger.LogDebug("⏰ SESSION: Marking session {DeviceId} as potentially ended, will cleanup in {Seconds}s if no activity",
|
||||
deviceId, timeout.TotalSeconds);
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
var markedTime = DateTime.UtcNow;
|
||||
await Task.Delay(timeout);
|
||||
|
||||
// Check if there's been activity since we marked it
|
||||
if (_sessions.TryGetValue(deviceId, out var currentSession) &&
|
||||
currentSession.LastActivity <= markedTime)
|
||||
{
|
||||
_logger.LogInformation("🧹 SESSION: Auto-removing inactive session {DeviceId} after playback stop", deviceId);
|
||||
await RemoveSessionAsync(deviceId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("✓ SESSION: Session {DeviceId} had activity, keeping alive", deviceId);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets information about current active sessions for debugging.
|
||||
/// </summary>
|
||||
public object GetSessionsInfo()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var sessions = _sessions.Values.Select(s => new
|
||||
{
|
||||
DeviceId = s.DeviceId,
|
||||
Client = s.Client,
|
||||
Device = s.Device,
|
||||
Version = s.Version,
|
||||
LastActivity = s.LastActivity,
|
||||
InactiveMinutes = Math.Round((now - s.LastActivity).TotalMinutes, 1),
|
||||
HasWebSocket = s.WebSocket != null,
|
||||
WebSocketState = s.WebSocket?.State.ToString() ?? "None"
|
||||
}).ToList();
|
||||
|
||||
return new
|
||||
{
|
||||
TotalSessions = sessions.Count,
|
||||
ActiveSessions = sessions.Count(s => s.InactiveMinutes < 2),
|
||||
StaleSessions = sessions.Count(s => s.InactiveMinutes >= 2),
|
||||
Sessions = sessions.OrderBy(s => s.InactiveMinutes)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a session when the client disconnects.
|
||||
/// </summary>
|
||||
public async Task RemoveSessionAsync(string deviceId)
|
||||
{
|
||||
if (_sessions.TryRemove(deviceId, out var session))
|
||||
{
|
||||
_logger.LogInformation("🗑️ SESSION: Removing session for device {DeviceId}", deviceId);
|
||||
|
||||
// Close WebSocket if it exists
|
||||
if (session.WebSocket != null && session.WebSocket.State == WebSocketState.Open)
|
||||
{
|
||||
try
|
||||
{
|
||||
await session.WebSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Session ended", CancellationToken.None);
|
||||
_logger.LogDebug("🔌 WEBSOCKET: Closed WebSocket for device {DeviceId}", deviceId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "⚠️ WEBSOCKET: Error closing WebSocket for {DeviceId}", deviceId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
session.WebSocket?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Report playback stopped to Jellyfin if we have a playing item (for scrobbling)
|
||||
if (!string.IsNullOrEmpty(session.LastPlayingItemId))
|
||||
{
|
||||
var stopPayload = new
|
||||
{
|
||||
ItemId = session.LastPlayingItemId,
|
||||
PositionTicks = session.LastPlayingPositionTicks ?? 0
|
||||
};
|
||||
var stopJson = JsonSerializer.Serialize(stopPayload);
|
||||
await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", stopJson, session.Headers);
|
||||
_logger.LogInformation("🛑 SESSION: Reported playback stopped for {DeviceId} (ItemId: {ItemId}, Position: {Position})",
|
||||
deviceId, session.LastPlayingItemId, session.LastPlayingPositionTicks);
|
||||
}
|
||||
|
||||
// Notify Jellyfin that the session is ending
|
||||
await _proxyService.PostJsonAsync("Sessions/Logout", "{}", session.Headers);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning("⚠️ SESSION: Error removing session for {DeviceId}: {Message}", deviceId, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maintains a WebSocket connection to Jellyfin on behalf of a client session.
|
||||
/// This allows the session to appear in Jellyfin's dashboard.
|
||||
/// </summary>
|
||||
private async Task MaintainWebSocketForSessionAsync(string deviceId, IHeaderDictionary headers)
|
||||
{
|
||||
if (!_sessions.TryGetValue(deviceId, out var session))
|
||||
{
|
||||
_logger.LogDebug("⚠️ WEBSOCKET: Cannot create WebSocket - session {DeviceId} not found", deviceId);
|
||||
return;
|
||||
}
|
||||
|
||||
ClientWebSocket? webSocket = null;
|
||||
|
||||
try
|
||||
{
|
||||
// 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";
|
||||
|
||||
// IMPORTANT: Do NOT add api_key to URL - we want to authenticate as the CLIENT, not the server
|
||||
// The client's token is passed via X-Emby-Authorization header
|
||||
// Using api_key would create a session for the server/admin, not the actual user's client
|
||||
|
||||
webSocket = new ClientWebSocket();
|
||||
session.WebSocket = webSocket;
|
||||
|
||||
// Log available headers for debugging
|
||||
_logger.LogDebug("🔍 WEBSOCKET: Available headers for {DeviceId}: {Headers}",
|
||||
deviceId, string.Join(", ", headers.Keys));
|
||||
|
||||
// Forward authentication headers from the CLIENT - this is critical for session to appear under the right user
|
||||
bool authFound = false;
|
||||
if (headers.TryGetValue("X-Emby-Authorization", out var embyAuth))
|
||||
{
|
||||
webSocket.Options.SetRequestHeader("X-Emby-Authorization", embyAuth.ToString());
|
||||
_logger.LogDebug("🔑 WEBSOCKET: Using X-Emby-Authorization for {DeviceId}: {Auth}",
|
||||
deviceId, embyAuth.ToString().Length > 50 ? embyAuth.ToString()[..50] + "..." : embyAuth.ToString());
|
||||
authFound = true;
|
||||
}
|
||||
else if (headers.TryGetValue("Authorization", out var auth))
|
||||
{
|
||||
var authValue = auth.ToString();
|
||||
if (authValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
webSocket.Options.SetRequestHeader("X-Emby-Authorization", authValue);
|
||||
_logger.LogDebug("🔑 WEBSOCKET: Converted Authorization to X-Emby-Authorization for {DeviceId}: {Auth}",
|
||||
deviceId, authValue.Length > 50 ? authValue[..50] + "..." : authValue);
|
||||
authFound = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
webSocket.Options.SetRequestHeader("Authorization", authValue);
|
||||
_logger.LogDebug("🔑 WEBSOCKET: Using Authorization for {DeviceId}: {Auth}",
|
||||
deviceId, authValue.Length > 50 ? authValue[..50] + "..." : authValue);
|
||||
authFound = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!authFound)
|
||||
{
|
||||
// No client auth found - fall back to server API key as last resort
|
||||
if (!string.IsNullOrEmpty(_settings.ApiKey))
|
||||
{
|
||||
jellyfinWsUrl += $"?api_key={_settings.ApiKey}";
|
||||
_logger.LogWarning("⚠️ WEBSOCKET: No client auth found in headers, falling back to server API key for {DeviceId}", deviceId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("❌ WEBSOCKET: No authentication available for {DeviceId}!", deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("🔗 WEBSOCKET: Connecting to Jellyfin for device {DeviceId}: {Url}", deviceId, jellyfinWsUrl);
|
||||
|
||||
// Set user agent
|
||||
webSocket.Options.SetRequestHeader("User-Agent", $"Allstarr-Proxy/{session.Client}");
|
||||
|
||||
// Connect to Jellyfin
|
||||
await webSocket.ConnectAsync(new Uri(jellyfinWsUrl), CancellationToken.None);
|
||||
_logger.LogInformation("✓ WEBSOCKET: Connected to Jellyfin for device {DeviceId}", deviceId);
|
||||
|
||||
// CRITICAL: Send ForceKeepAlive message to initialize session in Jellyfin
|
||||
// This tells Jellyfin to create/show the session in the dashboard
|
||||
// Without this message, the WebSocket is connected but no session appears
|
||||
var forceKeepAliveMessage = "{\"MessageType\":\"ForceKeepAlive\",\"Data\":100}";
|
||||
var messageBytes = Encoding.UTF8.GetBytes(forceKeepAliveMessage);
|
||||
await webSocket.SendAsync(new ArraySegment<byte>(messageBytes), WebSocketMessageType.Text, true, CancellationToken.None);
|
||||
_logger.LogDebug("📤 WEBSOCKET: Sent ForceKeepAlive to initialize session for {DeviceId}", deviceId);
|
||||
|
||||
// Also send SessionsStart to subscribe to session updates
|
||||
var sessionsStartMessage = "{\"MessageType\":\"SessionsStart\",\"Data\":\"0,1500\"}";
|
||||
messageBytes = Encoding.UTF8.GetBytes(sessionsStartMessage);
|
||||
await webSocket.SendAsync(new ArraySegment<byte>(messageBytes), WebSocketMessageType.Text, true, CancellationToken.None);
|
||||
_logger.LogDebug("📤 WEBSOCKET: Sent SessionsStart for {DeviceId}", deviceId);
|
||||
|
||||
// Keep the WebSocket alive by reading messages and sending periodic keep-alive
|
||||
var buffer = new byte[1024 * 4];
|
||||
var lastKeepAlive = DateTime.UtcNow;
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
while (webSocket.State == WebSocketState.Open && _sessions.ContainsKey(deviceId))
|
||||
{
|
||||
try
|
||||
{
|
||||
// Use a timeout so we can send keep-alive messages periodically
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token);
|
||||
timeoutCts.CancelAfter(TimeSpan.FromSeconds(30));
|
||||
|
||||
try
|
||||
{
|
||||
var result = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), timeoutCts.Token);
|
||||
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
_logger.LogDebug("🔌 WEBSOCKET: Jellyfin closed WebSocket for device {DeviceId}", deviceId);
|
||||
break;
|
||||
}
|
||||
|
||||
// Log received messages for debugging (only non-routine messages)
|
||||
if (result.MessageType == WebSocketMessageType.Text)
|
||||
{
|
||||
var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
|
||||
|
||||
// Respond to KeepAlive requests from Jellyfin
|
||||
if (message.Contains("\"MessageType\":\"KeepAlive\""))
|
||||
{
|
||||
_logger.LogDebug("💓 WEBSOCKET: Received KeepAlive from Jellyfin for {DeviceId}", deviceId);
|
||||
}
|
||||
else if (message.Contains("\"MessageType\":\"Sessions\""))
|
||||
{
|
||||
// Session updates are routine, log at debug level
|
||||
_logger.LogDebug("📥 WEBSOCKET: Session update for {DeviceId}", deviceId);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Log other message types at info level
|
||||
_logger.LogInformation("📥 WEBSOCKET: {DeviceId}: {Message}",
|
||||
deviceId, message.Length > 100 ? message[..100] + "..." : message);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (!cts.IsCancellationRequested)
|
||||
{
|
||||
// Timeout - this is expected, send keep-alive if needed
|
||||
}
|
||||
|
||||
// Send periodic keep-alive every 30 seconds
|
||||
if (DateTime.UtcNow - lastKeepAlive > TimeSpan.FromSeconds(30))
|
||||
{
|
||||
var keepAliveMsg = "{\"MessageType\":\"KeepAlive\"}";
|
||||
var keepAliveBytes = Encoding.UTF8.GetBytes(keepAliveMsg);
|
||||
await webSocket.SendAsync(new ArraySegment<byte>(keepAliveBytes), WebSocketMessageType.Text, true, CancellationToken.None);
|
||||
_logger.LogDebug("💓 WEBSOCKET: Sent KeepAlive for {DeviceId}", deviceId);
|
||||
lastKeepAlive = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
catch (WebSocketException wsEx)
|
||||
{
|
||||
_logger.LogWarning(wsEx, "⚠️ WEBSOCKET: WebSocket error for device {DeviceId}", deviceId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "❌ WEBSOCKET: Failed to maintain WebSocket for device {DeviceId}", deviceId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (webSocket != null)
|
||||
{
|
||||
if (webSocket.State == WebSocketState.Open)
|
||||
{
|
||||
try
|
||||
{
|
||||
await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Session ended", CancellationToken.None);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
webSocket.Dispose();
|
||||
_logger.LogDebug("🧹 WEBSOCKET: Cleaned up WebSocket for device {DeviceId}", deviceId);
|
||||
}
|
||||
|
||||
// Clear WebSocket reference from session
|
||||
if (_sessions.TryGetValue(deviceId, out var sess))
|
||||
{
|
||||
sess.WebSocket = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Periodically pings Jellyfin to keep sessions alive.
|
||||
/// Note: This is a backup mechanism. The WebSocket connection is the primary keep-alive.
|
||||
/// </summary>
|
||||
private async void KeepSessionsAlive(object? state)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var activeSessions = _sessions.Values.Where(s => now - s.LastActivity < TimeSpan.FromMinutes(5)).ToList();
|
||||
|
||||
if (activeSessions.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("💓 SESSION: Keeping {Count} sessions alive", activeSessions.Count);
|
||||
|
||||
foreach (var session in activeSessions)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Post capabilities again to keep session alive
|
||||
// Note: This may fail with 401 if the client's token has expired
|
||||
// That's okay - the WebSocket connection keeps the session alive anyway
|
||||
await PostCapabilitiesAsync(session.Headers);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "SESSION: Error keeping session alive for {DeviceId} (WebSocket still active)", session.DeviceId);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up stale sessions after 3 minutes of inactivity
|
||||
// This balances cleaning up finished sessions with allowing brief pauses/network issues
|
||||
var staleSessions = _sessions.Where(kvp => now - kvp.Value.LastActivity > TimeSpan.FromMinutes(3)).ToList();
|
||||
foreach (var stale in staleSessions)
|
||||
{
|
||||
_logger.LogInformation("🧹 SESSION: Removing stale session for {DeviceId} (inactive for {Minutes:F1} minutes)",
|
||||
stale.Key, (now - stale.Value.LastActivity).TotalMinutes);
|
||||
await RemoveSessionAsync(stale.Key);
|
||||
}
|
||||
}
|
||||
|
||||
private static IHeaderDictionary CloneHeaders(IHeaderDictionary headers)
|
||||
{
|
||||
var cloned = new HeaderDictionary();
|
||||
foreach (var header in headers)
|
||||
{
|
||||
cloned[header.Key] = header.Value;
|
||||
}
|
||||
return cloned;
|
||||
}
|
||||
|
||||
private class SessionInfo
|
||||
{
|
||||
public required string DeviceId { get; init; }
|
||||
public required string Client { get; init; }
|
||||
public required string Device { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public DateTime LastActivity { get; set; }
|
||||
public required IHeaderDictionary Headers { get; init; }
|
||||
public ClientWebSocket? WebSocket { get; set; }
|
||||
public string? LastPlayingItemId { get; set; }
|
||||
public long? LastPlayingPositionTicks { get; set; }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_keepAliveTimer?.Dispose();
|
||||
|
||||
// Close all WebSocket connections
|
||||
foreach (var session in _sessions.Values)
|
||||
{
|
||||
if (session.WebSocket != null && session.WebSocket.State == WebSocketState.Open)
|
||||
{
|
||||
try
|
||||
{
|
||||
session.WebSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Service stopping", CancellationToken.None).Wait(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
catch { }
|
||||
finally
|
||||
{
|
||||
session.WebSocket?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,8 +25,39 @@ public class LrclibService
|
||||
|
||||
public async Task<LyricsInfo?> GetLyricsAsync(string trackName, string artistName, string albumName, int durationSeconds)
|
||||
{
|
||||
return await GetLyricsAsync(trackName, new[] { artistName }, albumName, durationSeconds);
|
||||
}
|
||||
|
||||
public async Task<LyricsInfo?> GetLyricsAsync(string trackName, string[] artistNames, string albumName, int durationSeconds)
|
||||
{
|
||||
var artistName = string.Join(", ", artistNames);
|
||||
var cacheKey = $"lyrics:{artistName}:{trackName}:{albumName}:{durationSeconds}";
|
||||
|
||||
// FIRST: Check for manual lyrics mapping
|
||||
var manualMappingKey = $"lyrics:manual-map:{artistName}:{trackName}";
|
||||
var manualLyricsIdStr = await _cache.GetStringAsync(manualMappingKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(manualLyricsIdStr) && int.TryParse(manualLyricsIdStr, out var manualLyricsId) && manualLyricsId > 0)
|
||||
{
|
||||
_logger.LogInformation("✓ Manual lyrics mapping found for {Artist} - {Track}: Lyrics ID {Id}",
|
||||
artistName, trackName, manualLyricsId);
|
||||
|
||||
// Fetch lyrics by ID
|
||||
var manualLyrics = await GetLyricsByIdAsync(manualLyricsId);
|
||||
if (manualLyrics != null && !string.IsNullOrEmpty(manualLyrics.PlainLyrics))
|
||||
{
|
||||
// Cache the lyrics using the standard cache key
|
||||
await _cache.SetAsync(cacheKey, manualLyrics.PlainLyrics!);
|
||||
return manualLyrics;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Manual lyrics mapping points to invalid ID {Id} for {Artist} - {Track}",
|
||||
manualLyricsId, artistName, trackName);
|
||||
}
|
||||
}
|
||||
|
||||
// SECOND: Check standard cache
|
||||
var cached = await _cache.GetStringAsync(cacheKey);
|
||||
if (!string.IsNullOrEmpty(cached))
|
||||
{
|
||||
@@ -42,25 +73,112 @@ 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}";
|
||||
// Try searching with all artists joined (space-separated for better matching)
|
||||
var searchArtistName = string.Join(" ", artistNames);
|
||||
|
||||
_logger.LogDebug("Fetching lyrics from LRCLIB: {Url}", url);
|
||||
// First try search API for fuzzy matching (more forgiving)
|
||||
var searchUrl = $"{BaseUrl}/search?" +
|
||||
$"track_name={Uri.EscapeDataString(trackName)}&" +
|
||||
$"artist_name={Uri.EscapeDataString(searchArtistName)}";
|
||||
|
||||
var response = await _httpClient.GetAsync(url);
|
||||
_logger.LogInformation("Searching LRCLIB: {Url} (expecting {ArtistCount} artists)", searchUrl, artistNames.Length);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
var searchResponse = await _httpClient.GetAsync(searchUrl);
|
||||
|
||||
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 ?? "");
|
||||
|
||||
// Count artists in the result
|
||||
var resultArtistCount = CountArtists(result.ArtistName ?? "");
|
||||
var expectedArtistCount = artistNames.Length;
|
||||
|
||||
// Artist matching - check if all our artists are present
|
||||
var artistScore = CalculateArtistSimilarity(artistNames, result.ArtistName ?? "");
|
||||
|
||||
// STRONG bonus for matching artist count (this is critical!)
|
||||
var artistCountBonus = resultArtistCount == expectedArtistCount ? 50.0 : 0.0;
|
||||
|
||||
// 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) ? 15.0 : 0.0;
|
||||
|
||||
// Weighted score: track name important, artist match critical, artist count VERY important
|
||||
var totalScore = (trackScore * 0.3) + (artistScore * 0.3) + (durationScore * 0.15) + artistCountBonus + syncedBonus;
|
||||
|
||||
_logger.LogDebug("Candidate: {Track} by {Artist} ({ArtistCount} artists) - Score: {Score:F1} (track:{TrackScore:F1}, artist:{ArtistScore:F1}, duration:{DurationScore:F1}, countBonus:{CountBonus:F1}, synced:{Synced})",
|
||||
result.TrackName, result.ArtistName, resultArtistCount, totalScore, trackScore, artistScore, durationScore, artistCountBonus, !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 +186,7 @@ public class LrclibService
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = new LyricsInfo
|
||||
var exactResult = new LyricsInfo
|
||||
{
|
||||
Id = lyrics.Id,
|
||||
TrackName = lyrics.TrackName ?? trackName,
|
||||
@@ -80,11 +198,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 +216,91 @@ public class LrclibService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts the number of artists in an artist string (separated by comma, ampersand, or 'e')
|
||||
/// </summary>
|
||||
private static int CountArtists(string artistString)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(artistString))
|
||||
return 0;
|
||||
|
||||
// Split by common separators: comma, ampersand, " e " (Portuguese/Spanish "and")
|
||||
var separators = new[] { ',', '&' };
|
||||
var parts = artistString.Split(separators, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
// Also check for " e " pattern (like "Julia Michaels e Alessia Cara")
|
||||
var count = parts.Length;
|
||||
foreach (var part in parts)
|
||||
{
|
||||
if (part.Contains(" e ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
count += part.Split(new[] { " e " }, StringSplitOptions.RemoveEmptyEntries).Length - 1;
|
||||
}
|
||||
}
|
||||
|
||||
return Math.Max(1, count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates how well the expected artists match the result's artist string
|
||||
/// </summary>
|
||||
private static double CalculateArtistSimilarity(string[] expectedArtists, string resultArtistString)
|
||||
{
|
||||
if (expectedArtists.Length == 0 || string.IsNullOrWhiteSpace(resultArtistString))
|
||||
return 0;
|
||||
|
||||
var resultLower = resultArtistString.ToLowerInvariant();
|
||||
var matchedCount = 0;
|
||||
|
||||
foreach (var artist in expectedArtists)
|
||||
{
|
||||
var artistLower = artist.ToLowerInvariant();
|
||||
|
||||
// Check if this artist appears in the result string
|
||||
if (resultLower.Contains(artistLower))
|
||||
{
|
||||
matchedCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Try token-based matching for partial matches
|
||||
var artistTokens = artistLower.Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
var matchedTokens = artistTokens.Count(token => resultLower.Contains(token));
|
||||
|
||||
// If most tokens match, count it as a partial match
|
||||
if (matchedTokens >= artistTokens.Length * 0.7)
|
||||
{
|
||||
matchedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return percentage of artists matched
|
||||
return (matchedCount * 100.0) / expectedArtists.Length;
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
379
allstarr/Services/Lyrics/LyricsPrefetchService.cs
Normal file
379
allstarr/Services/Lyrics/LyricsPrefetchService.cs
Normal file
@@ -0,0 +1,379 @@
|
||||
using System.Text.Json;
|
||||
using allstarr.Models.Lyrics;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Services.Common;
|
||||
using allstarr.Services.Jellyfin;
|
||||
using allstarr.Services.Spotify;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace allstarr.Services.Lyrics;
|
||||
|
||||
/// <summary>
|
||||
/// Background service that prefetches lyrics for all tracks in injected Spotify playlists.
|
||||
/// Lyrics are cached in Redis and persisted to disk for fast loading on startup.
|
||||
/// </summary>
|
||||
public class LyricsPrefetchService : BackgroundService
|
||||
{
|
||||
private readonly SpotifyImportSettings _spotifySettings;
|
||||
private readonly LrclibService _lrclibService;
|
||||
private readonly SpotifyPlaylistFetcher _playlistFetcher;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<LyricsPrefetchService> _logger;
|
||||
private readonly string _lyricsCacheDir = "/app/cache/lyrics";
|
||||
private const int DelayBetweenRequestsMs = 500; // 500ms = 2 requests/second to be respectful
|
||||
|
||||
public LyricsPrefetchService(
|
||||
IOptions<SpotifyImportSettings> spotifySettings,
|
||||
LrclibService lrclibService,
|
||||
SpotifyPlaylistFetcher playlistFetcher,
|
||||
RedisCacheService cache,
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<LyricsPrefetchService> logger)
|
||||
{
|
||||
_spotifySettings = spotifySettings.Value;
|
||||
_lrclibService = lrclibService;
|
||||
_playlistFetcher = playlistFetcher;
|
||||
_cache = cache;
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("LyricsPrefetchService: Starting up...");
|
||||
|
||||
if (!_spotifySettings.Enabled)
|
||||
{
|
||||
_logger.LogInformation("Spotify playlist injection is DISABLED, lyrics prefetch will not run");
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure cache directory exists
|
||||
Directory.CreateDirectory(_lyricsCacheDir);
|
||||
|
||||
// Wait for playlist fetcher to initialize
|
||||
await Task.Delay(TimeSpan.FromMinutes(3), stoppingToken);
|
||||
|
||||
// Run initial prefetch
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Running initial lyrics prefetch on startup");
|
||||
await PrefetchAllPlaylistLyricsAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error during startup lyrics prefetch");
|
||||
}
|
||||
|
||||
// Run periodic prefetch (daily)
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromHours(24), stoppingToken);
|
||||
|
||||
try
|
||||
{
|
||||
await PrefetchAllPlaylistLyricsAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in lyrics prefetch service");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PrefetchAllPlaylistLyricsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("🎵 Starting lyrics prefetch for {Count} playlists", _spotifySettings.Playlists.Count);
|
||||
|
||||
var totalFetched = 0;
|
||||
var totalCached = 0;
|
||||
var totalMissing = 0;
|
||||
|
||||
foreach (var playlist in _spotifySettings.Playlists)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) break;
|
||||
|
||||
try
|
||||
{
|
||||
var (fetched, cached, missing) = await PrefetchPlaylistLyricsAsync(playlist.Name, cancellationToken);
|
||||
totalFetched += fetched;
|
||||
totalCached += cached;
|
||||
totalMissing += missing;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error prefetching lyrics for playlist {Playlist}", playlist.Name);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("✅ Lyrics prefetch complete: {Fetched} fetched, {Cached} already cached, {Missing} not found",
|
||||
totalFetched, totalCached, totalMissing);
|
||||
}
|
||||
|
||||
public async Task<(int Fetched, int Cached, int Missing)> PrefetchPlaylistLyricsAsync(
|
||||
string playlistName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Prefetching lyrics for playlist: {Playlist}", playlistName);
|
||||
|
||||
var tracks = await _playlistFetcher.GetPlaylistTracksAsync(playlistName);
|
||||
if (tracks.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("No tracks found for playlist {Playlist}", playlistName);
|
||||
return (0, 0, 0);
|
||||
}
|
||||
|
||||
var fetched = 0;
|
||||
var cached = 0;
|
||||
var missing = 0;
|
||||
|
||||
foreach (var track in tracks)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) break;
|
||||
|
||||
try
|
||||
{
|
||||
// Check if lyrics are already cached
|
||||
var cacheKey = $"lyrics:{track.PrimaryArtist}:{track.Title}:{track.Album}:{track.DurationMs / 1000}";
|
||||
var existingLyrics = await _cache.GetStringAsync(cacheKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(existingLyrics))
|
||||
{
|
||||
cached++;
|
||||
_logger.LogDebug("✓ Lyrics already cached for {Artist} - {Track}", track.PrimaryArtist, track.Title);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this track has local Jellyfin lyrics (embedded in file)
|
||||
var hasLocalLyrics = await CheckForLocalJellyfinLyricsAsync(track.SpotifyId);
|
||||
if (hasLocalLyrics)
|
||||
{
|
||||
cached++;
|
||||
_logger.LogInformation("✓ Local Jellyfin lyrics found for {Artist} - {Track}, skipping LRCLib fetch",
|
||||
track.PrimaryArtist, track.Title);
|
||||
|
||||
// Remove any previously cached LRCLib lyrics for this track
|
||||
await RemoveCachedLyricsAsync(track.PrimaryArtist, track.Title, track.Album, track.DurationMs / 1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fetch lyrics from LRCLib
|
||||
var lyrics = await _lrclibService.GetLyricsAsync(
|
||||
track.Title,
|
||||
track.Artists.ToArray(),
|
||||
track.Album,
|
||||
track.DurationMs / 1000);
|
||||
|
||||
if (lyrics != null)
|
||||
{
|
||||
fetched++;
|
||||
_logger.LogInformation("✓ Fetched lyrics for {Artist} - {Track} (synced: {HasSynced})",
|
||||
track.PrimaryArtist, track.Title, !string.IsNullOrEmpty(lyrics.SyncedLyrics));
|
||||
|
||||
// Save to file cache
|
||||
await SaveLyricsToFileAsync(track.PrimaryArtist, track.Title, track.Album, track.DurationMs / 1000, lyrics);
|
||||
}
|
||||
else
|
||||
{
|
||||
missing++;
|
||||
_logger.LogDebug("✗ No lyrics found for {Artist} - {Track}", track.PrimaryArtist, track.Title);
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
await Task.Delay(DelayBetweenRequestsMs, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to prefetch lyrics for {Artist} - {Track}", track.PrimaryArtist, track.Title);
|
||||
missing++;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Playlist {Playlist}: {Fetched} fetched, {Cached} cached, {Missing} missing",
|
||||
playlistName, fetched, cached, missing);
|
||||
|
||||
return (fetched, cached, missing);
|
||||
}
|
||||
|
||||
private async Task SaveLyricsToFileAsync(string artist, string title, string album, int duration, LyricsInfo lyrics)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fileName = $"{SanitizeFileName(artist)}_{SanitizeFileName(title)}_{duration}.json";
|
||||
var filePath = Path.Combine(_lyricsCacheDir, fileName);
|
||||
|
||||
var json = JsonSerializer.Serialize(lyrics, new JsonSerializerOptions { WriteIndented = true });
|
||||
await File.WriteAllTextAsync(filePath, json);
|
||||
|
||||
_logger.LogDebug("💾 Saved lyrics to file: {FileName}", fileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to save lyrics to file for {Artist} - {Track}", artist, title);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads lyrics from file cache into Redis on startup
|
||||
/// </summary>
|
||||
public async Task WarmCacheFromFilesAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(_lyricsCacheDir))
|
||||
{
|
||||
_logger.LogInformation("Lyrics cache directory does not exist, skipping cache warming");
|
||||
return;
|
||||
}
|
||||
|
||||
var files = Directory.GetFiles(_lyricsCacheDir, "*.json");
|
||||
if (files.Length == 0)
|
||||
{
|
||||
_logger.LogInformation("No lyrics cache files found");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("🔥 Warming lyrics cache from {Count} files...", files.Length);
|
||||
|
||||
var loaded = 0;
|
||||
foreach (var file in files)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(file);
|
||||
var lyrics = JsonSerializer.Deserialize<LyricsInfo>(json);
|
||||
|
||||
if (lyrics != null)
|
||||
{
|
||||
var cacheKey = $"lyrics:{lyrics.ArtistName}:{lyrics.TrackName}:{lyrics.AlbumName}:{lyrics.Duration}";
|
||||
await _cache.SetStringAsync(cacheKey, json, TimeSpan.FromDays(30));
|
||||
loaded++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load lyrics from file {File}", Path.GetFileName(file));
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("✅ Warmed {Count} lyrics from file cache", loaded);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error warming lyrics cache from files");
|
||||
}
|
||||
}
|
||||
|
||||
private static string SanitizeFileName(string fileName)
|
||||
{
|
||||
var invalid = Path.GetInvalidFileNameChars();
|
||||
return string.Join("_", fileName.Split(invalid, StringSplitOptions.RemoveEmptyEntries))
|
||||
.Replace(" ", "_")
|
||||
.ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes cached LRCLib lyrics from both Redis and file cache.
|
||||
/// Used when a track has local Jellyfin lyrics, making the LRCLib cache obsolete.
|
||||
/// </summary>
|
||||
private async Task RemoveCachedLyricsAsync(string artist, string title, string album, int duration)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Remove from Redis cache
|
||||
var cacheKey = $"lyrics:{artist}:{title}:{album}:{duration}";
|
||||
await _cache.DeleteAsync(cacheKey);
|
||||
|
||||
// Remove from file cache
|
||||
var fileName = $"{SanitizeFileName(artist)}_{SanitizeFileName(title)}_{duration}.json";
|
||||
var filePath = Path.Combine(_lyricsCacheDir, fileName);
|
||||
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
File.Delete(filePath);
|
||||
_logger.LogDebug("🗑️ Removed cached LRCLib lyrics file: {FileName}", fileName);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to remove cached lyrics for {Artist} - {Track}", artist, title);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a track has embedded lyrics in Jellyfin by querying the Jellyfin API.
|
||||
/// This prevents downloading lyrics from LRCLib when the local file already has them.
|
||||
/// </summary>
|
||||
private async Task<bool> CheckForLocalJellyfinLyricsAsync(string spotifyTrackId)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
|
||||
|
||||
if (proxyService == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Search for the track in Jellyfin by Spotify provider ID
|
||||
var searchParams = new Dictionary<string, string>
|
||||
{
|
||||
["anyProviderIdEquals"] = $"Spotify.{spotifyTrackId}",
|
||||
["includeItemTypes"] = "Audio",
|
||||
["recursive"] = "true",
|
||||
["limit"] = "1"
|
||||
};
|
||||
|
||||
var (searchResult, statusCode) = await proxyService.GetJsonAsync("Items", searchParams, null);
|
||||
|
||||
if (searchResult == null || statusCode != 200)
|
||||
{
|
||||
// Track not found in local library
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if we found any items
|
||||
if (!searchResult.RootElement.TryGetProperty("Items", out var items) ||
|
||||
items.GetArrayLength() == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get the first matching track's ID
|
||||
var firstItem = items[0];
|
||||
if (!firstItem.TryGetProperty("Id", out var idElement))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var jellyfinTrackId = idElement.GetString();
|
||||
if (string.IsNullOrEmpty(jellyfinTrackId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if this track has lyrics
|
||||
var (lyricsResult, lyricsStatusCode) = await proxyService.GetJsonAsync(
|
||||
$"Audio/{jellyfinTrackId}/Lyrics",
|
||||
null,
|
||||
null);
|
||||
|
||||
if (lyricsResult != null && lyricsStatusCode == 200)
|
||||
{
|
||||
// Track has embedded lyrics in Jellyfin
|
||||
_logger.LogDebug("Found embedded lyrics in Jellyfin for Spotify track {SpotifyId} (Jellyfin ID: {JellyfinId})",
|
||||
spotifyTrackId, jellyfinTrackId);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error checking for local Jellyfin lyrics for Spotify track {SpotifyId}", spotifyTrackId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
474
allstarr/Services/Lyrics/SpotifyLyricsService.cs
Normal file
474
allstarr/Services/Lyrics/SpotifyLyricsService.cs
Normal file
@@ -0,0 +1,474 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using allstarr.Models.Lyrics;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Services.Common;
|
||||
using allstarr.Services.Spotify;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace allstarr.Services.Lyrics;
|
||||
|
||||
/// <summary>
|
||||
/// Service for fetching synchronized lyrics from Spotify's internal color-lyrics API.
|
||||
///
|
||||
/// Spotify's lyrics API provides:
|
||||
/// - Line-by-line synchronized lyrics with precise timestamps
|
||||
/// - Word-level timing for karaoke-style display (syllable sync)
|
||||
/// - Background color suggestions based on album art
|
||||
/// - Support for multiple languages and translations
|
||||
///
|
||||
/// This requires the sp_dc session cookie for authentication.
|
||||
/// </summary>
|
||||
public class SpotifyLyricsService
|
||||
{
|
||||
private readonly ILogger<SpotifyLyricsService> _logger;
|
||||
private readonly SpotifyApiSettings _settings;
|
||||
private readonly SpotifyApiClient _spotifyClient;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
private const string LyricsApiBase = "https://spclient.wg.spotify.com/color-lyrics/v2/track";
|
||||
|
||||
public SpotifyLyricsService(
|
||||
ILogger<SpotifyLyricsService> logger,
|
||||
IOptions<SpotifyApiSettings> settings,
|
||||
SpotifyApiClient spotifyClient,
|
||||
RedisCacheService cache,
|
||||
IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_settings = settings.Value;
|
||||
_spotifyClient = spotifyClient;
|
||||
_cache = cache;
|
||||
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0");
|
||||
_httpClient.DefaultRequestHeaders.Add("App-Platform", "WebPlayer");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets synchronized lyrics for a Spotify track by its ID.
|
||||
/// </summary>
|
||||
/// <param name="spotifyTrackId">Spotify track ID (e.g., "3a8mo25v74BMUOJ1IDUEBL")</param>
|
||||
/// <returns>Lyrics info with synced lyrics in LRC format, or null if not available</returns>
|
||||
public async Task<SpotifyLyricsResult?> GetLyricsByTrackIdAsync(string spotifyTrackId)
|
||||
{
|
||||
if (!_settings.Enabled || string.IsNullOrEmpty(_settings.SessionCookie))
|
||||
{
|
||||
_logger.LogDebug("Spotify API not enabled or no session cookie configured");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Normalize track ID (remove URI prefix if present)
|
||||
spotifyTrackId = ExtractTrackId(spotifyTrackId);
|
||||
|
||||
// Check cache
|
||||
var cacheKey = $"spotify:lyrics:{spotifyTrackId}";
|
||||
var cached = await _cache.GetAsync<SpotifyLyricsResult>(cacheKey);
|
||||
if (cached != null)
|
||||
{
|
||||
_logger.LogDebug("Returning cached Spotify lyrics for track {TrackId}", spotifyTrackId);
|
||||
return cached;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Get access token
|
||||
var token = await _spotifyClient.GetWebAccessTokenAsync();
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
_logger.LogWarning("Could not get Spotify access token for lyrics");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Request lyrics from Spotify's color-lyrics API
|
||||
var url = $"{LyricsApiBase}/{spotifyTrackId}?format=json&vocalRemoval=false&market=from_token";
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
request.Headers.Add("Accept", "application/json");
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
_logger.LogDebug("No lyrics found on Spotify for track {TrackId}", spotifyTrackId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("Spotify lyrics API returned {StatusCode} for track {TrackId}",
|
||||
response.StatusCode, spotifyTrackId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var result = ParseLyricsResponse(json, spotifyTrackId);
|
||||
|
||||
if (result != null)
|
||||
{
|
||||
// Cache for 30 days (lyrics don't change)
|
||||
await _cache.SetAsync(cacheKey, result, TimeSpan.FromDays(30));
|
||||
_logger.LogInformation("Cached Spotify lyrics for track {TrackId} ({LineCount} lines)",
|
||||
spotifyTrackId, result.Lines.Count);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching Spotify lyrics for track {TrackId}", spotifyTrackId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches for a track on Spotify and returns its lyrics.
|
||||
/// Useful when you have track metadata but not a Spotify ID.
|
||||
/// </summary>
|
||||
public async Task<SpotifyLyricsResult?> SearchAndGetLyricsAsync(
|
||||
string trackName,
|
||||
string artistName,
|
||||
string? albumName = null,
|
||||
int? durationMs = null)
|
||||
{
|
||||
if (!_settings.Enabled || string.IsNullOrEmpty(_settings.SessionCookie))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var token = await _spotifyClient.GetWebAccessTokenAsync();
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Search for the track
|
||||
var query = $"track:{trackName} artist:{artistName}";
|
||||
if (!string.IsNullOrEmpty(albumName))
|
||||
{
|
||||
query += $" album:{albumName}";
|
||||
}
|
||||
|
||||
var searchUrl = $"https://api.spotify.com/v1/search?q={Uri.EscapeDataString(query)}&type=track&limit=5";
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, searchUrl);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (!root.TryGetProperty("tracks", out var tracks) ||
|
||||
!tracks.TryGetProperty("items", out var items) ||
|
||||
items.GetArrayLength() == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find best match considering duration if provided
|
||||
string? bestMatchId = null;
|
||||
var bestScore = 0;
|
||||
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
var id = item.TryGetProperty("id", out var idProp) ? idProp.GetString() : null;
|
||||
if (string.IsNullOrEmpty(id)) continue;
|
||||
|
||||
var score = 100; // Base score
|
||||
|
||||
// Check duration match
|
||||
if (durationMs.HasValue && item.TryGetProperty("duration_ms", out var durProp))
|
||||
{
|
||||
var trackDuration = durProp.GetInt32();
|
||||
var durationDiff = Math.Abs(trackDuration - durationMs.Value);
|
||||
if (durationDiff < 2000) score += 50; // Within 2 seconds
|
||||
else if (durationDiff < 5000) score += 25; // Within 5 seconds
|
||||
}
|
||||
|
||||
if (score > bestScore)
|
||||
{
|
||||
bestScore = score;
|
||||
bestMatchId = id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(bestMatchId))
|
||||
{
|
||||
return await GetLyricsByTrackIdAsync(bestMatchId);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error searching Spotify for lyrics: {Track} - {Artist}", trackName, artistName);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts Spotify lyrics to LRCLIB-compatible LyricsInfo format.
|
||||
/// </summary>
|
||||
public LyricsInfo? ToLyricsInfo(SpotifyLyricsResult spotifyLyrics)
|
||||
{
|
||||
if (spotifyLyrics.Lines.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build synced lyrics in LRC format
|
||||
var lrcLines = new List<string>();
|
||||
foreach (var line in spotifyLyrics.Lines)
|
||||
{
|
||||
var timestamp = TimeSpan.FromMilliseconds(line.StartTimeMs);
|
||||
var mm = (int)timestamp.TotalMinutes;
|
||||
var ss = timestamp.Seconds;
|
||||
var ms = timestamp.Milliseconds / 10; // LRC uses centiseconds
|
||||
|
||||
lrcLines.Add($"[{mm:D2}:{ss:D2}.{ms:D2}]{line.Words}");
|
||||
}
|
||||
|
||||
return new LyricsInfo
|
||||
{
|
||||
TrackName = spotifyLyrics.TrackName ?? "",
|
||||
ArtistName = spotifyLyrics.ArtistName ?? "",
|
||||
AlbumName = spotifyLyrics.AlbumName ?? "",
|
||||
Duration = (int)(spotifyLyrics.DurationMs / 1000),
|
||||
Instrumental = spotifyLyrics.Lines.Count == 0,
|
||||
SyncedLyrics = string.Join("\n", lrcLines),
|
||||
PlainLyrics = string.Join("\n", spotifyLyrics.Lines.Select(l => l.Words))
|
||||
};
|
||||
}
|
||||
|
||||
private SpotifyLyricsResult? ParseLyricsResponse(string json, string trackId)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var result = new SpotifyLyricsResult
|
||||
{
|
||||
SpotifyTrackId = trackId
|
||||
};
|
||||
|
||||
// Parse lyrics lines
|
||||
if (root.TryGetProperty("lyrics", out var lyrics))
|
||||
{
|
||||
// Check sync type
|
||||
if (lyrics.TryGetProperty("syncType", out var syncType))
|
||||
{
|
||||
result.SyncType = syncType.GetString() ?? "LINE_SYNCED";
|
||||
}
|
||||
|
||||
// Parse lines
|
||||
if (lyrics.TryGetProperty("lines", out var lines))
|
||||
{
|
||||
foreach (var line in lines.EnumerateArray())
|
||||
{
|
||||
var lyricsLine = new SpotifyLyricsLine
|
||||
{
|
||||
StartTimeMs = line.TryGetProperty("startTimeMs", out var start)
|
||||
? long.Parse(start.GetString() ?? "0") : 0,
|
||||
Words = line.TryGetProperty("words", out var words)
|
||||
? words.GetString() ?? "" : "",
|
||||
EndTimeMs = line.TryGetProperty("endTimeMs", out var end)
|
||||
? long.Parse(end.GetString() ?? "0") : 0
|
||||
};
|
||||
|
||||
// Parse syllables if available (for word-level sync)
|
||||
if (line.TryGetProperty("syllables", out var syllables))
|
||||
{
|
||||
foreach (var syllable in syllables.EnumerateArray())
|
||||
{
|
||||
lyricsLine.Syllables.Add(new SpotifyLyricsSyllable
|
||||
{
|
||||
StartTimeMs = syllable.TryGetProperty("startTimeMs", out var sStart)
|
||||
? long.Parse(sStart.GetString() ?? "0") : 0,
|
||||
Text = syllable.TryGetProperty("charsIndex", out var text)
|
||||
? text.GetString() ?? "" : ""
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
result.Lines.Add(lyricsLine);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse color information
|
||||
if (lyrics.TryGetProperty("colors", out var colors))
|
||||
{
|
||||
result.Colors = new SpotifyLyricsColors
|
||||
{
|
||||
Background = colors.TryGetProperty("background", out var bg)
|
||||
? ParseColorValue(bg) : null,
|
||||
Text = colors.TryGetProperty("text", out var txt)
|
||||
? ParseColorValue(txt) : null,
|
||||
HighlightText = colors.TryGetProperty("highlightText", out var ht)
|
||||
? ParseColorValue(ht) : null
|
||||
};
|
||||
}
|
||||
|
||||
// Language
|
||||
if (lyrics.TryGetProperty("language", out var lang))
|
||||
{
|
||||
result.Language = lang.GetString();
|
||||
}
|
||||
|
||||
// Provider info
|
||||
if (lyrics.TryGetProperty("provider", out var provider))
|
||||
{
|
||||
result.Provider = provider.GetString();
|
||||
}
|
||||
|
||||
// Display info
|
||||
if (lyrics.TryGetProperty("providerDisplayName", out var providerDisplay))
|
||||
{
|
||||
result.ProviderDisplayName = providerDisplay.GetString();
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error parsing Spotify lyrics response");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static int? ParseColorValue(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
return element.GetInt32();
|
||||
}
|
||||
if (element.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var str = element.GetString();
|
||||
if (!string.IsNullOrEmpty(str) && int.TryParse(str, out var val))
|
||||
{
|
||||
return val;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ExtractTrackId(string input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input)) return input;
|
||||
|
||||
// Handle spotify:track:xxxxx format
|
||||
if (input.StartsWith("spotify:track:"))
|
||||
{
|
||||
return input.Substring("spotify:track:".Length);
|
||||
}
|
||||
|
||||
// Handle https://open.spotify.com/track/xxxxx format
|
||||
if (input.Contains("open.spotify.com/track/"))
|
||||
{
|
||||
var start = input.IndexOf("/track/") + "/track/".Length;
|
||||
var end = input.IndexOf('?', start);
|
||||
return end > 0 ? input.Substring(start, end - start) : input.Substring(start);
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result from Spotify's color-lyrics API.
|
||||
/// </summary>
|
||||
public class SpotifyLyricsResult
|
||||
{
|
||||
public string SpotifyTrackId { get; set; } = string.Empty;
|
||||
public string? TrackName { get; set; }
|
||||
public string? ArtistName { get; set; }
|
||||
public string? AlbumName { get; set; }
|
||||
public long DurationMs { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Sync type: "LINE_SYNCED", "SYLLABLE_SYNCED", or "UNSYNCED"
|
||||
/// </summary>
|
||||
public string SyncType { get; set; } = "LINE_SYNCED";
|
||||
|
||||
/// <summary>
|
||||
/// Language code (e.g., "en", "es", "ja")
|
||||
/// </summary>
|
||||
public string? Language { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Lyrics provider (e.g., "MusixMatch", "Spotify")
|
||||
/// </summary>
|
||||
public string? Provider { get; set; }
|
||||
|
||||
public string? ProviderDisplayName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Lyrics lines in order
|
||||
/// </summary>
|
||||
public List<SpotifyLyricsLine> Lines { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Color suggestions based on album art
|
||||
/// </summary>
|
||||
public SpotifyLyricsColors? Colors { get; set; }
|
||||
}
|
||||
|
||||
public class SpotifyLyricsLine
|
||||
{
|
||||
/// <summary>
|
||||
/// Start time in milliseconds
|
||||
/// </summary>
|
||||
public long StartTimeMs { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// End time in milliseconds
|
||||
/// </summary>
|
||||
public long EndTimeMs { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The lyrics text for this line
|
||||
/// </summary>
|
||||
public string Words { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Syllable-level timing for karaoke display (if available)
|
||||
/// </summary>
|
||||
public List<SpotifyLyricsSyllable> Syllables { get; set; } = new();
|
||||
}
|
||||
|
||||
public class SpotifyLyricsSyllable
|
||||
{
|
||||
public long StartTimeMs { get; set; }
|
||||
public string Text { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class SpotifyLyricsColors
|
||||
{
|
||||
/// <summary>
|
||||
/// Suggested background color (ARGB integer)
|
||||
/// </summary>
|
||||
public int? Background { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Suggested text color (ARGB integer)
|
||||
/// </summary>
|
||||
public int? Text { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Suggested highlight/active text color (ARGB integer)
|
||||
/// </summary>
|
||||
public int? HighlightText { get; set; }
|
||||
}
|
||||
342
allstarr/Services/MusicBrainz/MusicBrainzService.cs
Normal file
342
allstarr/Services/MusicBrainz/MusicBrainzService.cs
Normal file
@@ -0,0 +1,342 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Models.Settings;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace allstarr.Services.MusicBrainz;
|
||||
|
||||
/// <summary>
|
||||
/// Service for querying MusicBrainz API for metadata enrichment.
|
||||
/// </summary>
|
||||
public class MusicBrainzService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly MusicBrainzSettings _settings;
|
||||
private readonly ILogger<MusicBrainzService> _logger;
|
||||
private DateTime _lastRequestTime = DateTime.MinValue;
|
||||
private readonly SemaphoreSlim _rateLimitSemaphore = new(1, 1);
|
||||
|
||||
public MusicBrainzService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptions<MusicBrainzSettings> settings,
|
||||
ILogger<MusicBrainzService> logger)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0.0 (https://github.com/SoPat712/allstarr)");
|
||||
_httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
|
||||
// Set up digest authentication if credentials provided
|
||||
if (!string.IsNullOrEmpty(_settings.Username) && !string.IsNullOrEmpty(_settings.Password))
|
||||
{
|
||||
var credentials = Convert.ToBase64String(
|
||||
Encoding.ASCII.GetBytes($"{_settings.Username}:{_settings.Password}"));
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Basic", credentials);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a recording by ISRC code.
|
||||
/// </summary>
|
||||
public async Task<MusicBrainzRecording?> LookupByIsrcAsync(string isrc)
|
||||
{
|
||||
if (!_settings.Enabled)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await RateLimitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var url = $"{_settings.BaseUrl}/isrc/{isrc}?fmt=json&inc=artists+releases+release-groups+genres+tags";
|
||||
_logger.LogDebug("MusicBrainz ISRC lookup: {Url}", url);
|
||||
|
||||
var response = await _httpClient.GetAsync(url);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("MusicBrainz ISRC lookup failed: {StatusCode}", response.StatusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var result = JsonSerializer.Deserialize<MusicBrainzIsrcResponse>(json, JsonOptions);
|
||||
|
||||
if (result?.Recordings == null || result.Recordings.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No MusicBrainz recordings found for ISRC: {Isrc}", isrc);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return the first recording (ISRCs should be unique)
|
||||
var recording = result.Recordings[0];
|
||||
var genres = recording.Genres?.Select(g => g.Name).Where(n => !string.IsNullOrEmpty(n)).ToList() ?? new List<string?>();
|
||||
_logger.LogInformation("✓ Found MusicBrainz recording for ISRC {Isrc}: {Title} by {Artist} (Genres: {Genres})",
|
||||
isrc, recording.Title, recording.ArtistCredit?[0]?.Name ?? "Unknown", string.Join(", ", genres));
|
||||
|
||||
return recording;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error looking up ISRC {Isrc} in MusicBrainz", isrc);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches for recordings by title and artist.
|
||||
/// </summary>
|
||||
public async Task<List<MusicBrainzRecording>> SearchRecordingsAsync(string title, string artist, int limit = 5)
|
||||
{
|
||||
if (!_settings.Enabled)
|
||||
{
|
||||
return new List<MusicBrainzRecording>();
|
||||
}
|
||||
|
||||
await RateLimitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Build Lucene query
|
||||
var query = $"recording:\"{title}\" AND artist:\"{artist}\"";
|
||||
var encodedQuery = Uri.EscapeDataString(query);
|
||||
var url = $"{_settings.BaseUrl}/recording?query={encodedQuery}&fmt=json&limit={limit}&inc=genres+tags";
|
||||
|
||||
_logger.LogDebug("MusicBrainz search: {Url}", url);
|
||||
|
||||
var response = await _httpClient.GetAsync(url);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("MusicBrainz search failed: {StatusCode}", response.StatusCode);
|
||||
return new List<MusicBrainzRecording>();
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var result = JsonSerializer.Deserialize<MusicBrainzSearchResponse>(json, JsonOptions);
|
||||
|
||||
if (result?.Recordings == null || result.Recordings.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No MusicBrainz recordings found for: {Title} - {Artist}", title, artist);
|
||||
return new List<MusicBrainzRecording>();
|
||||
}
|
||||
|
||||
_logger.LogInformation("Found {Count} MusicBrainz recordings for: {Title} - {Artist}",
|
||||
result.Recordings.Count, title, artist);
|
||||
|
||||
return result.Recordings;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error searching MusicBrainz for: {Title} - {Artist}", title, artist);
|
||||
return new List<MusicBrainzRecording>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enriches a song with genre information from MusicBrainz.
|
||||
/// First tries ISRC lookup, then falls back to title/artist search.
|
||||
/// </summary>
|
||||
public async Task<List<string>> GetGenresForSongAsync(string title, string artist, string? isrc = null)
|
||||
{
|
||||
if (!_settings.Enabled)
|
||||
{
|
||||
return new List<string>();
|
||||
}
|
||||
|
||||
MusicBrainzRecording? recording = null;
|
||||
|
||||
// Try ISRC lookup first (most accurate)
|
||||
if (!string.IsNullOrEmpty(isrc))
|
||||
{
|
||||
recording = await LookupByIsrcAsync(isrc);
|
||||
}
|
||||
|
||||
// Fall back to search if ISRC lookup failed or no ISRC provided
|
||||
if (recording == null)
|
||||
{
|
||||
var recordings = await SearchRecordingsAsync(title, artist, limit: 1);
|
||||
recording = recordings.FirstOrDefault();
|
||||
}
|
||||
|
||||
if (recording == null)
|
||||
{
|
||||
return new List<string>();
|
||||
}
|
||||
|
||||
// Extract genres (prioritize official genres over tags)
|
||||
var genres = new List<string>();
|
||||
|
||||
if (recording.Genres != null && recording.Genres.Count > 0)
|
||||
{
|
||||
// Get top genres by vote count
|
||||
genres.AddRange(recording.Genres
|
||||
.OrderByDescending(g => g.Count)
|
||||
.Take(5)
|
||||
.Select(g => g.Name)
|
||||
.Where(n => !string.IsNullOrEmpty(n))
|
||||
.Select(n => n!)
|
||||
.ToList());
|
||||
}
|
||||
|
||||
_logger.LogInformation("Found {Count} genres for {Title} - {Artist}: {Genres}",
|
||||
genres.Count, title, artist, string.Join(", ", genres));
|
||||
|
||||
return genres;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rate limiting to comply with MusicBrainz API rules (1 request per second).
|
||||
/// </summary>
|
||||
private async Task RateLimitAsync()
|
||||
{
|
||||
await _rateLimitSemaphore.WaitAsync();
|
||||
try
|
||||
{
|
||||
var timeSinceLastRequest = DateTime.UtcNow - _lastRequestTime;
|
||||
var minInterval = TimeSpan.FromMilliseconds(_settings.RateLimitMs);
|
||||
|
||||
if (timeSinceLastRequest < minInterval)
|
||||
{
|
||||
var delay = minInterval - timeSinceLastRequest;
|
||||
await Task.Delay(delay);
|
||||
}
|
||||
|
||||
_lastRequestTime = DateTime.UtcNow;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_rateLimitSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.KebabCaseLower,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MusicBrainz ISRC lookup response.
|
||||
/// </summary>
|
||||
public class MusicBrainzIsrcResponse
|
||||
{
|
||||
[JsonPropertyName("recordings")]
|
||||
public List<MusicBrainzRecording>? Recordings { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MusicBrainz search response.
|
||||
/// </summary>
|
||||
public class MusicBrainzSearchResponse
|
||||
{
|
||||
[JsonPropertyName("recordings")]
|
||||
public List<MusicBrainzRecording>? Recordings { get; set; }
|
||||
|
||||
[JsonPropertyName("count")]
|
||||
public int Count { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MusicBrainz recording.
|
||||
/// </summary>
|
||||
public class MusicBrainzRecording
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string? Id { get; set; }
|
||||
|
||||
[JsonPropertyName("title")]
|
||||
public string? Title { get; set; }
|
||||
|
||||
[JsonPropertyName("length")]
|
||||
public int? Length { get; set; } // in milliseconds
|
||||
|
||||
[JsonPropertyName("artist-credit")]
|
||||
public List<MusicBrainzArtistCredit>? ArtistCredit { get; set; }
|
||||
|
||||
[JsonPropertyName("releases")]
|
||||
public List<MusicBrainzRelease>? Releases { get; set; }
|
||||
|
||||
[JsonPropertyName("isrcs")]
|
||||
public List<string>? Isrcs { get; set; }
|
||||
|
||||
[JsonPropertyName("genres")]
|
||||
public List<MusicBrainzGenre>? Genres { get; set; }
|
||||
|
||||
[JsonPropertyName("tags")]
|
||||
public List<MusicBrainzTag>? Tags { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MusicBrainz artist credit.
|
||||
/// </summary>
|
||||
public class MusicBrainzArtistCredit
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("artist")]
|
||||
public MusicBrainzArtist? Artist { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MusicBrainz artist.
|
||||
/// </summary>
|
||||
public class MusicBrainzArtist
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string? Id { get; set; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MusicBrainz release.
|
||||
/// </summary>
|
||||
public class MusicBrainzRelease
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string? Id { get; set; }
|
||||
|
||||
[JsonPropertyName("title")]
|
||||
public string? Title { get; set; }
|
||||
|
||||
[JsonPropertyName("date")]
|
||||
public string? Date { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MusicBrainz genre.
|
||||
/// </summary>
|
||||
public class MusicBrainzGenre
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string? Id { get; set; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("count")]
|
||||
public int Count { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MusicBrainz tag (folksonomy).
|
||||
/// </summary>
|
||||
public class MusicBrainzTag
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("count")]
|
||||
public int Count { get; set; }
|
||||
}
|
||||
900
allstarr/Services/Spotify/SpotifyApiClient.cs
Normal file
900
allstarr/Services/Spotify/SpotifyApiClient.cs
Normal file
@@ -0,0 +1,900 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Models.Spotify;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OtpNet;
|
||||
|
||||
namespace allstarr.Services.Spotify;
|
||||
|
||||
/// <summary>
|
||||
/// Client for accessing Spotify's APIs directly.
|
||||
///
|
||||
/// Supports two modes:
|
||||
/// 1. Official API - For public playlists and standard operations
|
||||
/// 2. Web API (with session cookie) - For editorial/personalized playlists like Release Radar, Discover Weekly
|
||||
///
|
||||
/// The session cookie (sp_dc) is required because Spotify's official API doesn't expose
|
||||
/// algorithmically generated "Made For You" playlists.
|
||||
///
|
||||
/// Uses TOTP-based authentication similar to the Jellyfin Spotify Import plugin.
|
||||
/// </summary>
|
||||
public class SpotifyApiClient : IDisposable
|
||||
{
|
||||
private readonly ILogger<SpotifyApiClient> _logger;
|
||||
private readonly SpotifyApiSettings _settings;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly HttpClient _webApiClient;
|
||||
private readonly CookieContainer _cookieContainer;
|
||||
|
||||
// Spotify API endpoints
|
||||
private const string OfficialApiBase = "https://api.spotify.com/v1";
|
||||
private const string WebApiBase = "https://api-partner.spotify.com/pathfinder/v1";
|
||||
private const string SpotifyBaseUrl = "https://open.spotify.com";
|
||||
private const string TokenEndpoint = "https://open.spotify.com/api/token";
|
||||
|
||||
// URL for pre-scraped TOTP secrets (same as Jellyfin plugin uses)
|
||||
private const string TotpSecretsUrl = "https://raw.githubusercontent.com/xyloflake/spot-secrets-go/refs/heads/main/secrets/secretBytes.json";
|
||||
|
||||
// Web API access token (obtained via session cookie)
|
||||
private string? _webAccessToken;
|
||||
private DateTime _webTokenExpiry = DateTime.MinValue;
|
||||
private readonly SemaphoreSlim _tokenLock = new(1, 1);
|
||||
|
||||
// Cached TOTP secrets
|
||||
private TotpSecret? _cachedTotpSecret;
|
||||
private DateTime _totpSecretFetchedAt = DateTime.MinValue;
|
||||
|
||||
public SpotifyApiClient(
|
||||
ILogger<SpotifyApiClient> logger,
|
||||
IOptions<SpotifyApiSettings> settings)
|
||||
{
|
||||
_logger = logger;
|
||||
_settings = settings.Value;
|
||||
|
||||
// Client for official API
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri(OfficialApiBase),
|
||||
Timeout = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
|
||||
// Client for web API (requires session cookie)
|
||||
_cookieContainer = new CookieContainer();
|
||||
var handler = new HttpClientHandler
|
||||
{
|
||||
UseCookies = true,
|
||||
CookieContainer = _cookieContainer
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(_settings.SessionCookie))
|
||||
{
|
||||
_cookieContainer.SetCookies(
|
||||
new Uri(SpotifyBaseUrl),
|
||||
$"sp_dc={_settings.SessionCookie}");
|
||||
}
|
||||
|
||||
_webApiClient = new HttpClient(handler)
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
|
||||
// Common headers for web API
|
||||
_webApiClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36");
|
||||
_webApiClient.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
_webApiClient.DefaultRequestHeaders.Add("Accept-Language", "en-US");
|
||||
_webApiClient.DefaultRequestHeaders.Add("app-platform", "WebPlayer");
|
||||
_webApiClient.DefaultRequestHeaders.Add("spotify-app-version", "1.2.46.25.g7f189073");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an access token using the session cookie and TOTP authentication.
|
||||
/// This token can be used for both the official API and web API.
|
||||
/// </summary>
|
||||
public async Task<string?> GetWebAccessTokenAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_settings.SessionCookie))
|
||||
{
|
||||
_logger.LogWarning("No Spotify session cookie configured");
|
||||
return null;
|
||||
}
|
||||
|
||||
await _tokenLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
// Return cached token if still valid
|
||||
if (!string.IsNullOrEmpty(_webAccessToken) && DateTime.UtcNow < _webTokenExpiry)
|
||||
{
|
||||
return _webAccessToken;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Fetching new Spotify web access token using TOTP authentication");
|
||||
|
||||
// Fetch TOTP secrets if needed
|
||||
var totpSecret = await GetTotpSecretAsync(cancellationToken);
|
||||
if (totpSecret == null)
|
||||
{
|
||||
_logger.LogError("Failed to get TOTP secrets");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Generate TOTP
|
||||
var totpResult = await GenerateTotpAsync(totpSecret, cancellationToken);
|
||||
if (totpResult == null)
|
||||
{
|
||||
_logger.LogError("Failed to generate TOTP");
|
||||
return null;
|
||||
}
|
||||
|
||||
var (otp, serverTime) = totpResult.Value;
|
||||
var clientTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
// Build token URL with TOTP parameters
|
||||
var tokenUrl = $"{TokenEndpoint}?reason=init&productType=web-player&totp={otp}&totpServer={otp}&totpVer={totpSecret.Version}&sTime={serverTime}&cTime={clientTime}";
|
||||
|
||||
_logger.LogDebug("Requesting token from: {Url}", tokenUrl.Replace(otp, "***"));
|
||||
|
||||
var response = await _webApiClient.GetAsync(tokenUrl, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogError("Failed to get Spotify access token: {StatusCode} - {Body}", response.StatusCode, errorBody);
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var tokenResponse = JsonSerializer.Deserialize<SpotifyTokenResponse>(json);
|
||||
|
||||
if (tokenResponse == null || string.IsNullOrEmpty(tokenResponse.AccessToken))
|
||||
{
|
||||
_logger.LogError("No access token in Spotify response: {Json}", json);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (tokenResponse.IsAnonymous)
|
||||
{
|
||||
_logger.LogWarning("Spotify returned anonymous token - session cookie may be invalid");
|
||||
}
|
||||
|
||||
_webAccessToken = tokenResponse.AccessToken;
|
||||
|
||||
// Token typically expires in 1 hour, but we'll refresh early
|
||||
if (tokenResponse.ExpirationTimestampMs > 0)
|
||||
{
|
||||
_webTokenExpiry = DateTimeOffset.FromUnixTimeMilliseconds(tokenResponse.ExpirationTimestampMs).UtcDateTime;
|
||||
// Refresh 5 minutes early
|
||||
_webTokenExpiry = _webTokenExpiry.AddMinutes(-5);
|
||||
}
|
||||
else
|
||||
{
|
||||
_webTokenExpiry = DateTime.UtcNow.AddMinutes(55);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Obtained Spotify web access token, expires at {Expiry}, anonymous: {IsAnonymous}",
|
||||
_webTokenExpiry, tokenResponse.IsAnonymous);
|
||||
return _webAccessToken;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting Spotify web access token");
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_tokenLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches TOTP secrets from the pre-scraped secrets repository.
|
||||
/// </summary>
|
||||
private async Task<TotpSecret?> GetTotpSecretAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Return cached secret if fresh (cache for 1 hour)
|
||||
if (_cachedTotpSecret != null && DateTime.UtcNow - _totpSecretFetchedAt < TimeSpan.FromHours(1))
|
||||
{
|
||||
return _cachedTotpSecret;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Fetching TOTP secrets from {Url}", TotpSecretsUrl);
|
||||
|
||||
var response = await _webApiClient.GetAsync(TotpSecretsUrl, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError("Failed to fetch TOTP secrets: {StatusCode}", response.StatusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var secrets = JsonSerializer.Deserialize<TotpSecret[]>(json);
|
||||
|
||||
if (secrets == null || secrets.Length == 0)
|
||||
{
|
||||
_logger.LogError("No TOTP secrets found in response");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use the newest version
|
||||
_cachedTotpSecret = secrets.OrderByDescending(s => s.Version).First();
|
||||
_totpSecretFetchedAt = DateTime.UtcNow;
|
||||
|
||||
_logger.LogDebug("Got TOTP secret version {Version}", _cachedTotpSecret.Version);
|
||||
return _cachedTotpSecret;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching TOTP secrets");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a TOTP code using the secret and server time.
|
||||
/// Based on the Jellyfin plugin implementation.
|
||||
/// </summary>
|
||||
private async Task<(string Otp, long ServerTime)?> GenerateTotpAsync(TotpSecret secret, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get server time from Spotify via HEAD request
|
||||
var headRequest = new HttpRequestMessage(HttpMethod.Head, SpotifyBaseUrl);
|
||||
var response = await _webApiClient.SendAsync(headRequest, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError("Failed to get Spotify server time: {StatusCode}", response.StatusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
var serverTime = response.Headers.Date?.ToUnixTimeSeconds();
|
||||
if (serverTime == null)
|
||||
{
|
||||
_logger.LogError("No Date header in Spotify response");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Compute secret from cipher bytes
|
||||
// The secret bytes need to be transformed: XOR each byte with ((index % 33) + 9)
|
||||
var cipherBytes = secret.Secret.ToArray();
|
||||
var transformedBytes = cipherBytes.Select((b, i) => (byte)(b ^ ((i % 33) + 9))).ToArray();
|
||||
|
||||
// Convert to UTF-8 string representation then back to bytes for TOTP
|
||||
var transformedString = string.Join("", transformedBytes.Select(b => b.ToString()));
|
||||
var utf8Bytes = Encoding.UTF8.GetBytes(transformedString);
|
||||
|
||||
// Generate TOTP
|
||||
var totp = new Totp(utf8Bytes, step: 30, totpSize: 6);
|
||||
var otp = totp.ComputeTotp(DateTime.UnixEpoch.AddSeconds(serverTime.Value));
|
||||
|
||||
_logger.LogDebug("Generated TOTP for server time {ServerTime}", serverTime.Value);
|
||||
return (otp, serverTime.Value);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error generating TOTP");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a playlist with all its tracks from Spotify using the GraphQL API.
|
||||
/// This matches the approach used by the Jellyfin Spotify Import plugin.
|
||||
/// </summary>
|
||||
/// <param name="playlistId">Spotify playlist ID or URI</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Playlist with tracks in correct order, or null if not found</returns>
|
||||
public async Task<SpotifyPlaylist?> GetPlaylistAsync(string playlistId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Extract ID from URI if needed (spotify:playlist:xxxxx or https://open.spotify.com/playlist/xxxxx)
|
||||
playlistId = ExtractPlaylistId(playlistId);
|
||||
|
||||
var token = await GetWebAccessTokenAsync(cancellationToken);
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
_logger.LogError("Cannot fetch playlist without access token");
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Use GraphQL API (same as Jellyfin plugin) - more reliable and less rate-limited
|
||||
return await FetchPlaylistViaGraphQLAsync(playlistId, token, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching playlist {PlaylistId}", playlistId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetch playlist using Spotify's GraphQL API (api-partner.spotify.com/pathfinder/v1/query)
|
||||
/// This is the same approach used by the Jellyfin Spotify Import plugin
|
||||
/// </summary>
|
||||
private async Task<SpotifyPlaylist?> FetchPlaylistViaGraphQLAsync(
|
||||
string playlistId,
|
||||
string token,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const int pageLimit = 50;
|
||||
var offset = 0;
|
||||
var totalTrackCount = pageLimit;
|
||||
var tracks = new List<SpotifyPlaylistTrack>();
|
||||
|
||||
SpotifyPlaylist? playlist = null;
|
||||
|
||||
while (tracks.Count < totalTrackCount && offset < totalTrackCount)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) break;
|
||||
|
||||
// Build GraphQL query URL (same as Jellyfin plugin)
|
||||
var queryParams = new Dictionary<string, string>
|
||||
{
|
||||
{ "operationName", "fetchPlaylist" },
|
||||
{ "variables", $"{{\"uri\":\"spotify:playlist:{playlistId}\",\"offset\":{offset},\"limit\":{pageLimit}}}" },
|
||||
{ "extensions", "{\"persistedQuery\":{\"version\":1,\"sha256Hash\":\"19ff1327c29e99c208c86d7a9d8f1929cfdf3d3202a0ff4253c821f1901aa94d\"}}" }
|
||||
};
|
||||
|
||||
var queryString = string.Join("&", queryParams.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"));
|
||||
var url = $"{WebApiBase}/query?{queryString}";
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var response = await _webApiClient.SendAsync(request, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError("Failed to fetch playlist via GraphQL: {StatusCode}", response.StatusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
if (!doc.RootElement.TryGetProperty("data", out var data) ||
|
||||
!data.TryGetProperty("playlistV2", out var playlistV2))
|
||||
{
|
||||
_logger.LogError("Invalid GraphQL response structure");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse playlist metadata on first iteration
|
||||
if (playlist == null)
|
||||
{
|
||||
playlist = ParseGraphQLPlaylist(playlistV2, playlistId);
|
||||
if (playlist == null) return null;
|
||||
}
|
||||
|
||||
// Parse tracks from this page
|
||||
if (playlistV2.TryGetProperty("content", out var content))
|
||||
{
|
||||
if (content.TryGetProperty("totalCount", out var totalCount))
|
||||
{
|
||||
totalTrackCount = totalCount.GetInt32();
|
||||
}
|
||||
|
||||
if (content.TryGetProperty("items", out var items))
|
||||
{
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
var track = ParseGraphQLTrack(item, offset + tracks.Count);
|
||||
if (track != null)
|
||||
{
|
||||
tracks.Add(track);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
offset += pageLimit;
|
||||
}
|
||||
|
||||
if (playlist != null)
|
||||
{
|
||||
playlist.Tracks = tracks;
|
||||
playlist.TotalTracks = tracks.Count;
|
||||
_logger.LogInformation("Fetched playlist '{Name}' with {Count} tracks via GraphQL", playlist.Name, tracks.Count);
|
||||
}
|
||||
|
||||
return playlist;
|
||||
}
|
||||
|
||||
private SpotifyPlaylist? ParseGraphQLPlaylist(JsonElement playlistV2, string playlistId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var name = playlistV2.TryGetProperty("name", out var n) ? n.GetString() : "Unknown Playlist";
|
||||
var description = playlistV2.TryGetProperty("description", out var d) ? d.GetString() : null;
|
||||
|
||||
string? ownerName = null;
|
||||
if (playlistV2.TryGetProperty("ownerV2", out var owner) &&
|
||||
owner.TryGetProperty("data", out var ownerData) &&
|
||||
ownerData.TryGetProperty("name", out var ownerNameProp))
|
||||
{
|
||||
ownerName = ownerNameProp.GetString();
|
||||
}
|
||||
|
||||
return new SpotifyPlaylist
|
||||
{
|
||||
SpotifyId = playlistId,
|
||||
Name = name ?? "Unknown Playlist",
|
||||
Description = description,
|
||||
OwnerName = ownerName,
|
||||
FetchedAt = DateTime.UtcNow,
|
||||
Tracks = new List<SpotifyPlaylistTrack>()
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse GraphQL playlist metadata");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private SpotifyPlaylistTrack? ParseGraphQLTrack(JsonElement item, int position)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!item.TryGetProperty("itemV2", out var itemV2) ||
|
||||
!itemV2.TryGetProperty("data", out var data))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trackId = data.TryGetProperty("uri", out var uri) ? uri.GetString()?.Replace("spotify:track:", "") : null;
|
||||
var name = data.TryGetProperty("name", out var n) ? n.GetString() : null;
|
||||
|
||||
if (string.IsNullOrEmpty(trackId) || string.IsNullOrEmpty(name))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse artists
|
||||
var artists = new List<string>();
|
||||
if (data.TryGetProperty("artists", out var artistsObj) &&
|
||||
artistsObj.TryGetProperty("items", out var artistItems))
|
||||
{
|
||||
foreach (var artist in artistItems.EnumerateArray())
|
||||
{
|
||||
if (artist.TryGetProperty("profile", out var profile) &&
|
||||
profile.TryGetProperty("name", out var artistName))
|
||||
{
|
||||
var artistNameStr = artistName.GetString();
|
||||
if (!string.IsNullOrEmpty(artistNameStr))
|
||||
{
|
||||
artists.Add(artistNameStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse album
|
||||
string? albumName = null;
|
||||
if (data.TryGetProperty("albumOfTrack", out var album) &&
|
||||
album.TryGetProperty("name", out var albumNameProp))
|
||||
{
|
||||
albumName = albumNameProp.GetString();
|
||||
}
|
||||
|
||||
// Parse duration
|
||||
int durationMs = 0;
|
||||
if (data.TryGetProperty("trackDuration", out var duration) &&
|
||||
duration.TryGetProperty("totalMilliseconds", out var durationMsProp))
|
||||
{
|
||||
durationMs = durationMsProp.GetInt32();
|
||||
}
|
||||
|
||||
// Parse album art
|
||||
string? albumArtUrl = null;
|
||||
if (data.TryGetProperty("albumOfTrack", out var albumOfTrack) &&
|
||||
albumOfTrack.TryGetProperty("coverArt", out var coverArt) &&
|
||||
coverArt.TryGetProperty("sources", out var sources) &&
|
||||
sources.GetArrayLength() > 0)
|
||||
{
|
||||
var firstSource = sources[0];
|
||||
if (firstSource.TryGetProperty("url", out var urlProp))
|
||||
{
|
||||
albumArtUrl = urlProp.GetString();
|
||||
}
|
||||
}
|
||||
|
||||
return new SpotifyPlaylistTrack
|
||||
{
|
||||
SpotifyId = trackId,
|
||||
Title = name,
|
||||
Artists = artists,
|
||||
Album = albumName ?? string.Empty,
|
||||
DurationMs = durationMs,
|
||||
Position = position,
|
||||
AlbumArtUrl = albumArtUrl,
|
||||
Isrc = null // GraphQL doesn't return ISRC, we'll fetch it separately if needed
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse GraphQL track");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<SpotifyPlaylist?> FetchPlaylistMetadataAsync(
|
||||
string playlistId,
|
||||
string token,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var url = $"{OfficialApiBase}/playlists/{playlistId}?fields=id,name,description,owner(display_name,id),images,collaborative,public,snapshot_id,tracks.total";
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError("Failed to fetch playlist metadata: {StatusCode}", response.StatusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var playlist = new SpotifyPlaylist
|
||||
{
|
||||
SpotifyId = root.GetProperty("id").GetString() ?? playlistId,
|
||||
Name = root.GetProperty("name").GetString() ?? "Unknown Playlist",
|
||||
Description = root.TryGetProperty("description", out var desc) ? desc.GetString() : null,
|
||||
SnapshotId = root.TryGetProperty("snapshot_id", out var snap) ? snap.GetString() : null,
|
||||
Collaborative = root.TryGetProperty("collaborative", out var collab) && collab.GetBoolean(),
|
||||
Public = root.TryGetProperty("public", out var pub) && pub.ValueKind != JsonValueKind.Null && pub.GetBoolean(),
|
||||
FetchedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
if (root.TryGetProperty("owner", out var owner))
|
||||
{
|
||||
playlist.OwnerName = owner.TryGetProperty("display_name", out var dn) ? dn.GetString() : null;
|
||||
playlist.OwnerId = owner.TryGetProperty("id", out var oid) ? oid.GetString() : null;
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("images", out var images) && images.GetArrayLength() > 0)
|
||||
{
|
||||
playlist.ImageUrl = images[0].GetProperty("url").GetString();
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("tracks", out var tracks) && tracks.TryGetProperty("total", out var total))
|
||||
{
|
||||
playlist.TotalTracks = total.GetInt32();
|
||||
}
|
||||
|
||||
return playlist;
|
||||
}
|
||||
|
||||
private async Task<List<SpotifyPlaylistTrack>> FetchAllPlaylistTracksAsync(
|
||||
string playlistId,
|
||||
string token,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var allTracks = new List<SpotifyPlaylistTrack>();
|
||||
var offset = 0;
|
||||
const int limit = 100; // Spotify's max
|
||||
|
||||
while (true)
|
||||
{
|
||||
var tracks = await FetchPlaylistTracksPageAsync(playlistId, token, offset, limit, cancellationToken);
|
||||
if (tracks == null || tracks.Count == 0) break;
|
||||
|
||||
allTracks.AddRange(tracks);
|
||||
|
||||
if (tracks.Count < limit) break;
|
||||
|
||||
offset += limit;
|
||||
|
||||
// Rate limiting
|
||||
if (_settings.RateLimitDelayMs > 0)
|
||||
{
|
||||
await Task.Delay(_settings.RateLimitDelayMs, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
return allTracks;
|
||||
}
|
||||
|
||||
private async Task<List<SpotifyPlaylistTrack>?> FetchPlaylistTracksPageAsync(
|
||||
string playlistId,
|
||||
string token,
|
||||
int offset,
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Request fields needed for matching and ordering
|
||||
var fields = "items(added_at,track(id,name,album(id,name,images,release_date),artists(id,name),duration_ms,explicit,popularity,preview_url,disc_number,track_number,external_ids))";
|
||||
var url = $"{OfficialApiBase}/playlists/{playlistId}/tracks?offset={offset}&limit={limit}&fields={fields}";
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError("Failed to fetch playlist tracks: {StatusCode}", response.StatusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (!root.TryGetProperty("items", out var items))
|
||||
{
|
||||
return new List<SpotifyPlaylistTrack>();
|
||||
}
|
||||
|
||||
var tracks = new List<SpotifyPlaylistTrack>();
|
||||
var position = offset;
|
||||
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
// Skip null tracks (can happen with deleted/unavailable tracks)
|
||||
if (!item.TryGetProperty("track", out var trackElement) ||
|
||||
trackElement.ValueKind == JsonValueKind.Null)
|
||||
{
|
||||
position++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var track = ParseTrack(trackElement, position);
|
||||
|
||||
// Parse added_at timestamp
|
||||
if (item.TryGetProperty("added_at", out var addedAt) &&
|
||||
addedAt.ValueKind != JsonValueKind.Null)
|
||||
{
|
||||
var addedAtStr = addedAt.GetString();
|
||||
if (DateTime.TryParse(addedAtStr, out var addedAtDate))
|
||||
{
|
||||
track.AddedAt = addedAtDate;
|
||||
}
|
||||
}
|
||||
|
||||
tracks.Add(track);
|
||||
position++;
|
||||
}
|
||||
|
||||
return tracks;
|
||||
}
|
||||
|
||||
private SpotifyPlaylistTrack ParseTrack(JsonElement track, int position)
|
||||
{
|
||||
var result = new SpotifyPlaylistTrack
|
||||
{
|
||||
Position = position,
|
||||
SpotifyId = track.TryGetProperty("id", out var id) ? id.GetString() ?? "" : "",
|
||||
Title = track.TryGetProperty("name", out var name) ? name.GetString() ?? "" : "",
|
||||
DurationMs = track.TryGetProperty("duration_ms", out var dur) ? dur.GetInt32() : 0,
|
||||
Explicit = track.TryGetProperty("explicit", out var exp) && exp.GetBoolean(),
|
||||
Popularity = track.TryGetProperty("popularity", out var pop) ? pop.GetInt32() : 0,
|
||||
PreviewUrl = track.TryGetProperty("preview_url", out var prev) && prev.ValueKind != JsonValueKind.Null
|
||||
? prev.GetString() : null,
|
||||
DiscNumber = track.TryGetProperty("disc_number", out var disc) ? disc.GetInt32() : 1,
|
||||
TrackNumber = track.TryGetProperty("track_number", out var tn) ? tn.GetInt32() : 1
|
||||
};
|
||||
|
||||
// Parse album
|
||||
if (track.TryGetProperty("album", out var album))
|
||||
{
|
||||
result.Album = album.TryGetProperty("name", out var albumName)
|
||||
? albumName.GetString() ?? "" : "";
|
||||
result.AlbumId = album.TryGetProperty("id", out var albumId)
|
||||
? albumId.GetString() ?? "" : "";
|
||||
result.ReleaseDate = album.TryGetProperty("release_date", out var rd)
|
||||
? rd.GetString() : null;
|
||||
|
||||
if (album.TryGetProperty("images", out var images) && images.GetArrayLength() > 0)
|
||||
{
|
||||
result.AlbumArtUrl = images[0].GetProperty("url").GetString();
|
||||
}
|
||||
}
|
||||
|
||||
// Parse artists
|
||||
if (track.TryGetProperty("artists", out var artists))
|
||||
{
|
||||
foreach (var artist in artists.EnumerateArray())
|
||||
{
|
||||
if (artist.TryGetProperty("name", out var artistName))
|
||||
{
|
||||
result.Artists.Add(artistName.GetString() ?? "");
|
||||
}
|
||||
if (artist.TryGetProperty("id", out var artistId))
|
||||
{
|
||||
result.ArtistIds.Add(artistId.GetString() ?? "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse ISRC from external_ids
|
||||
if (track.TryGetProperty("external_ids", out var externalIds) &&
|
||||
externalIds.TryGetProperty("isrc", out var isrc))
|
||||
{
|
||||
result.Isrc = isrc.GetString();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches for a user's playlists by name.
|
||||
/// Useful for finding playlists like "Release Radar" or "Discover Weekly" by their names.
|
||||
/// </summary>
|
||||
public async Task<List<SpotifyPlaylist>> SearchUserPlaylistsAsync(
|
||||
string searchName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var token = await GetWebAccessTokenAsync(cancellationToken);
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
return new List<SpotifyPlaylist>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var playlists = new List<SpotifyPlaylist>();
|
||||
var offset = 0;
|
||||
const int limit = 50;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var url = $"{OfficialApiBase}/me/playlists?offset={offset}&limit={limit}";
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode) break;
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (!root.TryGetProperty("items", out var items) || items.GetArrayLength() == 0)
|
||||
break;
|
||||
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
var itemName = item.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
|
||||
|
||||
// Check if name matches (case-insensitive)
|
||||
if (itemName.Contains(searchName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
playlists.Add(new SpotifyPlaylist
|
||||
{
|
||||
SpotifyId = item.TryGetProperty("id", out var itemId) ? itemId.GetString() ?? "" : "",
|
||||
Name = itemName,
|
||||
Description = item.TryGetProperty("description", out var desc) ? desc.GetString() : null,
|
||||
TotalTracks = item.TryGetProperty("tracks", out var tracks) &&
|
||||
tracks.TryGetProperty("total", out var total)
|
||||
? total.GetInt32() : 0,
|
||||
SnapshotId = item.TryGetProperty("snapshot_id", out var snap) ? snap.GetString() : null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (items.GetArrayLength() < limit) break;
|
||||
offset += limit;
|
||||
|
||||
if (_settings.RateLimitDelayMs > 0)
|
||||
{
|
||||
await Task.Delay(_settings.RateLimitDelayMs, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
return playlists;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error searching user playlists for '{SearchName}'", searchName);
|
||||
return new List<SpotifyPlaylist>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current user's profile to verify authentication is working.
|
||||
/// </summary>
|
||||
public async Task<(bool Success, string? UserId, string? DisplayName)> GetCurrentUserAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var token = await GetWebAccessTokenAsync(cancellationToken);
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
return (false, null, null);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, $"{OfficialApiBase}/me");
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogWarning("Spotify /me endpoint returned {StatusCode}: {Body}", response.StatusCode, errorBody);
|
||||
return (false, null, null);
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var userId = root.TryGetProperty("id", out var id) ? id.GetString() : null;
|
||||
var displayName = root.TryGetProperty("display_name", out var dn) ? dn.GetString() : null;
|
||||
|
||||
return (true, userId, displayName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting current Spotify user");
|
||||
return (false, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ExtractPlaylistId(string input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input)) return input;
|
||||
|
||||
// Handle spotify:playlist:xxxxx format
|
||||
if (input.StartsWith("spotify:playlist:"))
|
||||
{
|
||||
return input.Substring("spotify:playlist:".Length);
|
||||
}
|
||||
|
||||
// Handle https://open.spotify.com/playlist/xxxxx format
|
||||
if (input.Contains("open.spotify.com/playlist/"))
|
||||
{
|
||||
var start = input.IndexOf("/playlist/") + "/playlist/".Length;
|
||||
var end = input.IndexOf('?', start);
|
||||
return end > 0 ? input.Substring(start, end - start) : input.Substring(start);
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
_webApiClient.Dispose();
|
||||
_tokenLock.Dispose();
|
||||
}
|
||||
|
||||
// Internal classes for JSON deserialization
|
||||
private class SpotifyTokenResponse
|
||||
{
|
||||
[JsonPropertyName("accessToken")]
|
||||
public string AccessToken { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("accessTokenExpirationTimestampMs")]
|
||||
public long ExpirationTimestampMs { get; set; }
|
||||
|
||||
[JsonPropertyName("isAnonymous")]
|
||||
public bool IsAnonymous { get; set; }
|
||||
|
||||
[JsonPropertyName("clientId")]
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private class TotpSecret
|
||||
{
|
||||
[JsonPropertyName("version")]
|
||||
public int Version { get; set; }
|
||||
|
||||
[JsonPropertyName("secret")]
|
||||
public List<byte> Secret { get; set; } = new();
|
||||
}
|
||||
}
|
||||
673
allstarr/Services/Spotify/SpotifyMissingTracksFetcher.cs
Normal file
673
allstarr/Services/Spotify/SpotifyMissingTracksFetcher.cs
Normal file
@@ -0,0 +1,673 @@
|
||||
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<SpotifyApiSettings> _spotifyApiSettings;
|
||||
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<SpotifyApiSettings> spotifyApiSettings,
|
||||
IOptions<JellyfinSettings> jellyfinSettings,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
RedisCacheService cache,
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<SpotifyMissingTracksFetcher> logger)
|
||||
{
|
||||
_spotifySettings = spotifySettings;
|
||||
_spotifyApiSettings = spotifyApiSettings;
|
||||
_jellyfinSettings = jellyfinSettings;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_cache = cache;
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Public method to trigger fetching manually (called from controller).
|
||||
/// </summary>
|
||||
public async Task TriggerFetchAsync()
|
||||
{
|
||||
_logger.LogInformation("Manual fetch triggered");
|
||||
await FetchMissingTracksAsync(CancellationToken.None, bypassSyncWindowCheck: true);
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("========================================");
|
||||
_logger.LogInformation("SpotifyMissingTracksFetcher: Starting up...");
|
||||
|
||||
// Ensure cache directory exists
|
||||
Directory.CreateDirectory(CacheDirectory);
|
||||
|
||||
// Check if SpotifyApi is enabled with a valid session cookie
|
||||
// If so, SpotifyPlaylistFetcher will handle everything - we don't need to scrape Jellyfin
|
||||
if (_spotifyApiSettings.Value.Enabled && !string.IsNullOrEmpty(_spotifyApiSettings.Value.SessionCookie))
|
||||
{
|
||||
_logger.LogInformation("SpotifyApi is enabled with session cookie - using direct Spotify API instead of Jellyfin scraping");
|
||||
_logger.LogInformation("This service will remain dormant. SpotifyPlaylistFetcher is handling playlists.");
|
||||
_logger.LogInformation("========================================");
|
||||
return;
|
||||
}
|
||||
|
||||
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 Playlists: {Count}", _spotifySettings.Value.Playlists.Count);
|
||||
|
||||
// Log the search schedule
|
||||
var settings = _spotifySettings.Value;
|
||||
var syncTime = DateTime.Today
|
||||
.AddHours(settings.SyncStartHour)
|
||||
.AddMinutes(settings.SyncStartMinute);
|
||||
var syncEndTime = syncTime.AddHours(settings.SyncWindowHours);
|
||||
|
||||
_logger.LogInformation("Search Schedule:");
|
||||
_logger.LogInformation(" Plugin sync time: {Time:HH:mm} UTC (configured)", syncTime);
|
||||
_logger.LogInformation(" Search window: {Start:HH:mm} - {End:HH:mm} UTC ({Hours}h window)",
|
||||
syncTime, syncEndTime, settings.SyncWindowHours);
|
||||
_logger.LogInformation(" Will search for new files once per day after sync window ends");
|
||||
_logger.LogInformation(" Background check interval: 5 minutes");
|
||||
|
||||
// Fetch playlist names from Jellyfin
|
||||
await LoadPlaylistNamesAsync();
|
||||
|
||||
_logger.LogInformation("Configured Playlists:");
|
||||
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 - already have current files");
|
||||
_hasRunOnce = true;
|
||||
}
|
||||
}
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Only fetch if we're past today's sync window AND we haven't fetched today yet
|
||||
var shouldFetch = await ShouldFetchNowAsync();
|
||||
if (shouldFetch)
|
||||
{
|
||||
await FetchMissingTracksAsync(stoppingToken);
|
||||
_hasRunOnce = true;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching Spotify missing tracks");
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> ShouldFetchNowAsync()
|
||||
{
|
||||
var settings = _spotifySettings.Value;
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Calculate today's sync window
|
||||
var todaySync = now.Date
|
||||
.AddHours(settings.SyncStartHour)
|
||||
.AddMinutes(settings.SyncStartMinute);
|
||||
var todaySyncEnd = todaySync.AddHours(settings.SyncWindowHours);
|
||||
|
||||
// Only fetch if we're past today's sync window
|
||||
if (now < todaySyncEnd)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if we already have today's files
|
||||
foreach (var playlistName in _playlistIdToName.Values)
|
||||
{
|
||||
var filePath = GetCacheFilePath(playlistName);
|
||||
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
var fileTime = File.GetLastWriteTimeUtc(filePath);
|
||||
|
||||
// If file is from today's sync or later, we already have it
|
||||
if (fileTime >= todaySync)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Missing today's file for this playlist
|
||||
return true;
|
||||
}
|
||||
|
||||
// All playlists have today's files
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task LoadPlaylistNamesAsync()
|
||||
{
|
||||
_playlistIdToName.Clear();
|
||||
|
||||
// Use configured playlists
|
||||
foreach (var playlist in _spotifySettings.Value.Playlists)
|
||||
{
|
||||
_playlistIdToName[playlist.Id] = playlist.Name;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> ShouldRunOnStartupAsync()
|
||||
{
|
||||
_logger.LogInformation("=== STARTUP CACHE CHECK ===");
|
||||
|
||||
var settings = _spotifySettings.Value;
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Calculate today's sync window
|
||||
var todaySync = now.Date
|
||||
.AddHours(settings.SyncStartHour)
|
||||
.AddMinutes(settings.SyncStartMinute);
|
||||
var todaySyncEnd = todaySync.AddHours(settings.SyncWindowHours);
|
||||
|
||||
_logger.LogInformation("Today's sync window: {Start:yyyy-MM-dd HH:mm} - {End:yyyy-MM-dd HH:mm} UTC",
|
||||
todaySync, todaySyncEnd);
|
||||
_logger.LogInformation("Current time: {Now:yyyy-MM-dd HH:mm} UTC", now);
|
||||
|
||||
// If we're still before today's sync window end, we should have yesterday's or today's file
|
||||
// Don't search again until after today's sync window ends
|
||||
if (now < todaySyncEnd)
|
||||
{
|
||||
_logger.LogInformation("We're before today's sync window end - checking if we have recent cache...");
|
||||
|
||||
var allPlaylistsHaveCache = true;
|
||||
|
||||
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 ===");
|
||||
_logger.LogInformation("Will search again after {Time:yyyy-MM-dd HH:mm} UTC", todaySyncEnd);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// If we're after today's sync window end, check if we already have today's file
|
||||
if (now >= todaySyncEnd)
|
||||
{
|
||||
_logger.LogInformation("We're after today's sync window end - checking if we already fetched today's files...");
|
||||
|
||||
var allPlaylistsHaveTodaysFile = true;
|
||||
|
||||
foreach (var playlistName in _playlistIdToName.Values)
|
||||
{
|
||||
var filePath = GetCacheFilePath(playlistName);
|
||||
var cacheKey = $"spotify:missing:{playlistName}";
|
||||
|
||||
// Check if file exists and was created today (after sync start)
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
var fileTime = File.GetLastWriteTimeUtc(filePath);
|
||||
|
||||
// File should be from today's sync window or later
|
||||
if (fileTime >= todaySync)
|
||||
{
|
||||
var fileAge = DateTime.UtcNow - fileTime;
|
||||
_logger.LogInformation(" {Playlist}: Have today's file (created {Time:yyyy-MM-dd HH:mm}, age: {Age:F1}h)",
|
||||
playlistName, fileTime, fileAge.TotalHours);
|
||||
|
||||
// Load into Redis if not already there
|
||||
if (!await _cache.ExistsAsync(cacheKey))
|
||||
{
|
||||
await LoadFromFileCache(playlistName);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(" {Playlist}: File is old (from {Time:yyyy-MM-dd HH:mm}, before today's sync)",
|
||||
playlistName, fileTime);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(" {Playlist}: No file found", playlistName);
|
||||
}
|
||||
|
||||
allPlaylistsHaveTodaysFile = false;
|
||||
}
|
||||
|
||||
if (allPlaylistsHaveTodaysFile)
|
||||
{
|
||||
_logger.LogInformation("=== ALL PLAYLISTS HAVE TODAY'S FILES - SKIPPING STARTUP FETCH ===");
|
||||
|
||||
// Calculate when to search next (tomorrow after sync window)
|
||||
var tomorrowSyncEnd = todaySyncEnd.AddDays(1);
|
||||
_logger.LogInformation("Will search again after {Time:yyyy-MM-dd HH:mm} UTC", tomorrowSyncEnd);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("=== WILL FETCH ON STARTUP ===");
|
||||
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);
|
||||
|
||||
// Track when we find files to optimize search for other playlists
|
||||
DateTime? firstFoundTime = null;
|
||||
var foundPlaylists = new HashSet<string>();
|
||||
|
||||
foreach (var kvp in _playlistIdToName)
|
||||
{
|
||||
_logger.LogInformation("Fetching playlist: {Name}", kvp.Value);
|
||||
var foundTime = await FetchPlaylistMissingTracksAsync(kvp.Value, cancellationToken, firstFoundTime);
|
||||
|
||||
if (foundTime.HasValue)
|
||||
{
|
||||
foundPlaylists.Add(kvp.Value);
|
||||
if (!firstFoundTime.HasValue)
|
||||
{
|
||||
firstFoundTime = foundTime;
|
||||
_logger.LogInformation(" → Will search within ±1h of {Time:yyyy-MM-dd HH:mm} for remaining playlists", firstFoundTime.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("=== FINISHED FETCHING MISSING TRACKS ({Found}/{Total} playlists found) ===",
|
||||
foundPlaylists.Count, _playlistIdToName.Count);
|
||||
}
|
||||
|
||||
private async Task<DateTime?> FetchPlaylistMissingTracksAsync(
|
||||
string playlistName,
|
||||
CancellationToken cancellationToken,
|
||||
DateTime? hintTime = null)
|
||||
{
|
||||
var cacheKey = $"spotify:missing:{playlistName}";
|
||||
|
||||
// Check if we have existing cache
|
||||
var existingTracks = await _cache.GetAsync<List<MissingTrack>>(cacheKey);
|
||||
var filePath = GetCacheFilePath(playlistName);
|
||||
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
var fileAge = DateTime.UtcNow - File.GetLastWriteTimeUtc(filePath);
|
||||
_logger.LogInformation(" Existing cache file age: {Age:F1}h", fileAge.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 null;
|
||||
}
|
||||
|
||||
var httpClient = _httpClientFactory.CreateClient();
|
||||
|
||||
// Search starting from 24 hours ahead, going backwards for 72 hours
|
||||
// This handles timezone differences where the plugin may have run "in the future" from our perspective
|
||||
var now = DateTime.UtcNow;
|
||||
var searchStart = now.AddHours(24); // Start 24 hours from now
|
||||
var totalMinutesToSearch = 72 * 60; // 72 hours = 4320 minutes
|
||||
|
||||
_logger.LogInformation(" Current UTC time: {Now:yyyy-MM-dd HH:mm}", now);
|
||||
_logger.LogInformation(" Search start: {Start:yyyy-MM-dd HH:mm} (24h ahead)", searchStart);
|
||||
_logger.LogInformation(" Searching backwards for 72 hours ({Minutes} minutes)", totalMinutesToSearch);
|
||||
|
||||
var found = false;
|
||||
DateTime? foundFileTime = null;
|
||||
|
||||
// If we have a hint time from another playlist, search ±1 hour around it first
|
||||
if (hintTime.HasValue)
|
||||
{
|
||||
_logger.LogInformation(" Hint: Searching ±1h around {Time:yyyy-MM-dd HH:mm} (from another playlist)", hintTime.Value);
|
||||
|
||||
// Search ±60 minutes around the hint time
|
||||
for (var minuteOffset = 0; minuteOffset <= 60; minuteOffset++)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) break;
|
||||
|
||||
// Try both forward and backward from hint
|
||||
if (minuteOffset > 0)
|
||||
{
|
||||
// Try forward
|
||||
var timeForward = hintTime.Value.AddMinutes(minuteOffset);
|
||||
var resultForward = await TryFetchMissingTracksFile(playlistName, timeForward, jellyfinUrl, apiKey, httpClient, cancellationToken);
|
||||
if (resultForward.found)
|
||||
{
|
||||
found = true;
|
||||
foundFileTime = resultForward.fileTime;
|
||||
_logger.LogInformation(" ✓ Found using hint (+{Minutes}min from hint)", minuteOffset);
|
||||
return foundFileTime;
|
||||
}
|
||||
}
|
||||
|
||||
// Try backward
|
||||
var timeBackward = hintTime.Value.AddMinutes(-minuteOffset);
|
||||
var resultBackward = await TryFetchMissingTracksFile(playlistName, timeBackward, jellyfinUrl, apiKey, httpClient, cancellationToken);
|
||||
if (resultBackward.found)
|
||||
{
|
||||
found = true;
|
||||
foundFileTime = resultBackward.fileTime;
|
||||
_logger.LogInformation(" ✓ Found using hint (-{Minutes}min from hint)", minuteOffset);
|
||||
return foundFileTime;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(" Not found within ±1h of hint, doing full search...");
|
||||
}
|
||||
|
||||
// Search from 24h ahead, going backwards minute by minute for 72 hours
|
||||
_logger.LogInformation(" Searching from {Start:yyyy-MM-dd HH:mm} backwards to {End:yyyy-MM-dd HH:mm}...",
|
||||
searchStart, searchStart.AddMinutes(-totalMinutesToSearch));
|
||||
|
||||
for (var minutesBehind = 0; minutesBehind <= totalMinutesToSearch; minutesBehind++)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) break;
|
||||
|
||||
var time = searchStart.AddMinutes(-minutesBehind);
|
||||
|
||||
var result = await TryFetchMissingTracksFile(playlistName, time, jellyfinUrl, apiKey, httpClient, cancellationToken);
|
||||
if (result.found)
|
||||
{
|
||||
found = true;
|
||||
foundFileTime = result.fileTime;
|
||||
return foundFileTime;
|
||||
}
|
||||
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
|
||||
return foundFileTime;
|
||||
}
|
||||
|
||||
private async Task<(bool found, DateTime? fileTime)> TryFetchMissingTracksFile(
|
||||
string playlistName,
|
||||
DateTime time,
|
||||
string jellyfinUrl,
|
||||
string apiKey,
|
||||
HttpClient httpClient,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
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
|
||||
{
|
||||
// Log every request with the actual filename
|
||||
_logger.LogInformation("Checking: {Playlist} at {DateTime}", playlistName, time.ToString("yyyy-MM-dd HH:mm"));
|
||||
|
||||
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)
|
||||
{
|
||||
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(
|
||||
"✓ FOUND! Cached {Count} missing tracks for {Playlist} from {Filename}",
|
||||
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;
|
||||
}
|
||||
}
|
||||
336
allstarr/Services/Spotify/SpotifyPlaylistFetcher.cs
Normal file
336
allstarr/Services/Spotify/SpotifyPlaylistFetcher.cs
Normal file
@@ -0,0 +1,336 @@
|
||||
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 fetches playlist tracks directly from Spotify's API.
|
||||
///
|
||||
/// This replaces the Jellyfin Spotify Import plugin dependency with key advantages:
|
||||
/// - Track ordering is preserved (critical for playlists like Release Radar)
|
||||
/// - ISRC codes available for exact matching
|
||||
/// - Real-time data without waiting for plugin sync schedules
|
||||
/// - Full track metadata (duration, release date, etc.)
|
||||
/// </summary>
|
||||
public class SpotifyPlaylistFetcher : BackgroundService
|
||||
{
|
||||
private readonly ILogger<SpotifyPlaylistFetcher> _logger;
|
||||
private readonly SpotifyApiSettings _spotifyApiSettings;
|
||||
private readonly SpotifyImportSettings _spotifyImportSettings;
|
||||
private readonly SpotifyApiClient _spotifyClient;
|
||||
private readonly RedisCacheService _cache;
|
||||
|
||||
private const string CacheDirectory = "/app/cache/spotify";
|
||||
private const string CacheKeyPrefix = "spotify:playlist:";
|
||||
|
||||
// Track Spotify playlist IDs after discovery
|
||||
private readonly Dictionary<string, string> _playlistNameToSpotifyId = new();
|
||||
|
||||
public SpotifyPlaylistFetcher(
|
||||
ILogger<SpotifyPlaylistFetcher> logger,
|
||||
IOptions<SpotifyApiSettings> spotifyApiSettings,
|
||||
IOptions<SpotifyImportSettings> spotifyImportSettings,
|
||||
SpotifyApiClient spotifyClient,
|
||||
RedisCacheService cache)
|
||||
{
|
||||
_logger = logger;
|
||||
_spotifyApiSettings = spotifyApiSettings.Value;
|
||||
_spotifyImportSettings = spotifyImportSettings.Value;
|
||||
_spotifyClient = spotifyClient;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Spotify playlist tracks in order, using cache if available.
|
||||
/// </summary>
|
||||
/// <param name="playlistName">Playlist name (e.g., "Release Radar", "Discover Weekly")</param>
|
||||
/// <returns>List of tracks in playlist order, or empty list if not found</returns>
|
||||
public async Task<List<SpotifyPlaylistTrack>> GetPlaylistTracksAsync(string playlistName)
|
||||
{
|
||||
var cacheKey = $"{CacheKeyPrefix}{playlistName}";
|
||||
|
||||
// Try Redis cache first
|
||||
var cached = await _cache.GetAsync<SpotifyPlaylist>(cacheKey);
|
||||
if (cached != null && cached.Tracks.Count > 0)
|
||||
{
|
||||
var age = DateTime.UtcNow - cached.FetchedAt;
|
||||
if (age.TotalMinutes < _spotifyApiSettings.CacheDurationMinutes)
|
||||
{
|
||||
_logger.LogDebug("Using cached playlist '{Name}' ({Count} tracks, age: {Age:F1}m)",
|
||||
playlistName, cached.Tracks.Count, age.TotalMinutes);
|
||||
return cached.Tracks;
|
||||
}
|
||||
}
|
||||
|
||||
// Try file cache
|
||||
var filePath = GetCacheFilePath(playlistName);
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(filePath);
|
||||
var filePlaylist = JsonSerializer.Deserialize<SpotifyPlaylist>(json);
|
||||
if (filePlaylist != null && filePlaylist.Tracks.Count > 0)
|
||||
{
|
||||
var age = DateTime.UtcNow - filePlaylist.FetchedAt;
|
||||
if (age.TotalMinutes < _spotifyApiSettings.CacheDurationMinutes)
|
||||
{
|
||||
_logger.LogDebug("Using file-cached playlist '{Name}' ({Count} tracks)",
|
||||
playlistName, filePlaylist.Tracks.Count);
|
||||
return filePlaylist.Tracks;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to read file cache for '{Name}'", playlistName);
|
||||
}
|
||||
}
|
||||
|
||||
// Need to fetch fresh - try to use cached or configured Spotify playlist ID
|
||||
if (!_playlistNameToSpotifyId.TryGetValue(playlistName, out var spotifyId))
|
||||
{
|
||||
// Check if we have a configured Spotify ID for this playlist
|
||||
var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName);
|
||||
if (playlistConfig != null && !string.IsNullOrEmpty(playlistConfig.Id))
|
||||
{
|
||||
// Use the configured Spotify playlist ID directly
|
||||
spotifyId = playlistConfig.Id;
|
||||
_playlistNameToSpotifyId[playlistName] = spotifyId;
|
||||
_logger.LogInformation("Using configured Spotify playlist ID for '{Name}': {Id}", playlistName, spotifyId);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No configured ID, try searching by name (works for public/followed playlists)
|
||||
_logger.LogDebug("No configured Spotify ID for '{Name}', searching...", playlistName);
|
||||
var playlists = await _spotifyClient.SearchUserPlaylistsAsync(playlistName);
|
||||
|
||||
var exactMatch = playlists.FirstOrDefault(p =>
|
||||
p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (exactMatch == null)
|
||||
{
|
||||
_logger.LogWarning("Could not find Spotify playlist named '{Name}' - try configuring the Spotify playlist ID", playlistName);
|
||||
|
||||
// Return file cache even if expired, as a fallback
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(filePath);
|
||||
var fallback = JsonSerializer.Deserialize<SpotifyPlaylist>(json);
|
||||
if (fallback != null)
|
||||
{
|
||||
_logger.LogWarning("Using expired file cache as fallback for '{Name}'", playlistName);
|
||||
return fallback.Tracks;
|
||||
}
|
||||
}
|
||||
|
||||
return new List<SpotifyPlaylistTrack>();
|
||||
}
|
||||
|
||||
spotifyId = exactMatch.SpotifyId;
|
||||
_playlistNameToSpotifyId[playlistName] = spotifyId;
|
||||
_logger.LogInformation("Found Spotify playlist '{Name}' with ID: {Id}", playlistName, spotifyId);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the full playlist
|
||||
var playlist = await _spotifyClient.GetPlaylistAsync(spotifyId);
|
||||
if (playlist == null || playlist.Tracks.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("Failed to fetch playlist '{Name}' from Spotify", playlistName);
|
||||
return cached?.Tracks ?? new List<SpotifyPlaylistTrack>();
|
||||
}
|
||||
|
||||
// Update cache
|
||||
await _cache.SetAsync(cacheKey, playlist, TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes * 2));
|
||||
await SaveToFileCacheAsync(playlistName, playlist);
|
||||
|
||||
_logger.LogInformation("Fetched and cached playlist '{Name}' with {Count} tracks in order",
|
||||
playlistName, playlist.Tracks.Count);
|
||||
|
||||
return playlist.Tracks;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets missing tracks for a playlist (tracks not found in Jellyfin library).
|
||||
/// This provides compatibility with the existing SpotifyMissingTracksFetcher interface.
|
||||
/// </summary>
|
||||
/// <param name="playlistName">Playlist name</param>
|
||||
/// <param name="jellyfinTrackIds">Set of Spotify IDs that exist in Jellyfin library</param>
|
||||
/// <returns>List of missing tracks with position preserved</returns>
|
||||
public async Task<List<SpotifyPlaylistTrack>> GetMissingTracksAsync(
|
||||
string playlistName,
|
||||
HashSet<string> jellyfinTrackIds)
|
||||
{
|
||||
var allTracks = await GetPlaylistTracksAsync(playlistName);
|
||||
|
||||
// Filter to only tracks not in Jellyfin, preserving order
|
||||
return allTracks
|
||||
.Where(t => !jellyfinTrackIds.Contains(t.SpotifyId))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manual trigger to refresh a specific playlist.
|
||||
/// </summary>
|
||||
public async Task RefreshPlaylistAsync(string playlistName)
|
||||
{
|
||||
_logger.LogInformation("Manual refresh triggered for playlist '{Name}'", playlistName);
|
||||
|
||||
// Clear cache to force refresh
|
||||
var cacheKey = $"{CacheKeyPrefix}{playlistName}";
|
||||
await _cache.DeleteAsync(cacheKey);
|
||||
|
||||
// Re-fetch
|
||||
await GetPlaylistTracksAsync(playlistName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manual trigger to refresh all configured playlists.
|
||||
/// </summary>
|
||||
public async Task TriggerFetchAsync()
|
||||
{
|
||||
_logger.LogInformation("Manual fetch triggered for all playlists");
|
||||
|
||||
foreach (var config in _spotifyImportSettings.Playlists)
|
||||
{
|
||||
await RefreshPlaylistAsync(config.Name);
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("========================================");
|
||||
_logger.LogInformation("SpotifyPlaylistFetcher: Starting up...");
|
||||
|
||||
// Ensure cache directory exists
|
||||
Directory.CreateDirectory(CacheDirectory);
|
||||
|
||||
if (!_spotifyApiSettings.Enabled)
|
||||
{
|
||||
_logger.LogInformation("Spotify API integration is DISABLED");
|
||||
_logger.LogInformation("========================================");
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie))
|
||||
{
|
||||
_logger.LogWarning("Spotify session cookie not configured - cannot access editorial playlists");
|
||||
_logger.LogInformation("========================================");
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify we can get an access token (the most reliable auth check)
|
||||
_logger.LogInformation("Attempting Spotify authentication...");
|
||||
var token = await _spotifyClient.GetWebAccessTokenAsync(stoppingToken);
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
_logger.LogError("Failed to get Spotify access token - check session cookie");
|
||||
_logger.LogInformation("========================================");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Spotify API ENABLED");
|
||||
_logger.LogInformation("Authenticated via sp_dc session cookie");
|
||||
_logger.LogInformation("Cache duration: {Minutes} minutes", _spotifyApiSettings.CacheDurationMinutes);
|
||||
_logger.LogInformation("ISRC matching: {Enabled}", _spotifyApiSettings.PreferIsrcMatching ? "enabled" : "disabled");
|
||||
_logger.LogInformation("Configured Playlists: {Count}", _spotifyImportSettings.Playlists.Count);
|
||||
|
||||
foreach (var playlist in _spotifyImportSettings.Playlists)
|
||||
{
|
||||
_logger.LogInformation(" - {Name}", playlist.Name);
|
||||
}
|
||||
|
||||
_logger.LogInformation("========================================");
|
||||
|
||||
// Initial fetch of all playlists
|
||||
await FetchAllPlaylistsAsync(stoppingToken);
|
||||
|
||||
// Periodic refresh loop
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes), stoppingToken);
|
||||
|
||||
try
|
||||
{
|
||||
await FetchAllPlaylistsAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error during periodic playlist refresh");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task FetchAllPlaylistsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("=== FETCHING SPOTIFY PLAYLISTS ===");
|
||||
|
||||
foreach (var config in _spotifyImportSettings.Playlists)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) break;
|
||||
|
||||
try
|
||||
{
|
||||
var tracks = await GetPlaylistTracksAsync(config.Name);
|
||||
_logger.LogInformation(" {Name}: {Count} tracks", config.Name, tracks.Count);
|
||||
|
||||
// Log sample of track order for debugging
|
||||
if (tracks.Count > 0)
|
||||
{
|
||||
_logger.LogDebug(" First track: #{Position} {Title} - {Artist}",
|
||||
tracks[0].Position, tracks[0].Title, tracks[0].PrimaryArtist);
|
||||
|
||||
if (tracks.Count > 1)
|
||||
{
|
||||
var last = tracks[^1];
|
||||
_logger.LogDebug(" Last track: #{Position} {Title} - {Artist}",
|
||||
last.Position, last.Title, last.PrimaryArtist);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching playlist '{Name}'", config.Name);
|
||||
}
|
||||
|
||||
// Rate limiting between playlists - Spotify is VERY aggressive with rate limiting
|
||||
// Wait 3 seconds between each playlist to avoid 429 TooManyRequests errors
|
||||
if (config != _spotifyImportSettings.Playlists.Last())
|
||||
{
|
||||
_logger.LogDebug("Waiting 3 seconds before next playlist to avoid rate limits...");
|
||||
await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("=== FINISHED FETCHING SPOTIFY PLAYLISTS ===");
|
||||
}
|
||||
|
||||
private string GetCacheFilePath(string playlistName)
|
||||
{
|
||||
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
|
||||
return Path.Combine(CacheDirectory, $"{safeName}_spotify.json");
|
||||
}
|
||||
|
||||
private async Task SaveToFileCacheAsync(string playlistName, SpotifyPlaylist playlist)
|
||||
{
|
||||
try
|
||||
{
|
||||
var filePath = GetCacheFilePath(playlistName);
|
||||
var json = JsonSerializer.Serialize(playlist, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
await File.WriteAllTextAsync(filePath, json);
|
||||
_logger.LogDebug("Saved playlist '{Name}' to file cache", playlistName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to save file cache for '{Name}'", playlistName);
|
||||
}
|
||||
}
|
||||
}
|
||||
1083
allstarr/Services/Spotify/SpotifyTrackMatchingService.cs
Normal file
1083
allstarr/Services/Spotify/SpotifyTrackMatchingService.cs
Normal file
@@ -0,0 +1,1083 @@
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Models.Spotify;
|
||||
using allstarr.Services.Common;
|
||||
using allstarr.Services.Jellyfin;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace allstarr.Services.Spotify;
|
||||
|
||||
/// <summary>
|
||||
/// Background service that pre-matches Spotify tracks with external providers.
|
||||
///
|
||||
/// Supports two modes:
|
||||
/// 1. Legacy mode: Uses MissingTrack from Jellyfin plugin (no ISRC, no ordering)
|
||||
/// 2. Direct API mode: Uses SpotifyPlaylistTrack (with ISRC and ordering)
|
||||
///
|
||||
/// When ISRC is available, exact matching is preferred. Falls back to fuzzy matching.
|
||||
/// </summary>
|
||||
public class SpotifyTrackMatchingService : BackgroundService
|
||||
{
|
||||
private readonly SpotifyImportSettings _spotifySettings;
|
||||
private readonly SpotifyApiSettings _spotifyApiSettings;
|
||||
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
|
||||
private const int BatchSize = 11; // Number of parallel searches (matches SquidWTF provider count)
|
||||
private DateTime _lastMatchingRun = DateTime.MinValue;
|
||||
private readonly TimeSpan _minimumMatchingInterval = TimeSpan.FromMinutes(5); // Don't run more than once per 5 minutes
|
||||
|
||||
public SpotifyTrackMatchingService(
|
||||
IOptions<SpotifyImportSettings> spotifySettings,
|
||||
IOptions<SpotifyApiSettings> spotifyApiSettings,
|
||||
RedisCacheService cache,
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<SpotifyTrackMatchingService> logger)
|
||||
{
|
||||
_spotifySettings = spotifySettings.Value;
|
||||
_spotifyApiSettings = spotifyApiSettings.Value;
|
||||
_cache = cache;
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper method to safely check if a dynamic cache result has a value
|
||||
/// Handles the case where JsonElement cannot be compared to null directly
|
||||
/// </summary>
|
||||
private static bool HasValue(object? obj)
|
||||
{
|
||||
if (obj == null) return false;
|
||||
if (obj is JsonElement jsonEl) return jsonEl.ValueKind != JsonValueKind.Null && jsonEl.ValueKind != JsonValueKind.Undefined;
|
||||
return true;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("SpotifyTrackMatchingService: Starting up...");
|
||||
|
||||
if (!_spotifySettings.Enabled)
|
||||
{
|
||||
_logger.LogInformation("Spotify playlist injection is DISABLED, matching service will not run");
|
||||
return;
|
||||
}
|
||||
|
||||
var matchMode = _spotifyApiSettings.Enabled && _spotifyApiSettings.PreferIsrcMatching
|
||||
? "ISRC-preferred" : "fuzzy";
|
||||
_logger.LogInformation("Matching mode: {Mode}", matchMode);
|
||||
|
||||
// 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");
|
||||
}
|
||||
|
||||
// Now start the periodic matching loop
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
// Wait for configured interval before next run (default 24 hours)
|
||||
var intervalHours = _spotifySettings.MatchingIntervalHours;
|
||||
if (intervalHours <= 0)
|
||||
{
|
||||
_logger.LogInformation("Periodic matching disabled (MatchingIntervalHours = {Hours}), only startup run will execute", intervalHours);
|
||||
break; // Exit loop - only run once on startup
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromHours(intervalHours), stoppingToken);
|
||||
|
||||
try
|
||||
{
|
||||
await MatchAllPlaylistsAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in track matching service");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Public method to trigger matching manually for all playlists (called from controller).
|
||||
/// </summary>
|
||||
public async Task TriggerMatchingAsync()
|
||||
{
|
||||
_logger.LogInformation("Manual track matching triggered for all playlists");
|
||||
await MatchAllPlaylistsAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Public method to trigger matching for a specific playlist (called from controller).
|
||||
/// </summary>
|
||||
public async Task TriggerMatchingForPlaylistAsync(string playlistName)
|
||||
{
|
||||
_logger.LogInformation("Manual track matching triggered for playlist: {Playlist}", playlistName);
|
||||
|
||||
var playlist = _spotifySettings.Playlists
|
||||
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (playlist == null)
|
||||
{
|
||||
_logger.LogWarning("Playlist {Playlist} not found in configuration", playlistName);
|
||||
return;
|
||||
}
|
||||
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var metadataService = scope.ServiceProvider.GetRequiredService<IMusicMetadataService>();
|
||||
|
||||
// Check if we should use the new SpotifyPlaylistFetcher
|
||||
SpotifyPlaylistFetcher? playlistFetcher = null;
|
||||
if (_spotifyApiSettings.Enabled)
|
||||
{
|
||||
playlistFetcher = scope.ServiceProvider.GetService<SpotifyPlaylistFetcher>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (playlistFetcher != null)
|
||||
{
|
||||
// Use new direct API mode with ISRC support
|
||||
await MatchPlaylistTracksWithIsrcAsync(
|
||||
playlist.Name, playlistFetcher, metadataService, CancellationToken.None);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fall back to legacy mode
|
||||
await MatchPlaylistTracksLegacyAsync(
|
||||
playlist.Name, metadataService, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error matching tracks for playlist {Playlist}", playlist.Name);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task MatchAllPlaylistsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Check if we've run too recently (cooldown period)
|
||||
var timeSinceLastRun = DateTime.UtcNow - _lastMatchingRun;
|
||||
if (timeSinceLastRun < _minimumMatchingInterval)
|
||||
{
|
||||
_logger.LogInformation("Skipping track matching - last run was {Seconds}s ago (minimum interval: {MinSeconds}s)",
|
||||
(int)timeSinceLastRun.TotalSeconds, (int)_minimumMatchingInterval.TotalSeconds);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("=== STARTING TRACK MATCHING ===");
|
||||
_lastMatchingRun = DateTime.UtcNow;
|
||||
|
||||
var playlists = _spotifySettings.Playlists;
|
||||
if (playlists.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("No playlists configured for matching");
|
||||
return;
|
||||
}
|
||||
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var metadataService = scope.ServiceProvider.GetRequiredService<IMusicMetadataService>();
|
||||
|
||||
// Check if we should use the new SpotifyPlaylistFetcher
|
||||
SpotifyPlaylistFetcher? playlistFetcher = null;
|
||||
if (_spotifyApiSettings.Enabled)
|
||||
{
|
||||
playlistFetcher = scope.ServiceProvider.GetService<SpotifyPlaylistFetcher>();
|
||||
}
|
||||
|
||||
foreach (var playlist in playlists)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) break;
|
||||
|
||||
try
|
||||
{
|
||||
if (playlistFetcher != null)
|
||||
{
|
||||
// Use new direct API mode with ISRC support
|
||||
await MatchPlaylistTracksWithIsrcAsync(
|
||||
playlist.Name, playlistFetcher, metadataService, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fall back to legacy mode
|
||||
await MatchPlaylistTracksLegacyAsync(
|
||||
playlist.Name, metadataService, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error matching tracks for playlist {Playlist}", playlist.Name);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("=== FINISHED TRACK MATCHING ===");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// New matching mode that uses ISRC when available for exact matches.
|
||||
/// Preserves track position for correct playlist ordering.
|
||||
/// Only matches tracks that aren't already in the Jellyfin playlist.
|
||||
/// </summary>
|
||||
private async Task MatchPlaylistTracksWithIsrcAsync(
|
||||
string playlistName,
|
||||
SpotifyPlaylistFetcher playlistFetcher,
|
||||
IMusicMetadataService metadataService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var matchedTracksKey = $"spotify:matched:ordered:{playlistName}";
|
||||
|
||||
// Get playlist tracks with full metadata including ISRC and position
|
||||
var spotifyTracks = await playlistFetcher.GetPlaylistTracksAsync(playlistName);
|
||||
if (spotifyTracks.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("No tracks found for {Playlist}, skipping matching", playlistName);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the Jellyfin playlist ID to check which tracks already exist
|
||||
var playlistConfig = _spotifySettings.Playlists
|
||||
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
HashSet<string> existingSpotifyIds = new();
|
||||
|
||||
if (!string.IsNullOrEmpty(playlistConfig?.JellyfinId))
|
||||
{
|
||||
// Get existing tracks from Jellyfin playlist to avoid re-matching
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
|
||||
var jellyfinSettings = scope.ServiceProvider.GetService<IOptions<JellyfinSettings>>()?.Value;
|
||||
|
||||
if (proxyService != null && jellyfinSettings != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
// CRITICAL: Must include UserId parameter or Jellyfin returns empty results
|
||||
var userId = jellyfinSettings.UserId;
|
||||
var playlistItemsUrl = $"Playlists/{playlistConfig.JellyfinId}/Items";
|
||||
var queryParams = new Dictionary<string, string>();
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
queryParams["UserId"] = userId;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("No UserId configured - may not be able to fetch existing playlist tracks for {Playlist}", playlistName);
|
||||
}
|
||||
|
||||
var (existingTracksResponse, _) = await proxyService.GetJsonAsyncInternal(
|
||||
playlistItemsUrl,
|
||||
queryParams);
|
||||
|
||||
if (existingTracksResponse != null &&
|
||||
existingTracksResponse.RootElement.TryGetProperty("Items", out var items))
|
||||
{
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
if (item.TryGetProperty("ProviderIds", out var providerIds) &&
|
||||
providerIds.TryGetProperty("Spotify", out var spotifyId))
|
||||
{
|
||||
var id = spotifyId.GetString();
|
||||
if (!string.IsNullOrEmpty(id))
|
||||
{
|
||||
existingSpotifyIds.Add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
_logger.LogInformation("Found {Count} tracks already in Jellyfin playlist {Playlist}, will skip matching these",
|
||||
existingSpotifyIds.Count, playlistName);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("No Items found in Jellyfin playlist response for {Playlist} - may need UserId parameter", playlistName);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not fetch existing Jellyfin tracks for {Playlist}, will match all tracks", playlistName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter to only tracks not already in Jellyfin
|
||||
var tracksToMatch = spotifyTracks
|
||||
.Where(t => !existingSpotifyIds.Contains(t.SpotifyId))
|
||||
.ToList();
|
||||
|
||||
if (tracksToMatch.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("All {Count} tracks for {Playlist} already exist in Jellyfin, skipping matching",
|
||||
spotifyTracks.Count, playlistName);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Matching {ToMatch}/{Total} tracks for {Playlist} (skipping {Existing} already in Jellyfin, ISRC: {IsrcEnabled})",
|
||||
tracksToMatch.Count, spotifyTracks.Count, playlistName, existingSpotifyIds.Count, _spotifyApiSettings.PreferIsrcMatching);
|
||||
|
||||
// Check cache - use snapshot/timestamp to detect changes
|
||||
var existingMatched = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
|
||||
|
||||
// CRITICAL: Skip matching if cache exists and is valid
|
||||
// Only re-match if cache is missing OR if we detect manual mappings that need to be applied
|
||||
if (existingMatched != null && existingMatched.Count > 0)
|
||||
{
|
||||
// Check if we have NEW manual mappings that aren't in the cache
|
||||
var hasNewManualMappings = false;
|
||||
foreach (var track in tracksToMatch)
|
||||
{
|
||||
// Check if this track has a manual mapping but isn't in the cached results
|
||||
var manualMappingKey = $"spotify:manual-map:{playlistName}:{track.SpotifyId}";
|
||||
var manualMapping = await _cache.GetAsync<string>(manualMappingKey);
|
||||
|
||||
var externalMappingKey = $"spotify:external-map:{playlistName}:{track.SpotifyId}";
|
||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||
|
||||
var hasManualMapping = !string.IsNullOrEmpty(manualMapping) || !string.IsNullOrEmpty(externalMappingJson);
|
||||
var isInCache = existingMatched.Any(m => m.SpotifyId == track.SpotifyId);
|
||||
|
||||
// If track has manual mapping but isn't in cache, we need to rebuild
|
||||
if (hasManualMapping && !isInCache)
|
||||
{
|
||||
hasNewManualMappings = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasNewManualMappings)
|
||||
{
|
||||
_logger.LogInformation("✓ Playlist {Playlist} already has {Count} matched tracks cached (skipping {ToMatch} new tracks), no re-matching needed",
|
||||
playlistName, existingMatched.Count, tracksToMatch.Count);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("New manual mappings detected for {Playlist}, rebuilding cache to apply them", playlistName);
|
||||
}
|
||||
|
||||
var matchedTracks = new List<MatchedTrack>();
|
||||
var isrcMatches = 0;
|
||||
var fuzzyMatches = 0;
|
||||
var noMatch = 0;
|
||||
|
||||
// Process tracks in batches for parallel searching
|
||||
var orderedTracks = tracksToMatch.OrderBy(t => t.Position).ToList();
|
||||
for (int i = 0; i < orderedTracks.Count; i += BatchSize)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) break;
|
||||
|
||||
var batch = orderedTracks.Skip(i).Take(BatchSize).ToList();
|
||||
_logger.LogDebug("Processing batch {Start}-{End} of {Total}",
|
||||
i + 1, Math.Min(i + BatchSize, orderedTracks.Count), orderedTracks.Count);
|
||||
|
||||
// Process all tracks in this batch in parallel
|
||||
var batchTasks = batch.Select(async spotifyTrack =>
|
||||
{
|
||||
try
|
||||
{
|
||||
Song? matchedSong = null;
|
||||
var matchType = "none";
|
||||
|
||||
// Try ISRC match first if available and enabled
|
||||
if (_spotifyApiSettings.PreferIsrcMatching && !string.IsNullOrEmpty(spotifyTrack.Isrc))
|
||||
{
|
||||
matchedSong = await TryMatchByIsrcAsync(spotifyTrack.Isrc, metadataService);
|
||||
if (matchedSong != null)
|
||||
{
|
||||
matchType = "isrc";
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to fuzzy matching
|
||||
if (matchedSong == null)
|
||||
{
|
||||
matchedSong = await TryMatchByFuzzyAsync(
|
||||
spotifyTrack.Title,
|
||||
spotifyTrack.Artists,
|
||||
metadataService);
|
||||
|
||||
if (matchedSong != null)
|
||||
{
|
||||
matchType = "fuzzy";
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedSong != null)
|
||||
{
|
||||
var matched = new MatchedTrack
|
||||
{
|
||||
Position = spotifyTrack.Position,
|
||||
SpotifyId = spotifyTrack.SpotifyId,
|
||||
SpotifyTitle = spotifyTrack.Title,
|
||||
SpotifyArtist = spotifyTrack.PrimaryArtist,
|
||||
Isrc = spotifyTrack.Isrc,
|
||||
MatchType = matchType,
|
||||
MatchedSong = matchedSong
|
||||
};
|
||||
|
||||
_logger.LogDebug(" #{Position} {Title} - {Artist} → {MatchType} match: {MatchedTitle}",
|
||||
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist,
|
||||
matchType, matchedSong.Title);
|
||||
|
||||
return ((MatchedTrack?)matched, matchType);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug(" #{Position} {Title} - {Artist} → no match",
|
||||
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist);
|
||||
return ((MatchedTrack?)null, "none");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
|
||||
spotifyTrack.Title, spotifyTrack.PrimaryArtist);
|
||||
return ((MatchedTrack?)null, "none");
|
||||
}
|
||||
}).ToList();
|
||||
|
||||
// Wait for all tracks in this batch to complete
|
||||
var batchResults = await Task.WhenAll(batchTasks);
|
||||
|
||||
// Collect results
|
||||
foreach (var result in batchResults)
|
||||
{
|
||||
var (matched, matchType) = result;
|
||||
if (matched != null)
|
||||
{
|
||||
matchedTracks.Add(matched);
|
||||
if (matchType == "isrc") isrcMatches++;
|
||||
else if (matchType == "fuzzy") fuzzyMatches++;
|
||||
}
|
||||
else
|
||||
{
|
||||
noMatch++;
|
||||
}
|
||||
}
|
||||
|
||||
// Rate limiting between batches (not between individual tracks)
|
||||
if (i + BatchSize < orderedTracks.Count)
|
||||
{
|
||||
await Task.Delay(DelayBetweenSearchesMs, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedTracks.Count > 0)
|
||||
{
|
||||
// Cache matched tracks with position data
|
||||
await _cache.SetAsync(matchedTracksKey, matchedTracks, TimeSpan.FromHours(1));
|
||||
|
||||
// Save matched tracks to file for persistence across restarts
|
||||
await SaveMatchedTracksToFileAsync(playlistName, matchedTracks);
|
||||
|
||||
// Also update legacy cache for backward compatibility
|
||||
var legacyKey = $"spotify:matched:{playlistName}";
|
||||
var legacySongs = matchedTracks.OrderBy(t => t.Position).Select(t => t.MatchedSong).ToList();
|
||||
await _cache.SetAsync(legacyKey, legacySongs, TimeSpan.FromHours(1));
|
||||
|
||||
_logger.LogInformation(
|
||||
"✓ Cached {Matched}/{Total} tracks for {Playlist} via search (ISRC: {Isrc}, Fuzzy: {Fuzzy}, No match: {NoMatch}) - manual mappings will be applied next",
|
||||
matchedTracks.Count, tracksToMatch.Count, playlistName, isrcMatches, fuzzyMatches, noMatch);
|
||||
|
||||
// Pre-build playlist items cache for instant serving
|
||||
await PreBuildPlaylistItemsCacheAsync(playlistName, playlistConfig?.JellyfinId, spotifyTracks, matchedTracks, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("No tracks matched for {Playlist}", playlistName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to match a track by ISRC using provider search.
|
||||
/// </summary>
|
||||
private async Task<Song?> TryMatchByIsrcAsync(string isrc, IMusicMetadataService metadataService)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Search by ISRC directly - most providers support this
|
||||
var results = await metadataService.SearchSongsAsync($"isrc:{isrc}", limit: 1);
|
||||
if (results.Count > 0 && results[0].Isrc == isrc)
|
||||
{
|
||||
return results[0];
|
||||
}
|
||||
|
||||
// Some providers may not support isrc: prefix, try without
|
||||
results = await metadataService.SearchSongsAsync(isrc, limit: 5);
|
||||
var exactMatch = results.FirstOrDefault(r =>
|
||||
!string.IsNullOrEmpty(r.Isrc) &&
|
||||
r.Isrc.Equals(isrc, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
return exactMatch;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to match a track by title and artist using fuzzy matching.
|
||||
/// </summary>
|
||||
private async Task<Song?> TryMatchByFuzzyAsync(
|
||||
string title,
|
||||
List<string> artists,
|
||||
IMusicMetadataService metadataService)
|
||||
{
|
||||
try
|
||||
{
|
||||
var primaryArtist = artists.FirstOrDefault() ?? "";
|
||||
var query = $"{title} {primaryArtist}";
|
||||
var results = await metadataService.SearchSongsAsync(query, limit: 5);
|
||||
|
||||
if (results.Count == 0) return null;
|
||||
|
||||
var bestMatch = results
|
||||
.Select(song => new
|
||||
{
|
||||
Song = song,
|
||||
TitleScore = FuzzyMatcher.CalculateSimilarity(title, song.Title),
|
||||
ArtistScore = CalculateArtistMatchScore(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)
|
||||
{
|
||||
return bestMatch.Song;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Legacy matching mode using MissingTrack from Jellyfin plugin.
|
||||
/// </summary>
|
||||
private async Task MatchPlaylistTracksLegacyAsync(
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pre-builds the playlist items cache for instant serving.
|
||||
/// This combines local Jellyfin tracks with external matched tracks in the correct Spotify order.
|
||||
/// </summary>
|
||||
private async Task PreBuildPlaylistItemsCacheAsync(
|
||||
string playlistName,
|
||||
string? jellyfinPlaylistId,
|
||||
List<SpotifyPlaylistTrack> spotifyTracks,
|
||||
List<MatchedTrack> matchedTracks,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("🔨 Pre-building playlist items cache for {Playlist}...", playlistName);
|
||||
|
||||
if (string.IsNullOrEmpty(jellyfinPlaylistId))
|
||||
{
|
||||
_logger.LogWarning("No Jellyfin playlist ID configured for {Playlist}, cannot pre-build cache", playlistName);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get existing tracks from Jellyfin playlist
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
|
||||
var responseBuilder = scope.ServiceProvider.GetService<JellyfinResponseBuilder>();
|
||||
var jellyfinSettings = scope.ServiceProvider.GetService<IOptions<JellyfinSettings>>()?.Value;
|
||||
|
||||
if (proxyService == null || responseBuilder == null || jellyfinSettings == null)
|
||||
{
|
||||
_logger.LogWarning("Required services not available for pre-building cache");
|
||||
return;
|
||||
}
|
||||
|
||||
var userId = jellyfinSettings.UserId;
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
{
|
||||
_logger.LogWarning("No UserId configured, cannot pre-build playlist cache for {Playlist}", playlistName);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create authentication headers for background service call
|
||||
var headers = new HeaderDictionary();
|
||||
if (!string.IsNullOrEmpty(jellyfinSettings.ApiKey))
|
||||
{
|
||||
headers["X-Emby-Authorization"] = $"MediaBrowser Token=\"{jellyfinSettings.ApiKey}\"";
|
||||
}
|
||||
|
||||
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items?UserId={userId}&Fields=MediaSources";
|
||||
var (existingTracksResponse, statusCode) = await proxyService.GetJsonAsync(playlistItemsUrl, null, headers);
|
||||
|
||||
if (statusCode != 200 || existingTracksResponse == null)
|
||||
{
|
||||
_logger.LogWarning("Failed to fetch Jellyfin playlist items for {Playlist}: HTTP {StatusCode}", playlistName, statusCode);
|
||||
return;
|
||||
}
|
||||
|
||||
// Index Jellyfin items by title+artist for matching
|
||||
var jellyfinItemsByName = new Dictionary<string, JsonElement>();
|
||||
|
||||
if (existingTracksResponse.RootElement.TryGetProperty("Items", out var items))
|
||||
{
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
|
||||
var artist = "";
|
||||
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
|
||||
{
|
||||
artist = artistsEl[0].GetString() ?? "";
|
||||
}
|
||||
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
|
||||
{
|
||||
artist = albumArtistEl.GetString() ?? "";
|
||||
}
|
||||
|
||||
var key = $"{title}|{artist}".ToLowerInvariant();
|
||||
if (!jellyfinItemsByName.ContainsKey(key))
|
||||
{
|
||||
jellyfinItemsByName[key] = item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build the final track list in correct Spotify order
|
||||
var finalItems = new List<Dictionary<string, object?>>();
|
||||
var usedJellyfinItems = new HashSet<string>();
|
||||
var localUsedCount = 0;
|
||||
var externalUsedCount = 0;
|
||||
var manualLocalCount = 0;
|
||||
var manualExternalCount = 0;
|
||||
|
||||
foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position))
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) break;
|
||||
|
||||
// FIRST: Check for manual mapping
|
||||
var manualMappingKey = $"spotify:manual-map:{playlistName}:{spotifyTrack.SpotifyId}";
|
||||
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
|
||||
|
||||
JsonElement? matchedJellyfinItem = null;
|
||||
string? matchedKey = null;
|
||||
|
||||
if (!string.IsNullOrEmpty(manualJellyfinId))
|
||||
{
|
||||
// Manual Jellyfin mapping exists - fetch the Jellyfin item by ID
|
||||
try
|
||||
{
|
||||
var itemUrl = $"Items/{manualJellyfinId}?UserId={userId}";
|
||||
var (itemResponse, itemStatusCode) = await proxyService.GetJsonAsync(itemUrl, null, headers);
|
||||
|
||||
if (itemStatusCode == 200 && itemResponse != null)
|
||||
{
|
||||
matchedJellyfinItem = itemResponse.RootElement;
|
||||
manualLocalCount++;
|
||||
_logger.LogDebug("✓ Using manual Jellyfin mapping for {Title}: Jellyfin ID {Id}",
|
||||
spotifyTrack.Title, manualJellyfinId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Manual Jellyfin mapping points to invalid Jellyfin ID {Id} for {Title}",
|
||||
manualJellyfinId, spotifyTrack.Title);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch manually mapped Jellyfin item {Id}", manualJellyfinId);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for external manual mapping if no Jellyfin mapping found
|
||||
if (!matchedJellyfinItem.HasValue)
|
||||
{
|
||||
var externalMappingKey = $"spotify:external-map:{playlistName}:{spotifyTrack.SpotifyId}";
|
||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(externalMappingJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(externalMappingJson);
|
||||
var root = doc.RootElement;
|
||||
|
||||
string? provider = null;
|
||||
string? externalId = null;
|
||||
|
||||
if (root.TryGetProperty("provider", out var providerEl))
|
||||
{
|
||||
provider = providerEl.GetString();
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("id", out var idEl))
|
||||
{
|
||||
externalId = idEl.GetString();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(provider) && !string.IsNullOrEmpty(externalId))
|
||||
{
|
||||
// Fetch full metadata from the provider instead of using minimal Spotify data
|
||||
Song? externalSong = null;
|
||||
|
||||
try
|
||||
{
|
||||
using var metadataScope = _serviceProvider.CreateScope();
|
||||
var metadataServiceForFetch = metadataScope.ServiceProvider.GetRequiredService<IMusicMetadataService>();
|
||||
externalSong = await metadataServiceForFetch.GetSongAsync(provider, externalId);
|
||||
|
||||
if (externalSong != null)
|
||||
{
|
||||
_logger.LogInformation("✓ Fetched full metadata for manual external mapping: {Title} by {Artist}",
|
||||
externalSong.Title, externalSong.Artist);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Failed to fetch metadata for {Provider} ID {ExternalId}, using fallback",
|
||||
provider, externalId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error fetching metadata for {Provider} ID {ExternalId}, using fallback",
|
||||
provider, externalId);
|
||||
}
|
||||
|
||||
// Fallback to minimal metadata if fetch failed
|
||||
if (externalSong == null)
|
||||
{
|
||||
externalSong = new Song
|
||||
{
|
||||
Id = $"ext-{provider}-song-{externalId}",
|
||||
Title = spotifyTrack.Title,
|
||||
Artist = spotifyTrack.PrimaryArtist,
|
||||
Album = spotifyTrack.Album,
|
||||
Duration = spotifyTrack.DurationMs / 1000,
|
||||
Isrc = spotifyTrack.Isrc,
|
||||
IsLocal = false,
|
||||
ExternalProvider = provider,
|
||||
ExternalId = externalId
|
||||
};
|
||||
}
|
||||
|
||||
var matchedTrack = new MatchedTrack
|
||||
{
|
||||
Position = spotifyTrack.Position,
|
||||
SpotifyId = spotifyTrack.SpotifyId,
|
||||
MatchedSong = externalSong
|
||||
};
|
||||
|
||||
matchedTracks.Add(matchedTrack);
|
||||
|
||||
// Convert external song to Jellyfin item format and add to finalItems
|
||||
var externalItem = responseBuilder.ConvertSongToJellyfinItem(externalSong);
|
||||
finalItems.Add(externalItem);
|
||||
externalUsedCount++;
|
||||
manualExternalCount++;
|
||||
|
||||
_logger.LogInformation("✓ Using manual external mapping for {Title}: {Provider} {ExternalId}",
|
||||
spotifyTrack.Title, provider, externalId);
|
||||
continue; // Skip to next track
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", spotifyTrack.Title);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SECOND: If no manual mapping, try fuzzy matching
|
||||
if (!matchedJellyfinItem.HasValue)
|
||||
{
|
||||
double bestScore = 0;
|
||||
|
||||
foreach (var kvp in jellyfinItemsByName)
|
||||
{
|
||||
if (usedJellyfinItems.Contains(kvp.Key)) continue;
|
||||
|
||||
var item = kvp.Value;
|
||||
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
|
||||
var artist = "";
|
||||
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
|
||||
{
|
||||
artist = artistsEl[0].GetString() ?? "";
|
||||
}
|
||||
|
||||
var titleScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.Title, title);
|
||||
var artistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, artist);
|
||||
var totalScore = (titleScore * 0.7) + (artistScore * 0.3);
|
||||
|
||||
if (totalScore > bestScore && totalScore >= 70)
|
||||
{
|
||||
bestScore = totalScore;
|
||||
matchedJellyfinItem = item;
|
||||
matchedKey = kvp.Key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedJellyfinItem.HasValue)
|
||||
{
|
||||
// Use the raw Jellyfin item (preserves ALL metadata)
|
||||
var itemDict = JsonSerializer.Deserialize<Dictionary<string, object?>>(matchedJellyfinItem.Value.GetRawText());
|
||||
if (itemDict != null)
|
||||
{
|
||||
finalItems.Add(itemDict);
|
||||
if (matchedKey != null)
|
||||
{
|
||||
usedJellyfinItems.Add(matchedKey);
|
||||
}
|
||||
localUsedCount++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No local match - try to find external track
|
||||
var matched = matchedTracks.FirstOrDefault(t => t.SpotifyId == spotifyTrack.SpotifyId);
|
||||
if (matched != null && matched.MatchedSong != null)
|
||||
{
|
||||
// Convert external song to Jellyfin item format
|
||||
var externalItem = responseBuilder.ConvertSongToJellyfinItem(matched.MatchedSong);
|
||||
finalItems.Add(externalItem);
|
||||
externalUsedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (finalItems.Count > 0)
|
||||
{
|
||||
// Save to Redis cache
|
||||
var cacheKey = $"spotify:playlist:items:{playlistName}";
|
||||
await _cache.SetAsync(cacheKey, finalItems, TimeSpan.FromHours(24));
|
||||
|
||||
// Save to file cache for persistence
|
||||
await SavePlaylistItemsToFileAsync(playlistName, finalItems);
|
||||
|
||||
var manualMappingInfo = "";
|
||||
if (manualLocalCount > 0 || manualExternalCount > 0)
|
||||
{
|
||||
manualMappingInfo = $" [Manual: {manualLocalCount} local, {manualExternalCount} external]";
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"✅ Pre-built playlist cache for {Playlist}: {Total} tracks ({Local} LOCAL + {External} EXTERNAL){ManualInfo}",
|
||||
playlistName, finalItems.Count, localUsedCount, externalUsedCount, manualMappingInfo);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("No items to cache for {Playlist}", playlistName);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to pre-build playlist items cache for {Playlist}", playlistName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves playlist items to file cache for persistence across restarts.
|
||||
/// </summary>
|
||||
private async Task SavePlaylistItemsToFileAsync(string playlistName, List<Dictionary<string, object?>> items)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cacheDir = "/app/cache/spotify";
|
||||
Directory.CreateDirectory(cacheDir);
|
||||
|
||||
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
|
||||
var filePath = Path.Combine(cacheDir, $"{safeName}_items.json");
|
||||
|
||||
var json = JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true });
|
||||
await System.IO.File.WriteAllTextAsync(filePath, json);
|
||||
|
||||
_logger.LogDebug("💾 Saved {Count} playlist items to file cache: {Path}", items.Count, filePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save playlist items to file for {Playlist}", playlistName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves matched tracks to file cache for persistence across restarts.
|
||||
/// </summary>
|
||||
private async Task SaveMatchedTracksToFileAsync(string playlistName, List<MatchedTrack> matchedTracks)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cacheDir = "/app/cache/spotify";
|
||||
Directory.CreateDirectory(cacheDir);
|
||||
|
||||
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
|
||||
var filePath = Path.Combine(cacheDir, $"{safeName}_matched.json");
|
||||
|
||||
var json = JsonSerializer.Serialize(matchedTracks, new JsonSerializerOptions { WriteIndented = true });
|
||||
await System.IO.File.WriteAllTextAsync(filePath, json);
|
||||
|
||||
_logger.LogDebug("💾 Saved {Count} matched tracks to file cache: {Path}", matchedTracks.Count, filePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save matched tracks to file for {Playlist}", playlistName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -113,7 +130,8 @@ public class SquidWTFDownloadService : BaseDownloadService
|
||||
|
||||
// Build organized folder structure: Artist/Album/Track using AlbumArtist (fallback to Artist for singles)
|
||||
var artistForPath = song.AlbumArtist ?? song.Artist;
|
||||
var outputPath = PathHelper.BuildTrackPath(DownloadPath, artistForPath, song.Album, song.Title, song.Track, extension);
|
||||
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache ? CachePath : DownloadPath;
|
||||
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension);
|
||||
|
||||
// Create directories if they don't exist
|
||||
var albumFolder = Path.GetDirectoryName(outputPath)!;
|
||||
|
||||
@@ -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;
|
||||
@@ -508,26 +551,36 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
? volNum.GetInt32()
|
||||
: null;
|
||||
|
||||
// Get artist name - handle both single artist and artists array
|
||||
// Get all artists - Tidal provides both "artist" (singular) and "artists" (plural array)
|
||||
var allArtists = new List<string>();
|
||||
string artistName = "";
|
||||
if (track.TryGetProperty("artist", out var artist))
|
||||
string? artistId = null;
|
||||
|
||||
// Prefer the "artists" array as it includes all collaborators
|
||||
if (track.TryGetProperty("artists", out var artists) && artists.GetArrayLength() > 0)
|
||||
{
|
||||
foreach (var artistEl in artists.EnumerateArray())
|
||||
{
|
||||
var name = artistEl.GetProperty("name").GetString();
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
{
|
||||
allArtists.Add(name);
|
||||
}
|
||||
}
|
||||
|
||||
// First artist is the main artist
|
||||
if (allArtists.Count > 0)
|
||||
{
|
||||
artistName = allArtists[0];
|
||||
artistId = $"ext-squidwtf-artist-{artists[0].GetProperty("id").GetInt64()}";
|
||||
}
|
||||
}
|
||||
// Fallback to singular "artist" field
|
||||
else if (track.TryGetProperty("artist", out var artist))
|
||||
{
|
||||
artistName = artist.GetProperty("name").GetString() ?? "";
|
||||
}
|
||||
else if (track.TryGetProperty("artists", out var artists) && artists.GetArrayLength() > 0)
|
||||
{
|
||||
artistName = artists[0].GetProperty("name").GetString() ?? "";
|
||||
}
|
||||
|
||||
// Get artist ID
|
||||
string? artistId = null;
|
||||
if (track.TryGetProperty("artist", out var artistForId))
|
||||
{
|
||||
artistId = $"ext-squidwtf-artist-{artistForId.GetProperty("id").GetInt64()}";
|
||||
}
|
||||
else if (track.TryGetProperty("artists", out var artistsForId) && artistsForId.GetArrayLength() > 0)
|
||||
{
|
||||
artistId = $"ext-squidwtf-artist-{artistsForId[0].GetProperty("id").GetInt64()}";
|
||||
artistId = $"ext-squidwtf-artist-{artist.GetProperty("id").GetInt64()}";
|
||||
allArtists.Add(artistName);
|
||||
}
|
||||
|
||||
// Get album info
|
||||
@@ -553,6 +606,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
Title = track.GetProperty("title").GetString() ?? "",
|
||||
Artist = artistName,
|
||||
ArtistId = artistId,
|
||||
Artists = allArtists,
|
||||
Album = albumTitle,
|
||||
AlbumId = albumId,
|
||||
Duration = track.TryGetProperty("duration", out var duration)
|
||||
@@ -606,9 +660,34 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
}
|
||||
}
|
||||
|
||||
// Get artist info
|
||||
string artistName = track.GetProperty("artist").GetProperty("name").GetString() ?? "";
|
||||
long artistIdNum = track.GetProperty("artist").GetProperty("id").GetInt64();
|
||||
// Get all artists - prefer "artists" array for collaborations
|
||||
var allArtists = new List<string>();
|
||||
string artistName = "";
|
||||
long artistIdNum = 0;
|
||||
|
||||
if (track.TryGetProperty("artists", out var artists) && artists.GetArrayLength() > 0)
|
||||
{
|
||||
foreach (var artistEl in artists.EnumerateArray())
|
||||
{
|
||||
var name = artistEl.GetProperty("name").GetString();
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
{
|
||||
allArtists.Add(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (allArtists.Count > 0)
|
||||
{
|
||||
artistName = allArtists[0];
|
||||
artistIdNum = artists[0].GetProperty("id").GetInt64();
|
||||
}
|
||||
}
|
||||
else if (track.TryGetProperty("artist", out var artist))
|
||||
{
|
||||
artistName = artist.GetProperty("name").GetString() ?? "";
|
||||
artistIdNum = artist.GetProperty("id").GetInt64();
|
||||
allArtists.Add(artistName);
|
||||
}
|
||||
|
||||
// Album artist - same as main artist for Tidal tracks
|
||||
string? albumArtist = artistName;
|
||||
@@ -642,6 +721,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
Title = track.GetProperty("title").GetString() ?? "",
|
||||
Artist = artistName,
|
||||
ArtistId = $"ext-squidwtf-artist-{artistIdNum}",
|
||||
Artists = allArtists,
|
||||
Album = albumTitle,
|
||||
AlbumId = $"ext-squidwtf-album-{albumIdNum}",
|
||||
AlbumArtist = albumArtist,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,12 @@ public class SubsonicProxyService
|
||||
var body = await response.Content.ReadAsByteArrayAsync();
|
||||
var contentType = response.Content.Headers.ContentType?.ToString();
|
||||
|
||||
// Trigger GC for large files to prevent memory leaks
|
||||
if (body.Length > 1024 * 1024) // 1MB threshold
|
||||
{
|
||||
GC.Collect(2, GCCollectionMode.Optimized, blocking: false);
|
||||
}
|
||||
|
||||
return (body, contentType);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,9 +22,13 @@ public class StartupValidationOrchestrator : IHostedService
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Get version from assembly
|
||||
var version = typeof(StartupValidationOrchestrator).Assembly
|
||||
.GetName().Version?.ToString(3) ?? "unknown";
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("========================================");
|
||||
Console.WriteLine(" allstarr starting up... ");
|
||||
Console.WriteLine($" allstarr v{version} ");
|
||||
Console.WriteLine("========================================");
|
||||
Console.WriteLine();
|
||||
|
||||
|
||||
@@ -5,11 +5,15 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>allstarr</RootNamespace>
|
||||
<Version>1.0.0</Version>
|
||||
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
||||
<FileVersion>1.0.0.0</FileVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.4" />
|
||||
<PackageReference Include="Otp.NET" Version="1.4.1" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.8.16" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
|
||||
<PackageReference Include="TagLibSharp" Version="2.3.0" />
|
||||
|
||||
@@ -2,7 +2,16 @@
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"System.Net.Http.HttpClient.Default.LogicalHandler": "Warning",
|
||||
"System.Net.Http.HttpClient.Default.ClientHandler": "Warning"
|
||||
}
|
||||
},
|
||||
"SpotifyImport": {
|
||||
"Enabled": false,
|
||||
"SyncStartHour": 16,
|
||||
"SyncStartMinute": 15,
|
||||
"SyncWindowHours": 2,
|
||||
"Playlists": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"System.Net.Http.HttpClient.Default.LogicalHandler": "Warning",
|
||||
"System.Net.Http.HttpClient.Default.ClientHandler": "Warning"
|
||||
}
|
||||
},
|
||||
"Backend": {
|
||||
"Type": "Subsonic"
|
||||
},
|
||||
"Subsonic": {
|
||||
"Url": "https://navidrome.local.bransonb.com",
|
||||
"Url": "http://localhost:4533",
|
||||
"MusicService": "SquidWTF",
|
||||
"ExplicitFilter": "All",
|
||||
"DownloadMode": "Track",
|
||||
@@ -42,5 +50,29 @@
|
||||
"Redis": {
|
||||
"Enabled": true,
|
||||
"ConnectionString": "localhost:6379"
|
||||
},
|
||||
"SpotifyImport": {
|
||||
"Enabled": false,
|
||||
"SyncStartHour": 16,
|
||||
"SyncStartMinute": 15,
|
||||
"SyncWindowHours": 2,
|
||||
"MatchingIntervalHours": 24,
|
||||
"Playlists": []
|
||||
},
|
||||
"SpotifyApi": {
|
||||
"Enabled": false,
|
||||
"ClientId": "",
|
||||
"ClientSecret": "",
|
||||
"SessionCookie": "",
|
||||
"CacheDurationMinutes": 60,
|
||||
"RateLimitDelayMs": 100,
|
||||
"PreferIsrcMatching": true
|
||||
},
|
||||
"MusicBrainz": {
|
||||
"Enabled": true,
|
||||
"Username": "",
|
||||
"Password": "",
|
||||
"BaseUrl": "https://musicbrainz.org/ws/2",
|
||||
"RateLimitMs": 1000
|
||||
}
|
||||
}
|
||||
|
||||
2514
allstarr/wwwroot/index.html
Normal file
2514
allstarr/wwwroot/index.html
Normal file
@@ -0,0 +1,2514 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Allstarr Dashboard</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #0d1117;
|
||||
--bg-secondary: #161b22;
|
||||
--bg-tertiary: #21262d;
|
||||
--text-primary: #f0f6fc;
|
||||
--text-secondary: #8b949e;
|
||||
--accent: #58a6ff;
|
||||
--accent-hover: #79c0ff;
|
||||
--success: #3fb950;
|
||||
--warning: #d29922;
|
||||
--error: #f85149;
|
||||
--border: #30363d;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
h1 .version {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-badge.success { background: rgba(63, 185, 80, 0.2); color: var(--success); }
|
||||
.status-badge.warning { background: rgba(210, 153, 34, 0.2); color: var(--warning); }
|
||||
.status-badge.error { background: rgba(248, 81, 73, 0.2); color: var(--error); }
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.card h2 .actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.stat-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-value.success { color: var(--success); }
|
||||
.stat-value.warning { color: var(--warning); }
|
||||
.stat-value.error { color: var(--error); }
|
||||
|
||||
button {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
button.primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: rgba(248, 81, 73, 0.15);
|
||||
border-color: var(--error);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
button.danger:hover {
|
||||
background: rgba(248, 81, 73, 0.3);
|
||||
}
|
||||
|
||||
.playlist-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.playlist-table th,
|
||||
.playlist-table td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.playlist-table th {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.playlist-table tr:hover td {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.playlist-table .track-count {
|
||||
font-family: monospace;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.playlist-table .cache-age {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
input, select {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
input:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 120px auto;
|
||||
gap: 8px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.form-row label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.config-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.config-section h3 {
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.config-item {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr auto;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.config-item .label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.config-item .value {
|
||||
font-family: monospace;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
padding: 12px 20px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
z-index: 1000;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
.toast.success { border-color: var(--success); }
|
||||
.toast.error { border-color: var(--error); }
|
||||
.toast.warning { border-color: var(--warning); }
|
||||
.toast.info { border-color: var(--accent); }
|
||||
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.restart-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--bg-primary);
|
||||
z-index: 9999;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.restart-overlay.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.restart-overlay .spinner-large {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.restart-overlay h2 {
|
||||
color: var(--text-primary);
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.restart-overlay p {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.restart-banner {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--warning);
|
||||
color: var(--bg-primary);
|
||||
padding: 12px 20px;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
z-index: 9998;
|
||||
display: none;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.restart-banner.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.restart-banner button {
|
||||
margin-left: 16px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
border: none;
|
||||
padding: 6px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.restart-banner button:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.7);
|
||||
z-index: 1000;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
max-width: 75%;
|
||||
width: 75%;
|
||||
max-height: 65vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-content h3 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.modal-content .form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.modal-content .form-group label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.modal-content .form-group input,
|
||||
.modal-content .form-group select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 12px 20px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tracks-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.track-item {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.track-item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.track-position {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.track-info h4 {
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.track-info .artists {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.track-meta {
|
||||
text-align: right;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Restart Required Banner -->
|
||||
<div class="restart-banner" id="restart-banner">
|
||||
⚠️ Configuration changed. Restart required to apply changes.
|
||||
<button onclick="restartContainer()">Restart Now</button>
|
||||
<button onclick="dismissRestartBanner()" style="background: transparent; border: 1px solid var(--bg-primary);">Dismiss</button>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>
|
||||
Allstarr <span class="version" id="version">v1.0.0</span>
|
||||
</h1>
|
||||
<div id="status-indicator">
|
||||
<span class="status-badge" id="spotify-status">
|
||||
<span class="status-dot"></span>
|
||||
<span>Loading...</span>
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab active" data-tab="dashboard">Dashboard</div>
|
||||
<div class="tab" data-tab="jellyfin-playlists">Link Playlists</div>
|
||||
<div class="tab" data-tab="playlists">Active Playlists</div>
|
||||
<div class="tab" data-tab="config">Configuration</div>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Tab -->
|
||||
<div class="tab-content active" id="tab-dashboard">
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h2>Spotify API</h2>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Status</span>
|
||||
<span class="stat-value" id="spotify-auth-status">Loading...</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">User</span>
|
||||
<span class="stat-value" id="spotify-user">-</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Cookie Age</span>
|
||||
<span class="stat-value" id="spotify-cookie-age">-</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Cache Duration</span>
|
||||
<span class="stat-value" id="cache-duration">-</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">ISRC Matching</span>
|
||||
<span class="stat-value" id="isrc-matching">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Jellyfin</h2>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Backend</span>
|
||||
<span class="stat-value" id="backend-type">-</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">URL</span>
|
||||
<span class="stat-value" id="jellyfin-url">-</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Playlists</span>
|
||||
<span class="stat-value" id="playlist-count">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>
|
||||
Quick Actions
|
||||
</h2>
|
||||
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
|
||||
<button class="primary" onclick="refreshPlaylists()">Refresh All Playlists</button>
|
||||
<button onclick="clearCache()">Clear Cache</button>
|
||||
<button onclick="openAddPlaylist()">Add Playlist</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Link Playlists Tab -->
|
||||
<div class="tab-content" id="tab-jellyfin-playlists">
|
||||
<div class="card">
|
||||
<h2>
|
||||
Link Jellyfin Playlists to Spotify
|
||||
<div class="actions">
|
||||
<button onclick="fetchJellyfinPlaylists()">Refresh</button>
|
||||
</div>
|
||||
</h2>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||
Connect your Jellyfin playlists to Spotify playlists. Allstarr will automatically fill in missing tracks from Spotify using your preferred music service (SquidWTF/Deezer/Qobuz).
|
||||
<br><strong>Tip:</strong> Use the sp_dc cookie method for best results - it's simpler and more reliable.
|
||||
</p>
|
||||
|
||||
<div style="display: flex; gap: 16px; margin-bottom: 16px; flex-wrap: wrap;">
|
||||
<div class="form-group" style="margin: 0; flex: 1; min-width: 200px;">
|
||||
<label style="display: block; margin-bottom: 4px; color: var(--text-secondary); font-size: 0.85rem;">User</label>
|
||||
<select id="jellyfin-user-select" onchange="fetchJellyfinPlaylists()" style="width: 100%; padding: 8px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary);">
|
||||
<option value="">All Users</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="playlist-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Local</th>
|
||||
<th>External</th>
|
||||
<th>Linked Spotify ID</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="jellyfin-playlist-table-body">
|
||||
<tr>
|
||||
<td colspan="6" class="loading">
|
||||
<span class="spinner"></span> Loading Jellyfin playlists...
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Playlists Tab -->
|
||||
<div class="tab-content" id="tab-playlists">
|
||||
<div class="card">
|
||||
<h2>
|
||||
Active Spotify Playlists
|
||||
<div class="actions">
|
||||
<button onclick="matchAllPlaylists()">Match All Tracks</button>
|
||||
<button onclick="refreshPlaylists()">Refresh All</button>
|
||||
</div>
|
||||
</h2>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
||||
These are the Spotify playlists currently being monitored and filled with tracks from your music service.
|
||||
</p>
|
||||
<table class="playlist-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Spotify ID</th>
|
||||
<th>Tracks</th>
|
||||
<th>Completion</th>
|
||||
<th>Lyrics</th>
|
||||
<th>Cache Age</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="playlist-table-body">
|
||||
<tr>
|
||||
<td colspan="7" class="loading">
|
||||
<span class="spinner"></span> Loading playlists...
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configuration Tab -->
|
||||
<div class="tab-content" id="tab-config">
|
||||
<div class="card">
|
||||
<h2>Spotify API Settings</h2>
|
||||
<div class="config-section">
|
||||
<div class="config-item">
|
||||
<span class="label">API Enabled</span>
|
||||
<span class="value" id="config-spotify-enabled">-</span>
|
||||
<button onclick="openEditSetting('SPOTIFY_API_ENABLED', 'Spotify API Enabled', 'toggle')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Session Cookie (sp_dc)</span>
|
||||
<span class="value" id="config-spotify-cookie">-</span>
|
||||
<button onclick="openEditSetting('SPOTIFY_API_SESSION_COOKIE', 'Spotify Session Cookie', 'password', 'Get from browser dev tools while logged into Spotify. Cookie typically lasts ~1 year.')">Update</button>
|
||||
</div>
|
||||
<div class="config-item" style="grid-template-columns: 200px 1fr;">
|
||||
<span class="label">Cookie Age</span>
|
||||
<span class="value" id="config-cookie-age">-</span>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Cache Duration</span>
|
||||
<span class="value" id="config-cache-duration">-</span>
|
||||
<button onclick="openEditSetting('SPOTIFY_API_CACHE_DURATION_MINUTES', 'Cache Duration (minutes)', 'number')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">ISRC Matching</span>
|
||||
<span class="value" id="config-isrc-matching">-</span>
|
||||
<button onclick="openEditSetting('SPOTIFY_API_PREFER_ISRC_MATCHING', 'Prefer ISRC Matching', 'toggle')">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Deezer Settings</h2>
|
||||
<div class="config-section">
|
||||
<div class="config-item">
|
||||
<span class="label">ARL Token</span>
|
||||
<span class="value" id="config-deezer-arl">-</span>
|
||||
<button onclick="openEditSetting('DEEZER_ARL', 'Deezer ARL Token', 'password', 'Get from browser cookies while logged into Deezer')">Update</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Quality</span>
|
||||
<span class="value" id="config-deezer-quality">-</span>
|
||||
<button onclick="openEditSetting('DEEZER_QUALITY', 'Deezer Quality', 'select', '', ['FLAC', 'MP3_320', 'MP3_128'])">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>SquidWTF / Tidal Settings</h2>
|
||||
<div class="config-section">
|
||||
<div class="config-item">
|
||||
<span class="label">Quality</span>
|
||||
<span class="value" id="config-squid-quality">-</span>
|
||||
<button onclick="openEditSetting('SQUIDWTF_QUALITY', 'SquidWTF Quality', 'select', '', ['LOSSLESS', 'HIGH', 'LOW'])">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>MusicBrainz Settings</h2>
|
||||
<div class="config-section">
|
||||
<div class="config-item">
|
||||
<span class="label">Enabled</span>
|
||||
<span class="value" id="config-musicbrainz-enabled">-</span>
|
||||
<button onclick="openEditSetting('MUSICBRAINZ_ENABLED', 'MusicBrainz Enabled', 'select', '', ['true', 'false'])">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Username</span>
|
||||
<span class="value" id="config-musicbrainz-username">-</span>
|
||||
<button onclick="openEditSetting('MUSICBRAINZ_USERNAME', 'MusicBrainz Username', 'text', 'Your MusicBrainz username')">Update</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Password</span>
|
||||
<span class="value" id="config-musicbrainz-password">-</span>
|
||||
<button onclick="openEditSetting('MUSICBRAINZ_PASSWORD', 'MusicBrainz Password', 'password', 'Your MusicBrainz password')">Update</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Qobuz Settings</h2>
|
||||
<div class="config-section">
|
||||
<div class="config-item">
|
||||
<span class="label">User Auth Token</span>
|
||||
<span class="value" id="config-qobuz-token">-</span>
|
||||
<button onclick="openEditSetting('QOBUZ_USER_AUTH_TOKEN', 'Qobuz User Auth Token', 'password', 'Get from browser while logged into Qobuz')">Update</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Quality</span>
|
||||
<span class="value" id="config-qobuz-quality">-</span>
|
||||
<button onclick="openEditSetting('QOBUZ_QUALITY', 'Qobuz Quality', 'select', '', ['FLAC_24_192', 'FLAC_24_96', 'FLAC_16_44', 'MP3_320'])">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Jellyfin Settings</h2>
|
||||
<div class="config-section">
|
||||
<div class="config-item">
|
||||
<span class="label">URL</span>
|
||||
<span class="value" id="config-jellyfin-url">-</span>
|
||||
<button onclick="openEditSetting('JELLYFIN_URL', 'Jellyfin URL', 'text')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">API Key</span>
|
||||
<span class="value" id="config-jellyfin-api-key">-</span>
|
||||
<button onclick="openEditSetting('JELLYFIN_API_KEY', 'Jellyfin API Key', 'password')">Update</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">User ID</span>
|
||||
<span class="value" id="config-jellyfin-user-id">-</span>
|
||||
<button onclick="openEditSetting('JELLYFIN_USER_ID', 'Jellyfin User ID', 'text', 'Required for playlist operations. Get from Jellyfin user profile URL: userId=...')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Library ID</span>
|
||||
<span class="value" id="config-jellyfin-library-id">-</span>
|
||||
<button onclick="openEditSetting('JELLYFIN_LIBRARY_ID', 'Jellyfin Library ID', 'text')">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Sync Schedule</h2>
|
||||
<div class="config-section">
|
||||
<div class="config-item">
|
||||
<span class="label">Sync Start Time</span>
|
||||
<span class="value" id="config-sync-time">-</span>
|
||||
<button onclick="openEditSetting('SPOTIFY_IMPORT_SYNC_START_HOUR', 'Sync Start Hour (0-23)', 'number')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Sync Window</span>
|
||||
<span class="value" id="config-sync-window">-</span>
|
||||
<button onclick="openEditSetting('SPOTIFY_IMPORT_SYNC_WINDOW_HOURS', 'Sync Window (hours)', 'number')">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Configuration Backup</h2>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||
Export your .env configuration for backup or import a previously saved configuration.
|
||||
</p>
|
||||
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
|
||||
<button onclick="exportEnv()">📥 Export .env</button>
|
||||
<button onclick="document.getElementById('import-env-input').click()">📤 Import .env</button>
|
||||
<input type="file" id="import-env-input" accept=".env" style="display:none" onchange="importEnv(event)">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="background: rgba(248, 81, 73, 0.1); border-color: var(--error);">
|
||||
<h2 style="color: var(--error);">Danger Zone</h2>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||
These actions can affect your data. Use with caution.
|
||||
</p>
|
||||
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
|
||||
<button class="danger" onclick="clearCache()">Clear All Cache</button>
|
||||
<button class="danger" onclick="restartContainer()">Restart Container</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Playlist Modal -->
|
||||
<div class="modal" id="add-playlist-modal">
|
||||
<div class="modal-content">
|
||||
<h3>Add Playlist</h3>
|
||||
<div class="form-group">
|
||||
<label>Playlist Name</label>
|
||||
<input type="text" id="new-playlist-name" placeholder="e.g., Release Radar">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Spotify Playlist ID</label>
|
||||
<input type="text" id="new-playlist-id" placeholder="Get from Spotify Import plugin">
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button onclick="closeModal('add-playlist-modal')">Cancel</button>
|
||||
<button class="primary" onclick="addPlaylist()">Add Playlist</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Setting Modal -->
|
||||
<div class="modal" id="edit-setting-modal">
|
||||
<div class="modal-content">
|
||||
<h3 id="edit-setting-title">Edit Setting</h3>
|
||||
<p id="edit-setting-help" style="color: var(--text-secondary); margin-bottom: 16px; display: none;"></p>
|
||||
<div class="form-group">
|
||||
<label id="edit-setting-label">Value</label>
|
||||
<div id="edit-setting-input-container">
|
||||
<input type="text" id="edit-setting-value" placeholder="Enter value">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button onclick="closeModal('edit-setting-modal')">Cancel</button>
|
||||
<button class="primary" onclick="saveEditSetting()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Track List Modal -->
|
||||
<div class="modal" id="tracks-modal">
|
||||
<div class="modal-content" style="max-width: 90%; width: 90%;">
|
||||
<h3 id="tracks-modal-title">Playlist Tracks</h3>
|
||||
<div class="tracks-list" id="tracks-list">
|
||||
<div class="loading">
|
||||
<span class="spinner"></span> Loading tracks...
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button onclick="closeModal('tracks-modal')">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manual Track Mapping Modal -->
|
||||
<div class="modal" id="manual-map-modal">
|
||||
<div class="modal-content" style="max-width: 600px;">
|
||||
<h3>Map Track</h3>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||
Map this track to either a local Jellyfin track or provide an external provider ID.
|
||||
</p>
|
||||
|
||||
<!-- Track Info -->
|
||||
<div class="form-group">
|
||||
<label>Spotify Track (Position <span id="map-position"></span>)</label>
|
||||
<div style="background: var(--bg-primary); padding: 12px; border-radius: 8px; margin-bottom: 16px;">
|
||||
<strong id="map-spotify-title"></strong><br>
|
||||
<span style="color: var(--text-secondary);" id="map-spotify-artist"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mapping Type Selection -->
|
||||
<div class="form-group">
|
||||
<label>Mapping Type</label>
|
||||
<select id="map-type-select" onchange="toggleMappingType()" style="width: 100%;">
|
||||
<option value="jellyfin">Map to Local Jellyfin Track</option>
|
||||
<option value="external">Map to External Provider ID</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Jellyfin Mapping Section -->
|
||||
<div id="jellyfin-mapping-section">
|
||||
<div class="form-group">
|
||||
<label>Search Jellyfin Tracks</label>
|
||||
<input type="text" id="map-search-query" placeholder="Search by title, artist, or album..." oninput="searchJellyfinTracks()">
|
||||
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
|
||||
Tip: Use commas to search multiple terms (e.g., "It Ain't Easy, David Bowie")
|
||||
</small>
|
||||
</div>
|
||||
<div style="text-align: center; color: var(--text-secondary); margin: 12px 0;">— OR —</div>
|
||||
<div class="form-group">
|
||||
<label>Paste Jellyfin Track URL</label>
|
||||
<input type="text" id="map-jellyfin-url" placeholder="https://jellyfin.example.com/web/#/details?id=..." oninput="extractJellyfinId()">
|
||||
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
|
||||
Paste the full URL from your Jellyfin web interface
|
||||
</small>
|
||||
</div>
|
||||
<div id="map-search-results" style="max-height: 300px; overflow-y: auto; margin-top: 12px;">
|
||||
<p style="text-align: center; color: var(--text-secondary); padding: 20px;">
|
||||
Type to search for local tracks or paste a Jellyfin URL...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- External Mapping Section -->
|
||||
<div id="external-mapping-section" style="display: none;">
|
||||
<div class="form-group">
|
||||
<label>External Provider</label>
|
||||
<select id="map-external-provider" style="width: 100%;">
|
||||
<option value="SquidWTF">SquidWTF</option>
|
||||
<option value="Deezer">Deezer</option>
|
||||
<option value="Qobuz">Qobuz</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>External Provider ID</label>
|
||||
<input type="text" id="map-external-id" placeholder="Enter the provider-specific track ID..." oninput="validateExternalMapping()">
|
||||
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
|
||||
For SquidWTF: Use the track ID from the search results or URL<br>
|
||||
For Deezer: Use the track ID from Deezer URLs<br>
|
||||
For Qobuz: Use the track ID from Qobuz URLs
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="hidden" id="map-playlist-name">
|
||||
<input type="hidden" id="map-spotify-id">
|
||||
<input type="hidden" id="map-selected-jellyfin-id">
|
||||
<div class="modal-actions">
|
||||
<button onclick="closeModal('manual-map-modal')">Cancel</button>
|
||||
<button class="primary" onclick="saveManualMapping()" id="map-save-btn" disabled>Save Mapping</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Link Playlist Modal -->
|
||||
<div class="modal" id="link-playlist-modal">
|
||||
<div class="modal-content">
|
||||
<h3>Link to Spotify Playlist</h3>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||
Enter the Spotify playlist ID or URL. Allstarr will automatically download missing tracks from your configured music service.
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label>Jellyfin Playlist</label>
|
||||
<input type="text" id="link-jellyfin-name" readonly style="background: var(--bg-primary);">
|
||||
<input type="hidden" id="link-jellyfin-id">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Spotify Playlist ID or URL</label>
|
||||
<input type="text" id="link-spotify-id" placeholder="37i9dQZF1DXcBWIGoYBM5M or spotify:playlist:... or full URL">
|
||||
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
|
||||
Accepts: <code>37i9dQZF1DXcBWIGoYBM5M</code>, <code>spotify:playlist:37i9dQZF1DXcBWIGoYBM5M</code>, or full Spotify URL
|
||||
</small>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button onclick="closeModal('link-playlist-modal')">Cancel</button>
|
||||
<button class="primary" onclick="linkPlaylist()">Link Playlist</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lyrics ID Mapping Modal -->
|
||||
<div class="modal" id="lyrics-map-modal">
|
||||
<div class="modal-content" style="max-width: 600px;">
|
||||
<h3>Map Lyrics ID</h3>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||
Manually map a track to a specific lyrics ID from lrclib.net. You can find lyrics IDs by searching on <a href="https://lrclib.net" target="_blank" style="color: var(--accent);">lrclib.net</a>.
|
||||
</p>
|
||||
|
||||
<!-- Track Info -->
|
||||
<div class="form-group">
|
||||
<label>Track</label>
|
||||
<div style="background: var(--bg-primary); padding: 12px; border-radius: 8px; margin-bottom: 16px;">
|
||||
<strong id="lyrics-map-title"></strong><br>
|
||||
<span style="color: var(--text-secondary);" id="lyrics-map-artist"></span><br>
|
||||
<small style="color: var(--text-secondary);" id="lyrics-map-album"></small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lyrics ID Input -->
|
||||
<div class="form-group">
|
||||
<label>Lyrics ID from lrclib.net</label>
|
||||
<input type="number" id="lyrics-map-id" placeholder="Enter lyrics ID (e.g., 5929990)" min="1">
|
||||
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
|
||||
Search for the track on <a href="https://lrclib.net" target="_blank" style="color: var(--accent);">lrclib.net</a> and copy the ID from the URL or API response
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<input type="hidden" id="lyrics-map-artist-value">
|
||||
<input type="hidden" id="lyrics-map-title-value">
|
||||
<input type="hidden" id="lyrics-map-album-value">
|
||||
<input type="hidden" id="lyrics-map-duration">
|
||||
|
||||
<div class="modal-actions">
|
||||
<button onclick="closeModal('lyrics-map-modal')">Cancel</button>
|
||||
<button class="primary" onclick="saveLyricsMapping()" id="lyrics-map-save-btn">Save Mapping</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Restart Overlay -->
|
||||
<div class="restart-overlay" id="restart-overlay">
|
||||
<div class="spinner-large"></div>
|
||||
<h2>Restarting Container</h2>
|
||||
<p id="restart-status">Applying configuration changes...</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Current edit setting state
|
||||
let currentEditKey = null;
|
||||
let currentEditType = null;
|
||||
let currentEditOptions = null;
|
||||
|
||||
// Track if we've already initialized the cookie date to prevent infinite loop
|
||||
let cookieDateInitialized = false;
|
||||
|
||||
// Track if restart is required
|
||||
let restartRequired = false;
|
||||
|
||||
function showRestartBanner() {
|
||||
restartRequired = true;
|
||||
document.getElementById('restart-banner').classList.add('active');
|
||||
}
|
||||
|
||||
function dismissRestartBanner() {
|
||||
document.getElementById('restart-banner').classList.remove('active');
|
||||
}
|
||||
|
||||
// Tab switching with URL hash support
|
||||
function switchTab(tabName) {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||
|
||||
const tab = document.querySelector(`.tab[data-tab="${tabName}"]`);
|
||||
const content = document.getElementById('tab-' + tabName);
|
||||
|
||||
if (tab && content) {
|
||||
tab.classList.add('active');
|
||||
content.classList.add('active');
|
||||
window.location.hash = tabName;
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
switchTab(tab.dataset.tab);
|
||||
});
|
||||
});
|
||||
|
||||
// Restore tab from URL hash on page load
|
||||
window.addEventListener('load', () => {
|
||||
const hash = window.location.hash.substring(1);
|
||||
if (hash) {
|
||||
switchTab(hash);
|
||||
}
|
||||
});
|
||||
|
||||
// Toast notification
|
||||
function showToast(message, type = 'success', duration = 3000) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast ' + type;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), duration);
|
||||
}
|
||||
|
||||
// Modal helpers
|
||||
function openModal(id) {
|
||||
document.getElementById(id).classList.add('active');
|
||||
}
|
||||
|
||||
function closeModal(id) {
|
||||
document.getElementById(id).classList.remove('active');
|
||||
}
|
||||
|
||||
// Close modals on backdrop click
|
||||
document.querySelectorAll('.modal').forEach(modal => {
|
||||
modal.addEventListener('click', e => {
|
||||
if (e.target === modal) closeModal(modal.id);
|
||||
});
|
||||
});
|
||||
|
||||
// Format cookie age with color coding
|
||||
function formatCookieAge(setDateStr, hasCookie = false) {
|
||||
if (!setDateStr) {
|
||||
if (hasCookie) {
|
||||
return { text: 'Unknown age', class: 'warning', detail: 'Cookie date not tracked', needsInit: true };
|
||||
}
|
||||
return { text: 'No cookie', class: '', detail: '', needsInit: false };
|
||||
}
|
||||
|
||||
const setDate = new Date(setDateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now - setDate;
|
||||
const daysAgo = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
const monthsAgo = daysAgo / 30;
|
||||
|
||||
let status = 'success'; // green: < 6 months
|
||||
if (monthsAgo >= 10) status = 'error'; // red: > 10 months
|
||||
else if (monthsAgo >= 6) status = 'warning'; // yellow: 6-10 months
|
||||
|
||||
let text;
|
||||
if (daysAgo === 0) text = 'Set today';
|
||||
else if (daysAgo === 1) text = 'Set yesterday';
|
||||
else if (daysAgo < 30) text = `Set ${daysAgo} days ago`;
|
||||
else if (daysAgo < 60) text = 'Set ~1 month ago';
|
||||
else text = `Set ~${Math.floor(monthsAgo)} months ago`;
|
||||
|
||||
const remaining = 12 - monthsAgo;
|
||||
let detail;
|
||||
if (remaining > 6) detail = 'Cookie typically lasts ~1 year';
|
||||
else if (remaining > 2) detail = `~${Math.floor(remaining)} months until expiration`;
|
||||
else if (remaining > 0) detail = 'Cookie may expire soon!';
|
||||
else detail = 'Cookie may have expired - update if having issues';
|
||||
|
||||
return { text, class: status, detail, needsInit: false };
|
||||
}
|
||||
|
||||
// Initialize cookie date if cookie exists but date is not set
|
||||
async function initCookieDate() {
|
||||
if (cookieDateInitialized) {
|
||||
console.log('Cookie date already initialized, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
cookieDateInitialized = true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/config/init-cookie-date', { method: 'POST' });
|
||||
if (res.ok) {
|
||||
console.log('Cookie date initialized successfully - restart container to apply');
|
||||
showToast('Cookie date set. Restart container to apply changes.', 'success');
|
||||
} else {
|
||||
const data = await res.json();
|
||||
console.log('Cookie date init response:', data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to init cookie date:', error);
|
||||
cookieDateInitialized = false; // Allow retry on error
|
||||
}
|
||||
}
|
||||
|
||||
// API calls
|
||||
async function fetchStatus() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/status');
|
||||
const data = await res.json();
|
||||
|
||||
document.getElementById('version').textContent = 'v' + data.version;
|
||||
document.getElementById('backend-type').textContent = data.backendType;
|
||||
document.getElementById('jellyfin-url').textContent = data.jellyfinUrl || '-';
|
||||
document.getElementById('playlist-count').textContent = data.spotifyImport.playlistCount;
|
||||
document.getElementById('cache-duration').textContent = data.spotify.cacheDurationMinutes + ' min';
|
||||
document.getElementById('isrc-matching').textContent = data.spotify.preferIsrcMatching ? 'Enabled' : 'Disabled';
|
||||
document.getElementById('spotify-user').textContent = data.spotify.user || '-';
|
||||
|
||||
// Update status badge and cookie age
|
||||
const statusBadge = document.getElementById('spotify-status');
|
||||
const authStatus = document.getElementById('spotify-auth-status');
|
||||
const cookieAgeEl = document.getElementById('spotify-cookie-age');
|
||||
|
||||
if (data.spotify.authStatus === 'configured') {
|
||||
statusBadge.className = 'status-badge success';
|
||||
statusBadge.innerHTML = '<span class="status-dot"></span>Spotify Ready';
|
||||
authStatus.textContent = 'Cookie Set';
|
||||
authStatus.className = 'stat-value success';
|
||||
} else if (data.spotify.authStatus === 'missing_cookie') {
|
||||
statusBadge.className = 'status-badge warning';
|
||||
statusBadge.innerHTML = '<span class="status-dot"></span>Cookie Missing';
|
||||
authStatus.textContent = 'No Cookie';
|
||||
authStatus.className = 'stat-value warning';
|
||||
} else {
|
||||
statusBadge.className = 'status-badge';
|
||||
statusBadge.innerHTML = '<span class="status-dot"></span>Not Configured';
|
||||
authStatus.textContent = 'Not Configured';
|
||||
authStatus.className = 'stat-value';
|
||||
}
|
||||
|
||||
// Update cookie age display
|
||||
if (cookieAgeEl) {
|
||||
const hasCookie = data.spotify.hasCookie;
|
||||
const age = formatCookieAge(data.spotify.cookieSetDate, hasCookie);
|
||||
cookieAgeEl.innerHTML = `<span class="${age.class}">${age.text}</span><br><small style="color:var(--text-secondary)">${age.detail}</small>`;
|
||||
|
||||
// Auto-init cookie date if cookie exists but date is not set
|
||||
if (age.needsInit) {
|
||||
console.log('Cookie exists but date not set, initializing...');
|
||||
initCookieDate();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch status:', error);
|
||||
showToast('Failed to fetch status', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPlaylists() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/playlists');
|
||||
const data = await res.json();
|
||||
|
||||
const tbody = document.getElementById('playlist-table-body');
|
||||
|
||||
if (data.playlists.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists configured. Link playlists from the Jellyfin Playlists tab.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = data.playlists.map(p => {
|
||||
// Enhanced statistics display
|
||||
const spotifyTotal = p.trackCount || 0;
|
||||
const localCount = p.localTracks || 0;
|
||||
const externalMatched = p.externalMatched || 0;
|
||||
const externalMissing = p.externalMissing || 0;
|
||||
const totalInJellyfin = p.totalInJellyfin || 0;
|
||||
const totalPlayable = p.totalPlayable || (localCount + externalMatched); // Total tracks that will be served
|
||||
|
||||
// Debug: Log the raw data
|
||||
console.log(`Playlist ${p.name}:`, {
|
||||
spotifyTotal,
|
||||
localCount,
|
||||
externalMatched,
|
||||
externalMissing,
|
||||
totalInJellyfin,
|
||||
totalPlayable,
|
||||
rawData: p
|
||||
});
|
||||
|
||||
// Build detailed stats string - show total playable tracks prominently
|
||||
let statsHtml = `<span class="track-count">${totalPlayable}/${spotifyTotal}</span>`;
|
||||
|
||||
// Show breakdown with color coding
|
||||
let breakdownParts = [];
|
||||
if (localCount > 0) {
|
||||
breakdownParts.push(`<span style="color:var(--success)">${localCount} local</span>`);
|
||||
}
|
||||
if (externalMatched > 0) {
|
||||
breakdownParts.push(`<span style="color:var(--accent)">${externalMatched} matched</span>`);
|
||||
}
|
||||
if (externalMissing > 0) {
|
||||
breakdownParts.push(`<span style="color:var(--warning)">${externalMissing} missing</span>`);
|
||||
}
|
||||
|
||||
const breakdown = breakdownParts.length > 0
|
||||
? `<br><small style="color:var(--text-secondary)">${breakdownParts.join(' • ')}</small>`
|
||||
: '';
|
||||
|
||||
// Calculate completion percentage based on playable tracks
|
||||
const completionPct = spotifyTotal > 0 ? Math.round((totalPlayable / spotifyTotal) * 100) : 0;
|
||||
const localPct = spotifyTotal > 0 ? Math.round((localCount / spotifyTotal) * 100) : 0;
|
||||
const externalPct = spotifyTotal > 0 ? Math.round((externalMatched / spotifyTotal) * 100) : 0;
|
||||
const missingPct = spotifyTotal > 0 ? Math.round((externalMissing / spotifyTotal) * 100) : 0;
|
||||
const completionColor = completionPct === 100 ? 'var(--success)' : completionPct >= 80 ? 'var(--accent)' : 'var(--warning)';
|
||||
|
||||
// Debug logging
|
||||
console.log(`Progress bar for ${p.name}: local=${localPct}%, external=${externalPct}%, missing=${missingPct}%, total=${completionPct}%`);
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td><strong>${escapeHtml(p.name)}</strong></td>
|
||||
<td style="font-family:monospace;font-size:0.85rem;color:var(--text-secondary);">${p.id || '-'}</td>
|
||||
<td>${statsHtml}${breakdown}</td>
|
||||
<td>
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<div style="flex:1;background:var(--bg-tertiary);height:12px;border-radius:6px;overflow:hidden;display:flex;">
|
||||
<div style="width:${localPct}%;height:100%;background:#10b981;transition:width 0.3s;" title="${localCount} local tracks"></div>
|
||||
<div style="width:${externalPct}%;height:100%;background:#f59e0b;transition:width 0.3s;" title="${externalMatched} external matched tracks"></div>
|
||||
<div style="width:${missingPct}%;height:100%;background:#6b7280;transition:width 0.3s;" title="${externalMissing} missing tracks"></div>
|
||||
</div>
|
||||
<span style="font-size:0.85rem;color:${completionColor};font-weight:500;min-width:40px;">${completionPct}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
${p.lyricsTotal > 0 ? `
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<div style="flex:1;background:var(--bg-tertiary);height:12px;border-radius:6px;overflow:hidden;">
|
||||
<div style="width:${p.lyricsPercentage}%;height:100%;background:${p.lyricsPercentage === 100 ? '#10b981' : '#3b82f6'};transition:width 0.3s;" title="${p.lyricsCached} lyrics cached"></div>
|
||||
</div>
|
||||
<span style="font-size:0.85rem;color:var(--text-secondary);font-weight:500;min-width:40px;">${p.lyricsPercentage}%</span>
|
||||
</div>
|
||||
` : '<span style="color:var(--text-secondary);font-size:0.85rem;">-</span>'}
|
||||
</td>
|
||||
<td class="cache-age">${p.cacheAge || '-'}</td>
|
||||
<td>
|
||||
<button onclick="matchPlaylistTracks('${escapeJs(p.name)}')">Match Tracks</button>
|
||||
<button onclick="prefetchLyrics('${escapeJs(p.name)}')">Prefetch Lyrics</button>
|
||||
<button onclick="viewTracks('${escapeJs(p.name)}')">View</button>
|
||||
<button class="danger" onclick="removePlaylist('${escapeJs(p.name)}')">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch playlists:', error);
|
||||
showToast('Failed to fetch playlists', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchConfig() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/config');
|
||||
const data = await res.json();
|
||||
|
||||
// Spotify API settings
|
||||
document.getElementById('config-spotify-enabled').textContent = data.spotifyApi.enabled ? 'Yes' : 'No';
|
||||
document.getElementById('config-spotify-cookie').textContent = data.spotifyApi.sessionCookie;
|
||||
document.getElementById('config-cache-duration').textContent = data.spotifyApi.cacheDurationMinutes + ' minutes';
|
||||
document.getElementById('config-isrc-matching').textContent = data.spotifyApi.preferIsrcMatching ? 'Enabled' : 'Disabled';
|
||||
|
||||
// Cookie age in config tab
|
||||
const configCookieAge = document.getElementById('config-cookie-age');
|
||||
if (configCookieAge) {
|
||||
const hasCookie = data.spotifyApi.sessionCookie && data.spotifyApi.sessionCookie !== '(not set)';
|
||||
const age = formatCookieAge(data.spotifyApi.sessionCookieSetDate, hasCookie);
|
||||
configCookieAge.innerHTML = `<span class="${age.class}">${age.text}</span> - ${age.detail}`;
|
||||
}
|
||||
|
||||
// Deezer settings
|
||||
document.getElementById('config-deezer-arl').textContent = data.deezer.arl || '(not set)';
|
||||
document.getElementById('config-deezer-quality').textContent = data.deezer.quality;
|
||||
|
||||
// SquidWTF settings
|
||||
document.getElementById('config-squid-quality').textContent = data.squidWtf.quality;
|
||||
|
||||
// MusicBrainz settings
|
||||
document.getElementById('config-musicbrainz-enabled').textContent = data.musicBrainz.enabled ? 'Yes' : 'No';
|
||||
document.getElementById('config-musicbrainz-username').textContent = data.musicBrainz.username || '(not set)';
|
||||
document.getElementById('config-musicbrainz-password').textContent = data.musicBrainz.password || '(not set)';
|
||||
|
||||
// Qobuz settings
|
||||
document.getElementById('config-qobuz-token').textContent = data.qobuz.userAuthToken || '(not set)';
|
||||
document.getElementById('config-qobuz-quality').textContent = data.qobuz.quality || 'FLAC';
|
||||
|
||||
// Jellyfin settings
|
||||
document.getElementById('config-jellyfin-url').textContent = data.jellyfin.url || '-';
|
||||
document.getElementById('config-jellyfin-api-key').textContent = data.jellyfin.apiKey;
|
||||
document.getElementById('config-jellyfin-user-id').textContent = data.jellyfin.userId || '(not set)';
|
||||
document.getElementById('config-jellyfin-library-id').textContent = data.jellyfin.libraryId || '-';
|
||||
|
||||
// Sync settings
|
||||
const syncHour = data.spotifyImport.syncStartHour;
|
||||
const syncMin = data.spotifyImport.syncStartMinute;
|
||||
document.getElementById('config-sync-time').textContent = `${String(syncHour).padStart(2, '0')}:${String(syncMin).padStart(2, '0')}`;
|
||||
document.getElementById('config-sync-window').textContent = data.spotifyImport.syncWindowHours + ' hours';
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch config:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJellyfinUsers() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/jellyfin/users');
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const select = document.getElementById('jellyfin-user-select');
|
||||
select.innerHTML = '<option value="">All Users</option>' +
|
||||
data.users.map(u => `<option value="${u.id}">${escapeHtml(u.name)}</option>`).join('');
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function fetchJellyfinPlaylists() {
|
||||
const tbody = document.getElementById('jellyfin-playlist-table-body');
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="loading"><span class="spinner"></span> Loading Jellyfin playlists...</td></tr>';
|
||||
|
||||
try {
|
||||
// Build URL with optional user filter
|
||||
const userId = document.getElementById('jellyfin-user-select').value;
|
||||
|
||||
let url = '/api/admin/jellyfin/playlists';
|
||||
if (userId) url += '?userId=' + encodeURIComponent(userId);
|
||||
|
||||
const res = await fetch(url);
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json();
|
||||
tbody.innerHTML = `<tr><td colspan="6" style="text-align:center;color:var(--error);padding:40px;">${errorData.error || 'Failed to fetch playlists'}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.playlists.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists found in Jellyfin</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = data.playlists.map(p => {
|
||||
const statusBadge = p.isConfigured
|
||||
? '<span class="status-badge success"><span class="status-dot"></span>Linked</span>'
|
||||
: '<span class="status-badge"><span class="status-dot"></span>Not Linked</span>';
|
||||
|
||||
const actionButton = p.isConfigured
|
||||
? `<button class="danger" onclick="unlinkPlaylist('${escapeJs(p.name)}')">Unlink</button>`
|
||||
: `<button class="primary" onclick="openLinkPlaylist('${escapeJs(p.id)}', '${escapeJs(p.name)}')">Link to Spotify</button>`;
|
||||
|
||||
const localCount = p.localTracks || 0;
|
||||
const externalCount = p.externalTracks || 0;
|
||||
const externalAvail = p.externalAvailable || 0;
|
||||
|
||||
return `
|
||||
<tr data-playlist-id="${escapeHtml(p.id)}">
|
||||
<td><strong>${escapeHtml(p.name)}</strong></td>
|
||||
<td class="track-count">${localCount}</td>
|
||||
<td class="track-count">${externalCount > 0 ? `${externalAvail}/${externalCount}` : '-'}</td>
|
||||
<td style="font-family:monospace;font-size:0.85rem;color:var(--text-secondary);">${p.linkedSpotifyId || '-'}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>${actionButton}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch Jellyfin playlists:', error);
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--error);padding:40px;">Failed to fetch playlists</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function openLinkPlaylist(jellyfinId, name) {
|
||||
document.getElementById('link-jellyfin-id').value = jellyfinId;
|
||||
document.getElementById('link-jellyfin-name').value = name;
|
||||
document.getElementById('link-spotify-id').value = '';
|
||||
openModal('link-playlist-modal');
|
||||
}
|
||||
|
||||
async function linkPlaylist() {
|
||||
const jellyfinId = document.getElementById('link-jellyfin-id').value;
|
||||
const name = document.getElementById('link-jellyfin-name').value;
|
||||
const spotifyId = document.getElementById('link-spotify-id').value.trim();
|
||||
|
||||
if (!spotifyId) {
|
||||
showToast('Spotify Playlist ID is required', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract ID from various Spotify formats:
|
||||
// - spotify:playlist:37i9dQZF1DXcBWIGoYBM5M
|
||||
// - https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M
|
||||
// - 37i9dQZF1DXcBWIGoYBM5M
|
||||
let cleanSpotifyId = spotifyId;
|
||||
|
||||
// Handle spotify: URI format
|
||||
if (spotifyId.startsWith('spotify:playlist:')) {
|
||||
cleanSpotifyId = spotifyId.replace('spotify:playlist:', '');
|
||||
}
|
||||
// Handle URL format
|
||||
else if (spotifyId.includes('spotify.com/playlist/')) {
|
||||
const match = spotifyId.match(/playlist\/([a-zA-Z0-9]+)/);
|
||||
if (match) cleanSpotifyId = match[1];
|
||||
}
|
||||
// Remove any query parameters or trailing slashes
|
||||
cleanSpotifyId = cleanSpotifyId.split('?')[0].split('#')[0].replace(/\/$/, '');
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/jellyfin/playlists/${encodeURIComponent(jellyfinId)}/link`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, spotifyPlaylistId: cleanSpotifyId })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
showToast('Playlist linked!', 'success');
|
||||
showRestartBanner();
|
||||
closeModal('link-playlist-modal');
|
||||
|
||||
// Update UI state without refetching all playlists
|
||||
const playlistsTable = document.getElementById('jellyfinPlaylistsTable');
|
||||
if (playlistsTable) {
|
||||
const rows = playlistsTable.querySelectorAll('tr');
|
||||
rows.forEach(row => {
|
||||
if (row.dataset.playlistId === jellyfinId) {
|
||||
const actionCell = row.querySelector('td:last-child');
|
||||
if (actionCell) {
|
||||
actionCell.innerHTML = `<button class="danger" onclick="unlinkPlaylist('${escapeJs(name)}')">Unlink</button>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fetchPlaylists(); // Only refresh the Active Playlists tab
|
||||
} else {
|
||||
showToast(data.error || 'Failed to link playlist', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to link playlist', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function unlinkPlaylist(name) {
|
||||
if (!confirm(`Unlink playlist "${name}"? This will stop filling in missing tracks.`)) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/jellyfin/playlists/${encodeURIComponent(name)}/unlink`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
showToast('Playlist unlinked.', 'success');
|
||||
showRestartBanner();
|
||||
|
||||
// Update UI state without refetching all playlists
|
||||
const playlistsTable = document.getElementById('jellyfinPlaylistsTable');
|
||||
if (playlistsTable) {
|
||||
const rows = playlistsTable.querySelectorAll('tr');
|
||||
rows.forEach(row => {
|
||||
const nameCell = row.querySelector('td:first-child');
|
||||
if (nameCell && nameCell.textContent === name) {
|
||||
const actionCell = row.querySelector('td:last-child');
|
||||
if (actionCell) {
|
||||
const playlistId = row.dataset.playlistId;
|
||||
actionCell.innerHTML = `<button class="primary" onclick="openLinkPlaylist('${escapeJs(playlistId)}', '${escapeJs(name)}')">Link to Spotify</button>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fetchPlaylists(); // Only refresh the Active Playlists tab
|
||||
} else {
|
||||
showToast(data.error || 'Failed to unlink playlist', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to unlink playlist', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshPlaylists() {
|
||||
try {
|
||||
showToast('Refreshing playlists...', 'success');
|
||||
const res = await fetch('/api/admin/playlists/refresh', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
showToast(data.message, 'success');
|
||||
setTimeout(fetchPlaylists, 2000);
|
||||
} catch (error) {
|
||||
showToast('Failed to refresh playlists', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function matchPlaylistTracks(name) {
|
||||
try {
|
||||
showToast(`Matching tracks for ${name}...`, 'success');
|
||||
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(name)}/match`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
showToast(`✓ ${data.message}`, 'success');
|
||||
// Refresh the playlists table after a delay to show updated counts
|
||||
setTimeout(fetchPlaylists, 2000);
|
||||
} else {
|
||||
showToast(data.error || 'Failed to match tracks', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to match tracks', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function matchAllPlaylists() {
|
||||
if (!confirm('Match tracks for ALL playlists? This may take a few minutes.')) return;
|
||||
|
||||
try {
|
||||
showToast('Matching tracks for all playlists...', 'success');
|
||||
const res = await fetch('/api/admin/playlists/match-all', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
showToast(`✓ ${data.message}`, 'success');
|
||||
// Refresh the playlists table after a delay to show updated counts
|
||||
setTimeout(fetchPlaylists, 2000);
|
||||
} else {
|
||||
showToast(data.error || 'Failed to match tracks', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to match tracks', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function prefetchLyrics(name) {
|
||||
try {
|
||||
showToast(`Prefetching lyrics for ${name}...`, 'info', 5000);
|
||||
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(name)}/prefetch-lyrics`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
const summary = `Fetched: ${data.fetched}, Cached: ${data.cached}, Missing: ${data.missing}`;
|
||||
showToast(`✓ Lyrics prefetch complete for ${name}. ${summary}`, 'success', 8000);
|
||||
} else {
|
||||
showToast(data.error || 'Failed to prefetch lyrics', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to prefetch lyrics', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function searchProvider(query, provider) {
|
||||
// Use SquidWTF HiFi API with round-robin base URLs for all searches
|
||||
// Get a random base URL from the backend
|
||||
try {
|
||||
const response = await fetch('/api/admin/squidwtf-base-url');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.baseUrl) {
|
||||
// Use the HiFi API search endpoint: /search/?s=query
|
||||
const searchUrl = `${data.baseUrl}/search/?s=${encodeURIComponent(query)}`;
|
||||
window.open(searchUrl, '_blank');
|
||||
} else {
|
||||
showToast('Failed to get search URL', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to get search URL', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function capitalizeProvider(provider) {
|
||||
// Capitalize provider names for display
|
||||
const providerMap = {
|
||||
'squidwtf': 'SquidWTF',
|
||||
'deezer': 'Deezer',
|
||||
'qobuz': 'Qobuz'
|
||||
};
|
||||
return providerMap[provider?.toLowerCase()] || provider;
|
||||
}
|
||||
|
||||
async function clearCache() {
|
||||
if (!confirm('Clear all cached playlist data?')) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/cache/clear', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
showToast(data.message, 'success');
|
||||
fetchPlaylists();
|
||||
} catch (error) {
|
||||
showToast('Failed to clear cache', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function exportEnv() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/export-env');
|
||||
if (!res.ok) {
|
||||
throw new Error('Export failed');
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `.env.backup.${new Date().toISOString().split('T')[0]}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
showToast('.env file exported successfully', 'success');
|
||||
} catch (error) {
|
||||
showToast('Failed to export .env file', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function importEnv(event) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!confirm('Import this .env file? This will replace your current configuration.\n\nA backup will be created automatically.\n\nYou will need to restart the container for changes to take effect.')) {
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const res = await fetch('/api/admin/import-env', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
showToast(data.message, 'success');
|
||||
} else {
|
||||
showToast(data.error || 'Failed to import .env file', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to import .env file', 'error');
|
||||
}
|
||||
|
||||
event.target.value = '';
|
||||
}
|
||||
|
||||
async function restartContainer() {
|
||||
if (!confirm('Restart the container to apply configuration changes?\n\nThe dashboard will be temporarily unavailable.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/restart', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
// Show the restart overlay
|
||||
document.getElementById('restart-overlay').classList.add('active');
|
||||
document.getElementById('restart-status').textContent = 'Stopping container...';
|
||||
|
||||
// Wait a bit then start checking if the server is back
|
||||
setTimeout(() => {
|
||||
document.getElementById('restart-status').textContent = 'Waiting for server to come back...';
|
||||
checkServerAndReload();
|
||||
}, 3000);
|
||||
} else {
|
||||
showToast(data.message || data.error || 'Failed to restart', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to restart container', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function checkServerAndReload() {
|
||||
let attempts = 0;
|
||||
const maxAttempts = 60; // Try for 60 seconds
|
||||
|
||||
const checkHealth = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/status', {
|
||||
method: 'GET',
|
||||
cache: 'no-store'
|
||||
});
|
||||
if (res.ok) {
|
||||
document.getElementById('restart-status').textContent = 'Server is back! Reloading...';
|
||||
dismissRestartBanner();
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// Server still restarting
|
||||
}
|
||||
|
||||
attempts++;
|
||||
document.getElementById('restart-status').textContent = `Waiting for server to come back... (${attempts}s)`;
|
||||
|
||||
if (attempts < maxAttempts) {
|
||||
setTimeout(checkHealth, 1000);
|
||||
} else {
|
||||
document.getElementById('restart-overlay').classList.remove('active');
|
||||
showToast('Server may still be restarting. Please refresh manually.', 'warning');
|
||||
}
|
||||
};
|
||||
|
||||
checkHealth();
|
||||
}
|
||||
|
||||
function openAddPlaylist() {
|
||||
document.getElementById('new-playlist-name').value = '';
|
||||
document.getElementById('new-playlist-id').value = '';
|
||||
openModal('add-playlist-modal');
|
||||
}
|
||||
|
||||
async function addPlaylist() {
|
||||
const name = document.getElementById('new-playlist-name').value.trim();
|
||||
const id = document.getElementById('new-playlist-id').value.trim();
|
||||
|
||||
if (!name || !id) {
|
||||
showToast('Name and ID are required', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/playlists', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, spotifyId: id })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
showToast('Playlist added.', 'success');
|
||||
showRestartBanner();
|
||||
closeModal('add-playlist-modal');
|
||||
} else {
|
||||
showToast(data.error || 'Failed to add playlist', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to add playlist', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function removePlaylist(name) {
|
||||
if (!confirm(`Remove playlist "${name}"?`)) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/playlists/' + encodeURIComponent(name), {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
showToast('Playlist removed.', 'success');
|
||||
showRestartBanner();
|
||||
fetchPlaylists();
|
||||
} else {
|
||||
showToast(data.error || 'Failed to remove playlist', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to remove playlist', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function viewTracks(name) {
|
||||
document.getElementById('tracks-modal-title').textContent = name + ' - Tracks';
|
||||
document.getElementById('tracks-list').innerHTML = '<div class="loading"><span class="spinner"></span> Loading tracks...</div>';
|
||||
openModal('tracks-modal');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/playlists/' + encodeURIComponent(name) + '/tracks');
|
||||
const data = await res.json();
|
||||
|
||||
if (data.tracks.length === 0) {
|
||||
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--text-secondary);padding:40px;">No tracks found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('tracks-list').innerHTML = data.tracks.map(t => {
|
||||
let statusBadge = '';
|
||||
let mapButton = '';
|
||||
let lyricsBadge = '';
|
||||
|
||||
// Add lyrics status badge
|
||||
if (t.hasLyrics) {
|
||||
lyricsBadge = '<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:4px;background:#3b82f6;color:white;"><span class="status-dot" style="background:white;"></span>Lyrics</span>';
|
||||
}
|
||||
|
||||
if (t.isLocal === true) {
|
||||
statusBadge = '<span class="status-badge success" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>Local</span>';
|
||||
// Add manual mapping indicator for local tracks
|
||||
if (t.isManualMapping && t.manualMappingType === 'jellyfin') {
|
||||
statusBadge += '<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:4px;background:var(--info);color:white;"><span class="status-dot" style="background:white;"></span>Manual</span>';
|
||||
}
|
||||
} else if (t.isLocal === false) {
|
||||
const provider = capitalizeProvider(t.externalProvider) || 'External';
|
||||
statusBadge = `<span class="status-badge warning" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>${escapeHtml(provider)}</span>`;
|
||||
// Add manual mapping indicator for external tracks
|
||||
if (t.isManualMapping && t.manualMappingType === 'external') {
|
||||
statusBadge += '<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:4px;background:var(--info);color:white;"><span class="status-dot" style="background:white;"></span>Manual</span>';
|
||||
}
|
||||
// Add both mapping buttons for external tracks using data attributes
|
||||
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
|
||||
mapButton = `<button class="small map-track-btn"
|
||||
data-playlist-name="${escapeHtml(name)}"
|
||||
data-position="${t.position}"
|
||||
data-title="${escapeHtml(t.title || '')}"
|
||||
data-artist="${escapeHtml(firstArtist)}"
|
||||
data-spotify-id="${escapeHtml(t.spotifyId || '')}"
|
||||
style="margin-left:8px;font-size:0.75rem;padding:4px 8px;">Map to Local</button>
|
||||
<button class="small map-external-btn"
|
||||
data-playlist-name="${escapeHtml(name)}"
|
||||
data-position="${t.position}"
|
||||
data-title="${escapeHtml(t.title || '')}"
|
||||
data-artist="${escapeHtml(firstArtist)}"
|
||||
data-spotify-id="${escapeHtml(t.spotifyId || '')}"
|
||||
style="margin-left:4px;font-size:0.75rem;padding:4px 8px;background:var(--warning);border-color:var(--warning);">Map to External</button>`;
|
||||
} else {
|
||||
// isLocal is null/undefined - track is missing (not found locally or externally)
|
||||
statusBadge = '<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;background:var(--bg-tertiary);color:var(--text-secondary);"><span class="status-dot" style="background:var(--text-secondary);"></span>Missing</span>';
|
||||
// Add both mapping buttons for missing tracks
|
||||
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
|
||||
mapButton = `<button class="small map-track-btn"
|
||||
data-playlist-name="${escapeHtml(name)}"
|
||||
data-position="${t.position}"
|
||||
data-title="${escapeHtml(t.title || '')}"
|
||||
data-artist="${escapeHtml(firstArtist)}"
|
||||
data-spotify-id="${escapeHtml(t.spotifyId || '')}"
|
||||
style="margin-left:8px;font-size:0.75rem;padding:4px 8px;">Map to Local</button>
|
||||
<button class="small map-external-btn"
|
||||
data-playlist-name="${escapeHtml(name)}"
|
||||
data-position="${t.position}"
|
||||
data-title="${escapeHtml(t.title || '')}"
|
||||
data-artist="${escapeHtml(firstArtist)}"
|
||||
data-spotify-id="${escapeHtml(t.spotifyId || '')}"
|
||||
style="margin-left:4px;font-size:0.75rem;padding:4px 8px;background:var(--warning);border-color:var(--warning);">Map to External</button>`;
|
||||
}
|
||||
|
||||
// Build search link with track name and artist
|
||||
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
|
||||
const searchLinkText = `${t.title} - ${firstArtist}`;
|
||||
const durationSeconds = Math.floor((t.durationMs || 0) / 1000);
|
||||
|
||||
// Add lyrics mapping button
|
||||
const lyricsMapButton = `<button class="small" onclick="openLyricsMap('${escapeJs(firstArtist)}', '${escapeJs(t.title)}', '${escapeJs(t.album || '')}', ${durationSeconds})" style="margin-left:4px;font-size:0.75rem;padding:4px 8px;background:#3b82f6;border-color:#3b82f6;color:white;">Map Lyrics ID</button>`;
|
||||
|
||||
return `
|
||||
<div class="track-item" data-position="${t.position}">
|
||||
<span class="track-position">${t.position + 1}</span>
|
||||
<div class="track-info">
|
||||
<h4>${escapeHtml(t.title)}${statusBadge}${lyricsBadge}${mapButton}${lyricsMapButton}</h4>
|
||||
<span class="artists">${escapeHtml((t.artists || []).join(', '))}</span>
|
||||
</div>
|
||||
<div class="track-meta">
|
||||
${t.album ? escapeHtml(t.album) : ''}
|
||||
${t.isrc ? '<br><small>ISRC: ' + t.isrc + '</small>' : ''}
|
||||
${t.isLocal === false && t.searchQuery && t.externalProvider ? '<br><small style="color:var(--accent)"><a href="#" onclick="searchProvider(\'' + escapeJs(t.searchQuery) + '\', \'' + escapeJs(t.externalProvider) + '\'); return false;" style="color:var(--accent);text-decoration:underline;">🔍 Search: ' + escapeHtml(searchLinkText) + '</a></small>' : ''}
|
||||
${t.isLocal === null && t.searchQuery ? '<br><small style="color:var(--text-secondary)"><a href="#" onclick="searchProvider(\'' + escapeJs(t.searchQuery) + '\', \'squidwtf\'); return false;" style="color:var(--text-secondary);text-decoration:underline;">🔍 Search: ' + escapeHtml(searchLinkText) + '</a></small>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Add event listeners to map buttons
|
||||
document.querySelectorAll('.map-track-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const playlistName = this.getAttribute('data-playlist-name');
|
||||
const position = parseInt(this.getAttribute('data-position'));
|
||||
const title = this.getAttribute('data-title');
|
||||
const artist = this.getAttribute('data-artist');
|
||||
const spotifyId = this.getAttribute('data-spotify-id');
|
||||
openManualMap(playlistName, position, title, artist, spotifyId);
|
||||
});
|
||||
});
|
||||
|
||||
// Add event listeners to external map buttons
|
||||
document.querySelectorAll('.map-external-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const playlistName = this.getAttribute('data-playlist-name');
|
||||
const position = parseInt(this.getAttribute('data-position'));
|
||||
const title = this.getAttribute('data-title');
|
||||
const artist = this.getAttribute('data-artist');
|
||||
const spotifyId = this.getAttribute('data-spotify-id');
|
||||
openExternalMap(playlistName, position, title, artist, spotifyId);
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Failed to load tracks</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// Generic edit setting modal
|
||||
function openEditSetting(envKey, label, inputType, helpText = '', options = []) {
|
||||
currentEditKey = envKey;
|
||||
currentEditType = inputType;
|
||||
currentEditOptions = options;
|
||||
|
||||
document.getElementById('edit-setting-title').textContent = 'Edit ' + label;
|
||||
document.getElementById('edit-setting-label').textContent = label;
|
||||
|
||||
const helpEl = document.getElementById('edit-setting-help');
|
||||
if (helpText) {
|
||||
helpEl.textContent = helpText;
|
||||
helpEl.style.display = 'block';
|
||||
} else {
|
||||
helpEl.style.display = 'none';
|
||||
}
|
||||
|
||||
const container = document.getElementById('edit-setting-input-container');
|
||||
|
||||
if (inputType === 'toggle') {
|
||||
container.innerHTML = `
|
||||
<select id="edit-setting-value">
|
||||
<option value="true">Enabled</option>
|
||||
<option value="false">Disabled</option>
|
||||
</select>
|
||||
`;
|
||||
} else if (inputType === 'select') {
|
||||
container.innerHTML = `
|
||||
<select id="edit-setting-value">
|
||||
${options.map(opt => `<option value="${opt}">${opt}</option>`).join('')}
|
||||
</select>
|
||||
`;
|
||||
} else if (inputType === 'password') {
|
||||
container.innerHTML = `<input type="password" id="edit-setting-value" placeholder="Enter new value" autocomplete="off">`;
|
||||
} else if (inputType === 'number') {
|
||||
container.innerHTML = `<input type="number" id="edit-setting-value" placeholder="Enter value">`;
|
||||
} else {
|
||||
container.innerHTML = `<input type="text" id="edit-setting-value" placeholder="Enter value">`;
|
||||
}
|
||||
|
||||
openModal('edit-setting-modal');
|
||||
}
|
||||
|
||||
async function saveEditSetting() {
|
||||
const value = document.getElementById('edit-setting-value').value.trim();
|
||||
|
||||
if (!value && currentEditType !== 'toggle') {
|
||||
showToast('Value is required', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ updates: { [currentEditKey]: value } })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
showToast('Setting updated.', 'success');
|
||||
showRestartBanner();
|
||||
closeModal('edit-setting-modal');
|
||||
fetchConfig();
|
||||
fetchStatus();
|
||||
} else {
|
||||
showToast(data.error || 'Failed to update setting', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to update setting', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Manual track mapping
|
||||
let searchTimeout = null;
|
||||
|
||||
async function searchJellyfinTracks() {
|
||||
const query = document.getElementById('map-search-query').value.trim();
|
||||
|
||||
if (!query) {
|
||||
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">Type to search for local tracks or paste a Jellyfin URL...</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear URL input when searching
|
||||
document.getElementById('map-jellyfin-url').value = '';
|
||||
|
||||
// Debounce search
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(async () => {
|
||||
document.getElementById('map-search-results').innerHTML = '<div class="loading"><span class="spinner"></span> Searching...</div>';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/jellyfin/search?query=' + encodeURIComponent(query));
|
||||
const data = await res.json();
|
||||
|
||||
if (data.tracks.length === 0) {
|
||||
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">No tracks found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('map-search-results').innerHTML = data.tracks.map(t => `
|
||||
<div class="track-item" style="cursor: pointer; border: 2px solid transparent;" onclick="selectJellyfinTrack('${t.id}', this)">
|
||||
<div class="track-info">
|
||||
<h4>${escapeHtml(t.title)}</h4>
|
||||
<span class="artists">${escapeHtml(t.artist)}</span>
|
||||
</div>
|
||||
<div class="track-meta">
|
||||
${t.album ? escapeHtml(t.album) : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (error) {
|
||||
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--error); padding: 20px;">Search failed</p>';
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
async function extractJellyfinId() {
|
||||
const url = document.getElementById('map-jellyfin-url').value.trim();
|
||||
|
||||
if (!url) {
|
||||
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">Type to search for local tracks or paste a Jellyfin URL...</p>';
|
||||
document.getElementById('map-selected-jellyfin-id').value = '';
|
||||
document.getElementById('map-save-btn').disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear search input when using URL
|
||||
document.getElementById('map-search-query').value = '';
|
||||
|
||||
// Extract ID from URL patterns:
|
||||
// https://jellyfin.example.com/web/#/details?id=XXXXX&serverId=...
|
||||
// https://jellyfin.example.com/web/index.html#!/details?id=XXXXX
|
||||
let jellyfinId = null;
|
||||
|
||||
try {
|
||||
const idMatch = url.match(/[?&]id=([a-f0-9]+)/i);
|
||||
if (idMatch) {
|
||||
jellyfinId = idMatch[1];
|
||||
}
|
||||
} catch (e) {
|
||||
// Invalid URL format
|
||||
}
|
||||
|
||||
if (!jellyfinId) {
|
||||
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--error); padding: 20px;">Could not extract track ID from URL. Make sure it contains "?id=..."</p>';
|
||||
document.getElementById('map-selected-jellyfin-id').value = '';
|
||||
document.getElementById('map-save-btn').disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch track details to show preview
|
||||
document.getElementById('map-search-results').innerHTML = '<div class="loading"><span class="spinner"></span> Loading track details...</div>';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/jellyfin/track/' + jellyfinId);
|
||||
const track = await res.json();
|
||||
|
||||
if (res.ok && track.id) {
|
||||
document.getElementById('map-selected-jellyfin-id').value = track.id;
|
||||
document.getElementById('map-save-btn').disabled = false;
|
||||
|
||||
document.getElementById('map-search-results').innerHTML = `
|
||||
<div class="track-item" style="border: 2px solid var(--accent); background: var(--bg-tertiary);">
|
||||
<div class="track-info">
|
||||
<h4>${escapeHtml(track.title)}</h4>
|
||||
<span class="artists">${escapeHtml(track.artist)}</span>
|
||||
</div>
|
||||
<div class="track-meta">
|
||||
${track.album ? escapeHtml(track.album) : ''}
|
||||
</div>
|
||||
</div>
|
||||
<p style="text-align: center; color: var(--success); padding: 12px; margin-top: 8px;">
|
||||
✓ Track loaded from URL. Click "Save Mapping" to confirm.
|
||||
</p>
|
||||
`;
|
||||
} else {
|
||||
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--error); padding: 20px;">Track not found in Jellyfin</p>';
|
||||
document.getElementById('map-selected-jellyfin-id').value = '';
|
||||
document.getElementById('map-save-btn').disabled = true;
|
||||
}
|
||||
} catch (error) {
|
||||
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--error); padding: 20px;">Failed to load track details</p>';
|
||||
document.getElementById('map-selected-jellyfin-id').value = '';
|
||||
document.getElementById('map-save-btn').disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
function selectJellyfinTrack(jellyfinId, element) {
|
||||
// Remove selection from all tracks
|
||||
document.querySelectorAll('#map-search-results .track-item').forEach(el => {
|
||||
el.style.border = '2px solid transparent';
|
||||
el.style.background = '';
|
||||
});
|
||||
|
||||
// Highlight selected track
|
||||
element.style.border = '2px solid var(--accent)';
|
||||
element.style.background = 'var(--bg-tertiary)';
|
||||
|
||||
// Store selected ID and enable save button
|
||||
document.getElementById('map-selected-jellyfin-id').value = jellyfinId;
|
||||
document.getElementById('map-save-btn').disabled = false;
|
||||
}
|
||||
|
||||
// Toggle between Jellyfin and external mapping modes
|
||||
function toggleMappingType() {
|
||||
const mappingType = document.getElementById('map-type-select').value;
|
||||
const jellyfinSection = document.getElementById('jellyfin-mapping-section');
|
||||
const externalSection = document.getElementById('external-mapping-section');
|
||||
const saveBtn = document.getElementById('map-save-btn');
|
||||
|
||||
if (mappingType === 'jellyfin') {
|
||||
jellyfinSection.style.display = 'block';
|
||||
externalSection.style.display = 'none';
|
||||
// Reset external fields
|
||||
document.getElementById('map-external-id').value = '';
|
||||
// Check if Jellyfin track is selected
|
||||
const jellyfinId = document.getElementById('map-selected-jellyfin-id').value;
|
||||
saveBtn.disabled = !jellyfinId;
|
||||
} else {
|
||||
jellyfinSection.style.display = 'none';
|
||||
externalSection.style.display = 'block';
|
||||
// Reset Jellyfin fields
|
||||
document.getElementById('map-search-query').value = '';
|
||||
document.getElementById('map-jellyfin-url').value = '';
|
||||
document.getElementById('map-selected-jellyfin-id').value = '';
|
||||
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">Enter an external provider ID above</p>';
|
||||
// Check if external mapping is valid
|
||||
validateExternalMapping();
|
||||
}
|
||||
}
|
||||
|
||||
// Validate external mapping input
|
||||
function validateExternalMapping() {
|
||||
const externalId = document.getElementById('map-external-id').value.trim();
|
||||
const saveBtn = document.getElementById('map-save-btn');
|
||||
|
||||
// Enable save button if external ID is provided
|
||||
saveBtn.disabled = !externalId;
|
||||
}
|
||||
|
||||
// Update the openManualMap function to reset the modal state
|
||||
function openManualMap(playlistName, position, title, artist, spotifyId) {
|
||||
document.getElementById('map-playlist-name').value = playlistName;
|
||||
document.getElementById('map-position').textContent = position + 1;
|
||||
document.getElementById('map-spotify-title').textContent = title;
|
||||
document.getElementById('map-spotify-artist').textContent = artist;
|
||||
document.getElementById('map-spotify-id').value = spotifyId;
|
||||
|
||||
// Reset to Jellyfin mapping mode
|
||||
document.getElementById('map-type-select').value = 'jellyfin';
|
||||
document.getElementById('jellyfin-mapping-section').style.display = 'block';
|
||||
document.getElementById('external-mapping-section').style.display = 'none';
|
||||
|
||||
// Reset all fields
|
||||
document.getElementById('map-search-query').value = '';
|
||||
document.getElementById('map-jellyfin-url').value = '';
|
||||
document.getElementById('map-selected-jellyfin-id').value = '';
|
||||
document.getElementById('map-external-id').value = '';
|
||||
document.getElementById('map-external-provider').value = 'SquidWTF';
|
||||
document.getElementById('map-save-btn').disabled = true;
|
||||
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">Type to search for local tracks or paste a Jellyfin URL...</p>';
|
||||
|
||||
openModal('manual-map-modal');
|
||||
}
|
||||
|
||||
// Open external mapping modal (pre-set to external mode)
|
||||
function openExternalMap(playlistName, position, title, artist, spotifyId) {
|
||||
document.getElementById('map-playlist-name').value = playlistName;
|
||||
document.getElementById('map-position').textContent = position + 1;
|
||||
document.getElementById('map-spotify-title').textContent = title;
|
||||
document.getElementById('map-spotify-artist').textContent = artist;
|
||||
document.getElementById('map-spotify-id').value = spotifyId;
|
||||
|
||||
// Set to external mapping mode
|
||||
document.getElementById('map-type-select').value = 'external';
|
||||
document.getElementById('jellyfin-mapping-section').style.display = 'none';
|
||||
document.getElementById('external-mapping-section').style.display = 'block';
|
||||
|
||||
// Reset all fields
|
||||
document.getElementById('map-search-query').value = '';
|
||||
document.getElementById('map-jellyfin-url').value = '';
|
||||
document.getElementById('map-selected-jellyfin-id').value = '';
|
||||
document.getElementById('map-external-id').value = '';
|
||||
document.getElementById('map-external-provider').value = 'SquidWTF';
|
||||
document.getElementById('map-save-btn').disabled = true;
|
||||
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">Enter an external provider ID above</p>';
|
||||
|
||||
openModal('manual-map-modal');
|
||||
}
|
||||
|
||||
// Update the saveManualMapping function to handle both types
|
||||
async function saveManualMapping() {
|
||||
const playlistName = document.getElementById('map-playlist-name').value;
|
||||
const spotifyId = document.getElementById('map-spotify-id').value;
|
||||
const mappingType = document.getElementById('map-type-select').value;
|
||||
const position = parseInt(document.getElementById('map-position').textContent) - 1; // Convert back to 0-indexed
|
||||
|
||||
let requestBody = { spotifyId };
|
||||
|
||||
if (mappingType === 'jellyfin') {
|
||||
const jellyfinId = document.getElementById('map-selected-jellyfin-id').value;
|
||||
if (!jellyfinId) {
|
||||
showToast('Please select a track', 'error');
|
||||
return;
|
||||
}
|
||||
requestBody.jellyfinId = jellyfinId;
|
||||
} else {
|
||||
const externalProvider = document.getElementById('map-external-provider').value;
|
||||
const externalId = document.getElementById('map-external-id').value.trim();
|
||||
if (!externalId) {
|
||||
showToast('Please enter an external provider ID', 'error');
|
||||
return;
|
||||
}
|
||||
requestBody.externalProvider = externalProvider;
|
||||
requestBody.externalId = externalId;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
const saveBtn = document.getElementById('map-save-btn');
|
||||
const originalText = saveBtn.textContent;
|
||||
saveBtn.textContent = 'Saving...';
|
||||
saveBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout
|
||||
|
||||
const res = await fetch('/api/admin/playlists/' + encodeURIComponent(playlistName) + '/map', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
const mappingTypeText = mappingType === 'jellyfin' ? 'local Jellyfin track' : `${requestBody.externalProvider} ID`;
|
||||
showToast(`✓ Track mapped to ${mappingTypeText} - rebuilding playlist...`, 'success');
|
||||
closeModal('manual-map-modal');
|
||||
|
||||
// Show rebuilding indicator
|
||||
showPlaylistRebuildingIndicator(playlistName);
|
||||
|
||||
// Show detailed info toast after a moment
|
||||
setTimeout(() => {
|
||||
if (mappingType === 'jellyfin') {
|
||||
showToast('🔄 Rebuilding playlist with your local track mapping...', 'info', 8000);
|
||||
} else {
|
||||
showToast(`🔄 Rebuilding playlist with your ${requestBody.externalProvider} mapping...`, 'info', 8000);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Update the track in the UI without refreshing
|
||||
const trackItem = document.querySelector(`.track-item[data-position="${position}"]`);
|
||||
if (trackItem) {
|
||||
const titleEl = trackItem.querySelector('.track-info h4');
|
||||
if (titleEl && mappingType === 'jellyfin' && data.track) {
|
||||
// For Jellyfin mappings, update with actual track info
|
||||
const titleText = data.track.title;
|
||||
const newStatusBadge = '<span class="status-badge success" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>Local</span>';
|
||||
titleEl.innerHTML = escapeHtml(titleText) + newStatusBadge;
|
||||
|
||||
const artistEl = trackItem.querySelector('.track-info .artists');
|
||||
if (artistEl) artistEl.textContent = data.track.artist;
|
||||
} else if (titleEl && mappingType === 'external') {
|
||||
// For external mappings, update status badge to show provider
|
||||
const currentTitle = titleEl.textContent.split(' - ')[0]; // Remove old status
|
||||
const capitalizedProvider = capitalizeProvider(requestBody.externalProvider);
|
||||
const newStatusBadge = `<span class="status-badge warning" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>${escapeHtml(capitalizedProvider)}</span>`;
|
||||
titleEl.innerHTML = escapeHtml(currentTitle) + newStatusBadge;
|
||||
}
|
||||
|
||||
// Remove search link since it's now mapped
|
||||
const searchLink = trackItem.querySelector('.track-meta a');
|
||||
if (searchLink) {
|
||||
searchLink.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Also refresh the playlist counts in the background
|
||||
fetchPlaylists();
|
||||
} else {
|
||||
showToast(data.error || 'Failed to save mapping', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
showToast('Request timed out - mapping may still be processing', 'warning');
|
||||
} else {
|
||||
showToast('Failed to save mapping', 'error');
|
||||
}
|
||||
} finally {
|
||||
// Reset button state
|
||||
saveBtn.textContent = originalText;
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showPlaylistRebuildingIndicator(playlistName) {
|
||||
// Find the playlist in the UI and show rebuilding state
|
||||
const playlistCards = document.querySelectorAll('.playlist-card');
|
||||
for (const card of playlistCards) {
|
||||
const nameEl = card.querySelector('h3');
|
||||
if (nameEl && nameEl.textContent.trim() === playlistName) {
|
||||
// Add rebuilding indicator
|
||||
const existingIndicator = card.querySelector('.rebuilding-indicator');
|
||||
if (!existingIndicator) {
|
||||
const indicator = document.createElement('div');
|
||||
indicator.className = 'rebuilding-indicator';
|
||||
indicator.style.cssText = `
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: var(--warning);
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
z-index: 10;
|
||||
`;
|
||||
indicator.innerHTML = '<span class="spinner" style="width: 10px; height: 10px;"></span>Rebuilding...';
|
||||
card.style.position = 'relative';
|
||||
card.appendChild(indicator);
|
||||
|
||||
// Auto-remove after 30 seconds and refresh
|
||||
setTimeout(() => {
|
||||
indicator.remove();
|
||||
fetchPlaylists(); // Refresh to get updated counts
|
||||
}, 30000);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function escapeJs(text) {
|
||||
if (!text) return '';
|
||||
return text.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
// Lyrics ID mapping functions
|
||||
function openLyricsMap(artist, title, album, durationSeconds) {
|
||||
document.getElementById('lyrics-map-artist').textContent = artist;
|
||||
document.getElementById('lyrics-map-title').textContent = title;
|
||||
document.getElementById('lyrics-map-album').textContent = album || '(No album)';
|
||||
document.getElementById('lyrics-map-artist-value').value = artist;
|
||||
document.getElementById('lyrics-map-title-value').value = title;
|
||||
document.getElementById('lyrics-map-album-value').value = album || '';
|
||||
document.getElementById('lyrics-map-duration').value = durationSeconds;
|
||||
document.getElementById('lyrics-map-id').value = '';
|
||||
|
||||
openModal('lyrics-map-modal');
|
||||
}
|
||||
|
||||
async function saveLyricsMapping() {
|
||||
const artist = document.getElementById('lyrics-map-artist-value').value;
|
||||
const title = document.getElementById('lyrics-map-title-value').value;
|
||||
const album = document.getElementById('lyrics-map-album-value').value;
|
||||
const durationSeconds = parseInt(document.getElementById('lyrics-map-duration').value);
|
||||
const lyricsId = parseInt(document.getElementById('lyrics-map-id').value);
|
||||
|
||||
if (!lyricsId || lyricsId <= 0) {
|
||||
showToast('Please enter a valid lyrics ID', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const saveBtn = document.getElementById('lyrics-map-save-btn');
|
||||
const originalText = saveBtn.textContent;
|
||||
saveBtn.textContent = 'Saving...';
|
||||
saveBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/lyrics/map', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
artist,
|
||||
title,
|
||||
album,
|
||||
durationSeconds,
|
||||
lyricsId
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
if (data.cached && data.lyrics) {
|
||||
showToast(`✓ Lyrics mapped and cached: ${data.lyrics.trackName} by ${data.lyrics.artistName}`, 'success', 5000);
|
||||
} else {
|
||||
showToast('✓ Lyrics mapping saved successfully', 'success');
|
||||
}
|
||||
closeModal('lyrics-map-modal');
|
||||
} else {
|
||||
showToast(data.error || 'Failed to save lyrics mapping', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to save lyrics mapping', 'error');
|
||||
} finally {
|
||||
saveBtn.textContent = originalText;
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Initial load
|
||||
fetchStatus();
|
||||
fetchPlaylists();
|
||||
fetchJellyfinUsers();
|
||||
fetchJellyfinPlaylists();
|
||||
fetchConfig();
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
setInterval(() => {
|
||||
fetchStatus();
|
||||
fetchPlaylists();
|
||||
}, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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
|
||||
|
||||
@@ -32,6 +34,9 @@ services:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5274:8080"
|
||||
# Admin UI on port 5275 - for local/Tailscale access only
|
||||
# DO NOT expose through reverse proxy - contains sensitive config
|
||||
- "5275:5275"
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
@@ -74,6 +79,26 @@ 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__Playlists=${SPOTIFY_IMPORT_PLAYLISTS:-}
|
||||
- SpotifyImport__PlaylistIds=${SPOTIFY_IMPORT_PLAYLIST_IDS:-}
|
||||
- SpotifyImport__PlaylistNames=${SPOTIFY_IMPORT_PLAYLIST_NAMES:-}
|
||||
- SpotifyImport__PlaylistLocalTracksPositions=${SPOTIFY_IMPORT_PLAYLIST_LOCAL_TRACKS_POSITIONS:-}
|
||||
|
||||
# ===== SPOTIFY DIRECT API (for lyrics, ISRC matching, track ordering) =====
|
||||
- SpotifyApi__Enabled=${SPOTIFY_API_ENABLED:-false}
|
||||
- SpotifyApi__ClientId=${SPOTIFY_API_CLIENT_ID:-}
|
||||
- SpotifyApi__ClientSecret=${SPOTIFY_API_CLIENT_SECRET:-}
|
||||
- SpotifyApi__SessionCookie=${SPOTIFY_API_SESSION_COOKIE:-}
|
||||
- SpotifyApi__SessionCookieSetDate=${SPOTIFY_API_SESSION_COOKIE_SET_DATE:-}
|
||||
- SpotifyApi__CacheDurationMinutes=${SPOTIFY_API_CACHE_DURATION_MINUTES:-60}
|
||||
- SpotifyApi__RateLimitDelayMs=${SPOTIFY_API_RATE_LIMIT_DELAY_MS:-100}
|
||||
- SpotifyApi__PreferIsrcMatching=${SPOTIFY_API_PREFER_ISRC_MATCHING:-true}
|
||||
|
||||
# ===== SHARED =====
|
||||
- Library__DownloadPath=/app/downloads
|
||||
- SquidWTF__Quality=${SQUIDWTF_QUALITY:-FLAC}
|
||||
@@ -85,6 +110,12 @@ services:
|
||||
- Qobuz__Quality=${QOBUZ_QUALITY:-FLAC}
|
||||
volumes:
|
||||
- ${DOWNLOAD_PATH:-./downloads}:/app/downloads
|
||||
- ${KEPT_PATH:-./kept}:/app/kept
|
||||
- ${CACHE_PATH:-./cache}:/app/cache
|
||||
# Mount .env file for runtime configuration updates from admin UI
|
||||
- ./.env:/app/.env
|
||||
# Docker socket for self-restart capability (admin UI only)
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
|
||||
networks:
|
||||
allstarr-network:
|
||||
|
||||
Reference in New Issue
Block a user