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
This commit is contained in:
2026-01-31 16:43:49 -05:00
parent 35d5249843
commit 8912758b5e
9 changed files with 501 additions and 1 deletions

View File

@@ -95,3 +95,36 @@ STORAGE_MODE=Permanent
# Based on last access time (updated each time the file is streamed) # 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 location: /tmp/allstarr-cache (or $TMPDIR/allstarr-cache if TMPDIR is set)
CACHE_DURATION_HOURS=1 CACHE_DURATION_HOURS=1
# ===== SPOTIFY PLAYLIST INJECTION (JELLYFIN ONLY) =====
# REQUIRES: Jellyfin Spotify Import Plugin (https://github.com/Viperinius/jellyfin-plugin-spotify-import)
# This feature injects virtual Spotify playlists (Release Radar, Discover Weekly) into Jellyfin
# with tracks auto-matched from external providers (SquidWTF, Deezer, Qobuz)
# Enable Spotify playlist injection (optional, default: false)
SPOTIFY_IMPORT_ENABLED=false
# Jellyfin server URL (required if SPOTIFY_IMPORT_ENABLED=true)
# Should match your JELLYFIN_URL unless using a different URL for plugin access
SPOTIFY_IMPORT_JELLYFIN_URL=http://localhost:8096
# Jellyfin API key (REQUIRED if SPOTIFY_IMPORT_ENABLED=true)
# Get from Jellyfin Dashboard > API Keys > Create new key
# This is used to fetch missing tracks files from the Spotify Import plugin
SPOTIFY_IMPORT_API_KEY=
# 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
# Playlists to inject (comma-separated, optional)
# Available: Release Radar, Discover Weekly
# Leave empty to disable all, or specify which ones to enable
SPOTIFY_IMPORT_PLAYLISTS=Release Radar,Discover Weekly

View File

@@ -90,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 - **Artist Deduplication**: Merges local and streaming artists to avoid duplicates
- **Album Enrichment**: Adds missing tracks to local albums from streaming providers - **Album Enrichment**: Adds missing tracks to local albums from streaming providers
- **Cover Art Proxy**: Serves cover art for external content - **Cover Art Proxy**: Serves cover art for external content
- **Spotify Playlist Injection** (Jellyfin only): Injects virtual Spotify playlists (Release Radar, Discover Weekly) with tracks auto-matched from streaming providers
## Supported Backends ## Supported Backends
@@ -287,6 +288,47 @@ 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. > **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 inject virtual Spotify playlists (Release Radar, Discover Weekly) into Jellyfin with tracks automatically matched from your configured streaming provider.
**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 API key with access to plugin endpoints
**Configuration:**
| Setting | Description |
|---------|-------------|
| `SpotifyImport:Enabled` | Enable Spotify playlist injection (default: `false`) |
| `SpotifyImport:JellyfinUrl` | Jellyfin server URL for plugin access |
| `SpotifyImport:ApiKey` | **REQUIRED** - Jellyfin API key for accessing missing tracks files |
| `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 missing tracks files for playlists
2. Allstarr fetches these files within the configured time window
3. For each missing track, Allstarr searches your streaming provider (SquidWTF, Deezer, or Qobuz)
4. Virtual playlists appear in Jellyfin with matched tracks ready to stream
5. Tracks are downloaded on-demand when played
**Environment variables:**
```bash
SPOTIFY_IMPORT_ENABLED=true
SPOTIFY_IMPORT_JELLYFIN_URL=http://localhost:8096
SPOTIFY_IMPORT_API_KEY=your-jellyfin-api-key
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 only works with Jellyfin backend. The plugin must be configured to run on a schedule, and the sync window should cover the plugin's execution time.
### Getting Credentials ### Getting Credentials
#### Deezer ARL Token #### Deezer ARL Token

View File

@@ -22,6 +22,7 @@ namespace allstarr.Controllers;
public class JellyfinController : ControllerBase public class JellyfinController : ControllerBase
{ {
private readonly JellyfinSettings _settings; private readonly JellyfinSettings _settings;
private readonly SpotifyImportSettings _spotifySettings;
private readonly IMusicMetadataService _metadataService; private readonly IMusicMetadataService _metadataService;
private readonly ILocalLibraryService _localLibraryService; private readonly ILocalLibraryService _localLibraryService;
private readonly IDownloadService _downloadService; private readonly IDownloadService _downloadService;
@@ -29,20 +30,24 @@ public class JellyfinController : ControllerBase
private readonly JellyfinModelMapper _modelMapper; private readonly JellyfinModelMapper _modelMapper;
private readonly JellyfinProxyService _proxyService; private readonly JellyfinProxyService _proxyService;
private readonly PlaylistSyncService? _playlistSyncService; private readonly PlaylistSyncService? _playlistSyncService;
private readonly RedisCacheService _cache;
private readonly ILogger<JellyfinController> _logger; private readonly ILogger<JellyfinController> _logger;
public JellyfinController( public JellyfinController(
IOptions<JellyfinSettings> settings, IOptions<JellyfinSettings> settings,
IOptions<SpotifyImportSettings> spotifySettings,
IMusicMetadataService metadataService, IMusicMetadataService metadataService,
ILocalLibraryService localLibraryService, ILocalLibraryService localLibraryService,
IDownloadService downloadService, IDownloadService downloadService,
JellyfinResponseBuilder responseBuilder, JellyfinResponseBuilder responseBuilder,
JellyfinModelMapper modelMapper, JellyfinModelMapper modelMapper,
JellyfinProxyService proxyService, JellyfinProxyService proxyService,
RedisCacheService cache,
ILogger<JellyfinController> logger, ILogger<JellyfinController> logger,
PlaylistSyncService? playlistSyncService = null) PlaylistSyncService? playlistSyncService = null)
{ {
_settings = settings.Value; _settings = settings.Value;
_spotifySettings = spotifySettings.Value;
_metadataService = metadataService; _metadataService = metadataService;
_localLibraryService = localLibraryService; _localLibraryService = localLibraryService;
_downloadService = downloadService; _downloadService = downloadService;
@@ -50,6 +55,7 @@ public class JellyfinController : ControllerBase
_modelMapper = modelMapper; _modelMapper = modelMapper;
_proxyService = proxyService; _proxyService = proxyService;
_playlistSyncService = playlistSyncService; _playlistSyncService = playlistSyncService;
_cache = cache;
_logger = logger; _logger = logger;
if (string.IsNullOrWhiteSpace(_settings.Url)) if (string.IsNullOrWhiteSpace(_settings.Url))
@@ -214,6 +220,13 @@ public class JellyfinController : ControllerBase
mergedAlbums.AddRange(scoredPlaylists); mergedAlbums.AddRange(scoredPlaylists);
} }
// Inject Spotify playlists if enabled
if (_spotifySettings.Enabled)
{
var spotifyPlaylists = await GetSpotifyPlaylistsAsync(cleanQuery);
mergedAlbums.AddRange(spotifyPlaylists);
}
_logger.LogInformation("Scored and filtered results: Songs={Songs}, Albums={Albums}, Artists={Artists}", _logger.LogInformation("Scored and filtered results: Songs={Songs}, Albums={Albums}, Artists={Artists}",
mergedSongs.Count, mergedAlbums.Count, mergedArtists.Count); mergedSongs.Count, mergedAlbums.Count, mergedArtists.Count);
@@ -1251,6 +1264,12 @@ public class JellyfinController : ControllerBase
{ {
try try
{ {
// Check if this is a Spotify playlist
if (playlistId.StartsWith("spotify-"))
{
return await GetSpotifyPlaylistTracksAsync(playlistId);
}
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(playlistId); var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(playlistId);
var tracks = await _metadataService.GetPlaylistTracksAsync(provider, externalId); var tracks = await _metadataService.GetPlaylistTracksAsync(provider, externalId);
@@ -1827,5 +1846,118 @@ public class JellyfinController : ControllerBase
} }
#endregion #endregion
#region Spotify Playlist Injection
/// <summary>
/// Gets Spotify playlists that have cached missing tracks.
/// </summary>
private async Task<List<Dictionary<string, object?>>> GetSpotifyPlaylistsAsync(string searchQuery)
{
var playlists = new List<Dictionary<string, object?>>();
foreach (var playlist in _spotifySettings.Playlists.Where(p => p.Enabled))
{
var cacheKey = $"spotify:missing:{playlist.SpotifyName}";
var hasTracks = await _cache.ExistsAsync(cacheKey);
if (!hasTracks) continue;
var score = string.IsNullOrWhiteSpace(searchQuery)
? 100
: FuzzyMatcher.CalculateSimilarity(searchQuery, playlist.Name);
if (score < 30 && !string.IsNullOrWhiteSpace(searchQuery)) continue;
playlists.Add(new Dictionary<string, object?>
{
["Id"] = $"spotify-{playlist.SpotifyName.ToLower().Replace(" ", "-")}",
["Name"] = $"{playlist.Name} - S",
["Type"] = "Playlist",
["IsFolder"] = false,
["MediaType"] = "Audio",
["CollectionType"] = "music"
});
}
return playlists;
}
/// <summary>
/// Gets tracks for a Spotify playlist by matching missing tracks against external providers.
/// </summary>
private async Task<IActionResult> GetSpotifyPlaylistTracksAsync(string playlistId)
{
try
{
var playlistName = playlistId.Replace("spotify-", "").Replace("-", " ");
var matchingPlaylist = _spotifySettings.Playlists
.FirstOrDefault(p => p.SpotifyName.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
if (matchingPlaylist == null)
{
_logger.LogWarning("Spotify playlist not found in config: {PlaylistName}", playlistName);
return _responseBuilder.CreateItemsResponse(new List<Song>());
}
var cacheKey = $"spotify:matched:{matchingPlaylist.SpotifyName}";
var cachedTracks = await _cache.GetAsync<List<Song>>(cacheKey);
if (cachedTracks != null)
{
_logger.LogDebug("Returning {Count} cached matched tracks for {Playlist}",
cachedTracks.Count, matchingPlaylist.Name);
return _responseBuilder.CreateItemsResponse(cachedTracks);
}
var missingTracksKey = $"spotify:missing:{matchingPlaylist.SpotifyName}";
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}", matchingPlaylist.Name);
return _responseBuilder.CreateItemsResponse(new List<Song>());
}
_logger.LogInformation("Matching {Count} tracks for {Playlist}",
missingTracks.Count, matchingPlaylist.Name);
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, matchingPlaylist.Name);
return _responseBuilder.CreateItemsResponse(matchedTracks);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting Spotify playlist tracks {PlaylistId}", playlistId);
return _responseBuilder.CreateError(500, "Failed to get Spotify playlist tracks");
}
}
#endregion
} }
// force rebuild Sun Jan 25 13:22:47 EST 2026 // force rebuild Sun Jan 25 13:22:47 EST 2026

View File

@@ -0,0 +1,69 @@
namespace allstarr.Models.Settings;
/// <summary>
/// Configuration for Spotify playlist injection feature.
/// Requires Jellyfin Spotify Import Plugin: https://github.com/Viperinius/jellyfin-plugin-spotify-import
/// </summary>
public class SpotifyImportSettings
{
/// <summary>
/// Enable Spotify playlist injection feature
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Jellyfin server URL (for accessing plugin API)
/// </summary>
public string JellyfinUrl { get; set; } = string.Empty;
/// <summary>
/// Jellyfin API key (REQUIRED for accessing missing tracks files)
/// Get from Jellyfin Dashboard > API Keys
/// </summary>
public string ApiKey { get; set; } = string.Empty;
/// <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>
/// List of playlists to inject
/// </summary>
public List<SpotifyPlaylistConfig> Playlists { get; set; } = new();
}
/// <summary>
/// Configuration for a single Spotify playlist
/// </summary>
public class SpotifyPlaylistConfig
{
/// <summary>
/// Display name in Jellyfin (e.g., "Release Radar")
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Playlist name in Spotify Import plugin missing tracks file
/// Must match exactly (e.g., "Release Radar")
/// </summary>
public string SpotifyName { get; set; } = string.Empty;
/// <summary>
/// Enable this playlist
/// </summary>
public bool Enabled { get; set; } = true;
}

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,8 @@ builder.Services.Configure<SquidWTFSettings>(
builder.Configuration.GetSection("SquidWTF")); builder.Configuration.GetSection("SquidWTF"));
builder.Services.Configure<RedisSettings>( builder.Services.Configure<RedisSettings>(
builder.Configuration.GetSection("Redis")); builder.Configuration.GetSection("Redis"));
builder.Services.Configure<SpotifyImportSettings>(
builder.Configuration.GetSection("SpotifyImport"));
// Get shared settings from the active backend config // Get shared settings from the active backend config
MusicService musicService; MusicService musicService;
@@ -229,6 +231,9 @@ builder.Services.AddHostedService<StartupValidationOrchestrator>();
// Register cache cleanup service (only runs when StorageMode is Cache) // Register cache cleanup service (only runs when StorageMode is Cache)
builder.Services.AddHostedService<CacheCleanupService>(); 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 => builder.Services.AddCors(options =>
{ {
options.AddDefaultPolicy(policy => options.AddDefaultPolicy(policy =>

View File

@@ -0,0 +1,167 @@
using allstarr.Models.Settings;
using allstarr.Models.Spotify;
using allstarr.Services.Common;
using Microsoft.Extensions.Options;
using System.Text.Json;
namespace allstarr.Services.Spotify;
public class SpotifyMissingTracksFetcher : BackgroundService
{
private readonly IOptions<SpotifyImportSettings> _settings;
private readonly IHttpClientFactory _httpClientFactory;
private readonly RedisCacheService _cache;
private readonly ILogger<SpotifyMissingTracksFetcher> _logger;
public SpotifyMissingTracksFetcher(
IOptions<SpotifyImportSettings> settings,
IHttpClientFactory httpClientFactory,
RedisCacheService cache,
ILogger<SpotifyMissingTracksFetcher> logger)
{
_settings = settings;
_httpClientFactory = httpClientFactory;
_cache = cache;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!_settings.Value.Enabled)
{
_logger.LogInformation("Spotify playlist injection is disabled");
return;
}
_logger.LogInformation("Spotify missing tracks fetcher started");
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 FetchMissingTracksAsync(CancellationToken cancellationToken)
{
var settings = _settings.Value;
if (string.IsNullOrEmpty(settings.ApiKey))
{
_logger.LogWarning("Spotify import API key not configured");
return;
}
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;
}
foreach (var playlist in settings.Playlists.Where(p => p.Enabled))
{
await FetchPlaylistMissingTracksAsync(playlist, cancellationToken);
}
}
private async Task FetchPlaylistMissingTracksAsync(
SpotifyPlaylistConfig playlist,
CancellationToken cancellationToken)
{
var cacheKey = $"spotify:missing:{playlist.SpotifyName}";
if (await _cache.ExistsAsync(cacheKey))
{
return;
}
var settings = _settings.Value;
var httpClient = _httpClientFactory.CreateClient();
var today = DateTime.UtcNow.Date;
var syncStart = today
.AddHours(settings.SyncStartHour)
.AddMinutes(settings.SyncStartMinute);
var syncEnd = syncStart.AddHours(settings.SyncWindowHours);
for (var time = syncStart; time <= syncEnd; time = time.AddMinutes(5))
{
if (cancellationToken.IsCancellationRequested) break;
var filename = $"{playlist.SpotifyName}_missing_{time:yyyy-MM-dd_HH-mm}.json";
var url = $"{settings.JellyfinUrl}/Viperinius.Plugin.SpotifyImport/MissingTracksFile" +
$"?name={Uri.EscapeDataString(filename)}&api_key={settings.ApiKey}";
try
{
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, playlist.Name, 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

@@ -4,5 +4,25 @@
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
},
"SpotifyImport": {
"Enabled": false,
"JellyfinUrl": "http://localhost:8096",
"ApiKey": "",
"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" "Type": "Subsonic"
}, },
"Subsonic": { "Subsonic": {
"Url": "https://navidrome.local.bransonb.com", "Url": "http://localhost:4533",
"MusicService": "SquidWTF", "MusicService": "SquidWTF",
"ExplicitFilter": "All", "ExplicitFilter": "All",
"DownloadMode": "Track", "DownloadMode": "Track",
@@ -42,5 +42,25 @@
"Redis": { "Redis": {
"Enabled": true, "Enabled": true,
"ConnectionString": "localhost:6379" "ConnectionString": "localhost:6379"
},
"SpotifyImport": {
"Enabled": false,
"JellyfinUrl": "https://jellyfin.example.com",
"ApiKey": "",
"SyncStartHour": 16,
"SyncStartMinute": 15,
"SyncWindowHours": 2,
"Playlists": [
{
"Name": "Release Radar",
"SpotifyName": "Release Radar",
"Enabled": true
},
{
"Name": "Discover Weekly",
"SpotifyName": "Discover Weekly",
"Enabled": true
}
]
} }
} }