mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-11 00:18:38 -05:00
Compare commits
17 Commits
6c06c59f61
...
v1.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
0a9e528418
|
|||
|
f74728fc73
|
|||
|
87467be61b
|
|||
|
713ecd4ec8
|
|||
|
0ff1e3a428
|
|||
|
cef18b9482
|
|||
|
1bfe30b216
|
|||
|
c9c82a650d
|
|||
|
d0a7dbcc96
|
|||
|
9c9a827a91
|
|||
|
96889738df
|
|||
|
f3c791496e
|
|||
|
f68706f300
|
|||
|
9f362b4920
|
|||
|
2b09484c0b
|
|||
|
fa9739bfaa
|
|||
|
0ba51e2b30
|
44
.env.example
44
.env.example
@@ -18,27 +18,29 @@ SUBSONIC_URL=http://localhost:4533
|
||||
# Server URL (required if using Jellyfin backend)
|
||||
JELLYFIN_URL=http://localhost:8096
|
||||
|
||||
# API key for authentication (get from Jellyfin Dashboard > API Keys)
|
||||
# API key for SERVER-SIDE operations only (get from Jellyfin Dashboard > API Keys)
|
||||
# This is used by Allstarr to query Jellyfin's library on behalf of the server
|
||||
# CLIENT authentication is handled transparently - clients authenticate directly with Jellyfin
|
||||
JELLYFIN_API_KEY=
|
||||
|
||||
# User ID (get from Jellyfin Dashboard > Users > click user > check URL)
|
||||
# User ID for SERVER-SIDE library queries (get from Jellyfin Dashboard > Users > click user > check URL)
|
||||
# This determines which user's library Allstarr queries when searching/browsing
|
||||
JELLYFIN_USER_ID=
|
||||
|
||||
# Music library ID (optional, auto-detected if not set)
|
||||
# If you have multiple libraries, set this to filter to music only
|
||||
JELLYFIN_LIBRARY_ID=
|
||||
|
||||
# ===== MUSIC SOURCE SELECTION =====
|
||||
# Music service to use: SquidWTF, Deezer, or Qobuz (default: SquidWTF)
|
||||
MUSIC_SERVICE=SquidWTF
|
||||
|
||||
# Path where downloaded songs will be stored on the host (only applies if STORAGE_MODE=Permanent)
|
||||
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
|
||||
# Base directory for all downloads (default: ./downloads)
|
||||
# This creates three subdirectories:
|
||||
# - downloads/permanent/ - Permanently downloaded tracks (STORAGE_MODE=Permanent)
|
||||
# - downloads/cache/ - Temporarily cached tracks (STORAGE_MODE=Cache)
|
||||
# - downloads/kept/ - Favorited external tracks (always permanent)
|
||||
Library__DownloadPath=./downloads
|
||||
|
||||
# ===== SQUIDWTF CONFIGURATION =====
|
||||
# Different quality options for SquidWTF. Only FLAC supported right now
|
||||
@@ -108,27 +110,14 @@ CACHE_DURATION_HOURS=1
|
||||
|
||||
# ===== SPOTIFY PLAYLIST INJECTION (JELLYFIN ONLY) =====
|
||||
# REQUIRES: Jellyfin Spotify Import Plugin (https://github.com/Viperinius/jellyfin-plugin-spotify-import)
|
||||
# This feature intercepts Spotify Import plugin playlists (Release Radar, Discover Weekly) and fills them
|
||||
# with tracks auto-matched from external providers (SquidWTF, Deezer, Qobuz)
|
||||
# This feature intercepts Spotify Import plugin playlists and fills them with tracks from external providers
|
||||
# Uses JELLYFIN_URL and JELLYFIN_API_KEY configured above (no separate credentials needed)
|
||||
|
||||
# Enable Spotify playlist injection (optional, default: false)
|
||||
SPOTIFY_IMPORT_ENABLED=false
|
||||
|
||||
# Sync schedule: When does the Spotify Import plugin run?
|
||||
# Set these to match your plugin's sync schedule in Jellyfin
|
||||
# Example: If plugin runs daily at 4:15 PM, set HOUR=16 and MINUTE=15
|
||||
SPOTIFY_IMPORT_SYNC_START_HOUR=16
|
||||
SPOTIFY_IMPORT_SYNC_START_MINUTE=15
|
||||
|
||||
# Sync window: How long to search for missing tracks files (in hours)
|
||||
# The fetcher will check every 5 minutes within this window
|
||||
# Example: If plugin runs at 4:15 PM and window is 2 hours, checks from 4:00 PM to 6:00 PM
|
||||
SPOTIFY_IMPORT_SYNC_WINDOW_HOURS=2
|
||||
|
||||
# Matching interval: How often to run track matching (in hours)
|
||||
# Spotify playlists like Discover Weekly update once per week, Release Radar updates weekly
|
||||
# Most playlists don't change frequently, so running once per day is reasonable
|
||||
# Set to 0 to only run once on startup (manual trigger via admin UI still works)
|
||||
# Default: 24 hours
|
||||
SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS=24
|
||||
@@ -154,13 +143,6 @@ SPOTIFY_IMPORT_PLAYLISTS=[]
|
||||
# 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.
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -103,4 +103,7 @@ apis/api-calls/endpoint-usage.json
|
||||
originals/
|
||||
|
||||
# Sample missing playlists for Spotify integration testing
|
||||
sampleMissingPlaylists/
|
||||
sampleMissingPlaylists/
|
||||
|
||||
# Migration guide (local only)
|
||||
MIGRATION.md
|
||||
344
README.md
344
README.md
@@ -5,11 +5,7 @@
|
||||
[](https://github.com/SoPat712/allstarr/pkgs/container/allstarr)
|
||||
[](LICENSE)
|
||||
|
||||
A media server proxy that integrates music streaming providers with your local library. Works with **Jellyfin** and **Subsonic-compatible** servers (Navidrome). When a song isn't in your local library, it gets fetched from your configured provider, downloaded, and served to your client. The downloaded song then lives in your library for next time.
|
||||
|
||||
**THIS IS UNDER ACTIVE DEVELOPMENT**
|
||||
|
||||
Please report all bugs as soon as possible, as the Jellyfin addition is entirely a test at this point
|
||||
A media server proxy that integrates music streaming providers with your local library. Works with **Jellyfin** and **Subsonic-compatible** servers. When a song isn't in your local library, it gets fetched from your configured provider, downloaded, and served to your client. The downloaded song then lives in your library for next time.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -40,15 +36,17 @@ The proxy will be available at `http://localhost:5274`.
|
||||
|
||||
## Web Dashboard
|
||||
|
||||
Allstarr includes a web-based dashboard for easy configuration and playlist management, accessible at `http://localhost:5275` (internal port, not exposed through reverse proxy).
|
||||
Allstarr includes a web UI for easy configuration and playlist management, accessible at `http://localhost:5275`
|
||||
<img width="1664" height="1101" alt="image" src="https://github.com/user-attachments/assets/9159100b-7e11-449e-8530-517d336d6bd2" />
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
- **Real-time Status**: Monitor Spotify authentication, cookie age, and playlist sync status
|
||||
- **Playlist Management**: Link Jellyfin playlists to Spotify playlists with a few clicks
|
||||
- **Configuration Editor**: Update settings without manually editing .env files
|
||||
- **Track Viewer**: Browse tracks in your configured playlists
|
||||
- **Cache Management**: Clear cached data and restart the container
|
||||
- **Playlist Management**: Link Jellyfin playlists to Spotify playlists with just a few clicks
|
||||
- **Provider Matching**: It should fill in the gaps of your Jellyfin library with tracks from your selected provider
|
||||
- **WebUI**: Update settings without manually editing .env files
|
||||
- **Music**: Using multiple sources for music (optimized for SquidWTF right now, though)
|
||||
- **Lyrics**: Using multiple sources for lyrics, first Jellyfin Lyrics, then Spotify Lyrics, then LrcLib as a last resort
|
||||
|
||||
### Quick Setup with Web UI
|
||||
|
||||
@@ -65,18 +63,18 @@ Allstarr includes a web-based dashboard for easy configuration and playlist mana
|
||||
- `37i9dQZF1DXcBWIGoYBM5M` (just the ID)
|
||||
- `spotify:playlist:37i9dQZF1DXcBWIGoYBM5M` (Spotify URI)
|
||||
- `https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M` (full URL)
|
||||
4. **Restart** to apply changes (button in Configuration tab)
|
||||
4. **Restart** to apply changes (should be a banner)
|
||||
|
||||
### Why Two Playlist Tabs?
|
||||
|
||||
- **Link Playlists**: Shows all Jellyfin playlists and lets you connect them to Spotify
|
||||
- **Active Playlists**: Shows which Spotify playlists are currently being monitored and filled with tracks
|
||||
Then, proceeed to **Active Playlists**, which shows you which Spotify playlists are currently being monitored and filled with tracks, and lets you do a bunch of useful operations on them.
|
||||
|
||||
### Configuration Persistence
|
||||
|
||||
The web UI updates your `.env` file directly. Changes persist across container restarts, but require a restart to take effect. In development mode, the `.env` file is in your project root. In Docker, it's at `/app/.env`.
|
||||
|
||||
**Recommended workflow**: Use the `sp_dc` cookie method (simpler and more reliable than the Jellyfin Spotify Import plugin).
|
||||
There's an environment variable to modify this.
|
||||
|
||||
|
||||
**Recommended workflow**: Use the `sp_dc` cookie method alongside the [Spotify Import Plugin](https://github.com/Viperinius/jellyfin-plugin-spotify-import?tab=readme-ov-file).
|
||||
|
||||
### Nginx Proxy Setup (Required)
|
||||
|
||||
@@ -141,7 +139,14 @@ This project brings together all the music streaming providers into one unified
|
||||
**Compatible Jellyfin clients:**
|
||||
|
||||
- [Feishin](https://github.com/jeffvli/feishin) (Mac/Windows/Linux)
|
||||
- [Musiver](https://music.aqzscn.cn/en/) (Android/IOS/Windows/Android)
|
||||
<img width="1691" height="1128" alt="image" src="https://github.com/user-attachments/assets/c602f71c-c4dd-49a9-b533-1558e24a9f45" />
|
||||
|
||||
|
||||
- [Musiver](https://music.aqzscn.cn/en/) (Android/iOS/Windows/Android)
|
||||
<img width="523" height="1025" alt="image" src="https://github.com/user-attachments/assets/135e2721-5fd7-482f-bb06-b0736003cfe7" />
|
||||
|
||||
|
||||
- [Finamp](https://github.com/jmshrv/finamp) (Android/iOS)
|
||||
|
||||
_Working on getting more currently_
|
||||
|
||||
@@ -335,7 +340,10 @@ Subsonic__EnableExternalPlaylists=false
|
||||
|
||||
### Spotify Playlist Injection (Jellyfin Only)
|
||||
|
||||
Allstarr can automatically fill your Spotify playlists (like Release Radar and Discover Weekly) with tracks from your configured streaming provider (SquidWTF, Deezer, or Qobuz). This feature works by intercepting playlists created by the Jellyfin Spotify Import plugin and matching missing tracks with your streaming service.
|
||||
Allstarr automatically fills your Spotify playlists (like Release Radar and Discover Weekly) with tracks from your configured streaming provider (SquidWTF, Deezer, or Qobuz). This works by intercepting playlists created by the Jellyfin Spotify Import plugin and matching missing tracks with your streaming service.
|
||||
|
||||
<img width="1649" height="3764" alt="image" src="https://github.com/user-attachments/assets/a4d3d79c-7741-427f-8c01-ffc90f3a579b" />
|
||||
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
@@ -349,136 +357,112 @@ Allstarr can automatically fill your Spotify playlists (like Release Radar and D
|
||||
- Go to Jellyfin Dashboard → Plugins → Spotify Import
|
||||
- Connect your Spotify account
|
||||
- Select which playlists to sync (e.g., Release Radar, Discover Weekly)
|
||||
- Set a daily sync schedule (e.g., 4:15 PM daily)
|
||||
- The plugin will create playlists in Jellyfin and generate "missing tracks" files for songs not in your library
|
||||
- Set a sync schedule (the plugin will create playlists in Jellyfin)
|
||||
|
||||
3. **Configure Allstarr**
|
||||
- Allstarr needs to know when the plugin runs and which playlists to intercept
|
||||
- Uses your existing `JELLYFIN_URL` and `JELLYFIN_API_KEY` settings (no additional credentials needed)
|
||||
- Enable Spotify Import in Allstarr (see configuration below)
|
||||
- Link your Jellyfin playlists to Spotify playlists via the Web UI
|
||||
- Uses your existing `JELLYFIN_URL` and `JELLYFIN_API_KEY` settings
|
||||
|
||||
#### Configuration
|
||||
|
||||
| Setting | Description |
|
||||
|---------|-------------|
|
||||
| `SpotifyImport:Enabled` | Enable Spotify playlist injection (default: `false`) |
|
||||
| `SpotifyImport:SyncStartHour` | Hour when the Spotify Import plugin runs (24-hour format, 0-23) |
|
||||
| `SpotifyImport:SyncStartMinute` | Minute when the plugin runs (0-59) |
|
||||
| `SpotifyImport:SyncWindowHours` | Hours to search for missing tracks files after sync time (default: 2) |
|
||||
| `SpotifyImport:PlaylistIds` | Comma-separated Jellyfin playlist IDs to intercept |
|
||||
| `SpotifyImport:PlaylistNames` | Comma-separated playlist names (must match order of IDs) |
|
||||
| `SpotifyImport:MatchingIntervalHours` | How often to run track matching in hours (default: 24, set to 0 for startup only) |
|
||||
| `SpotifyImport:Playlists` | JSON array of playlists (managed via Web UI) |
|
||||
|
||||
**Environment variables example:**
|
||||
```bash
|
||||
# Enable the feature
|
||||
SPOTIFY_IMPORT_ENABLED=true
|
||||
|
||||
# Sync window settings (optional - used to prevent fetching too frequently)
|
||||
# The fetcher searches backwards from current time for the last 48 hours
|
||||
SPOTIFY_IMPORT_SYNC_START_HOUR=16
|
||||
SPOTIFY_IMPORT_SYNC_START_MINUTE=15
|
||||
SPOTIFY_IMPORT_SYNC_WINDOW_HOURS=2
|
||||
# Matching interval (24 hours = once per day)
|
||||
SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS=24
|
||||
|
||||
# Get playlist IDs from Jellyfin URLs: https://jellyfin.example.com/web/#/details?id=PLAYLIST_ID
|
||||
SPOTIFY_IMPORT_PLAYLIST_IDS=ba50e26c867ec9d57ab2f7bf24cfd6b0,4383a46d8bcac3be2ef9385053ea18df
|
||||
|
||||
# Names must match exactly as they appear in Jellyfin (used to find missing tracks files)
|
||||
SPOTIFY_IMPORT_PLAYLIST_NAMES=Release Radar,Discover Weekly
|
||||
# Playlists (use Web UI to manage instead of editing manually)
|
||||
SPOTIFY_IMPORT_PLAYLISTS=[["Discover Weekly","37i9dQZEVXcV6s7Dm7RXsU","first"],["Release Radar","37i9dQZEVXbng2vDHnfQlC","first"]]
|
||||
```
|
||||
|
||||
#### How It Works
|
||||
|
||||
1. **Spotify Import Plugin Runs** (e.g., daily at 4:15 PM)
|
||||
1. **Spotify Import Plugin Runs**
|
||||
- Plugin fetches your Spotify playlists
|
||||
- Creates/updates playlists in Jellyfin with tracks already in your library
|
||||
- Generates "missing tracks" JSON files for songs not found locally
|
||||
- Files are named like: `Release Radar_missing_2026-02-01_16-15.json`
|
||||
|
||||
2. **Allstarr Fetches Missing Tracks** (within sync window)
|
||||
- Searches for missing tracks files from the Jellyfin plugin
|
||||
- Searches **+24 hours forward first** (newest files), then **-48 hours backward** if not found
|
||||
- This efficiently finds the most recent file regardless of timezone differences
|
||||
- Example: Server time 12 PM EST, file timestamped 9 PM UTC (same day) → Found in forward search
|
||||
- Caches the list of missing tracks in Redis + file cache
|
||||
- Runs automatically on startup (if needed) and every 5 minutes during the sync window
|
||||
|
||||
3. **Allstarr Matches Tracks** (2 minutes after startup, then configurable interval)
|
||||
2. **Allstarr Matches Tracks** (on startup + every 24 hours by default)
|
||||
- Reads missing tracks files from the Jellyfin plugin
|
||||
- For each missing track, searches your streaming provider (SquidWTF, Deezer, or Qobuz)
|
||||
- Uses fuzzy matching to find the best match (title + artist similarity)
|
||||
- Rate-limited to avoid overwhelming the service (150ms delay between searches)
|
||||
- Caches matched results for 1 hour
|
||||
- **Pre-builds playlist items cache** for instant serving (no "on the fly" building)
|
||||
- Default interval: 24 hours (configurable via `SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS`)
|
||||
- Set to 0 to only run once on startup (manual trigger via admin UI still works)
|
||||
- Pre-builds playlist cache for instant loading
|
||||
|
||||
4. **You Open the Playlist in Jellyfin**
|
||||
3. **You Open the Playlist in Jellyfin**
|
||||
- Allstarr intercepts the request
|
||||
- Returns a merged list: local tracks + matched streaming tracks
|
||||
- Loads instantly from cache (no searching needed!)
|
||||
- Loads instantly from cache!
|
||||
|
||||
5. **You Play a Track**
|
||||
- If it's a local track, streams from Jellyfin normally
|
||||
- If it's a matched track, downloads from streaming provider on-demand
|
||||
4. **You Play a Track**
|
||||
- Local tracks stream from Jellyfin normally
|
||||
- Matched tracks download from streaming provider on-demand
|
||||
- Downloaded tracks are saved to your library for future use
|
||||
|
||||
#### Manual Triggers
|
||||
#### Manual API Triggers
|
||||
|
||||
You can manually trigger syncing and matching via API:
|
||||
You can manually trigger operations via the admin API:
|
||||
|
||||
```bash
|
||||
# Get API key from your .env file
|
||||
API_KEY="your-api-key-here"
|
||||
|
||||
# Fetch missing tracks from Jellyfin plugin
|
||||
curl "https://your-jellyfin-proxy.com/spotify/sync?api_key=YOUR_API_KEY"
|
||||
curl "http://localhost:5274/spotify/sync?api_key=$API_KEY"
|
||||
|
||||
# Trigger track matching (searches streaming provider)
|
||||
curl "https://your-jellyfin-proxy.com/spotify/match?api_key=YOUR_API_KEY"
|
||||
curl "http://localhost:5274/spotify/match?api_key=$API_KEY"
|
||||
|
||||
# Clear cache to force re-matching
|
||||
curl "https://your-jellyfin-proxy.com/spotify/clear-cache?api_key=YOUR_API_KEY"
|
||||
# Match all playlists (refresh all matches)
|
||||
curl "http://localhost:5274/spotify/match-all?api_key=$API_KEY"
|
||||
|
||||
# Clear cache and rebuild
|
||||
curl "http://localhost:5274/spotify/clear-cache?api_key=$API_KEY"
|
||||
|
||||
# Refresh specific playlist
|
||||
curl "http://localhost:5274/spotify/refresh-playlist?playlistId=PLAYLIST_ID&api_key=$API_KEY"
|
||||
```
|
||||
|
||||
#### Startup Behavior
|
||||
#### Web UI Management
|
||||
|
||||
When Allstarr starts with Spotify Import enabled:
|
||||
The easiest way to manage Spotify playlists is through the Web UI at `http://localhost:5275`:
|
||||
|
||||
**Smart Cache Check:**
|
||||
- Checks if today's sync window has passed (e.g., if sync is at 4 PM + 2 hour window = 6 PM)
|
||||
- If before 6 PM and yesterday's cache exists → **Skips fetch** (cache is still current)
|
||||
- If after 6 PM or no cache exists → **Fetches missing tracks** from Jellyfin plugin
|
||||
|
||||
**Track Matching:**
|
||||
- **T+2min**: Matches tracks with streaming provider (with rate limiting)
|
||||
- Only matches playlists that don't already have cached matches
|
||||
- **Result**: Playlists load instantly when you open them!
|
||||
|
||||
**Example Timeline:**
|
||||
- Plugin runs daily at 4:15 PM, creates files at ~4:16 PM
|
||||
- You restart Allstarr at 12:00 PM (noon) the next day
|
||||
- Startup check: "Today's sync window ends at 6 PM, and I have yesterday's 4:16 PM file"
|
||||
- **Decision**: Skip fetch, use existing cache
|
||||
- At 6:01 PM: Next scheduled check will search for new files
|
||||
1. **Link Playlists Tab**: Link Jellyfin playlists to Spotify playlists
|
||||
2. **Active Playlists Tab**: View status, trigger matching, and manage playlists
|
||||
3. **Configuration Tab**: Enable/disable Spotify Import and adjust settings
|
||||
|
||||
#### Troubleshooting
|
||||
|
||||
**Playlists are empty:**
|
||||
- Check that the Spotify Import plugin is running and creating playlists
|
||||
- Verify `SPOTIFY_IMPORT_PLAYLIST_IDS` match your Jellyfin playlist IDs
|
||||
- Verify playlists are linked in the Web UI
|
||||
- Check logs: `docker-compose logs -f allstarr | grep -i spotify`
|
||||
|
||||
**Tracks aren't matching:**
|
||||
- Ensure your streaming provider is configured (`MUSIC_SERVICE`, credentials)
|
||||
- Check that playlist names in `SPOTIFY_IMPORT_PLAYLIST_NAMES` match exactly
|
||||
- Manually trigger matching: `curl "https://your-proxy.com/spotify/match?api_key=KEY"`
|
||||
- Manually trigger matching via Web UI or API
|
||||
- Check that the Jellyfin plugin generated missing tracks files
|
||||
|
||||
**Sync timing issues:**
|
||||
- Set `SPOTIFY_IMPORT_SYNC_START_HOUR/MINUTE` to match your plugin schedule
|
||||
- Increase `SPOTIFY_IMPORT_SYNC_WINDOW_HOURS` if files aren't being found
|
||||
- Check Jellyfin plugin logs to confirm when it runs
|
||||
**Performance:**
|
||||
- Matching runs in background with rate limiting (150ms between searches)
|
||||
- First match may take a few minutes for large playlists
|
||||
- Subsequent loads are instant (served from cache)
|
||||
|
||||
#### Notes
|
||||
|
||||
- This feature uses your existing `JELLYFIN_URL` and `JELLYFIN_API_KEY` settings
|
||||
- Matched tracks are cached for 1 hour to avoid repeated searches
|
||||
- Missing tracks cache persists across restarts (stored in Redis + file cache)
|
||||
- Rate limiting prevents overwhelming your streaming provider (150ms between searches)
|
||||
- Uses your existing `JELLYFIN_URL` and `JELLYFIN_API_KEY` settings
|
||||
- Matched tracks cached for fast loading
|
||||
- Missing tracks cache persists across restarts (Redis + file cache)
|
||||
- Rate limiting prevents overwhelming your streaming provider
|
||||
- Only works with Jellyfin backend (not Subsonic/Navidrome)
|
||||
|
||||
### Getting Credentials
|
||||
@@ -592,9 +576,46 @@ If you prefer to run Allstarr without Docker:
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Jellyfin Backend (Primary Focus)
|
||||
|
||||
The proxy provides comprehensive Jellyfin API support with streaming provider integration:
|
||||
|
||||
| Endpoint | Description |
|
||||
|----------|-------------|
|
||||
| `GET /Items` | Search and browse library items (local + streaming providers) |
|
||||
| `GET /Artists` | Browse artists with merged results from local + streaming |
|
||||
| `GET /Artists/AlbumArtists` | Album artists with streaming provider results |
|
||||
| `GET /Users/{userId}/Items` | User library items with external content |
|
||||
| `GET /Audio/{id}/stream` | Stream audio, downloading from provider on-demand |
|
||||
| `GET /Audio/{id}/Lyrics` | Lyrics from Jellyfin, Spotify, or LRCLib |
|
||||
| `GET /Items/{id}/Images/{type}` | Proxy cover art for external content |
|
||||
| `GET /Playlists/{id}/Items` | Playlist items (Spotify Import integration) |
|
||||
| `POST /UserFavoriteItems/{id}` | Favorite items; copies external tracks to kept folder |
|
||||
| `DELETE /UserFavoriteItems/{id}` | Unfavorite items |
|
||||
| `POST /Sessions/Playing` | Playback reporting for external tracks |
|
||||
| `POST /Sessions/Playing/Progress` | Playback progress tracking |
|
||||
| `POST /Sessions/Playing/Stopped` | Playback stopped reporting |
|
||||
| `WebSocket /socket` | Real-time session management and remote control |
|
||||
|
||||
**Admin API (Port 5275):**
|
||||
| Endpoint | Description |
|
||||
|----------|-------------|
|
||||
| `GET /api/config` | Get current configuration |
|
||||
| `POST /api/config` | Update configuration |
|
||||
| `GET /api/playlists` | List Spotify Import playlists |
|
||||
| `POST /api/playlists/link` | Link Jellyfin playlist to Spotify |
|
||||
| `DELETE /api/playlists/{id}` | Unlink playlist |
|
||||
| `POST /spotify/sync` | Fetch missing tracks from Jellyfin plugin |
|
||||
| `POST /spotify/match` | Trigger track matching |
|
||||
| `POST /spotify/match-all` | Match all playlists |
|
||||
| `POST /spotify/clear-cache` | Clear playlist cache |
|
||||
| `POST /spotify/refresh-playlist` | Refresh specific playlist |
|
||||
|
||||
All other Jellyfin API endpoints are passed through unchanged.
|
||||
|
||||
### Subsonic Backend
|
||||
|
||||
The proxy implements the Subsonic API and adds transparent streaming provider integration:
|
||||
The proxy implements the Subsonic API with streaming provider integration:
|
||||
|
||||
| Endpoint | Description |
|
||||
|----------|-------------|
|
||||
@@ -608,20 +629,6 @@ The proxy implements the Subsonic API and adds transparent streaming provider in
|
||||
|
||||
All other Subsonic API endpoints are passed through to Navidrome unchanged.
|
||||
|
||||
### Jellyfin Backend
|
||||
|
||||
The proxy implements a subset of the Jellyfin API:
|
||||
|
||||
| Endpoint | Description |
|
||||
|----------|-------------|
|
||||
| `GET /Items` | Search and browse library items |
|
||||
| `GET /Artists` | Browse artists with streaming provider results |
|
||||
| `GET /Audio/{id}/stream` | Stream audio, downloading from provider if needed |
|
||||
| `GET /Items/{id}/Images/{type}` | Proxy cover art for external content |
|
||||
| `POST /UserFavoriteItems/{id}` | Favorite items; triggers playlist download |
|
||||
|
||||
All other Jellyfin API endpoints are passed through unchanged.
|
||||
|
||||
## External ID Format
|
||||
|
||||
External (streaming provider) content uses typed IDs:
|
||||
@@ -636,25 +643,37 @@ Legacy format `ext-deezer-{id}` is also supported (assumes song type).
|
||||
|
||||
## Download Folder Structure
|
||||
|
||||
Downloaded music is organized as:
|
||||
All downloads are organized under a single base directory (default: `./downloads`):
|
||||
|
||||
```
|
||||
downloads/
|
||||
├── Artist Name/
|
||||
│ ├── Album Title/
|
||||
│ │ ├── 01 - Track One.mp3
|
||||
│ │ ├── 02 - Track Two.mp3
|
||||
│ │ └── ...
|
||||
│ └── Another Album/
|
||||
│ └── ...
|
||||
├── Another Artist/
|
||||
│ └── ...
|
||||
└── playlists/
|
||||
├── My Favorite Songs.m3u
|
||||
├── Chill Vibes.m3u
|
||||
└── ...
|
||||
├── permanent/ # Permanent downloads (STORAGE_MODE=Permanent)
|
||||
│ ├── Artist Name/
|
||||
│ │ ├── Album Title/
|
||||
│ │ │ ├── 01 - Track One.flac
|
||||
│ │ │ ├── 02 - Track Two.flac
|
||||
│ │ │ └── ...
|
||||
│ │ └── Another Album/
|
||||
│ │ └── ...
|
||||
│ └── playlists/
|
||||
│ ├── My Favorite Songs.m3u
|
||||
│ └── Chill Vibes.m3u
|
||||
├── cache/ # Temporary cache (STORAGE_MODE=Cache)
|
||||
│ └── Artist Name/
|
||||
│ └── Album Title/
|
||||
│ └── Track.flac
|
||||
└── kept/ # Favorited external tracks (always permanent)
|
||||
└── Artist Name/
|
||||
└── Album Title/
|
||||
└── Track.flac
|
||||
```
|
||||
|
||||
Playlists are stored as M3U files with relative paths to downloaded tracks, making them portable and compatible with most music players.
|
||||
**Storage modes:**
|
||||
- **Permanent** (`downloads/permanent/`): Files saved permanently and registered in your media server
|
||||
- **Cache** (`downloads/cache/`): Temporary files, auto-cleaned after `CACHE_DURATION_HOURS`
|
||||
- **Kept** (`downloads/kept/`): External tracks you've favorited - always permanent, separate from cache
|
||||
|
||||
Playlists are stored as M3U files with relative paths, making them portable and compatible with most music players.
|
||||
|
||||
## Metadata Embedding
|
||||
|
||||
@@ -685,10 +704,17 @@ dotnet test
|
||||
```
|
||||
allstarr/
|
||||
├── Controllers/
|
||||
│ ├── JellyfinController.cs # Jellyfin API controller (registered when Backend:Type=Jellyfin)
|
||||
│ └── SubsonicController.cs # Subsonic API controller (registered when Backend:Type=Subsonic)
|
||||
│ ├── AdminController.cs # Admin dashboard API
|
||||
│ ├── JellyfinController.cs # Jellyfin API controller
|
||||
│ └── SubsonicController.cs # Subsonic API controller
|
||||
├── Filters/
|
||||
│ ├── AdminPortFilter.cs # Admin port access control
|
||||
│ ├── ApiKeyAuthFilter.cs # API key authentication
|
||||
│ └── JellyfinAuthFilter.cs # Jellyfin authentication
|
||||
├── Middleware/
|
||||
│ └── GlobalExceptionHandler.cs # Global error handling
|
||||
│ ├── AdminStaticFilesMiddleware.cs # Admin UI static file serving
|
||||
│ ├── GlobalExceptionHandler.cs # Global error handling
|
||||
│ └── WebSocketProxyMiddleware.cs # WebSocket proxying for Jellyfin
|
||||
├── Models/
|
||||
│ ├── Domain/ # Domain entities
|
||||
│ │ ├── Song.cs
|
||||
@@ -697,18 +723,39 @@ allstarr/
|
||||
│ ├── Settings/ # Configuration models
|
||||
│ │ ├── SubsonicSettings.cs
|
||||
│ │ ├── DeezerSettings.cs
|
||||
│ │ └── QobuzSettings.cs
|
||||
│ │ ├── QobuzSettings.cs
|
||||
│ │ ├── SquidWTFSettings.cs
|
||||
│ │ ├── SpotifyApiSettings.cs
|
||||
│ │ ├── SpotifyImportSettings.cs
|
||||
│ │ ├── MusicBrainzSettings.cs
|
||||
│ │ └── RedisSettings.cs
|
||||
│ ├── Download/ # Download-related models
|
||||
│ │ ├── DownloadInfo.cs
|
||||
│ │ └── DownloadStatus.cs
|
||||
│ ├── Lyrics/
|
||||
│ │ └── LyricsInfo.cs
|
||||
│ ├── Search/
|
||||
│ │ └── SearchResult.cs
|
||||
│ ├── Spotify/
|
||||
│ │ ├── MissingTrack.cs
|
||||
│ │ └── SpotifyPlaylistTrack.cs
|
||||
│ └── Subsonic/
|
||||
│ ├── ExternalPlaylist.cs
|
||||
│ └── ScanStatus.cs
|
||||
├── Services/
|
||||
│ ├── Common/ # Shared services
|
||||
│ │ ├── BaseDownloadService.cs # Template method base class
|
||||
│ │ ├── CacheCleanupService.cs # Cache cleanup background service
|
||||
│ │ ├── CacheWarmingService.cs # Startup cache warming
|
||||
│ │ ├── EndpointBenchmarkService.cs # Endpoint performance benchmarking
|
||||
│ │ ├── FuzzyMatcher.cs # Fuzzy string matching
|
||||
│ │ ├── GenreEnrichmentService.cs # MusicBrainz genre enrichment
|
||||
│ │ ├── OdesliService.cs # Odesli/song.link conversion
|
||||
│ │ ├── ParallelMetadataService.cs # Parallel metadata fetching
|
||||
│ │ ├── PathHelper.cs # Path utilities
|
||||
│ │ ├── PlaylistIdHelper.cs # Playlist ID helpers
|
||||
│ │ ├── RedisCacheService.cs # Redis caching
|
||||
│ │ ├── RoundRobinFallbackHelper.cs # Load balancing and failover
|
||||
│ │ ├── Result.cs # Result<T> pattern
|
||||
│ │ └── Error.cs # Error types
|
||||
│ ├── Deezer/ # Deezer provider
|
||||
@@ -720,12 +767,35 @@ allstarr/
|
||||
│ │ ├── QobuzMetadataService.cs
|
||||
│ │ ├── QobuzBundleService.cs
|
||||
│ │ └── QobuzStartupValidator.cs
|
||||
│ ├── SquidWTF/ # SquidWTF provider
|
||||
│ │ ├── SquidWTFDownloadService.cs
|
||||
│ │ ├── SquidWTFMetadataService.cs
|
||||
│ │ └── SquidWTFStartupValidator.cs
|
||||
│ ├── Jellyfin/ # Jellyfin integration
|
||||
│ │ ├── JellyfinModelMapper.cs # Model mapping
|
||||
│ │ ├── JellyfinProxyService.cs # Request proxying
|
||||
│ │ ├── JellyfinResponseBuilder.cs # Response building
|
||||
│ │ ├── JellyfinSessionManager.cs # Session management
|
||||
│ │ └── JellyfinStartupValidator.cs # Startup validation
|
||||
│ ├── Lyrics/ # Lyrics services
|
||||
│ │ ├── LrclibService.cs # LRCLIB lyrics
|
||||
│ │ ├── LyricsPrefetchService.cs # Background lyrics prefetching
|
||||
│ │ ├── LyricsStartupValidator.cs # Lyrics validation
|
||||
│ │ └── SpotifyLyricsService.cs # Spotify lyrics
|
||||
│ ├── MusicBrainz/
|
||||
│ │ └── MusicBrainzService.cs # MusicBrainz metadata
|
||||
│ ├── Spotify/ # Spotify integration
|
||||
│ │ ├── SpotifyApiClient.cs # Spotify API client
|
||||
│ │ ├── SpotifyMissingTracksFetcher.cs # Missing tracks fetcher
|
||||
│ │ ├── SpotifyPlaylistFetcher.cs # Playlist fetcher
|
||||
│ │ └── SpotifyTrackMatchingService.cs # Track matching
|
||||
│ ├── Local/ # Local library
|
||||
│ │ ├── ILocalLibraryService.cs
|
||||
│ │ └── LocalLibraryService.cs
|
||||
│ ├── Subsonic/ # Subsonic API logic
|
||||
│ │ ├── SubsonicProxyService.cs # Request proxying
|
||||
│ │ ├── PlaylistSyncService.cs # Playlist synchronization
|
||||
│ │ ├── SubsonicModelMapper.cs # Model mapping
|
||||
│ │ ├── SubsonicProxyService.cs # Request proxying
|
||||
│ │ ├── SubsonicRequestParser.cs # Request parsing
|
||||
│ │ └── SubsonicResponseBuilder.cs # Response building
|
||||
│ ├── Validation/ # Startup validation
|
||||
@@ -737,13 +807,17 @@ allstarr/
|
||||
│ ├── IDownloadService.cs # Download interface
|
||||
│ ├── IMusicMetadataService.cs # Metadata interface
|
||||
│ └── StartupValidationService.cs
|
||||
├── wwwroot/ # Admin UI static files
|
||||
│ ├── index.html # Admin dashboard
|
||||
│ └── placeholder.png # Placeholder image
|
||||
├── Program.cs # Application entry point
|
||||
└── appsettings.json # Configuration
|
||||
|
||||
allstarr.Tests/
|
||||
├── DeezerDownloadServiceTests.cs # Deezer download tests
|
||||
├── DeezerMetadataServiceTests.cs # Deezer metadata tests
|
||||
├── QobuzDownloadServiceTests.cs # Qobuz download tests (127 tests)
|
||||
├── JellyfinResponseStructureTests.cs # Jellyfin response tests
|
||||
├── QobuzDownloadServiceTests.cs # Qobuz download tests
|
||||
├── LocalLibraryServiceTests.cs # Local library tests
|
||||
├── SubsonicModelMapperTests.cs # Model mapping tests
|
||||
├── SubsonicProxyServiceTests.cs # Proxy service tests
|
||||
@@ -817,7 +891,7 @@ We welcome contributions! Here's how to get started:
|
||||
- Follow existing code patterns and conventions
|
||||
- Add tests for new features
|
||||
- Update documentation as needed
|
||||
- Keep commits focused and atomic
|
||||
- Keep commits feature focused
|
||||
|
||||
### Testing
|
||||
|
||||
@@ -839,8 +913,14 @@ GPL-3.0
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- [Navidrome](https://www.navidrome.org/) - The excellent self-hosted music server
|
||||
- [octo-fiesta](https://github.com/V1ck3s/octo-fiesta) - The original
|
||||
- [octo-fiestarr](https://github.com/bransoned/octo-fiestarr) - The fork that introduced me to this idea based on the above
|
||||
- [Jellyfin Spotify Import Plugin](https://github.com/Viperinius/jellyfin-plugin-spotify-import?tab=readme-ov-file) - The plugin that I **strongly** recommend using alongside this repo
|
||||
- [Jellyfin](https://jellyfin.org/) - The free and open-source media server
|
||||
- [Navidrome](https://www.navidrome.org/) - The excellent self-hosted music server
|
||||
- [Subsonic API](http://www.subsonic.org/pages/api.jsp) - The API specification
|
||||
- [Hi-Fi API](https://github.com/binimum/hifi-api) - These people do some great work, and you should thank them for this even existing!
|
||||
- [Deezer](https://www.deezer.com/) - Music streaming service
|
||||
- [Qobuz](https://www.qobuz.com/) - Hi-Res music streaming service
|
||||
- [Subsonic API](http://www.subsonic.org/pages/api.jsp) - The API specification
|
||||
- [spotify-lyrics-api](https://github.com/akashrchandran/spotify-lyrics-api) - Thank them for the fact that we have access to Spotify's lyrics!
|
||||
- [LRCLIB](https://github.com/tranxuanthang/lrclib) - The GOATS for giving us a free api for lyrics! They power LRCGET, which I'm sure some of you have heard of
|
||||
|
||||
162
allstarr.Tests/FuzzyMatcherTests.cs
Normal file
162
allstarr.Tests/FuzzyMatcherTests.cs
Normal file
@@ -0,0 +1,162 @@
|
||||
using Xunit;
|
||||
using allstarr.Services.Common;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
public class FuzzyMatcherTests
|
||||
{
|
||||
|
||||
[Theory]
|
||||
[InlineData("Mr. Brightside", "Mr. Brightside", 100)]
|
||||
[InlineData("Mr Brightside", "Mr. Brightside", 100)]
|
||||
[InlineData("Mr. Brightside", "Mr Brightside", 100)]
|
||||
[InlineData("The Killers", "Killers", 85)]
|
||||
[InlineData("Dua Lipa", "Dua-Lipa", 100)]
|
||||
public void CalculateSimilarity_ExactAndNearMatches_ReturnsHighScore(string str1, string str2, int expectedMin)
|
||||
{
|
||||
// Act
|
||||
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
||||
|
||||
// Assert
|
||||
Assert.True(score >= expectedMin, $"Expected score >= {expectedMin}, got {score}");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Mr. Brightside", "Somebody Told Me", 20)]
|
||||
[InlineData("The Killers", "The Beatles", 40)]
|
||||
[InlineData("Hot Fuss", "Sam's Town", 20)]
|
||||
public void CalculateSimilarity_DifferentStrings_ReturnsLowScore(string str1, string str2, int expectedMax)
|
||||
{
|
||||
// Act
|
||||
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
||||
|
||||
// Assert
|
||||
Assert.True(score <= expectedMax, $"Expected score <= {expectedMax}, got {score}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateSimilarity_IgnoresPunctuation()
|
||||
{
|
||||
// Arrange
|
||||
var str1 = "Don't Stop Believin'";
|
||||
var str2 = "Dont Stop Believin";
|
||||
|
||||
// Act
|
||||
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
||||
|
||||
// Assert
|
||||
Assert.True(score >= 95, $"Expected high score for punctuation differences, got {score}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateSimilarity_IgnoresCase()
|
||||
{
|
||||
// Arrange
|
||||
var str1 = "Mr. Brightside";
|
||||
var str2 = "mr. brightside";
|
||||
|
||||
// Act
|
||||
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(100, score);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateSimilarity_HandlesArticles()
|
||||
{
|
||||
// Arrange
|
||||
var str1 = "The Killers";
|
||||
var str2 = "Killers";
|
||||
|
||||
// Act
|
||||
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
||||
|
||||
// Assert
|
||||
Assert.True(score >= 80, $"Expected high score when 'The' is removed, got {score}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateSimilarity_HandlesFeaturedArtists()
|
||||
{
|
||||
// Arrange
|
||||
var str1 = "Song Title (feat. Artist)";
|
||||
var str2 = "Song Title";
|
||||
|
||||
// Act
|
||||
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
||||
|
||||
// Assert
|
||||
Assert.True(score >= 70, $"Expected decent score for featured artist variations, got {score}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateSimilarity_HandlesRemixes()
|
||||
{
|
||||
// Arrange
|
||||
var str1 = "Song Title - Radio Edit";
|
||||
var str2 = "Song Title";
|
||||
|
||||
// Act
|
||||
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
||||
|
||||
// Assert
|
||||
Assert.True(score >= 70, $"Expected decent score for remix/edit variations, got {score}");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("", "", 0)]
|
||||
[InlineData("Test", "", 0)]
|
||||
[InlineData("", "Test", 0)]
|
||||
public void CalculateSimilarity_EmptyStrings_ReturnsZero(string str1, string str2, int expected)
|
||||
{
|
||||
// Act
|
||||
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expected, score);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateSimilarity_TokenOrder_DoesNotMatter()
|
||||
{
|
||||
// Arrange
|
||||
var str1 = "Bright Side Mr";
|
||||
var str2 = "Mr Bright Side";
|
||||
|
||||
// Act
|
||||
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
||||
|
||||
// Assert
|
||||
Assert.True(score >= 90, $"Expected high score regardless of token order, got {score}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateSimilarity_PartialTokenMatch_ReturnsModerateScore()
|
||||
{
|
||||
// Arrange
|
||||
var str1 = "Mr. Brightside";
|
||||
var str2 = "Mr. Brightside (Live)";
|
||||
|
||||
// Act
|
||||
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
||||
|
||||
// Assert
|
||||
Assert.True(score >= 70 && score < 100, $"Expected moderate score for partial match, got {score}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateSimilarity_SpecialCharacters_AreNormalized()
|
||||
{
|
||||
// Arrange
|
||||
var str1 = "Café del Mar";
|
||||
var str2 = "Cafe del Mar";
|
||||
|
||||
// Act
|
||||
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
||||
|
||||
// Assert
|
||||
Assert.True(score >= 90, $"Expected high score for accented characters, got {score}");
|
||||
}
|
||||
|
||||
}
|
||||
96
allstarr.Tests/LrclibServiceTests.cs
Normal file
96
allstarr.Tests/LrclibServiceTests.cs
Normal file
@@ -0,0 +1,96 @@
|
||||
using Xunit;
|
||||
using Moq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using allstarr.Services.Lyrics;
|
||||
using allstarr.Services.Common;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Models.Settings;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
public class LrclibServiceTests
|
||||
{
|
||||
private readonly Mock<ILogger<LrclibService>> _mockLogger;
|
||||
private readonly Mock<IHttpClientFactory> _mockHttpClientFactory;
|
||||
private readonly Mock<RedisCacheService> _mockCache;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public LrclibServiceTests()
|
||||
{
|
||||
_mockLogger = new Mock<ILogger<LrclibService>>();
|
||||
_mockHttpClientFactory = new Mock<IHttpClientFactory>();
|
||||
|
||||
// Create mock Redis cache
|
||||
var mockRedisLogger = new Mock<ILogger<RedisCacheService>>();
|
||||
var mockRedisSettings = Options.Create(new RedisSettings { Enabled = false });
|
||||
_mockCache = new Mock<RedisCacheService>(mockRedisSettings, mockRedisLogger.Object);
|
||||
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri("https://lrclib.net")
|
||||
};
|
||||
|
||||
_mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(_httpClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InitializesWithDependencies()
|
||||
{
|
||||
// Act
|
||||
var service = new LrclibService(_mockHttpClientFactory.Object, _mockCache.Object, _mockLogger.Object);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLyricsAsync_RequiresValidParameters()
|
||||
{
|
||||
// Arrange
|
||||
var service = new LrclibService(_mockHttpClientFactory.Object, _mockCache.Object, _mockLogger.Object);
|
||||
|
||||
// Act & Assert - Should handle empty parameters gracefully
|
||||
var result = service.GetLyricsAsync("", "Artist", "Album", 180);
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLyricsAsync_SupportsMultipleArtists()
|
||||
{
|
||||
// Arrange
|
||||
var service = new LrclibService(_mockHttpClientFactory.Object, _mockCache.Object, _mockLogger.Object);
|
||||
var artists = new[] { "Artist 1", "Artist 2", "Artist 3" };
|
||||
|
||||
// Act
|
||||
var result = service.GetLyricsAsync("Track Name", artists, "Album", 180);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLyricsByIdAsync_AcceptsValidId()
|
||||
{
|
||||
// Arrange
|
||||
var service = new LrclibService(_mockHttpClientFactory.Object, _mockCache.Object, _mockLogger.Object);
|
||||
|
||||
// Act
|
||||
var result = service.GetLyricsByIdAsync(123456);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLyricsCachedAsync_UsesCache()
|
||||
{
|
||||
// Arrange
|
||||
var service = new LrclibService(_mockHttpClientFactory.Object, _mockCache.Object, _mockLogger.Object);
|
||||
|
||||
// Act
|
||||
var result = service.GetLyricsCachedAsync("Track", "Artist", "Album", 180);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
}
|
||||
260
allstarr.Tests/RedisCacheServiceTests.cs
Normal file
260
allstarr.Tests/RedisCacheServiceTests.cs
Normal file
@@ -0,0 +1,260 @@
|
||||
using Xunit;
|
||||
using Moq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Services.Common;
|
||||
using allstarr.Models.Settings;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
public class RedisCacheServiceTests
|
||||
{
|
||||
private readonly Mock<ILogger<RedisCacheService>> _mockLogger;
|
||||
private readonly IOptions<RedisSettings> _settings;
|
||||
|
||||
public RedisCacheServiceTests()
|
||||
{
|
||||
_mockLogger = new Mock<ILogger<RedisCacheService>>();
|
||||
_settings = Options.Create(new RedisSettings
|
||||
{
|
||||
Enabled = false, // Disabled for unit tests to avoid requiring actual Redis
|
||||
ConnectionString = "localhost:6379"
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InitializesWithSettings()
|
||||
{
|
||||
// Act
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
Assert.False(service.IsEnabled); // Should be disabled in tests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithEnabledSettings_AttemptsConnection()
|
||||
{
|
||||
// Arrange
|
||||
var enabledSettings = Options.Create(new RedisSettings
|
||||
{
|
||||
Enabled = true,
|
||||
ConnectionString = "localhost:6379"
|
||||
});
|
||||
|
||||
// Act - Constructor will try to connect but should handle failure gracefully
|
||||
var service = new RedisCacheService(enabledSettings, _mockLogger.Object);
|
||||
|
||||
// Assert - Service should be created even if connection fails
|
||||
Assert.NotNull(service);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStringAsync_WhenDisabled_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
|
||||
// Act
|
||||
var result = await service.GetStringAsync("test:key");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_WhenDisabled_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
|
||||
// Act
|
||||
var result = await service.GetAsync<TestObject>("test:key");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetStringAsync_WhenDisabled_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
|
||||
// Act
|
||||
var result = await service.SetStringAsync("test:key", "test value");
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetAsync_WhenDisabled_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
var testObj = new TestObject { Id = 1, Name = "Test" };
|
||||
|
||||
// Act
|
||||
var result = await service.SetAsync("test:key", testObj);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_WhenDisabled_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
|
||||
// Act
|
||||
var result = await service.DeleteAsync("test:key");
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExistsAsync_WhenDisabled_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
|
||||
// Act
|
||||
var result = await service.ExistsAsync("test:key");
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteByPatternAsync_WhenDisabled_ReturnsZero()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
|
||||
// Act
|
||||
var result = await service.DeleteByPatternAsync("test:*");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetStringAsync_WithExpiry_AcceptsTimeSpan()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
var expiry = TimeSpan.FromHours(1);
|
||||
|
||||
// Act
|
||||
var result = await service.SetStringAsync("test:key", "value", expiry);
|
||||
|
||||
// Assert - Should return false when disabled, but not throw
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetAsync_WithExpiry_AcceptsTimeSpan()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
var testObj = new TestObject { Id = 1, Name = "Test" };
|
||||
var expiry = TimeSpan.FromDays(30);
|
||||
|
||||
// Act
|
||||
var result = await service.SetAsync("test:key", testObj, expiry);
|
||||
|
||||
// Assert - Should return false when disabled, but not throw
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsEnabled_ReflectsSettings()
|
||||
{
|
||||
// Arrange
|
||||
var disabledService = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
|
||||
var enabledSettings = Options.Create(new RedisSettings
|
||||
{
|
||||
Enabled = true,
|
||||
ConnectionString = "localhost:6379"
|
||||
});
|
||||
var enabledService = new RedisCacheService(enabledSettings, _mockLogger.Object);
|
||||
|
||||
// Assert
|
||||
Assert.False(disabledService.IsEnabled);
|
||||
// enabledService.IsEnabled may be false if connection fails, which is expected
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_DeserializesComplexObjects()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
|
||||
// Act
|
||||
var result = await service.GetAsync<ComplexTestObject>("test:complex");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result); // Null when disabled
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetAsync_SerializesComplexObjects()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
var complexObj = new ComplexTestObject
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Test",
|
||||
Items = new System.Collections.Generic.List<string> { "Item1", "Item2" },
|
||||
Metadata = new System.Collections.Generic.Dictionary<string, string>
|
||||
{
|
||||
{ "Key1", "Value1" },
|
||||
{ "Key2", "Value2" }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await service.SetAsync("test:complex", complexObj, TimeSpan.FromHours(1));
|
||||
|
||||
// Assert
|
||||
Assert.False(result); // False when disabled
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConnectionString_IsConfigurable()
|
||||
{
|
||||
// Arrange
|
||||
var customSettings = Options.Create(new RedisSettings
|
||||
{
|
||||
Enabled = false,
|
||||
ConnectionString = "redis-server:6380,password=secret,ssl=true"
|
||||
});
|
||||
|
||||
// Act
|
||||
var service = new RedisCacheService(customSettings, _mockLogger.Object);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
}
|
||||
|
||||
private class TestObject
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private class ComplexTestObject
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public System.Collections.Generic.List<string> Items { get; set; } = new();
|
||||
public System.Collections.Generic.Dictionary<string, string> Metadata { get; set; } = new();
|
||||
}
|
||||
}
|
||||
82
allstarr.Tests/SpotifyApiClientTests.cs
Normal file
82
allstarr.Tests/SpotifyApiClientTests.cs
Normal file
@@ -0,0 +1,82 @@
|
||||
using Xunit;
|
||||
using Moq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Services.Spotify;
|
||||
using allstarr.Models.Settings;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
public class SpotifyApiClientTests
|
||||
{
|
||||
private readonly Mock<ILogger<SpotifyApiClient>> _mockLogger;
|
||||
private readonly IOptions<SpotifyApiSettings> _settings;
|
||||
|
||||
public SpotifyApiClientTests()
|
||||
{
|
||||
_mockLogger = new Mock<ILogger<SpotifyApiClient>>();
|
||||
_settings = Options.Create(new SpotifyApiSettings
|
||||
{
|
||||
Enabled = true,
|
||||
SessionCookie = "test_session_cookie_value",
|
||||
CacheDurationMinutes = 60,
|
||||
RateLimitDelayMs = 100,
|
||||
PreferIsrcMatching = true
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InitializesWithSettings()
|
||||
{
|
||||
// Act
|
||||
var client = new SpotifyApiClient(_mockLogger.Object, _settings);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(client);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Settings_AreConfiguredCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var client = new SpotifyApiClient(_mockLogger.Object, _settings);
|
||||
|
||||
// Assert - Constructor should not throw
|
||||
Assert.NotNull(client);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionCookie_IsRequired_ForWebApiAccess()
|
||||
{
|
||||
// Arrange
|
||||
var settingsWithoutCookie = Options.Create(new SpotifyApiSettings
|
||||
{
|
||||
Enabled = true,
|
||||
SessionCookie = "" // Empty cookie
|
||||
});
|
||||
|
||||
// Act
|
||||
var client = new SpotifyApiClient(_mockLogger.Object, settingsWithoutCookie);
|
||||
|
||||
// Assert - Constructor should not throw, but GetWebAccessTokenAsync will return null
|
||||
Assert.NotNull(client);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RateLimitSettings_AreRespected()
|
||||
{
|
||||
// Arrange
|
||||
var customSettings = Options.Create(new SpotifyApiSettings
|
||||
{
|
||||
Enabled = true,
|
||||
SessionCookie = "test_cookie",
|
||||
RateLimitDelayMs = 500
|
||||
});
|
||||
|
||||
// Act
|
||||
var client = new SpotifyApiClient(_mockLogger.Object, customSettings);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(client);
|
||||
}
|
||||
}
|
||||
342
allstarr.Tests/SquidWTFMetadataServiceTests.cs
Normal file
342
allstarr.Tests/SquidWTFMetadataServiceTests.cs
Normal file
@@ -0,0 +1,342 @@
|
||||
using Xunit;
|
||||
using Moq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Services.SquidWTF;
|
||||
using allstarr.Services.Common;
|
||||
using allstarr.Models.Settings;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
public class SquidWTFMetadataServiceTests
|
||||
{
|
||||
private readonly Mock<ILogger<SquidWTFMetadataService>> _mockLogger;
|
||||
private readonly Mock<IHttpClientFactory> _mockHttpClientFactory;
|
||||
private readonly IOptions<SubsonicSettings> _subsonicSettings;
|
||||
private readonly IOptions<SquidWTFSettings> _squidwtfSettings;
|
||||
private readonly Mock<RedisCacheService> _mockCache;
|
||||
private readonly List<string> _apiUrls;
|
||||
|
||||
public SquidWTFMetadataServiceTests()
|
||||
{
|
||||
_mockLogger = new Mock<ILogger<SquidWTFMetadataService>>();
|
||||
_mockHttpClientFactory = new Mock<IHttpClientFactory>();
|
||||
|
||||
_subsonicSettings = Options.Create(new SubsonicSettings
|
||||
{
|
||||
ExplicitFilter = ExplicitFilter.All
|
||||
});
|
||||
|
||||
_squidwtfSettings = Options.Create(new SquidWTFSettings
|
||||
{
|
||||
Quality = "FLAC"
|
||||
});
|
||||
|
||||
// Create mock Redis cache
|
||||
var mockRedisLogger = new Mock<ILogger<RedisCacheService>>();
|
||||
var mockRedisSettings = Options.Create(new RedisSettings { Enabled = false });
|
||||
_mockCache = new Mock<RedisCacheService>(mockRedisSettings, mockRedisLogger.Object);
|
||||
|
||||
_apiUrls = new List<string>
|
||||
{
|
||||
"https://squid.wtf",
|
||||
"https://mirror1.squid.wtf",
|
||||
"https://mirror2.squid.wtf"
|
||||
};
|
||||
|
||||
var httpClient = new System.Net.Http.HttpClient();
|
||||
_mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InitializesWithDependencies()
|
||||
{
|
||||
// Act
|
||||
var service = new SquidWTFMetadataService(
|
||||
_mockHttpClientFactory.Object,
|
||||
_subsonicSettings,
|
||||
_squidwtfSettings,
|
||||
_mockLogger.Object,
|
||||
_mockCache.Object,
|
||||
_apiUrls);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_AcceptsOptionalGenreEnrichment()
|
||||
{
|
||||
// Arrange - GenreEnrichmentService is optional, just pass null
|
||||
|
||||
// Act
|
||||
var service = new SquidWTFMetadataService(
|
||||
_mockHttpClientFactory.Object,
|
||||
_subsonicSettings,
|
||||
_squidwtfSettings,
|
||||
_mockLogger.Object,
|
||||
_mockCache.Object,
|
||||
_apiUrls,
|
||||
null); // GenreEnrichmentService is optional
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SearchSongsAsync_AcceptsQueryAndLimit()
|
||||
{
|
||||
// Arrange
|
||||
var service = new SquidWTFMetadataService(
|
||||
_mockHttpClientFactory.Object,
|
||||
_subsonicSettings,
|
||||
_squidwtfSettings,
|
||||
_mockLogger.Object,
|
||||
_mockCache.Object,
|
||||
_apiUrls);
|
||||
|
||||
// Act
|
||||
var result = service.SearchSongsAsync("Mr. Brightside", 20);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SearchAlbumsAsync_AcceptsQueryAndLimit()
|
||||
{
|
||||
// Arrange
|
||||
var service = new SquidWTFMetadataService(
|
||||
_mockHttpClientFactory.Object,
|
||||
_subsonicSettings,
|
||||
_squidwtfSettings,
|
||||
_mockLogger.Object,
|
||||
_mockCache.Object,
|
||||
_apiUrls);
|
||||
|
||||
// Act
|
||||
var result = service.SearchAlbumsAsync("Hot Fuss", 20);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SearchArtistsAsync_AcceptsQueryAndLimit()
|
||||
{
|
||||
// Arrange
|
||||
var service = new SquidWTFMetadataService(
|
||||
_mockHttpClientFactory.Object,
|
||||
_subsonicSettings,
|
||||
_squidwtfSettings,
|
||||
_mockLogger.Object,
|
||||
_mockCache.Object,
|
||||
_apiUrls);
|
||||
|
||||
// Act
|
||||
var result = service.SearchArtistsAsync("The Killers", 20);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SearchPlaylistsAsync_AcceptsQueryAndLimit()
|
||||
{
|
||||
// Arrange
|
||||
var service = new SquidWTFMetadataService(
|
||||
_mockHttpClientFactory.Object,
|
||||
_subsonicSettings,
|
||||
_squidwtfSettings,
|
||||
_mockLogger.Object,
|
||||
_mockCache.Object,
|
||||
_apiUrls);
|
||||
|
||||
// Act
|
||||
var result = service.SearchPlaylistsAsync("Rock Classics", 20);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSongAsync_RequiresProviderAndId()
|
||||
{
|
||||
// Arrange
|
||||
var service = new SquidWTFMetadataService(
|
||||
_mockHttpClientFactory.Object,
|
||||
_subsonicSettings,
|
||||
_squidwtfSettings,
|
||||
_mockLogger.Object,
|
||||
_mockCache.Object,
|
||||
_apiUrls);
|
||||
|
||||
// Act
|
||||
var result = service.GetSongAsync("squidwtf", "123456");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAlbumAsync_RequiresProviderAndId()
|
||||
{
|
||||
// Arrange
|
||||
var service = new SquidWTFMetadataService(
|
||||
_mockHttpClientFactory.Object,
|
||||
_subsonicSettings,
|
||||
_squidwtfSettings,
|
||||
_mockLogger.Object,
|
||||
_mockCache.Object,
|
||||
_apiUrls);
|
||||
|
||||
// Act
|
||||
var result = service.GetAlbumAsync("squidwtf", "789012");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetArtistAsync_RequiresProviderAndId()
|
||||
{
|
||||
// Arrange
|
||||
var service = new SquidWTFMetadataService(
|
||||
_mockHttpClientFactory.Object,
|
||||
_subsonicSettings,
|
||||
_squidwtfSettings,
|
||||
_mockLogger.Object,
|
||||
_mockCache.Object,
|
||||
_apiUrls);
|
||||
|
||||
// Act
|
||||
var result = service.GetArtistAsync("squidwtf", "345678");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetArtistAlbumsAsync_RequiresProviderAndId()
|
||||
{
|
||||
// Arrange
|
||||
var service = new SquidWTFMetadataService(
|
||||
_mockHttpClientFactory.Object,
|
||||
_subsonicSettings,
|
||||
_squidwtfSettings,
|
||||
_mockLogger.Object,
|
||||
_mockCache.Object,
|
||||
_apiUrls);
|
||||
|
||||
// Act
|
||||
var result = service.GetArtistAlbumsAsync("squidwtf", "345678");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPlaylistAsync_RequiresProviderAndId()
|
||||
{
|
||||
// Arrange
|
||||
var service = new SquidWTFMetadataService(
|
||||
_mockHttpClientFactory.Object,
|
||||
_subsonicSettings,
|
||||
_squidwtfSettings,
|
||||
_mockLogger.Object,
|
||||
_mockCache.Object,
|
||||
_apiUrls);
|
||||
|
||||
// Act
|
||||
var result = service.GetPlaylistAsync("squidwtf", "playlist123");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPlaylistTracksAsync_RequiresProviderAndId()
|
||||
{
|
||||
// Arrange
|
||||
var service = new SquidWTFMetadataService(
|
||||
_mockHttpClientFactory.Object,
|
||||
_subsonicSettings,
|
||||
_squidwtfSettings,
|
||||
_mockLogger.Object,
|
||||
_mockCache.Object,
|
||||
_apiUrls);
|
||||
|
||||
// Act
|
||||
var result = service.GetPlaylistTracksAsync("squidwtf", "playlist123");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SearchAllAsync_CombinesAllSearchTypes()
|
||||
{
|
||||
// Arrange
|
||||
var service = new SquidWTFMetadataService(
|
||||
_mockHttpClientFactory.Object,
|
||||
_subsonicSettings,
|
||||
_squidwtfSettings,
|
||||
_mockLogger.Object,
|
||||
_mockCache.Object,
|
||||
_apiUrls);
|
||||
|
||||
// Act
|
||||
var result = service.SearchAllAsync("The Killers", 20, 20, 20);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExplicitFilter_RespectsSettings()
|
||||
{
|
||||
// Arrange - Test with CleanOnly filter
|
||||
var cleanOnlySettings = Options.Create(new SubsonicSettings
|
||||
{
|
||||
ExplicitFilter = ExplicitFilter.CleanOnly
|
||||
});
|
||||
|
||||
// Act
|
||||
var service = new SquidWTFMetadataService(
|
||||
_mockHttpClientFactory.Object,
|
||||
cleanOnlySettings,
|
||||
_squidwtfSettings,
|
||||
_mockLogger.Object,
|
||||
_mockCache.Object,
|
||||
_apiUrls);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultipleApiUrls_EnablesRoundRobinFallback()
|
||||
{
|
||||
// Arrange
|
||||
var multipleUrls = new List<string>
|
||||
{
|
||||
"https://primary.squid.wtf",
|
||||
"https://backup1.squid.wtf",
|
||||
"https://backup2.squid.wtf",
|
||||
"https://backup3.squid.wtf"
|
||||
};
|
||||
|
||||
// Act
|
||||
var service = new SquidWTFMetadataService(
|
||||
_mockHttpClientFactory.Object,
|
||||
_subsonicSettings,
|
||||
_squidwtfSettings,
|
||||
_mockLogger.Object,
|
||||
_mockCache.Object,
|
||||
multipleUrls);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
}
|
||||
}
|
||||
@@ -166,8 +166,7 @@ public class AdminController : ControllerBase
|
||||
spotifyImport = new
|
||||
{
|
||||
enabled = _spotifyImportSettings.Enabled,
|
||||
syncTime = $"{_spotifyImportSettings.SyncStartHour:D2}:{_spotifyImportSettings.SyncStartMinute:D2}",
|
||||
syncWindowHours = _spotifyImportSettings.SyncWindowHours,
|
||||
matchingIntervalHours = _spotifyImportSettings.MatchingIntervalHours,
|
||||
playlistCount = _spotifyImportSettings.Playlists.Count
|
||||
},
|
||||
deezer = new
|
||||
@@ -260,6 +259,7 @@ public class AdminController : ControllerBase
|
||||
["id"] = config.Id,
|
||||
["jellyfinId"] = config.JellyfinId,
|
||||
["localTracksPosition"] = config.LocalTracksPosition.ToString(),
|
||||
["syncSchedule"] = config.SyncSchedule ?? "0 8 * * 1",
|
||||
["trackCount"] = 0,
|
||||
["localTracks"] = 0,
|
||||
["externalTracks"] = 0,
|
||||
@@ -1380,6 +1380,12 @@ public class AdminController : ControllerBase
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
backendType = _configuration.GetValue<string>("Backend:Type") ?? "Jellyfin",
|
||||
musicService = _configuration.GetValue<string>("MusicService") ?? "SquidWTF",
|
||||
explicitFilter = _configuration.GetValue<string>("ExplicitFilter") ?? "All",
|
||||
enableExternalPlaylists = _configuration.GetValue<bool>("EnableExternalPlaylists", false),
|
||||
playlistsDirectory = _configuration.GetValue<string>("PlaylistsDirectory") ?? "(not set)",
|
||||
redisEnabled = _configuration.GetValue<bool>("Redis:Enabled", false),
|
||||
spotifyApi = new
|
||||
{
|
||||
enabled = _spotifyApiSettings.Enabled,
|
||||
@@ -1392,9 +1398,7 @@ public class AdminController : ControllerBase
|
||||
spotifyImport = new
|
||||
{
|
||||
enabled = _spotifyImportSettings.Enabled,
|
||||
syncStartHour = _spotifyImportSettings.SyncStartHour,
|
||||
syncStartMinute = _spotifyImportSettings.SyncStartMinute,
|
||||
syncWindowHours = _spotifyImportSettings.SyncWindowHours,
|
||||
matchingIntervalHours = _spotifyImportSettings.MatchingIntervalHours,
|
||||
playlists = _spotifyImportSettings.Playlists.Select(p => new
|
||||
{
|
||||
name = p.Name,
|
||||
@@ -1412,9 +1416,9 @@ public class AdminController : ControllerBase
|
||||
library = new
|
||||
{
|
||||
downloadPath = _subsonicSettings.StorageMode == StorageMode.Cache
|
||||
? Path.Combine("cache", "Music")
|
||||
: (_configuration["Library:DownloadPath"] ?? "./downloads"),
|
||||
keptPath = _configuration["Library:KeptPath"] ?? "/app/kept",
|
||||
? Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "cache")
|
||||
: Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "permanent"),
|
||||
keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept"),
|
||||
storageMode = _subsonicSettings.StorageMode.ToString(),
|
||||
cacheDurationHours = _subsonicSettings.CacheDurationHours,
|
||||
downloadMode = _subsonicSettings.DownloadMode.ToString()
|
||||
@@ -1524,6 +1528,12 @@ public class AdminController : ControllerBase
|
||||
|
||||
_logger.LogInformation("Config file updated successfully at {Path}", _envFilePath);
|
||||
|
||||
// Invalidate playlist summary cache if playlists were updated
|
||||
if (appliedUpdates.Contains("SPOTIFY_IMPORT_PLAYLISTS"))
|
||||
{
|
||||
InvalidatePlaylistSummaryCache();
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
message = "Configuration updated. Restart container to apply changes.",
|
||||
@@ -1922,6 +1932,53 @@ public class AdminController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all playlists from the user's Spotify account
|
||||
/// </summary>
|
||||
[HttpGet("spotify/user-playlists")]
|
||||
public async Task<IActionResult> GetSpotifyUserPlaylists()
|
||||
{
|
||||
if (!_spotifyApiSettings.Enabled || string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie))
|
||||
{
|
||||
return BadRequest(new { error = "Spotify API not configured. Please set sp_dc session cookie." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Get list of already-configured Spotify playlist IDs
|
||||
var configuredPlaylists = await ReadPlaylistsFromEnvFile();
|
||||
var linkedSpotifyIds = new HashSet<string>(
|
||||
configuredPlaylists.Select(p => p.Id),
|
||||
StringComparer.OrdinalIgnoreCase
|
||||
);
|
||||
|
||||
// Use SpotifyApiClient's GraphQL method - much less rate-limited than REST API
|
||||
var spotifyPlaylists = await _spotifyClient.GetUserPlaylistsAsync(searchName: null);
|
||||
|
||||
if (spotifyPlaylists == null || spotifyPlaylists.Count == 0)
|
||||
{
|
||||
return Ok(new { playlists = new List<object>() });
|
||||
}
|
||||
|
||||
var playlists = spotifyPlaylists.Select(p => new
|
||||
{
|
||||
id = p.SpotifyId,
|
||||
name = p.Name,
|
||||
trackCount = p.TotalTracks,
|
||||
owner = p.OwnerName ?? "",
|
||||
isPublic = p.Public,
|
||||
isLinked = linkedSpotifyIds.Contains(p.SpotifyId)
|
||||
}).ToList();
|
||||
|
||||
return Ok(new { playlists });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching Spotify user playlists");
|
||||
return StatusCode(500, new { error = "Failed to fetch Spotify playlists", details = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all playlists from Jellyfin
|
||||
/// </summary>
|
||||
@@ -1993,11 +2050,16 @@ public class AdminController : ControllerBase
|
||||
trackStats = await GetPlaylistTrackStats(id!);
|
||||
}
|
||||
|
||||
// Use actual track stats for configured playlists, otherwise use Jellyfin's count
|
||||
var actualTrackCount = isConfigured
|
||||
? trackStats.LocalTracks + trackStats.ExternalTracks
|
||||
: childCount;
|
||||
|
||||
playlists.Add(new
|
||||
{
|
||||
id,
|
||||
name,
|
||||
trackCount = childCount,
|
||||
trackCount = actualTrackCount,
|
||||
linkedSpotifyId,
|
||||
isConfigured,
|
||||
localTracks = trackStats.LocalTracks,
|
||||
@@ -2166,12 +2228,19 @@ public class AdminController : ControllerBase
|
||||
Name = request.Name,
|
||||
Id = request.SpotifyPlaylistId,
|
||||
JellyfinId = jellyfinPlaylistId,
|
||||
LocalTracksPosition = LocalTracksPosition.First // Use Spotify order
|
||||
LocalTracksPosition = LocalTracksPosition.First, // Use Spotify order
|
||||
SyncSchedule = request.SyncSchedule ?? "0 8 * * 1" // Default to Monday 8 AM
|
||||
});
|
||||
|
||||
// Convert to JSON format for env var: [["Name","SpotifyId","JellyfinId","first|last"],...]
|
||||
// Convert to JSON format for env var: [["Name","SpotifyId","JellyfinId","first|last","cronSchedule"],...]
|
||||
var playlistsJson = JsonSerializer.Serialize(
|
||||
currentPlaylists.Select(p => new[] { p.Name, p.Id, p.JellyfinId, p.LocalTracksPosition.ToString().ToLower() }).ToArray()
|
||||
currentPlaylists.Select(p => new[] {
|
||||
p.Name,
|
||||
p.Id,
|
||||
p.JellyfinId,
|
||||
p.LocalTracksPosition.ToString().ToLower(),
|
||||
p.SyncSchedule ?? "0 8 * * 1"
|
||||
}).ToArray()
|
||||
);
|
||||
|
||||
// Update .env file
|
||||
@@ -2196,6 +2265,60 @@ public class AdminController : ControllerBase
|
||||
return await RemovePlaylist(decodedName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update playlist sync schedule
|
||||
/// </summary>
|
||||
[HttpPut("playlists/{name}/schedule")]
|
||||
public async Task<IActionResult> UpdatePlaylistSchedule(string name, [FromBody] UpdateScheduleRequest request)
|
||||
{
|
||||
var decodedName = Uri.UnescapeDataString(name);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.SyncSchedule))
|
||||
{
|
||||
return BadRequest(new { error = "SyncSchedule is required" });
|
||||
}
|
||||
|
||||
// Basic cron validation
|
||||
var cronParts = request.SyncSchedule.Trim().Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (cronParts.Length != 5)
|
||||
{
|
||||
return BadRequest(new { error = "Invalid cron format. Expected: minute hour day month dayofweek" });
|
||||
}
|
||||
|
||||
// Read current playlists
|
||||
var currentPlaylists = await ReadPlaylistsFromEnvFile();
|
||||
var playlist = currentPlaylists.FirstOrDefault(p => p.Name.Equals(decodedName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (playlist == null)
|
||||
{
|
||||
return NotFound(new { error = $"Playlist '{decodedName}' not found" });
|
||||
}
|
||||
|
||||
// Update the schedule
|
||||
playlist.SyncSchedule = request.SyncSchedule.Trim();
|
||||
|
||||
// Save back to .env
|
||||
var playlistsJson = JsonSerializer.Serialize(
|
||||
currentPlaylists.Select(p => new[] {
|
||||
p.Name,
|
||||
p.Id,
|
||||
p.JellyfinId,
|
||||
p.LocalTracksPosition.ToString().ToLower(),
|
||||
p.SyncSchedule ?? "0 8 * * 1"
|
||||
}).ToArray()
|
||||
);
|
||||
|
||||
var updateRequest = new ConfigUpdateRequest
|
||||
{
|
||||
Updates = new Dictionary<string, string>
|
||||
{
|
||||
["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson
|
||||
}
|
||||
};
|
||||
|
||||
return await UpdateConfig(updateRequest);
|
||||
}
|
||||
|
||||
private string GetJellyfinAuthHeader()
|
||||
{
|
||||
return $"MediaBrowser Client=\"Allstarr\", Device=\"Server\", DeviceId=\"allstarr-admin\", Version=\"1.0.0\", Token=\"{_jellyfinSettings.ApiKey}\"";
|
||||
@@ -2227,7 +2350,7 @@ public class AdminController : ControllerBase
|
||||
return playlists;
|
||||
}
|
||||
|
||||
// Parse JSON array format: [["Name","SpotifyId","JellyfinId","first|last"],...]
|
||||
// Parse JSON array format: [["Name","SpotifyId","JellyfinId","first|last","cronSchedule"],...]
|
||||
var playlistArrays = JsonSerializer.Deserialize<string[][]>(value);
|
||||
if (playlistArrays != null)
|
||||
{
|
||||
@@ -2243,7 +2366,8 @@ public class AdminController : ControllerBase
|
||||
LocalTracksPosition = arr.Length >= 4 &&
|
||||
arr[3].Trim().Equals("last", StringComparison.OrdinalIgnoreCase)
|
||||
? LocalTracksPosition.Last
|
||||
: LocalTracksPosition.First
|
||||
: LocalTracksPosition.First,
|
||||
SyncSchedule = arr.Length >= 5 ? arr[4].Trim() : "0 8 * * 1"
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3298,6 +3422,12 @@ public class LinkPlaylistRequest
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string SpotifyPlaylistId { get; set; } = string.Empty;
|
||||
public string SyncSchedule { get; set; } = "0 8 * * 1"; // Default: 8 AM every Monday
|
||||
}
|
||||
|
||||
public class UpdateScheduleRequest
|
||||
{
|
||||
public string SyncSchedule { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -3309,7 +3439,7 @@ public class LinkPlaylistRequest
|
||||
{
|
||||
try
|
||||
{
|
||||
var keptPath = _configuration["Library:KeptPath"] ?? "/app/kept";
|
||||
var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
|
||||
|
||||
_logger.LogInformation("📂 Checking kept folder: {Path}", keptPath);
|
||||
_logger.LogInformation("📂 Directory exists: {Exists}", Directory.Exists(keptPath));
|
||||
@@ -3392,7 +3522,7 @@ public class LinkPlaylistRequest
|
||||
return BadRequest(new { error = "Path is required" });
|
||||
}
|
||||
|
||||
var keptPath = _configuration["Library:KeptPath"] ?? "/app/kept";
|
||||
var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
|
||||
var fullPath = Path.Combine(keptPath, path);
|
||||
|
||||
_logger.LogInformation("🗑️ Delete request for: {Path}", fullPath);
|
||||
@@ -3456,7 +3586,7 @@ public class LinkPlaylistRequest
|
||||
return BadRequest(new { error = "Path is required" });
|
||||
}
|
||||
|
||||
var keptPath = _configuration["Library:KeptPath"] ?? "/app/kept";
|
||||
var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
|
||||
var fullPath = Path.Combine(keptPath, path);
|
||||
|
||||
// Security: Ensure the path is within the kept directory
|
||||
|
||||
@@ -39,7 +39,9 @@ public class JellyfinController : ControllerBase
|
||||
private readonly PlaylistSyncService? _playlistSyncService;
|
||||
private readonly SpotifyPlaylistFetcher? _spotifyPlaylistFetcher;
|
||||
private readonly SpotifyLyricsService? _spotifyLyricsService;
|
||||
private readonly LyricsPlusService? _lyricsPlusService;
|
||||
private readonly LrclibService? _lrclibService;
|
||||
private readonly LyricsOrchestrator? _lyricsOrchestrator;
|
||||
private readonly OdesliService _odesliService;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly IConfiguration _configuration;
|
||||
@@ -64,7 +66,9 @@ public class JellyfinController : ControllerBase
|
||||
PlaylistSyncService? playlistSyncService = null,
|
||||
SpotifyPlaylistFetcher? spotifyPlaylistFetcher = null,
|
||||
SpotifyLyricsService? spotifyLyricsService = null,
|
||||
LrclibService? lrclibService = null)
|
||||
LyricsPlusService? lyricsPlusService = null,
|
||||
LrclibService? lrclibService = null,
|
||||
LyricsOrchestrator? lyricsOrchestrator = null)
|
||||
{
|
||||
_settings = settings.Value;
|
||||
_spotifySettings = spotifySettings.Value;
|
||||
@@ -80,7 +84,9 @@ public class JellyfinController : ControllerBase
|
||||
_playlistSyncService = playlistSyncService;
|
||||
_spotifyPlaylistFetcher = spotifyPlaylistFetcher;
|
||||
_spotifyLyricsService = spotifyLyricsService;
|
||||
_lyricsPlusService = lyricsPlusService;
|
||||
_lrclibService = lrclibService;
|
||||
_lyricsOrchestrator = lyricsOrchestrator;
|
||||
_odesliService = odesliService;
|
||||
_cache = cache;
|
||||
_configuration = configuration;
|
||||
@@ -279,53 +285,50 @@ public class JellyfinController : ControllerBase
|
||||
// Parse Jellyfin results into domain models
|
||||
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult);
|
||||
|
||||
// Respect source ordering (SquidWTF/Tidal has better search ranking than our fuzzy matching)
|
||||
// Just interleave local and external results based on which source has better overall match
|
||||
|
||||
// Calculate average match score for each source to determine which should come first
|
||||
var localSongsAvgScore = localSongs.Any()
|
||||
? localSongs.Average(s => FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title))
|
||||
: 0.0;
|
||||
var externalSongsAvgScore = externalResult.Songs.Any()
|
||||
? externalResult.Songs.Average(s => FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title))
|
||||
: 0.0;
|
||||
// Sort all results by match score (local tracks get +10 boost)
|
||||
// This ensures best matches appear first regardless of source
|
||||
var allSongs = localSongs.Concat(externalResult.Songs)
|
||||
.Select(s => new { Song = s, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title) + (s.IsLocal ? 10.0 : 0.0) })
|
||||
.OrderByDescending(x => x.Score)
|
||||
.Select(x => x.Song)
|
||||
.ToList();
|
||||
|
||||
var localAlbumsAvgScore = localAlbums.Any()
|
||||
? localAlbums.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title))
|
||||
: 0.0;
|
||||
var externalAlbumsAvgScore = externalResult.Albums.Any()
|
||||
? externalResult.Albums.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title))
|
||||
: 0.0;
|
||||
var allAlbums = localAlbums.Concat(externalResult.Albums)
|
||||
.Select(a => new { Album = a, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title) + (a.IsLocal ? 10.0 : 0.0) })
|
||||
.OrderByDescending(x => x.Score)
|
||||
.Select(x => x.Album)
|
||||
.ToList();
|
||||
|
||||
var localArtistsAvgScore = localArtists.Any()
|
||||
? localArtists.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name))
|
||||
: 0.0;
|
||||
var externalArtistsAvgScore = externalResult.Artists.Any()
|
||||
? externalResult.Artists.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name))
|
||||
: 0.0;
|
||||
var allArtists = localArtists.Concat(externalResult.Artists)
|
||||
.Select(a => new { Artist = a, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name) + (a.IsLocal ? 10.0 : 0.0) })
|
||||
.OrderByDescending(x => x.Score)
|
||||
.Select(x => x.Artist)
|
||||
.ToList();
|
||||
|
||||
// Interleave results: put better-matching source first, preserve original ordering within each source
|
||||
var allSongs = localSongsAvgScore >= externalSongsAvgScore
|
||||
? localSongs.Concat(externalResult.Songs).ToList()
|
||||
: externalResult.Songs.Concat(localSongs).ToList();
|
||||
|
||||
var allAlbums = localAlbumsAvgScore >= externalAlbumsAvgScore
|
||||
? localAlbums.Concat(externalResult.Albums).ToList()
|
||||
: externalResult.Albums.Concat(localAlbums).ToList();
|
||||
|
||||
var allArtists = localArtistsAvgScore >= externalArtistsAvgScore
|
||||
? localArtists.Concat(externalResult.Artists).ToList()
|
||||
: externalResult.Artists.Concat(localArtists).ToList();
|
||||
|
||||
// Log results for debugging
|
||||
// Log top results for debugging
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
_logger.LogDebug("🎵 Songs: Local avg score={LocalScore:F2}, External avg score={ExtScore:F2}, Local first={LocalFirst}",
|
||||
localSongsAvgScore, externalSongsAvgScore, localSongsAvgScore >= externalSongsAvgScore);
|
||||
_logger.LogDebug("💿 Albums: Local avg score={LocalScore:F2}, External avg score={ExtScore:F2}, Local first={LocalFirst}",
|
||||
localAlbumsAvgScore, externalAlbumsAvgScore, localAlbumsAvgScore >= externalAlbumsAvgScore);
|
||||
_logger.LogDebug("🎤 Artists: Local avg score={LocalScore:F2}, External avg score={ExtScore:F2}, Local first={LocalFirst}",
|
||||
localArtistsAvgScore, externalArtistsAvgScore, localArtistsAvgScore >= externalArtistsAvgScore);
|
||||
if (allSongs.Any())
|
||||
{
|
||||
var topSong = allSongs.First();
|
||||
var topScore = FuzzyMatcher.CalculateSimilarity(cleanQuery, topSong.Title) + (topSong.IsLocal ? 10.0 : 0.0);
|
||||
_logger.LogDebug("🎵 Top song: '{Title}' (local={IsLocal}, score={Score:F2})",
|
||||
topSong.Title, topSong.IsLocal, topScore);
|
||||
}
|
||||
if (allAlbums.Any())
|
||||
{
|
||||
var topAlbum = allAlbums.First();
|
||||
var topScore = FuzzyMatcher.CalculateSimilarity(cleanQuery, topAlbum.Title) + (topAlbum.IsLocal ? 10.0 : 0.0);
|
||||
_logger.LogDebug("💿 Top album: '{Title}' (local={IsLocal}, score={Score:F2})",
|
||||
topAlbum.Title, topAlbum.IsLocal, topScore);
|
||||
}
|
||||
if (allArtists.Any())
|
||||
{
|
||||
var topArtist = allArtists.First();
|
||||
var topScore = FuzzyMatcher.CalculateSimilarity(cleanQuery, topArtist.Name) + (topArtist.IsLocal ? 10.0 : 0.0);
|
||||
_logger.LogDebug("🎤 Top artist: '{Name}' (local={IsLocal}, score={Score:F2})",
|
||||
topArtist.Name, topArtist.IsLocal, topScore);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to Jellyfin format
|
||||
@@ -343,7 +346,7 @@ public class JellyfinController : ControllerBase
|
||||
mergedAlbums.AddRange(playlistItems);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Merged results (preserving source order): Songs={Songs}, Albums={Albums}, Artists={Artists}",
|
||||
_logger.LogInformation("Merged and sorted results by score: Songs={Songs}, Albums={Albums}, Artists={Artists}",
|
||||
mergedSongs.Count, mergedAlbums.Count, mergedArtists.Count);
|
||||
|
||||
// Pre-fetch lyrics for top 3 songs in background (don't await)
|
||||
@@ -1274,50 +1277,53 @@ public class JellyfinController : ControllerBase
|
||||
searchArtists.Add(searchArtist);
|
||||
}
|
||||
|
||||
// Use orchestrator for clean, modular lyrics fetching
|
||||
LyricsInfo? lyrics = null;
|
||||
|
||||
// Try Spotify lyrics ONLY if we have a valid Spotify track ID
|
||||
// Spotify lyrics only work for tracks from injected playlists that have been matched
|
||||
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled && !string.IsNullOrEmpty(spotifyTrackId))
|
||||
if (_lyricsOrchestrator != null)
|
||||
{
|
||||
// Validate that this is a real Spotify ID (not spotify:local or other invalid formats)
|
||||
var cleanSpotifyId = spotifyTrackId.Replace("spotify:track:", "").Trim();
|
||||
|
||||
// Spotify track IDs are 22 characters, base62 encoded
|
||||
if (cleanSpotifyId.Length == 22 && !cleanSpotifyId.Contains(":") && !cleanSpotifyId.Contains("local"))
|
||||
{
|
||||
_logger.LogInformation("Trying Spotify lyrics for track ID: {SpotifyId} ({Artist} - {Title})",
|
||||
cleanSpotifyId, searchArtist, searchTitle);
|
||||
|
||||
var spotifyLyrics = await _spotifyLyricsService.GetLyricsByTrackIdAsync(cleanSpotifyId);
|
||||
|
||||
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);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("No Spotify lyrics found for track ID {SpotifyId}", cleanSpotifyId);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Invalid Spotify ID format: {SpotifyId}, skipping Spotify lyrics", spotifyTrackId);
|
||||
}
|
||||
lyrics = await _lyricsOrchestrator.GetLyricsAsync(
|
||||
trackName: searchTitle,
|
||||
artistNames: searchArtists.ToArray(),
|
||||
albumName: searchAlbum,
|
||||
durationSeconds: song.Duration ?? 0,
|
||||
spotifyTrackId: spotifyTrackId);
|
||||
}
|
||||
|
||||
// Fall back to LRCLIB if no Spotify lyrics
|
||||
if (lyrics == null)
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Searching LRCLIB for lyrics: {Artists} - {Title}",
|
||||
string.Join(", ", searchArtists),
|
||||
searchTitle);
|
||||
var lrclibService = HttpContext.RequestServices.GetService<LrclibService>();
|
||||
if (lrclibService != null)
|
||||
// Fallback to manual fetching if orchestrator not available
|
||||
_logger.LogWarning("LyricsOrchestrator not available, using fallback method");
|
||||
|
||||
// Try Spotify lyrics ONLY if we have a valid Spotify track ID
|
||||
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled && !string.IsNullOrEmpty(spotifyTrackId))
|
||||
{
|
||||
lyrics = await lrclibService.GetLyricsAsync(
|
||||
var cleanSpotifyId = spotifyTrackId.Replace("spotify:track:", "").Trim();
|
||||
|
||||
if (cleanSpotifyId.Length == 22 && !cleanSpotifyId.Contains(":") && !cleanSpotifyId.Contains("local"))
|
||||
{
|
||||
var spotifyLyrics = await _spotifyLyricsService.GetLyricsByTrackIdAsync(cleanSpotifyId);
|
||||
|
||||
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
|
||||
{
|
||||
lyrics = _spotifyLyricsService.ToLyricsInfo(spotifyLyrics);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to LyricsPlus
|
||||
if (lyrics == null && _lyricsPlusService != null)
|
||||
{
|
||||
lyrics = await _lyricsPlusService.GetLyricsAsync(
|
||||
searchTitle,
|
||||
searchArtists.ToArray(),
|
||||
searchAlbum,
|
||||
song.Duration ?? 0);
|
||||
}
|
||||
|
||||
// Fall back to LRCLIB
|
||||
if (lyrics == null && _lrclibService != null)
|
||||
{
|
||||
lyrics = await _lrclibService.GetLyricsAsync(
|
||||
searchTitle,
|
||||
searchArtists.ToArray(),
|
||||
searchAlbum,
|
||||
@@ -1498,6 +1504,21 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
_logger.LogDebug("🎵 Prefetching lyrics for: {Artist} - {Title}", searchArtist, searchTitle);
|
||||
|
||||
// Use orchestrator for prefetching
|
||||
if (_lyricsOrchestrator != null)
|
||||
{
|
||||
await _lyricsOrchestrator.PrefetchLyricsAsync(
|
||||
trackName: searchTitle,
|
||||
artistNames: searchArtists.ToArray(),
|
||||
albumName: searchAlbum,
|
||||
durationSeconds: song.Duration ?? 0,
|
||||
spotifyTrackId: spotifyTrackId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to manual prefetching if orchestrator not available
|
||||
_logger.LogWarning("LyricsOrchestrator not available for prefetch, using fallback method");
|
||||
|
||||
// Try Spotify lyrics if we have a valid Spotify track ID
|
||||
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled && !string.IsNullOrEmpty(spotifyTrackId))
|
||||
{
|
||||
@@ -1516,6 +1537,22 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to LyricsPlus
|
||||
if (_lyricsPlusService != null)
|
||||
{
|
||||
var lyrics = await _lyricsPlusService.GetLyricsAsync(
|
||||
searchTitle,
|
||||
searchArtists.ToArray(),
|
||||
searchAlbum,
|
||||
song.Duration ?? 0);
|
||||
|
||||
if (lyrics != null)
|
||||
{
|
||||
_logger.LogDebug("✓ Prefetched LyricsPlus lyrics for {Artist} - {Title}", searchArtist, searchTitle);
|
||||
return; // Success, lyrics are now cached
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to LRCLIB
|
||||
if (_lrclibService != null)
|
||||
{
|
||||
@@ -2183,7 +2220,7 @@ public class JellyfinController : ControllerBase
|
||||
var method = Request.Method;
|
||||
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : "";
|
||||
|
||||
_logger.LogInformation("📡 Session capabilities reported - Method: {Method}, Query: {Query}", method, queryString);
|
||||
_logger.LogDebug("📡 Session capabilities reported - Method: {Method}, Query: {Query}", method, queryString);
|
||||
_logger.LogInformation("Headers: {Headers}",
|
||||
string.Join(", ", Request.Headers.Where(h => h.Key.Contains("Auth", StringComparison.OrdinalIgnoreCase) || h.Key.Contains("Device", StringComparison.OrdinalIgnoreCase) || h.Key.Contains("Client", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(h => $"{h.Key}={h.Value}")));
|
||||
@@ -2208,7 +2245,11 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
if (statusCode == 204 || statusCode == 200)
|
||||
{
|
||||
_logger.LogInformation("✓ Session capabilities forwarded to Jellyfin ({StatusCode})", statusCode);
|
||||
_logger.LogDebug("✓ Session capabilities forwarded to Jellyfin ({StatusCode})", statusCode);
|
||||
}
|
||||
else if (statusCode == 401)
|
||||
{
|
||||
_logger.LogDebug("⚠ Jellyfin returned 401 for capabilities (token expired)");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -2242,7 +2283,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
Request.Body.Position = 0;
|
||||
|
||||
_logger.LogInformation("📻 Playback START reported");
|
||||
_logger.LogDebug("📻 Playback START reported");
|
||||
|
||||
// Parse the body to check if it's an external track
|
||||
var doc = JsonDocument.Parse(body);
|
||||
@@ -2318,7 +2359,7 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
if (ghostStatusCode == 204 || ghostStatusCode == 200)
|
||||
{
|
||||
_logger.LogInformation("✓ Ghost playback start forwarded to Jellyfin for external track ({StatusCode})", ghostStatusCode);
|
||||
_logger.LogDebug("✓ Ghost playback start forwarded to Jellyfin for external track ({StatusCode})", ghostStatusCode);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -2346,7 +2387,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
|
||||
// For local tracks, forward playback start to Jellyfin FIRST
|
||||
_logger.LogInformation("Forwarding playback start to Jellyfin...");
|
||||
_logger.LogDebug("Forwarding playback start to Jellyfin...");
|
||||
|
||||
// Fetch full item details to include in playback report
|
||||
try
|
||||
@@ -2372,7 +2413,7 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
if (statusCode == 204 || statusCode == 200)
|
||||
{
|
||||
_logger.LogInformation("✓ Playback start forwarded to Jellyfin ({StatusCode})", statusCode);
|
||||
_logger.LogDebug("✓ Playback start forwarded to Jellyfin ({StatusCode})", statusCode);
|
||||
|
||||
// NOW ensure session exists with capabilities (after playback is reported)
|
||||
if (!string.IsNullOrEmpty(deviceId))
|
||||
@@ -2380,7 +2421,7 @@ public class JellyfinController : ControllerBase
|
||||
var sessionCreated = await _sessionManager.EnsureSessionAsync(deviceId, client ?? "Unknown", device ?? "Unknown", version ?? "1.0", Request.Headers);
|
||||
if (sessionCreated)
|
||||
{
|
||||
_logger.LogWarning("✓ SESSION: Session ensured for device {DeviceId} after playback start", deviceId);
|
||||
_logger.LogDebug("✓ SESSION: Session ensured for device {DeviceId} after playback start", deviceId);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -2404,7 +2445,7 @@ public class JellyfinController : ControllerBase
|
||||
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", body, Request.Headers);
|
||||
if (statusCode == 204 || statusCode == 200)
|
||||
{
|
||||
_logger.LogInformation("✓ Basic playback start forwarded to Jellyfin ({StatusCode})", statusCode);
|
||||
_logger.LogDebug("✓ Basic playback start forwarded to Jellyfin ({StatusCode})", statusCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2517,7 +2558,7 @@ public class JellyfinController : ControllerBase
|
||||
// Only log at 10-second intervals
|
||||
if (position.Seconds % 10 == 0 && position.Milliseconds < 500)
|
||||
{
|
||||
_logger.LogInformation("▶️ Progress: {Position:mm\\:ss} for item {ItemId}", position, itemId);
|
||||
_logger.LogDebug("▶️ Progress: {Position:mm\\:ss} for item {ItemId}", position, itemId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2557,7 +2598,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
Request.Body.Position = 0;
|
||||
|
||||
_logger.LogInformation("⏹️ Playback STOPPED reported");
|
||||
_logger.LogDebug("⏹️ Playback STOPPED reported");
|
||||
|
||||
// Parse the body to check if it's an external track
|
||||
var doc = JsonDocument.Parse(body);
|
||||
@@ -2615,7 +2656,7 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
if (stopStatusCode == 204 || stopStatusCode == 200)
|
||||
{
|
||||
_logger.LogInformation("✓ Ghost playback stop forwarded to Jellyfin ({StatusCode})", stopStatusCode);
|
||||
_logger.LogDebug("✓ Ghost playback stop forwarded to Jellyfin ({StatusCode})", stopStatusCode);
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
@@ -2626,7 +2667,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
|
||||
// For local tracks, forward to Jellyfin
|
||||
_logger.LogInformation("Forwarding playback stop to Jellyfin...");
|
||||
_logger.LogDebug("Forwarding playback stop to Jellyfin...");
|
||||
|
||||
// Log the body being sent for debugging
|
||||
_logger.LogInformation("📤 Sending playback stop body: {Body}", body);
|
||||
@@ -2649,7 +2690,11 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
if (statusCode == 204 || statusCode == 200)
|
||||
{
|
||||
_logger.LogInformation("✓ Playback stop forwarded to Jellyfin ({StatusCode})", statusCode);
|
||||
_logger.LogDebug("✓ Playback stop forwarded to Jellyfin ({StatusCode})", statusCode);
|
||||
}
|
||||
else if (statusCode == 401)
|
||||
{
|
||||
_logger.LogDebug("Playback stop returned 401 (token expired)");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -2708,7 +2753,7 @@ public class JellyfinController : ControllerBase
|
||||
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : "";
|
||||
var endpoint = string.IsNullOrEmpty(path) ? $"Sessions{queryString}" : $"Sessions/{path}{queryString}";
|
||||
|
||||
_logger.LogInformation("🔄 Proxying session request: {Method} {Endpoint}", method, endpoint);
|
||||
_logger.LogDebug("🔄 Proxying session request: {Method} {Endpoint}", method, endpoint);
|
||||
_logger.LogDebug("Session proxy headers: {Headers}",
|
||||
string.Join(", ", Request.Headers.Where(h => h.Key.Contains("Auth", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(h => $"{h.Key}={h.Value}")));
|
||||
@@ -2738,11 +2783,11 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
if (result != null)
|
||||
{
|
||||
_logger.LogInformation("✓ Session request proxied successfully ({StatusCode})", statusCode);
|
||||
_logger.LogDebug("✓ Session request proxied successfully ({StatusCode})", statusCode);
|
||||
return new JsonResult(result.RootElement.Clone());
|
||||
}
|
||||
|
||||
_logger.LogInformation("✓ Session request proxied ({StatusCode}, no body)", statusCode);
|
||||
_logger.LogDebug("✓ Session request proxied ({StatusCode}, no body)", statusCode);
|
||||
return StatusCode(statusCode);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -2800,7 +2845,7 @@ public class JellyfinController : ControllerBase
|
||||
if (path.Contains("session", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.Contains("capabilit", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogWarning("🔍 SESSION/CAPABILITY REQUEST: {Method} /{Path}{Query}", Request.Method, path, Request.QueryString);
|
||||
_logger.LogDebug("🔍 SESSION/CAPABILITY REQUEST: {Method} /{Path}{Query}", Request.Method, path, Request.QueryString);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -3521,8 +3566,17 @@ public class JellyfinController : ControllerBase
|
||||
return null; // Fall back to legacy mode
|
||||
}
|
||||
|
||||
// Request MediaSources field to get bitrate info
|
||||
var playlistItemsUrl = $"Playlists/{playlistId}/Items?UserId={userId}&Fields=MediaSources";
|
||||
// Pass through all requested fields from the original request
|
||||
var queryString = Request.QueryString.Value ?? "";
|
||||
var playlistItemsUrl = $"Playlists/{playlistId}/Items?UserId={userId}";
|
||||
|
||||
// Append the original query string (which includes Fields parameter)
|
||||
if (!string.IsNullOrEmpty(queryString))
|
||||
{
|
||||
// Remove the leading ? if present
|
||||
queryString = queryString.TrimStart('?');
|
||||
playlistItemsUrl = $"{playlistItemsUrl}&{queryString}";
|
||||
}
|
||||
|
||||
_logger.LogInformation("🔍 Fetching existing tracks from Jellyfin playlist {PlaylistId} with UserId {UserId}",
|
||||
playlistId, userId);
|
||||
@@ -3908,7 +3962,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
|
||||
// Build kept folder path: Artist/Album/
|
||||
var keptBasePath = _configuration["Library:KeptPath"] ?? "/app/kept";
|
||||
var keptBasePath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
|
||||
var keptArtistPath = Path.Combine(keptBasePath, PathHelper.SanitizeFileName(song.Artist));
|
||||
var keptAlbumPath = Path.Combine(keptArtistPath, PathHelper.SanitizeFileName(song.Album));
|
||||
|
||||
@@ -4204,7 +4258,7 @@ public class JellyfinController : ControllerBase
|
||||
var song = await _metadataService.GetSongAsync(provider!, externalId!);
|
||||
if (song == null) return;
|
||||
|
||||
var keptBasePath = _configuration["Library:KeptPath"] ?? "/app/kept";
|
||||
var keptBasePath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
|
||||
var keptArtistPath = Path.Combine(keptBasePath, PathHelper.SanitizeFileName(song.Artist));
|
||||
var keptAlbumPath = Path.Combine(keptArtistPath, PathHelper.SanitizeFileName(song.Album));
|
||||
|
||||
|
||||
@@ -2,239 +2,44 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Models.Settings;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace allstarr.Filters;
|
||||
|
||||
/// <summary>
|
||||
/// Authentication filter for Jellyfin API endpoints.
|
||||
/// Validates client credentials against configured username and API key.
|
||||
/// Clients can authenticate via:
|
||||
/// - Authorization header: MediaBrowser Token="apikey"
|
||||
/// - X-Emby-Token header
|
||||
/// - Query parameter: api_key
|
||||
/// - JSON body (for login endpoints): Username/Pw fields
|
||||
/// REMOVED: Authentication filter for Jellyfin API endpoints.
|
||||
///
|
||||
/// This filter has been removed because Allstarr acts as a TRANSPARENT PROXY.
|
||||
/// Clients authenticate directly with Jellyfin through the proxy, not with the proxy itself.
|
||||
///
|
||||
/// Authentication flow:
|
||||
/// 1. Client sends credentials to /Users/AuthenticateByName
|
||||
/// 2. Proxy forwards request to Jellyfin (no validation)
|
||||
/// 3. Jellyfin validates credentials and returns AccessToken
|
||||
/// 4. Client uses AccessToken in subsequent requests
|
||||
/// 5. Proxy forwards token to Jellyfin for validation
|
||||
///
|
||||
/// The proxy NEVER validates credentials or tokens - that's Jellyfin's job.
|
||||
/// The proxy only forwards authentication headers transparently.
|
||||
///
|
||||
/// If you need to restrict access to the proxy itself, use network-level controls
|
||||
/// (firewall, VPN, reverse proxy with auth) instead of application-level auth.
|
||||
/// </summary>
|
||||
public partial class JellyfinAuthFilter : IAsyncActionFilter
|
||||
public class JellyfinAuthFilter : IAsyncActionFilter
|
||||
{
|
||||
private readonly JellyfinSettings _settings;
|
||||
private readonly ILogger<JellyfinAuthFilter> _logger;
|
||||
|
||||
public JellyfinAuthFilter(
|
||||
IOptions<JellyfinSettings> settings,
|
||||
ILogger<JellyfinAuthFilter> logger)
|
||||
public JellyfinAuthFilter(ILogger<JellyfinAuthFilter> logger)
|
||||
{
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||
{
|
||||
// Skip auth if no credentials configured (open mode)
|
||||
if (string.IsNullOrEmpty(_settings.ClientUsername) || string.IsNullOrEmpty(_settings.ApiKey))
|
||||
{
|
||||
_logger.LogDebug("Auth skipped - no client credentials configured");
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
var request = context.HttpContext.Request;
|
||||
// This filter is now a no-op - all authentication is handled by Jellyfin
|
||||
// Keeping the class for backwards compatibility but it does nothing
|
||||
|
||||
_logger.LogTrace("JellyfinAuthFilter: Transparent proxy mode - no authentication check");
|
||||
|
||||
// Try to extract credentials from various sources
|
||||
var (username, token) = await ExtractCredentialsAsync(request);
|
||||
|
||||
// Validate credentials
|
||||
if (!ValidateCredentials(username, token))
|
||||
{
|
||||
_logger.LogWarning("Authentication failed for user '{Username}' from {IP}",
|
||||
username ?? "unknown",
|
||||
context.HttpContext.Connection.RemoteIpAddress);
|
||||
|
||||
context.Result = new UnauthorizedObjectResult(new
|
||||
{
|
||||
error = "Invalid credentials",
|
||||
message = "Authentication required. Provide valid username and API key."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Authentication successful for user '{Username}'", username);
|
||||
await next();
|
||||
}
|
||||
|
||||
private async Task<(string? username, string? token)> ExtractCredentialsAsync(HttpRequest request)
|
||||
{
|
||||
string? username = null;
|
||||
string? token = null;
|
||||
|
||||
// 1. Check Authorization header (MediaBrowser format)
|
||||
if (request.Headers.TryGetValue("Authorization", out var authHeader))
|
||||
{
|
||||
var authValue = authHeader.ToString();
|
||||
|
||||
// Parse MediaBrowser auth header: MediaBrowser Client="...", Token="..."
|
||||
if (authValue.StartsWith("MediaBrowser", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
token = ExtractTokenFromMediaBrowser(authValue);
|
||||
username = ExtractUserIdFromMediaBrowser(authValue);
|
||||
}
|
||||
// Basic auth: Basic base64(username:password)
|
||||
else if (authValue.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
(username, token) = ParseBasicAuth(authValue);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check X-Emby-Token header
|
||||
if (string.IsNullOrEmpty(token) && request.Headers.TryGetValue("X-Emby-Token", out var embyToken))
|
||||
{
|
||||
token = embyToken.ToString();
|
||||
}
|
||||
|
||||
// 3. Check X-MediaBrowser-Token header
|
||||
if (string.IsNullOrEmpty(token) && request.Headers.TryGetValue("X-MediaBrowser-Token", out var mbToken))
|
||||
{
|
||||
token = mbToken.ToString();
|
||||
}
|
||||
|
||||
// 4. Check X-Emby-Authorization header (alternative format)
|
||||
if (string.IsNullOrEmpty(token) && request.Headers.TryGetValue("X-Emby-Authorization", out var embyAuth))
|
||||
{
|
||||
token = ExtractTokenFromMediaBrowser(embyAuth.ToString());
|
||||
if (string.IsNullOrEmpty(username))
|
||||
{
|
||||
username = ExtractUserIdFromMediaBrowser(embyAuth.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Check query parameters
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
token = request.Query["api_key"].FirstOrDefault()
|
||||
?? request.Query["ApiKey"].FirstOrDefault()
|
||||
?? request.Query["X-Emby-Token"].FirstOrDefault();
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(username))
|
||||
{
|
||||
username = request.Query["userId"].FirstOrDefault()
|
||||
?? request.Query["UserId"].FirstOrDefault()
|
||||
?? request.Query["u"].FirstOrDefault();
|
||||
}
|
||||
|
||||
// 6. Check JSON body for login endpoints (Jellyfin: Username/Pw, Navidrome: username/password)
|
||||
if ((string.IsNullOrEmpty(username) || string.IsNullOrEmpty(token)) &&
|
||||
request.ContentType?.Contains("application/json", StringComparison.OrdinalIgnoreCase) == true &&
|
||||
request.ContentLength > 0)
|
||||
{
|
||||
var (bodyUsername, bodyPassword) = await ExtractCredentialsFromBodyAsync(request);
|
||||
if (string.IsNullOrEmpty(username)) username = bodyUsername;
|
||||
if (string.IsNullOrEmpty(token)) token = bodyPassword;
|
||||
}
|
||||
|
||||
return (username, token);
|
||||
}
|
||||
|
||||
private async Task<(string? username, string? password)> ExtractCredentialsFromBodyAsync(HttpRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
request.EnableBuffering();
|
||||
request.Body.Position = 0;
|
||||
|
||||
using var reader = new StreamReader(request.Body, leaveOpen: true);
|
||||
var body = await reader.ReadToEndAsync();
|
||||
request.Body.Position = 0;
|
||||
|
||||
if (string.IsNullOrEmpty(body)) return (null, null);
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// Try Jellyfin format: Username, Pw
|
||||
string? username = null;
|
||||
string? password = null;
|
||||
|
||||
if (root.TryGetProperty("Username", out var usernameProp))
|
||||
username = usernameProp.GetString();
|
||||
else if (root.TryGetProperty("username", out var usernameLowerProp))
|
||||
username = usernameLowerProp.GetString();
|
||||
|
||||
if (root.TryGetProperty("Pw", out var pwProp))
|
||||
password = pwProp.GetString();
|
||||
else if (root.TryGetProperty("pw", out var pwLowerProp))
|
||||
password = pwLowerProp.GetString();
|
||||
else if (root.TryGetProperty("Password", out var passwordProp))
|
||||
password = passwordProp.GetString();
|
||||
else if (root.TryGetProperty("password", out var passwordLowerProp))
|
||||
password = passwordLowerProp.GetString();
|
||||
|
||||
return (username, password);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to parse credentials from request body");
|
||||
return (null, null);
|
||||
}
|
||||
}
|
||||
|
||||
private string? ExtractTokenFromMediaBrowser(string header)
|
||||
{
|
||||
var match = TokenRegex().Match(header);
|
||||
return match.Success ? match.Groups[1].Value : null;
|
||||
}
|
||||
|
||||
private string? ExtractUserIdFromMediaBrowser(string header)
|
||||
{
|
||||
var match = UserIdRegex().Match(header);
|
||||
return match.Success ? match.Groups[1].Value : null;
|
||||
}
|
||||
|
||||
private static (string? username, string? password) ParseBasicAuth(string authHeader)
|
||||
{
|
||||
try
|
||||
{
|
||||
var base64 = authHeader["Basic ".Length..].Trim();
|
||||
var bytes = Convert.FromBase64String(base64);
|
||||
var credentials = System.Text.Encoding.UTF8.GetString(bytes);
|
||||
var parts = credentials.Split(':', 2);
|
||||
|
||||
return parts.Length == 2 ? (parts[0], parts[1]) : (null, null);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
}
|
||||
|
||||
private bool ValidateCredentials(string? username, string? token)
|
||||
{
|
||||
// Must have token (API key used as password)
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Token must match API key
|
||||
if (!string.Equals(token, _settings.ApiKey, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// If username provided, it must match configured client username
|
||||
if (!string.IsNullOrEmpty(username) &&
|
||||
!string.Equals(username, _settings.ClientUsername, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"Token=""([^""]+)""", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex TokenRegex();
|
||||
|
||||
[GeneratedRegex(@"UserId=""([^""]+)""", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex UserIdRegex();
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ public class WebSocketProxyMiddleware
|
||||
if (context.Request.Path.StartsWithSegments("/socket", StringComparison.OrdinalIgnoreCase) &&
|
||||
context.WebSockets.IsWebSocketRequest)
|
||||
{
|
||||
_logger.LogInformation("🔌 WEBSOCKET: WebSocket connection request received from {RemoteIp}",
|
||||
_logger.LogDebug("🔌 WEBSOCKET: WebSocket connection request received from {RemoteIp}",
|
||||
context.Connection.RemoteIpAddress);
|
||||
|
||||
await HandleWebSocketProxyAsync(context);
|
||||
@@ -139,10 +139,10 @@ public class WebSocketProxyMiddleware
|
||||
}
|
||||
|
||||
// Set user agent
|
||||
serverWebSocket.Options.SetRequestHeader("User-Agent", "Allstarr/1.0");
|
||||
serverWebSocket.Options.SetRequestHeader("User-Agent", "Allstarr/1.3.0");
|
||||
|
||||
await serverWebSocket.ConnectAsync(new Uri(jellyfinWsUrl), context.RequestAborted);
|
||||
_logger.LogInformation("✓ WEBSOCKET: Connected to Jellyfin WebSocket");
|
||||
_logger.LogDebug("✓ WEBSOCKET: Connected to Jellyfin WebSocket");
|
||||
|
||||
// Start bidirectional proxying
|
||||
var clientToServer = ProxyMessagesAsync(clientWebSocket, serverWebSocket, "Client→Server", context.RequestAborted);
|
||||
@@ -155,7 +155,15 @@ public class WebSocketProxyMiddleware
|
||||
}
|
||||
catch (WebSocketException wsEx)
|
||||
{
|
||||
_logger.LogWarning(wsEx, "⚠️ WEBSOCKET: WebSocket error: {Message}", wsEx.Message);
|
||||
// 403 is expected when tokens expire or session ends - don't spam logs
|
||||
if (wsEx.Message.Contains("403"))
|
||||
{
|
||||
_logger.LogDebug("WEBSOCKET: Connection rejected with 403 (token expired or session ended)");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(wsEx, "⚠️ WEBSOCKET: WebSocket error: {Message}", wsEx.Message);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -194,7 +202,7 @@ public class WebSocketProxyMiddleware
|
||||
// CRITICAL: Notify session manager that client disconnected
|
||||
if (!string.IsNullOrEmpty(deviceId))
|
||||
{
|
||||
_logger.LogInformation("🧹 WEBSOCKET: Client disconnected, removing session for device {DeviceId}", deviceId);
|
||||
_logger.LogDebug("🧹 WEBSOCKET: Client disconnected, removing session for device {DeviceId}", deviceId);
|
||||
await _sessionManager.RemoveSessionAsync(deviceId);
|
||||
}
|
||||
|
||||
@@ -239,7 +247,7 @@ public class WebSocketProxyMiddleware
|
||||
if (direction == "Server→Client")
|
||||
{
|
||||
var messageText = System.Text.Encoding.UTF8.GetString(messageBytes);
|
||||
_logger.LogInformation("📥 WEBSOCKET {Direction}: {Preview}",
|
||||
_logger.LogTrace("📥 WEBSOCKET {Direction}: {Preview}",
|
||||
direction,
|
||||
messageText.Length > 500 ? messageText[..500] + "..." : messageText);
|
||||
}
|
||||
@@ -274,7 +282,7 @@ public class WebSocketProxyMiddleware
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "⚠️ WEBSOCKET {Direction}: Error proxying messages", direction);
|
||||
_logger.LogDebug(ex, "WEBSOCKET {Direction}: Error proxying messages (connection closed)", direction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,12 @@ public class Song
|
||||
/// All artists for this track (main + featured). For display in Jellyfin clients.
|
||||
/// </summary>
|
||||
public List<string> Artists { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// All artist IDs corresponding to the Artists list. Index-matched with Artists.
|
||||
/// </summary>
|
||||
public List<string> ArtistIds { get; set; } = new();
|
||||
|
||||
public string Album { get; set; } = string.Empty;
|
||||
public string? AlbumId { get; set; }
|
||||
public int? Duration { get; set; } // In seconds
|
||||
|
||||
@@ -18,18 +18,6 @@ public class SpotifyApiSettings
|
||||
/// </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.
|
||||
|
||||
@@ -45,6 +45,14 @@ public class SpotifyPlaylistConfig
|
||||
/// Where to position local tracks: "first" or "last"
|
||||
/// </summary>
|
||||
public LocalTracksPosition LocalTracksPosition { get; set; } = LocalTracksPosition.First;
|
||||
|
||||
/// <summary>
|
||||
/// Cron schedule for syncing this playlist with Spotify
|
||||
/// Format: minute hour day month dayofweek
|
||||
/// Example: "0 8 * * 1" = 8 AM every Monday
|
||||
/// Default: "0 8 * * 1" (weekly on Monday at 8 AM)
|
||||
/// </summary>
|
||||
public string SyncSchedule { get; set; } = "0 8 * * 1";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -59,27 +67,6 @@ public class SpotifyImportSettings
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Hour when Spotify Import plugin runs (24-hour format, 0-23)
|
||||
/// NOTE: This setting is now optional and only used for the sync window check.
|
||||
/// The fetcher will search backwards from current time for the last 48 hours,
|
||||
/// so timezone confusion is avoided.
|
||||
/// </summary>
|
||||
public int SyncStartHour { get; set; } = 16;
|
||||
|
||||
/// <summary>
|
||||
/// Minute when Spotify Import plugin runs (0-59)
|
||||
/// NOTE: This setting is now optional and only used for the sync window check.
|
||||
/// </summary>
|
||||
public int SyncStartMinute { get; set; } = 15;
|
||||
|
||||
/// <summary>
|
||||
/// How many hours to search for missing tracks files after sync start time
|
||||
/// This prevents the fetcher from running too frequently.
|
||||
/// Set to 0 to disable the sync window check and always search on startup.
|
||||
/// </summary>
|
||||
public int SyncWindowHours { get; set; } = 2;
|
||||
|
||||
/// <summary>
|
||||
/// How often to run track matching in hours.
|
||||
/// Spotify playlists like Discover Weekly update once per week, Release Radar updates weekly.
|
||||
|
||||
@@ -13,25 +13,49 @@ using allstarr.Middleware;
|
||||
using allstarr.Filters;
|
||||
using Microsoft.Extensions.Http;
|
||||
using System.Text;
|
||||
using System.Net;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Configure forwarded headers for reverse proxy support (nginx, etc.)
|
||||
// This allows ASP.NET Core to read X-Forwarded-For, X-Real-IP, etc.
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
{
|
||||
options.ForwardedHeaders = Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedFor
|
||||
| Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedProto
|
||||
| Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedHost;
|
||||
|
||||
// Clear known networks and proxies to accept headers from any proxy
|
||||
// This is safe when running behind a trusted reverse proxy (nginx)
|
||||
options.KnownIPNetworks.Clear();
|
||||
options.KnownProxies.Clear();
|
||||
|
||||
// Trust X-Forwarded-* headers from any source
|
||||
// Only do this if your reverse proxy is properly configured and trusted
|
||||
options.ForwardLimit = null;
|
||||
});
|
||||
|
||||
// Decode SquidWTF API base URLs once at startup
|
||||
var squidWtfApiUrls = DecodeSquidWtfUrls();
|
||||
static List<string> DecodeSquidWtfUrls()
|
||||
{
|
||||
var encodedUrls = new[]
|
||||
{
|
||||
"aHR0cHM6Ly90cml0b24uc3F1aWQud3Rm", // triton
|
||||
"aHR0cHM6Ly90aWRhbC1hcGkuYmluaW11bS5vcmc=", // binimum
|
||||
"aHR0cHM6Ly90aWRhbC5raW5vcGx1cy5vbmxpbmU=", // kinoplus
|
||||
"aHR0cHM6Ly9oaWZpLXR3by5zcG90aXNhdmVyLm5ldA==", // spoti-2
|
||||
"aHR0cHM6Ly9oaWZpLW9uZS5zcG90aXNhdmVyLm5ldA==", // spoti-1
|
||||
"aHR0cHM6Ly93b2xmLnFxZGwuc2l0ZQ==", // wolf
|
||||
"aHR0cDovL2h1bmQucXFkbC5zaXRl", // hund
|
||||
"aHR0cHM6Ly9rYXR6ZS5xcWRsLnNpdGU=", // katze
|
||||
"aHR0cHM6Ly92b2dlbC5xcWRsLnNpdGU=", // vogel
|
||||
"aHR0cHM6Ly9tYXVzLnFxZGwuc2l0ZQ==" // maus
|
||||
"aHR0cHM6Ly90cml0b24uc3F1aWQud3Rm", // triton.squid.wtf
|
||||
"aHR0cHM6Ly90aWRhbC1hcGkuYmluaW11bS5vcmc=", // tidal-api.binimum.org
|
||||
"aHR0cHM6Ly90aWRhbC5raW5vcGx1cy5vbmxpbmU=", // tidal.kinoplus.online
|
||||
"aHR0cHM6Ly9oaWZpLXR3by5zcG90aXNhdmVyLm5ldA==", // hifi-two.spotisaver.net
|
||||
"aHR0cHM6Ly9oaWZpLW9uZS5zcG90aXNhdmVyLm5ldA==", // hifi-one.spotisaver.net
|
||||
"aHR0cHM6Ly93b2xmLnFxZGwuc2l0ZQ==", // wolf.qqdl.site
|
||||
"aHR0cDovL2h1bmQucXFkbC5zaXRl", // hund.qqdl.site (http)
|
||||
"aHR0cHM6Ly9rYXR6ZS5xcWRsLnNpdGU=", // katze.qqdl.site
|
||||
"aHR0cHM6Ly92b2dlbC5xcWRsLnNpdGU=", // vogel.qqdl.site
|
||||
"aHR0cHM6Ly9tYXVzLnFxZGwuc2l0ZQ==", // maus.qqdl.site
|
||||
"aHR0cHM6Ly9ldS1jZW50cmFsLm1vbm9jaHJvbWUudGY=", // eu-central.monochrome.tf
|
||||
"aHR0cHM6Ly91cy13ZXN0Lm1vbm9jaHJvbWUudGY=", // us-west.monochrome.tf
|
||||
"aHR0cHM6Ly9hcnJhbi5tb25vY2hyb21lLnRm", // arran.monochrome.tf
|
||||
"aHR0cHM6Ly9hcGkubW9ub2Nocm9tZS50Zg==", // api.monochrome.tf
|
||||
"aHR0cHM6Ly9odW5kLnFxZGwuc2l0ZQ==" // hund.qqdl.site (https)
|
||||
};
|
||||
|
||||
return encodedUrls
|
||||
@@ -353,7 +377,7 @@ builder.Services.Configure<SpotifyImportSettings>(options =>
|
||||
}
|
||||
|
||||
// Log configuration at startup
|
||||
Console.WriteLine($"Spotify Import: Enabled={options.Enabled}, SyncHour={options.SyncStartHour}:{options.SyncStartMinute:D2}, WindowHours={options.SyncWindowHours}");
|
||||
Console.WriteLine($"Spotify Import: Enabled={options.Enabled}, MatchingInterval={options.MatchingIntervalHours}h");
|
||||
Console.WriteLine($"Spotify Import Playlists: {options.Playlists.Count} configured");
|
||||
foreach (var playlist in options.Playlists)
|
||||
{
|
||||
@@ -449,7 +473,8 @@ else if (musicService == MusicService.SquidWTF)
|
||||
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
|
||||
sp.GetRequiredService<ILogger<SquidWTFMetadataService>>(),
|
||||
sp.GetRequiredService<RedisCacheService>(),
|
||||
squidWtfApiUrls));
|
||||
squidWtfApiUrls,
|
||||
sp.GetRequiredService<GenreEnrichmentService>()));
|
||||
builder.Services.AddSingleton<IDownloadService>(sp =>
|
||||
new SquidWTFDownloadService(
|
||||
sp.GetRequiredService<IHttpClientFactory>(),
|
||||
@@ -513,18 +538,6 @@ builder.Services.Configure<allstarr.Models.Settings.SpotifyApiSettings>(options
|
||||
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))
|
||||
{
|
||||
@@ -552,7 +565,6 @@ builder.Services.Configure<allstarr.Models.Settings.SpotifyApiSettings>(options
|
||||
// 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}");
|
||||
@@ -563,6 +575,12 @@ 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 LyricsPlus service (multi-source lyrics API)
|
||||
builder.Services.AddSingleton<allstarr.Services.Lyrics.LyricsPlusService>();
|
||||
|
||||
// Register Lyrics Orchestrator (manages priority-based lyrics fetching)
|
||||
builder.Services.AddSingleton<allstarr.Services.Lyrics.LyricsOrchestrator>();
|
||||
|
||||
// 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>());
|
||||
@@ -621,7 +639,23 @@ builder.Services.AddCors(options =>
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Migrate old .env file format on startup
|
||||
try
|
||||
{
|
||||
var migrationService = new EnvMigrationService(app.Services.GetRequiredService<ILogger<EnvMigrationService>>());
|
||||
migrationService.MigrateEnvFile();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
app.Logger.LogWarning(ex, "Failed to run .env migration");
|
||||
}
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
|
||||
// IMPORTANT: UseForwardedHeaders must be called BEFORE other middleware
|
||||
// This processes X-Forwarded-For, X-Real-IP, etc. from nginx
|
||||
app.UseForwardedHeaders();
|
||||
|
||||
app.UseExceptionHandler(_ => { }); // Global exception handler
|
||||
|
||||
// Enable response compression EARLY in the pipeline
|
||||
|
||||
@@ -95,22 +95,88 @@ public abstract class BaseDownloadService : IDownloadService
|
||||
|
||||
public async Task<Stream> DownloadAndStreamAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startTime = DateTime.UtcNow;
|
||||
|
||||
// Check if already downloaded locally
|
||||
var localPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId);
|
||||
if (localPath != null && IOFile.Exists(localPath))
|
||||
{
|
||||
Logger.LogInformation("Streaming from local cache: {Path}", localPath);
|
||||
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
|
||||
Logger.LogInformation("Streaming from local cache ({ElapsedMs}ms): {Path}", elapsed, localPath);
|
||||
|
||||
// Update access time for cache cleanup
|
||||
if (SubsonicSettings.StorageMode == StorageMode.Cache)
|
||||
{
|
||||
IOFile.SetLastAccessTime(localPath, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
// Start background Odesli conversion for lyrics (if not already cached)
|
||||
StartBackgroundOdesliConversion(externalProvider, externalId);
|
||||
|
||||
return IOFile.OpenRead(localPath);
|
||||
}
|
||||
|
||||
// For on-demand streaming, download to disk first to ensure complete file
|
||||
// Download to disk first to ensure complete file with metadata
|
||||
// This is necessary because:
|
||||
// 1. Clients may seek to arbitrary positions (requires full file)
|
||||
// 2. Metadata embedding requires complete file
|
||||
// 3. Caching for future plays
|
||||
Logger.LogInformation("Downloading song for streaming: {Provider}:{ExternalId}", externalProvider, externalId);
|
||||
localPath = await DownloadSongInternalAsync(externalProvider, externalId, triggerAlbumDownload: true, cancellationToken);
|
||||
return IOFile.OpenRead(localPath);
|
||||
|
||||
try
|
||||
{
|
||||
localPath = await DownloadSongInternalAsync(externalProvider, externalId, triggerAlbumDownload: true, cancellationToken);
|
||||
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
|
||||
Logger.LogInformation("Download completed, starting stream ({ElapsedMs}ms total): {Path}", elapsed, localPath);
|
||||
|
||||
// Start background Odesli conversion for lyrics (after stream starts)
|
||||
StartBackgroundOdesliConversion(externalProvider, externalId);
|
||||
|
||||
return IOFile.OpenRead(localPath);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
|
||||
Logger.LogWarning("Download cancelled by client after {ElapsedMs}ms for {Provider}:{ExternalId}", elapsed, externalProvider, externalId);
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
|
||||
Logger.LogError(ex, "Download failed after {ElapsedMs}ms for {Provider}:{ExternalId}", elapsed, externalProvider, externalId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts background Odesli conversion for lyrics support.
|
||||
/// This is called AFTER streaming starts so it doesn't block the client.
|
||||
/// </summary>
|
||||
private void StartBackgroundOdesliConversion(string externalProvider, string externalId)
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// Provider-specific conversion (override in subclasses if needed)
|
||||
await ConvertToSpotifyIdAsync(externalProvider, externalId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogDebug(ex, "Background Spotify ID conversion failed for {Provider}:{ExternalId}", externalProvider, externalId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts external track ID to Spotify ID for lyrics support.
|
||||
/// Override in provider-specific services if needed.
|
||||
/// </summary>
|
||||
protected virtual Task ConvertToSpotifyIdAsync(string externalProvider, string externalId)
|
||||
{
|
||||
// Default implementation does nothing
|
||||
// Provider-specific services can override this
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public DownloadInfo? GetDownloadStatus(string songId)
|
||||
@@ -198,6 +264,7 @@ public abstract class BaseDownloadService : IDownloadService
|
||||
|
||||
// Acquire lock BEFORE checking existence to prevent race conditions with concurrent requests
|
||||
await DownloadLock.WaitAsync(cancellationToken);
|
||||
var lockHeld = true;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -219,21 +286,27 @@ public abstract class BaseDownloadService : IDownloadService
|
||||
// Check if download in progress
|
||||
if (ActiveDownloads.TryGetValue(songId, out var activeDownload) && activeDownload.Status == DownloadStatus.InProgress)
|
||||
{
|
||||
Logger.LogInformation("Download already in progress for {SongId}, waiting...", songId);
|
||||
Logger.LogDebug("Download already in progress for {SongId}, waiting for completion...", songId);
|
||||
// Release lock while waiting
|
||||
DownloadLock.Release();
|
||||
lockHeld = false;
|
||||
|
||||
// Wait for download to complete, checking every 100ms (faster than 500ms)
|
||||
// Also respect cancellation token so client timeouts are handled immediately
|
||||
while (ActiveDownloads.TryGetValue(songId, out activeDownload) && activeDownload.Status == DownloadStatus.InProgress)
|
||||
{
|
||||
await Task.Delay(500, cancellationToken);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await Task.Delay(100, cancellationToken);
|
||||
}
|
||||
|
||||
if (activeDownload?.Status == DownloadStatus.Completed && activeDownload.LocalPath != null)
|
||||
{
|
||||
Logger.LogDebug("Download completed while waiting, returning path: {Path}", activeDownload.LocalPath);
|
||||
return activeDownload.LocalPath;
|
||||
}
|
||||
|
||||
throw new Exception(activeDownload?.ErrorMessage ?? "Download failed");
|
||||
// Download failed or was cancelled
|
||||
throw new Exception(activeDownload?.ErrorMessage ?? "Download failed while waiting");
|
||||
}
|
||||
|
||||
// Get metadata
|
||||
@@ -373,7 +446,10 @@ public abstract class BaseDownloadService : IDownloadService
|
||||
}
|
||||
finally
|
||||
{
|
||||
DownloadLock.Release();
|
||||
if (lockHeld)
|
||||
{
|
||||
DownloadLock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +66,9 @@ public class CacheCleanupService : BackgroundService
|
||||
|
||||
private async Task CleanupOldCachedFilesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var cachePath = PathHelper.GetCachePath();
|
||||
// Get the actual cache path used by download services
|
||||
var downloadPath = _configuration["Library:DownloadPath"] ?? "downloads";
|
||||
var cachePath = Path.Combine(downloadPath, "cache");
|
||||
|
||||
if (!Directory.Exists(cachePath))
|
||||
{
|
||||
@@ -78,7 +80,7 @@ public class CacheCleanupService : BackgroundService
|
||||
var deletedCount = 0;
|
||||
var totalSize = 0L;
|
||||
|
||||
_logger.LogInformation("Starting cache cleanup: deleting files older than {CutoffTime}", cutoffTime);
|
||||
_logger.LogInformation("Starting cache cleanup: deleting files older than {CutoffTime} from {Path}", cutoffTime, cachePath);
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -20,6 +20,9 @@ public class EndpointBenchmarkService
|
||||
/// <summary>
|
||||
/// Benchmarks a list of endpoints by making test requests.
|
||||
/// Returns endpoints sorted by average response time (fastest first).
|
||||
///
|
||||
/// IMPORTANT: The testFunc should implement its own timeout to prevent slow endpoints
|
||||
/// from blocking startup. Recommended: 5-10 second timeout per ping.
|
||||
/// </summary>
|
||||
public async Task<List<string>> BenchmarkEndpointsAsync(
|
||||
List<string> endpoints,
|
||||
|
||||
59
allstarr/Services/Common/EnvMigrationService.cs
Normal file
59
allstarr/Services/Common/EnvMigrationService.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
namespace allstarr.Services.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Service that runs on startup to migrate old .env file format to new format
|
||||
/// </summary>
|
||||
public class EnvMigrationService
|
||||
{
|
||||
private readonly ILogger<EnvMigrationService> _logger;
|
||||
private readonly string _envFilePath;
|
||||
|
||||
public EnvMigrationService(ILogger<EnvMigrationService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_envFilePath = Path.Combine(Directory.GetCurrentDirectory(), ".env");
|
||||
}
|
||||
|
||||
public void MigrateEnvFile()
|
||||
{
|
||||
if (!File.Exists(_envFilePath))
|
||||
{
|
||||
_logger.LogDebug("No .env file found, skipping migration");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var lines = File.ReadAllLines(_envFilePath);
|
||||
var modified = false;
|
||||
|
||||
for (int i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i].Trim();
|
||||
|
||||
// Skip comments and empty lines
|
||||
if (string.IsNullOrWhiteSpace(line) || line.StartsWith("#"))
|
||||
continue;
|
||||
|
||||
// Migrate DOWNLOAD_PATH to Library__DownloadPath
|
||||
if (line.StartsWith("DOWNLOAD_PATH="))
|
||||
{
|
||||
var value = line.Substring("DOWNLOAD_PATH=".Length);
|
||||
lines[i] = $"Library__DownloadPath={value}";
|
||||
modified = true;
|
||||
_logger.LogInformation("Migrated DOWNLOAD_PATH to Library__DownloadPath in .env file");
|
||||
}
|
||||
}
|
||||
|
||||
if (modified)
|
||||
{
|
||||
File.WriteAllLines(_envFilePath, lines);
|
||||
_logger.LogInformation("✅ .env file migration completed successfully");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to migrate .env file - please manually update DOWNLOAD_PATH to Library__DownloadPath");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,8 @@ public static class FuzzyMatcher
|
||||
/// Calculates similarity score following OPTIMAL ORDER:
|
||||
/// 1. Strip decorators (already done by caller)
|
||||
/// 2. Substring matching (cheap, high-precision)
|
||||
/// 3. Levenshtein distance (expensive, fuzzy)
|
||||
/// 3. Token-based matching (handles word order)
|
||||
/// 4. Levenshtein distance (expensive, fuzzy)
|
||||
/// Returns score 0-100.
|
||||
/// </summary>
|
||||
public static int CalculateSimilarity(string query, string target)
|
||||
@@ -103,11 +104,71 @@ public static class FuzzyMatcher
|
||||
return 85;
|
||||
}
|
||||
|
||||
// STEP 3: LEVENSHTEIN DISTANCE (expensive, fuzzy)
|
||||
// Only use this for candidates that survived substring checks
|
||||
|
||||
var distance = LevenshteinDistance(queryNorm, targetNorm);
|
||||
var maxLength = Math.Max(queryNorm.Length, targetNorm.Length);
|
||||
// STEP 3: TOKEN-BASED MATCHING (handles word order)
|
||||
var tokens1 = queryNorm.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
var tokens2 = targetNorm.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
if (tokens1.Length > 0 && tokens2.Length > 0)
|
||||
{
|
||||
// Calculate how many tokens match (order-independent)
|
||||
var matchedTokens = 0.0; // Use double for partial matches
|
||||
var usedTokens = new HashSet<int>();
|
||||
|
||||
foreach (var token1 in tokens1)
|
||||
{
|
||||
for (int i = 0; i < tokens2.Length; i++)
|
||||
{
|
||||
if (usedTokens.Contains(i)) continue;
|
||||
|
||||
var token2 = tokens2[i];
|
||||
|
||||
// Exact token match
|
||||
if (token1 == token2)
|
||||
{
|
||||
matchedTokens++;
|
||||
usedTokens.Add(i);
|
||||
break;
|
||||
}
|
||||
// Partial token match (one contains the other)
|
||||
else if (token1.Contains(token2) || token2.Contains(token1))
|
||||
{
|
||||
matchedTokens += 0.8; // Partial credit
|
||||
usedTokens.Add(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate token match percentage
|
||||
var maxTokens = Math.Max(tokens1.Length, tokens2.Length);
|
||||
var tokenMatchScore = (matchedTokens / maxTokens) * 100.0;
|
||||
|
||||
// If token match is very high (90%+), return it
|
||||
if (tokenMatchScore >= 90)
|
||||
{
|
||||
return (int)Math.Round(tokenMatchScore, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
// If token match is decent (70%+), use it as a floor for Levenshtein
|
||||
if (tokenMatchScore >= 70)
|
||||
{
|
||||
var levenshteinScore = CalculateLevenshteinScore(queryNorm, targetNorm);
|
||||
return (int)Math.Max(tokenMatchScore, levenshteinScore);
|
||||
}
|
||||
}
|
||||
|
||||
// STEP 4: LEVENSHTEIN DISTANCE (expensive, fuzzy)
|
||||
return CalculateLevenshteinScore(queryNorm, targetNorm);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates similarity score based on Levenshtein distance.
|
||||
/// Returns score 0-75 (reserve 75-100 for substring/token matches).
|
||||
/// </summary>
|
||||
private static int CalculateLevenshteinScore(string str1, string str2)
|
||||
{
|
||||
var distance = LevenshteinDistance(str1, str2);
|
||||
var maxLength = Math.Max(str1.Length, str2.Length);
|
||||
|
||||
if (maxLength == 0)
|
||||
{
|
||||
@@ -117,8 +178,9 @@ public static class FuzzyMatcher
|
||||
// Normalize distance by length: score = 1 - (distance / max_length)
|
||||
var normalizedSimilarity = 1.0 - ((double)distance / maxLength);
|
||||
|
||||
// Convert to 0-80 range (reserve 80-100 for substring matches)
|
||||
var score = (int)(normalizedSimilarity * 80);
|
||||
// Convert to 0-75 range (reserve 75-100 for substring/token matches)
|
||||
// Using 75 instead of 80 to be slightly stricter
|
||||
var score = (int)(normalizedSimilarity * 75);
|
||||
|
||||
return Math.Max(0, score);
|
||||
}
|
||||
@@ -154,7 +216,9 @@ public static class FuzzyMatcher
|
||||
/// <summary>
|
||||
/// Normalizes a string for matching by:
|
||||
/// - Converting to lowercase
|
||||
/// - Normalizing apostrophes (', ', ') to standard '
|
||||
/// - Removing accents/diacritics
|
||||
/// - Converting hyphens/underscores to spaces (for word separation)
|
||||
/// - Removing other punctuation (periods, apostrophes, commas, etc.)
|
||||
/// - Removing extra whitespace
|
||||
/// </summary>
|
||||
private static string NormalizeForMatching(string text)
|
||||
@@ -166,18 +230,42 @@ public static class FuzzyMatcher
|
||||
|
||||
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 (´)
|
||||
// Remove accents/diacritics (é -> e, ñ -> n, etc.)
|
||||
normalized = RemoveDiacritics(normalized);
|
||||
|
||||
// Replace hyphens and underscores with spaces (for word separation)
|
||||
// This ensures "Dua-Lipa" becomes "Dua Lipa" not "DuaLipa"
|
||||
normalized = normalized.Replace('-', ' ').Replace('_', ' ');
|
||||
|
||||
// Remove all other punctuation: periods, apostrophes, commas, etc.
|
||||
normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"[^\w\s]", "");
|
||||
|
||||
// Normalize whitespace
|
||||
normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"\s+", " ");
|
||||
normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"\s+", " ").Trim();
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes diacritics (accents) from characters.
|
||||
/// Example: é -> e, ñ -> n, ü -> u
|
||||
/// </summary>
|
||||
private static string RemoveDiacritics(string text)
|
||||
{
|
||||
var normalizedString = text.Normalize(System.Text.NormalizationForm.FormD);
|
||||
var stringBuilder = new System.Text.StringBuilder();
|
||||
|
||||
foreach (var c in normalizedString)
|
||||
{
|
||||
var unicodeCategory = System.Globalization.CharUnicodeInfo.GetUnicodeCategory(c);
|
||||
if (unicodeCategory != System.Globalization.UnicodeCategory.NonSpacingMark)
|
||||
{
|
||||
stringBuilder.Append(c);
|
||||
}
|
||||
}
|
||||
|
||||
return stringBuilder.ToString().Normalize(System.Text.NormalizationForm.FormC);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates Levenshtein distance between two strings.
|
||||
|
||||
@@ -11,6 +11,12 @@ public class RoundRobinFallbackHelper
|
||||
private readonly object _urlIndexLock = new object();
|
||||
private readonly ILogger _logger;
|
||||
private readonly string _serviceName;
|
||||
private readonly HttpClient _healthCheckClient;
|
||||
|
||||
// Cache health check results for 30 seconds to avoid excessive checks
|
||||
private readonly Dictionary<string, (bool isHealthy, DateTime checkedAt)> _healthCache = new();
|
||||
private readonly object _healthCacheLock = new object();
|
||||
private readonly TimeSpan _healthCacheExpiry = TimeSpan.FromSeconds(30);
|
||||
|
||||
public int EndpointCount => _apiUrls.Count;
|
||||
|
||||
@@ -24,6 +30,91 @@ public class RoundRobinFallbackHelper
|
||||
{
|
||||
throw new ArgumentException("API URLs list cannot be empty", nameof(apiUrls));
|
||||
}
|
||||
|
||||
// Create a dedicated HttpClient for health checks with short timeout
|
||||
_healthCheckClient = new HttpClient
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(3) // Quick health check timeout
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Quickly checks if an endpoint is healthy (responds within 3 seconds).
|
||||
/// Results are cached for 30 seconds to avoid excessive health checks.
|
||||
/// </summary>
|
||||
private async Task<bool> IsEndpointHealthyAsync(string baseUrl)
|
||||
{
|
||||
// Check cache first
|
||||
lock (_healthCacheLock)
|
||||
{
|
||||
if (_healthCache.TryGetValue(baseUrl, out var cached))
|
||||
{
|
||||
if (DateTime.UtcNow - cached.checkedAt < _healthCacheExpiry)
|
||||
{
|
||||
return cached.isHealthy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Perform health check
|
||||
try
|
||||
{
|
||||
var response = await _healthCheckClient.GetAsync(baseUrl, HttpCompletionOption.ResponseHeadersRead);
|
||||
var isHealthy = response.IsSuccessStatusCode;
|
||||
|
||||
// Cache result
|
||||
lock (_healthCacheLock)
|
||||
{
|
||||
_healthCache[baseUrl] = (isHealthy, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
if (!isHealthy)
|
||||
{
|
||||
_logger.LogDebug("{Service} endpoint {Endpoint} health check failed: {StatusCode}",
|
||||
_serviceName, baseUrl, response.StatusCode);
|
||||
}
|
||||
|
||||
return isHealthy;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "{Service} endpoint {Endpoint} health check failed", _serviceName, baseUrl);
|
||||
|
||||
// Cache as unhealthy
|
||||
lock (_healthCacheLock)
|
||||
{
|
||||
_healthCache[baseUrl] = (false, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a list of healthy endpoints, checking them in parallel.
|
||||
/// Falls back to all endpoints if none are healthy.
|
||||
/// </summary>
|
||||
private async Task<List<string>> GetHealthyEndpointsAsync()
|
||||
{
|
||||
var healthCheckTasks = _apiUrls.Select(async url => new
|
||||
{
|
||||
Url = url,
|
||||
IsHealthy = await IsEndpointHealthyAsync(url)
|
||||
}).ToList();
|
||||
|
||||
var results = await Task.WhenAll(healthCheckTasks);
|
||||
var healthyEndpoints = results.Where(r => r.IsHealthy).Select(r => r.Url).ToList();
|
||||
|
||||
if (healthyEndpoints.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("{Service} health check: no healthy endpoints found, will try all", _serviceName);
|
||||
return _apiUrls;
|
||||
}
|
||||
|
||||
_logger.LogDebug("{Service} health check: {Healthy}/{Total} endpoints healthy",
|
||||
_serviceName, healthyEndpoints.Count, _apiUrls.Count);
|
||||
|
||||
return healthyEndpoints;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -54,10 +145,14 @@ public class RoundRobinFallbackHelper
|
||||
/// <summary>
|
||||
/// Tries the request with the next provider in round-robin, then falls back to others on failure.
|
||||
/// This distributes load evenly across all providers while maintaining reliability.
|
||||
/// Performs quick health checks first to avoid wasting time on dead endpoints.
|
||||
/// Throws exception if all endpoints fail.
|
||||
/// </summary>
|
||||
public async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action)
|
||||
{
|
||||
// Get healthy endpoints first (with caching to avoid excessive checks)
|
||||
var healthyEndpoints = await GetHealthyEndpointsAsync();
|
||||
|
||||
// Start with the next URL in round-robin to distribute load
|
||||
var startIndex = 0;
|
||||
lock (_urlIndexLock)
|
||||
@@ -66,16 +161,21 @@ public class RoundRobinFallbackHelper
|
||||
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
|
||||
}
|
||||
|
||||
// Try healthy endpoints first, then fall back to all if needed
|
||||
var endpointsToTry = healthyEndpoints.Count < _apiUrls.Count
|
||||
? healthyEndpoints.Concat(_apiUrls.Except(healthyEndpoints)).ToList()
|
||||
: healthyEndpoints;
|
||||
|
||||
// Try all URLs starting from the round-robin selected one
|
||||
for (int attempt = 0; attempt < _apiUrls.Count; attempt++)
|
||||
for (int attempt = 0; attempt < endpointsToTry.Count; attempt++)
|
||||
{
|
||||
var urlIndex = (startIndex + attempt) % _apiUrls.Count;
|
||||
var baseUrl = _apiUrls[urlIndex];
|
||||
var urlIndex = (startIndex + attempt) % endpointsToTry.Count;
|
||||
var baseUrl = endpointsToTry[urlIndex];
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Trying {Service} endpoint {Endpoint} (attempt {Attempt}/{Total})",
|
||||
_serviceName, baseUrl, attempt + 1, _apiUrls.Count);
|
||||
_serviceName, baseUrl, attempt + 1, endpointsToTry.Count);
|
||||
return await action(baseUrl);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -83,9 +183,15 @@ public class RoundRobinFallbackHelper
|
||||
_logger.LogWarning(ex, "{Service} request failed with endpoint {Endpoint}, trying next...",
|
||||
_serviceName, baseUrl);
|
||||
|
||||
if (attempt == _apiUrls.Count - 1)
|
||||
// Mark as unhealthy in cache
|
||||
lock (_healthCacheLock)
|
||||
{
|
||||
_logger.LogError("All {Count} {Service} endpoints failed", _apiUrls.Count, _serviceName);
|
||||
_healthCache[baseUrl] = (false, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
if (attempt == endpointsToTry.Count - 1)
|
||||
{
|
||||
_logger.LogError("All {Count} {Service} endpoints failed", endpointsToTry.Count, _serviceName);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
@@ -150,10 +256,14 @@ public class RoundRobinFallbackHelper
|
||||
|
||||
/// <summary>
|
||||
/// Tries the request with the next provider in round-robin, then falls back to others on failure.
|
||||
/// Performs quick health checks first to avoid wasting time on dead endpoints.
|
||||
/// Returns default value if all endpoints fail (does not throw).
|
||||
/// </summary>
|
||||
public async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action, T defaultValue)
|
||||
{
|
||||
// Get healthy endpoints first (with caching to avoid excessive checks)
|
||||
var healthyEndpoints = await GetHealthyEndpointsAsync();
|
||||
|
||||
// Start with the next URL in round-robin to distribute load
|
||||
var startIndex = 0;
|
||||
lock (_urlIndexLock)
|
||||
@@ -162,16 +272,21 @@ public class RoundRobinFallbackHelper
|
||||
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
|
||||
}
|
||||
|
||||
// Try healthy endpoints first, then fall back to all if needed
|
||||
var endpointsToTry = healthyEndpoints.Count < _apiUrls.Count
|
||||
? healthyEndpoints.Concat(_apiUrls.Except(healthyEndpoints)).ToList()
|
||||
: healthyEndpoints;
|
||||
|
||||
// Try all URLs starting from the round-robin selected one
|
||||
for (int attempt = 0; attempt < _apiUrls.Count; attempt++)
|
||||
for (int attempt = 0; attempt < endpointsToTry.Count; attempt++)
|
||||
{
|
||||
var urlIndex = (startIndex + attempt) % _apiUrls.Count;
|
||||
var baseUrl = _apiUrls[urlIndex];
|
||||
var urlIndex = (startIndex + attempt) % endpointsToTry.Count;
|
||||
var baseUrl = endpointsToTry[urlIndex];
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Trying {Service} endpoint {Endpoint} (attempt {Attempt}/{Total})",
|
||||
_serviceName, baseUrl, attempt + 1, _apiUrls.Count);
|
||||
_serviceName, baseUrl, attempt + 1, endpointsToTry.Count);
|
||||
return await action(baseUrl);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -179,10 +294,16 @@ public class RoundRobinFallbackHelper
|
||||
_logger.LogWarning(ex, "{Service} request failed with endpoint {Endpoint}, trying next...",
|
||||
_serviceName, baseUrl);
|
||||
|
||||
if (attempt == _apiUrls.Count - 1)
|
||||
// Mark as unhealthy in cache
|
||||
lock (_healthCacheLock)
|
||||
{
|
||||
_healthCache[baseUrl] = (false, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
if (attempt == endpointsToTry.Count - 1)
|
||||
{
|
||||
_logger.LogError("All {Count} {Service} endpoints failed, returning default value",
|
||||
_apiUrls.Count, _serviceName);
|
||||
endpointsToTry.Count, _serviceName);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,10 +107,10 @@ public class DeezerDownloadService : BaseDownloadService
|
||||
|
||||
// Build organized folder structure: Artist/Album/Track using AlbumArtist (fallback to Artist for singles)
|
||||
var artistForPath = song.AlbumArtist ?? song.Artist;
|
||||
// Cache mode uses cache/Music folder (cleaned up after 24h), Permanent mode uses downloads folder
|
||||
// Cache mode uses downloads/cache/ folder, Permanent mode uses downloads/permanent/
|
||||
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache
|
||||
? Path.Combine("cache", "Music")
|
||||
: "downloads";
|
||||
? Path.Combine("downloads", "cache")
|
||||
: Path.Combine("downloads", "permanent");
|
||||
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension);
|
||||
|
||||
// Create directories if they don't exist
|
||||
|
||||
@@ -3,6 +3,7 @@ using allstarr.Models.Settings;
|
||||
using allstarr.Models.Download;
|
||||
using allstarr.Models.Search;
|
||||
using allstarr.Models.Subsonic;
|
||||
using allstarr.Services.Common;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
@@ -15,12 +16,17 @@ public class DeezerMetadataService : IMusicMetadataService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly SubsonicSettings _settings;
|
||||
private readonly GenreEnrichmentService? _genreEnrichment;
|
||||
private const string BaseUrl = "https://api.deezer.com";
|
||||
|
||||
public DeezerMetadataService(IHttpClientFactory httpClientFactory, IOptions<SubsonicSettings> settings)
|
||||
public DeezerMetadataService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptions<SubsonicSettings> settings,
|
||||
GenreEnrichmentService? genreEnrichment = null)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_settings = settings.Value;
|
||||
_genreEnrichment = genreEnrichment;
|
||||
}
|
||||
|
||||
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20)
|
||||
@@ -203,6 +209,23 @@ public class DeezerMetadataService : IMusicMetadataService
|
||||
}
|
||||
}
|
||||
|
||||
// Enrich with MusicBrainz genres if missing
|
||||
if (_genreEnrichment != null && string.IsNullOrEmpty(song.Genre))
|
||||
{
|
||||
// Fire-and-forget: don't block the response waiting for genre enrichment
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await _genreEnrichment.EnrichSongGenreAsync(song);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Silently ignore genre enrichment failures
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return song;
|
||||
}
|
||||
|
||||
@@ -384,17 +407,23 @@ public class DeezerMetadataService : IMusicMetadataService
|
||||
}
|
||||
}
|
||||
|
||||
// Contributors
|
||||
// Contributors (all artists including features)
|
||||
var contributors = new List<string>();
|
||||
var contributorIds = new List<string>();
|
||||
if (track.TryGetProperty("contributors", out var contribs))
|
||||
{
|
||||
foreach (var contrib in contribs.EnumerateArray())
|
||||
{
|
||||
if (contrib.TryGetProperty("name", out var contribName))
|
||||
if (contrib.TryGetProperty("name", out var contribName) &&
|
||||
contrib.TryGetProperty("id", out var contribId))
|
||||
{
|
||||
var name = contribName.GetString();
|
||||
var id = contribId.GetInt64();
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
{
|
||||
contributors.Add(name);
|
||||
contributorIds.Add($"ext-deezer-artist-{id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -437,6 +466,8 @@ public class DeezerMetadataService : IMusicMetadataService
|
||||
ArtistId = track.TryGetProperty("artist", out var artistForId)
|
||||
? $"ext-deezer-artist-{artistForId.GetProperty("id").GetInt64()}"
|
||||
: null,
|
||||
Artists = contributors.Count > 0 ? contributors : new List<string>(),
|
||||
ArtistIds = contributorIds.Count > 0 ? contributorIds : new List<string>(),
|
||||
Album = track.TryGetProperty("album", out var album)
|
||||
? album.GetProperty("title").GetString() ?? ""
|
||||
: "",
|
||||
|
||||
@@ -168,6 +168,11 @@ public class JellyfinProxyService
|
||||
(h.Value.ToString().Contains("image", StringComparison.OrdinalIgnoreCase) ||
|
||||
h.Value.ToString().Contains("document", StringComparison.OrdinalIgnoreCase))) == true);
|
||||
|
||||
// Check if this is a public endpoint that doesn't require authentication
|
||||
bool isPublicEndpoint = url.Contains("/System/Info/Public", StringComparison.OrdinalIgnoreCase) ||
|
||||
url.Contains("/Branding/", StringComparison.OrdinalIgnoreCase) ||
|
||||
url.Contains("/Startup/", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Forward authentication headers from client if provided
|
||||
if (clientHeaders != null && clientHeaders.Count > 0)
|
||||
{
|
||||
@@ -179,11 +184,27 @@ public class JellyfinProxyService
|
||||
var headerValue = header.Value.ToString();
|
||||
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
|
||||
authHeaderAdded = true;
|
||||
_logger.LogInformation("✓ Forwarded X-Emby-Authorization: {Value}", headerValue);
|
||||
_logger.LogTrace("Forwarded X-Emby-Authorization header");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Try X-Emby-Token (simpler format used by some clients)
|
||||
if (!authHeaderAdded)
|
||||
{
|
||||
foreach (var header in clientHeaders)
|
||||
{
|
||||
if (header.Key.Equals("X-Emby-Token", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var headerValue = header.Value.ToString();
|
||||
request.Headers.TryAddWithoutValidation("X-Emby-Token", headerValue);
|
||||
authHeaderAdded = true;
|
||||
_logger.LogTrace("Forwarded X-Emby-Token header");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no X-Emby-Authorization, check if Authorization header contains MediaBrowser format
|
||||
// Some clients send it as "Authorization" instead of "X-Emby-Authorization"
|
||||
if (!authHeaderAdded)
|
||||
@@ -201,37 +222,32 @@ public class JellyfinProxyService
|
||||
// Forward as X-Emby-Authorization (Jellyfin's expected header)
|
||||
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
|
||||
authHeaderAdded = true;
|
||||
_logger.LogInformation("✓ Converted Authorization to X-Emby-Authorization: {Value}", headerValue);
|
||||
_logger.LogTrace("Converted Authorization to X-Emby-Authorization");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Standard Bearer token - forward as-is
|
||||
request.Headers.TryAddWithoutValidation("Authorization", headerValue);
|
||||
authHeaderAdded = true;
|
||||
_logger.LogInformation("✓ Forwarded Authorization (Bearer): {Value}", headerValue);
|
||||
_logger.LogTrace("Forwarded Authorization header");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only log warnings for non-browser static requests
|
||||
if (!authHeaderAdded && !isBrowserStaticRequest)
|
||||
// Check for api_key query parameter (some clients use this)
|
||||
if (!authHeaderAdded && url.Contains("api_key=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogWarning("✗ No auth header found. Available headers: {Headers}",
|
||||
string.Join(", ", clientHeaders.Select(h => $"{h.Key}={h.Value}")));
|
||||
authHeaderAdded = true; // It's in the URL, no need to add header
|
||||
_logger.LogTrace("Using api_key from query string");
|
||||
}
|
||||
}
|
||||
else if (!isBrowserStaticRequest)
|
||||
{
|
||||
_logger.LogWarning("✗ No client headers provided for {Url}", url);
|
||||
}
|
||||
|
||||
// DO NOT use server API key as fallback - let Jellyfin handle unauthenticated requests
|
||||
// If client doesn't provide auth, they get what they deserve (401 from Jellyfin)
|
||||
if (!authHeaderAdded && !isBrowserStaticRequest)
|
||||
// Only log warnings for non-public, non-browser requests without auth
|
||||
if (!authHeaderAdded && !isBrowserStaticRequest && !isPublicEndpoint)
|
||||
{
|
||||
_logger.LogInformation("No client auth provided for {Url} - forwarding without auth", url);
|
||||
_logger.LogDebug("No client auth provided for {Url} - Jellyfin will handle authentication", url);
|
||||
}
|
||||
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
@@ -248,14 +264,28 @@ public class JellyfinProxyService
|
||||
{
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
|
||||
{
|
||||
_logger.LogWarning("Jellyfin returned 401 Unauthorized for {Url} - passing through to client", url);
|
||||
// 401 means token expired or invalid - client needs to re-authenticate
|
||||
_logger.LogInformation("Jellyfin returned 401 Unauthorized for {Url} - client should re-authenticate", url);
|
||||
}
|
||||
else if (!isBrowserStaticRequest) // Don't log 404s for browser static requests
|
||||
else if (!isBrowserStaticRequest && !isPublicEndpoint)
|
||||
{
|
||||
_logger.LogWarning("Jellyfin request failed: {StatusCode} for {Url}", response.StatusCode, url);
|
||||
}
|
||||
|
||||
// Return null body with the actual status code
|
||||
// Try to parse error response to pass through to client
|
||||
if (!string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
try
|
||||
{
|
||||
var errorDoc = JsonDocument.Parse(content);
|
||||
return (errorDoc, statusCode);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Not valid JSON, return null
|
||||
}
|
||||
}
|
||||
|
||||
return (null, statusCode);
|
||||
}
|
||||
|
||||
@@ -297,8 +327,10 @@ public class JellyfinProxyService
|
||||
request.Content = new StringContent(bodyToSend, System.Text.Encoding.UTF8, "application/json");
|
||||
|
||||
bool authHeaderAdded = false;
|
||||
bool isAuthEndpoint = endpoint.Contains("Authenticate", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Forward authentication headers from client (case-insensitive)
|
||||
// Try X-Emby-Authorization first
|
||||
foreach (var header in clientHeaders)
|
||||
{
|
||||
if (header.Key.Equals("X-Emby-Authorization", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -306,11 +338,28 @@ public class JellyfinProxyService
|
||||
var headerValue = header.Value.ToString();
|
||||
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
|
||||
authHeaderAdded = true;
|
||||
_logger.LogDebug("Forwarded X-Emby-Authorization from client");
|
||||
_logger.LogTrace("Forwarded X-Emby-Authorization header");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Try X-Emby-Token
|
||||
if (!authHeaderAdded)
|
||||
{
|
||||
foreach (var header in clientHeaders)
|
||||
{
|
||||
if (header.Key.Equals("X-Emby-Token", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var headerValue = header.Value.ToString();
|
||||
request.Headers.TryAddWithoutValidation("X-Emby-Token", headerValue);
|
||||
authHeaderAdded = true;
|
||||
_logger.LogTrace("Forwarded X-Emby-Token header");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try Authorization header
|
||||
if (!authHeaderAdded)
|
||||
{
|
||||
foreach (var header in clientHeaders)
|
||||
@@ -325,13 +374,13 @@ public class JellyfinProxyService
|
||||
{
|
||||
// Forward as X-Emby-Authorization
|
||||
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
|
||||
_logger.LogDebug("Converted Authorization to X-Emby-Authorization");
|
||||
_logger.LogTrace("Converted Authorization to X-Emby-Authorization");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Standard Bearer token
|
||||
request.Headers.TryAddWithoutValidation("Authorization", headerValue);
|
||||
_logger.LogDebug("Forwarded Authorization header");
|
||||
_logger.LogTrace("Forwarded Authorization header");
|
||||
}
|
||||
authHeaderAdded = true;
|
||||
break;
|
||||
@@ -339,30 +388,23 @@ public class JellyfinProxyService
|
||||
}
|
||||
}
|
||||
|
||||
// DO NOT use server credentials as fallback
|
||||
// Exception: For auth endpoints, client provides their own credentials in the body
|
||||
// For all other endpoints, if client doesn't provide auth, let Jellyfin reject it
|
||||
if (!authHeaderAdded)
|
||||
// For authentication endpoints, credentials are in the body, not headers
|
||||
// For other endpoints without auth, let Jellyfin reject the request
|
||||
if (!authHeaderAdded && !isAuthEndpoint)
|
||||
{
|
||||
_logger.LogInformation("No client auth provided for POST {Url} - forwarding without auth", url);
|
||||
_logger.LogDebug("No client auth provided for POST {Url} - Jellyfin will handle authentication", url);
|
||||
}
|
||||
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
|
||||
// DO NOT log the body for auth endpoints - it contains passwords!
|
||||
if (endpoint.Contains("Authenticate", StringComparison.OrdinalIgnoreCase))
|
||||
if (isAuthEndpoint)
|
||||
{
|
||||
_logger.LogDebug("POST to Jellyfin: {Url} (auth request - body not logged)", url);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("POST to Jellyfin: {Url}, body length: {Length} bytes", url, bodyToSend.Length);
|
||||
|
||||
// Log body content for playback endpoints to debug
|
||||
if (endpoint.Contains("Playing", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogInformation("Sending body to Jellyfin: {Body}", bodyToSend);
|
||||
}
|
||||
_logger.LogTrace("POST to Jellyfin: {Url}, body length: {Length} bytes", url, bodyToSend.Length);
|
||||
}
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
@@ -372,8 +414,17 @@ public class JellyfinProxyService
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogWarning("❌ SESSION: Jellyfin POST request failed: {StatusCode} for {Url}. Response: {Response}",
|
||||
response.StatusCode, url, errorContent);
|
||||
|
||||
// 401 is expected when tokens expire - don't spam logs
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
|
||||
{
|
||||
_logger.LogInformation("Jellyfin POST returned 401 for {Url} - client should re-authenticate", url);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Jellyfin POST request failed: {StatusCode} for {Url}. Response: {Response}",
|
||||
response.StatusCode, url, errorContent.Length > 200 ? errorContent[..200] + "..." : errorContent);
|
||||
}
|
||||
|
||||
// Try to parse error response as JSON to pass through to client
|
||||
if (!string.IsNullOrWhiteSpace(errorContent))
|
||||
@@ -395,7 +446,7 @@ public class JellyfinProxyService
|
||||
// Log successful session-related responses
|
||||
if (endpoint.Contains("Sessions", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogWarning("✓ SESSION: Jellyfin responded {StatusCode} for {Endpoint}", statusCode, endpoint);
|
||||
_logger.LogTrace("Jellyfin responded {StatusCode} for {Endpoint}", statusCode, endpoint);
|
||||
}
|
||||
|
||||
// Handle 204 No Content responses (e.g., /sessions/playing, /sessions/playing/progress)
|
||||
@@ -412,13 +463,6 @@ public class JellyfinProxyService
|
||||
return (null, statusCode);
|
||||
}
|
||||
|
||||
// Log response content for session endpoints
|
||||
if (endpoint.Contains("Sessions", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(responseContent))
|
||||
{
|
||||
var preview = responseContent.Length > 200 ? responseContent[..200] + "..." : responseContent;
|
||||
_logger.LogWarning("📥 SESSION: Jellyfin response body: {Body}", preview);
|
||||
}
|
||||
|
||||
return (JsonDocument.Parse(responseContent), statusCode);
|
||||
}
|
||||
|
||||
|
||||
@@ -263,9 +263,11 @@ public class JellyfinResponseBuilder
|
||||
["Name"] = songTitle,
|
||||
["ServerId"] = "allstarr",
|
||||
["Id"] = song.Id,
|
||||
["PlaylistItemId"] = song.Id, // Required for playlist items
|
||||
["HasLyrics"] = false, // Could be enhanced to check if lyrics exist
|
||||
["Container"] = "flac",
|
||||
["PremiereDate"] = song.Year.HasValue ? $"{song.Year}-01-01T00:00:00.0000000Z" : null,
|
||||
["DateCreated"] = song.Year.HasValue ? $"{song.Year}-01-01T00:00:00.0000000Z" : DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ"),
|
||||
["RunTimeTicks"] = (song.Duration ?? 0) * TimeSpan.TicksPerSecond,
|
||||
["ProductionYear"] = song.Year,
|
||||
["IndexNumber"] = song.Track,
|
||||
@@ -273,6 +275,7 @@ public class JellyfinResponseBuilder
|
||||
["IsFolder"] = false,
|
||||
["Type"] = "Audio",
|
||||
["ChannelId"] = (object?)null,
|
||||
["ParentId"] = song.AlbumId,
|
||||
["Genres"] = !string.IsNullOrEmpty(song.Genre)
|
||||
? new[] { song.Genre }
|
||||
: new string[0],
|
||||
@@ -286,6 +289,9 @@ public class JellyfinResponseBuilder
|
||||
}
|
||||
}
|
||||
: new Dictionary<string, object?>[0],
|
||||
["Tags"] = new string[0],
|
||||
["People"] = new object[0],
|
||||
["SortName"] = songTitle,
|
||||
["ParentLogoItemId"] = song.AlbumId,
|
||||
["ParentBackdropItemId"] = song.AlbumId,
|
||||
["ParentBackdropImageTags"] = new string[0],
|
||||
@@ -299,13 +305,11 @@ public class JellyfinResponseBuilder
|
||||
["ItemId"] = song.Id
|
||||
},
|
||||
["Artists"] = artistNames.Count > 0 ? artistNames.ToArray() : new[] { artistName ?? "" },
|
||||
["ArtistItems"] = artistNames.Count > 0
|
||||
["ArtistItems"] = artistNames.Count > 0 && song.ArtistIds.Count == artistNames.Count
|
||||
? artistNames.Select((name, index) => new Dictionary<string, object?>
|
||||
{
|
||||
["Name"] = name,
|
||||
["Id"] = index == 0 && song.ArtistId != null
|
||||
? song.ArtistId
|
||||
: $"{song.Id}-artist-{index}"
|
||||
["Id"] = song.ArtistIds[index]
|
||||
}).ToArray()
|
||||
: new[]
|
||||
{
|
||||
|
||||
@@ -38,12 +38,13 @@ public class JellyfinSessionManager : IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Ensures a session exists for the given device. Creates one if needed.
|
||||
/// Returns false if token is expired (401), indicating client needs to re-authenticate.
|
||||
/// </summary>
|
||||
public async Task<bool> EnsureSessionAsync(string deviceId, string client, string device, string version, IHeaderDictionary headers)
|
||||
{
|
||||
if (string.IsNullOrEmpty(deviceId))
|
||||
{
|
||||
_logger.LogWarning("⚠️ SESSION: Cannot create session - no device ID");
|
||||
_logger.LogWarning("Cannot create session - no device ID");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -51,27 +52,43 @@ public class JellyfinSessionManager : IDisposable
|
||||
if (_sessions.TryGetValue(deviceId, out var existingSession))
|
||||
{
|
||||
existingSession.LastActivity = DateTime.UtcNow;
|
||||
_logger.LogDebug("✓ SESSION: Session already exists for device {DeviceId}", deviceId);
|
||||
_logger.LogTrace("Session already exists for device {DeviceId}", deviceId);
|
||||
|
||||
// Refresh capabilities to keep session alive
|
||||
await PostCapabilitiesAsync(headers);
|
||||
// If this returns false (401), the token expired and client needs to re-auth
|
||||
var success = await PostCapabilitiesAsync(headers);
|
||||
if (!success)
|
||||
{
|
||||
// Token expired - remove the stale session
|
||||
_logger.LogInformation("Token expired for device {DeviceId} - removing session", deviceId);
|
||||
await RemoveSessionAsync(deviceId);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
_logger.LogInformation("🔧 SESSION: Creating new session for device: {DeviceId} ({Client} on {Device})", deviceId, client, device);
|
||||
|
||||
// Log the headers we received for debugging
|
||||
_logger.LogDebug("🔍 SESSION: Headers received for session creation: {Headers}",
|
||||
string.Join(", ", headers.Select(h => $"{h.Key}={h.Value.ToString().Substring(0, Math.Min(30, h.Value.ToString().Length))}...")));
|
||||
_logger.LogDebug("Creating new session for device: {DeviceId} ({Client} on {Device})", deviceId, client, device);
|
||||
|
||||
try
|
||||
{
|
||||
// Post session capabilities to Jellyfin - this creates the session
|
||||
await PostCapabilitiesAsync(headers);
|
||||
var success = await PostCapabilitiesAsync(headers);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
// Token expired or invalid - client needs to re-authenticate
|
||||
_logger.LogInformation("Failed to create session for {DeviceId} - token may be expired", deviceId);
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.LogInformation("✓ SESSION: Session created for {DeviceId}", deviceId);
|
||||
_logger.LogDebug("Session created for {DeviceId}", deviceId);
|
||||
|
||||
// Track this session
|
||||
var clientIp = headers["X-Forwarded-For"].FirstOrDefault()?.Split(',')[0].Trim()
|
||||
?? headers["X-Real-IP"].FirstOrDefault()
|
||||
?? "Unknown";
|
||||
|
||||
_sessions[deviceId] = new SessionInfo
|
||||
{
|
||||
DeviceId = deviceId,
|
||||
@@ -79,7 +96,8 @@ public class JellyfinSessionManager : IDisposable
|
||||
Device = device,
|
||||
Version = version,
|
||||
LastActivity = DateTime.UtcNow,
|
||||
Headers = CloneHeaders(headers)
|
||||
Headers = CloneHeaders(headers),
|
||||
ClientIp = clientIp
|
||||
};
|
||||
|
||||
// Start a WebSocket connection to Jellyfin on behalf of this client
|
||||
@@ -89,15 +107,16 @@ public class JellyfinSessionManager : IDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "❌ SESSION: Error creating session for {DeviceId}", deviceId);
|
||||
_logger.LogError(ex, "Error creating session for {DeviceId}", deviceId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Posts session capabilities to Jellyfin.
|
||||
/// Returns true if successful, false if token expired (401).
|
||||
/// </summary>
|
||||
private async Task PostCapabilitiesAsync(IHeaderDictionary headers)
|
||||
private async Task<bool> PostCapabilitiesAsync(IHeaderDictionary headers)
|
||||
{
|
||||
var capabilities = new
|
||||
{
|
||||
@@ -118,12 +137,19 @@ public class JellyfinSessionManager : IDisposable
|
||||
|
||||
if (statusCode == 204 || statusCode == 200)
|
||||
{
|
||||
_logger.LogDebug("✓ SESSION: Posted capabilities successfully ({StatusCode})", statusCode);
|
||||
_logger.LogTrace("Posted capabilities successfully ({StatusCode})", statusCode);
|
||||
return true;
|
||||
}
|
||||
else if (statusCode == 401)
|
||||
{
|
||||
// Token expired - this is expected, client needs to re-authenticate
|
||||
_logger.LogDebug("Capabilities returned 401 (token expired) - client should re-authenticate");
|
||||
return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 401 is common when cached headers have expired - not a critical error
|
||||
_logger.LogDebug("SESSION: Capabilities post returned {StatusCode} (may be expected if token expired)", statusCode);
|
||||
_logger.LogDebug("Capabilities post returned {StatusCode}", statusCode);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,7 +204,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
if (_sessions.TryGetValue(deviceId, out var currentSession) &&
|
||||
currentSession.LastActivity <= markedTime)
|
||||
{
|
||||
_logger.LogInformation("🧹 SESSION: Auto-removing inactive session {DeviceId} after playback stop", deviceId);
|
||||
_logger.LogDebug("🧹 SESSION: Auto-removing inactive session {DeviceId} after playback stop", deviceId);
|
||||
await RemoveSessionAsync(deviceId);
|
||||
}
|
||||
else
|
||||
@@ -201,6 +227,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
Client = s.Client,
|
||||
Device = s.Device,
|
||||
Version = s.Version,
|
||||
ClientIp = s.ClientIp,
|
||||
LastActivity = s.LastActivity,
|
||||
InactiveMinutes = Math.Round((now - s.LastActivity).TotalMinutes, 1),
|
||||
HasWebSocket = s.WebSocket != null,
|
||||
@@ -223,7 +250,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
{
|
||||
if (_sessions.TryRemove(deviceId, out var session))
|
||||
{
|
||||
_logger.LogInformation("🗑️ SESSION: Removing session for device {DeviceId}", deviceId);
|
||||
_logger.LogDebug("🗑️ SESSION: Removing session for device {DeviceId}", deviceId);
|
||||
|
||||
// Close WebSocket if it exists
|
||||
if (session.WebSocket != null && session.WebSocket.State == WebSocketState.Open)
|
||||
@@ -235,7 +262,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "⚠️ WEBSOCKET: Error closing WebSocket for {DeviceId}", deviceId);
|
||||
_logger.LogDebug(ex, "WEBSOCKET: Error closing WebSocket for {DeviceId}", deviceId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -255,7 +282,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
};
|
||||
var stopJson = JsonSerializer.Serialize(stopPayload);
|
||||
await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", stopJson, session.Headers);
|
||||
_logger.LogInformation("🛑 SESSION: Reported playback stopped for {DeviceId} (ItemId: {ItemId}, Position: {Position})",
|
||||
_logger.LogDebug("🛑 SESSION: Reported playback stopped for {DeviceId} (ItemId: {ItemId}, Position: {Position})",
|
||||
deviceId, session.LastPlayingItemId, session.LastPlayingPositionTicks);
|
||||
}
|
||||
|
||||
@@ -339,11 +366,11 @@ public class JellyfinSessionManager : IDisposable
|
||||
if (!string.IsNullOrEmpty(_settings.ApiKey))
|
||||
{
|
||||
jellyfinWsUrl += $"?api_key={_settings.ApiKey}";
|
||||
_logger.LogWarning("⚠️ WEBSOCKET: No client auth found in headers, falling back to server API key for {DeviceId}", deviceId);
|
||||
_logger.LogDebug("WEBSOCKET: No client auth found in headers, falling back to server API key for {DeviceId}", deviceId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("❌ WEBSOCKET: No authentication available for {DeviceId}!", deviceId);
|
||||
_logger.LogWarning("❌ WEBSOCKET: No authentication available for {DeviceId} - WebSocket will fail", deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,7 +381,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
|
||||
// Connect to Jellyfin
|
||||
await webSocket.ConnectAsync(new Uri(jellyfinWsUrl), CancellationToken.None);
|
||||
_logger.LogInformation("✓ WEBSOCKET: Connected to Jellyfin for device {DeviceId}", deviceId);
|
||||
_logger.LogDebug("✓ WEBSOCKET: Connected to Jellyfin for device {DeviceId}", deviceId);
|
||||
|
||||
// CRITICAL: Send ForceKeepAlive message to initialize session in Jellyfin
|
||||
// This tells Jellyfin to create/show the session in the dashboard
|
||||
@@ -410,8 +437,8 @@ public class JellyfinSessionManager : IDisposable
|
||||
}
|
||||
else
|
||||
{
|
||||
// Log other message types at info level
|
||||
_logger.LogInformation("📥 WEBSOCKET: {DeviceId}: {Message}",
|
||||
// Log other message types at trace level
|
||||
_logger.LogTrace("📥 WEBSOCKET: {DeviceId}: {Message}",
|
||||
deviceId, message.Length > 100 ? message[..100] + "..." : message);
|
||||
}
|
||||
}
|
||||
@@ -433,7 +460,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
}
|
||||
catch (WebSocketException wsEx)
|
||||
{
|
||||
_logger.LogWarning(wsEx, "⚠️ WEBSOCKET: WebSocket error for device {DeviceId}", deviceId);
|
||||
_logger.LogDebug(wsEx, "WEBSOCKET: Connection closed for device {DeviceId}", deviceId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -469,6 +496,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
/// <summary>
|
||||
/// Periodically pings Jellyfin to keep sessions alive.
|
||||
/// Note: This is a backup mechanism. The WebSocket connection is the primary keep-alive.
|
||||
/// Removes sessions with expired tokens (401 responses).
|
||||
/// </summary>
|
||||
private async void KeepSessionsAlive(object? state)
|
||||
{
|
||||
@@ -480,29 +508,43 @@ public class JellyfinSessionManager : IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("💓 SESSION: Keeping {Count} sessions alive", activeSessions.Count);
|
||||
_logger.LogTrace("Keeping {Count} sessions alive", activeSessions.Count);
|
||||
|
||||
var expiredSessions = new List<string>();
|
||||
|
||||
foreach (var session in activeSessions)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Post capabilities again to keep session alive
|
||||
// Note: This may fail with 401 if the client's token has expired
|
||||
// That's okay - the WebSocket connection keeps the session alive anyway
|
||||
await PostCapabilitiesAsync(session.Headers);
|
||||
// If this returns false (401), the token has expired
|
||||
var success = await PostCapabilitiesAsync(session.Headers);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
_logger.LogInformation("Token expired for device {DeviceId} during keep-alive - marking for removal", session.DeviceId);
|
||||
expiredSessions.Add(session.DeviceId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "SESSION: Error keeping session alive for {DeviceId} (WebSocket still active)", session.DeviceId);
|
||||
_logger.LogDebug(ex, "Error keeping session alive for {DeviceId}", session.DeviceId);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove sessions with expired tokens
|
||||
foreach (var deviceId in expiredSessions)
|
||||
{
|
||||
_logger.LogInformation("Removing session with expired token: {DeviceId}", deviceId);
|
||||
await RemoveSessionAsync(deviceId);
|
||||
}
|
||||
|
||||
// Clean up stale sessions after 3 minutes of inactivity
|
||||
// This balances cleaning up finished sessions with allowing brief pauses/network issues
|
||||
var staleSessions = _sessions.Where(kvp => now - kvp.Value.LastActivity > TimeSpan.FromMinutes(3)).ToList();
|
||||
foreach (var stale in staleSessions)
|
||||
{
|
||||
_logger.LogInformation("🧹 SESSION: Removing stale session for {DeviceId} (inactive for {Minutes:F1} minutes)",
|
||||
_logger.LogDebug("Removing stale session for {DeviceId} (inactive for {Minutes:F1} minutes)",
|
||||
stale.Key, (now - stale.Value.LastActivity).TotalMinutes);
|
||||
await RemoveSessionAsync(stale.Key);
|
||||
}
|
||||
@@ -529,6 +571,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
public ClientWebSocket? WebSocket { get; set; }
|
||||
public string? LastPlayingItemId { get; set; }
|
||||
public long? LastPlayingPositionTicks { get; set; }
|
||||
public string? ClientIp { get; set; }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Models.Download;
|
||||
using allstarr.Models.Search;
|
||||
using allstarr.Models.Subsonic;
|
||||
using allstarr.Services;
|
||||
|
||||
namespace allstarr.Services.Local;
|
||||
|
||||
/// <summary>
|
||||
/// Local library service implementation
|
||||
/// Uses a simple JSON file to store mappings (can be replaced with a database)
|
||||
/// </summary>
|
||||
public class LocalLibraryService : ILocalLibraryService
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Models.Download;
|
||||
using allstarr.Models.Search;
|
||||
using allstarr.Models.Subsonic;
|
||||
using allstarr.Services;
|
||||
|
||||
namespace allstarr.Services.Local;
|
||||
|
||||
/// <summary>
|
||||
/// Local library service implementation
|
||||
/// Uses a simple JSON file to store mappings (can be replaced with a database)
|
||||
/// </summary>
|
||||
public class LocalLibraryService : ILocalLibraryService
|
||||
{
|
||||
private readonly string _mappingFilePath;
|
||||
private readonly string _downloadDirectory;
|
||||
|
||||
@@ -18,7 +18,7 @@ public class LrclibService
|
||||
ILogger<LrclibService> logger)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0.0 (https://github.com/SoPat712/allstarr)");
|
||||
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.3.0 (https://github.com/SoPat712/allstarr)");
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
228
allstarr/Services/Lyrics/LyricsOrchestrator.cs
Normal file
228
allstarr/Services/Lyrics/LyricsOrchestrator.cs
Normal file
@@ -0,0 +1,228 @@
|
||||
using allstarr.Models.Lyrics;
|
||||
using allstarr.Models.Settings;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace allstarr.Services.Lyrics;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates lyrics fetching from multiple sources with priority-based fallback.
|
||||
/// Priority order: Spotify → LyricsPlus → LRCLib
|
||||
/// Note: Jellyfin local lyrics are handled by the controller before calling this orchestrator.
|
||||
/// </summary>
|
||||
public class LyricsOrchestrator
|
||||
{
|
||||
private readonly SpotifyLyricsService _spotifyLyrics;
|
||||
private readonly LyricsPlusService _lyricsPlus;
|
||||
private readonly LrclibService _lrclib;
|
||||
private readonly SpotifyApiSettings _spotifySettings;
|
||||
private readonly ILogger<LyricsOrchestrator> _logger;
|
||||
|
||||
public LyricsOrchestrator(
|
||||
SpotifyLyricsService spotifyLyrics,
|
||||
LyricsPlusService lyricsPlus,
|
||||
LrclibService lrclib,
|
||||
IOptions<SpotifyApiSettings> spotifySettings,
|
||||
ILogger<LyricsOrchestrator> logger)
|
||||
{
|
||||
_spotifyLyrics = spotifyLyrics;
|
||||
_lyricsPlus = lyricsPlus;
|
||||
_lrclib = lrclib;
|
||||
_spotifySettings = spotifySettings.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches lyrics with automatic fallback through all available sources.
|
||||
/// Note: Jellyfin local lyrics are handled by the controller before calling this.
|
||||
/// </summary>
|
||||
/// <param name="trackName">Track title</param>
|
||||
/// <param name="artistNames">Artist names (can be multiple)</param>
|
||||
/// <param name="albumName">Album name</param>
|
||||
/// <param name="durationSeconds">Track duration in seconds</param>
|
||||
/// <param name="spotifyTrackId">Spotify track ID (if available)</param>
|
||||
/// <returns>Lyrics info or null if not found</returns>
|
||||
public async Task<LyricsInfo?> GetLyricsAsync(
|
||||
string trackName,
|
||||
string[] artistNames,
|
||||
string? albumName,
|
||||
int durationSeconds,
|
||||
string? spotifyTrackId = null)
|
||||
{
|
||||
var artistName = string.Join(", ", artistNames);
|
||||
|
||||
_logger.LogInformation("🎵 Fetching lyrics for: {Artist} - {Track}", artistName, trackName);
|
||||
|
||||
// 1. Try Spotify lyrics (if Spotify ID provided)
|
||||
if (!string.IsNullOrEmpty(spotifyTrackId))
|
||||
{
|
||||
var spotifyLyrics = await TrySpotifyLyrics(spotifyTrackId, artistName, trackName);
|
||||
if (spotifyLyrics != null)
|
||||
{
|
||||
return spotifyLyrics;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try LyricsPlus
|
||||
var lyricsPlusLyrics = await TryLyricsPlusLyrics(trackName, artistNames, albumName, durationSeconds, artistName);
|
||||
if (lyricsPlusLyrics != null)
|
||||
{
|
||||
return lyricsPlusLyrics;
|
||||
}
|
||||
|
||||
// 3. Try LRCLib
|
||||
var lrclibLyrics = await TryLrclibLyrics(trackName, artistNames, albumName, durationSeconds, artistName);
|
||||
if (lrclibLyrics != null)
|
||||
{
|
||||
return lrclibLyrics;
|
||||
}
|
||||
|
||||
_logger.LogInformation("❌ No lyrics found for: {Artist} - {Track}", artistName, trackName);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prefetches lyrics in the background (for cache warming).
|
||||
/// Skips Jellyfin local since we don't have an itemId.
|
||||
/// </summary>
|
||||
public async Task<bool> PrefetchLyricsAsync(
|
||||
string trackName,
|
||||
string[] artistNames,
|
||||
string? albumName,
|
||||
int durationSeconds,
|
||||
string? spotifyTrackId = null)
|
||||
{
|
||||
var artistName = string.Join(", ", artistNames);
|
||||
|
||||
_logger.LogDebug("🎵 Prefetching lyrics for: {Artist} - {Track}", artistName, trackName);
|
||||
|
||||
// 1. Try Spotify lyrics (if Spotify ID provided)
|
||||
if (!string.IsNullOrEmpty(spotifyTrackId))
|
||||
{
|
||||
var spotifyLyrics = await TrySpotifyLyrics(spotifyTrackId, artistName, trackName);
|
||||
if (spotifyLyrics != null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try LyricsPlus
|
||||
var lyricsPlusLyrics = await TryLyricsPlusLyrics(trackName, artistNames, albumName, durationSeconds, artistName);
|
||||
if (lyricsPlusLyrics != null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3. Try LRCLib
|
||||
var lrclibLyrics = await TryLrclibLyrics(trackName, artistNames, albumName, durationSeconds, artistName);
|
||||
if (lrclibLyrics != null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
_logger.LogDebug("No lyrics found for prefetch: {Artist} - {Track}", artistName, trackName);
|
||||
return false;
|
||||
}
|
||||
|
||||
#region Private Helper Methods
|
||||
|
||||
private async Task<LyricsInfo?> TrySpotifyLyrics(string spotifyTrackId, string artistName, string trackName)
|
||||
{
|
||||
if (!_spotifySettings.Enabled)
|
||||
{
|
||||
_logger.LogDebug("Spotify API not enabled, skipping Spotify lyrics");
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Validate Spotify ID format
|
||||
var cleanSpotifyId = spotifyTrackId.Replace("spotify:track:", "").Trim();
|
||||
|
||||
if (cleanSpotifyId.Length != 22 || cleanSpotifyId.Contains(":") || cleanSpotifyId.Contains("local"))
|
||||
{
|
||||
_logger.LogDebug("Invalid Spotify ID format: {SpotifyId}, skipping", spotifyTrackId);
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogDebug("→ Trying Spotify lyrics for track ID: {SpotifyId}", cleanSpotifyId);
|
||||
|
||||
var spotifyLyrics = await _spotifyLyrics.GetLyricsByTrackIdAsync(cleanSpotifyId);
|
||||
|
||||
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
|
||||
{
|
||||
_logger.LogInformation("✓ Found Spotify lyrics for {Artist} - {Track} ({LineCount} lines, type: {SyncType})",
|
||||
artistName, trackName, spotifyLyrics.Lines.Count, spotifyLyrics.SyncType);
|
||||
|
||||
return _spotifyLyrics.ToLyricsInfo(spotifyLyrics);
|
||||
}
|
||||
|
||||
_logger.LogDebug("No Spotify lyrics found for track ID {SpotifyId}", cleanSpotifyId);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error fetching Spotify lyrics for track ID {SpotifyId}", spotifyTrackId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<LyricsInfo?> TryLyricsPlusLyrics(
|
||||
string trackName,
|
||||
string[] artistNames,
|
||||
string? albumName,
|
||||
int durationSeconds,
|
||||
string artistName)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("→ Trying LyricsPlus for: {Artist} - {Track}", artistName, trackName);
|
||||
|
||||
var lyrics = await _lyricsPlus.GetLyricsAsync(trackName, artistNames, albumName, durationSeconds);
|
||||
|
||||
if (lyrics != null)
|
||||
{
|
||||
_logger.LogInformation("✓ Found LyricsPlus lyrics for {Artist} - {Track}", artistName, trackName);
|
||||
return lyrics;
|
||||
}
|
||||
|
||||
_logger.LogDebug("No LyricsPlus lyrics found for {Artist} - {Track}", artistName, trackName);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error fetching LyricsPlus lyrics for {Artist} - {Track}", artistName, trackName);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<LyricsInfo?> TryLrclibLyrics(
|
||||
string trackName,
|
||||
string[] artistNames,
|
||||
string? albumName,
|
||||
int durationSeconds,
|
||||
string artistName)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("→ Trying LRCLib for: {Artist} - {Track}", artistName, trackName);
|
||||
|
||||
var lyrics = await _lrclib.GetLyricsAsync(trackName, artistNames, albumName ?? string.Empty, durationSeconds);
|
||||
|
||||
if (lyrics != null)
|
||||
{
|
||||
_logger.LogInformation("✓ Found LRCLib lyrics for {Artist} - {Track}", artistName, trackName);
|
||||
return lyrics;
|
||||
}
|
||||
|
||||
_logger.LogDebug("No LRCLib lyrics found for {Artist} - {Track}", artistName, trackName);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error fetching LRCLib lyrics for {Artist} - {Track}", artistName, trackName);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
254
allstarr/Services/Lyrics/LyricsPlusService.cs
Normal file
254
allstarr/Services/Lyrics/LyricsPlusService.cs
Normal file
@@ -0,0 +1,254 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using allstarr.Models.Lyrics;
|
||||
using allstarr.Services.Common;
|
||||
|
||||
namespace allstarr.Services.Lyrics;
|
||||
|
||||
/// <summary>
|
||||
/// Service for fetching lyrics from LyricsPlus API (https://lyricsplus.prjktla.workers.dev)
|
||||
/// Supports multiple sources: Apple Music, Spotify, Musixmatch, and more
|
||||
/// </summary>
|
||||
public class LyricsPlusService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly ILogger<LyricsPlusService> _logger;
|
||||
private const string BaseUrl = "https://lyricsplus.prjktla.workers.dev/v2/lyrics/get";
|
||||
|
||||
public LyricsPlusService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
RedisCacheService cache,
|
||||
ILogger<LyricsPlusService> logger)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.3.0 (https://github.com/SoPat712/allstarr)");
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
// Validate input parameters
|
||||
if (string.IsNullOrWhiteSpace(trackName) || artistNames == null || artistNames.Length == 0)
|
||||
{
|
||||
_logger.LogDebug("Invalid parameters for LyricsPlus search: trackName={TrackName}, artistCount={ArtistCount}",
|
||||
trackName, artistNames?.Length ?? 0);
|
||||
return null;
|
||||
}
|
||||
|
||||
var artistName = string.Join(", ", artistNames);
|
||||
var cacheKey = $"lyricsplus:{artistName}:{trackName}:{albumName}:{durationSeconds}";
|
||||
|
||||
// Check cache
|
||||
var cached = await _cache.GetStringAsync(cacheKey);
|
||||
if (!string.IsNullOrEmpty(cached))
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<LyricsInfo>(cached, JsonOptions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to deserialize cached LyricsPlus lyrics");
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Build URL with query parameters
|
||||
var url = $"{BaseUrl}?title={Uri.EscapeDataString(trackName)}&artist={Uri.EscapeDataString(artistName)}";
|
||||
|
||||
if (!string.IsNullOrEmpty(albumName))
|
||||
{
|
||||
url += $"&album={Uri.EscapeDataString(albumName)}";
|
||||
}
|
||||
|
||||
if (durationSeconds > 0)
|
||||
{
|
||||
url += $"&duration={durationSeconds}";
|
||||
}
|
||||
|
||||
// Add sources: apple, lyricsplus, musixmatch, spotify, musixmatch-word
|
||||
url += "&source=apple,lyricsplus,musixmatch,spotify,musixmatch-word";
|
||||
|
||||
_logger.LogDebug("Fetching lyrics from LyricsPlus: {Url}", url);
|
||||
|
||||
var response = await _httpClient.GetAsync(url);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
_logger.LogDebug("Lyrics not found on LyricsPlus for {Artist} - {Track}", artistName, trackName);
|
||||
return null;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var lyricsResponse = JsonSerializer.Deserialize<LyricsPlusResponse>(json, JsonOptions);
|
||||
|
||||
if (lyricsResponse == null || lyricsResponse.Lyrics == null || lyricsResponse.Lyrics.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("Empty lyrics response from LyricsPlus for {Artist} - {Track}", artistName, trackName);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert to LyricsInfo format
|
||||
var result = ConvertToLyricsInfo(lyricsResponse, trackName, artistName, albumName, durationSeconds);
|
||||
|
||||
if (result != null)
|
||||
{
|
||||
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(result, JsonOptions), TimeSpan.FromDays(30));
|
||||
_logger.LogInformation("✓ Retrieved lyrics from LyricsPlus for {Artist} - {Track} (type: {Type}, source: {Source})",
|
||||
artistName, trackName, lyricsResponse.Type, lyricsResponse.Metadata?.Source);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch lyrics from LyricsPlus for {Artist} - {Track}", artistName, trackName);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching lyrics from LyricsPlus for {Artist} - {Track}", artistName, trackName);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private LyricsInfo? ConvertToLyricsInfo(LyricsPlusResponse response, string trackName, string artistName, string? albumName, int durationSeconds)
|
||||
{
|
||||
if (response.Lyrics == null || response.Lyrics.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string? syncedLyrics = null;
|
||||
string? plainLyrics = null;
|
||||
|
||||
// Convert based on type
|
||||
if (response.Type == "Word")
|
||||
{
|
||||
// Word-level timing - convert to line-level LRC
|
||||
syncedLyrics = ConvertWordTimingToLrc(response.Lyrics);
|
||||
plainLyrics = string.Join("\n", response.Lyrics.Select(l => l.Text));
|
||||
}
|
||||
else if (response.Type == "Line")
|
||||
{
|
||||
// Line-level timing - convert to LRC
|
||||
syncedLyrics = ConvertLineTimingToLrc(response.Lyrics);
|
||||
plainLyrics = string.Join("\n", response.Lyrics.Select(l => l.Text));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Static or unknown type - just plain text
|
||||
plainLyrics = string.Join("\n", response.Lyrics.Select(l => l.Text));
|
||||
}
|
||||
|
||||
return new LyricsInfo
|
||||
{
|
||||
TrackName = trackName,
|
||||
ArtistName = artistName,
|
||||
AlbumName = albumName ?? string.Empty,
|
||||
Duration = durationSeconds,
|
||||
Instrumental = false,
|
||||
PlainLyrics = plainLyrics,
|
||||
SyncedLyrics = syncedLyrics
|
||||
};
|
||||
}
|
||||
|
||||
private string ConvertLineTimingToLrc(List<LyricsPlusLine> lines)
|
||||
{
|
||||
var lrcLines = new List<string>();
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (line.Time.HasValue)
|
||||
{
|
||||
var timestamp = TimeSpan.FromMilliseconds(line.Time.Value);
|
||||
var mm = (int)timestamp.TotalMinutes;
|
||||
var ss = timestamp.Seconds;
|
||||
var cs = timestamp.Milliseconds / 10; // Convert to centiseconds
|
||||
|
||||
lrcLines.Add($"[{mm:D2}:{ss:D2}.{cs:D2}]{line.Text}");
|
||||
}
|
||||
else
|
||||
{
|
||||
// No timing, just add the text
|
||||
lrcLines.Add(line.Text);
|
||||
}
|
||||
}
|
||||
|
||||
return string.Join("\n", lrcLines);
|
||||
}
|
||||
|
||||
private string ConvertWordTimingToLrc(List<LyricsPlusLine> lines)
|
||||
{
|
||||
// For word-level timing, we use the line start time
|
||||
// (word-level detail is in syllabus array but we simplify to line-level for LRC)
|
||||
return ConvertLineTimingToLrc(lines);
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private class LyricsPlusResponse
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; } = string.Empty; // "Word", "Line", or "Static"
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public LyricsPlusMetadata? Metadata { get; set; }
|
||||
|
||||
[JsonPropertyName("lyrics")]
|
||||
public List<LyricsPlusLine> Lyrics { get; set; } = new();
|
||||
}
|
||||
|
||||
private class LyricsPlusMetadata
|
||||
{
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; set; }
|
||||
|
||||
[JsonPropertyName("title")]
|
||||
public string? Title { get; set; }
|
||||
|
||||
[JsonPropertyName("language")]
|
||||
public string? Language { get; set; }
|
||||
}
|
||||
|
||||
private class LyricsPlusLine
|
||||
{
|
||||
[JsonPropertyName("time")]
|
||||
public long? Time { get; set; } // Milliseconds
|
||||
|
||||
[JsonPropertyName("duration")]
|
||||
public long? Duration { get; set; }
|
||||
|
||||
[JsonPropertyName("text")]
|
||||
public string Text { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("syllabus")]
|
||||
public List<LyricsPlusSyllable>? Syllabus { get; set; }
|
||||
}
|
||||
|
||||
private class LyricsPlusSyllable
|
||||
{
|
||||
[JsonPropertyName("time")]
|
||||
public long Time { get; set; }
|
||||
|
||||
[JsonPropertyName("duration")]
|
||||
public long Duration { get; set; }
|
||||
|
||||
[JsonPropertyName("text")]
|
||||
public string Text { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -167,16 +167,14 @@ public class LyricsStartupValidator : BaseStartupValidator
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(_spotifySettings.ClientId))
|
||||
if (!_spotifySettings.Enabled)
|
||||
{
|
||||
WriteStatus("Spotify API", "NOT CONFIGURED", ConsoleColor.Yellow);
|
||||
WriteDetail("Set SpotifyApi__ClientId to enable");
|
||||
WriteStatus("Spotify API", "DISABLED", ConsoleColor.Gray);
|
||||
return true;
|
||||
}
|
||||
|
||||
WriteStatus("Spotify API", "CONFIGURED", ConsoleColor.Green);
|
||||
WriteDetail($"Client ID: {_spotifySettings.ClientId.Substring(0, Math.Min(8, _spotifySettings.ClientId.Length))}...");
|
||||
WriteDetail("Note: Spotify API is used for track matching, not lyrics");
|
||||
WriteDetail("Note: Spotify API is used for track matching and lyrics");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -25,7 +25,7 @@ public class MusicBrainzService
|
||||
ILogger<MusicBrainzService> logger)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0.0 (https://github.com/SoPat712/allstarr)");
|
||||
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.3.0 (https://github.com/SoPat712/allstarr)");
|
||||
_httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
|
||||
_settings = settings.Value;
|
||||
@@ -92,6 +92,7 @@ public class MusicBrainzService
|
||||
|
||||
/// <summary>
|
||||
/// Searches for recordings by title and artist.
|
||||
/// Note: Search API doesn't return genres, only MBIDs. Use LookupByMbidAsync to get genres.
|
||||
/// </summary>
|
||||
public async Task<List<MusicBrainzRecording>> SearchRecordingsAsync(string title, string artist, int limit = 5)
|
||||
{
|
||||
@@ -107,7 +108,8 @@ public class MusicBrainzService
|
||||
// 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";
|
||||
// Note: Search API doesn't support inc=genres, only returns basic info + MBIDs
|
||||
var url = $"{_settings.BaseUrl}/recording?query={encodedQuery}&fmt=json&limit={limit}";
|
||||
|
||||
_logger.LogDebug("MusicBrainz search: {Url}", url);
|
||||
|
||||
@@ -140,9 +142,56 @@ public class MusicBrainzService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a recording by MBID to get full details including genres.
|
||||
/// </summary>
|
||||
public async Task<MusicBrainzRecording?> LookupByMbidAsync(string mbid)
|
||||
{
|
||||
if (!_settings.Enabled)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await RateLimitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var url = $"{_settings.BaseUrl}/recording/{mbid}?fmt=json&inc=artists+releases+release-groups+genres+tags";
|
||||
_logger.LogDebug("MusicBrainz MBID lookup: {Url}", url);
|
||||
|
||||
var response = await _httpClient.GetAsync(url);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("MusicBrainz MBID lookup failed: {StatusCode}", response.StatusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var recording = JsonSerializer.Deserialize<MusicBrainzRecording>(json, JsonOptions);
|
||||
|
||||
if (recording == null)
|
||||
{
|
||||
_logger.LogDebug("No MusicBrainz recording found for MBID: {Mbid}", mbid);
|
||||
return null;
|
||||
}
|
||||
|
||||
var genres = recording.Genres?.Select(g => g.Name).Where(n => !string.IsNullOrEmpty(n)).ToList() ?? new List<string?>();
|
||||
_logger.LogInformation("✓ Found MusicBrainz recording for MBID {Mbid}: {Title} by {Artist} (Genres: {Genres})",
|
||||
mbid, recording.Title, recording.ArtistCredit?[0]?.Name ?? "Unknown", string.Join(", ", genres));
|
||||
|
||||
return recording;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error looking up MBID {Mbid} in MusicBrainz", mbid);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enriches a song with genre information from MusicBrainz.
|
||||
/// First tries ISRC lookup, then falls back to title/artist search.
|
||||
/// First tries ISRC lookup, then falls back to title/artist search + MBID lookup.
|
||||
/// </summary>
|
||||
public async Task<List<string>> GetGenresForSongAsync(string title, string artist, string? isrc = null)
|
||||
{
|
||||
@@ -153,17 +202,23 @@ public class MusicBrainzService
|
||||
|
||||
MusicBrainzRecording? recording = null;
|
||||
|
||||
// Try ISRC lookup first (most accurate)
|
||||
// Try ISRC lookup first (most accurate and includes genres)
|
||||
if (!string.IsNullOrEmpty(isrc))
|
||||
{
|
||||
recording = await LookupByIsrcAsync(isrc);
|
||||
}
|
||||
|
||||
// Fall back to search if ISRC lookup failed or no ISRC provided
|
||||
// Fall back to search + MBID lookup if ISRC lookup failed or no ISRC provided
|
||||
if (recording == null)
|
||||
{
|
||||
var recordings = await SearchRecordingsAsync(title, artist, limit: 1);
|
||||
recording = recordings.FirstOrDefault();
|
||||
var searchResult = recordings.FirstOrDefault();
|
||||
|
||||
// If we found a recording from search, do a full lookup by MBID to get genres
|
||||
if (searchResult != null && !string.IsNullOrEmpty(searchResult.Id))
|
||||
{
|
||||
recording = await LookupByMbidAsync(searchResult.Id);
|
||||
}
|
||||
}
|
||||
|
||||
if (recording == null)
|
||||
|
||||
@@ -110,10 +110,10 @@ public class QobuzDownloadService : BaseDownloadService
|
||||
|
||||
// Build organized folder structure using AlbumArtist (fallback to Artist for singles)
|
||||
var artistForPath = song.AlbumArtist ?? song.Artist;
|
||||
// Cache mode uses cache/Music folder (cleaned up after 24h), Permanent mode uses downloads folder
|
||||
// Cache mode uses downloads/cache/ folder, Permanent mode uses downloads/permanent/
|
||||
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache
|
||||
? Path.Combine("cache", "Music")
|
||||
: "downloads";
|
||||
? Path.Combine(DownloadPath, "cache")
|
||||
: Path.Combine(DownloadPath, "permanent");
|
||||
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension);
|
||||
|
||||
var albumFolder = Path.GetDirectoryName(outputPath)!;
|
||||
|
||||
@@ -3,6 +3,7 @@ using allstarr.Models.Settings;
|
||||
using allstarr.Models.Download;
|
||||
using allstarr.Models.Search;
|
||||
using allstarr.Models.Subsonic;
|
||||
using allstarr.Services.Common;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
@@ -18,6 +19,7 @@ public class QobuzMetadataService : IMusicMetadataService
|
||||
private readonly SubsonicSettings _settings;
|
||||
private readonly QobuzBundleService _bundleService;
|
||||
private readonly ILogger<QobuzMetadataService> _logger;
|
||||
private readonly GenreEnrichmentService? _genreEnrichment;
|
||||
private readonly string? _userAuthToken;
|
||||
private readonly string? _userId;
|
||||
|
||||
@@ -28,12 +30,14 @@ public class QobuzMetadataService : IMusicMetadataService
|
||||
IOptions<SubsonicSettings> settings,
|
||||
IOptions<QobuzSettings> qobuzSettings,
|
||||
QobuzBundleService bundleService,
|
||||
ILogger<QobuzMetadataService> logger)
|
||||
ILogger<QobuzMetadataService> logger,
|
||||
GenreEnrichmentService? genreEnrichment = null)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_settings = settings.Value;
|
||||
_bundleService = bundleService;
|
||||
_logger = logger;
|
||||
_genreEnrichment = genreEnrichment;
|
||||
|
||||
var qobuzConfig = qobuzSettings.Value;
|
||||
_userAuthToken = qobuzConfig.UserAuthToken;
|
||||
@@ -177,7 +181,26 @@ public class QobuzMetadataService : IMusicMetadataService
|
||||
|
||||
if (track.TryGetProperty("error", out _)) return null;
|
||||
|
||||
return ParseQobuzTrackFull(track);
|
||||
var song = ParseQobuzTrackFull(track);
|
||||
|
||||
// Enrich with MusicBrainz genres if missing
|
||||
if (_genreEnrichment != null && song != null && string.IsNullOrEmpty(song.Genre))
|
||||
{
|
||||
// Fire-and-forget: don't block the response waiting for genre enrichment
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await _genreEnrichment.EnrichSongGenreAsync(song);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to enrich genre for {Title}", song.Title);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return song;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -349,6 +349,17 @@ public class SpotifyApiClient : IDisposable
|
||||
|
||||
var response = await _webApiClient.SendAsync(request, cancellationToken);
|
||||
|
||||
// Handle 429 rate limiting with exponential backoff
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(5);
|
||||
_logger.LogWarning("Spotify rate limit hit (429) when fetching playlist {PlaylistId}. Waiting {Seconds}s before retry...", playlistId, retryAfter.TotalSeconds);
|
||||
await Task.Delay(retryAfter, cancellationToken);
|
||||
|
||||
// Retry the request
|
||||
response = await _webApiClient.SendAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError("Failed to fetch playlist via GraphQL: {StatusCode}", response.StatusCode);
|
||||
@@ -735,6 +746,18 @@ public class SpotifyApiClient : IDisposable
|
||||
public async Task<List<SpotifyPlaylist>> SearchUserPlaylistsAsync(
|
||||
string searchName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await GetUserPlaylistsAsync(searchName, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all playlists from the user's library, optionally filtered by name.
|
||||
/// Uses GraphQL API which is less rate-limited than REST API.
|
||||
/// </summary>
|
||||
/// <param name="searchName">Optional name filter (case-insensitive). If null, returns all playlists.</param>
|
||||
public async Task<List<SpotifyPlaylist>> GetUserPlaylistsAsync(
|
||||
string? searchName = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var token = await GetWebAccessTokenAsync(cancellationToken);
|
||||
if (string.IsNullOrEmpty(token))
|
||||
@@ -744,61 +767,204 @@ public class SpotifyApiClient : IDisposable
|
||||
|
||||
try
|
||||
{
|
||||
// Use GraphQL endpoint instead of REST API to avoid rate limiting
|
||||
// GraphQL is less aggressive with rate limits
|
||||
var playlists = new List<SpotifyPlaylist>();
|
||||
var offset = 0;
|
||||
const int limit = 50;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var url = $"{OfficialApiBase}/me/playlists?offset={offset}&limit={limit}";
|
||||
// GraphQL query to fetch user playlists - using libraryV3 operation
|
||||
var queryParams = new Dictionary<string, string>
|
||||
{
|
||||
{ "operationName", "libraryV3" },
|
||||
{ "variables", $"{{\"filters\":[\"Playlists\",\"By Spotify\"],\"order\":null,\"textFilter\":\"\",\"features\":[\"LIKED_SONGS\",\"YOUR_EPISODES\"],\"offset\":{offset},\"limit\":{limit}}}" },
|
||||
{ "extensions", "{\"persistedQuery\":{\"version\":1,\"sha256Hash\":\"50650f72ea32a99b5b46240bee22fea83024eec302478a9a75cfd05a0814ba99\"}}" }
|
||||
};
|
||||
|
||||
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 _httpClient.SendAsync(request, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode) break;
|
||||
var response = await _webApiClient.SendAsync(request, cancellationToken);
|
||||
|
||||
// Handle 429 rate limiting with exponential backoff
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(5);
|
||||
_logger.LogWarning("Spotify rate limit hit (429) when fetching library playlists. Waiting {Seconds}s before retry...", retryAfter.TotalSeconds);
|
||||
await Task.Delay(retryAfter, cancellationToken);
|
||||
|
||||
// Retry the request
|
||||
response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("GraphQL user playlists request failed: {StatusCode}", response.StatusCode);
|
||||
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)
|
||||
if (!root.TryGetProperty("data", out var data) ||
|
||||
!data.TryGetProperty("me", out var me) ||
|
||||
!me.TryGetProperty("libraryV3", out var library) ||
|
||||
!library.TryGetProperty("items", out var items))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
// Get total count
|
||||
if (library.TryGetProperty("totalCount", out var totalCount))
|
||||
{
|
||||
var total = totalCount.GetInt32();
|
||||
if (total == 0) break;
|
||||
}
|
||||
|
||||
var itemCount = 0;
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
var itemName = item.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
|
||||
itemCount++;
|
||||
|
||||
// Check if name matches (case-insensitive)
|
||||
if (itemName.Contains(searchName, StringComparison.OrdinalIgnoreCase))
|
||||
if (!item.TryGetProperty("item", out var playlistItem) ||
|
||||
!playlistItem.TryGetProperty("data", out var playlist))
|
||||
{
|
||||
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
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check __typename to filter out folders and only include playlists
|
||||
if (playlistItem.TryGetProperty("__typename", out var typename))
|
||||
{
|
||||
var typeStr = typename.GetString();
|
||||
// Skip folders - only process Playlist types
|
||||
if (typeStr != null && typeStr.Contains("Folder", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Get playlist URI/ID
|
||||
string? uri = null;
|
||||
if (playlistItem.TryGetProperty("uri", out var uriProp))
|
||||
{
|
||||
uri = uriProp.GetString();
|
||||
}
|
||||
else if (playlistItem.TryGetProperty("_uri", out var uriProp2))
|
||||
{
|
||||
uri = uriProp2.GetString();
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(uri)) continue;
|
||||
|
||||
// Skip if not a playlist URI (e.g., folders have different URI format)
|
||||
if (!uri.StartsWith("spotify:playlist:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var spotifyId = uri.Replace("spotify:playlist:", "", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var itemName = playlist.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
|
||||
|
||||
// Check if name matches (case-insensitive) - if searchName is provided
|
||||
if (!string.IsNullOrEmpty(searchName) &&
|
||||
!itemName.Contains(searchName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get track count if available - try multiple possible paths
|
||||
var trackCount = 0;
|
||||
if (playlist.TryGetProperty("content", out var content))
|
||||
{
|
||||
if (content.TryGetProperty("totalCount", out var totalTrackCount))
|
||||
{
|
||||
trackCount = totalTrackCount.GetInt32();
|
||||
}
|
||||
}
|
||||
// Fallback: try attributes.itemCount
|
||||
else if (playlist.TryGetProperty("attributes", out var attributes) &&
|
||||
attributes.TryGetProperty("itemCount", out var itemCountProp))
|
||||
{
|
||||
trackCount = itemCountProp.GetInt32();
|
||||
}
|
||||
// Fallback: try totalCount directly
|
||||
else if (playlist.TryGetProperty("totalCount", out var directTotalCount))
|
||||
{
|
||||
trackCount = directTotalCount.GetInt32();
|
||||
}
|
||||
|
||||
// Log if we couldn't find track count for debugging
|
||||
if (trackCount == 0)
|
||||
{
|
||||
_logger.LogDebug("Could not find track count for playlist {Name} (ID: {Id}). Response structure: {Json}",
|
||||
itemName, spotifyId, playlist.GetRawText());
|
||||
}
|
||||
|
||||
// Get owner name
|
||||
string? ownerName = null;
|
||||
if (playlist.TryGetProperty("ownerV2", out var ownerV2) &&
|
||||
ownerV2.TryGetProperty("data", out var ownerData) &&
|
||||
ownerData.TryGetProperty("username", out var ownerNameProp))
|
||||
{
|
||||
ownerName = ownerNameProp.GetString();
|
||||
}
|
||||
|
||||
// Get image URL
|
||||
string? imageUrl = null;
|
||||
if (playlist.TryGetProperty("images", out var images) &&
|
||||
images.TryGetProperty("items", out var imageItems) &&
|
||||
imageItems.GetArrayLength() > 0)
|
||||
{
|
||||
var firstImage = imageItems[0];
|
||||
if (firstImage.TryGetProperty("sources", out var sources) &&
|
||||
sources.GetArrayLength() > 0)
|
||||
{
|
||||
var firstSource = sources[0];
|
||||
if (firstSource.TryGetProperty("url", out var urlProp))
|
||||
{
|
||||
imageUrl = urlProp.GetString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
playlists.Add(new SpotifyPlaylist
|
||||
{
|
||||
SpotifyId = spotifyId,
|
||||
Name = itemName,
|
||||
Description = playlist.TryGetProperty("description", out var desc) ? desc.GetString() : null,
|
||||
TotalTracks = trackCount,
|
||||
OwnerName = ownerName,
|
||||
ImageUrl = imageUrl,
|
||||
SnapshotId = null
|
||||
});
|
||||
}
|
||||
|
||||
if (items.GetArrayLength() < limit) break;
|
||||
if (itemCount < limit) break;
|
||||
offset += limit;
|
||||
|
||||
if (_settings.RateLimitDelayMs > 0)
|
||||
{
|
||||
await Task.Delay(_settings.RateLimitDelayMs, cancellationToken);
|
||||
}
|
||||
// Add delay between pages to avoid rate limiting
|
||||
// Library fetching can be aggressive, so use a longer delay
|
||||
var delayMs = Math.Max(_settings.RateLimitDelayMs, 500); // Minimum 500ms between pages
|
||||
_logger.LogDebug("Waiting {DelayMs}ms before fetching next page of library playlists...", delayMs);
|
||||
await Task.Delay(delayMs, cancellationToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Found {Count} playlists{Filter} via GraphQL",
|
||||
playlists.Count,
|
||||
string.IsNullOrEmpty(searchName) ? "" : $" matching '{searchName}'");
|
||||
return playlists;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error searching user playlists for '{SearchName}'", searchName);
|
||||
_logger.LogError(ex, "Error fetching user playlists{Filter} via GraphQL",
|
||||
string.IsNullOrEmpty(searchName) ? "" : $" matching '{searchName}'");
|
||||
return new List<SpotifyPlaylist>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
public async Task TriggerFetchAsync()
|
||||
{
|
||||
_logger.LogInformation("Manual fetch triggered");
|
||||
await FetchMissingTracksAsync(CancellationToken.None, bypassSyncWindowCheck: true);
|
||||
await FetchMissingTracksAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
@@ -84,20 +84,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
|
||||
_logger.LogInformation("Spotify Import ENABLED");
|
||||
_logger.LogInformation("Configured Playlists: {Count}", _spotifySettings.Value.Playlists.Count);
|
||||
|
||||
// Log the search schedule
|
||||
var settings = _spotifySettings.Value;
|
||||
var syncTime = DateTime.Today
|
||||
.AddHours(settings.SyncStartHour)
|
||||
.AddMinutes(settings.SyncStartMinute);
|
||||
var syncEndTime = syncTime.AddHours(settings.SyncWindowHours);
|
||||
|
||||
_logger.LogInformation("Search Schedule:");
|
||||
_logger.LogInformation(" Plugin sync time: {Time:HH:mm} UTC (configured)", syncTime);
|
||||
_logger.LogInformation(" Search window: {Start:HH:mm} - {End:HH:mm} UTC ({Hours}h window)",
|
||||
syncTime, syncEndTime, settings.SyncWindowHours);
|
||||
_logger.LogInformation(" Will search for new files once per day after sync window ends");
|
||||
_logger.LogInformation(" Background check interval: 5 minutes");
|
||||
_logger.LogInformation("Background check interval: 5 minutes");
|
||||
|
||||
// Fetch playlist names from Jellyfin
|
||||
await LoadPlaylistNamesAsync();
|
||||
@@ -109,7 +96,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
}
|
||||
_logger.LogInformation("========================================");
|
||||
|
||||
// Check if we should run on startup
|
||||
// Run on startup if we don't have cache
|
||||
if (!_hasRunOnce)
|
||||
{
|
||||
var shouldRun = await ShouldRunOnStartupAsync();
|
||||
@@ -118,7 +105,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
_logger.LogInformation("Running initial fetch on startup");
|
||||
try
|
||||
{
|
||||
await FetchMissingTracksAsync(stoppingToken, bypassSyncWindowCheck: true);
|
||||
await FetchMissingTracksAsync(stoppingToken);
|
||||
_hasRunOnce = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -128,21 +115,20 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Skipping startup fetch - already have current files");
|
||||
_logger.LogInformation("Skipping startup fetch - already have cached files");
|
||||
_hasRunOnce = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Background loop - check for new files every 5 minutes
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Only fetch if we're past today's sync window AND we haven't fetched today yet
|
||||
var shouldFetch = await ShouldFetchNowAsync();
|
||||
if (shouldFetch)
|
||||
{
|
||||
await FetchMissingTracksAsync(stoppingToken);
|
||||
_hasRunOnce = true;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -156,42 +142,29 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
|
||||
private async Task<bool> ShouldFetchNowAsync()
|
||||
{
|
||||
var settings = _spotifySettings.Value;
|
||||
// Check if we have recent cache files (within last 24 hours)
|
||||
var now = DateTime.UtcNow;
|
||||
var cacheThreshold = now.AddHours(-24);
|
||||
|
||||
// Calculate today's sync window
|
||||
var todaySync = now.Date
|
||||
.AddHours(settings.SyncStartHour)
|
||||
.AddMinutes(settings.SyncStartMinute);
|
||||
var todaySyncEnd = todaySync.AddHours(settings.SyncWindowHours);
|
||||
|
||||
// Only fetch if we're past today's sync window
|
||||
if (now < todaySyncEnd)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if we already have today's files
|
||||
foreach (var playlistName in _playlistIdToName.Values)
|
||||
{
|
||||
var filePath = GetCacheFilePath(playlistName);
|
||||
|
||||
if (File.Exists(filePath))
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
var fileTime = File.GetLastWriteTimeUtc(filePath);
|
||||
|
||||
// If file is from today's sync or later, we already have it
|
||||
if (fileTime >= todaySync)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// Missing cache file for this playlist
|
||||
return true;
|
||||
}
|
||||
|
||||
// Missing today's file for this playlist
|
||||
return true;
|
||||
var fileTime = File.GetLastWriteTimeUtc(filePath);
|
||||
if (fileTime < cacheThreshold)
|
||||
{
|
||||
// Cache file is older than 24 hours
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// All playlists have today's files
|
||||
// All playlists have recent cache files
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -210,120 +183,43 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
{
|
||||
_logger.LogInformation("=== STARTUP CACHE CHECK ===");
|
||||
|
||||
var settings = _spotifySettings.Value;
|
||||
var now = DateTime.UtcNow;
|
||||
var allPlaylistsHaveCache = true;
|
||||
|
||||
// Calculate today's sync window
|
||||
var todaySync = now.Date
|
||||
.AddHours(settings.SyncStartHour)
|
||||
.AddMinutes(settings.SyncStartMinute);
|
||||
var todaySyncEnd = todaySync.AddHours(settings.SyncWindowHours);
|
||||
|
||||
_logger.LogInformation("Today's sync window: {Start:yyyy-MM-dd HH:mm} - {End:yyyy-MM-dd HH:mm} UTC",
|
||||
todaySync, todaySyncEnd);
|
||||
_logger.LogInformation("Current time: {Now:yyyy-MM-dd HH:mm} UTC", now);
|
||||
|
||||
// If we're still before today's sync window end, we should have yesterday's or today's file
|
||||
// Don't search again until after today's sync window ends
|
||||
if (now < todaySyncEnd)
|
||||
foreach (var playlistName in _playlistIdToName.Values)
|
||||
{
|
||||
_logger.LogInformation("We're before today's sync window end - checking if we have recent cache...");
|
||||
var filePath = GetCacheFilePath(playlistName);
|
||||
var cacheKey = $"spotify:missing:{playlistName}";
|
||||
|
||||
var allPlaylistsHaveCache = true;
|
||||
|
||||
foreach (var playlistName in _playlistIdToName.Values)
|
||||
// Check file cache
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
var filePath = GetCacheFilePath(playlistName);
|
||||
var cacheKey = $"spotify:missing:{playlistName}";
|
||||
var fileAge = DateTime.UtcNow - File.GetLastWriteTimeUtc(filePath);
|
||||
_logger.LogInformation(" {Playlist}: Found file cache (age: {Age:F1}h)", playlistName, fileAge.TotalHours);
|
||||
|
||||
// Check file cache
|
||||
if (File.Exists(filePath))
|
||||
// Load into Redis if not already there
|
||||
if (!await _cache.ExistsAsync(cacheKey))
|
||||
{
|
||||
var fileAge = DateTime.UtcNow - File.GetLastWriteTimeUtc(filePath);
|
||||
_logger.LogInformation(" {Playlist}: Found file cache (age: {Age:F1}h)", playlistName, fileAge.TotalHours);
|
||||
|
||||
// Load into Redis if not already there
|
||||
if (!await _cache.ExistsAsync(cacheKey))
|
||||
{
|
||||
await LoadFromFileCache(playlistName);
|
||||
}
|
||||
continue;
|
||||
await LoadFromFileCache(playlistName);
|
||||
}
|
||||
|
||||
// Check Redis cache
|
||||
if (await _cache.ExistsAsync(cacheKey))
|
||||
{
|
||||
_logger.LogInformation(" {Playlist}: Found in Redis cache", playlistName);
|
||||
continue;
|
||||
}
|
||||
|
||||
// No cache found for this playlist
|
||||
_logger.LogInformation(" {Playlist}: No cache found", playlistName);
|
||||
allPlaylistsHaveCache = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (allPlaylistsHaveCache)
|
||||
// Check Redis cache
|
||||
if (await _cache.ExistsAsync(cacheKey))
|
||||
{
|
||||
_logger.LogInformation("=== ALL PLAYLISTS HAVE CACHE - SKIPPING STARTUP FETCH ===");
|
||||
_logger.LogInformation("Will search again after {Time:yyyy-MM-dd HH:mm} UTC", todaySyncEnd);
|
||||
return false;
|
||||
_logger.LogInformation(" {Playlist}: Found in Redis cache", playlistName);
|
||||
continue;
|
||||
}
|
||||
|
||||
// No cache found for this playlist
|
||||
_logger.LogInformation(" {Playlist}: No cache found", playlistName);
|
||||
allPlaylistsHaveCache = false;
|
||||
}
|
||||
|
||||
// If we're after today's sync window end, check if we already have today's file
|
||||
if (now >= todaySyncEnd)
|
||||
if (allPlaylistsHaveCache)
|
||||
{
|
||||
_logger.LogInformation("We're after today's sync window end - checking if we already fetched today's files...");
|
||||
|
||||
var allPlaylistsHaveTodaysFile = true;
|
||||
|
||||
foreach (var playlistName in _playlistIdToName.Values)
|
||||
{
|
||||
var filePath = GetCacheFilePath(playlistName);
|
||||
var cacheKey = $"spotify:missing:{playlistName}";
|
||||
|
||||
// Check if file exists and was created today (after sync start)
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
var fileTime = File.GetLastWriteTimeUtc(filePath);
|
||||
|
||||
// File should be from today's sync window or later
|
||||
if (fileTime >= todaySync)
|
||||
{
|
||||
var fileAge = DateTime.UtcNow - fileTime;
|
||||
_logger.LogInformation(" {Playlist}: Have today's file (created {Time:yyyy-MM-dd HH:mm}, age: {Age:F1}h)",
|
||||
playlistName, fileTime, fileAge.TotalHours);
|
||||
|
||||
// Load into Redis if not already there
|
||||
if (!await _cache.ExistsAsync(cacheKey))
|
||||
{
|
||||
await LoadFromFileCache(playlistName);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(" {Playlist}: File is old (from {Time:yyyy-MM-dd HH:mm}, before today's sync)",
|
||||
playlistName, fileTime);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(" {Playlist}: No file found", playlistName);
|
||||
}
|
||||
|
||||
allPlaylistsHaveTodaysFile = false;
|
||||
}
|
||||
|
||||
if (allPlaylistsHaveTodaysFile)
|
||||
{
|
||||
_logger.LogInformation("=== ALL PLAYLISTS HAVE TODAY'S FILES - SKIPPING STARTUP FETCH ===");
|
||||
|
||||
// Calculate when to search next (tomorrow after sync window)
|
||||
var tomorrowSyncEnd = todaySyncEnd.AddDays(1);
|
||||
_logger.LogInformation("Will search again after {Time:yyyy-MM-dd HH:mm} UTC", tomorrowSyncEnd);
|
||||
return false;
|
||||
}
|
||||
_logger.LogInformation("=== ALL PLAYLISTS HAVE CACHE - SKIPPING STARTUP FETCH ===");
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.LogInformation("=== WILL FETCH ON STARTUP ===");
|
||||
@@ -380,32 +276,9 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
private async Task FetchMissingTracksAsync(CancellationToken cancellationToken, bool bypassSyncWindowCheck = false)
|
||||
private async Task FetchMissingTracksAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var settings = _spotifySettings.Value;
|
||||
var now = DateTime.UtcNow;
|
||||
var syncStart = now.Date
|
||||
.AddHours(settings.SyncStartHour)
|
||||
.AddMinutes(settings.SyncStartMinute);
|
||||
var syncEnd = syncStart.AddHours(settings.SyncWindowHours);
|
||||
|
||||
// Only run after the sync window has passed (unless bypassing for startup)
|
||||
if (!bypassSyncWindowCheck && now < syncEnd)
|
||||
{
|
||||
_logger.LogInformation("Skipping fetch - sync window not passed yet (now: {Now}, window ends: {End})",
|
||||
now, syncEnd);
|
||||
return;
|
||||
}
|
||||
|
||||
if (bypassSyncWindowCheck)
|
||||
{
|
||||
_logger.LogInformation("=== FETCHING MISSING TRACKS (STARTUP MODE) ===");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("=== FETCHING MISSING TRACKS (SYNC WINDOW PASSED) ===");
|
||||
}
|
||||
|
||||
_logger.LogInformation("=== FETCHING MISSING TRACKS ===");
|
||||
_logger.LogInformation("Processing {Count} playlists", _playlistIdToName.Count);
|
||||
|
||||
// Track when we find files to optimize search for other playlists
|
||||
|
||||
@@ -3,6 +3,7 @@ using allstarr.Models.Spotify;
|
||||
using allstarr.Services.Common;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text.Json;
|
||||
using Cronos;
|
||||
|
||||
namespace allstarr.Services.Spotify;
|
||||
|
||||
@@ -14,6 +15,9 @@ namespace allstarr.Services.Spotify;
|
||||
/// - ISRC codes available for exact matching
|
||||
/// - Real-time data without waiting for plugin sync schedules
|
||||
/// - Full track metadata (duration, release date, etc.)
|
||||
///
|
||||
/// CRON SCHEDULING: Playlists are fetched based on their cron schedules, not a global interval.
|
||||
/// Cache persists until next cron run to prevent excess Spotify API calls.
|
||||
/// </summary>
|
||||
public class SpotifyPlaylistFetcher : BackgroundService
|
||||
{
|
||||
@@ -45,6 +49,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Spotify playlist tracks in order, using cache if available.
|
||||
/// Cache persists until next cron run to prevent excess API calls.
|
||||
/// </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>
|
||||
@@ -57,7 +62,38 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
if (cached != null && cached.Tracks.Count > 0)
|
||||
{
|
||||
var age = DateTime.UtcNow - cached.FetchedAt;
|
||||
if (age.TotalMinutes < _spotifyApiSettings.CacheDurationMinutes)
|
||||
|
||||
// Calculate if cache should still be valid based on cron schedule
|
||||
var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName);
|
||||
var shouldRefresh = false;
|
||||
|
||||
if (playlistConfig != null && !string.IsNullOrEmpty(playlistConfig.SyncSchedule))
|
||||
{
|
||||
try
|
||||
{
|
||||
var cron = CronExpression.Parse(playlistConfig.SyncSchedule);
|
||||
var nextRun = cron.GetNextOccurrence(cached.FetchedAt, TimeZoneInfo.Utc);
|
||||
|
||||
if (nextRun.HasValue && DateTime.UtcNow >= nextRun.Value)
|
||||
{
|
||||
shouldRefresh = true;
|
||||
_logger.LogInformation("Cache expired for '{Name}' - next cron run was at {NextRun} UTC",
|
||||
playlistName, nextRun.Value);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not parse cron schedule for '{Name}', falling back to cache duration", playlistName);
|
||||
shouldRefresh = age.TotalMinutes >= _spotifyApiSettings.CacheDurationMinutes;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No cron schedule, use cache duration from settings
|
||||
shouldRefresh = age.TotalMinutes >= _spotifyApiSettings.CacheDurationMinutes;
|
||||
}
|
||||
|
||||
if (!shouldRefresh)
|
||||
{
|
||||
_logger.LogDebug("Using cached playlist '{Name}' ({Count} tracks, age: {Age:F1}m)",
|
||||
playlistName, cached.Tracks.Count, age.TotalMinutes);
|
||||
@@ -94,11 +130,11 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
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))
|
||||
var config = _spotifyImportSettings.GetPlaylistByName(playlistName);
|
||||
if (config != null && !string.IsNullOrEmpty(config.Id))
|
||||
{
|
||||
// Use the configured Spotify playlist ID directly
|
||||
spotifyId = playlistConfig.Id;
|
||||
spotifyId = config.Id;
|
||||
_playlistNameToSpotifyId[playlistName] = spotifyId;
|
||||
_logger.LogInformation("Using configured Spotify playlist ID for '{Name}': {Id}", playlistName, spotifyId);
|
||||
}
|
||||
@@ -144,12 +180,39 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
return cached?.Tracks ?? new List<SpotifyPlaylistTrack>();
|
||||
}
|
||||
|
||||
// Update cache
|
||||
await _cache.SetAsync(cacheKey, playlist, TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes * 2));
|
||||
// Calculate cache expiration based on cron schedule
|
||||
var playlistCfg = _spotifyImportSettings.GetPlaylistByName(playlistName);
|
||||
var cacheExpiration = TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes * 2); // Default
|
||||
|
||||
if (playlistCfg != null && !string.IsNullOrEmpty(playlistCfg.SyncSchedule))
|
||||
{
|
||||
try
|
||||
{
|
||||
var cron = CronExpression.Parse(playlistCfg.SyncSchedule);
|
||||
var nextRun = cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc);
|
||||
|
||||
if (nextRun.HasValue)
|
||||
{
|
||||
var timeUntilNextRun = nextRun.Value - DateTime.UtcNow;
|
||||
// Add 5 minutes buffer
|
||||
cacheExpiration = timeUntilNextRun + TimeSpan.FromMinutes(5);
|
||||
|
||||
_logger.LogInformation("Playlist '{Name}' cache will persist until next cron run: {NextRun} UTC (in {Hours:F1}h)",
|
||||
playlistName, nextRun.Value, timeUntilNextRun.TotalHours);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not calculate next cron run for '{Name}', using default cache duration", playlistName);
|
||||
}
|
||||
}
|
||||
|
||||
// Update cache with cron-based expiration
|
||||
await _cache.SetAsync(cacheKey, playlist, cacheExpiration);
|
||||
await SaveToFileCacheAsync(playlistName, playlist);
|
||||
|
||||
_logger.LogInformation("Fetched and cached playlist '{Name}' with {Count} tracks in order",
|
||||
playlistName, playlist.Tracks.Count);
|
||||
_logger.LogInformation("Fetched and cached playlist '{Name}' with {Count} tracks (expires in {Hours:F1}h)",
|
||||
playlistName, playlist.Tracks.Count, cacheExpiration.TotalHours);
|
||||
|
||||
return playlist.Tracks;
|
||||
}
|
||||
@@ -235,32 +298,102 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
|
||||
_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);
|
||||
var schedule = string.IsNullOrEmpty(playlist.SyncSchedule) ? "0 8 * * 1" : playlist.SyncSchedule;
|
||||
_logger.LogInformation(" - {Name}: {Schedule}", playlist.Name, schedule);
|
||||
}
|
||||
|
||||
_logger.LogInformation("========================================");
|
||||
|
||||
// Initial fetch of all playlists
|
||||
// Initial fetch of all playlists on startup
|
||||
await FetchAllPlaylistsAsync(stoppingToken);
|
||||
|
||||
// Periodic refresh loop
|
||||
// Cron-based refresh loop - only fetch when cron schedule triggers
|
||||
// This prevents excess Spotify API calls
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes), stoppingToken);
|
||||
|
||||
try
|
||||
{
|
||||
await FetchAllPlaylistsAsync(stoppingToken);
|
||||
// Check each playlist to see if it needs refreshing based on cron schedule
|
||||
var now = DateTime.UtcNow;
|
||||
var needsRefresh = new List<string>();
|
||||
|
||||
foreach (var config in _spotifyImportSettings.Playlists)
|
||||
{
|
||||
var schedule = string.IsNullOrEmpty(config.SyncSchedule) ? "0 8 * * 1" : config.SyncSchedule;
|
||||
|
||||
try
|
||||
{
|
||||
var cron = CronExpression.Parse(schedule);
|
||||
|
||||
// Check if we have cached data
|
||||
var cacheKey = $"{CacheKeyPrefix}{config.Name}";
|
||||
var cached = await _cache.GetAsync<SpotifyPlaylist>(cacheKey);
|
||||
|
||||
if (cached != null)
|
||||
{
|
||||
// Calculate when the next run should be after the last fetch
|
||||
var nextRun = cron.GetNextOccurrence(cached.FetchedAt, TimeZoneInfo.Utc);
|
||||
|
||||
if (nextRun.HasValue && now >= nextRun.Value)
|
||||
{
|
||||
needsRefresh.Add(config.Name);
|
||||
_logger.LogInformation("Playlist '{Name}' needs refresh - last fetched {Age:F1}h ago, next run was {NextRun}",
|
||||
config.Name, (now - cached.FetchedAt).TotalHours, nextRun.Value);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No cache, fetch it
|
||||
needsRefresh.Add(config.Name);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Invalid cron schedule for playlist {Name}: {Schedule}", config.Name, schedule);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch playlists that need refreshing
|
||||
if (needsRefresh.Count > 0)
|
||||
{
|
||||
_logger.LogInformation("=== CRON TRIGGER: Fetching {Count} playlists ===", needsRefresh.Count);
|
||||
|
||||
foreach (var playlistName in needsRefresh)
|
||||
{
|
||||
if (stoppingToken.IsCancellationRequested) break;
|
||||
|
||||
try
|
||||
{
|
||||
await GetPlaylistTracksAsync(playlistName);
|
||||
|
||||
// Rate limiting between playlists
|
||||
if (playlistName != needsRefresh.Last())
|
||||
{
|
||||
_logger.LogDebug("Waiting 3 seconds before next playlist to avoid rate limits...");
|
||||
await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching playlist '{Name}'", playlistName);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("=== FINISHED FETCHING PLAYLISTS ===");
|
||||
}
|
||||
|
||||
// Sleep for 1 hour before checking again
|
||||
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error during periodic playlist refresh");
|
||||
_logger.LogError(ex, "Error in playlist fetcher loop");
|
||||
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ using allstarr.Services.Jellyfin;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text.Json;
|
||||
using Cronos;
|
||||
|
||||
namespace allstarr.Services.Spotify;
|
||||
|
||||
@@ -17,6 +18,9 @@ namespace allstarr.Services.Spotify;
|
||||
/// 2. Direct API mode: Uses SpotifyPlaylistTrack (with ISRC and ordering)
|
||||
///
|
||||
/// When ISRC is available, exact matching is preferred. Falls back to fuzzy matching.
|
||||
///
|
||||
/// CRON SCHEDULING: Each playlist has its own cron schedule. Matching only runs when the schedule triggers.
|
||||
/// Manual refresh is always allowed. Cache persists until next cron run.
|
||||
/// </summary>
|
||||
public class SpotifyTrackMatchingService : BackgroundService
|
||||
{
|
||||
@@ -27,8 +31,10 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
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
|
||||
|
||||
// Track last run time per playlist to prevent duplicate runs
|
||||
private readonly Dictionary<string, DateTime> _lastRunTimes = new();
|
||||
private readonly TimeSpan _minimumRunInterval = TimeSpan.FromMinutes(5); // Cooldown between runs
|
||||
|
||||
public SpotifyTrackMatchingService(
|
||||
IOptions<SpotifyImportSettings> spotifySettings,
|
||||
@@ -57,17 +63,29 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("========================================");
|
||||
_logger.LogInformation("SpotifyTrackMatchingService: Starting up...");
|
||||
|
||||
if (!_spotifySettings.Enabled)
|
||||
{
|
||||
_logger.LogInformation("Spotify playlist injection is DISABLED, matching service will not run");
|
||||
_logger.LogInformation("========================================");
|
||||
return;
|
||||
}
|
||||
|
||||
var matchMode = _spotifyApiSettings.Enabled && _spotifyApiSettings.PreferIsrcMatching
|
||||
? "ISRC-preferred" : "fuzzy";
|
||||
_logger.LogInformation("Matching mode: {Mode}", matchMode);
|
||||
_logger.LogInformation("Cron-based scheduling: Each playlist has independent schedule");
|
||||
|
||||
// Log all playlist schedules
|
||||
foreach (var playlist in _spotifySettings.Playlists)
|
||||
{
|
||||
var schedule = string.IsNullOrEmpty(playlist.SyncSchedule) ? "0 8 * * 1" : playlist.SyncSchedule;
|
||||
_logger.LogInformation(" - {Name}: {Schedule}", playlist.Name, schedule);
|
||||
}
|
||||
|
||||
_logger.LogInformation("========================================");
|
||||
|
||||
// Wait a bit for the fetcher to run first
|
||||
await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
|
||||
@@ -75,7 +93,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
// Run once on startup to match any existing missing tracks
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Running initial track matching on startup");
|
||||
_logger.LogInformation("Running initial track matching on startup (one-time)");
|
||||
await MatchAllPlaylistsAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -83,46 +101,100 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
_logger.LogError(ex, "Error during startup track matching");
|
||||
}
|
||||
|
||||
// Now start the periodic matching loop
|
||||
// Now start the cron-based scheduling 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);
|
||||
// Calculate next run time for each playlist
|
||||
var now = DateTime.UtcNow;
|
||||
var nextRuns = new List<(string PlaylistName, DateTime NextRun, CronExpression Cron)>();
|
||||
|
||||
foreach (var playlist in _spotifySettings.Playlists)
|
||||
{
|
||||
var schedule = string.IsNullOrEmpty(playlist.SyncSchedule) ? "0 8 * * 1" : playlist.SyncSchedule;
|
||||
|
||||
try
|
||||
{
|
||||
var cron = CronExpression.Parse(schedule);
|
||||
var nextRun = cron.GetNextOccurrence(now, TimeZoneInfo.Utc);
|
||||
|
||||
if (nextRun.HasValue)
|
||||
{
|
||||
nextRuns.Add((playlist.Name, nextRun.Value, cron));
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Could not calculate next run for playlist {Name} with schedule {Schedule}",
|
||||
playlist.Name, schedule);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Invalid cron schedule for playlist {Name}: {Schedule}",
|
||||
playlist.Name, schedule);
|
||||
}
|
||||
}
|
||||
|
||||
if (nextRuns.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("No valid cron schedules found, sleeping for 1 hour");
|
||||
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the next playlist that needs to run
|
||||
var nextPlaylist = nextRuns.OrderBy(x => x.NextRun).First();
|
||||
var waitTime = nextPlaylist.NextRun - now;
|
||||
|
||||
if (waitTime.TotalSeconds > 0)
|
||||
{
|
||||
_logger.LogInformation("Next scheduled run: {Playlist} at {Time} UTC (in {Minutes:F1} minutes)",
|
||||
nextPlaylist.PlaylistName, nextPlaylist.NextRun, waitTime.TotalMinutes);
|
||||
|
||||
// Wait until next run (or max 1 hour to re-check schedules)
|
||||
var maxWait = TimeSpan.FromHours(1);
|
||||
var actualWait = waitTime > maxWait ? maxWait : waitTime;
|
||||
await Task.Delay(actualWait, stoppingToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Time to run this playlist
|
||||
_logger.LogInformation("=== CRON TRIGGER: Running scheduled match for {Playlist} ===", nextPlaylist.PlaylistName);
|
||||
|
||||
// Check cooldown to prevent duplicate runs
|
||||
if (_lastRunTimes.TryGetValue(nextPlaylist.PlaylistName, out var lastRun))
|
||||
{
|
||||
var timeSinceLastRun = now - lastRun;
|
||||
if (timeSinceLastRun < _minimumRunInterval)
|
||||
{
|
||||
_logger.LogInformation("Skipping {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)",
|
||||
nextPlaylist.PlaylistName, (int)timeSinceLastRun.TotalSeconds, (int)_minimumRunInterval.TotalSeconds);
|
||||
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Run matching for this playlist
|
||||
await MatchSinglePlaylistAsync(nextPlaylist.PlaylistName, stoppingToken);
|
||||
_lastRunTimes[nextPlaylist.PlaylistName] = DateTime.UtcNow;
|
||||
|
||||
_logger.LogInformation("=== FINISHED: {Playlist} - Next run at {NextRun} UTC ===",
|
||||
nextPlaylist.PlaylistName, nextPlaylist.Cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in track matching service");
|
||||
_logger.LogError(ex, "Error in cron scheduling loop");
|
||||
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <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).
|
||||
/// Matches tracks for a single playlist (called by cron scheduler or manual trigger).
|
||||
/// </summary>
|
||||
public async Task TriggerMatchingForPlaylistAsync(string playlistName)
|
||||
private async Task MatchSinglePlaylistAsync(string playlistName, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Manual track matching triggered for playlist: {Playlist}", playlistName);
|
||||
|
||||
var playlist = _spotifySettings.Playlists
|
||||
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
@@ -148,13 +220,13 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
{
|
||||
// Use new direct API mode with ISRC support
|
||||
await MatchPlaylistTracksWithIsrcAsync(
|
||||
playlist.Name, playlistFetcher, metadataService, CancellationToken.None);
|
||||
playlist.Name, playlistFetcher, metadataService, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fall back to legacy mode
|
||||
await MatchPlaylistTracksLegacyAsync(
|
||||
playlist.Name, metadataService, CancellationToken.None);
|
||||
playlist.Name, metadataService, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -164,19 +236,43 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
private async Task MatchAllPlaylistsAsync(CancellationToken cancellationToken)
|
||||
/// <summary>
|
||||
/// Public method to trigger matching manually for all playlists (called from controller).
|
||||
/// This bypasses cron schedules and runs immediately.
|
||||
/// </summary>
|
||||
public async Task TriggerMatchingAsync()
|
||||
{
|
||||
// Check if we've run too recently (cooldown period)
|
||||
var timeSinceLastRun = DateTime.UtcNow - _lastMatchingRun;
|
||||
if (timeSinceLastRun < _minimumMatchingInterval)
|
||||
_logger.LogInformation("Manual track matching triggered for all playlists (bypassing cron schedules)");
|
||||
await MatchAllPlaylistsAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Public method to trigger matching for a specific playlist (called from controller).
|
||||
/// This bypasses cron schedules and runs immediately.
|
||||
/// </summary>
|
||||
public async Task TriggerMatchingForPlaylistAsync(string playlistName)
|
||||
{
|
||||
_logger.LogInformation("Manual track matching triggered for playlist: {Playlist} (bypassing cron schedule)", playlistName);
|
||||
|
||||
// Check cooldown to prevent abuse
|
||||
if (_lastRunTimes.TryGetValue(playlistName, out var lastRun))
|
||||
{
|
||||
_logger.LogInformation("Skipping track matching - last run was {Seconds}s ago (minimum interval: {MinSeconds}s)",
|
||||
(int)timeSinceLastRun.TotalSeconds, (int)_minimumMatchingInterval.TotalSeconds);
|
||||
return;
|
||||
var timeSinceLastRun = DateTime.UtcNow - lastRun;
|
||||
if (timeSinceLastRun < _minimumRunInterval)
|
||||
{
|
||||
_logger.LogWarning("Skipping manual refresh for {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)",
|
||||
playlistName, (int)timeSinceLastRun.TotalSeconds, (int)_minimumRunInterval.TotalSeconds);
|
||||
throw new InvalidOperationException($"Please wait {(int)(_minimumRunInterval - timeSinceLastRun).TotalSeconds} more seconds before refreshing again");
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("=== STARTING TRACK MATCHING ===");
|
||||
_lastMatchingRun = DateTime.UtcNow;
|
||||
await MatchSinglePlaylistAsync(playlistName, CancellationToken.None);
|
||||
_lastRunTimes[playlistName] = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
private async Task MatchAllPlaylistsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("=== STARTING TRACK MATCHING FOR ALL PLAYLISTS ===");
|
||||
|
||||
var playlists = _spotifySettings.Playlists;
|
||||
if (playlists.Count == 0)
|
||||
@@ -185,34 +281,13 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
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);
|
||||
}
|
||||
await MatchSinglePlaylistAsync(playlist.Name, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -220,7 +295,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("=== FINISHED TRACK MATCHING ===");
|
||||
_logger.LogInformation("=== FINISHED TRACK MATCHING FOR ALL PLAYLISTS ===");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -497,8 +572,37 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
|
||||
if (matchedTracks.Count > 0)
|
||||
{
|
||||
// Cache matched tracks with position data
|
||||
await _cache.SetAsync(matchedTracksKey, matchedTracks, TimeSpan.FromHours(1));
|
||||
// Calculate cache expiration: until next cron run (not just cache duration from settings)
|
||||
var playlist = _spotifySettings.Playlists
|
||||
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var cacheExpiration = TimeSpan.FromHours(24); // Default 24 hours
|
||||
|
||||
if (playlist != null && !string.IsNullOrEmpty(playlist.SyncSchedule))
|
||||
{
|
||||
try
|
||||
{
|
||||
var cron = CronExpression.Parse(playlist.SyncSchedule);
|
||||
var nextRun = cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc);
|
||||
|
||||
if (nextRun.HasValue)
|
||||
{
|
||||
var timeUntilNextRun = nextRun.Value - DateTime.UtcNow;
|
||||
// Add 5 minutes buffer to ensure cache doesn't expire before next run
|
||||
cacheExpiration = timeUntilNextRun + TimeSpan.FromMinutes(5);
|
||||
|
||||
_logger.LogInformation("Cache will persist until next cron run: {NextRun} UTC (in {Hours:F1} hours)",
|
||||
nextRun.Value, timeUntilNextRun.TotalHours);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not calculate next cron run for {Playlist}, using default cache duration", playlistName);
|
||||
}
|
||||
}
|
||||
|
||||
// Cache matched tracks with position data until next cron run
|
||||
await _cache.SetAsync(matchedTracksKey, matchedTracks, cacheExpiration);
|
||||
|
||||
// Save matched tracks to file for persistence across restarts
|
||||
await SaveMatchedTracksToFileAsync(playlistName, matchedTracks);
|
||||
@@ -506,15 +610,15 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
// 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));
|
||||
await _cache.SetAsync(legacyKey, legacySongs, cacheExpiration);
|
||||
|
||||
_logger.LogInformation(
|
||||
"✓ Cached {Matched}/{Total} tracks for {Playlist} via GREEDY ASSIGNMENT (ISRC: {Isrc}, Fuzzy: {Fuzzy}, No match: {NoMatch}) - manual mappings will be applied next",
|
||||
matchedTracks.Count, tracksToMatch.Count, playlistName, isrcMatches, fuzzyMatches, noMatch);
|
||||
"✓ Cached {Matched}/{Total} tracks for {Playlist} via GREEDY ASSIGNMENT (ISRC: {Isrc}, Fuzzy: {Fuzzy}, No match: {NoMatch}) - cache expires in {Hours:F1}h",
|
||||
matchedTracks.Count, tracksToMatch.Count, playlistName, isrcMatches, fuzzyMatches, noMatch, cacheExpiration.TotalHours);
|
||||
|
||||
// Pre-build playlist items cache for instant serving
|
||||
// This is what makes the UI show all matched tracks at once
|
||||
await PreBuildPlaylistItemsCacheAsync(playlistName, playlistConfig?.JellyfinId, spotifyTracks, matchedTracks, cancellationToken);
|
||||
await PreBuildPlaylistItemsCacheAsync(playlistName, playlistConfig?.JellyfinId, spotifyTracks, matchedTracks, cacheExpiration, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -849,6 +953,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
string? jellyfinPlaylistId,
|
||||
List<SpotifyPlaylistTrack> spotifyTracks,
|
||||
List<MatchedTrack> matchedTracks,
|
||||
TimeSpan cacheExpiration,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
@@ -887,7 +992,8 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
headers["X-Emby-Authorization"] = $"MediaBrowser Token=\"{jellyfinSettings.ApiKey}\"";
|
||||
}
|
||||
|
||||
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items?UserId={userId}&Fields=MediaSources";
|
||||
// Request all fields that clients typically need (not just MediaSources)
|
||||
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items?UserId={userId}&Fields=Genres,DateCreated,MediaSources,ParentId,People,Tags,SortName,ProviderIds";
|
||||
var (existingTracksResponse, statusCode) = await proxyService.GetJsonAsync(playlistItemsUrl, null, headers);
|
||||
|
||||
if (statusCode != 200 || existingTracksResponse == null)
|
||||
@@ -1196,9 +1302,64 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
|
||||
if (finalItems.Count > 0)
|
||||
{
|
||||
// Save to Redis cache
|
||||
// Enrich external tracks with genres from MusicBrainz
|
||||
if (externalUsedCount > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var genreEnrichment = _serviceProvider.GetService<GenreEnrichmentService>();
|
||||
if (genreEnrichment != null)
|
||||
{
|
||||
_logger.LogInformation("🎨 Enriching {Count} external tracks with genres from MusicBrainz...", externalUsedCount);
|
||||
|
||||
// Extract external songs from matched tracks
|
||||
var externalSongs = matchedTracks
|
||||
.Where(t => t.MatchedSong != null && !t.MatchedSong.IsLocal)
|
||||
.Select(t => t.MatchedSong!)
|
||||
.ToList();
|
||||
|
||||
// Enrich genres in parallel
|
||||
await genreEnrichment.EnrichSongsGenresAsync(externalSongs);
|
||||
|
||||
// Update the genres in finalItems
|
||||
foreach (var item in finalItems)
|
||||
{
|
||||
if (item.TryGetValue("Id", out var idObj) && idObj is string id && id.StartsWith("ext-"))
|
||||
{
|
||||
// Find the corresponding song
|
||||
var song = externalSongs.FirstOrDefault(s => s.Id == id);
|
||||
if (song != null && !string.IsNullOrEmpty(song.Genre))
|
||||
{
|
||||
// Update Genres array
|
||||
item["Genres"] = new[] { song.Genre };
|
||||
|
||||
// Update GenreItems array
|
||||
item["GenreItems"] = new[]
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["Name"] = song.Genre,
|
||||
["Id"] = $"genre-{song.Genre.ToLowerInvariant()}"
|
||||
}
|
||||
};
|
||||
|
||||
_logger.LogDebug("✓ Enriched {Title} with genre: {Genre}", song.Title, song.Genre);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("✅ Genre enrichment complete for {Playlist}", playlistName);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to enrich genres for {Playlist}, continuing without genres", playlistName);
|
||||
}
|
||||
}
|
||||
|
||||
// Save to Redis cache with same expiration as matched tracks (until next cron run)
|
||||
var cacheKey = $"spotify:playlist:items:{playlistName}";
|
||||
await _cache.SetAsync(cacheKey, finalItems, TimeSpan.FromHours(24));
|
||||
await _cache.SetAsync(cacheKey, finalItems, cacheExpiration);
|
||||
|
||||
// Save to file cache for persistence
|
||||
await SavePlaylistItemsToFileAsync(playlistName, finalItems);
|
||||
@@ -1210,8 +1371,8 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"✅ Pre-built playlist cache for {Playlist}: {Total} tracks ({Local} LOCAL + {External} EXTERNAL){ManualInfo}",
|
||||
playlistName, finalItems.Count, localUsedCount, externalUsedCount, manualMappingInfo);
|
||||
"✅ Pre-built playlist cache for {Playlist}: {Total} tracks ({Local} LOCAL + {External} EXTERNAL){ManualInfo} - expires in {Hours:F1}h",
|
||||
playlistName, finalItems.Count, localUsedCount, externalUsedCount, manualMappingInfo, cacheExpiration.TotalHours);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ using allstarr.Models.Search;
|
||||
using allstarr.Models.Subsonic;
|
||||
using allstarr.Services.Local;
|
||||
using allstarr.Services.Common;
|
||||
using allstarr.Services.Lyrics;
|
||||
using Microsoft.Extensions.Options;
|
||||
using IOFile = System.IO.File;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -55,6 +56,7 @@ public class SquidWTFDownloadService : BaseDownloadService
|
||||
private readonly SquidWTFSettings _squidwtfSettings;
|
||||
private readonly OdesliService _odesliService;
|
||||
private readonly RoundRobinFallbackHelper _fallbackHelper;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
|
||||
protected override string ProviderName => "squidwtf";
|
||||
|
||||
@@ -75,6 +77,10 @@ public class SquidWTFDownloadService : BaseDownloadService
|
||||
_squidwtfSettings = SquidWTFSettings.Value;
|
||||
_odesliService = odesliService;
|
||||
_fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF");
|
||||
_serviceProvider = serviceProvider;
|
||||
|
||||
// Increase timeout for large downloads and slow endpoints
|
||||
_httpClient.Timeout = TimeSpan.FromMinutes(5);
|
||||
}
|
||||
|
||||
|
||||
@@ -108,9 +114,6 @@ public class SquidWTFDownloadService : BaseDownloadService
|
||||
Logger.LogInformation("Track download URL obtained from hifi-api: {Url}", downloadInfo.DownloadUrl);
|
||||
Logger.LogInformation("Using format: {Format} (Quality: {Quality})", downloadInfo.MimeType, downloadInfo.AudioQuality);
|
||||
|
||||
// Start Spotify ID conversion in parallel with download (don't await yet)
|
||||
var spotifyIdTask = _odesliService.ConvertTidalToSpotifyIdAsync(trackId, cancellationToken);
|
||||
|
||||
// Determine extension from MIME type
|
||||
var extension = downloadInfo.MimeType?.ToLower() switch
|
||||
{
|
||||
@@ -122,10 +125,10 @@ public class SquidWTFDownloadService : BaseDownloadService
|
||||
|
||||
// Build organized folder structure: Artist/Album/Track using AlbumArtist (fallback to Artist for singles)
|
||||
var artistForPath = song.AlbumArtist ?? song.Artist;
|
||||
// Cache mode uses cache/Music folder (cleaned up after 24h), Permanent mode uses downloads folder
|
||||
// Cache mode uses downloads/cache/ folder, Permanent mode uses downloads/permanent/
|
||||
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache
|
||||
? Path.Combine("cache", "Music")
|
||||
: "downloads";
|
||||
? Path.Combine("downloads", "cache")
|
||||
: Path.Combine("downloads", "permanent");
|
||||
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension);
|
||||
|
||||
// Create directories if they don't exist
|
||||
@@ -199,14 +202,26 @@ public class SquidWTFDownloadService : BaseDownloadService
|
||||
// Close file before writing metadata
|
||||
await outputFile.DisposeAsync();
|
||||
|
||||
// Wait for Spotify ID conversion to complete and update song metadata
|
||||
var spotifyId = await spotifyIdTask;
|
||||
if (!string.IsNullOrEmpty(spotifyId))
|
||||
// Start Spotify ID conversion in background (for lyrics support)
|
||||
// This doesn't block streaming - lyrics endpoint will fetch it on-demand if needed
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
song.SpotifyId = spotifyId;
|
||||
}
|
||||
try
|
||||
{
|
||||
var spotifyId = await _odesliService.ConvertTidalToSpotifyIdAsync(trackId, CancellationToken.None);
|
||||
if (!string.IsNullOrEmpty(spotifyId))
|
||||
{
|
||||
Logger.LogDebug("Background Spotify ID obtained for Tidal/{TrackId}: {SpotifyId}", trackId, spotifyId);
|
||||
// Spotify ID is cached by Odesli service for future lyrics requests
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogDebug(ex, "Background Spotify ID conversion failed for Tidal/{TrackId}", trackId);
|
||||
}
|
||||
});
|
||||
|
||||
// Write metadata and cover art
|
||||
// Write metadata and cover art (without Spotify ID - it's only needed for lyrics)
|
||||
await WriteMetadataAsync(outputPath, song, cancellationToken);
|
||||
|
||||
return outputPath;
|
||||
@@ -299,6 +314,53 @@ public class SquidWTFDownloadService : BaseDownloadService
|
||||
|
||||
#region Utility Methods
|
||||
|
||||
/// <summary>
|
||||
/// Converts Tidal track ID to Spotify ID for lyrics support.
|
||||
/// Called in background after streaming starts.
|
||||
/// Also prefetches lyrics immediately after conversion.
|
||||
/// </summary>
|
||||
protected override async Task ConvertToSpotifyIdAsync(string externalProvider, string externalId)
|
||||
{
|
||||
if (externalProvider != "squidwtf")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var spotifyId = await _odesliService.ConvertTidalToSpotifyIdAsync(externalId, CancellationToken.None);
|
||||
if (!string.IsNullOrEmpty(spotifyId))
|
||||
{
|
||||
Logger.LogDebug("Background Spotify ID obtained for Tidal/{TrackId}: {SpotifyId}", externalId, spotifyId);
|
||||
|
||||
// Immediately prefetch lyrics now that we have the Spotify ID
|
||||
// This ensures lyrics are cached and ready when the client requests them
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var spotifyLyricsService = scope.ServiceProvider.GetService<SpotifyLyricsService>();
|
||||
|
||||
if (spotifyLyricsService != null)
|
||||
{
|
||||
var lyrics = await spotifyLyricsService.GetLyricsByTrackIdAsync(spotifyId);
|
||||
if (lyrics != null && lyrics.Lines.Count > 0)
|
||||
{
|
||||
Logger.LogDebug("Background lyrics prefetched for Spotify/{SpotifyId}: {LineCount} lines",
|
||||
spotifyId, lyrics.Lines.Count);
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogDebug("No lyrics available for Spotify/{SpotifyId}", spotifyId);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogDebug(ex, "Background lyrics prefetch failed for Spotify/{SpotifyId}", spotifyId);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
private readonly ILogger<SquidWTFMetadataService> _logger;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly RoundRobinFallbackHelper _fallbackHelper;
|
||||
private readonly GenreEnrichmentService? _genreEnrichment;
|
||||
|
||||
public SquidWTFMetadataService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
@@ -63,36 +64,41 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
IOptions<SquidWTFSettings> squidwtfSettings,
|
||||
ILogger<SquidWTFMetadataService> logger,
|
||||
RedisCacheService cache,
|
||||
List<string> apiUrls)
|
||||
List<string> apiUrls,
|
||||
GenreEnrichmentService? genreEnrichment = null)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
_cache = cache;
|
||||
_fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF");
|
||||
_genreEnrichment = genreEnrichment;
|
||||
|
||||
// Set up default headers
|
||||
_httpClient.DefaultRequestHeaders.Add("User-Agent",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0");
|
||||
|
||||
// Increase timeout for large artist/album responses (some artists have 100+ albums)
|
||||
_httpClient.Timeout = TimeSpan.FromMinutes(5);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20)
|
||||
{
|
||||
// Race all endpoints for fastest search results
|
||||
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
|
||||
// Use round-robin to distribute load across endpoints (allows parallel processing of multiple tracks)
|
||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
{
|
||||
// Use 's' parameter for track search as per hifi-api spec
|
||||
var url = $"{baseUrl}/search/?s={Uri.EscapeDataString(query)}";
|
||||
var response = await _httpClient.GetAsync(url, ct);
|
||||
var response = await _httpClient.GetAsync(url);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(ct);
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// Check for error in response body
|
||||
var result = JsonDocument.Parse(json);
|
||||
@@ -126,19 +132,19 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
|
||||
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20)
|
||||
{
|
||||
// Race all endpoints for fastest search results
|
||||
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
|
||||
// Use round-robin to distribute load across endpoints (allows parallel processing)
|
||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
{
|
||||
// Note: hifi-api doesn't document album search, but 'al' parameter is commonly used
|
||||
var url = $"{baseUrl}/search/?al={Uri.EscapeDataString(query)}";
|
||||
var response = await _httpClient.GetAsync(url, ct);
|
||||
var response = await _httpClient.GetAsync(url);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(ct);
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var result = JsonDocument.Parse(json);
|
||||
|
||||
var albums = new List<Album>();
|
||||
@@ -163,14 +169,14 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
|
||||
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20)
|
||||
{
|
||||
// Race all endpoints for fastest search results
|
||||
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
|
||||
// Use round-robin to distribute load across endpoints (allows parallel processing)
|
||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
{
|
||||
// Per hifi-api spec: use 'a' parameter for artist search
|
||||
var url = $"{baseUrl}/search/?a={Uri.EscapeDataString(query)}";
|
||||
_logger.LogInformation("🔍 SQUIDWTF: Searching artists with URL: {Url}", url);
|
||||
|
||||
var response = await _httpClient.GetAsync(url, ct);
|
||||
var response = await _httpClient.GetAsync(url);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
@@ -178,7 +184,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(ct);
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var result = JsonDocument.Parse(json);
|
||||
|
||||
var artists = new List<Artist>();
|
||||
@@ -283,6 +289,23 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
|
||||
var song = ParseTidalTrackFull(track);
|
||||
|
||||
// Enrich with MusicBrainz genres if missing (SquidWTF/Tidal doesn't provide genres)
|
||||
if (_genreEnrichment != null && string.IsNullOrEmpty(song.Genre))
|
||||
{
|
||||
// Fire-and-forget: don't block the response waiting for genre enrichment
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await _genreEnrichment.EnrichSongGenreAsync(song);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to enrich genre for {Title}", song.Title);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// NOTE: Spotify ID conversion happens during download (in SquidWTFDownloadService)
|
||||
// This avoids redundant conversions and ensures it's done in parallel with the download
|
||||
|
||||
@@ -592,6 +615,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
|
||||
// Get all artists - Tidal provides both "artist" (singular) and "artists" (plural array)
|
||||
var allArtists = new List<string>();
|
||||
var allArtistIds = new List<string>();
|
||||
string artistName = "";
|
||||
string? artistId = null;
|
||||
|
||||
@@ -601,9 +625,11 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
foreach (var artistEl in artists.EnumerateArray())
|
||||
{
|
||||
var name = artistEl.GetProperty("name").GetString();
|
||||
var id = artistEl.GetProperty("id").GetInt64();
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
{
|
||||
allArtists.Add(name);
|
||||
allArtistIds.Add($"ext-squidwtf-artist-{id}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -611,7 +637,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
if (allArtists.Count > 0)
|
||||
{
|
||||
artistName = allArtists[0];
|
||||
artistId = $"ext-squidwtf-artist-{artists[0].GetProperty("id").GetInt64()}";
|
||||
artistId = allArtistIds[0];
|
||||
}
|
||||
}
|
||||
// Fallback to singular "artist" field
|
||||
@@ -620,6 +646,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
artistName = artist.GetProperty("name").GetString() ?? "";
|
||||
artistId = $"ext-squidwtf-artist-{artist.GetProperty("id").GetInt64()}";
|
||||
allArtists.Add(artistName);
|
||||
allArtistIds.Add(artistId);
|
||||
}
|
||||
|
||||
// Get album info
|
||||
@@ -646,6 +673,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
Artist = artistName,
|
||||
ArtistId = artistId,
|
||||
Artists = allArtists,
|
||||
ArtistIds = allArtistIds,
|
||||
Album = albumTitle,
|
||||
AlbumId = albumId,
|
||||
Duration = track.TryGetProperty("duration", out var duration)
|
||||
@@ -708,6 +736,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
|
||||
// Get all artists - prefer "artists" array for collaborations
|
||||
var allArtists = new List<string>();
|
||||
var allArtistIds = new List<string>();
|
||||
string artistName = "";
|
||||
long artistIdNum = 0;
|
||||
|
||||
@@ -716,9 +745,11 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
foreach (var artistEl in artists.EnumerateArray())
|
||||
{
|
||||
var name = artistEl.GetProperty("name").GetString();
|
||||
var id = artistEl.GetProperty("id").GetInt64();
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
{
|
||||
allArtists.Add(name);
|
||||
allArtistIds.Add($"ext-squidwtf-artist-{id}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -733,6 +764,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
artistName = artist.GetProperty("name").GetString() ?? "";
|
||||
artistIdNum = artist.GetProperty("id").GetInt64();
|
||||
allArtists.Add(artistName);
|
||||
allArtistIds.Add($"ext-squidwtf-artist-{artistIdNum}");
|
||||
}
|
||||
|
||||
// Album artist - same as main artist for Tidal tracks
|
||||
@@ -768,6 +800,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
Artist = artistName,
|
||||
ArtistId = $"ext-squidwtf-artist-{artistIdNum}",
|
||||
Artists = allArtists,
|
||||
ArtistIds = allArtistIds,
|
||||
Album = albumTitle,
|
||||
AlbumId = $"ext-squidwtf-album-{albumIdNum}",
|
||||
AlbumArtist = albumArtist,
|
||||
|
||||
@@ -73,7 +73,11 @@ public class SquidWTFStartupValidator : BaseStartupValidator
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync(endpoint, ct);
|
||||
// 5 second timeout per ping - mark slow endpoints as failed
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCts.CancelAfter(TimeSpan.FromSeconds(5));
|
||||
|
||||
var response = await _httpClient.GetAsync(endpoint, timeoutCts.Token);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
catch
|
||||
|
||||
@@ -5,13 +5,14 @@
|
||||
<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>
|
||||
<Version>1.3.0</Version>
|
||||
<AssemblyVersion>1.3.0.0</AssemblyVersion>
|
||||
<FileVersion>1.3.0.0</FileVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<PackageReference Include="Cronos" Version="0.11.1" />
|
||||
<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" />
|
||||
|
||||
@@ -32,8 +32,7 @@
|
||||
"EnableExternalPlaylists": true
|
||||
},
|
||||
"Library": {
|
||||
"DownloadPath": "./downloads",
|
||||
"KeptPath": "/app/kept"
|
||||
"DownloadPath": "./downloads"
|
||||
},
|
||||
"Qobuz": {
|
||||
"UserAuthToken": "your-qobuz-token",
|
||||
@@ -62,8 +61,6 @@
|
||||
},
|
||||
"SpotifyApi": {
|
||||
"Enabled": false,
|
||||
"ClientId": "",
|
||||
"ClientSecret": "",
|
||||
"SessionCookie": "",
|
||||
"CacheDurationMinutes": 60,
|
||||
"RateLimitDelayMs": 100,
|
||||
|
||||
@@ -537,7 +537,7 @@
|
||||
<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="playlists">Injected Playlists</div>
|
||||
<div class="tab" data-tab="config">Configuration</div>
|
||||
<div class="tab" data-tab="endpoints">API Analytics</div>
|
||||
</div>
|
||||
@@ -652,7 +652,7 @@
|
||||
|
||||
<div class="card">
|
||||
<h2>
|
||||
Active Spotify Playlists
|
||||
Injected Spotify Playlists
|
||||
<div class="actions">
|
||||
<button onclick="matchAllPlaylists()" title="Match tracks for all playlists against your local library and external providers. This may take several minutes.">Match All Tracks</button>
|
||||
<button onclick="refreshPlaylists()" title="Fetch the latest playlist data from Spotify without re-matching tracks.">Refresh All</button>
|
||||
@@ -660,13 +660,14 @@
|
||||
</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.
|
||||
These are the Spotify playlists currently being injected into Jellyfin with tracks from your music service.
|
||||
</p>
|
||||
<table class="playlist-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Spotify ID</th>
|
||||
<th>Sync Schedule</th>
|
||||
<th>Tracks</th>
|
||||
<th>Completion</th>
|
||||
<th>Cache Age</th>
|
||||
@@ -675,7 +676,7 @@
|
||||
</thead>
|
||||
<tbody id="playlist-table-body">
|
||||
<tr>
|
||||
<td colspan="6" class="loading">
|
||||
<td colspan="7" class="loading">
|
||||
<span class="spinner"></span> Loading playlists...
|
||||
</td>
|
||||
</tr>
|
||||
@@ -806,8 +807,62 @@
|
||||
|
||||
<!-- Configuration Tab -->
|
||||
<div class="tab-content" id="tab-config">
|
||||
<div class="card">
|
||||
<h2>Core Settings</h2>
|
||||
<div class="config-section">
|
||||
<div class="config-item">
|
||||
<span class="label">Backend Type <span style="color: var(--error);">*</span></span>
|
||||
<span class="value" id="config-backend-type">-</span>
|
||||
<button onclick="openEditSetting('BACKEND_TYPE', 'Backend Type', 'select', 'Choose your media server backend', ['Jellyfin', 'Subsonic'])">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Music Service <span style="color: var(--error);">*</span></span>
|
||||
<span class="value" id="config-music-service">-</span>
|
||||
<button onclick="openEditSetting('MUSIC_SERVICE', 'Music Service', 'select', 'Choose your music download provider', ['SquidWTF', 'Deezer', 'Qobuz'])">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Storage Mode</span>
|
||||
<span class="value" id="config-storage-mode">-</span>
|
||||
<button onclick="openEditSetting('STORAGE_MODE', 'Storage Mode', 'select', 'Permanent keeps files forever, Cache auto-deletes after duration', ['Permanent', 'Cache'])">Edit</button>
|
||||
</div>
|
||||
<div class="config-item" id="cache-duration-row" style="display: none;">
|
||||
<span class="label">Cache Duration (hours)</span>
|
||||
<span class="value" id="config-cache-duration-hours">-</span>
|
||||
<button onclick="openEditSetting('CACHE_DURATION_HOURS', 'Cache Duration (hours)', 'number', 'How long to keep cached files before deletion')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Download Mode</span>
|
||||
<span class="value" id="config-download-mode">-</span>
|
||||
<button onclick="openEditSetting('DOWNLOAD_MODE', 'Download Mode', 'select', 'Download individual tracks or full albums', ['Track', 'Album'])">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Explicit Filter</span>
|
||||
<span class="value" id="config-explicit-filter">-</span>
|
||||
<button onclick="openEditSetting('EXPLICIT_FILTER', 'Explicit Filter', 'select', 'Filter explicit content', ['All', 'Explicit', 'Clean'])">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Enable External Playlists</span>
|
||||
<span class="value" id="config-enable-external-playlists">-</span>
|
||||
<button onclick="openEditSetting('ENABLE_EXTERNAL_PLAYLISTS', 'Enable External Playlists', 'toggle')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Playlists Directory</span>
|
||||
<span class="value" id="config-playlists-directory">-</span>
|
||||
<button onclick="openEditSetting('PLAYLISTS_DIRECTORY', 'Playlists Directory', 'text', 'Directory path for external playlists')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Redis Enabled</span>
|
||||
<span class="value" id="config-redis-enabled">-</span>
|
||||
<button onclick="openEditSetting('REDIS_ENABLED', 'Redis Enabled', 'toggle')">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Spotify API Settings</h2>
|
||||
<div style="background: rgba(248, 81, 73, 0.15); border: 1px solid var(--error); border-radius: 6px; padding: 12px; margin-bottom: 16px; color: var(--text-primary);">
|
||||
⚠️ For active playlists and link functionality to work, sp_dc session cookie must be set!
|
||||
</div>
|
||||
<div class="config-section">
|
||||
<div class="config-item">
|
||||
<span class="label">API Enabled</span>
|
||||
@@ -815,7 +870,7 @@
|
||||
<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="label">Session Cookie (sp_dc) <span style="color: var(--error);">*</span></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>
|
||||
@@ -904,17 +959,17 @@
|
||||
<h2>Jellyfin Settings</h2>
|
||||
<div class="config-section">
|
||||
<div class="config-item">
|
||||
<span class="label">URL</span>
|
||||
<span class="label">URL <span style="color: var(--error);">*</span></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="label">API Key <span style="color: var(--error);">*</span></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="label">User ID <span style="color: var(--error);">*</span></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>
|
||||
@@ -943,17 +998,17 @@
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Sync Schedule</h2>
|
||||
<h2>Spotify Import Settings</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>
|
||||
<span class="label">Spotify Import Enabled</span>
|
||||
<span class="value" id="config-spotify-import-enabled">-</span>
|
||||
<button onclick="openEditSetting('SPOTIFY_IMPORT_ENABLED', 'Spotify Import Enabled', 'toggle')">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>
|
||||
<span class="label">Matching Interval (hours)</span>
|
||||
<span class="value" id="config-matching-interval">-</span>
|
||||
<button onclick="openEditSetting('SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS', 'Matching Interval (hours)', 'number', 'How often to check for playlist updates')">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1119,7 +1174,7 @@
|
||||
<div class="modal-content" style="max-width: 600px;">
|
||||
<h3>Map Track to External Provider</h3>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||
Map this track to an external provider (SquidWTF, Deezer, or Qobuz). For local Jellyfin tracks, use the Spotify Import plugin instead.
|
||||
Map this track to an external provider (SquidWTF, Deezer, or Qobuz). For local Jellyfin tracks, use the Jellyfin mapping modal instead.
|
||||
</p>
|
||||
|
||||
<!-- Track Info -->
|
||||
@@ -1161,25 +1216,94 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Local Jellyfin Track Mapping Modal -->
|
||||
<div class="modal" id="local-map-modal">
|
||||
<div class="modal-content" style="max-width: 700px;">
|
||||
<h3>Map Track to Local Jellyfin Track</h3>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||
Search your Jellyfin library and select a local track to map to this Spotify track.
|
||||
</p>
|
||||
|
||||
<!-- Track Info -->
|
||||
<div class="form-group">
|
||||
<label>Spotify Track (Position <span id="local-map-position"></span>)</label>
|
||||
<div style="background: var(--bg-primary); padding: 12px; border-radius: 8px; margin-bottom: 16px;">
|
||||
<strong id="local-map-spotify-title"></strong><br>
|
||||
<span style="color: var(--text-secondary);" id="local-map-spotify-artist"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Section -->
|
||||
<div class="form-group">
|
||||
<label>Search Jellyfin Library</label>
|
||||
<input type="text" id="local-map-search" placeholder="Search for track name or artist...">
|
||||
<button onclick="searchJellyfinTracks()" style="margin-top: 8px; width: 100%;">🔍 Search</button>
|
||||
</div>
|
||||
|
||||
<!-- Search Results -->
|
||||
<div id="local-map-results" style="max-height: 300px; overflow-y: auto; margin-top: 16px;"></div>
|
||||
|
||||
<input type="hidden" id="local-map-playlist-name">
|
||||
<input type="hidden" id="local-map-spotify-id">
|
||||
<input type="hidden" id="local-map-jellyfin-id">
|
||||
<div class="modal-actions">
|
||||
<button onclick="closeModal('local-map-modal')">Cancel</button>
|
||||
<button class="primary" onclick="saveLocalMapping()" id="local-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.
|
||||
Select a playlist from your Spotify library or enter a playlist ID/URL manually. 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">
|
||||
|
||||
<!-- Toggle between select and manual input -->
|
||||
<div style="display: flex; gap: 8px; margin-bottom: 16px;">
|
||||
<button type="button" id="select-mode-btn" class="primary" onclick="switchLinkMode('select')" style="flex: 1;">Select from My Playlists</button>
|
||||
<button type="button" id="manual-mode-btn" onclick="switchLinkMode('manual')" style="flex: 1;">Enter Manually</button>
|
||||
</div>
|
||||
|
||||
<!-- Select from user playlists -->
|
||||
<div class="form-group" id="link-select-group">
|
||||
<label>Your Spotify Playlists</label>
|
||||
<select id="link-spotify-select" style="width: 100%;">
|
||||
<option value="">Loading playlists...</option>
|
||||
</select>
|
||||
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
|
||||
Select a playlist from your Spotify library
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Manual input -->
|
||||
<div class="form-group" id="link-manual-group" style="display: none;">
|
||||
<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>
|
||||
|
||||
<!-- Sync Schedule -->
|
||||
<div class="form-group">
|
||||
<label>Sync Schedule (Cron)</label>
|
||||
<input type="text" id="link-sync-schedule" placeholder="0 8 * * 1" value="0 8 * * 1" style="font-family: monospace;">
|
||||
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
|
||||
Cron format: <code>minute hour day month dayofweek</code><br>
|
||||
Default: <code>0 8 * * 1</code> = 8 AM every Monday<br>
|
||||
Examples: <code>0 6 * * *</code> = daily at 6 AM, <code>0 20 * * 5</code> = Fridays at 8 PM<br>
|
||||
<a href="https://crontab.guru/" target="_blank" style="color: var(--primary);">Use crontab.guru to build your schedule</a>
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button onclick="closeModal('link-playlist-modal')">Cancel</button>
|
||||
<button class="primary" onclick="linkPlaylist()">Link Playlist</button>
|
||||
@@ -1460,7 +1584,7 @@
|
||||
|
||||
if (data.playlists.length === 0) {
|
||||
if (!silent) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists configured. Link playlists from the Jellyfin Playlists tab.</td></tr>';
|
||||
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;
|
||||
}
|
||||
@@ -1514,10 +1638,16 @@
|
||||
// Debug logging
|
||||
console.log(`Progress bar for ${p.name}: local=${localPct}%, external=${externalPct}%, missing=${missingPct}%, total=${completionPct}%`);
|
||||
|
||||
const syncSchedule = p.syncSchedule || '0 8 * * 1';
|
||||
|
||||
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 style="font-family:monospace;font-size:0.85rem;">
|
||||
${escapeHtml(syncSchedule)}
|
||||
<button onclick="editPlaylistSchedule('${escapeJs(p.name)}', '${escapeJs(syncSchedule)}')" style="margin-left:4px;font-size:0.75rem;padding:2px 6px;">Edit</button>
|
||||
</td>
|
||||
<td>${statsHtml}${breakdown}</td>
|
||||
<td>
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
@@ -1776,6 +1906,23 @@
|
||||
const res = await fetch('/api/admin/config');
|
||||
const data = await res.json();
|
||||
|
||||
// Core settings
|
||||
document.getElementById('config-backend-type').textContent = data.backendType || 'Jellyfin';
|
||||
document.getElementById('config-music-service').textContent = data.musicService || 'SquidWTF';
|
||||
document.getElementById('config-storage-mode').textContent = data.library?.storageMode || 'Cache';
|
||||
document.getElementById('config-cache-duration-hours').textContent = data.library?.cacheDurationHours || '24';
|
||||
document.getElementById('config-download-mode').textContent = data.library?.downloadMode || 'Track';
|
||||
document.getElementById('config-explicit-filter').textContent = data.explicitFilter || 'All';
|
||||
document.getElementById('config-enable-external-playlists').textContent = data.enableExternalPlaylists ? 'Yes' : 'No';
|
||||
document.getElementById('config-playlists-directory').textContent = data.playlistsDirectory || '(not set)';
|
||||
document.getElementById('config-redis-enabled').textContent = data.redisEnabled ? 'Yes' : 'No';
|
||||
|
||||
// Show/hide cache duration based on storage mode
|
||||
const cacheDurationRow = document.getElementById('cache-duration-row');
|
||||
if (cacheDurationRow) {
|
||||
cacheDurationRow.style.display = data.library?.storageMode === 'Cache' ? 'grid' : 'none';
|
||||
}
|
||||
|
||||
// Spotify API settings
|
||||
document.getElementById('config-spotify-enabled').textContent = data.spotifyApi.enabled ? 'Yes' : 'No';
|
||||
document.getElementById('config-spotify-cookie').textContent = data.spotifyApi.sessionCookie;
|
||||
@@ -1817,10 +1964,8 @@
|
||||
document.getElementById('config-kept-path').textContent = data.library?.keptPath || '/app/kept';
|
||||
|
||||
// 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';
|
||||
document.getElementById('config-spotify-import-enabled').textContent = data.spotifyImport?.enabled ? 'Yes' : 'No';
|
||||
document.getElementById('config-matching-interval').textContent = (data.spotifyImport?.matchingIntervalHours || 24) + ' hours';
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch config:', error);
|
||||
}
|
||||
@@ -1896,23 +2041,138 @@
|
||||
}
|
||||
}
|
||||
|
||||
function openLinkPlaylist(jellyfinId, name) {
|
||||
let currentLinkMode = 'select'; // 'select' or 'manual'
|
||||
let spotifyUserPlaylists = []; // Cache of user playlists
|
||||
|
||||
function switchLinkMode(mode) {
|
||||
currentLinkMode = mode;
|
||||
|
||||
const selectGroup = document.getElementById('link-select-group');
|
||||
const manualGroup = document.getElementById('link-manual-group');
|
||||
const selectBtn = document.getElementById('select-mode-btn');
|
||||
const manualBtn = document.getElementById('manual-mode-btn');
|
||||
|
||||
if (mode === 'select') {
|
||||
selectGroup.style.display = 'block';
|
||||
manualGroup.style.display = 'none';
|
||||
selectBtn.classList.add('primary');
|
||||
manualBtn.classList.remove('primary');
|
||||
} else {
|
||||
selectGroup.style.display = 'none';
|
||||
manualGroup.style.display = 'block';
|
||||
selectBtn.classList.remove('primary');
|
||||
manualBtn.classList.add('primary');
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSpotifyUserPlaylists() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/spotify/user-playlists');
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
console.error('Failed to fetch Spotify playlists:', res.status, error);
|
||||
|
||||
// Show user-friendly error message
|
||||
if (res.status === 429) {
|
||||
showToast('Spotify rate limit reached. Please wait a moment and try again.', 'warning', 5000);
|
||||
} else if (res.status === 401) {
|
||||
showToast('Spotify authentication failed. Check your sp_dc cookie.', 'error', 5000);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
const data = await res.json();
|
||||
return data.playlists || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch Spotify playlists:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function openLinkPlaylist(jellyfinId, name) {
|
||||
document.getElementById('link-jellyfin-id').value = jellyfinId;
|
||||
document.getElementById('link-jellyfin-name').value = name;
|
||||
document.getElementById('link-spotify-id').value = '';
|
||||
|
||||
// Reset to select mode
|
||||
switchLinkMode('select');
|
||||
|
||||
// Fetch user playlists if not already cached
|
||||
if (spotifyUserPlaylists.length === 0) {
|
||||
const select = document.getElementById('link-spotify-select');
|
||||
select.innerHTML = '<option value="">Loading playlists...</option>';
|
||||
|
||||
spotifyUserPlaylists = await fetchSpotifyUserPlaylists();
|
||||
|
||||
// Filter out already-linked playlists
|
||||
const availablePlaylists = spotifyUserPlaylists.filter(p => !p.isLinked);
|
||||
|
||||
if (availablePlaylists.length === 0) {
|
||||
if (spotifyUserPlaylists.length > 0) {
|
||||
select.innerHTML = '<option value="">All your playlists are already linked</option>';
|
||||
} else {
|
||||
select.innerHTML = '<option value="">No playlists found or Spotify not configured</option>';
|
||||
}
|
||||
// Switch to manual mode if no available playlists
|
||||
switchLinkMode('manual');
|
||||
} else {
|
||||
// Populate dropdown with only unlinked playlists
|
||||
select.innerHTML = '<option value="">-- Select a playlist --</option>' +
|
||||
availablePlaylists.map(p =>
|
||||
`<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)} (${p.trackCount} tracks)</option>`
|
||||
).join('');
|
||||
}
|
||||
} else {
|
||||
// Re-filter in case playlists were linked since last fetch
|
||||
const select = document.getElementById('link-spotify-select');
|
||||
const availablePlaylists = spotifyUserPlaylists.filter(p => !p.isLinked);
|
||||
|
||||
if (availablePlaylists.length === 0) {
|
||||
select.innerHTML = '<option value="">All your playlists are already linked</option>';
|
||||
switchLinkMode('manual');
|
||||
} else {
|
||||
select.innerHTML = '<option value="">-- Select a playlist --</option>' +
|
||||
availablePlaylists.map(p =>
|
||||
`<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)} (${p.trackCount} tracks)</option>`
|
||||
).join('');
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
const syncSchedule = document.getElementById('link-sync-schedule').value.trim();
|
||||
|
||||
if (!spotifyId) {
|
||||
showToast('Spotify Playlist ID is required', 'error');
|
||||
// Validate sync schedule (basic cron format check)
|
||||
if (!syncSchedule) {
|
||||
showToast('Sync schedule is required', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const cronParts = syncSchedule.split(/\s+/);
|
||||
if (cronParts.length !== 5) {
|
||||
showToast('Invalid cron format. Expected: minute hour day month dayofweek', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get Spotify ID based on current mode
|
||||
let spotifyId = '';
|
||||
if (currentLinkMode === 'select') {
|
||||
spotifyId = document.getElementById('link-spotify-select').value;
|
||||
if (!spotifyId) {
|
||||
showToast('Please select a Spotify playlist', 'error');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
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
|
||||
@@ -1935,7 +2195,11 @@
|
||||
const res = await fetch(`/api/admin/jellyfin/playlists/${encodeURIComponent(jellyfinId)}/link`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, spotifyPlaylistId: cleanSpotifyId })
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
spotifyPlaylistId: cleanSpotifyId,
|
||||
syncSchedule: syncSchedule
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
@@ -1945,6 +2209,9 @@
|
||||
showRestartBanner();
|
||||
closeModal('link-playlist-modal');
|
||||
|
||||
// Clear the Spotify playlists cache so it refreshes next time
|
||||
spotifyUserPlaylists = [];
|
||||
|
||||
// Update UI state without refetching all playlists
|
||||
const playlistsTable = document.getElementById('jellyfinPlaylistsTable');
|
||||
if (playlistsTable) {
|
||||
@@ -1982,6 +2249,9 @@
|
||||
showToast('Playlist unlinked.', 'success');
|
||||
showRestartBanner();
|
||||
|
||||
// Clear the Spotify playlists cache so it refreshes next time
|
||||
spotifyUserPlaylists = [];
|
||||
|
||||
// Update UI state without refetching all playlists
|
||||
const playlistsTable = document.getElementById('jellyfinPlaylistsTable');
|
||||
if (playlistsTable) {
|
||||
@@ -2345,6 +2615,39 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function editPlaylistSchedule(playlistName, currentSchedule) {
|
||||
const newSchedule = prompt(`Edit sync schedule for "${playlistName}"\n\nCron format: minute hour day month dayofweek\nExamples:\n• 0 8 * * 1 = Monday 8 AM\n• 0 6 * * * = Daily 6 AM\n• 0 20 * * 5 = Friday 8 PM\n\nUse https://crontab.guru/ to build your schedule`, currentSchedule);
|
||||
|
||||
if (!newSchedule || newSchedule === currentSchedule) return;
|
||||
|
||||
// Validate cron format
|
||||
const cronParts = newSchedule.trim().split(/\s+/);
|
||||
if (cronParts.length !== 5) {
|
||||
showToast('Invalid cron format. Expected: minute hour day month dayofweek', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(playlistName)}/schedule`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ syncSchedule: newSchedule.trim() })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
showToast('Sync schedule updated!', 'success');
|
||||
showRestartBanner();
|
||||
fetchPlaylists();
|
||||
} else {
|
||||
const error = await res.json();
|
||||
showToast(error.error || 'Failed to update schedule', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update schedule:', error);
|
||||
showToast('Failed to update schedule', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function removePlaylist(name) {
|
||||
if (!confirm(`Remove playlist "${name}"?`)) return;
|
||||
|
||||
@@ -2374,8 +2677,23 @@
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/playlists/' + encodeURIComponent(name) + '/tracks');
|
||||
|
||||
if (!res.ok) {
|
||||
console.error('Failed to fetch tracks:', res.status, res.statusText);
|
||||
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Failed to load tracks: ' + res.status + ' ' + res.statusText + '</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
console.log('Tracks data received:', data);
|
||||
|
||||
if (!data || !data.tracks) {
|
||||
console.error('Invalid data structure:', data);
|
||||
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Invalid data received from server</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -2490,7 +2808,8 @@
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Failed to load tracks</p>';
|
||||
console.error('Error in viewTracks:', error);
|
||||
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Failed to load tracks: ' + error.message + '</p>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2715,8 +3034,27 @@
|
||||
saveBtn.disabled = !externalId;
|
||||
}
|
||||
|
||||
// Open manual mapping modal (external only)
|
||||
// Open local Jellyfin mapping modal
|
||||
function openManualMap(playlistName, position, title, artist, spotifyId) {
|
||||
document.getElementById('local-map-playlist-name').value = playlistName;
|
||||
document.getElementById('local-map-position').textContent = position + 1;
|
||||
document.getElementById('local-map-spotify-title').textContent = title;
|
||||
document.getElementById('local-map-spotify-artist').textContent = artist;
|
||||
document.getElementById('local-map-spotify-id').value = spotifyId;
|
||||
|
||||
// Pre-fill search with track info
|
||||
document.getElementById('local-map-search').value = `${title} ${artist}`;
|
||||
|
||||
// Reset fields
|
||||
document.getElementById('local-map-results').innerHTML = '';
|
||||
document.getElementById('local-map-jellyfin-id').value = '';
|
||||
document.getElementById('local-map-save-btn').disabled = true;
|
||||
|
||||
openModal('local-map-modal');
|
||||
}
|
||||
|
||||
// Open external mapping modal
|
||||
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;
|
||||
@@ -2731,12 +3069,123 @@
|
||||
openModal('manual-map-modal');
|
||||
}
|
||||
|
||||
// Alias for backward compatibility
|
||||
function openExternalMap(playlistName, position, title, artist, spotifyId) {
|
||||
openManualMap(playlistName, position, title, artist, spotifyId);
|
||||
// Search Jellyfin tracks for local mapping
|
||||
async function searchJellyfinTracks() {
|
||||
const query = document.getElementById('local-map-search').value.trim();
|
||||
if (!query) {
|
||||
showToast('Please enter a search query', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const resultsDiv = document.getElementById('local-map-results');
|
||||
resultsDiv.innerHTML = '<p style="text-align:center;padding:20px;">Searching...</p>';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/jellyfin/search?query=' + encodeURIComponent(query));
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
resultsDiv.innerHTML = '<p style="text-align:center;color:var(--error);padding:20px;">Search failed</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data.tracks || data.tracks.length === 0) {
|
||||
resultsDiv.innerHTML = '<p style="text-align:center;color:var(--text-secondary);padding:20px;">No tracks found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
resultsDiv.innerHTML = data.tracks.map(track => `
|
||||
<div style="padding: 12px; border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px; cursor: pointer; transition: background 0.2s;"
|
||||
onclick="selectJellyfinTrack('${escapeJs(track.id)}', '${escapeJs(track.name)}', '${escapeJs(track.artist)}')"
|
||||
onmouseover="this.style.background='var(--bg-primary)'"
|
||||
onmouseout="this.style.background='transparent'">
|
||||
<strong>${escapeHtml(track.name)}</strong><br>
|
||||
<span style="color: var(--text-secondary); font-size: 0.9em;">${escapeHtml(track.artist)}</span>
|
||||
${track.album ? '<br><span style="color: var(--text-secondary); font-size: 0.85em;">' + escapeHtml(track.album) + '</span>' : ''}
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
resultsDiv.innerHTML = '<p style="text-align:center;color:var(--error);padding:20px;">Search failed</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// Save manual mapping (external only)
|
||||
// Select a Jellyfin track for mapping
|
||||
function selectJellyfinTrack(jellyfinId, name, artist) {
|
||||
document.getElementById('local-map-jellyfin-id').value = jellyfinId;
|
||||
document.getElementById('local-map-save-btn').disabled = false;
|
||||
|
||||
// Highlight selected track
|
||||
document.querySelectorAll('#local-map-results > div').forEach(div => {
|
||||
div.style.background = 'transparent';
|
||||
div.style.border = '1px solid var(--border)';
|
||||
});
|
||||
event.target.closest('div').style.background = 'var(--primary)';
|
||||
event.target.closest('div').style.border = '1px solid var(--primary)';
|
||||
}
|
||||
|
||||
// Save local Jellyfin mapping
|
||||
async function saveLocalMapping() {
|
||||
const playlistName = document.getElementById('local-map-playlist-name').value;
|
||||
const spotifyId = document.getElementById('local-map-spotify-id').value;
|
||||
const jellyfinId = document.getElementById('local-map-jellyfin-id').value;
|
||||
|
||||
if (!jellyfinId) {
|
||||
showToast('Please select a Jellyfin track', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
spotifyId,
|
||||
jellyfinId
|
||||
};
|
||||
|
||||
// Show loading state
|
||||
const saveBtn = document.getElementById('local-map-save-btn');
|
||||
const originalText = saveBtn.textContent;
|
||||
saveBtn.textContent = 'Saving...';
|
||||
saveBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 30000);
|
||||
|
||||
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);
|
||||
|
||||
if (res.ok) {
|
||||
showToast('Track mapped successfully!', 'success');
|
||||
closeModal('local-map-modal');
|
||||
|
||||
// Refresh the tracks view if it's open
|
||||
const tracksModal = document.getElementById('tracks-modal');
|
||||
if (tracksModal.style.display === 'flex') {
|
||||
await viewTracks(playlistName);
|
||||
}
|
||||
} else {
|
||||
const data = await res.json();
|
||||
showToast(data.error || 'Failed to save mapping', 'error');
|
||||
saveBtn.textContent = originalText;
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
showToast('Request timed out. The mapping may still be processing.', 'warning');
|
||||
} else {
|
||||
showToast('Failed to save mapping', 'error');
|
||||
}
|
||||
saveBtn.textContent = originalText;
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Save manual mapping (external only) - kept for backward compatibility
|
||||
async function saveManualMapping() {
|
||||
const playlistName = document.getElementById('map-playlist-name').value;
|
||||
const spotifyId = document.getElementById('map-spotify-id').value;
|
||||
|
||||
@@ -17,8 +17,11 @@ services:
|
||||
networks:
|
||||
- allstarr-network
|
||||
|
||||
# Spotify Lyrics API sidecar service
|
||||
# Note: This image only supports AMD64. On ARM64 systems, Docker will use emulation.
|
||||
spotify-lyrics:
|
||||
image: akashrchandran/spotify-lyrics-api:latest
|
||||
platform: linux/amd64
|
||||
container_name: allstarr-spotify-lyrics
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -104,8 +107,6 @@ services:
|
||||
|
||||
# ===== 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}
|
||||
|
||||
Reference in New Issue
Block a user