mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-04-23 10:42:37 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
39c8f16b59
|
|||
|
a6a423d5a1
|
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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\n• Clear 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\n• Re-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';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user