mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 15:45:10 -05:00
Compare commits
9 Commits
d9375405a5
...
bbb0d9bb73
| Author | SHA1 | Date | |
|---|---|---|---|
|
bbb0d9bb73
|
|||
|
0356f3c54d
|
|||
|
0c25d16e42
|
|||
|
4c3709113f
|
|||
|
12db8370a3
|
|||
|
64be6eddf4
|
|||
|
0980547848
|
|||
|
2bb1ffa581
|
|||
|
51702a544b
|
29
.env.example
29
.env.example
@@ -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
|
||||
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
|
||||
# Playlists configuration (SIMPLE FORMAT - recommended for .env files)
|
||||
# Comma-separated lists - all three must have the same number of items
|
||||
#
|
||||
# 1. Playlist IDs (get from Jellyfin playlist URL: https://jellyfin.example.com/web/#/details?id=PLAYLIST_ID)
|
||||
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
|
||||
#
|
||||
# 2. Playlist names (as they appear in Jellyfin)
|
||||
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"]]
|
||||
|
||||
@@ -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<List<allstarr.Models.Spotify.MissingTrack>>(missingTracksKey);
|
||||
|
||||
@@ -2528,17 +2525,48 @@ public class JellyfinController : ControllerBase
|
||||
_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)
|
||||
{
|
||||
// Update ChildCount to show the number of tracks we'll provide
|
||||
itemDict["ChildCount"] = missingTracks.Count;
|
||||
// Update ChildCount to show total tracks (local + external)
|
||||
var totalCount = localTracksCount + missingTracks.Count;
|
||||
itemDict["ChildCount"] = totalCount;
|
||||
modified = true;
|
||||
_logger.LogInformation("✓ Updated ChildCount for Spotify playlist {Name} to {Count}",
|
||||
playlistName, missingTracks.Count);
|
||||
_logger.LogInformation("✓ Updated ChildCount for Spotify playlist {Name} to {Total} ({Local} local + {External} external)",
|
||||
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
|
||||
{
|
||||
_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
|
||||
// Local tracks position is configurable per-playlist
|
||||
var playlistConfig = _spotifySettings.GetPlaylistById(playlistId);
|
||||
var localTracksPosition = playlistConfig?.LocalTracksPosition ?? LocalTracksPosition.First;
|
||||
|
||||
var finalTracks = new List<Song>();
|
||||
foreach (var missingTrack in missingTracks)
|
||||
if (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)
|
||||
{
|
||||
finalTracks.Add(existingTrack);
|
||||
}
|
||||
else if (matchedBySpotifyId.TryGetValue(missingTrack.SpotifyId, out var matchedTrack))
|
||||
{
|
||||
finalTracks.Add(matchedTrack);
|
||||
}
|
||||
// Skip tracks we couldn't match
|
||||
// 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} matched, {Missing} missing)",
|
||||
_logger.LogInformation("Final playlist: {Total} tracks ({Existing} local + {Matched} external, LocalTracksPosition: {Position})",
|
||||
finalTracks.Count,
|
||||
existingTracks.Count,
|
||||
matchedBySpotifyId.Count,
|
||||
missingTracks.Count - existingTracks.Count - matchedBySpotifyId.Count);
|
||||
localTracksPosition);
|
||||
|
||||
return _responseBuilder.CreateItemsResponse(finalTracks);
|
||||
}
|
||||
@@ -2997,25 +3024,22 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
// Check what was cached
|
||||
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
|
||||
? _spotifySettings.PlaylistNames[i]
|
||||
: _spotifySettings.PlaylistIds[i];
|
||||
|
||||
var cacheKey = $"spotify:missing:{playlistName}";
|
||||
var cacheKey = $"spotify:missing:{playlist.Name}";
|
||||
var tracks = await _cache.GetAsync<List<allstarr.Models.Spotify.MissingTrack>>(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"
|
||||
};
|
||||
@@ -3067,9 +3091,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() })
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3128,12 +3150,12 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
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);
|
||||
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 });
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
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>
|
||||
/// Configuration for Spotify playlist injection feature.
|
||||
/// 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;
|
||||
|
||||
/// <summary>
|
||||
/// 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"]]
|
||||
/// </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();
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
[Obsolete("Use Playlists instead")]
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -117,35 +117,227 @@ builder.Services.Configure<SpotifyImportSettings>(options =>
|
||||
{
|
||||
builder.Configuration.GetSection("SpotifyImport").Bind(options);
|
||||
|
||||
// Parse SPOTIFY_IMPORT_PLAYLIST_IDS env var (comma-separated) into PlaylistIds array
|
||||
var playlistIdsEnv = builder.Configuration.GetValue<string>("SpotifyImport:PlaylistIds");
|
||||
if (!string.IsNullOrWhiteSpace(playlistIdsEnv) && options.PlaylistIds.Count == 0)
|
||||
// Debug: Check what Bind() populated
|
||||
Console.WriteLine($"DEBUG: After Bind(), Playlists.Count = {options.Playlists.Count}");
|
||||
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
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(id => id.Trim())
|
||||
.Where(id => !string.IsNullOrEmpty(id))
|
||||
.ToList();
|
||||
Console.WriteLine($"Found SPOTIFY_IMPORT_PLAYLISTS env var: {playlistsEnv.Length} chars");
|
||||
try
|
||||
{
|
||||
// Parse as JSON array of arrays
|
||||
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");
|
||||
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
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(name => name.Trim())
|
||||
.Where(name => !string.IsNullOrEmpty(name))
|
||||
.ToList();
|
||||
Console.WriteLine("Parsing legacy Spotify playlist format...");
|
||||
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
|
||||
// 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
|
||||
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})");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,11 +457,15 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
|
||||
var httpClient = _httpClientFactory.CreateClient();
|
||||
|
||||
// Search forward first (newest files), then backwards to handle timezone differences
|
||||
// We want the file with the furthest forward timestamp (most recent)
|
||||
// Search starting from 24 hours ahead, going backwards for 72 hours
|
||||
// This handles timezone differences where the plugin may have run "in the future" from our perspective
|
||||
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(" 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;
|
||||
DateTime? foundFileTime = null;
|
||||
@@ -511,14 +510,15 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
_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)
|
||||
_logger.LogInformation(" Phase 1: Searching forward 24 hours...");
|
||||
// Search from 24h ahead, going backwards minute by minute for 72 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;
|
||||
|
||||
var time = now.AddMinutes(minutesAhead);
|
||||
var time = searchStart.AddMinutes(-minutesBehind);
|
||||
|
||||
var result = await TryFetchMissingTracksFile(playlistName, time, jellyfinUrl, apiKey, httpClient, cancellationToken);
|
||||
if (result.found)
|
||||
@@ -529,39 +529,12 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
{
|
||||
_logger.LogWarning(" ✗ Could not find new missing tracks file (searched +24h forward, -48h backward)");
|
||||
|
||||
@@ -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<IMusicMetadataService>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,9 +22,13 @@ public class StartupValidationOrchestrator : IHostedService
|
||||
|
||||
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(" allstarr starting up... ");
|
||||
Console.WriteLine($" allstarr v{version} ");
|
||||
Console.WriteLine("========================================");
|
||||
Console.WriteLine();
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>allstarr</RootNamespace>
|
||||
<Version>1.0.0</Version>
|
||||
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
||||
<FileVersion>1.0.0.0</FileVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -81,8 +81,10 @@ 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__Playlists=${SPOTIFY_IMPORT_PLAYLISTS:-}
|
||||
- SpotifyImport__PlaylistIds=${SPOTIFY_IMPORT_PLAYLIST_IDS:-}
|
||||
- SpotifyImport__PlaylistNames=${SPOTIFY_IMPORT_PLAYLIST_NAMES:-}
|
||||
- SpotifyImport__PlaylistLocalTracksPositions=${SPOTIFY_IMPORT_PLAYLIST_LOCAL_TRACKS_POSITIONS:-}
|
||||
|
||||
# ===== SHARED =====
|
||||
- Library__DownloadPath=/app/downloads
|
||||
|
||||
Reference in New Issue
Block a user