diff --git a/.env.example b/.env.example
index 6352368..f1292e9 100644
--- a/.env.example
+++ b/.env.example
@@ -120,3 +120,9 @@ SPOTIFY_IMPORT_SYNC_WINDOW_HOURS=2
# 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=
diff --git a/allstarr/Controllers/JellyfinController.cs b/allstarr/Controllers/JellyfinController.cs
index 8d33364..9afa4d4 100644
--- a/allstarr/Controllers/JellyfinController.cs
+++ b/allstarr/Controllers/JellyfinController.cs
@@ -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;
@@ -1283,7 +1284,7 @@ public class JellyfinController : ControllerBase
var playlistName = nameElement.GetString() ?? "";
_logger.LogInformation("✓ MATCHED! Intercepting Spotify playlist: {PlaylistName} (ID: {PlaylistId})",
playlistName, playlistId);
- return await GetSpotifyPlaylistTracksAsync(playlistName);
+ return await GetSpotifyPlaylistTracksAsync(playlistName, playlistId);
}
else
{
@@ -1668,11 +1669,11 @@ public class JellyfinController : ControllerBase
{
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));
+ _logger.LogInformation("=== PLAYLIST REQUEST ===");
+ _logger.LogInformation("Playlist ID: {PlaylistId}", playlistId);
+ _logger.LogInformation("Spotify Enabled: {Enabled}", _spotifySettings.Enabled);
+ _logger.LogInformation("Configured IDs: {Ids}", string.Join(", ", _spotifySettings.PlaylistIds));
+ _logger.LogInformation("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)))
@@ -1914,9 +1915,10 @@ public class JellyfinController : ControllerBase
#region Spotify Playlist Injection
///
- /// Gets tracks for a Spotify playlist by matching missing tracks against external providers.
+ /// Gets tracks for a Spotify playlist by matching missing tracks against external providers
+ /// and merging with existing local tracks from Jellyfin.
///
- private async Task GetSpotifyPlaylistTracksAsync(string spotifyPlaylistName)
+ private async Task GetSpotifyPlaylistTracksAsync(string spotifyPlaylistName, string playlistId)
{
try
{
@@ -1930,45 +1932,135 @@ public class JellyfinController : ControllerBase
return _responseBuilder.CreateItemsResponse(cachedTracks);
}
+ // Get existing Jellyfin playlist items (tracks the plugin already found)
+ var existingTracksResponse = await _proxyService.GetJsonAsync(
+ $"Playlists/{playlistId}/Items",
+ null,
+ Request.Headers);
+
+ var existingTracks = new List();
+ var existingSpotifyIds = new HashSet();
+
+ if (existingTracksResponse != null &&
+ existingTracksResponse.RootElement.TryGetProperty("Items", out var items))
+ {
+ foreach (var item in items.EnumerateArray())
+ {
+ var song = _modelMapper.ParseSong(item);
+ existingTracks.Add(song);
+
+ // Track Spotify IDs to avoid duplicates
+ if (item.TryGetProperty("ProviderIds", out var providerIds) &&
+ providerIds.TryGetProperty("Spotify", out var spotifyId))
+ {
+ existingSpotifyIds.Add(spotifyId.GetString() ?? "");
+ }
+ }
+ _logger.LogInformation("Found {Count} existing tracks in Jellyfin playlist", existingTracks.Count);
+ }
+
var missingTracksKey = $"spotify:missing:{spotifyPlaylistName}";
var missingTracks = await _cache.GetAsync>(missingTracksKey);
if (missingTracks == null || missingTracks.Count == 0)
{
- _logger.LogInformation("No missing tracks found for {Playlist}", spotifyPlaylistName);
- return _responseBuilder.CreateItemsResponse(new List());
+ _logger.LogInformation("No missing tracks found for {Playlist}, returning {Count} existing tracks",
+ spotifyPlaylistName, existingTracks.Count);
+ return _responseBuilder.CreateItemsResponse(existingTracks);
}
- _logger.LogInformation("Matching {Count} tracks for {Playlist}",
+ _logger.LogInformation("Matching {Count} missing tracks for {Playlist}",
missingTracks.Count, spotifyPlaylistName);
- var matchTasks = missingTracks.Select(async track =>
+ // Match missing tracks (excluding ones we already have locally)
+ var matchTasks = missingTracks
+ .Where(track => !existingSpotifyIds.Contains(track.SpotifyId))
+ .Select(async track =>
+ {
+ try
+ {
+ // Search with just title and artist for better matching
+ var query = $"{track.Title} {track.PrimaryArtist}";
+ var results = await _metadataService.SearchSongsAsync(query, limit: 5);
+
+ if (results.Count == 0)
+ return (track.SpotifyId, (Song?)null);
+
+ // Fuzzy match to find best result
+ var bestMatch = results
+ .Select(song => new
+ {
+ Song = song,
+ TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, song.Title),
+ ArtistScore = FuzzyMatcher.CalculateSimilarity(track.PrimaryArtist, song.Artist),
+ TotalScore = 0.0
+ })
+ .Select(x => new
+ {
+ x.Song,
+ x.TitleScore,
+ x.ArtistScore,
+ TotalScore = (x.TitleScore * 0.6) + (x.ArtistScore * 0.4) // Weight title more
+ })
+ .OrderByDescending(x => x.TotalScore)
+ .FirstOrDefault();
+
+ // Only return if match is good enough (>60% combined score)
+ if (bestMatch != null && bestMatch.TotalScore >= 60)
+ {
+ _logger.LogDebug("Matched '{Title}' by {Artist} -> '{MatchTitle}' by {MatchArtist} (score: {Score:F1})",
+ track.Title, track.PrimaryArtist,
+ bestMatch.Song.Title, bestMatch.Song.Artist,
+ bestMatch.TotalScore);
+ return (track.SpotifyId, (Song?)bestMatch.Song);
+ }
+
+ _logger.LogDebug("No good match for '{Title}' by {Artist} (best score: {Score:F1})",
+ track.Title, track.PrimaryArtist, bestMatch?.TotalScore ?? 0);
+ return (track.SpotifyId, (Song?)null);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
+ track.Title, track.PrimaryArtist);
+ return (track.SpotifyId, (Song?)null);
+ }
+ });
+
+ var matchResults = await Task.WhenAll(matchTasks);
+ var matchedBySpotifyId = matchResults
+ .Where(x => x.Item2 != null)
+ .ToDictionary(x => x.SpotifyId, x => x.Item2!);
+
+ // Build final track list in Spotify playlist order
+ var finalTracks = new List();
+ foreach (var missingTrack in missingTracks)
{
- try
+ // Check if we have it locally first
+ var existingTrack = existingTracks.FirstOrDefault(t =>
+ t.Title.Equals(missingTrack.Title, StringComparison.OrdinalIgnoreCase) &&
+ t.Artist.Equals(missingTrack.PrimaryArtist, StringComparison.OrdinalIgnoreCase));
+
+ if (existingTrack != null)
{
- var query = $"{track.Title} {track.AllArtists} {track.Album}";
- var results = await _metadataService.SearchSongsAsync(query, limit: 1);
- return results.FirstOrDefault();
+ finalTracks.Add(existingTrack);
}
- catch (Exception ex)
+ else if (matchedBySpotifyId.TryGetValue(missingTrack.SpotifyId, out var matchedTrack))
{
- _logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
- track.Title, track.PrimaryArtist);
- return null;
+ finalTracks.Add(matchedTrack);
}
- });
+ // Skip tracks we couldn't match
+ }
- var matchedTracks = (await Task.WhenAll(matchTasks))
- .Where(t => t != null)
- .Cast()
- .ToList();
+ await _cache.SetAsync(cacheKey, finalTracks, TimeSpan.FromHours(1));
- await _cache.SetAsync(cacheKey, matchedTracks, TimeSpan.FromHours(1));
+ _logger.LogInformation("Final playlist: {Total} tracks ({Existing} local, {Matched} matched, {Missing} missing)",
+ finalTracks.Count,
+ existingTracks.Count,
+ matchedBySpotifyId.Count,
+ missingTracks.Count - existingTracks.Count - matchedBySpotifyId.Count);
- _logger.LogInformation("Matched {Matched}/{Total} tracks for {Playlist}",
- matchedTracks.Count, missingTracks.Count, spotifyPlaylistName);
-
- return _responseBuilder.CreateItemsResponse(matchedTracks);
+ return _responseBuilder.CreateItemsResponse(finalTracks);
}
catch (Exception ex)
{
@@ -1977,6 +2069,164 @@ public class JellyfinController : ControllerBase
}
}
+ ///
+ /// Manual trigger endpoint to force fetch Spotify missing tracks.
+ /// GET /spotify/sync?api_key=YOUR_KEY
+ ///
+ [HttpGet("spotify/sync")]
+ [ServiceFilter(typeof(ApiKeyAuthFilter))]
+ public async Task TriggerSpotifySync()
+ {
+ if (!_spotifySettings.Enabled)
+ {
+ return BadRequest(new { error = "Spotify Import is not enabled" });
+ }
+
+ _logger.LogInformation("Manual Spotify sync triggered");
+
+ var results = new Dictionary();
+
+ 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 ParseMissingTracksJson(string json)
+ {
+ var tracks = new List();
+
+ 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
+
+ ///
+ /// Clear Spotify playlist cache to force re-matching.
+ /// GET /spotify/clear-cache?api_key=YOUR_KEY
+ ///
+ [HttpGet("spotify/clear-cache")]
+ [ServiceFilter(typeof(ApiKeyAuthFilter))]
+ public async Task ClearSpotifyCache()
+ {
+ if (!_spotifySettings.Enabled)
+ {
+ return BadRequest(new { error = "Spotify Import is not enabled" });
+ }
+
+ var cleared = new List();
+
+ 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
diff --git a/allstarr/Filters/ApiKeyAuthFilter.cs b/allstarr/Filters/ApiKeyAuthFilter.cs
new file mode 100644
index 0000000..ef403e4
--- /dev/null
+++ b/allstarr/Filters/ApiKeyAuthFilter.cs
@@ -0,0 +1,52 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Filters;
+using Microsoft.Extensions.Options;
+using allstarr.Models.Settings;
+
+namespace allstarr.Filters;
+
+///
+/// Simple API key authentication filter for admin endpoints.
+/// Validates against Jellyfin API key via query parameter or header.
+///
+public class ApiKeyAuthFilter : IAsyncActionFilter
+{
+ private readonly JellyfinSettings _settings;
+ private readonly ILogger _logger;
+
+ public ApiKeyAuthFilter(
+ IOptions settings,
+ ILogger 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();
+ }
+}
diff --git a/allstarr/Models/Settings/SpotifyImportSettings.cs b/allstarr/Models/Settings/SpotifyImportSettings.cs
index 22e4104..716f35f 100644
--- a/allstarr/Models/Settings/SpotifyImportSettings.cs
+++ b/allstarr/Models/Settings/SpotifyImportSettings.cs
@@ -36,4 +36,12 @@ public class SpotifyImportSettings
/// Get IDs from Jellyfin playlist URLs
///
public List PlaylistIds { get; set; } = new();
+
+ ///
+ /// 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
+ ///
+ public List PlaylistNames { get; set; } = new();
}
diff --git a/allstarr/Program.cs b/allstarr/Program.cs
index 60eacf4..44c1684 100644
--- a/allstarr/Program.cs
+++ b/allstarr/Program.cs
@@ -124,12 +124,24 @@ builder.Services.Configure(options =>
.ToList();
}
+ // Parse SPOTIFY_IMPORT_PLAYLIST_NAMES env var (comma-separated) into PlaylistNames array
+ var playlistNamesEnv = builder.Configuration.GetValue("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");
- foreach (var id in options.PlaylistIds)
+ for (int i = 0; i < options.PlaylistIds.Count; i++)
{
- Console.WriteLine($" - {id}");
+ var name = i < options.PlaylistNames.Count ? options.PlaylistNames[i] : options.PlaylistIds[i];
+ Console.WriteLine($" - {name} (ID: {options.PlaylistIds[i]})");
}
});
@@ -162,6 +174,7 @@ if (backendType == BackendType.Jellyfin)
builder.Services.AddSingleton();
builder.Services.AddScoped();
builder.Services.AddScoped();
+ builder.Services.AddScoped();
}
else
{
diff --git a/allstarr/Services/Jellyfin/JellyfinResponseBuilder.cs b/allstarr/Services/Jellyfin/JellyfinResponseBuilder.cs
index 21feaef..dd4dcea 100644
--- a/allstarr/Services/Jellyfin/JellyfinResponseBuilder.cs
+++ b/allstarr/Services/Jellyfin/JellyfinResponseBuilder.cs
@@ -289,6 +289,24 @@ public class JellyfinResponseBuilder
var providerIds = (Dictionary)item["ProviderIds"]!;
providerIds["ISRC"] = song.Isrc;
}
+
+ // Add MediaSources with bitrate for external tracks
+ item["MediaSources"] = new[]
+ {
+ new Dictionary
+ {
+ ["Id"] = song.Id,
+ ["Type"] = "Default",
+ ["Container"] = "flac",
+ ["Size"] = (song.Duration ?? 180) * 1337 * 128, // Approximate file size
+ ["Bitrate"] = 1337000, // 1337 kbps in bps
+ ["Path"] = $"/music/{song.Artist}/{song.Album}/{song.Title}.flac",
+ ["Protocol"] = "File",
+ ["SupportsDirectStream"] = true,
+ ["SupportsTranscoding"] = true,
+ ["SupportsDirectPlay"] = true
+ }
+ };
}
if (!string.IsNullOrEmpty(song.Genre))
diff --git a/allstarr/Services/Spotify/SpotifyMissingTracksFetcher.cs b/allstarr/Services/Spotify/SpotifyMissingTracksFetcher.cs
index e4744b3..3d46347 100644
--- a/allstarr/Services/Spotify/SpotifyMissingTracksFetcher.cs
+++ b/allstarr/Services/Spotify/SpotifyMissingTracksFetcher.cs
@@ -111,27 +111,15 @@ public class SpotifyMissingTracksFetcher : BackgroundService
{
_playlistIdToName.Clear();
- using var scope = _serviceProvider.CreateScope();
- var proxyService = scope.ServiceProvider.GetRequiredService();
-
- foreach (var playlistId in _spotifySettings.Value.PlaylistIds)
+ // Use configured playlist names instead of fetching from API
+ for (int i = 0; i < _spotifySettings.Value.PlaylistIds.Count; i++)
{
- 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);
- }
+ 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;
}
}
@@ -158,12 +146,13 @@ public class SpotifyMissingTracksFetcher : BackgroundService
.AddMinutes(settings.SyncStartMinute);
var syncEnd = syncStart.AddHours(settings.SyncWindowHours);
- if (now < syncStart || now > syncEnd)
+ // Only run after the sync window has passed
+ if (now < syncEnd)
{
return;
}
- _logger.LogInformation("Within sync window, fetching missing tracks...");
+ _logger.LogInformation("Sync window passed, searching last 24 hours for missing tracks...");
foreach (var kvp in _playlistIdToName)
{
@@ -187,46 +176,106 @@ public class SpotifyMissingTracksFetcher : BackgroundService
var jellyfinUrl = _jellyfinSettings.Value.Url;
var apiKey = _jellyfinSettings.Value.ApiKey;
var httpClient = _httpClientFactory.CreateClient();
- var today = DateTime.UtcNow.Date;
- var syncStart = today
+
+ // Start from the configured sync time (most likely time)
+ var now = DateTime.UtcNow;
+ var todaySync = now.Date
.AddHours(settings.SyncStartHour)
.AddMinutes(settings.SyncStartMinute);
- var syncEnd = syncStart.AddHours(settings.SyncWindowHours);
+
+ // If we haven't reached today's sync time yet, start from yesterday's sync time
+ var syncTime = now >= todaySync ? todaySync : todaySync.AddDays(-1);
- _logger.LogInformation("Searching for missing tracks file for {Playlist}", playlistName);
+ _logger.LogInformation("Searching ±12 hours around {SyncTime} for {Playlist}",
+ syncTime, playlistName);
- for (var time = syncStart; time <= syncEnd; time = time.AddMinutes(5))
+ var found = false;
+
+ // Search forward 12 hours from sync time
+ for (var minutesAhead = 0; minutesAhead <= 720; minutesAhead++) // 720 minutes = 12 hours
{
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
+ var time = syncTime.AddMinutes(minutesAhead);
+ if (await TryFetchMissingTracksFile(playlistName, time, jellyfinUrl, apiKey, httpClient, cancellationToken))
{
- _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;
- }
- }
+ found = true;
+ break;
}
- catch (Exception ex)
+
+ // Small delay every 60 requests
+ if (minutesAhead > 0 && minutesAhead % 60 == 0)
{
- _logger.LogDebug(ex, "Failed to fetch {Filename}", filename);
+ await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
}
}
+
+ // Then search backwards 12 hours from sync time
+ if (!found)
+ {
+ for (var minutesBehind = 1; minutesBehind <= 720; minutesBehind++)
+ {
+ if (cancellationToken.IsCancellationRequested) break;
+
+ var time = syncTime.AddMinutes(-minutesBehind);
+ if (await TryFetchMissingTracksFile(playlistName, time, jellyfinUrl, apiKey, httpClient, cancellationToken))
+ {
+ found = true;
+ break;
+ }
+
+ // Small delay every 60 requests
+ if (minutesBehind % 60 == 0)
+ {
+ await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
+ }
+ }
+ }
+
+ if (!found)
+ {
+ _logger.LogWarning("Could not find missing tracks file for {Playlist} in ±12 hour window", playlistName);
+ }
+ }
+
+ private async Task TryFetchMissingTracksFile(
+ string playlistName,
+ DateTime time,
+ string jellyfinUrl,
+ string apiKey,
+ HttpClient httpClient,
+ CancellationToken cancellationToken)
+ {
+ var filename = $"{playlistName}_missing_{time:yyyy-MM-dd_HH-mm}.json";
+ var url = $"{jellyfinUrl}/Viperinius.Plugin.SpotifyImport/MissingTracksFile" +
+ $"?name={Uri.EscapeDataString(filename)}&api_key={apiKey}";
+
+ try
+ {
+ _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)
+ {
+ var cacheKey = $"spotify:missing:{playlistName}";
+ await _cache.SetAsync(cacheKey, tracks, TimeSpan.FromHours(24));
+ _logger.LogInformation(
+ "✓ Cached {Count} missing tracks for {Playlist} from {Filename}",
+ tracks.Count, playlistName, filename);
+ return true;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Failed to fetch {Filename}", filename);
+ }
+
+ return false;
}
private List ParseMissingTracks(string json)
diff --git a/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs b/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs
index 39b7e0d..56b84da 100644
--- a/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs
+++ b/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs
@@ -78,11 +78,18 @@ public class SquidWTFMetadataService : IMusicMetadataService
if (!response.IsSuccessStatusCode)
{
- return new List();
+ throw new HttpRequestException($"HTTP {response.StatusCode}");
}
var json = await response.Content.ReadAsStringAsync();
+
+ // Check for error in response body
var result = JsonDocument.Parse(json);
+ if (result.RootElement.TryGetProperty("detail", out _) ||
+ result.RootElement.TryGetProperty("error", out _))
+ {
+ throw new HttpRequestException("API returned error response");
+ }
var songs = new List();
if (result.RootElement.TryGetProperty("data", out var data) &&
diff --git a/docker-compose.yml b/docker-compose.yml
index a3fca71..73f572e 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -80,6 +80,7 @@ services:
- 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