diff --git a/.env.example b/.env.example index 3ea1037..ed98ecc 100644 --- a/.env.example +++ b/.env.example @@ -126,13 +126,13 @@ SPOTIFY_IMPORT_SYNC_START_MINUTE=15 # 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= - -# 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= +# Playlists configuration (JSON array format - combines name, ID, and local track position) +# Format: [["PlaylistName","JellyfinPlaylistId","first|last"],...] +# - PlaylistName: Name as it appears in Jellyfin (e.g., "Discover Weekly", "Release Radar") +# - JellyfinPlaylistId: Get from playlist URL: https://jellyfin.example.com/web/#/details?id=PLAYLIST_ID +# - first|last: Where to position local tracks relative to external tracks +# - "first": Local tracks appear first, external tracks at the end (default) +# - "last": External tracks appear first, local tracks at the end +# Example with 2 playlists: +# SPOTIFY_IMPORT_PLAYLISTS=[["Discover Weekly","4383a46d8bcac3be2ef9385053ea18df","first"],["Release Radar","ba50e26c867ec9d57ab2f7bf24cfd6b0","last"]] +SPOTIFY_IMPORT_PLAYLISTS= diff --git a/allstarr/Controllers/JellyfinController.cs b/allstarr/Controllers/JellyfinController.cs index 330d6d7..563dcad 100644 --- a/allstarr/Controllers/JellyfinController.cs +++ b/allstarr/Controllers/JellyfinController.cs @@ -1366,11 +1366,10 @@ public class JellyfinController : ControllerBase } // Check if this is a Spotify playlist (by ID) - _logger.LogInformation("Spotify Import Enabled: {Enabled}, Configured IDs: {Count}", - _spotifySettings.Enabled, _spotifySettings.PlaylistIds.Count); + _logger.LogInformation("Spotify Import Enabled: {Enabled}, Configured Playlists: {Count}", + _spotifySettings.Enabled, _spotifySettings.Playlists.Count); - if (_spotifySettings.Enabled && - _spotifySettings.PlaylistIds.Any(id => id.Equals(playlistId, StringComparison.OrdinalIgnoreCase))) + if (_spotifySettings.Enabled && _spotifySettings.IsSpotifyPlaylist(playlistId)) { // Get playlist info from Jellyfin to get the name for matching missing tracks _logger.LogInformation("Fetching playlist info from Jellyfin for ID: {PlaylistId}", playlistId); @@ -2242,11 +2241,11 @@ public class JellyfinController : ControllerBase _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)); + _logger.LogInformation("Configured Playlists: {Playlists}", string.Join(", ", _spotifySettings.Playlists.Select(p => $"{p.Name}:{p.Id}"))); + _logger.LogInformation("Is configured: {IsConfigured}", _spotifySettings.IsSpotifyPlaylist(playlistId)); // Check if this playlist ID is configured for Spotify injection - if (_spotifySettings.PlaylistIds.Any(id => id.Equals(playlistId, StringComparison.OrdinalIgnoreCase))) + if (_spotifySettings.IsSpotifyPlaylist(playlistId)) { _logger.LogInformation("========================================"); _logger.LogInformation("=== INTERCEPTING SPOTIFY PLAYLIST ==="); @@ -2502,18 +2501,16 @@ public class JellyfinController : ControllerBase var playlistId = idProp.GetString(); _logger.LogDebug("Checking item with ID: {Id}", playlistId); - if (!string.IsNullOrEmpty(playlistId) && - _spotifySettings.PlaylistIds.Any(id => id.Equals(playlistId, StringComparison.OrdinalIgnoreCase))) + if (!string.IsNullOrEmpty(playlistId) && _spotifySettings.IsSpotifyPlaylist(playlistId)) { _logger.LogInformation("Found Spotify playlist: {Id}", playlistId); // This is a Spotify playlist - get the actual track count - var playlistIndex = _spotifySettings.PlaylistIds.FindIndex(id => - id.Equals(playlistId, StringComparison.OrdinalIgnoreCase)); + var playlistConfig = _spotifySettings.GetPlaylistById(playlistId); - if (playlistIndex >= 0 && playlistIndex < _spotifySettings.PlaylistNames.Count) + if (playlistConfig != null) { - var playlistName = _spotifySettings.PlaylistNames[playlistIndex]; + var playlistName = playlistConfig.Name; var missingTracksKey = $"spotify:missing:{playlistName}"; var missingTracks = await _cache.GetAsync>(missingTracksKey); @@ -2828,18 +2825,32 @@ public class JellyfinController : ControllerBase } } - // Build final track list: local tracks first, then matched external tracks - // Local tracks are already in Jellyfin's playlist - just return them as-is - // Append external matches at the end for tracks the plugin couldn't find locally - var finalTracks = new List(existingTracks); - finalTracks.AddRange(matchedBySpotifyId.Values); + // Build final track list based on playlist configuration + // Local tracks position is configurable per-playlist + var playlistConfig = _spotifySettings.GetPlaylistById(playlistId); + var localTracksPosition = playlistConfig?.LocalTracksPosition ?? LocalTracksPosition.First; + + var finalTracks = new List(); + if (localTracksPosition == LocalTracksPosition.First) + { + // Local tracks first, external tracks at the end + finalTracks.AddRange(existingTracks); + finalTracks.AddRange(matchedBySpotifyId.Values); + } + else + { + // External tracks first, local tracks at the end + finalTracks.AddRange(matchedBySpotifyId.Values); + finalTracks.AddRange(existingTracks); + } await _cache.SetAsync(cacheKey, finalTracks, TimeSpan.FromHours(1)); - _logger.LogInformation("Final playlist: {Total} tracks ({Existing} local + {Matched} external)", + _logger.LogInformation("Final playlist: {Total} tracks ({Existing} local + {Matched} external, LocalTracksPosition: {Position})", finalTracks.Count, existingTracks.Count, - matchedBySpotifyId.Count); + matchedBySpotifyId.Count, + localTracksPosition); return _responseBuilder.CreateItemsResponse(finalTracks); } @@ -2982,25 +2993,22 @@ public class JellyfinController : ControllerBase // Check what was cached var results = new Dictionary(); - for (int i = 0; i < _spotifySettings.PlaylistIds.Count; i++) + foreach (var playlist in _spotifySettings.Playlists) { - var playlistName = i < _spotifySettings.PlaylistNames.Count - ? _spotifySettings.PlaylistNames[i] - : _spotifySettings.PlaylistIds[i]; - - var cacheKey = $"spotify:missing:{playlistName}"; + var cacheKey = $"spotify:missing:{playlist.Name}"; var tracks = await _cache.GetAsync>(cacheKey); if (tracks != null && tracks.Count > 0) { - results[playlistName] = new { + results[playlist.Name] = new { status = "success", - tracks = tracks.Count + tracks = tracks.Count, + localTracksPosition = playlist.LocalTracksPosition.ToString() }; } else { - results[playlistName] = new { + results[playlist.Name] = new { status = "not_found", message = "No missing tracks found" }; @@ -3052,9 +3060,7 @@ public class JellyfinController : ControllerBase { status = "started", message = "Track matching started in background. Check logs for progress.", - playlists = _spotifySettings.PlaylistNames.Count > 0 - ? _spotifySettings.PlaylistNames - : _spotifySettings.PlaylistIds + playlists = _spotifySettings.Playlists.Select(p => new { p.Name, p.Id, localTracksPosition = p.LocalTracksPosition.ToString() }) }); } @@ -3113,12 +3119,12 @@ public class JellyfinController : ControllerBase var cleared = new List(); - foreach (var playlistName in _spotifySettings.PlaylistNames) + foreach (var playlist in _spotifySettings.Playlists) { - var matchedKey = $"spotify:matched:{playlistName}"; + var matchedKey = $"spotify:matched:{playlist.Name}"; await _cache.DeleteAsync(matchedKey); - cleared.Add(playlistName); - _logger.LogInformation("Cleared cache for {Playlist}", playlistName); + cleared.Add(playlist.Name); + _logger.LogInformation("Cleared cache for {Playlist}", playlist.Name); } return Ok(new { status = "success", cleared = cleared }); diff --git a/allstarr/Models/Settings/SpotifyImportSettings.cs b/allstarr/Models/Settings/SpotifyImportSettings.cs index a2feb0b..5cddd8f 100644 --- a/allstarr/Models/Settings/SpotifyImportSettings.cs +++ b/allstarr/Models/Settings/SpotifyImportSettings.cs @@ -1,5 +1,44 @@ namespace allstarr.Models.Settings; +/// +/// Where to position local tracks relative to external matched tracks in Spotify playlists. +/// +public enum LocalTracksPosition +{ + /// + /// Local tracks appear first, external tracks appended at the end (default) + /// + First, + + /// + /// External tracks appear first, local tracks appended at the end + /// + Last +} + +/// +/// Configuration for a single Spotify Import playlist. +/// +public class SpotifyPlaylistConfig +{ + /// + /// Playlist name as it appears in Jellyfin/Spotify Import plugin + /// Example: "Discover Weekly", "Release Radar" + /// + public string Name { get; set; } = string.Empty; + + /// + /// Jellyfin playlist ID (get from playlist URL) + /// Example: "4383a46d8bcac3be2ef9385053ea18df" + /// + public string Id { get; set; } = string.Empty; + + /// + /// Where to position local tracks: "first" or "last" + /// + public LocalTracksPosition LocalTracksPosition { get; set; } = LocalTracksPosition.First; +} + /// /// Configuration for Spotify playlist injection feature. /// Requires Jellyfin Spotify Import Plugin: https://github.com/Viperinius/jellyfin-plugin-spotify-import @@ -34,17 +73,41 @@ public class SpotifyImportSettings public int SyncWindowHours { get; set; } = 2; /// - /// Comma-separated list of Jellyfin playlist IDs to inject - /// Example: "4383a46d8bcac3be2ef9385053ea18df,ba50e26c867ec9d57ab2f7bf24cfd6b0" - /// Get IDs from Jellyfin playlist URLs + /// Combined playlist configuration as JSON array. + /// Format: [["Name","Id","first|last"],...] + /// Example: [["Discover Weekly","abc123","first"],["Release Radar","def456","last"]] /// + public List Playlists { get; set; } = new(); + + /// + /// Legacy: Comma-separated list of Jellyfin playlist IDs to inject + /// Deprecated: Use Playlists instead + /// + [Obsolete("Use Playlists instead")] 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 + /// Legacy: Comma-separated list of playlist names + /// Deprecated: Use Playlists instead /// + [Obsolete("Use Playlists instead")] public List PlaylistNames { get; set; } = new(); + + /// + /// Gets the playlist configuration by Jellyfin playlist ID. + /// + public SpotifyPlaylistConfig? GetPlaylistById(string playlistId) => + Playlists.FirstOrDefault(p => p.Id.Equals(playlistId, StringComparison.OrdinalIgnoreCase)); + + /// + /// Gets the playlist configuration by name. + /// + public SpotifyPlaylistConfig? GetPlaylistByName(string name) => + Playlists.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + + /// + /// Checks if a playlist ID is configured for Spotify import. + /// + public bool IsSpotifyPlaylist(string playlistId) => + Playlists.Any(p => p.Id.Equals(playlistId, StringComparison.OrdinalIgnoreCase)); } diff --git a/allstarr/Program.cs b/allstarr/Program.cs index 4174848..f2fe58a 100644 --- a/allstarr/Program.cs +++ b/allstarr/Program.cs @@ -117,35 +117,87 @@ 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) + // Parse SPOTIFY_IMPORT_PLAYLISTS env var (JSON array format) + // Format: [["Name","Id","first|last"],["Name2","Id2","first|last"]] + var playlistsEnv = builder.Configuration.GetValue("SpotifyImport:Playlists"); + if (!string.IsNullOrWhiteSpace(playlistsEnv) && options.Playlists.Count == 0) { - options.PlaylistIds = playlistIdsEnv - .Split(',', StringSplitOptions.RemoveEmptyEntries) - .Select(id => id.Trim()) - .Where(id => !string.IsNullOrEmpty(id)) - .ToList(); + try + { + // Parse as JSON array of arrays + var playlistArrays = System.Text.Json.JsonSerializer.Deserialize(playlistsEnv); + if (playlistArrays != null) + { + foreach (var arr in playlistArrays) + { + if (arr.Length >= 2) + { + var config = new SpotifyPlaylistConfig + { + Name = arr[0].Trim(), + Id = arr[1].Trim(), + LocalTracksPosition = arr.Length >= 3 && + arr[2].Trim().Equals("last", StringComparison.OrdinalIgnoreCase) + ? LocalTracksPosition.Last + : LocalTracksPosition.First + }; + options.Playlists.Add(config); + } + } + } + } + catch (System.Text.Json.JsonException ex) + { + Console.WriteLine($"Warning: Failed to parse SPOTIFY_IMPORT_PLAYLISTS: {ex.Message}"); + Console.WriteLine("Expected format: [[\"Name\",\"Id\",\"first|last\"],[\"Name2\",\"Id2\",\"first|last\"]]"); + } } - // 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) + // Legacy support: Parse old SPOTIFY_IMPORT_PLAYLIST_IDS/NAMES env vars + // Only used if new Playlists format is not configured + if (options.Playlists.Count == 0) { - options.PlaylistNames = playlistNamesEnv - .Split(',', StringSplitOptions.RemoveEmptyEntries) - .Select(name => name.Trim()) - .Where(name => !string.IsNullOrEmpty(name)) - .ToList(); + #pragma warning disable CS0618 // Type or member is obsolete + 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(); + } + + 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(); + } + + // Convert legacy format to new Playlists array + for (int i = 0; i < options.PlaylistIds.Count; i++) + { + var name = i < options.PlaylistNames.Count ? options.PlaylistNames[i] : options.PlaylistIds[i]; + options.Playlists.Add(new SpotifyPlaylistConfig + { + Name = name, + Id = options.PlaylistIds[i], + LocalTracksPosition = LocalTracksPosition.First // Default for legacy + }); + } + #pragma warning restore CS0618 } // 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"); - for (int i = 0; i < options.PlaylistIds.Count; i++) + Console.WriteLine($"Spotify Import Playlists: {options.Playlists.Count} configured"); + foreach (var playlist in options.Playlists) { - var name = i < options.PlaylistNames.Count ? options.PlaylistNames[i] : options.PlaylistIds[i]; - Console.WriteLine($" - {name} (ID: {options.PlaylistIds[i]})"); + Console.WriteLine($" - {playlist.Name} (ID: {playlist.Id}, LocalTracks: {playlist.LocalTracksPosition})"); } }); diff --git a/allstarr/Services/Spotify/SpotifyMissingTracksFetcher.cs b/allstarr/Services/Spotify/SpotifyMissingTracksFetcher.cs index abb023a..67cc220 100644 --- a/allstarr/Services/Spotify/SpotifyMissingTracksFetcher.cs +++ b/allstarr/Services/Spotify/SpotifyMissingTracksFetcher.cs @@ -70,7 +70,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService } _logger.LogInformation("Spotify Import ENABLED"); - _logger.LogInformation("Configured Playlist IDs: {Count}", _spotifySettings.Value.PlaylistIds.Count); + _logger.LogInformation("Configured Playlists: {Count}", _spotifySettings.Value.Playlists.Count); // Log the search schedule var settings = _spotifySettings.Value; @@ -186,15 +186,10 @@ public class SpotifyMissingTracksFetcher : BackgroundService { _playlistIdToName.Clear(); - // Use configured playlist names instead of fetching from API - for (int i = 0; i < _spotifySettings.Value.PlaylistIds.Count; i++) + // Use configured playlists + foreach (var playlist in _spotifySettings.Value.Playlists) { - 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; + _playlistIdToName[playlist.Id] = playlist.Name; } } diff --git a/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs b/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs index 4a921da..d122867 100644 --- a/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs +++ b/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs @@ -85,8 +85,8 @@ public class SpotifyTrackMatchingService : BackgroundService { _logger.LogInformation("=== STARTING TRACK MATCHING ==="); - var playlistNames = _spotifySettings.Value.PlaylistNames; - if (playlistNames.Count == 0) + var playlists = _spotifySettings.Value.Playlists; + if (playlists.Count == 0) { _logger.LogInformation("No playlists configured for matching"); return; @@ -95,17 +95,17 @@ public class SpotifyTrackMatchingService : BackgroundService using var scope = _serviceProvider.CreateScope(); var metadataService = scope.ServiceProvider.GetRequiredService(); - foreach (var playlistName in playlistNames) + foreach (var playlist in playlists) { if (cancellationToken.IsCancellationRequested) break; try { - await MatchPlaylistTracksAsync(playlistName, metadataService, cancellationToken); + await MatchPlaylistTracksAsync(playlist.Name, metadataService, cancellationToken); } catch (Exception ex) { - _logger.LogError(ex, "Error matching tracks for playlist {Playlist}", playlistName); + _logger.LogError(ex, "Error matching tracks for playlist {Playlist}", playlist.Name); } } diff --git a/docker-compose.yml b/docker-compose.yml index fc38039..3812688 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -81,8 +81,7 @@ services: - SpotifyImport__SyncStartHour=${SPOTIFY_IMPORT_SYNC_START_HOUR:-16} - SpotifyImport__SyncStartMinute=${SPOTIFY_IMPORT_SYNC_START_MINUTE:-15} - SpotifyImport__SyncWindowHours=${SPOTIFY_IMPORT_SYNC_WINDOW_HOURS:-2} - - SpotifyImport__PlaylistIds=${SPOTIFY_IMPORT_PLAYLIST_IDS:-} - - SpotifyImport__PlaylistNames=${SPOTIFY_IMPORT_PLAYLIST_NAMES:-} + - SpotifyImport__Playlists=${SPOTIFY_IMPORT_PLAYLISTS:-} # ===== SHARED ===== - Library__DownloadPath=/app/downloads