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