Compare commits

...

97 Commits

Author SHA1 Message Date
74bd64c949 add missing using statement for ApiKeyAuthFilter
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-01-31 20:03:26 -05:00
1afa68064e add API key authentication to Spotify admin endpoints 2026-01-31 20:01:59 -05:00
5251c7ef6d add spotify cache clear endpoint 2026-01-31 20:01:12 -05:00
63ab25ca91 parse SPOTIFY_IMPORT_PLAYLIST_NAMES env var 2026-01-31 19:53:03 -05:00
628f845e77 add missing SPOTIFY_IMPORT_PLAYLIST_NAMES env var 2026-01-31 19:49:57 -05:00
8ef5ee7d8f fix background service to use configured playlist names 2026-01-31 19:44:47 -05:00
fb3ea1b876 fix playlist names format in env example 2026-01-31 19:42:11 -05:00
3f3e1b708d add configurable playlist names env var 2026-01-31 19:37:10 -05:00
bc4faead74 fix playlist names to use underscores for filenames 2026-01-31 19:35:55 -05:00
6ffa2a3277 use hardcoded playlist names for Spotify sync 2026-01-31 19:34:45 -05:00
c3c01b5559 check every minute for missing tracks files 2026-01-31 19:33:02 -05:00
47d59ec0f5 search last 24 hours for missing tracks files in manual sync 2026-01-31 19:30:01 -05:00
e7f72cd87a add manual Spotify sync trigger endpoint 2026-01-31 19:12:34 -05:00
6d15d02f16 add Spotify Import environment variables to docker-compose.yml 2026-01-31 18:11:25 -05:00
3137cc4657 fix: remove duplicate Spotify playlist interception code with compilation error 2026-01-31 18:07:22 -05:00
18e700d6a4 Fix compilation errors and remove unused code 2026-01-31 18:03:55 -05:00
2420cd9a23 Fix SpotifyMissingTracksFetcher to work with ID-based configuration and add detailed logging 2026-01-31 18:00:24 -05:00
65d6eb041a Refactor Spotify playlist injection to use playlist IDs instead of names 2026-01-31 17:59:01 -05:00
103808f079 Add logging to every ProxyRequest call 2026-01-31 17:54:51 -05:00
cd29e0de6c Add debug logging to diagnose Spotify configuration issue 2026-01-31 17:21:14 -05:00
bd480be382 Fix Spotify playlist interception in ProxyRequest with detailed logging 2026-01-31 17:17:59 -05:00
293f6f5cc4 Add detailed logging to GetPlaylistItems interception 2026-01-31 17:16:10 -05:00
e9b893eb3e Add detailed Spotify Import configuration logging 2026-01-31 17:14:24 -05:00
51694a395d Add comprehensive debug logging for Spotify playlist interception 2026-01-31 17:09:28 -05:00
32166061ef Add Spotify playlist interception in ProxyRequest method 2026-01-31 17:07:31 -05:00
a8845a9ef3 Change GetPlaylistItems route order to 1 and add debug logging 2026-01-31 17:05:56 -05:00
e873cfe3bf Fix JsonDocument.RootElement access in GetPlaylistTracks 2026-01-31 17:03:36 -05:00
43718eaefc Remove duplicate GetPlaylistItems route and add constructor logging 2026-01-31 17:02:35 -05:00
5f9451f5b4 Add playlist parsing from env var and debug logging for Spotify feature 2026-01-31 17:00:16 -05:00
2c3ef5c360 Fix Spotify playlist injection: add dedicated route, startup fetch, and clarify config 2026-01-31 16:57:52 -05:00
4ba2245876 refactor: optimize playlist interception order
- Check external playlists first (fast check)
- Only check Spotify if enabled (avoid extra API call)
- Fall back to regular Jellyfin playlists
- Maintains correct behavior for all playlist types
2026-01-31 16:54:39 -05:00
c117fa41f6 fix: add route to intercept playlist items requests
- Add GetPlaylistItems route with Order=5 (before catch-all)
- Intercepts /playlists/{id}/items requests
- Routes to GetPlaylistTracks for Spotify playlist handling
2026-01-31 16:54:01 -05:00
2b078453b2 refactor: reuse Jellyfin settings for Spotify feature
- Remove duplicate JellyfinUrl and ApiKey from SpotifyImportSettings
- Use existing JELLYFIN_URL and JELLYFIN_API_KEY settings
- Simplify configuration - no duplicate settings needed
- Update documentation and .env.example
2026-01-31 16:52:26 -05:00
0ee1883ccb fix: intercept existing Jellyfin Spotify playlists
- Check playlist name instead of creating virtual playlists
- Intercept playlist items request for Spotify playlists
- Fill empty playlists with matched tracks from providers
- Works with existing Jellyfin Spotify Import plugin playlists
2026-01-31 16:50:16 -05:00
8912758b5e feat: spotify playlist injection
- Add SpotifyImportSettings configuration model
- Create SpotifyMissingTracksFetcher background service
- Inject virtual Spotify playlists into search results
- Auto-match tracks from external providers
- Update README with feature documentation
- Configure sync window and playlist settings
2026-01-31 16:43:49 -05:00
35d5249843 chore: add sampleMissingPlaylists to gitignore 2026-01-31 12:09:16 -05:00
62bfb367bc docs: update README
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-01-31 11:15:44 -05:00
6f91361966 refactor: use token-based fuzzy matching for flexible search
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-01-31 01:03:14 -05:00
d4036095f1 fix: check all permutations of title/artist/album in search scoring 2026-01-31 01:00:56 -05:00
6620b39357 fix: remove score filtering, add combined title+artist matching 2026-01-31 01:00:00 -05:00
dcaa89171a refactor: change external suffix from H to S (SquidWTF) 2026-01-31 00:50:55 -05:00
1889dc6e19 fix: gracefully skip malformed playlists instead of failing all endpoints 2026-01-31 00:47:59 -05:00
615ad58bc6 refactor: change external provider suffix from SW to H 2026-01-31 00:08:11 -05:00
6176777d0f fix: forward client auth headers for login 2026-01-30 22:09:09 -05:00
a339574f05 fix: forward caching headers for client-side caching
Jellyfin sends ETag, Last-Modified, and Cache-Control headers that allow clients like Feishin to cache songs locally. Proxy now forwards these headers so clients don't re-download songs unnecessarily.
2026-01-30 22:02:35 -05:00
67b4fac64c Merge branch 'main' into dev
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-01-30 14:12:05 -05:00
ada6653bd1 fix: enable deduplication for cache mode
- Cache mode now registers downloaded songs in mappings
- Prevents duplicate downloads in cache mode
- Only library scan/album download skip cache mode
- Fixes wasted API calls and duplicate cache files
2026-01-30 14:10:34 -05:00
df8dbfc5e1 fix: prevent duplicate downloads by registering before releasing lock
- Move RegisterDownloadedSongAsync call before lock release
- Prevents race condition where multiple threads download same song
- Second thread now finds registered mapping before starting download
- Fixes duplicate files like 'Song (1).flac', 'Song (2).flac'
- Library scan and album download still happen after lock release
2026-01-30 13:37:42 -05:00
e8d3fc4d17 refactor: simplify endpoint comments to just names 2026-01-30 13:21:44 -05:00
649351f68b feat: add automatic fallback support for SquidWTF endpoints
- Decode 6 base64 URLs at startup (1 primary + 5 backups)
- Automatic fallback when endpoint fails
- All services try next endpoint on failure
- Metadata, Download, and Validator all support fallback
- Endpoints: triton.squid.wtf, wolf/hund/maus/vogel/katze.qqdl.site
- Logs which endpoint is being used
- Cycles through all endpoints before giving up
2026-01-30 13:21:23 -05:00
3487f79b5e refactor: pass decoded SquidWTF URL from Program.cs to services
- Decode base64 URL once at startup in Program.cs
- Pass decoded URL as constructor parameter to all SquidWTF services
- Services receive plain URL, no decoding logic in service classes
- Cleaner separation: encoding/decoding only in Program.cs
- All service code remains unchanged, just receives decoded URL
2026-01-30 13:16:26 -05:00
3a3f572ead fix: decode base64 URLs once at startup in static fields
- Changed from decoding in constructor to static readonly fields
- Decode happens once per class initialization, stored in memory
- Cleaner implementation, no repeated decoding per instance
- All three SquidWTF services updated (Metadata, Download, Validator)
2026-01-30 13:13:16 -05:00
d7f15fc3ab Add base64 encoding for SquidWTF endpoint (without trailing slash) 2026-01-30 13:08:10 -05:00
e43f5cd427 REVERT: Remove base64 encoding and endpoint fallback, restore simple hardcoded URL 2026-01-30 12:57:42 -05:00
1b79138923 Fix HTTP 307 redirect handling: configure HttpClient to follow redirects 2026-01-30 12:49:58 -05:00
dda9736f8d Fix endpoint fallback: remove shared state, always try primary first 2026-01-30 12:46:40 -05:00
9493cb48a5 Merge branch 'main' into dev 2026-01-30 12:40:41 -05:00
6c2453896f Add automatic endpoint fallback to all SquidWTF metadata search methods 2026-01-30 12:40:34 -05:00
40594dea7e Merge branch 'main' into dev 2026-01-30 12:38:57 -05:00
a06bf42887 Standardize all SquidWTF API URL formats to prevent double slashes 2026-01-30 12:38:19 -05:00
6713007650 Merge branch 'main' into dev 2026-01-30 12:37:13 -05:00
e7724c2cc0 Fix double slash bug in SquidWTF API URLs 2026-01-30 12:36:16 -05:00
3358fe019d Merge branch 'main' into dev 2026-01-30 12:26:43 -05:00
9efc54857f Update startup validator to use base64 encoded endpoint 2026-01-30 12:26:14 -05:00
fcdf47984c Merge branch 'main' into dev 2026-01-30 12:25:28 -05:00
040a5451a1 Fix remaining BaseUrl references 2026-01-30 12:24:39 -05:00
8d76e97449 Merge branch 'main' into dev 2026-01-30 12:13:17 -05:00
a86a8013e6 Add backup API endpoints with automatic fallback 2026-01-30 12:11:41 -05:00
4c557a0325 Merge branch 'main' into dev 2026-01-30 12:01:12 -05:00
8540a22846 Add Contributing section with development setup 2026-01-30 11:59:58 -05:00
36a224bd45 Consolidate Quick Start sections and update to use docker pull 2026-01-30 11:57:05 -05:00
3af3ebb52b Merge branch 'main' into dev 2026-01-30 11:54:57 -05:00
614adb9892 Add build status badges for main and beta branches 2026-01-30 11:51:49 -05:00
f434f13a19 Merge branch 'main' into dev 2026-01-30 11:48:53 -05:00
625a75f8f9 Update docker-compose to use GHCR image by default 2026-01-30 11:46:56 -05:00
d600c5e456 Merge branch 'main' into dev 2026-01-30 11:39:14 -05:00
e23e22a736 Fix nullable reference warnings 2026-01-30 11:36:59 -05:00
ba0fe35e72 Merge branch 'main' into dev 2026-01-30 11:32:42 -05:00
6e9fe0e69e Upgrade to .NET 10.0 2026-01-30 11:31:45 -05:00
cba955c427 Fix JellyfinProxyServiceTests for RedisCacheService parameter 2026-01-30 11:27:14 -05:00
192173ea64 Merge branch 'main' into dev 2026-01-30 11:23:41 -05:00
c33180abd7 Fix: Parse LRC format into individual lines for Feishin compatibility
Feishin expects lyrics as an array of {Start, Text} objects with timestamps
in ticks, not as a single LRC-formatted text block
2026-01-30 03:00:55 -05:00
680454e76e Clarify lyrics preference: synced lyrics preferred over plain
Already working correctly, just made the code clearer with explicit variable
2026-01-30 02:28:05 -05:00
34bfc20d28 Change Redis cache logging from Debug to Info level
Makes cache hits/misses visible in production logs without needing Debug level
2026-01-30 02:20:11 -05:00
489159b424 Add debug logging for Redis cache hits/misses
Now logs when cache entries are retrieved (HIT/MISS) and when they're stored
2026-01-30 02:17:45 -05:00
2bb754b245 Fix: Pass through full query string for browse requests
Recently added, recently played, and frequently played sections use
different query parameters (SortBy, SortOrder, Filters, etc) that we
weren't forwarding. Now we pass the complete query string to Jellyfin.
2026-01-30 02:15:48 -05:00
8d8c0892a2 Fix: Handle decimal duration values from LRCLIB API
LRCLIB returns duration as a decimal/float, not int. Convert to int using Math.Round.
2026-01-30 02:13:10 -05:00
e12851e9ca Fix: Handle nullable Duration in lyrics endpoint 2026-01-30 02:10:25 -05:00
f8969bea8d Add LRCLIB lyrics integration for Jellyfin
- Create LrclibService to fetch lyrics from lrclib.net API
- Add LyricsInfo model for lyrics data
- Add /Audio/{itemId}/Lyrics and /Items/{itemId}/Lyrics endpoints
- Support both local and external songs
- Cache lyrics for 30 days in Redis
- Return lyrics in Jellyfin format with synced/plain lyrics
2026-01-30 02:09:27 -05:00
ceaa17f018 Fix: Register SquidWTFSettings configuration in Program.cs
The SquidWTF quality setting from .env was not being loaded because
SquidWTFSettings wasn't registered with the DI container.
2026-01-30 00:50:34 -05:00
9aa7ceb138 Remove unused _preferredQuality field from SquidWTFDownloadService
The quality setting is already being used correctly via _squidwtfSettings.Quality
2026-01-30 00:49:41 -05:00
72b1ebc2eb Fix: Wrap playback request bodies in required field names for Jellyfin
Jellyfin expects playback endpoints to have bodies wrapped:
- /Sessions/Playing -> {"playbackStartInfo": {...}}
- /Sessions/Playing/Progress -> {"playbackProgressInfo": {...}}
- /Sessions/Playing/Stopped -> {"playbackStopInfo": {...}}

This fixes the 'field is required' validation errors.
2026-01-30 00:48:38 -05:00
48a0351862 Improve POST body debugging for playback endpoints
- Better logging to show what client sends vs what we forward
- Log all headers when body is empty to help diagnose issues
2026-01-30 00:45:16 -05:00
4b95f9910c Fix: Ensure POST requests always send body content to Jellyfin
- Always send body content for POST requests, even if empty (send '{}')
- Update TODO.md to mark tasks 2 and 3 as done
- Improve logging for POST body debugging
2026-01-30 00:44:39 -05:00
80424a867d Fix: Update last access time for cached files to enable proper cleanup 2026-01-30 00:41:17 -05:00
4afd769602 Fix: Proxy Artists/{id}/Similar to correct Jellyfin endpoint 2026-01-30 00:29:56 -05:00
b47a5f9063 Add ' - SW' suffix to external albums and artists
Makes it easy to distinguish external content from local Jellyfin library
2026-01-30 00:21:37 -05:00
15 changed files with 943 additions and 41 deletions

View File

@@ -95,3 +95,34 @@ STORAGE_MODE=Permanent
# Based on last access time (updated each time the file is streamed)
# Cache location: /tmp/allstarr-cache (or $TMPDIR/allstarr-cache if TMPDIR is set)
CACHE_DURATION_HOURS=1
# ===== SPOTIFY PLAYLIST INJECTION (JELLYFIN ONLY) =====
# REQUIRES: Jellyfin Spotify Import Plugin (https://github.com/Viperinius/jellyfin-plugin-spotify-import)
# This feature intercepts Spotify Import plugin playlists (Release Radar, Discover Weekly) and fills them
# with tracks auto-matched from external providers (SquidWTF, Deezer, Qobuz)
# Uses JELLYFIN_URL and JELLYFIN_API_KEY configured above (no separate credentials needed)
# Enable Spotify playlist injection (optional, default: false)
SPOTIFY_IMPORT_ENABLED=false
# Sync schedule: When does the Spotify Import plugin run?
# Set these to match your plugin's sync schedule in Jellyfin
# Example: If plugin runs daily at 4:15 PM, set HOUR=16 and MINUTE=15
SPOTIFY_IMPORT_SYNC_START_HOUR=16
SPOTIFY_IMPORT_SYNC_START_MINUTE=15
# Sync window: How long to search for missing tracks files (in hours)
# The fetcher will check every 5 minutes within this window
# Example: If plugin runs at 4:15 PM and window is 2 hours, checks from 4:00 PM to 6:00 PM
SPOTIFY_IMPORT_SYNC_WINDOW_HOURS=2
# Playlist IDs to inject (comma-separated)
# Get IDs from Jellyfin playlist URLs: https://jellyfin.example.com/web/#/details?id=PLAYLIST_ID
# Example: SPOTIFY_IMPORT_PLAYLIST_IDS=4383a46d8bcac3be2ef9385053ea18df,ba50e26c867ec9d57ab2f7bf24cfd6b0
SPOTIFY_IMPORT_PLAYLIST_IDS=
# Playlist names (comma-separated, must match Spotify Import plugin format)
# IMPORTANT: Use the exact playlist names as they appear in Jellyfin
# Must be in same order as SPOTIFY_IMPORT_PLAYLIST_IDS
# Example: SPOTIFY_IMPORT_PLAYLIST_NAMES=Discover Weekly,Release Radar
SPOTIFY_IMPORT_PLAYLIST_NAMES=

3
.gitignore vendored
View File

@@ -84,3 +84,6 @@ apis/*.json
# Original source code for reference
originals/
# Sample missing playlists for Spotify integration testing
sampleMissingPlaylists/

View File

@@ -16,13 +16,18 @@ Please report all bugs as soon as possible, as the Jellyfin addition is entirely
Using Docker (recommended):
```bash
# 1. Pull the latest image
docker-compose pull
# 1. Download the docker-compose.yml file and the .env.example file to a folder on the machine you have Docker
curl -O https://raw.githubusercontent.com/SoPat712/allstarr/refs/heads/main/docker-compose.yml \
-O https://raw.githubusercontent.com/SoPat712/allstarr/refs/heads/main/.env.example
# 2. Configure environment
cp .env.example .env
vi .env # Edit with your settings
# 3. Pull the latest image
docker-compose pull
# 3. Start services
docker-compose up -d
@@ -35,7 +40,7 @@ The proxy will be available at `http://localhost:5274`.
### Nginx Proxy Setup (Required)
This service only exposes ports internally. You **must** use nginx to proxy to it:
This service only exposes ports internally. You can use nginx to proxy to it, however PLEASE take significant precautions before exposing this! Everyone decides their own level of risk, but this is currently untested, potentially dangerous software, with almost unfettered access to your Jellyfin server. My recommendation is use Tailscale or something similar!
```nginx
server {
@@ -85,6 +90,7 @@ This project brings together all the music streaming providers into one unified
- **Artist Deduplication**: Merges local and streaming artists to avoid duplicates
- **Album Enrichment**: Adds missing tracks to local albums from streaming providers
- **Cover Art Proxy**: Serves cover art for external content
- **Spotify Playlist Injection** (Jellyfin only): Intercepts Spotify Import plugin playlists (Release Radar, Discover Weekly) and fills them with tracks auto-matched from streaming providers
## Supported Backends
@@ -282,6 +288,44 @@ Subsonic__EnableExternalPlaylists=false
> **Note**: Due to client-side filtering, playlists from streaming providers may not appear in the "Playlists" tab of some clients, but will show up in global search results.
### Spotify Playlist Injection (Jellyfin Only)
Allstarr can intercept Spotify Import plugin playlists (Release Radar, Discover Weekly) and fill them with tracks automatically matched from your configured streaming provider (SquidWTF, Deezer, or Qobuz).
**Requirements:**
- [Jellyfin Spotify Import Plugin](https://github.com/Viperinius/jellyfin-plugin-spotify-import) installed and configured
- Plugin must run on a daily schedule (e.g., 4:15 PM daily)
- Jellyfin URL and API key configured (uses existing JELLYFIN_URL and JELLYFIN_API_KEY settings)
**Configuration:**
| Setting | Description |
|---------|-------------|
| `SpotifyImport:Enabled` | Enable Spotify playlist injection (default: `false`) |
| `SpotifyImport:SyncStartHour` | Hour when plugin runs (24-hour format, 0-23) |
| `SpotifyImport:SyncStartMinute` | Minute when plugin runs (0-59) |
| `SpotifyImport:SyncWindowHours` | Hours to search for missing tracks files after sync time |
| `SpotifyImport:Playlists` | Array of playlists to inject (Name, SpotifyName, Enabled) |
**How it works:**
1. Jellyfin Spotify Import plugin runs daily and creates playlists + missing tracks files
2. Allstarr fetches these missing tracks files within the configured time window
3. For each missing track, Allstarr searches your streaming provider (SquidWTF, Deezer, or Qobuz)
4. When you open the playlist in Jellyfin, Allstarr intercepts the request and returns matched tracks
5. Tracks are downloaded on-demand when played
6. On startup, Allstarr will fetch missing tracks if it hasn't run in the last 24 hours
**Environment variables:**
```bash
SPOTIFY_IMPORT_ENABLED=true
SPOTIFY_IMPORT_SYNC_START_HOUR=16
SPOTIFY_IMPORT_SYNC_START_MINUTE=15
SPOTIFY_IMPORT_SYNC_WINDOW_HOURS=2
SPOTIFY_IMPORT_PLAYLISTS=Release Radar,Discover Weekly
```
> **Note**: This feature uses your existing JELLYFIN_URL and JELLYFIN_API_KEY settings. The plugin must be configured to run on a schedule, and the sync window should cover the plugin's execution time.
### Getting Credentials
#### Deezer ARL Token

View File

@@ -10,6 +10,7 @@ using allstarr.Services.Local;
using allstarr.Services.Jellyfin;
using allstarr.Services.Subsonic;
using allstarr.Services.Lyrics;
using allstarr.Filters;
namespace allstarr.Controllers;
@@ -22,6 +23,7 @@ namespace allstarr.Controllers;
public class JellyfinController : ControllerBase
{
private readonly JellyfinSettings _settings;
private readonly SpotifyImportSettings _spotifySettings;
private readonly IMusicMetadataService _metadataService;
private readonly ILocalLibraryService _localLibraryService;
private readonly IDownloadService _downloadService;
@@ -29,20 +31,24 @@ public class JellyfinController : ControllerBase
private readonly JellyfinModelMapper _modelMapper;
private readonly JellyfinProxyService _proxyService;
private readonly PlaylistSyncService? _playlistSyncService;
private readonly RedisCacheService _cache;
private readonly ILogger<JellyfinController> _logger;
public JellyfinController(
IOptions<JellyfinSettings> settings,
IOptions<SpotifyImportSettings> spotifySettings,
IMusicMetadataService metadataService,
ILocalLibraryService localLibraryService,
IDownloadService downloadService,
JellyfinResponseBuilder responseBuilder,
JellyfinModelMapper modelMapper,
JellyfinProxyService proxyService,
RedisCacheService cache,
ILogger<JellyfinController> logger,
PlaylistSyncService? playlistSyncService = null)
{
_settings = settings.Value;
_spotifySettings = spotifySettings.Value;
_metadataService = metadataService;
_localLibraryService = localLibraryService;
_downloadService = downloadService;
@@ -50,6 +56,7 @@ public class JellyfinController : ControllerBase
_modelMapper = modelMapper;
_proxyService = proxyService;
_playlistSyncService = playlistSyncService;
_cache = cache;
_logger = logger;
if (string.IsNullOrWhiteSpace(_settings.Url))
@@ -169,31 +176,28 @@ public class JellyfinController : ControllerBase
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult);
// Score and filter Jellyfin results by relevance
var scoredLocalSongs = ScoreSearchResults(cleanQuery, localSongs, s => s.Title, s => s.Artist, isExternal: false);
var scoredLocalAlbums = ScoreSearchResults(cleanQuery, localAlbums, a => a.Title, a => a.Artist, isExternal: false);
var scoredLocalArtists = ScoreSearchResults(cleanQuery, localArtists, a => a.Name, _ => null, isExternal: false);
var scoredLocalSongs = ScoreSearchResults(cleanQuery, localSongs, s => s.Title, s => s.Artist, s => s.Album, isExternal: false);
var scoredLocalAlbums = ScoreSearchResults(cleanQuery, localAlbums, a => a.Title, a => a.Artist, _ => null, isExternal: false);
var scoredLocalArtists = ScoreSearchResults(cleanQuery, localArtists, a => a.Name, _ => null, _ => null, isExternal: false);
// Score external results with a small boost
var scoredExternalSongs = ScoreSearchResults(cleanQuery, externalResult.Songs, s => s.Title, s => s.Artist, isExternal: true);
var scoredExternalAlbums = ScoreSearchResults(cleanQuery, externalResult.Albums, a => a.Title, a => a.Artist, isExternal: true);
var scoredExternalArtists = ScoreSearchResults(cleanQuery, externalResult.Artists, a => a.Name, _ => null, isExternal: true);
var scoredExternalSongs = ScoreSearchResults(cleanQuery, externalResult.Songs, s => s.Title, s => s.Artist, s => s.Album, isExternal: true);
var scoredExternalAlbums = ScoreSearchResults(cleanQuery, externalResult.Albums, a => a.Title, a => a.Artist, _ => null, isExternal: true);
var scoredExternalArtists = ScoreSearchResults(cleanQuery, externalResult.Artists, a => a.Name, _ => null, _ => null, isExternal: true);
// Merge and sort by score (only include items with score >= 40)
// Merge and sort by score (no filtering - just reorder by relevance)
var allSongs = scoredLocalSongs.Concat(scoredExternalSongs)
.Where(x => x.Score >= 40)
.OrderByDescending(x => x.Score)
.Select(x => x.Item)
.ToList();
var allAlbums = scoredLocalAlbums.Concat(scoredExternalAlbums)
.Where(x => x.Score >= 40)
.OrderByDescending(x => x.Score)
.Select(x => x.Item)
.ToList();
// Dedupe artists by name, keeping highest scored version
var artistScores = scoredLocalArtists.Concat(scoredExternalArtists)
.Where(x => x.Score >= 40)
.GroupBy(x => x.Item.Name, StringComparer.OrdinalIgnoreCase)
.Select(g => g.OrderByDescending(x => x.Score).First())
.OrderByDescending(x => x.Score)
@@ -210,7 +214,6 @@ public class JellyfinController : ControllerBase
{
var scoredPlaylists = playlistResult
.Select(p => new { Playlist = p, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, p.Name) })
.Where(x => x.Score >= 40)
.OrderByDescending(x => x.Score)
.Select(x => _responseBuilder.ConvertPlaylistToJellyfinItem(x.Playlist))
.ToList();
@@ -778,6 +781,23 @@ public class JellyfinController : ControllerBase
var contentType = response.Content.Headers.ContentType?.ToString() ?? "audio/mpeg";
// Forward caching headers for client-side caching
if (response.Headers.ETag != null)
{
Response.Headers["ETag"] = response.Headers.ETag.ToString();
}
if (response.Content.Headers.LastModified.HasValue)
{
Response.Headers["Last-Modified"] = response.Content.Headers.LastModified.Value.ToString("R");
}
if (response.Headers.CacheControl != null)
{
Response.Headers["Cache-Control"] = response.Headers.CacheControl.ToString();
}
// Forward range headers for seeking
if (response.Content.Headers.ContentRange != null)
{
Response.Headers["Content-Range"] = response.Content.Headers.ContentRange.ToString();
@@ -1238,10 +1258,55 @@ public class JellyfinController : ControllerBase
{
try
{
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(playlistId);
var tracks = await _metadataService.GetPlaylistTracksAsync(provider, externalId);
_logger.LogInformation("=== GetPlaylistTracks called === PlaylistId: {PlaylistId}", playlistId);
return _responseBuilder.CreateItemsResponse(tracks);
// Check if this is an external playlist (Deezer/Qobuz) first
if (PlaylistIdHelper.IsExternalPlaylist(playlistId))
{
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(playlistId);
var tracks = await _metadataService.GetPlaylistTracksAsync(provider, externalId);
return _responseBuilder.CreateItemsResponse(tracks);
}
// Check if this is a Spotify playlist (by ID)
_logger.LogInformation("Spotify Import Enabled: {Enabled}, Configured IDs: {Count}",
_spotifySettings.Enabled, _spotifySettings.PlaylistIds.Count);
if (_spotifySettings.Enabled &&
_spotifySettings.PlaylistIds.Any(id => id.Equals(playlistId, StringComparison.OrdinalIgnoreCase)))
{
// Get playlist info from Jellyfin to get the name for matching missing tracks
_logger.LogInformation("Fetching playlist info from Jellyfin for ID: {PlaylistId}", playlistId);
var playlistInfo = await _proxyService.GetJsonAsync($"Items/{playlistId}", null, Request.Headers);
if (playlistInfo != null && playlistInfo.RootElement.TryGetProperty("Name", out var nameElement))
{
var playlistName = nameElement.GetString() ?? "";
_logger.LogInformation("✓ MATCHED! Intercepting Spotify playlist: {PlaylistName} (ID: {PlaylistId})",
playlistName, playlistId);
return await GetSpotifyPlaylistTracksAsync(playlistName);
}
else
{
_logger.LogWarning("Could not get playlist name from Jellyfin for ID: {PlaylistId}", playlistId);
}
}
// Regular Jellyfin playlist - proxy through
var endpoint = $"Playlists/{playlistId}/Items";
if (Request.QueryString.HasValue)
{
endpoint = $"{endpoint}{Request.QueryString.Value}";
}
_logger.LogInformation("Proxying to Jellyfin: {Endpoint}", endpoint);
var result = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
if (result == null)
{
return _responseBuilder.CreateError(404, "Playlist not found");
}
return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText()));
}
catch (Exception ex)
{
@@ -1590,6 +1655,38 @@ public class JellyfinController : ControllerBase
[HttpPost("{**path}", Order = 100)]
public async Task<IActionResult> ProxyRequest(string path)
{
// DEBUG: Log EVERY request to see what's happening
_logger.LogWarning("ProxyRequest called with path: {Path}", path);
// Intercept Spotify playlist requests by ID
if (_spotifySettings.Enabled &&
path.StartsWith("playlists/", StringComparison.OrdinalIgnoreCase) &&
path.Contains("/items", StringComparison.OrdinalIgnoreCase))
{
// Extract playlist ID from path: playlists/{id}/items
var parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 2 && parts[0].Equals("playlists", StringComparison.OrdinalIgnoreCase))
{
var playlistId = parts[1];
_logger.LogWarning("=== PLAYLIST REQUEST ===");
_logger.LogWarning("Playlist ID: {PlaylistId}", playlistId);
_logger.LogWarning("Spotify Enabled: {Enabled}", _spotifySettings.Enabled);
_logger.LogWarning("Configured IDs: {Ids}", string.Join(", ", _spotifySettings.PlaylistIds));
_logger.LogWarning("Is configured: {IsConfigured}", _spotifySettings.PlaylistIds.Contains(playlistId, StringComparer.OrdinalIgnoreCase));
// Check if this playlist ID is configured for Spotify injection
if (_spotifySettings.PlaylistIds.Any(id => id.Equals(playlistId, StringComparison.OrdinalIgnoreCase)))
{
_logger.LogInformation("========================================");
_logger.LogInformation("=== INTERCEPTING SPOTIFY PLAYLIST ===");
_logger.LogInformation("Playlist ID: {PlaylistId}", playlistId);
_logger.LogInformation("========================================");
return await GetPlaylistTracks(playlistId);
}
}
}
// Handle non-JSON responses (robots.txt, etc.)
if (path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase) ||
path.EndsWith(".xml", StringComparison.OrdinalIgnoreCase))
@@ -1761,28 +1858,52 @@ public class JellyfinController : ControllerBase
private static List<(T Item, int Score)> ScoreSearchResults<T>(
string query,
List<T> items,
Func<T, string> primaryField,
Func<T, string?> secondaryField,
Func<T, string> titleField,
Func<T, string?> artistField,
Func<T, string?> albumField,
bool isExternal = false)
{
return items.Select(item =>
{
var primary = primaryField(item) ?? "";
var secondary = secondaryField(item) ?? "";
var title = titleField(item) ?? "";
var artist = artistField(item) ?? "";
var album = albumField(item) ?? "";
// Score against primary field (title/name)
var primaryScore = FuzzyMatcher.CalculateSimilarity(query, primary);
// Token-based fuzzy matching: split query and fields into words
var queryTokens = query.ToLower()
.Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries)
.ToList();
// Score against secondary field (artist) if provided
var secondaryScore = string.IsNullOrEmpty(secondary)
? 0
: FuzzyMatcher.CalculateSimilarity(query, secondary);
var fieldText = $"{title} {artist} {album}".ToLower();
var fieldTokens = fieldText
.Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries)
.ToList();
// Use the better of the two scores
var baseScore = Math.Max(primaryScore, secondaryScore);
if (queryTokens.Count == 0) return (item, 0);
// Count how many query tokens match field tokens (with fuzzy tolerance)
var matchedTokens = 0;
foreach (var queryToken in queryTokens)
{
// Check if any field token matches this query token
var hasMatch = fieldTokens.Any(fieldToken =>
{
// Exact match or substring match
if (fieldToken.Contains(queryToken) || queryToken.Contains(fieldToken))
return true;
// Fuzzy match with Levenshtein distance
var similarity = FuzzyMatcher.CalculateSimilarity(queryToken, fieldToken);
return similarity >= 70; // 70% similarity threshold for individual words
});
if (hasMatch) matchedTokens++;
}
// Score = percentage of query tokens that matched
var baseScore = (matchedTokens * 100) / queryTokens.Count;
// Give external results a small boost (+5 points) to prioritize the larger catalog
// This means external results will rank slightly higher when scores are close
var finalScore = isExternal ? Math.Min(100, baseScore + 5) : baseScore;
return (item, finalScore);
@@ -1790,5 +1911,231 @@ public class JellyfinController : ControllerBase
}
#endregion
#region Spotify Playlist Injection
/// <summary>
/// Gets tracks for a Spotify playlist by matching missing tracks against external providers.
/// </summary>
private async Task<IActionResult> GetSpotifyPlaylistTracksAsync(string spotifyPlaylistName)
{
try
{
var cacheKey = $"spotify:matched:{spotifyPlaylistName}";
var cachedTracks = await _cache.GetAsync<List<Song>>(cacheKey);
if (cachedTracks != null)
{
_logger.LogDebug("Returning {Count} cached matched tracks for {Playlist}",
cachedTracks.Count, spotifyPlaylistName);
return _responseBuilder.CreateItemsResponse(cachedTracks);
}
var missingTracksKey = $"spotify:missing:{spotifyPlaylistName}";
var missingTracks = await _cache.GetAsync<List<allstarr.Models.Spotify.MissingTrack>>(missingTracksKey);
if (missingTracks == null || missingTracks.Count == 0)
{
_logger.LogInformation("No missing tracks found for {Playlist}", spotifyPlaylistName);
return _responseBuilder.CreateItemsResponse(new List<Song>());
}
_logger.LogInformation("Matching {Count} tracks for {Playlist}",
missingTracks.Count, spotifyPlaylistName);
var matchTasks = missingTracks.Select(async track =>
{
try
{
var query = $"{track.Title} {track.AllArtists} {track.Album}";
var results = await _metadataService.SearchSongsAsync(query, limit: 1);
return results.FirstOrDefault();
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
track.Title, track.PrimaryArtist);
return null;
}
});
var matchedTracks = (await Task.WhenAll(matchTasks))
.Where(t => t != null)
.Cast<Song>()
.ToList();
await _cache.SetAsync(cacheKey, matchedTracks, TimeSpan.FromHours(1));
_logger.LogInformation("Matched {Matched}/{Total} tracks for {Playlist}",
matchedTracks.Count, missingTracks.Count, spotifyPlaylistName);
return _responseBuilder.CreateItemsResponse(matchedTracks);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting Spotify playlist tracks {PlaylistName}", spotifyPlaylistName);
return _responseBuilder.CreateError(500, "Failed to get Spotify playlist tracks");
}
}
/// <summary>
/// Manual trigger endpoint to force fetch Spotify missing tracks.
/// GET /spotify/sync?api_key=YOUR_KEY
/// </summary>
[HttpGet("spotify/sync")]
[ServiceFilter(typeof(ApiKeyAuthFilter))]
public async Task<IActionResult> TriggerSpotifySync()
{
if (!_spotifySettings.Enabled)
{
return BadRequest(new { error = "Spotify Import is not enabled" });
}
_logger.LogInformation("Manual Spotify sync triggered");
var results = new Dictionary<string, object>();
for (int i = 0; i < _spotifySettings.PlaylistIds.Count; i++)
{
var playlistId = _spotifySettings.PlaylistIds[i];
try
{
// Use configured name if available, otherwise use ID
var playlistName = i < _spotifySettings.PlaylistNames.Count
? _spotifySettings.PlaylistNames[i]
: playlistId;
_logger.LogInformation("Fetching missing tracks for {Playlist} (ID: {Id})", playlistName, playlistId);
// Try to fetch the missing tracks file - search last 24 hours
var now = DateTime.UtcNow;
var searchStart = now.AddHours(-24);
var httpClient = new HttpClient();
var found = false;
// Search every minute for the last 24 hours (1440 attempts max)
for (var time = searchStart; time <= now; time = time.AddMinutes(1))
{
var filename = $"{playlistName}_missing_{time:yyyy-MM-dd_HH-mm}.json";
var url = $"{_settings.Url}/Viperinius.Plugin.SpotifyImport/MissingTracksFile" +
$"?name={Uri.EscapeDataString(filename)}&api_key={_settings.ApiKey}";
try
{
_logger.LogDebug("Trying {Filename}", filename);
var response = await httpClient.GetAsync(url);
if (response.IsSuccessStatusCode)
{
var json = await response.Content.ReadAsStringAsync();
var tracks = ParseMissingTracksJson(json);
if (tracks.Count > 0)
{
var cacheKey = $"spotify:missing:{playlistName}";
await _cache.SetAsync(cacheKey, tracks, TimeSpan.FromHours(24));
results[playlistName] = new {
status = "success",
tracks = tracks.Count,
filename = filename
};
_logger.LogInformation("✓ Cached {Count} missing tracks for {Playlist} from {Filename}",
tracks.Count, playlistName, filename);
found = true;
break;
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to fetch {Filename}", filename);
}
}
if (!found)
{
results[playlistName] = new { status = "not_found", message = "No missing tracks file found" };
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error syncing playlist {PlaylistId}", playlistId);
results[playlistId] = new { status = "error", message = ex.Message };
}
}
return Ok(results);
}
private List<allstarr.Models.Spotify.MissingTrack> ParseMissingTracksJson(string json)
{
var tracks = new List<allstarr.Models.Spotify.MissingTrack>();
try
{
var doc = JsonDocument.Parse(json);
foreach (var item in doc.RootElement.EnumerateArray())
{
var track = new allstarr.Models.Spotify.MissingTrack
{
SpotifyId = item.GetProperty("Id").GetString() ?? "",
Title = item.GetProperty("Name").GetString() ?? "",
Album = item.GetProperty("AlbumName").GetString() ?? "",
Artists = item.GetProperty("ArtistNames")
.EnumerateArray()
.Select(a => a.GetString() ?? "")
.Where(a => !string.IsNullOrEmpty(a))
.ToList()
};
if (!string.IsNullOrEmpty(track.Title))
{
tracks.Add(track);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to parse missing tracks JSON");
}
return tracks;
}
#endregion
#region Spotify Debug
/// <summary>
/// Clear Spotify playlist cache to force re-matching.
/// GET /spotify/clear-cache?api_key=YOUR_KEY
/// </summary>
[HttpGet("spotify/clear-cache")]
[ServiceFilter(typeof(ApiKeyAuthFilter))]
public async Task<IActionResult> ClearSpotifyCache()
{
if (!_spotifySettings.Enabled)
{
return BadRequest(new { error = "Spotify Import is not enabled" });
}
var cleared = new List<string>();
foreach (var playlistName in _spotifySettings.PlaylistNames)
{
var matchedKey = $"spotify:matched:{playlistName}";
await _cache.DeleteAsync(matchedKey);
cleared.Add(playlistName);
_logger.LogInformation("Cleared cache for {Playlist}", playlistName);
}
return Ok(new { status = "success", cleared = cleared });
}
#endregion
}
// force rebuild Sun Jan 25 13:22:47 EST 2026

View File

@@ -0,0 +1,52 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Options;
using allstarr.Models.Settings;
namespace allstarr.Filters;
/// <summary>
/// Simple API key authentication filter for admin endpoints.
/// Validates against Jellyfin API key via query parameter or header.
/// </summary>
public class ApiKeyAuthFilter : IAsyncActionFilter
{
private readonly JellyfinSettings _settings;
private readonly ILogger<ApiKeyAuthFilter> _logger;
public ApiKeyAuthFilter(
IOptions<JellyfinSettings> settings,
ILogger<ApiKeyAuthFilter> logger)
{
_settings = settings.Value;
_logger = logger;
}
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
var request = context.HttpContext.Request;
// Extract API key from query parameter or header
var apiKey = request.Query["api_key"].FirstOrDefault()
?? request.Headers["X-Api-Key"].FirstOrDefault()
?? request.Headers["X-Emby-Token"].FirstOrDefault();
// Validate API key
if (string.IsNullOrEmpty(apiKey) || !string.Equals(apiKey, _settings.ApiKey, StringComparison.Ordinal))
{
_logger.LogWarning("Unauthorized access attempt to {Path} from {IP}",
request.Path,
context.HttpContext.Connection.RemoteIpAddress);
context.Result = new UnauthorizedObjectResult(new
{
error = "Unauthorized",
message = "Valid API key required. Provide via ?api_key=YOUR_KEY or X-Api-Key header."
});
return;
}
_logger.LogDebug("API key authentication successful for {Path}", request.Path);
await next();
}
}

View File

@@ -0,0 +1,47 @@
namespace allstarr.Models.Settings;
/// <summary>
/// Configuration for Spotify playlist injection feature.
/// Requires Jellyfin Spotify Import Plugin: https://github.com/Viperinius/jellyfin-plugin-spotify-import
/// Uses JellyfinSettings.Url and JellyfinSettings.ApiKey for API access.
/// </summary>
public class SpotifyImportSettings
{
/// <summary>
/// Enable Spotify playlist injection feature
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Hour when Spotify Import plugin runs (24-hour format, 0-23)
/// Example: 16 for 4:00 PM
/// </summary>
public int SyncStartHour { get; set; } = 16;
/// <summary>
/// Minute when Spotify Import plugin runs (0-59)
/// Example: 15 for 4:15 PM
/// </summary>
public int SyncStartMinute { get; set; } = 15;
/// <summary>
/// How many hours to search for missing tracks files after sync start time
/// Example: 2 means search from 4:00 PM to 6:00 PM
/// </summary>
public int SyncWindowHours { get; set; } = 2;
/// <summary>
/// Comma-separated list of Jellyfin playlist IDs to inject
/// Example: "4383a46d8bcac3be2ef9385053ea18df,ba50e26c867ec9d57ab2f7bf24cfd6b0"
/// Get IDs from Jellyfin playlist URLs
/// </summary>
public List<string> PlaylistIds { get; set; } = new();
/// <summary>
/// Comma-separated list of playlist names (must match Spotify Import plugin format)
/// Example: "Discover_Weekly,Release_Radar"
/// Must be in same order as PlaylistIds
/// Plugin replaces spaces with underscores in filenames
/// </summary>
public List<string> PlaylistNames { get; set; } = new();
}

View File

@@ -0,0 +1,12 @@
namespace allstarr.Models.Spotify;
public class MissingTrack
{
public string SpotifyId { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string Album { get; set; } = string.Empty;
public List<string> Artists { get; set; } = new();
public string PrimaryArtist => Artists.FirstOrDefault() ?? "";
public string AllArtists => string.Join(", ", Artists);
}

View File

@@ -108,6 +108,42 @@ builder.Services.Configure<SquidWTFSettings>(
builder.Configuration.GetSection("SquidWTF"));
builder.Services.Configure<RedisSettings>(
builder.Configuration.GetSection("Redis"));
// Configure Spotify Import settings with custom playlist parsing from env var
builder.Services.Configure<SpotifyImportSettings>(options =>
{
builder.Configuration.GetSection("SpotifyImport").Bind(options);
// Parse SPOTIFY_IMPORT_PLAYLIST_IDS env var (comma-separated) into PlaylistIds array
var playlistIdsEnv = builder.Configuration.GetValue<string>("SpotifyImport:PlaylistIds");
if (!string.IsNullOrWhiteSpace(playlistIdsEnv) && options.PlaylistIds.Count == 0)
{
options.PlaylistIds = playlistIdsEnv
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(id => id.Trim())
.Where(id => !string.IsNullOrEmpty(id))
.ToList();
}
// Parse SPOTIFY_IMPORT_PLAYLIST_NAMES env var (comma-separated) into PlaylistNames array
var playlistNamesEnv = builder.Configuration.GetValue<string>("SpotifyImport:PlaylistNames");
if (!string.IsNullOrWhiteSpace(playlistNamesEnv) && options.PlaylistNames.Count == 0)
{
options.PlaylistNames = playlistNamesEnv
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(name => name.Trim())
.Where(name => !string.IsNullOrEmpty(name))
.ToList();
}
// Log configuration at startup
Console.WriteLine($"Spotify Import: Enabled={options.Enabled}, SyncHour={options.SyncStartHour}:{options.SyncStartMinute:D2}, WindowHours={options.SyncWindowHours}");
Console.WriteLine($"Spotify Import Playlist IDs: {options.PlaylistIds.Count} configured");
for (int i = 0; i < options.PlaylistIds.Count; i++)
{
var name = i < options.PlaylistNames.Count ? options.PlaylistNames[i] : options.PlaylistIds[i];
Console.WriteLine($" - {name} (ID: {options.PlaylistIds[i]})");
}
});
// Get shared settings from the active backend config
MusicService musicService;
@@ -138,6 +174,7 @@ if (backendType == BackendType.Jellyfin)
builder.Services.AddSingleton<JellyfinModelMapper>();
builder.Services.AddScoped<JellyfinProxyService>();
builder.Services.AddScoped<JellyfinAuthFilter>();
builder.Services.AddScoped<allstarr.Filters.ApiKeyAuthFilter>();
}
else
{
@@ -229,6 +266,9 @@ builder.Services.AddHostedService<StartupValidationOrchestrator>();
// Register cache cleanup service (only runs when StorageMode is Cache)
builder.Services.AddHostedService<CacheCleanupService>();
// Register Spotify missing tracks fetcher (only runs when SpotifyImport is enabled)
builder.Services.AddHostedService<allstarr.Services.Spotify.SpotifyMissingTracksFetcher>();
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>

View File

@@ -297,8 +297,10 @@ public class JellyfinProxyService
{
if (header.Key.Equals("X-Emby-Authorization", StringComparison.OrdinalIgnoreCase))
{
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", header.Value.ToString());
var headerValue = header.Value.ToString();
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
authHeaderAdded = true;
_logger.LogDebug("Forwarded X-Emby-Authorization from client");
break;
}
}
@@ -309,21 +311,38 @@ public class JellyfinProxyService
{
if (header.Key.Equals("Authorization", StringComparison.OrdinalIgnoreCase))
{
request.Headers.TryAddWithoutValidation("Authorization", header.Value.ToString());
var headerValue = header.Value.ToString();
// Check if it's MediaBrowser/Jellyfin format
if (headerValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase) ||
headerValue.Contains("Client=", StringComparison.OrdinalIgnoreCase))
{
// Forward as X-Emby-Authorization
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
_logger.LogDebug("Converted Authorization to X-Emby-Authorization");
}
else
{
// Standard Bearer token
request.Headers.TryAddWithoutValidation("Authorization", headerValue);
_logger.LogDebug("Forwarded Authorization header");
}
authHeaderAdded = true;
break;
}
}
}
// For login requests without auth headers, provide a minimal client auth header
if (!authHeaderAdded)
// For non-auth requests without headers, use API key
// For auth requests, client MUST provide their own client info
if (!authHeaderAdded && !endpoint.Contains("Authenticate", StringComparison.OrdinalIgnoreCase))
{
var clientAuthHeader = $"MediaBrowser Client=\"{_settings.ClientName}\", " +
$"Device=\"{_settings.DeviceName}\", " +
$"DeviceId=\"{_settings.DeviceId}\", " +
$"Version=\"{_settings.ClientVersion}\"";
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", clientAuthHeader);
_logger.LogDebug("Using server API key for non-auth request");
}
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

View File

@@ -304,11 +304,11 @@ public class JellyfinResponseBuilder
/// </summary>
public Dictionary<string, object?> ConvertAlbumToJellyfinItem(Album album)
{
// Add " - SW" suffix to external album names
// Add " - S" suffix to external album names (S = SquidWTF)
var albumName = album.Title;
if (!album.IsLocal)
{
albumName = $"{album.Title} - SW";
albumName = $"{album.Title} - S";
}
var item = new Dictionary<string, object?>
@@ -371,11 +371,11 @@ public class JellyfinResponseBuilder
/// </summary>
public Dictionary<string, object?> ConvertArtistToJellyfinItem(Artist artist)
{
// Add " - SW" suffix to external artist names
// Add " - S" suffix to external artist names (S = SquidWTF)
var artistName = artist.Name;
if (!artist.IsLocal)
{
artistName = $"{artist.Name} - SW";
artistName = $"{artist.Name} - S";
}
var item = new Dictionary<string, object?>

View File

@@ -0,0 +1,255 @@
using allstarr.Models.Settings;
using allstarr.Models.Spotify;
using allstarr.Services.Common;
using allstarr.Services.Jellyfin;
using Microsoft.Extensions.Options;
using System.Text.Json;
namespace allstarr.Services.Spotify;
public class SpotifyMissingTracksFetcher : BackgroundService
{
private readonly IOptions<SpotifyImportSettings> _spotifySettings;
private readonly IOptions<JellyfinSettings> _jellyfinSettings;
private readonly IHttpClientFactory _httpClientFactory;
private readonly RedisCacheService _cache;
private readonly ILogger<SpotifyMissingTracksFetcher> _logger;
private readonly IServiceProvider _serviceProvider;
private bool _hasRunOnce = false;
private Dictionary<string, string> _playlistIdToName = new();
public SpotifyMissingTracksFetcher(
IOptions<SpotifyImportSettings> spotifySettings,
IOptions<JellyfinSettings> jellyfinSettings,
IHttpClientFactory httpClientFactory,
RedisCacheService cache,
IServiceProvider serviceProvider,
ILogger<SpotifyMissingTracksFetcher> logger)
{
_spotifySettings = spotifySettings;
_jellyfinSettings = jellyfinSettings;
_httpClientFactory = httpClientFactory;
_cache = cache;
_serviceProvider = serviceProvider;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("========================================");
_logger.LogInformation("SpotifyMissingTracksFetcher: Starting up...");
if (!_spotifySettings.Value.Enabled)
{
_logger.LogInformation("Spotify playlist injection is DISABLED");
_logger.LogInformation("========================================");
return;
}
var jellyfinUrl = _jellyfinSettings.Value.Url;
var apiKey = _jellyfinSettings.Value.ApiKey;
if (string.IsNullOrEmpty(jellyfinUrl) || string.IsNullOrEmpty(apiKey))
{
_logger.LogWarning("Jellyfin URL or API key not configured, Spotify playlist injection disabled");
_logger.LogInformation("========================================");
return;
}
_logger.LogInformation("Spotify Import ENABLED");
_logger.LogInformation("Configured Playlist IDs: {Count}", _spotifySettings.Value.PlaylistIds.Count);
// Fetch playlist names from Jellyfin
await LoadPlaylistNamesAsync();
foreach (var kvp in _playlistIdToName)
{
_logger.LogInformation(" - {Name} (ID: {Id})", kvp.Value, kvp.Key);
}
_logger.LogInformation("========================================");
// Run once on startup if we haven't run in the last 24 hours
if (!_hasRunOnce)
{
var shouldRunOnStartup = await ShouldRunOnStartupAsync();
if (shouldRunOnStartup)
{
_logger.LogInformation("Running initial fetch on startup");
try
{
await FetchMissingTracksAsync(stoppingToken);
_hasRunOnce = true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during startup fetch");
}
}
else
{
_logger.LogInformation("Skipping startup fetch - already ran within last 24 hours");
_hasRunOnce = true;
}
}
while (!stoppingToken.IsCancellationRequested)
{
try
{
await FetchMissingTracksAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching Spotify missing tracks");
}
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
}
}
private async Task LoadPlaylistNamesAsync()
{
_playlistIdToName.Clear();
// Use configured playlist names instead of fetching from API
for (int i = 0; i < _spotifySettings.Value.PlaylistIds.Count; i++)
{
var playlistId = _spotifySettings.Value.PlaylistIds[i];
var playlistName = i < _spotifySettings.Value.PlaylistNames.Count
? _spotifySettings.Value.PlaylistNames[i]
: playlistId; // Fallback to ID if name not configured
_playlistIdToName[playlistId] = playlistName;
}
}
private async Task<bool> ShouldRunOnStartupAsync()
{
// Check if any playlist has cached data from the last 24 hours
foreach (var playlistName in _playlistIdToName.Values)
{
var cacheKey = $"spotify:missing:{playlistName}";
if (await _cache.ExistsAsync(cacheKey))
{
return false; // Already have recent data
}
}
return true; // No recent data, should fetch
}
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);
if (now < syncStart || now > syncEnd)
{
return;
}
_logger.LogInformation("Within sync window, fetching missing tracks...");
foreach (var kvp in _playlistIdToName)
{
await FetchPlaylistMissingTracksAsync(kvp.Value, cancellationToken);
}
}
private async Task FetchPlaylistMissingTracksAsync(
string playlistName,
CancellationToken cancellationToken)
{
var cacheKey = $"spotify:missing:{playlistName}";
if (await _cache.ExistsAsync(cacheKey))
{
_logger.LogDebug("Cache already exists for {Playlist}", playlistName);
return;
}
var settings = _spotifySettings.Value;
var jellyfinUrl = _jellyfinSettings.Value.Url;
var apiKey = _jellyfinSettings.Value.ApiKey;
var httpClient = _httpClientFactory.CreateClient();
var today = DateTime.UtcNow.Date;
var syncStart = today
.AddHours(settings.SyncStartHour)
.AddMinutes(settings.SyncStartMinute);
var syncEnd = syncStart.AddHours(settings.SyncWindowHours);
_logger.LogInformation("Searching for missing tracks file for {Playlist}", playlistName);
for (var time = syncStart; time <= syncEnd; time = time.AddMinutes(5))
{
if (cancellationToken.IsCancellationRequested) break;
var filename = $"{playlistName}_missing_{time:yyyy-MM-dd_HH-mm}.json";
var url = $"{jellyfinUrl}/Viperinius.Plugin.SpotifyImport/MissingTracksFile" +
$"?name={Uri.EscapeDataString(filename)}&api_key={apiKey}";
try
{
_logger.LogDebug("Trying {Filename}", filename);
var response = await httpClient.GetAsync(url, cancellationToken);
if (response.IsSuccessStatusCode)
{
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var tracks = ParseMissingTracks(json);
if (tracks.Count > 0)
{
await _cache.SetAsync(cacheKey, tracks, TimeSpan.FromHours(24));
_logger.LogInformation(
"✓ Cached {Count} missing tracks for {Playlist} from {Filename}",
tracks.Count, playlistName, filename);
break;
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to fetch {Filename}", filename);
}
}
}
private List<MissingTrack> ParseMissingTracks(string json)
{
var tracks = new List<MissingTrack>();
try
{
var doc = JsonDocument.Parse(json);
foreach (var item in doc.RootElement.EnumerateArray())
{
var track = new MissingTrack
{
SpotifyId = item.GetProperty("Id").GetString() ?? "",
Title = item.GetProperty("Name").GetString() ?? "",
Album = item.GetProperty("AlbumName").GetString() ?? "",
Artists = item.GetProperty("ArtistNames")
.EnumerateArray()
.Select(a => a.GetString() ?? "")
.Where(a => !string.IsNullOrEmpty(a))
.ToList()
};
if (!string.IsNullOrEmpty(track.Title))
{
tracks.Add(track);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to parse missing tracks JSON");
}
return tracks;
}
}

View File

@@ -188,7 +188,15 @@ public class SquidWTFMetadataService : IMusicMetadataService
{
foreach(var playlist in items.EnumerateArray())
{
playlists.Add(ParseTidalPlaylist(playlist));
try
{
playlists.Add(ParseTidalPlaylist(playlist));
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to parse playlist, skipping");
// Skip this playlist and continue with others
}
}
}
return playlists;

View File

@@ -4,5 +4,23 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"SpotifyImport": {
"Enabled": false,
"SyncStartHour": 16,
"SyncStartMinute": 15,
"SyncWindowHours": 2,
"Playlists": [
{
"Name": "Release Radar",
"SpotifyName": "Release Radar",
"Enabled": true
},
{
"Name": "Discover Weekly",
"SpotifyName": "Discover Weekly",
"Enabled": true
}
]
}
}

View File

@@ -3,7 +3,7 @@
"Type": "Subsonic"
},
"Subsonic": {
"Url": "https://navidrome.local.bransonb.com",
"Url": "http://localhost:4533",
"MusicService": "SquidWTF",
"ExplicitFilter": "All",
"DownloadMode": "Track",
@@ -42,5 +42,23 @@
"Redis": {
"Enabled": true,
"ConnectionString": "localhost:6379"
},
"SpotifyImport": {
"Enabled": false,
"SyncStartHour": 16,
"SyncStartMinute": 15,
"SyncWindowHours": 2,
"Playlists": [
{
"Name": "Release Radar",
"SpotifyName": "Release Radar",
"Enabled": true
},
{
"Name": "Discover Weekly",
"SpotifyName": "Discover Weekly",
"Enabled": true
}
]
}
}

View File

@@ -74,6 +74,14 @@ services:
- Jellyfin__CacheDurationHours=${CACHE_DURATION_HOURS:-1}
- Jellyfin__EnableExternalPlaylists=${ENABLE_EXTERNAL_PLAYLISTS:-true}
# ===== SPOTIFY PLAYLIST INJECTION (JELLYFIN ONLY) =====
- SpotifyImport__Enabled=${SPOTIFY_IMPORT_ENABLED:-false}
- SpotifyImport__SyncStartHour=${SPOTIFY_IMPORT_SYNC_START_HOUR:-16}
- SpotifyImport__SyncStartMinute=${SPOTIFY_IMPORT_SYNC_START_MINUTE:-15}
- SpotifyImport__SyncWindowHours=${SPOTIFY_IMPORT_SYNC_WINDOW_HOURS:-2}
- SpotifyImport__PlaylistIds=${SPOTIFY_IMPORT_PLAYLIST_IDS:-}
- SpotifyImport__PlaylistNames=${SPOTIFY_IMPORT_PLAYLIST_NAMES:-}
# ===== SHARED =====
- Library__DownloadPath=/app/downloads
- SquidWTF__Quality=${SQUIDWTF_QUALITY:-FLAC}