Compare commits

..

9 Commits

Author SHA1 Message Date
bbb0d9bb73 track count fix
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-02-03 01:49:58 -05:00
0356f3c54d logic fix 2 2026-02-03 01:46:38 -05:00
0c25d16e42 logic fix 2026-02-03 01:44:02 -05:00
4c3709113f Add legacy Spotify playlist format support with position control 2026-02-03 01:39:33 -05:00
12db8370a3 format 2026-02-03 01:29:27 -05:00
64be6eddf4 .env format 2026-02-03 01:24:08 -05:00
0980547848 Combine Spotify playlist config into single JSON array with local track position option 2026-02-03 00:23:57 -05:00
2bb1ffa581 Fix Spotify playlist to return local tracks + external tracks
Local tracks in Jellyfin playlist are now returned first, with
matched external tracks (from squid.wtf) appended at the end.

Previously the code tried to match local tracks by exact title/artist
which often failed due to naming differences.
2026-02-03 00:06:08 -05:00
51702a544b fix spotify search: start 24h ahead, search backwards for 72h 2026-02-02 23:49:39 -05:00
9 changed files with 416 additions and 136 deletions

View File

@@ -126,13 +126,26 @@ 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 # 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 SPOTIFY_IMPORT_SYNC_WINDOW_HOURS=2
# Playlist IDs to inject (comma-separated) # Playlists configuration (SIMPLE FORMAT - recommended for .env files)
# Get IDs from Jellyfin playlist URLs: https://jellyfin.example.com/web/#/details?id=PLAYLIST_ID # Comma-separated lists - all three must have the same number of items
# Example: SPOTIFY_IMPORT_PLAYLIST_IDS=4383a46d8bcac3be2ef9385053ea18df,ba50e26c867ec9d57ab2f7bf24cfd6b0 #
# 1. Playlist IDs (get from Jellyfin playlist URL: https://jellyfin.example.com/web/#/details?id=PLAYLIST_ID)
SPOTIFY_IMPORT_PLAYLIST_IDS= SPOTIFY_IMPORT_PLAYLIST_IDS=
#
# Playlist names (comma-separated, must match Spotify Import plugin format) # 2. Playlist names (as they appear in Jellyfin)
# 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= SPOTIFY_IMPORT_PLAYLIST_NAMES=
#
# 3. Local track positions (optional - defaults to "first" if not specified)
# - "first": Local tracks appear first, external tracks at the end
# - "last": External tracks appear first, local tracks at the end
SPOTIFY_IMPORT_PLAYLIST_LOCAL_TRACKS_POSITIONS=
#
# Example with 4 playlists:
# SPOTIFY_IMPORT_PLAYLIST_IDS=4383a46d8bcac3be2ef9385053ea18df,ba50e26c867ec9d57ab2f7bf24cfd6b0,8203ce3be9b0053b122190eb23bac7ea,7c2b218bd69b00e24c986363ba71852f
# SPOTIFY_IMPORT_PLAYLIST_NAMES=Discover Weekly,Release Radar,Today's Top Hits,On Repeat
# SPOTIFY_IMPORT_PLAYLIST_LOCAL_TRACKS_POSITIONS=first,first,last,first
#
# Advanced: JSON array format (use only if you can't use the simple format above)
# Format: [["PlaylistName","JellyfinPlaylistId","first|last"],...]
# Note: This format may not work in .env files due to Docker Compose limitations
# SPOTIFY_IMPORT_PLAYLISTS=[["Discover Weekly","4383a46d8bcac3be2ef9385053ea18df","first"],["Release Radar","ba50e26c867ec9d57ab2f7bf24cfd6b0","last"]]

View File

@@ -1366,11 +1366,10 @@ public class JellyfinController : ControllerBase
} }
// Check if this is a Spotify playlist (by ID) // Check if this is a Spotify playlist (by ID)
_logger.LogInformation("Spotify Import Enabled: {Enabled}, Configured IDs: {Count}", _logger.LogInformation("Spotify Import Enabled: {Enabled}, Configured Playlists: {Count}",
_spotifySettings.Enabled, _spotifySettings.PlaylistIds.Count); _spotifySettings.Enabled, _spotifySettings.Playlists.Count);
if (_spotifySettings.Enabled && if (_spotifySettings.Enabled && _spotifySettings.IsSpotifyPlaylist(playlistId))
_spotifySettings.PlaylistIds.Any(id => id.Equals(playlistId, StringComparison.OrdinalIgnoreCase)))
{ {
// Get playlist info from Jellyfin to get the name for matching missing tracks // Get playlist info from Jellyfin to get the name for matching missing tracks
_logger.LogInformation("Fetching playlist info from Jellyfin for ID: {PlaylistId}", playlistId); _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 REQUEST ===");
_logger.LogInformation("Playlist ID: {PlaylistId}", playlistId); _logger.LogInformation("Playlist ID: {PlaylistId}", playlistId);
_logger.LogInformation("Spotify Enabled: {Enabled}", _spotifySettings.Enabled); _logger.LogInformation("Spotify Enabled: {Enabled}", _spotifySettings.Enabled);
_logger.LogInformation("Configured IDs: {Ids}", string.Join(", ", _spotifySettings.PlaylistIds)); _logger.LogInformation("Configured Playlists: {Playlists}", string.Join(", ", _spotifySettings.Playlists.Select(p => $"{p.Name}:{p.Id}")));
_logger.LogInformation("Is configured: {IsConfigured}", _spotifySettings.PlaylistIds.Contains(playlistId, StringComparer.OrdinalIgnoreCase)); _logger.LogInformation("Is configured: {IsConfigured}", _spotifySettings.IsSpotifyPlaylist(playlistId));
// Check if this playlist ID is configured for Spotify injection // 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("========================================");
_logger.LogInformation("=== INTERCEPTING SPOTIFY PLAYLIST ==="); _logger.LogInformation("=== INTERCEPTING SPOTIFY PLAYLIST ===");
@@ -2502,18 +2501,16 @@ public class JellyfinController : ControllerBase
var playlistId = idProp.GetString(); var playlistId = idProp.GetString();
_logger.LogDebug("Checking item with ID: {Id}", playlistId); _logger.LogDebug("Checking item with ID: {Id}", playlistId);
if (!string.IsNullOrEmpty(playlistId) && if (!string.IsNullOrEmpty(playlistId) && _spotifySettings.IsSpotifyPlaylist(playlistId))
_spotifySettings.PlaylistIds.Any(id => id.Equals(playlistId, StringComparison.OrdinalIgnoreCase)))
{ {
_logger.LogInformation("Found Spotify playlist: {Id}", playlistId); _logger.LogInformation("Found Spotify playlist: {Id}", playlistId);
// This is a Spotify playlist - get the actual track count // This is a Spotify playlist - get the actual track count
var playlistIndex = _spotifySettings.PlaylistIds.FindIndex(id => var playlistConfig = _spotifySettings.GetPlaylistById(playlistId);
id.Equals(playlistId, StringComparison.OrdinalIgnoreCase));
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 missingTracksKey = $"spotify:missing:{playlistName}";
var missingTracks = await _cache.GetAsync<List<allstarr.Models.Spotify.MissingTrack>>(missingTracksKey); var missingTracks = await _cache.GetAsync<List<allstarr.Models.Spotify.MissingTrack>>(missingTracksKey);
@@ -2528,17 +2525,48 @@ public class JellyfinController : ControllerBase
_logger.LogInformation("File cache result: {Count} tracks", missingTracks?.Count ?? 0); _logger.LogInformation("File cache result: {Count} tracks", missingTracks?.Count ?? 0);
} }
// Get local tracks count from Jellyfin
var localTracksCount = 0;
try
{
var (localTracksResponse, _) = await _proxyService.GetJsonAsync(
$"Playlists/{playlistId}/Items",
null,
Request.Headers);
if (localTracksResponse != null &&
localTracksResponse.RootElement.TryGetProperty("Items", out var localItems))
{
localTracksCount = localItems.GetArrayLength();
_logger.LogInformation("Found {Count} local tracks in Jellyfin playlist {Name}",
localTracksCount, playlistName);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get local tracks count for {Name}", playlistName);
}
if (missingTracks != null && missingTracks.Count > 0) if (missingTracks != null && missingTracks.Count > 0)
{ {
// Update ChildCount to show the number of tracks we'll provide // Update ChildCount to show total tracks (local + external)
itemDict["ChildCount"] = missingTracks.Count; var totalCount = localTracksCount + missingTracks.Count;
itemDict["ChildCount"] = totalCount;
modified = true; modified = true;
_logger.LogInformation("✓ Updated ChildCount for Spotify playlist {Name} to {Count}", _logger.LogInformation("✓ Updated ChildCount for Spotify playlist {Name} to {Total} ({Local} local + {External} external)",
playlistName, missingTracks.Count); playlistName, totalCount, localTracksCount, missingTracks.Count);
}
else if (localTracksCount > 0)
{
// No external tracks, but we have local tracks
itemDict["ChildCount"] = localTracksCount;
modified = true;
_logger.LogInformation("✓ Updated ChildCount for Spotify playlist {Name} to {Count} (local only, no external tracks)",
playlistName, localTracksCount);
} }
else else
{ {
_logger.LogWarning("No missing tracks found for {Name}", playlistName); _logger.LogWarning("No tracks found for {Name} (neither local nor external)", playlistName);
} }
} }
} }
@@ -2828,33 +2856,32 @@ public class JellyfinController : ControllerBase
} }
} }
// Build final track list in Spotify playlist order // Build final track list based on playlist configuration
var finalTracks = new List<Song>(); // Local tracks position is configurable per-playlist
foreach (var missingTrack in missingTracks) var playlistConfig = _spotifySettings.GetPlaylistById(playlistId);
{ var localTracksPosition = playlistConfig?.LocalTracksPosition ?? LocalTracksPosition.First;
// 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 finalTracks = new List<Song>();
{ if (localTracksPosition == LocalTracksPosition.First)
finalTracks.Add(existingTrack); {
} // Local tracks first, external tracks at the end
else if (matchedBySpotifyId.TryGetValue(missingTrack.SpotifyId, out var matchedTrack)) finalTracks.AddRange(existingTracks);
{ finalTracks.AddRange(matchedBySpotifyId.Values);
finalTracks.Add(matchedTrack); }
} else
// Skip tracks we couldn't match {
// External tracks first, local tracks at the end
finalTracks.AddRange(matchedBySpotifyId.Values);
finalTracks.AddRange(existingTracks);
} }
await _cache.SetAsync(cacheKey, finalTracks, TimeSpan.FromHours(1)); await _cache.SetAsync(cacheKey, finalTracks, TimeSpan.FromHours(1));
_logger.LogInformation("Final playlist: {Total} tracks ({Existing} local, {Matched} matched, {Missing} missing)", _logger.LogInformation("Final playlist: {Total} tracks ({Existing} local + {Matched} external, LocalTracksPosition: {Position})",
finalTracks.Count, finalTracks.Count,
existingTracks.Count, existingTracks.Count,
matchedBySpotifyId.Count, matchedBySpotifyId.Count,
missingTracks.Count - existingTracks.Count - matchedBySpotifyId.Count); localTracksPosition);
return _responseBuilder.CreateItemsResponse(finalTracks); return _responseBuilder.CreateItemsResponse(finalTracks);
} }
@@ -2997,25 +3024,22 @@ public class JellyfinController : ControllerBase
// Check what was cached // Check what was cached
var results = new Dictionary<string, object>(); var results = new Dictionary<string, object>();
for (int i = 0; i < _spotifySettings.PlaylistIds.Count; i++) foreach (var playlist in _spotifySettings.Playlists)
{ {
var playlistName = i < _spotifySettings.PlaylistNames.Count var cacheKey = $"spotify:missing:{playlist.Name}";
? _spotifySettings.PlaylistNames[i]
: _spotifySettings.PlaylistIds[i];
var cacheKey = $"spotify:missing:{playlistName}";
var tracks = await _cache.GetAsync<List<allstarr.Models.Spotify.MissingTrack>>(cacheKey); var tracks = await _cache.GetAsync<List<allstarr.Models.Spotify.MissingTrack>>(cacheKey);
if (tracks != null && tracks.Count > 0) if (tracks != null && tracks.Count > 0)
{ {
results[playlistName] = new { results[playlist.Name] = new {
status = "success", status = "success",
tracks = tracks.Count tracks = tracks.Count,
localTracksPosition = playlist.LocalTracksPosition.ToString()
}; };
} }
else else
{ {
results[playlistName] = new { results[playlist.Name] = new {
status = "not_found", status = "not_found",
message = "No missing tracks found" message = "No missing tracks found"
}; };
@@ -3067,9 +3091,7 @@ public class JellyfinController : ControllerBase
{ {
status = "started", status = "started",
message = "Track matching started in background. Check logs for progress.", message = "Track matching started in background. Check logs for progress.",
playlists = _spotifySettings.PlaylistNames.Count > 0 playlists = _spotifySettings.Playlists.Select(p => new { p.Name, p.Id, localTracksPosition = p.LocalTracksPosition.ToString() })
? _spotifySettings.PlaylistNames
: _spotifySettings.PlaylistIds
}); });
} }
@@ -3128,12 +3150,12 @@ public class JellyfinController : ControllerBase
var cleared = new List<string>(); var cleared = new List<string>();
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); await _cache.DeleteAsync(matchedKey);
cleared.Add(playlistName); cleared.Add(playlist.Name);
_logger.LogInformation("Cleared cache for {Playlist}", playlistName); _logger.LogInformation("Cleared cache for {Playlist}", playlist.Name);
} }
return Ok(new { status = "success", cleared = cleared }); return Ok(new { status = "success", cleared = cleared });

View File

@@ -1,5 +1,44 @@
namespace allstarr.Models.Settings; namespace allstarr.Models.Settings;
/// <summary>
/// Where to position local tracks relative to external matched tracks in Spotify playlists.
/// </summary>
public enum LocalTracksPosition
{
/// <summary>
/// Local tracks appear first, external tracks appended at the end (default)
/// </summary>
First,
/// <summary>
/// External tracks appear first, local tracks appended at the end
/// </summary>
Last
}
/// <summary>
/// Configuration for a single Spotify Import playlist.
/// </summary>
public class SpotifyPlaylistConfig
{
/// <summary>
/// Playlist name as it appears in Jellyfin/Spotify Import plugin
/// Example: "Discover Weekly", "Release Radar"
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Jellyfin playlist ID (get from playlist URL)
/// Example: "4383a46d8bcac3be2ef9385053ea18df"
/// </summary>
public string Id { get; set; } = string.Empty;
/// <summary>
/// Where to position local tracks: "first" or "last"
/// </summary>
public LocalTracksPosition LocalTracksPosition { get; set; } = LocalTracksPosition.First;
}
/// <summary> /// <summary>
/// Configuration for Spotify playlist injection feature. /// Configuration for Spotify playlist injection feature.
/// Requires Jellyfin Spotify Import Plugin: https://github.com/Viperinius/jellyfin-plugin-spotify-import /// Requires Jellyfin Spotify Import Plugin: https://github.com/Viperinius/jellyfin-plugin-spotify-import
@@ -34,17 +73,49 @@ public class SpotifyImportSettings
public int SyncWindowHours { get; set; } = 2; public int SyncWindowHours { get; set; } = 2;
/// <summary> /// <summary>
/// Comma-separated list of Jellyfin playlist IDs to inject /// Combined playlist configuration as JSON array.
/// Example: "4383a46d8bcac3be2ef9385053ea18df,ba50e26c867ec9d57ab2f7bf24cfd6b0" /// Format: [["Name","Id","first|last"],...]
/// Get IDs from Jellyfin playlist URLs /// Example: [["Discover Weekly","abc123","first"],["Release Radar","def456","last"]]
/// </summary> /// </summary>
public List<SpotifyPlaylistConfig> Playlists { get; set; } = new();
/// <summary>
/// Legacy: Comma-separated list of Jellyfin playlist IDs to inject
/// Deprecated: Use Playlists instead
/// </summary>
[Obsolete("Use Playlists instead")]
public List<string> PlaylistIds { get; set; } = new(); public List<string> PlaylistIds { get; set; } = new();
/// <summary> /// <summary>
/// Comma-separated list of playlist names (must match Spotify Import plugin format) /// Legacy: Comma-separated list of playlist names
/// Example: "Discover_Weekly,Release_Radar" /// Deprecated: Use Playlists instead
/// Must be in same order as PlaylistIds
/// Plugin replaces spaces with underscores in filenames
/// </summary> /// </summary>
[Obsolete("Use Playlists instead")]
public List<string> PlaylistNames { get; set; } = new(); public List<string> PlaylistNames { get; set; } = new();
/// <summary>
/// Legacy: Comma-separated list of local track positions ("first" or "last")
/// Deprecated: Use Playlists instead
/// Example: "first,last,first,first" (one per playlist)
/// </summary>
[Obsolete("Use Playlists instead")]
public List<string> PlaylistLocalTracksPositions { get; set; } = new();
/// <summary>
/// Gets the playlist configuration by Jellyfin playlist ID.
/// </summary>
public SpotifyPlaylistConfig? GetPlaylistById(string playlistId) =>
Playlists.FirstOrDefault(p => p.Id.Equals(playlistId, StringComparison.OrdinalIgnoreCase));
/// <summary>
/// Gets the playlist configuration by name.
/// </summary>
public SpotifyPlaylistConfig? GetPlaylistByName(string name) =>
Playlists.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
/// <summary>
/// Checks if a playlist ID is configured for Spotify import.
/// </summary>
public bool IsSpotifyPlaylist(string playlistId) =>
Playlists.Any(p => p.Id.Equals(playlistId, StringComparison.OrdinalIgnoreCase));
} }

View File

@@ -117,35 +117,227 @@ builder.Services.Configure<SpotifyImportSettings>(options =>
{ {
builder.Configuration.GetSection("SpotifyImport").Bind(options); builder.Configuration.GetSection("SpotifyImport").Bind(options);
// Parse SPOTIFY_IMPORT_PLAYLIST_IDS env var (comma-separated) into PlaylistIds array // Debug: Check what Bind() populated
var playlistIdsEnv = builder.Configuration.GetValue<string>("SpotifyImport:PlaylistIds"); Console.WriteLine($"DEBUG: After Bind(), Playlists.Count = {options.Playlists.Count}");
if (!string.IsNullOrWhiteSpace(playlistIdsEnv) && options.PlaylistIds.Count == 0) Console.WriteLine($"DEBUG: After Bind(), PlaylistIds.Count = {options.PlaylistIds.Count}");
Console.WriteLine($"DEBUG: After Bind(), PlaylistNames.Count = {options.PlaylistNames.Count}");
// Parse SPOTIFY_IMPORT_PLAYLISTS env var (JSON array format)
// Format: [["Name","Id","first|last"],["Name2","Id2","first|last"]]
var playlistsEnv = builder.Configuration.GetValue<string>("SpotifyImport:Playlists");
if (!string.IsNullOrWhiteSpace(playlistsEnv))
{ {
options.PlaylistIds = playlistIdsEnv Console.WriteLine($"Found SPOTIFY_IMPORT_PLAYLISTS env var: {playlistsEnv.Length} chars");
.Split(',', StringSplitOptions.RemoveEmptyEntries) try
.Select(id => id.Trim()) {
.Where(id => !string.IsNullOrEmpty(id)) // Parse as JSON array of arrays
.ToList(); var playlistArrays = System.Text.Json.JsonSerializer.Deserialize<string[][]>(playlistsEnv);
if (playlistArrays != null && playlistArrays.Length > 0)
{
Console.WriteLine($"Parsed {playlistArrays.Length} playlists from JSON format");
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);
Console.WriteLine($" Added: {config.Name} (ID: {config.Id}, Position: {config.LocalTracksPosition})");
}
}
}
else
{
Console.WriteLine("JSON format was empty or invalid, will try legacy format");
}
}
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\"]]");
Console.WriteLine("Will try legacy format instead");
}
}
else
{
Console.WriteLine("No SPOTIFY_IMPORT_PLAYLISTS env var found, will try legacy format");
} }
// Parse SPOTIFY_IMPORT_PLAYLIST_NAMES env var (comma-separated) into PlaylistNames array // Legacy support: Parse old SPOTIFY_IMPORT_PLAYLIST_IDS/NAMES env vars
// Only used if new Playlists format is not configured
// Check if we have legacy env vars to parse
var playlistIdsEnv = builder.Configuration.GetValue<string>("SpotifyImport:PlaylistIds");
var playlistNamesEnv = builder.Configuration.GetValue<string>("SpotifyImport:PlaylistNames"); var playlistNamesEnv = builder.Configuration.GetValue<string>("SpotifyImport:PlaylistNames");
if (!string.IsNullOrWhiteSpace(playlistNamesEnv) && options.PlaylistNames.Count == 0) var hasLegacyConfig = !string.IsNullOrWhiteSpace(playlistIdsEnv) || !string.IsNullOrWhiteSpace(playlistNamesEnv);
if (hasLegacyConfig && options.Playlists.Count == 0)
{ {
options.PlaylistNames = playlistNamesEnv Console.WriteLine("Parsing legacy Spotify playlist format...");
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(name => name.Trim()) #pragma warning disable CS0618 // Type or member is obsolete
.Where(name => !string.IsNullOrEmpty(name))
.ToList(); // Clear any auto-bound values from the Bind() call above
// The auto-binder doesn't handle comma-separated strings correctly
options.PlaylistIds.Clear();
options.PlaylistNames.Clear();
options.PlaylistLocalTracksPositions.Clear();
if (!string.IsNullOrWhiteSpace(playlistIdsEnv))
{
options.PlaylistIds = playlistIdsEnv
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(id => id.Trim())
.Where(id => !string.IsNullOrEmpty(id))
.ToList();
Console.WriteLine($" Parsed {options.PlaylistIds.Count} playlist IDs from env var");
}
if (!string.IsNullOrWhiteSpace(playlistNamesEnv))
{
options.PlaylistNames = playlistNamesEnv
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(name => name.Trim())
.Where(name => !string.IsNullOrEmpty(name))
.ToList();
Console.WriteLine($" Parsed {options.PlaylistNames.Count} playlist names from env var");
}
var playlistPositionsEnv = builder.Configuration.GetValue<string>("SpotifyImport:PlaylistLocalTracksPositions");
if (!string.IsNullOrWhiteSpace(playlistPositionsEnv))
{
options.PlaylistLocalTracksPositions = playlistPositionsEnv
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(pos => pos.Trim())
.Where(pos => !string.IsNullOrEmpty(pos))
.ToList();
Console.WriteLine($" Parsed {options.PlaylistLocalTracksPositions.Count} playlist positions from env var");
}
else
{
Console.WriteLine(" No playlist positions env var found, will use defaults");
}
// Convert legacy format to new Playlists array
Console.WriteLine($" Converting {options.PlaylistIds.Count} playlists to new format...");
for (int i = 0; i < options.PlaylistIds.Count; i++)
{
var name = i < options.PlaylistNames.Count ? options.PlaylistNames[i] : options.PlaylistIds[i];
var position = LocalTracksPosition.First; // Default
// Parse position if provided
if (i < options.PlaylistLocalTracksPositions.Count)
{
var posStr = options.PlaylistLocalTracksPositions[i];
if (posStr.Equals("last", StringComparison.OrdinalIgnoreCase))
{
position = LocalTracksPosition.Last;
}
}
options.Playlists.Add(new SpotifyPlaylistConfig
{
Name = name,
Id = options.PlaylistIds[i],
LocalTracksPosition = position
});
Console.WriteLine($" [{i}] {name} (ID: {options.PlaylistIds[i]}, Position: {position})");
}
#pragma warning restore CS0618
}
else if (hasLegacyConfig && options.Playlists.Count > 0)
{
// Bind() incorrectly populated Playlists from legacy env vars
// Clear it and re-parse properly
Console.WriteLine($"DEBUG: Bind() incorrectly populated {options.Playlists.Count} playlists, clearing and re-parsing...");
options.Playlists.Clear();
options.PlaylistIds.Clear();
options.PlaylistNames.Clear();
options.PlaylistLocalTracksPositions.Clear();
Console.WriteLine("Parsing legacy Spotify playlist format...");
#pragma warning disable CS0618 // Type or member is obsolete
if (!string.IsNullOrWhiteSpace(playlistIdsEnv))
{
options.PlaylistIds = playlistIdsEnv
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(id => id.Trim())
.Where(id => !string.IsNullOrEmpty(id))
.ToList();
Console.WriteLine($" Parsed {options.PlaylistIds.Count} playlist IDs from env var");
}
if (!string.IsNullOrWhiteSpace(playlistNamesEnv))
{
options.PlaylistNames = playlistNamesEnv
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(name => name.Trim())
.Where(name => !string.IsNullOrEmpty(name))
.ToList();
Console.WriteLine($" Parsed {options.PlaylistNames.Count} playlist names from env var");
}
var playlistPositionsEnv = builder.Configuration.GetValue<string>("SpotifyImport:PlaylistLocalTracksPositions");
if (!string.IsNullOrWhiteSpace(playlistPositionsEnv))
{
options.PlaylistLocalTracksPositions = playlistPositionsEnv
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(pos => pos.Trim())
.Where(pos => !string.IsNullOrEmpty(pos))
.ToList();
Console.WriteLine($" Parsed {options.PlaylistLocalTracksPositions.Count} playlist positions from env var");
}
else
{
Console.WriteLine(" No playlist positions env var found, will use defaults");
}
// Convert legacy format to new Playlists array
Console.WriteLine($" Converting {options.PlaylistIds.Count} playlists to new format...");
for (int i = 0; i < options.PlaylistIds.Count; i++)
{
var name = i < options.PlaylistNames.Count ? options.PlaylistNames[i] : options.PlaylistIds[i];
var position = LocalTracksPosition.First; // Default
// Parse position if provided
if (i < options.PlaylistLocalTracksPositions.Count)
{
var posStr = options.PlaylistLocalTracksPositions[i];
if (posStr.Equals("last", StringComparison.OrdinalIgnoreCase))
{
position = LocalTracksPosition.Last;
}
}
options.Playlists.Add(new SpotifyPlaylistConfig
{
Name = name,
Id = options.PlaylistIds[i],
LocalTracksPosition = position
});
Console.WriteLine($" [{i}] {name} (ID: {options.PlaylistIds[i]}, Position: {position})");
}
#pragma warning restore CS0618
}
else
{
Console.WriteLine($"Using new Playlists format: {options.Playlists.Count} playlists configured");
} }
// Log configuration at startup // Log configuration at startup
Console.WriteLine($"Spotify Import: Enabled={options.Enabled}, SyncHour={options.SyncStartHour}:{options.SyncStartMinute:D2}, WindowHours={options.SyncWindowHours}"); 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"); Console.WriteLine($"Spotify Import Playlists: {options.Playlists.Count} configured");
for (int i = 0; i < options.PlaylistIds.Count; i++) foreach (var playlist in options.Playlists)
{ {
var name = i < options.PlaylistNames.Count ? options.PlaylistNames[i] : options.PlaylistIds[i]; Console.WriteLine($" - {playlist.Name} (ID: {playlist.Id}, LocalTracks: {playlist.LocalTracksPosition})");
Console.WriteLine($" - {name} (ID: {options.PlaylistIds[i]})");
} }
}); });

View File

@@ -70,7 +70,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
} }
_logger.LogInformation("Spotify Import ENABLED"); _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 // Log the search schedule
var settings = _spotifySettings.Value; var settings = _spotifySettings.Value;
@@ -186,15 +186,10 @@ public class SpotifyMissingTracksFetcher : BackgroundService
{ {
_playlistIdToName.Clear(); _playlistIdToName.Clear();
// Use configured playlist names instead of fetching from API // Use configured playlists
for (int i = 0; i < _spotifySettings.Value.PlaylistIds.Count; i++) foreach (var playlist in _spotifySettings.Value.Playlists)
{ {
var playlistId = _spotifySettings.Value.PlaylistIds[i]; _playlistIdToName[playlist.Id] = playlist.Name;
var playlistName = i < _spotifySettings.Value.PlaylistNames.Count
? _spotifySettings.Value.PlaylistNames[i]
: playlistId; // Fallback to ID if name not configured
_playlistIdToName[playlistId] = playlistName;
} }
} }
@@ -462,11 +457,15 @@ public class SpotifyMissingTracksFetcher : BackgroundService
var httpClient = _httpClientFactory.CreateClient(); var httpClient = _httpClientFactory.CreateClient();
// Search forward first (newest files), then backwards to handle timezone differences // Search starting from 24 hours ahead, going backwards for 72 hours
// We want the file with the furthest forward timestamp (most recent) // This handles timezone differences where the plugin may have run "in the future" from our perspective
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var searchStart = now.AddHours(24); // Start 24 hours from now
var totalMinutesToSearch = 72 * 60; // 72 hours = 4320 minutes
_logger.LogInformation(" Current UTC time: {Now:yyyy-MM-dd HH:mm}", now); _logger.LogInformation(" Current UTC time: {Now:yyyy-MM-dd HH:mm}", now);
_logger.LogInformation(" Searching +24h forward, then -48h backward"); _logger.LogInformation(" Search start: {Start:yyyy-MM-dd HH:mm} (24h ahead)", searchStart);
_logger.LogInformation(" Searching backwards for 72 hours ({Minutes} minutes)", totalMinutesToSearch);
var found = false; var found = false;
DateTime? foundFileTime = null; DateTime? foundFileTime = null;
@@ -511,14 +510,15 @@ public class SpotifyMissingTracksFetcher : BackgroundService
_logger.LogInformation(" Not found within ±1h of hint, doing full search..."); _logger.LogInformation(" Not found within ±1h of hint, doing full search...");
} }
// First search forward 24 hours (most likely to find newest files with timezone ahead) // Search from 24h ahead, going backwards minute by minute for 72 hours
_logger.LogInformation(" Phase 1: Searching forward 24 hours..."); _logger.LogInformation(" Searching from {Start:yyyy-MM-dd HH:mm} backwards to {End:yyyy-MM-dd HH:mm}...",
searchStart, searchStart.AddMinutes(-totalMinutesToSearch));
for (var minutesAhead = 1; minutesAhead <= 1440; minutesAhead++) for (var minutesBehind = 0; minutesBehind <= totalMinutesToSearch; minutesBehind++)
{ {
if (cancellationToken.IsCancellationRequested) break; if (cancellationToken.IsCancellationRequested) break;
var time = now.AddMinutes(minutesAhead); var time = searchStart.AddMinutes(-minutesBehind);
var result = await TryFetchMissingTracksFile(playlistName, time, jellyfinUrl, apiKey, httpClient, cancellationToken); var result = await TryFetchMissingTracksFile(playlistName, time, jellyfinUrl, apiKey, httpClient, cancellationToken);
if (result.found) if (result.found)
@@ -529,39 +529,12 @@ public class SpotifyMissingTracksFetcher : BackgroundService
} }
// Small delay every 60 requests to avoid rate limiting // Small delay every 60 requests to avoid rate limiting
if (minutesAhead % 60 == 0) if (minutesBehind > 0 && minutesBehind % 60 == 0)
{ {
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
} }
} }
// If not found forward, search backwards 48 hours
if (!found)
{
_logger.LogInformation(" Phase 2: Searching backward 48 hours...");
for (var minutesBehind = 0; minutesBehind <= 2880; minutesBehind++)
{
if (cancellationToken.IsCancellationRequested) break;
var time = now.AddMinutes(-minutesBehind);
var result = await TryFetchMissingTracksFile(playlistName, time, jellyfinUrl, apiKey, httpClient, cancellationToken);
if (result.found)
{
found = true;
foundFileTime = result.fileTime;
return foundFileTime;
}
// Small delay every 60 requests to avoid rate limiting
if (minutesBehind > 0 && minutesBehind % 60 == 0)
{
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
}
}
}
if (!found) if (!found)
{ {
_logger.LogWarning(" ✗ Could not find new missing tracks file (searched +24h forward, -48h backward)"); _logger.LogWarning(" ✗ Could not find new missing tracks file (searched +24h forward, -48h backward)");

View File

@@ -85,8 +85,8 @@ public class SpotifyTrackMatchingService : BackgroundService
{ {
_logger.LogInformation("=== STARTING TRACK MATCHING ==="); _logger.LogInformation("=== STARTING TRACK MATCHING ===");
var playlistNames = _spotifySettings.Value.PlaylistNames; var playlists = _spotifySettings.Value.Playlists;
if (playlistNames.Count == 0) if (playlists.Count == 0)
{ {
_logger.LogInformation("No playlists configured for matching"); _logger.LogInformation("No playlists configured for matching");
return; return;
@@ -95,17 +95,17 @@ public class SpotifyTrackMatchingService : BackgroundService
using var scope = _serviceProvider.CreateScope(); using var scope = _serviceProvider.CreateScope();
var metadataService = scope.ServiceProvider.GetRequiredService<IMusicMetadataService>(); var metadataService = scope.ServiceProvider.GetRequiredService<IMusicMetadataService>();
foreach (var playlistName in playlistNames) foreach (var playlist in playlists)
{ {
if (cancellationToken.IsCancellationRequested) break; if (cancellationToken.IsCancellationRequested) break;
try try
{ {
await MatchPlaylistTracksAsync(playlistName, metadataService, cancellationToken); await MatchPlaylistTracksAsync(playlist.Name, metadataService, cancellationToken);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error matching tracks for playlist {Playlist}", playlistName); _logger.LogError(ex, "Error matching tracks for playlist {Playlist}", playlist.Name);
} }
} }

View File

@@ -22,9 +22,13 @@ public class StartupValidationOrchestrator : IHostedService
public async Task StartAsync(CancellationToken cancellationToken) public async Task StartAsync(CancellationToken cancellationToken)
{ {
// Get version from assembly
var version = typeof(StartupValidationOrchestrator).Assembly
.GetName().Version?.ToString(3) ?? "unknown";
Console.WriteLine(); Console.WriteLine();
Console.WriteLine("========================================"); Console.WriteLine("========================================");
Console.WriteLine(" allstarr starting up... "); Console.WriteLine($" allstarr v{version} ");
Console.WriteLine("========================================"); Console.WriteLine("========================================");
Console.WriteLine(); Console.WriteLine();

View File

@@ -5,6 +5,9 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>allstarr</RootNamespace> <RootNamespace>allstarr</RootNamespace>
<Version>1.0.0</Version>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View File

@@ -81,8 +81,10 @@ services:
- SpotifyImport__SyncStartHour=${SPOTIFY_IMPORT_SYNC_START_HOUR:-16} - SpotifyImport__SyncStartHour=${SPOTIFY_IMPORT_SYNC_START_HOUR:-16}
- SpotifyImport__SyncStartMinute=${SPOTIFY_IMPORT_SYNC_START_MINUTE:-15} - SpotifyImport__SyncStartMinute=${SPOTIFY_IMPORT_SYNC_START_MINUTE:-15}
- SpotifyImport__SyncWindowHours=${SPOTIFY_IMPORT_SYNC_WINDOW_HOURS:-2} - SpotifyImport__SyncWindowHours=${SPOTIFY_IMPORT_SYNC_WINDOW_HOURS:-2}
- SpotifyImport__Playlists=${SPOTIFY_IMPORT_PLAYLISTS:-}
- SpotifyImport__PlaylistIds=${SPOTIFY_IMPORT_PLAYLIST_IDS:-} - SpotifyImport__PlaylistIds=${SPOTIFY_IMPORT_PLAYLIST_IDS:-}
- SpotifyImport__PlaylistNames=${SPOTIFY_IMPORT_PLAYLIST_NAMES:-} - SpotifyImport__PlaylistNames=${SPOTIFY_IMPORT_PLAYLIST_NAMES:-}
- SpotifyImport__PlaylistLocalTracksPositions=${SPOTIFY_IMPORT_PLAYLIST_LOCAL_TRACKS_POSITIONS:-}
# ===== SHARED ===== # ===== SHARED =====
- Library__DownloadPath=/app/downloads - Library__DownloadPath=/app/downloads