Compare commits

...

49 Commits

Author SHA1 Message Date
df6c500ef5 add Spotify playlist injection feature
Some checks failed
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-01-31 18:11:35 -05:00
cb4a156507 feat: add Spotify playlist injection with ID-based configuration 2026-01-31 18:07:40 -05:00
554ac90fee docs: update README
Some checks failed
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-01-31 11:15:54 -05:00
ae3b26d46a fix: improve auth, search, and stability
Some checks failed
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-01-31 01:14:32 -05:00
d43494dc42 Merge branch 'main' into beta
Some checks failed
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-01-30 14:11:59 -05:00
70a56e758a fix: enable deduplication for cache mode
- Cache mode now registers downloaded songs in mappings
- Prevents duplicate downloads in cache mode
- Fixes wasted API calls and duplicate cache files
- Both cache and download modes benefit from deduplication
2026-01-30 14:11:18 -05:00
13205087ff fix: prevent duplicate downloads by registering before releasing lock
- Race condition fixed where multiple threads could download same song
- RegisterDownloadedSongAsync now called before lock release
- Second thread finds registered mapping and skips download
- Eliminates duplicate files with (1), (2) suffixes
2026-01-30 13:38:11 -05:00
3c4e368912 fix: add base64 encoded SquidWTF endpoints with automatic fallback
- Decode 6 endpoints at startup (triton, wolf, hund, maus, vogel, katze)
- Automatic fallback when endpoint fails
- All services cycle through endpoints on failure
- URLs stored as base64, decoded once in Program.cs
- Fixes search/download issues
2026-01-30 13:24:10 -05:00
018c1d5d93 Merge branch 'main' into beta 2026-01-30 12:40:39 -05:00
6d38dd2615 Add automatic endpoint fallback to all SquidWTF metadata search methods 2026-01-30 12:40:36 -05:00
a2917f14fa Merge branch 'main' into beta 2026-01-30 12:38:51 -05:00
8ee3ce96d3 Standardize all SquidWTF API URL formats to prevent double slashes 2026-01-30 12:38:30 -05:00
46a9f945e6 Merge branch 'main' into beta 2026-01-30 12:37:03 -05:00
5e90cd8ea8 Fix double slash bug in SquidWTF API URLs 2026-01-30 12:36:35 -05:00
a540f1f766 Merge branch 'main' into beta 2026-01-30 12:26:42 -05:00
0cfefd667f Update startup validator to use base64 encoded endpoint 2026-01-30 12:26:28 -05:00
93b55599ee Merge branch 'main' into beta 2026-01-30 12:25:21 -05:00
2a66eba58d Fix remaining BaseUrl references 2026-01-30 12:24:53 -05:00
b9acc502af Merge branch 'main' into beta 2026-01-30 12:13:09 -05:00
aae7dfe59e Add backup API endpoints with automatic fallback 2026-01-30 12:12:41 -05:00
4472f71265 Merge branch 'main' into beta 2026-01-30 12:01:05 -05:00
fa176bad0f Consolidate Quick Start and add Contributing section 2026-01-30 12:00:38 -05:00
b3fe044405 Merge branch 'main' into beta 2026-01-30 11:54:46 -05:00
5a0ae860e3 Add build status badges to README 2026-01-30 11:54:15 -05:00
a307ef8975 Merge branch 'main' into beta 2026-01-30 11:48:23 -05:00
4d21291598 Update docker-compose to use GHCR image by default 2026-01-30 11:47:46 -05:00
7c19f199f1 Merge branch 'main' into beta 2026-01-30 11:39:00 -05:00
629bf38ea7 Update CI/CD: deploy only on main and beta branches 2026-01-30 11:38:28 -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
14 changed files with 716 additions and 41 deletions

View File

@@ -95,3 +95,28 @@ 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=

5
.gitignore vendored
View File

@@ -83,4 +83,7 @@ apis/*.json
!apis/jellyfin-openapi-stable.json
# Original source code for reference
originals/
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

@@ -22,6 +22,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 +30,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 +55,7 @@ public class JellyfinController : ControllerBase
_modelMapper = modelMapper;
_proxyService = proxyService;
_playlistSyncService = playlistSyncService;
_cache = cache;
_logger = logger;
if (string.IsNullOrWhiteSpace(_settings.Url))
@@ -169,31 +175,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 +213,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 +780,23 @@ public class JellyfinController : ControllerBase
var contentType = response.Content.Headers.ContentType?.ToString() ?? "audio/mpeg";
// Forward caching headers for client-side caching
if (response.Headers.ETag != null)
{
Response.Headers["ETag"] = response.Headers.ETag.ToString();
}
if (response.Content.Headers.LastModified.HasValue)
{
Response.Headers["Last-Modified"] = response.Content.Headers.LastModified.Value.ToString("R");
}
if (response.Headers.CacheControl != null)
{
Response.Headers["Cache-Control"] = response.Headers.CacheControl.ToString();
}
// Forward range headers for seeking
if (response.Content.Headers.ContentRange != null)
{
Response.Headers["Content-Range"] = response.Content.Headers.ContentRange.ToString();
@@ -1238,10 +1257,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);
// 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);
}
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 +1654,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 +1857,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 +1910,73 @@ 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");
}
}
#endregion
}
// force rebuild Sun Jan 25 13:22:47 EST 2026

View File

@@ -0,0 +1,39 @@
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();
}

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,30 @@ 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();
}
// 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");
foreach (var id in options.PlaylistIds)
{
Console.WriteLine($" - {id}");
}
});
// Get shared settings from the active backend config
MusicService musicService;
@@ -229,6 +253,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,267 @@
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();
using var scope = _serviceProvider.CreateScope();
var proxyService = scope.ServiceProvider.GetRequiredService<JellyfinProxyService>();
foreach (var playlistId in _spotifySettings.Value.PlaylistIds)
{
try
{
var playlistInfo = await proxyService.GetJsonAsync($"Items/{playlistId}", null, null);
if (playlistInfo != null && playlistInfo.RootElement.TryGetProperty("Name", out var nameElement))
{
var name = nameElement.GetString() ?? "";
if (!string.IsNullOrEmpty(name))
{
_playlistIdToName[playlistId] = name;
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get name for playlist {PlaylistId}", playlistId);
}
}
}
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,13 @@ 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:-}
# ===== SHARED =====
- Library__DownloadPath=/app/downloads
- SquidWTF__Quality=${SQUIDWTF_QUALITY:-FLAC}