Compare commits

...

2 Commits

Author SHA1 Message Date
joshpatra 39c8f16b59 v1.1.3-beta.1: version bump, removed duplicate method; this is why we run tests... 2026-02-20 20:01:22 -05:00
joshpatra a6a423d5a1 v1.1.1-beta-1: fix: redid logic for sync schedule in playlist injection, made a constant for versioning, fixed external artist album and track fetching
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-02-20 18:57:10 -05:00
18 changed files with 531 additions and 187 deletions
+2
View File
@@ -153,6 +153,8 @@ This project brings together all the music streaming providers into one unified
- [Finamp](https://github.com/jmshrv/finamp) (Android/iOS)
- [Finer Player](https://monk-studio.com/finer) (iOS/iPadOS/macOS/tvOS)
_Working on getting more currently_
### Subsonic/Navidrome
+13
View File
@@ -0,0 +1,13 @@
namespace allstarr;
/// <summary>
/// Single source of truth for application version.
/// Update this value when releasing a new version.
/// </summary>
public static class AppVersion
{
/// <summary>
/// Current application version.
/// </summary>
public const string Version = "1.1.3";
}
@@ -93,7 +93,7 @@ public class DiagnosticsController : ControllerBase
return Ok(new
{
version = "1.0.3",
version = AppVersion.Version,
backendType = _configuration.GetValue<string>("Backend:Type") ?? "Jellyfin",
jellyfinUrl = _jellyfinSettings.Url,
spotify = new
@@ -38,35 +38,38 @@ public partial class JellyfinController
// ============================================================================
// REQUEST ROUTING LOGIC (Priority Order)
// ============================================================================
// 1. ParentId present → GetChildItems (handles external playlists/albums/artists OR proxies library items)
// 2. AlbumIds present → Handle external albums OR proxy library albums
// 3. ArtistIds present → Handle external artists OR proxy library artists
// 4. SearchTerm present → Integrated search (Jellyfin + external sources)
// 5. Otherwise → Proxy browse request transparently to Jellyfin
// 1. ArtistIds present (external) → Handle external artists (even with ParentId)
// 2. AlbumIds present (external) → Handle external albums (even with ParentId)
// 3. ParentId present → GetChildItems (handles external playlists/albums/artists OR proxies library items)
// 4. ArtistIds present (library) → Proxy to Jellyfin with artist filter
// 5. SearchTerm present → Integrated search (Jellyfin + external sources)
// 6. Otherwise → Proxy browse request transparently to Jellyfin
// ============================================================================
// PRIORITY 1: ParentId takes precedence - handles both external and library items
if (!string.IsNullOrWhiteSpace(parentId))
// PRIORITY 1: External artist filter - takes precedence over everything (including ParentId)
if (!string.IsNullOrWhiteSpace(effectiveArtistIds))
{
// Check if this is the music library root with a search term - if so, do integrated search
var isMusicLibrary = parentId == _settings.LibraryId;
var artistId = effectiveArtistIds.Split(',')[0]; // Take first artist if multiple
var (isExternal, provider, type, externalId) = _localLibraryService.ParseExternalId(artistId);
if (isMusicLibrary && !string.IsNullOrWhiteSpace(searchTerm))
if (isExternal)
{
_logger.LogInformation("Searching within music library {ParentId}, including external sources",
parentId);
// Fall through to integrated search below
}
else
{
// Browse parent item (external playlist/album/artist OR library item)
_logger.LogDebug("Browsing parent: {ParentId}", parentId);
return await GetChildItems(parentId, includeItemTypes, limit, startIndex, sortBy);
// Check if this is a curator ID (format: ext-{provider}-curator-{name})
if (artistId.Contains("-curator-", StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation("Fetching playlists for curator: {ArtistId}", artistId);
return await GetCuratorPlaylists(provider!, externalId!, includeItemTypes);
}
_logger.LogInformation("Fetching content for external artist: {Provider}/{ExternalId}, type={Type}, parentId={ParentId}",
provider, externalId, type, parentId);
return await GetExternalChildItems(provider!, type!, externalId!, includeItemTypes);
}
// If library artist, fall through to handle with ParentId or proxy
}
// PRIORITY 2: Filter by album (no parentId)
if (string.IsNullOrWhiteSpace(parentId) && !string.IsNullOrWhiteSpace(albumIds))
// PRIORITY 2: External album filter
if (!string.IsNullOrWhiteSpace(albumIds))
{
var albumId = albumIds.Split(',')[0]; // Take first album if multiple
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(albumId);
@@ -92,51 +95,42 @@ public partial class JellyfinController
StartIndex = startIndex
});
}
else
{
// Library album - proxy transparently with full query string
_logger.LogDebug("Library album filter requested: {AlbumId}, proxying to Jellyfin", albumId);
var endpoint = userId != null
? $"Users/{userId}/Items{Request.QueryString}"
: $"Items{Request.QueryString}";
var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
return HandleProxyResponse(result, statusCode);
}
// If library album, fall through to handle with ParentId or proxy
}
// PRIORITY 3: Filter by artist (no parentId, no albumIds)
if (string.IsNullOrWhiteSpace(parentId) && string.IsNullOrWhiteSpace(albumIds) &&
!string.IsNullOrWhiteSpace(effectiveArtistIds))
// PRIORITY 3: ParentId present - handles both external and library items
if (!string.IsNullOrWhiteSpace(parentId))
{
var artistId = effectiveArtistIds.Split(',')[0]; // Take first artist if multiple
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(artistId);
// Check if this is the music library root with a search term - if so, do integrated search
var isMusicLibrary = parentId == _settings.LibraryId;
if (isExternal)
if (isMusicLibrary && !string.IsNullOrWhiteSpace(searchTerm))
{
// Check if this is a curator ID (format: ext-{provider}-curator-{name})
if (artistId.Contains("-curator-", StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation("Fetching playlists for curator: {ArtistId}", artistId);
return await GetCuratorPlaylists(provider!, externalId!, includeItemTypes);
}
_logger.LogInformation("Fetching content for external artist: {Provider}/{ExternalId}", provider,
externalId);
return await GetExternalChildItems(provider!, externalId!, includeItemTypes);
_logger.LogInformation("Searching within music library {ParentId}, including external sources",
parentId);
// Fall through to integrated search below
}
else
{
// Library artist - proxy transparently with full query string
_logger.LogDebug("Library artist filter requested: {ArtistId}, proxying to Jellyfin", artistId);
var endpoint = userId != null
? $"Users/{userId}/Items{Request.QueryString}"
: $"Items{Request.QueryString}";
var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
return HandleProxyResponse(result, statusCode);
// Browse parent item (external playlist/album/artist OR library item)
_logger.LogDebug("Browsing parent: {ParentId}", parentId);
return await GetChildItems(parentId, includeItemTypes, limit, startIndex, sortBy);
}
}
// PRIORITY 4: Search term present - do integrated search (Jellyfin + external)
// PRIORITY 4: Library artist filter (already checked for external above)
if (!string.IsNullOrWhiteSpace(effectiveArtistIds))
{
// Library artist - proxy transparently with full query string
_logger.LogDebug("Library artist filter requested, proxying to Jellyfin");
var endpoint = userId != null
? $"Users/{userId}/Items{Request.QueryString}"
: $"Items{Request.QueryString}";
var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
return HandleProxyResponse(result, statusCode);
}
// PRIORITY 5: Search term present - do integrated search (Jellyfin + external)
if (!string.IsNullOrWhiteSpace(searchTerm))
{
// Check cache for search results (only cache pure searches, not filtered searches)
@@ -154,7 +148,7 @@ public partial class JellyfinController
// Fall through to integrated search below
}
// PRIORITY 5: No filters, no search - proxy browse request transparently
// PRIORITY 6: No filters, no search - proxy browse request transparently
else
{
_logger.LogDebug("Browse request with no filters, proxying to Jellyfin with full query string");
@@ -508,18 +502,23 @@ public partial class JellyfinController
return await GetPlaylistTracks(parentId);
}
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(parentId);
var (isExternal, provider, type, externalId) = _localLibraryService.ParseExternalId(parentId);
if (isExternal)
{
// Get external album or artist content
return await GetExternalChildItems(provider!, externalId!, includeItemTypes);
return await GetExternalChildItems(provider!, type!, externalId!, includeItemTypes);
}
// For library items, proxy transparently with full query string
_logger.LogDebug("Proxying library item request to Jellyfin: ParentId={ParentId}", parentId);
var endpoint = $"Users/{Request.RouteValues["userId"]}/Items{Request.QueryString}";
// Build endpoint - handle both /Items and /Users/{userId}/Items routes
var userIdFromRoute = Request.RouteValues["userId"]?.ToString();
var endpoint = string.IsNullOrEmpty(userIdFromRoute)
? $"Items{Request.QueryString}"
: $"Users/{userIdFromRoute}/Items{Request.QueryString}";
var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
return HandleProxyResponse(result, statusCode);
+74 -31
View File
@@ -189,44 +189,66 @@ public partial class JellyfinController : ControllerBase
/// <summary>
/// Gets child items for an external parent (album tracks or artist albums).
/// </summary>
private async Task<IActionResult> GetExternalChildItems(string provider, string externalId, string? includeItemTypes)
private async Task<IActionResult> GetExternalChildItems(string provider, string type, string externalId, string? includeItemTypes)
{
var itemTypes = ParseItemTypes(includeItemTypes);
_logger.LogDebug("GetExternalChildItems: provider={Provider}, externalId={ExternalId}, itemTypes={ItemTypes}",
provider, externalId, string.Join(",", itemTypes ?? Array.Empty<string>()));
_logger.LogDebug("GetExternalChildItems: provider={Provider}, type={Type}, externalId={ExternalId}, itemTypes={ItemTypes}",
provider, type, externalId, string.Join(",", itemTypes ?? Array.Empty<string>()));
// Check if asking for audio (album tracks)
// Check if asking for audio (album tracks or artist songs)
if (itemTypes?.Contains("Audio") == true)
{
_logger.LogDebug("Fetching album tracks for {Provider}/{ExternalId}", provider, externalId);
var album = await _metadataService.GetAlbumAsync(provider, externalId);
if (album == null)
if (type == "album")
{
return _responseBuilder.CreateError(404, "Album not found");
}
_logger.LogDebug("Fetching album tracks for {Provider}/{ExternalId}", provider, externalId);
var album = await _metadataService.GetAlbumAsync(provider, externalId);
if (album == null)
{
return _responseBuilder.CreateError(404, "Album not found");
}
return _responseBuilder.CreateItemsResponse(album.Songs);
return _responseBuilder.CreateItemsResponse(album.Songs);
}
else if (type == "artist")
{
// For artist + Audio, fetch top tracks from the artist endpoint
_logger.LogDebug("Fetching artist tracks for {Provider}/{ExternalId}", provider, externalId);
var tracks = await _metadataService.GetArtistTracksAsync(provider, externalId);
_logger.LogDebug("Found {Count} tracks for artist", tracks.Count);
return _responseBuilder.CreateItemsResponse(tracks);
}
}
// Otherwise assume it's artist albums
_logger.LogDebug("Fetching artist albums for {Provider}/{ExternalId}", provider, externalId);
var albums = await _metadataService.GetArtistAlbumsAsync(provider, externalId);
var artist = await _metadataService.GetArtistAsync(provider, externalId);
_logger.LogDebug("Found {Count} albums for artist {ArtistName}", albums.Count, artist?.Name ?? "unknown");
// Fill artist info
if (artist != null)
// Check if asking for albums (artist albums)
if (itemTypes?.Contains("MusicAlbum") == true || itemTypes == null)
{
foreach (var a in albums)
if (type == "artist")
{
if (string.IsNullOrEmpty(a.Artist)) a.Artist = artist.Name;
if (string.IsNullOrEmpty(a.ArtistId)) a.ArtistId = artist.Id;
_logger.LogDebug("Fetching artist albums for {Provider}/{ExternalId}", provider, externalId);
var albums = await _metadataService.GetArtistAlbumsAsync(provider, externalId);
var artist = await _metadataService.GetArtistAsync(provider, externalId);
_logger.LogDebug("Found {Count} albums for artist {ArtistName}", albums.Count, artist?.Name ?? "unknown");
// Fill artist info
if (artist != null)
{
foreach (var a in albums)
{
if (string.IsNullOrEmpty(a.Artist)) a.Artist = artist.Name;
if (string.IsNullOrEmpty(a.ArtistId)) a.ArtistId = artist.Id;
}
}
return _responseBuilder.CreateAlbumsResponse(albums);
}
}
return _responseBuilder.CreateAlbumsResponse(albums);
// Fallback: return empty result
_logger.LogWarning("Unhandled GetExternalChildItems request: provider={Provider}, type={Type}, externalId={ExternalId}, itemTypes={ItemTypes}",
provider, type, externalId, string.Join(",", itemTypes ?? Array.Empty<string>()));
return _responseBuilder.CreateItemsResponse(new List<Song>());
}
private async Task<IActionResult> GetCuratorPlaylists(string provider, string externalId, string? includeItemTypes)
{
@@ -517,8 +539,11 @@ public partial class JellyfinController : ControllerBase
_ => null
};
_logger.LogDebug("External {Type} {Provider}/{ExternalId} coverUrl: {CoverUrl}", type, provider, externalId, coverUrl ?? "NULL");
if (string.IsNullOrEmpty(coverUrl))
{
_logger.LogDebug("No cover URL for external {Type}, returning placeholder", type);
// Return placeholder "no image available" image
return await GetPlaceholderImageAsync();
}
@@ -526,16 +551,34 @@ public partial class JellyfinController : ControllerBase
// Fetch and return the image using the proxy service's HttpClient
try
{
var response = await _proxyService.HttpClient.GetAsync(coverUrl);
if (!response.IsSuccessStatusCode)
_logger.LogDebug("Fetching external image from {Url}", coverUrl);
var imageBytes = await RetryHelper.RetryWithBackoffAsync(async () =>
{
var response = await _proxyService.HttpClient.GetAsync(coverUrl);
if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests ||
response.StatusCode == System.Net.HttpStatusCode.ServiceUnavailable)
{
throw new HttpRequestException($"Transient error: {response.StatusCode}", null, response.StatusCode);
}
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("Failed to fetch external image from {Url}: {StatusCode}", coverUrl, response.StatusCode);
return null;
}
return await response.Content.ReadAsByteArrayAsync();
}, _logger, maxRetries: 3, initialDelayMs: 500);
if (imageBytes == null)
{
// Return placeholder on fetch failure
return await GetPlaceholderImageAsync();
}
var imageBytes = await response.Content.ReadAsByteArrayAsync();
var contentType = response.Content.Headers.ContentType?.ToString() ?? "image/jpeg";
return File(imageBytes, contentType);
_logger.LogDebug("Successfully fetched external image from {Url}, size: {Size} bytes", coverUrl, imageBytes.Length);
return File(imageBytes, "image/jpeg");
}
catch (Exception ex)
{
@@ -967,7 +1010,7 @@ public partial class JellyfinController : ControllerBase
{
LocalAddress = Request.Host.ToString(),
ServerName = serverName ?? "Allstarr",
Version = version ?? "1.0.3",
Version = version ?? AppVersion.Version,
ProductName = "Allstarr (Jellyfin Proxy)",
OperatingSystem = Environment.OSVersion.Platform.ToString(),
Id = _settings.DeviceId,
+70 -50
View File
@@ -1002,6 +1002,43 @@ public class PlaylistController : ControllerBase
return Ok(new { message = "Playlist refresh triggered", timestamp = DateTime.UtcNow });
}
/// <summary>
/// Refresh a single playlist from Spotify (fetch latest data without re-matching).
/// </summary>
[HttpPost("playlists/{name}/refresh")]
public async Task<IActionResult> RefreshPlaylist(string name)
{
var decodedName = Uri.UnescapeDataString(name);
_logger.LogInformation("Manual refresh triggered for playlist: {Name}", decodedName);
if (_playlistFetcher == null)
{
return BadRequest(new { error = "Playlist fetcher is not available" });
}
try
{
await _playlistFetcher.RefreshPlaylistAsync(decodedName);
// Clear playlist stats cache first (so it gets recalculated with fresh data)
var statsCacheKey = $"spotify:playlist:stats:{decodedName}";
await _cache.DeleteAsync(statsCacheKey);
// Then invalidate playlist summary cache (will rebuild with fresh stats)
_helperService.InvalidatePlaylistSummaryCache();
return Ok(new {
message = $"Refreshed {decodedName} from Spotify (no re-matching)",
timestamp = DateTime.UtcNow
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to refresh playlist {Name}", decodedName);
return StatusCode(500, new { error = "Failed to refresh playlist", details = ex.Message });
}
}
/// <summary>
/// Re-match tracks when LOCAL library has changed (checks if Jellyfin playlist changed).
/// This is a lightweight operation that reuses cached Spotify data.
@@ -1065,7 +1102,7 @@ public class PlaylistController : ControllerBase
public async Task<IActionResult> ClearPlaylistCache(string name)
{
var decodedName = Uri.UnescapeDataString(name);
_logger.LogInformation("Rebuild from scratch triggered for playlist: {Name} (clearing Spotify cache)", decodedName);
_logger.LogInformation("Rebuild from scratch triggered for playlist: {Name} (same as cron job)", decodedName);
if (_matchingService == null)
{
@@ -1074,65 +1111,22 @@ public class PlaylistController : ControllerBase
try
{
// Clear ALL cache keys for this playlist (including Spotify data)
var cacheKeys = new[]
{
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(decodedName), // Pre-built items cache
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(decodedName), // Ordered matched tracks
$"spotify:matched:{decodedName}", // Legacy matched tracks
CacheKeyBuilder.BuildSpotifyMissingTracksKey(decodedName), // Missing tracks
$"spotify:playlist:jellyfin-signature:{decodedName}", // Jellyfin signature
CacheKeyBuilder.BuildSpotifyPlaylistKey(decodedName) // Spotify playlist data
};
foreach (var key in cacheKeys)
{
await _cache.DeleteAsync(key);
_logger.LogDebug("Cleared cache key: {Key}", key);
}
// Delete file caches
var safeName = AdminHelperService.SanitizeFileName(decodedName);
var filesToDelete = new[]
{
Path.Combine(CacheDirectory, $"{safeName}_items.json"),
Path.Combine(CacheDirectory, $"{safeName}_matched.json")
};
foreach (var file in filesToDelete)
{
if (System.IO.File.Exists(file))
{
System.IO.File.Delete(file);
_logger.LogDebug("Deleted cache file: {File}", file);
}
}
_logger.LogInformation("✓ Cleared all caches for playlist: {Name} (including Spotify data)", decodedName);
// Trigger rebuild (will fetch fresh Spotify data)
await _matchingService.TriggerMatchingForPlaylistAsync(decodedName);
// Use the unified rebuild method (same as cron job and "Rebuild All Remote")
await _matchingService.TriggerRebuildForPlaylistAsync(decodedName);
// Invalidate playlist summary cache
_helperService.InvalidatePlaylistSummaryCache();
// Clear playlist stats cache to force recalculation from new mappings
var statsCacheKey = $"spotify:playlist:stats:{decodedName}";
await _cache.DeleteAsync(statsCacheKey);
_logger.LogDebug("Cleared stats cache for {Name}", decodedName);
return Ok(new
{
message = $"Rebuilding {decodedName} from scratch (fetching fresh Spotify data)",
timestamp = DateTime.UtcNow,
clearedKeys = cacheKeys.Length,
clearedFiles = filesToDelete.Count(f => System.IO.File.Exists(f))
message = $"Rebuilding {decodedName} from scratch (same as cron job)",
timestamp = DateTime.UtcNow
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to clear cache for {Name}", decodedName);
return StatusCode(500, new { error = "Failed to clear cache", details = ex.Message });
_logger.LogError(ex, "Failed to rebuild playlist {Name}", decodedName);
return StatusCode(500, new { error = "Failed to rebuild playlist", details = ex.Message });
}
}
@@ -1498,6 +1492,32 @@ public class PlaylistController : ControllerBase
}
}
/// <summary>
/// Rebuild all playlists from scratch (clear cache, fetch fresh data, re-match).
/// This is the same process as the scheduled cron job - used by "Rebuild All Remote" button.
/// </summary>
[HttpPost("playlists/rebuild-all")]
public async Task<IActionResult> RebuildAllPlaylists()
{
_logger.LogInformation("Manual full rebuild triggered for all playlists (same as cron job)");
if (_matchingService == null)
{
return BadRequest(new { error = "Track matching service is not available" });
}
try
{
await _matchingService.TriggerRebuildAllAsync();
return Ok(new { message = "Full rebuild triggered for all playlists (same as cron job)", timestamp = DateTime.UtcNow });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to trigger full rebuild for all playlists");
return StatusCode(500, new { error = "Failed to trigger full rebuild", details = ex.Message });
}
}
/// <summary>
/// Get current configuration (safe values only)
/// </summary>
+1 -1
View File
@@ -43,7 +43,7 @@ public class JellyfinSettings
/// <summary>
/// Client version reported to Jellyfin
/// </summary>
public string ClientVersion { get; set; } = "1.0.3";
public string ClientVersion { get; set; } = AppVersion.Version;
/// <summary>
/// Device ID reported to Jellyfin
@@ -27,7 +27,7 @@ public class AdminHelperService
public string GetJellyfinAuthHeader()
{
return $"MediaBrowser Client=\"Allstarr\", Device=\"Server\", DeviceId=\"allstarr-admin\", Version=\"1.0.3\", Token=\"{_jellyfinSettings.ApiKey}\"";
return $"MediaBrowser Client=\"Allstarr\", Device=\"Server\", DeviceId=\"allstarr-admin\", Version=\"{AppVersion.Version}\", Token=\"{_jellyfinSettings.ApiKey}\"";
}
public async Task<List<SpotifyPlaylistConfig>> ReadPlaylistsFromEnvFileAsync()
@@ -312,6 +312,30 @@ public class DeezerMetadataService : IMusicMetadataService
return albums;
}
public async Task<List<Song>> GetArtistTracksAsync(string externalProvider, string externalId)
{
if (externalProvider != "deezer") return new List<Song>();
var url = $"{BaseUrl}/artist/{externalId}/top?limit=50";
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode) return new List<Song>();
var json = await response.Content.ReadAsStringAsync();
var result = JsonDocument.Parse(json);
var tracks = new List<Song>();
if (result.RootElement.TryGetProperty("data", out var data))
{
foreach (var track in data.EnumerateArray())
{
tracks.Add(ParseDeezerTrack(track));
}
}
return tracks;
}
private Song ParseDeezerTrack(JsonElement track, int? fallbackTrackNumber = null, string? albumArtist = null)
{
var externalId = track.GetProperty("id").GetInt64().ToString();
@@ -55,6 +55,11 @@ public interface IMusicMetadataService
/// </summary>
Task<List<Album>> GetArtistAlbumsAsync(string externalProvider, string externalId);
/// <summary>
/// Gets an artist's top tracks (not all songs, just popular tracks from the artist endpoint)
/// </summary>
Task<List<Song>> GetArtistTracksAsync(string externalProvider, string externalId);
/// <summary>
/// Searches for playlists on external providers
/// </summary>
@@ -328,6 +328,14 @@ public class QobuzMetadataService : IMusicMetadataService
}
}
public async Task<List<Song>> GetArtistTracksAsync(string externalProvider, string externalId)
{
// Qobuz doesn't have a dedicated "artist top tracks" endpoint
// Return empty list - clients will need to browse albums instead
if (externalProvider != "qobuz") return new List<Song>();
return new List<Song>();
}
public async Task<List<ExternalPlaylist>> SearchPlaylistsAsync(string query, int limit = 20)
{
try
@@ -166,7 +166,7 @@ public class SpotifyTrackMatchingService : BackgroundService
}
// Time to run this playlist
_logger.LogInformation("=== CRON TRIGGER: Running scheduled match for {Playlist} ===", nextPlaylist.PlaylistName);
_logger.LogInformation("=== CRON TRIGGER: Running scheduled sync for {Playlist} ===", nextPlaylist.PlaylistName);
// Check cooldown to prevent duplicate runs
if (_lastRunTimes.TryGetValue(nextPlaylist.PlaylistName, out var lastRun))
@@ -181,8 +181,8 @@ public class SpotifyTrackMatchingService : BackgroundService
}
}
// Run matching for this playlist
await MatchSinglePlaylistAsync(nextPlaylist.PlaylistName, stoppingToken);
// Run full rebuild for this playlist (same as "Rebuild All Remote" button)
await RebuildSinglePlaylistAsync(nextPlaylist.PlaylistName, stoppingToken);
_lastRunTimes[nextPlaylist.PlaylistName] = DateTime.UtcNow;
_logger.LogInformation("=== FINISHED: {Playlist} - Next run at {NextRun} UTC ===",
@@ -197,7 +197,85 @@ public class SpotifyTrackMatchingService : BackgroundService
}
/// <summary>
/// Matches tracks for a single playlist (called by cron scheduler or manual trigger).
/// Rebuilds a single playlist from scratch (clears cache, fetches fresh data, re-matches).
/// This is the unified method used by both cron scheduler and "Rebuild All Remote" button.
/// </summary>
private async Task RebuildSinglePlaylistAsync(string playlistName, CancellationToken cancellationToken)
{
var playlist = _spotifySettings.Playlists
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
if (playlist == null)
{
_logger.LogInformation("Playlist {Playlist} not found in configuration", playlistName);
return;
}
_logger.LogInformation("Step 1/3: Clearing cache for {Playlist}", playlistName);
// Clear cache for this playlist (same as "Rebuild All Remote" button)
var keysToDelete = new[]
{
CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name),
CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlist.Name),
$"spotify:matched:{playlist.Name}", // Legacy key
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name),
$"spotify:playlist:items:{playlist.Name}",
$"spotify:playlist:ordered:{playlist.Name}",
$"spotify:playlist:stats:{playlist.Name}"
};
foreach (var key in keysToDelete)
{
await _cache.DeleteAsync(key);
}
_logger.LogInformation("Step 2/3: Fetching fresh data from Spotify for {Playlist}", playlistName);
using var scope = _serviceProvider.CreateScope();
var metadataService = scope.ServiceProvider.GetRequiredService<IMusicMetadataService>();
// Trigger fresh fetch from Spotify
SpotifyPlaylistFetcher? playlistFetcher = null;
if (_spotifyApiSettings.Enabled)
{
playlistFetcher = scope.ServiceProvider.GetService<SpotifyPlaylistFetcher>();
if (playlistFetcher != null)
{
// Force refresh from Spotify (clears cache and re-fetches)
await playlistFetcher.RefreshPlaylistAsync(playlist.Name);
}
}
_logger.LogInformation("Step 3/3: Matching tracks for {Playlist}", playlistName);
try
{
if (playlistFetcher != null)
{
// Use new direct API mode with ISRC support
await MatchPlaylistTracksWithIsrcAsync(
playlist.Name, playlistFetcher, metadataService, cancellationToken);
}
else
{
// Fall back to legacy mode
await MatchPlaylistTracksLegacyAsync(
playlist.Name, metadataService, cancellationToken);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error matching tracks for playlist {Playlist}", playlist.Name);
throw;
}
_logger.LogInformation("✓ Rebuild complete for {Playlist}", playlistName);
}
/// <summary>
/// Matches tracks for a single playlist WITHOUT clearing cache or refreshing from Spotify.
/// Used for lightweight re-matching when only local library has changed.
/// </summary>
private async Task MatchSinglePlaylistAsync(string playlistName, CancellationToken cancellationToken)
{
@@ -243,8 +321,43 @@ public class SpotifyTrackMatchingService : BackgroundService
}
/// <summary>
/// Public method to trigger matching manually for all playlists (called from controller).
/// This bypasses cron schedules and runs immediately.
/// Public method to trigger full rebuild for all playlists (called from "Rebuild All Remote" button).
/// This clears caches, fetches fresh data, and re-matches everything - same as cron job.
/// </summary>
public async Task TriggerRebuildAllAsync()
{
_logger.LogInformation("Manual full rebuild triggered for all playlists (same as cron job)");
await RebuildAllPlaylistsAsync(CancellationToken.None);
}
/// <summary>
/// Public method to trigger full rebuild for a single playlist (called from individual "Rebuild Remote" button).
/// This clears cache, fetches fresh data, and re-matches - same as cron job.
/// </summary>
public async Task TriggerRebuildForPlaylistAsync(string playlistName)
{
_logger.LogInformation("Manual full rebuild triggered for playlist: {Playlist} (same as cron job)", playlistName);
// Check cooldown to prevent abuse
if (_lastRunTimes.TryGetValue(playlistName, out var lastRun))
{
var timeSinceLastRun = DateTime.UtcNow - lastRun;
if (timeSinceLastRun < _minimumRunInterval)
{
_logger.LogWarning("Skipping manual rebuild for {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)",
playlistName, (int)timeSinceLastRun.TotalSeconds, (int)_minimumRunInterval.TotalSeconds);
throw new InvalidOperationException($"Please wait {(int)(_minimumRunInterval - timeSinceLastRun).TotalSeconds} more seconds before rebuilding again");
}
}
await RebuildSinglePlaylistAsync(playlistName, CancellationToken.None);
_lastRunTimes[playlistName] = DateTime.UtcNow;
}
/// <summary>
/// Public method to trigger lightweight matching for all playlists (called from controller).
/// This bypasses cron schedules and runs immediately WITHOUT clearing cache or refreshing from Spotify.
/// Use this when only the local library has changed.
/// </summary>
public async Task TriggerMatchingAsync()
{
@@ -253,12 +366,13 @@ public class SpotifyTrackMatchingService : BackgroundService
}
/// <summary>
/// Public method to trigger matching for a specific playlist (called from controller).
/// This bypasses cron schedules and runs immediately.
/// Public method to trigger lightweight matching for a single playlist (called from "Re-match Local" button).
/// This bypasses cron schedules and runs immediately WITHOUT clearing cache or refreshing from Spotify.
/// Use this when only the local library has changed, not when Spotify playlist changed.
/// </summary>
public async Task TriggerMatchingForPlaylistAsync(string playlistName)
{
_logger.LogInformation("Manual track matching triggered for playlist: {Playlist} (bypassing cron schedule)", playlistName);
_logger.LogInformation("Manual track matching triggered for playlist: {Playlist} (lightweight, no cache clear)", playlistName);
// Check cooldown to prevent abuse
if (_lastRunTimes.TryGetValue(playlistName, out var lastRun))
@@ -276,6 +390,34 @@ public class SpotifyTrackMatchingService : BackgroundService
_lastRunTimes[playlistName] = DateTime.UtcNow;
}
private async Task RebuildAllPlaylistsAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("=== STARTING FULL REBUILD FOR ALL PLAYLISTS ===");
var playlists = _spotifySettings.Playlists;
if (playlists.Count == 0)
{
_logger.LogInformation("No playlists configured for rebuild");
return;
}
foreach (var playlist in playlists)
{
if (cancellationToken.IsCancellationRequested) break;
try
{
await RebuildSinglePlaylistAsync(playlist.Name, cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error rebuilding playlist {Playlist}", playlist.Name);
}
}
_logger.LogInformation("=== FINISHED FULL REBUILD FOR ALL PLAYLISTS ===");
}
private async Task MatchAllPlaylistsAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("=== STARTING TRACK MATCHING FOR ALL PLAYLISTS ===");
@@ -375,7 +375,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
var cached = await _cache.GetAsync<Artist>(cacheKey);
if (cached != null)
{
_logger.LogDebug("Returning cached artist {ArtistName}", cached.Name);
_logger.LogDebug("Returning cached artist {ArtistName}, ImageUrl: {ImageUrl}", cached.Name, cached.ImageUrl ?? "NULL");
return cached;
}
@@ -432,13 +432,21 @@ public class SquidWTFMetadataService : IMusicMetadataService
}
var artistElement = artistSource.Value;
// Extract picture UUID (may be null)
string? pictureUuid = null;
if (artistElement.TryGetProperty("picture", out var pictureEl) && pictureEl.ValueKind != JsonValueKind.Null)
{
pictureUuid = pictureEl.GetString();
}
// Normalize artist data to include album count
var normalizedArtist = new JsonObject
{
["id"] = artistElement.GetProperty("id").GetInt64(),
["name"] = artistElement.GetProperty("name").GetString(),
["albums_count"] = albumCount,
["picture"] = artistElement.GetProperty("picture").GetString()
["picture"] = pictureUuid
};
using var doc = JsonDocument.Parse(normalizedArtist.ToJsonString());
@@ -500,6 +508,50 @@ public class SquidWTFMetadataService : IMusicMetadataService
}, new List<Album>());
}
public async Task<List<Song>> GetArtistTracksAsync(string externalProvider, string externalId)
{
if (externalProvider != "squidwtf") return new List<Song>();
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
_logger.LogDebug("GetArtistTracksAsync called for SquidWTF artist {ExternalId}", externalId);
// Same endpoint as albums - /artist/?f={artistId} returns both albums and tracks
var url = $"{baseUrl}/artist/?f={externalId}";
_logger.LogDebug("Fetching artist tracks from URL: {Url}", url);
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
_logger.LogError("SquidWTF artist tracks request failed with status {StatusCode}", response.StatusCode);
return new List<Song>();
}
var json = await response.Content.ReadAsStringAsync();
_logger.LogDebug("SquidWTF artist tracks response for {ExternalId}: {JsonLength} bytes", externalId, json.Length);
var result = JsonDocument.Parse(json);
var tracks = new List<Song>();
// Response structure: { "tracks": [ track objects ] }
if (result.RootElement.TryGetProperty("tracks", out var tracksArray))
{
foreach (var track in tracksArray.EnumerateArray())
{
var parsedTrack = ParseTidalTrack(track);
tracks.Add(parsedTrack);
}
_logger.LogDebug("Found {TrackCount} tracks for artist {ExternalId}", tracks.Count, externalId);
}
else
{
_logger.LogWarning("No tracks found in response for artist {ExternalId}", externalId);
}
return tracks;
}, new List<Song>());
}
public async Task<ExternalPlaylist?> GetPlaylistAsync(string externalProvider, string externalId)
{
if (externalProvider != "squidwtf") return null;
@@ -901,18 +953,24 @@ public class SquidWTFMetadataService : IMusicMetadataService
private Artist ParseTidalArtist(JsonElement artist)
{
var externalId = artist.GetProperty("id").GetInt64().ToString();
var artistName = artist.GetProperty("name").GetString() ?? "";
string? imageUrl = null;
if (artist.TryGetProperty("picture", out var picture))
{
var pictureGuid = picture.GetString()?.Replace("-", "/");
imageUrl = $"https://resources.tidal.com/images/{pictureGuid}/320x320.jpg";
var pictureUuid = picture.GetString();
if (!string.IsNullOrEmpty(pictureUuid))
{
var pictureGuid = pictureUuid.Replace("-", "/");
imageUrl = $"https://resources.tidal.com/images/{pictureGuid}/320x320.jpg";
_logger.LogDebug("Artist {ArtistName} picture: {ImageUrl}", artistName, imageUrl);
}
}
return new Artist
{
Id = $"ext-squidwtf-artist-{externalId}",
Name = artist.GetProperty("name").GetString() ?? "",
Name = artistName,
ImageUrl = imageUrl,
AlbumCount = artist.TryGetProperty("albums_count", out var albumsCount)
? albumsCount.GetInt32()
+3 -3
View File
@@ -5,9 +5,9 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>allstarr</RootNamespace>
<Version>1.0.3</Version>
<AssemblyVersion>1.0.3.0</AssemblyVersion>
<FileVersion>1.0.3.0</FileVersion>
<Version>1.1.1</Version>
<AssemblyVersion>1.1.1.0</AssemblyVersion>
<FileVersion>1.1.1.0</FileVersion>
</PropertyGroup>
<ItemGroup>
+16 -7
View File
@@ -20,7 +20,7 @@
<div class="container">
<header>
<h1>
Allstarr <span class="version" id="version">v1.0.3</span>
Allstarr <span class="version" id="version">Loading...</span>
</h1>
<div id="status-indicator">
<span class="status-badge" id="spotify-status">
@@ -175,16 +175,25 @@
Injected Spotify Playlists
<div class="actions">
<button onclick="matchAllPlaylists()"
title="Re-match tracks when local library changed (uses cached Spotify data)">Re-match All
Local</button>
title="Re-match tracks when local library changed (uses cached Spotify data)">Rematch All</button>
<button onclick="refreshPlaylists()"
title="Fetch the latest playlist data from Spotify without re-matching tracks.">Refresh
All</button>
title="Fetch the latest playlist data from Spotify without re-matching tracks">Refresh All</button>
<button onclick="refreshAndMatchAll()"
title="Rebuild all playlists when Spotify playlists changed (fetches fresh data and re-matches)"
style="background:var(--accent);border-color:var(--accent);">Rebuild All Remote</button>
title="Rebuild all playlists when Spotify playlists changed (clears cache, fetches fresh data, re-matches)"
style="background:var(--accent);border-color:var(--accent);">Rebuild All</button>
</div>
</h2>
<!-- Info box explaining the differences -->
<div style="background: rgba(139, 92, 246, 0.1); border: 1px solid rgba(139, 92, 246, 0.3); border-radius: 6px; padding: 14px; margin-bottom: 16px; font-size: 0.9rem;">
<div style="font-weight: 600; margin-bottom: 8px; color: var(--text-primary);">📋 Button Guide:</div>
<div style="display: grid; gap: 8px; color: var(--text-secondary);">
<div><strong style="color: var(--text-primary);">Rematch:</strong> Re-match tracks when your <em>local Jellyfin library</em> changed (fast, uses cached Spotify data)</div>
<div><strong style="color: var(--text-primary);">Refresh:</strong> Fetch latest data from Spotify without re-matching (updates track counts only)</div>
<div><strong style="color: var(--accent);">Rebuild:</strong> Full rebuild when <em>Spotify playlist</em> changed (clears cache, fetches fresh data, re-matches everything - same as scheduled cron job)</div>
</div>
</div>
<p style="color: var(--text-secondary); margin-bottom: 12px;">
These are the Spotify playlists currently being injected into Jellyfin with tracks from your music
service.
+16
View File
@@ -141,6 +141,14 @@ export async function refreshPlaylists() {
return await res.json();
}
export async function refreshPlaylist(name) {
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(name)}/refresh`, { method: 'POST' });
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return await res.json();
}
export async function clearPlaylistCache(name) {
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(name)}/clear-cache`, { method: 'POST' });
if (!res.ok) {
@@ -165,6 +173,14 @@ export async function matchAllPlaylists() {
return await res.json();
}
export async function rebuildAllPlaylists() {
const res = await fetch('/api/admin/playlists/rebuild-all', { method: 'POST' });
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return await res.json();
}
export async function clearCache() {
const res = await fetch('/api/admin/cache/clear', { method: 'POST' });
if (!res.ok) {
+22 -18
View File
@@ -304,7 +304,7 @@ window.fetchJellyfinUsers = async function() {
}
};
// Refresh playlists
// Refresh playlists (fetch from Spotify without re-matching)
window.refreshPlaylists = async function() {
try {
showToast('Refreshing playlists...', 'success');
@@ -316,9 +316,21 @@ window.refreshPlaylists = async function() {
}
};
// Clear playlist cache
// Refresh single playlist (fetch from Spotify without re-matching)
window.refreshPlaylist = async function(name) {
try {
showToast(`Refreshing ${name} from Spotify...`, 'info');
const data = await API.refreshPlaylist(name);
showToast(`${data.message}`, 'success');
setTimeout(window.fetchPlaylists, 2000);
} catch (error) {
showToast('Failed to refresh playlist', 'error');
}
};
// Clear playlist cache (individual "Rebuild Remote" button)
window.clearPlaylistCache = async function(name) {
if (!confirm(`Rebuild "${name}" from scratch?\n\nThis will:\n• Fetch fresh Spotify playlist data\nClear all caches\n• Re-match all tracks\n\nUse this when the Spotify playlist has changed.\n\nThis may take a minute.`)) return;
if (!confirm(`Rebuild "${name}" from scratch?\n\nThis will:\n Clear all caches\n Fetch fresh Spotify playlist data\nRe-match all tracks\n\nThis is the SAME process as the scheduled cron job.\n\nUse this when the Spotify playlist has changed.\n\nThis may take a minute.`)) return;
try {
document.getElementById('matching-warning-banner').style.display = 'block';
@@ -372,32 +384,24 @@ window.matchAllPlaylists = async function() {
}
};
// Refresh and match all
// Refresh and match all (Rebuild All Remote button)
window.refreshAndMatchAll = async function() {
if (!confirm('Clear caches, refresh from Spotify, and match all tracks?\n\nThis will:\n• Clear all playlist caches\n• Fetch fresh data from Spotify\n• Match all tracks against local library and external providers\n\nThis may take several minutes.')) return;
if (!confirm('Rebuild all playlists from scratch?\n\nThis will:\n• Clear all playlist caches\n• Fetch fresh data from Spotify\n• Re-match all tracks against local library and external providers\n\nThis is the SAME process as the scheduled cron job.\n\nThis may take several minutes.')) return;
try {
document.getElementById('matching-warning-banner').style.display = 'block';
showToast('Starting full refresh and match...', 'info', 3000);
showToast('Starting full rebuild (same as cron job)...', 'info', 3000);
showToast('Step 1/3: Clearing caches...', 'info', 2000);
await API.clearCache();
await new Promise(resolve => setTimeout(resolve, 2000));
showToast('Step 2/3: Fetching from Spotify...', 'info', 2000);
await API.refreshPlaylists();
await new Promise(resolve => setTimeout(resolve, 5000));
showToast('Step 3/3: Matching all tracks (this may take several minutes)...', 'info', 3000);
const data = await API.matchAllPlaylists();
showToast(`✓ Full refresh and match complete!`, 'success', 5000);
// Call the unified rebuild endpoint
const data = await API.rebuildAllPlaylists();
showToast(`✓ Full rebuild complete!`, 'success', 5000);
setTimeout(() => {
window.fetchPlaylists();
document.getElementById('matching-warning-banner').style.display = 'none';
}, 3000);
} catch (error) {
showToast('Failed to complete refresh and match', 'error');
showToast('Failed to complete rebuild', 'error');
document.getElementById('matching-warning-banner').style.display = 'none';
}
};
+3 -2
View File
@@ -118,8 +118,9 @@ export function updatePlaylistsUI(data) {
</td>
<td class="cache-age">${p.cacheAge || '-'}</td>
<td>
<button onclick="matchPlaylistTracks('${escapeJs(p.name)}')" title="Re-match when local library changed">Re-match Local</button>
<button onclick="clearPlaylistCache('${escapeJs(p.name)}')" title="Rebuild when Spotify playlist changed" style="background:var(--accent);border-color:var(--accent);">Rebuild Remote</button>
<button onclick="matchPlaylistTracks('${escapeJs(p.name)}')" title="Re-match when local Jellyfin library changed">Rematch</button>
<button onclick="refreshPlaylist('${escapeJs(p.name)}')" title="Fetch latest from Spotify without re-matching">Refresh</button>
<button onclick="clearPlaylistCache('${escapeJs(p.name)}')" title="Rebuild when Spotify playlist changed (same as cron job)" style="background:var(--accent);border-color:var(--accent);">Rebuild</button>
<button onclick="viewTracks('${escapeJs(p.name)}')">View</button>
<button class="danger" onclick="removePlaylist('${escapeJs(p.name)}')">Remove</button>
</td>