From cb4a156507ed5b5f250e4fc69a88cc6a02112716 Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Sat, 31 Jan 2026 18:07:40 -0500 Subject: [PATCH] feat: add Spotify playlist injection with ID-based configuration --- .env.example | 25 ++ .gitignore | 5 +- README.md | 39 +++ allstarr/Controllers/JellyfinController.cs | 157 +++++++++- .../Models/Settings/SpotifyImportSettings.cs | 39 +++ allstarr/Models/Spotify/MissingTrack.cs | 12 + allstarr/Program.cs | 27 ++ .../Spotify/SpotifyMissingTracksFetcher.cs | 267 ++++++++++++++++++ allstarr/appsettings.Development.json | 18 ++ allstarr/appsettings.json | 20 +- 10 files changed, 604 insertions(+), 5 deletions(-) create mode 100644 allstarr/Models/Settings/SpotifyImportSettings.cs create mode 100644 allstarr/Models/Spotify/MissingTrack.cs create mode 100644 allstarr/Services/Spotify/SpotifyMissingTracksFetcher.cs diff --git a/.env.example b/.env.example index 876ba2f..6352368 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore index a1cf899..5ed0e7f 100644 --- a/.gitignore +++ b/.gitignore @@ -83,4 +83,7 @@ apis/*.json !apis/jellyfin-openapi-stable.json # Original source code for reference -originals/ \ No newline at end of file +originals/ + +# Sample missing playlists for Spotify integration testing +sampleMissingPlaylists/ \ No newline at end of file diff --git a/README.md b/README.md index c675435..b3432b7 100644 --- a/README.md +++ b/README.md @@ -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 - **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 @@ -287,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 diff --git a/allstarr/Controllers/JellyfinController.cs b/allstarr/Controllers/JellyfinController.cs index 539689d..8d33364 100644 --- a/allstarr/Controllers/JellyfinController.cs +++ b/allstarr/Controllers/JellyfinController.cs @@ -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 _logger; public JellyfinController( IOptions settings, + IOptions spotifySettings, IMusicMetadataService metadataService, ILocalLibraryService localLibraryService, IDownloadService downloadService, JellyfinResponseBuilder responseBuilder, JellyfinModelMapper modelMapper, JellyfinProxyService proxyService, + RedisCacheService cache, ILogger 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)) @@ -1251,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(result.RootElement.GetRawText())); } catch (Exception ex) { @@ -1603,6 +1654,38 @@ public class JellyfinController : ControllerBase [HttpPost("{**path}", Order = 100)] public async Task 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)) @@ -1827,5 +1910,73 @@ public class JellyfinController : ControllerBase } #endregion + + #region Spotify Playlist Injection + + /// + /// Gets tracks for a Spotify playlist by matching missing tracks against external providers. + /// + private async Task GetSpotifyPlaylistTracksAsync(string spotifyPlaylistName) + { + try + { + var cacheKey = $"spotify:matched:{spotifyPlaylistName}"; + var cachedTracks = await _cache.GetAsync>(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>(missingTracksKey); + + if (missingTracks == null || missingTracks.Count == 0) + { + _logger.LogInformation("No missing tracks found for {Playlist}", spotifyPlaylistName); + return _responseBuilder.CreateItemsResponse(new List()); + } + + _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() + .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 diff --git a/allstarr/Models/Settings/SpotifyImportSettings.cs b/allstarr/Models/Settings/SpotifyImportSettings.cs new file mode 100644 index 0000000..22e4104 --- /dev/null +++ b/allstarr/Models/Settings/SpotifyImportSettings.cs @@ -0,0 +1,39 @@ +namespace allstarr.Models.Settings; + +/// +/// 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. +/// +public class SpotifyImportSettings +{ + /// + /// Enable Spotify playlist injection feature + /// + public bool Enabled { get; set; } + + /// + /// Hour when Spotify Import plugin runs (24-hour format, 0-23) + /// Example: 16 for 4:00 PM + /// + public int SyncStartHour { get; set; } = 16; + + /// + /// Minute when Spotify Import plugin runs (0-59) + /// Example: 15 for 4:15 PM + /// + public int SyncStartMinute { get; set; } = 15; + + /// + /// 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 + /// + public int SyncWindowHours { get; set; } = 2; + + /// + /// Comma-separated list of Jellyfin playlist IDs to inject + /// Example: "4383a46d8bcac3be2ef9385053ea18df,ba50e26c867ec9d57ab2f7bf24cfd6b0" + /// Get IDs from Jellyfin playlist URLs + /// + public List PlaylistIds { get; set; } = new(); +} diff --git a/allstarr/Models/Spotify/MissingTrack.cs b/allstarr/Models/Spotify/MissingTrack.cs new file mode 100644 index 0000000..6f02b80 --- /dev/null +++ b/allstarr/Models/Spotify/MissingTrack.cs @@ -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 Artists { get; set; } = new(); + + public string PrimaryArtist => Artists.FirstOrDefault() ?? ""; + public string AllArtists => string.Join(", ", Artists); +} diff --git a/allstarr/Program.cs b/allstarr/Program.cs index a8371e4..60eacf4 100644 --- a/allstarr/Program.cs +++ b/allstarr/Program.cs @@ -108,6 +108,30 @@ builder.Services.Configure( builder.Configuration.GetSection("SquidWTF")); builder.Services.Configure( builder.Configuration.GetSection("Redis")); +// Configure Spotify Import settings with custom playlist parsing from env var +builder.Services.Configure(options => +{ + builder.Configuration.GetSection("SpotifyImport").Bind(options); + + // Parse SPOTIFY_IMPORT_PLAYLIST_IDS env var (comma-separated) into PlaylistIds array + var playlistIdsEnv = builder.Configuration.GetValue("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(); // Register cache cleanup service (only runs when StorageMode is Cache) builder.Services.AddHostedService(); +// Register Spotify missing tracks fetcher (only runs when SpotifyImport is enabled) +builder.Services.AddHostedService(); + builder.Services.AddCors(options => { options.AddDefaultPolicy(policy => diff --git a/allstarr/Services/Spotify/SpotifyMissingTracksFetcher.cs b/allstarr/Services/Spotify/SpotifyMissingTracksFetcher.cs new file mode 100644 index 0000000..e4744b3 --- /dev/null +++ b/allstarr/Services/Spotify/SpotifyMissingTracksFetcher.cs @@ -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 _spotifySettings; + private readonly IOptions _jellyfinSettings; + private readonly IHttpClientFactory _httpClientFactory; + private readonly RedisCacheService _cache; + private readonly ILogger _logger; + private readonly IServiceProvider _serviceProvider; + private bool _hasRunOnce = false; + private Dictionary _playlistIdToName = new(); + + public SpotifyMissingTracksFetcher( + IOptions spotifySettings, + IOptions jellyfinSettings, + IHttpClientFactory httpClientFactory, + RedisCacheService cache, + IServiceProvider serviceProvider, + ILogger 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(); + + 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 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 ParseMissingTracks(string json) + { + var tracks = new List(); + + 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; + } +} diff --git a/allstarr/appsettings.Development.json b/allstarr/appsettings.Development.json index ff66ba6..f1324b3 100644 --- a/allstarr/appsettings.Development.json +++ b/allstarr/appsettings.Development.json @@ -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 + } + ] } } diff --git a/allstarr/appsettings.json b/allstarr/appsettings.json index 8fec627..de60894 100644 --- a/allstarr/appsettings.json +++ b/allstarr/appsettings.json @@ -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 + } + ] } }