From a37f7e0b1d394ef804004cf497b9e0cdf435ccb0 Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Mon, 9 Feb 2026 13:22:02 -0500 Subject: [PATCH] feat: add sync schedule editing and improve Spotify rate limit handling Renamed Active Playlists to Injected Playlists. Added sync schedule column with inline edit button. Added endpoint to update playlist sync schedules. Improved error handling for Spotify rate limits with user-friendly messages. --- allstarr/Controllers/AdminController.cs | 60 +++++++++++++++++++++++++ allstarr/wwwroot/index.html | 59 +++++++++++++++++++++--- 2 files changed, 113 insertions(+), 6 deletions(-) diff --git a/allstarr/Controllers/AdminController.cs b/allstarr/Controllers/AdminController.cs index 6da2235..7a271e3 100644 --- a/allstarr/Controllers/AdminController.cs +++ b/allstarr/Controllers/AdminController.cs @@ -259,6 +259,7 @@ public class AdminController : ControllerBase ["id"] = config.Id, ["jellyfinId"] = config.JellyfinId, ["localTracksPosition"] = config.LocalTracksPosition.ToString(), + ["syncSchedule"] = config.SyncSchedule ?? "0 8 * * 1", ["trackCount"] = 0, ["localTracks"] = 0, ["externalTracks"] = 0, @@ -2311,6 +2312,60 @@ public class AdminController : ControllerBase return await RemovePlaylist(decodedName); } + /// + /// Update playlist sync schedule + /// + [HttpPut("playlists/{name}/schedule")] + public async Task UpdatePlaylistSchedule(string name, [FromBody] UpdateScheduleRequest request) + { + var decodedName = Uri.UnescapeDataString(name); + + if (string.IsNullOrWhiteSpace(request.SyncSchedule)) + { + return BadRequest(new { error = "SyncSchedule is required" }); + } + + // Basic cron validation + var cronParts = request.SyncSchedule.Trim().Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + if (cronParts.Length != 5) + { + return BadRequest(new { error = "Invalid cron format. Expected: minute hour day month dayofweek" }); + } + + // Read current playlists + var currentPlaylists = await ReadPlaylistsFromEnvFile(); + var playlist = currentPlaylists.FirstOrDefault(p => p.Name.Equals(decodedName, StringComparison.OrdinalIgnoreCase)); + + if (playlist == null) + { + return NotFound(new { error = $"Playlist '{decodedName}' not found" }); + } + + // Update the schedule + playlist.SyncSchedule = request.SyncSchedule.Trim(); + + // Save back to .env + var playlistsJson = JsonSerializer.Serialize( + currentPlaylists.Select(p => new[] { + p.Name, + p.Id, + p.JellyfinId, + p.LocalTracksPosition.ToString().ToLower(), + p.SyncSchedule ?? "0 8 * * 1" + }).ToArray() + ); + + var updateRequest = new ConfigUpdateRequest + { + Updates = new Dictionary + { + ["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson + } + }; + + return await UpdateConfig(updateRequest); + } + private string GetJellyfinAuthHeader() { return $"MediaBrowser Client=\"Allstarr\", Device=\"Server\", DeviceId=\"allstarr-admin\", Version=\"1.0.0\", Token=\"{_jellyfinSettings.ApiKey}\""; @@ -3417,6 +3472,11 @@ public class LinkPlaylistRequest public string SyncSchedule { get; set; } = "0 8 * * 1"; // Default: 8 AM every Monday } +public class UpdateScheduleRequest +{ + public string SyncSchedule { get; set; } = string.Empty; +} + /// /// GET /api/admin/downloads /// Lists all downloaded files in the KEPT folder only (favorited tracks) diff --git a/allstarr/wwwroot/index.html b/allstarr/wwwroot/index.html index 1a3ce1e..d40785d 100644 --- a/allstarr/wwwroot/index.html +++ b/allstarr/wwwroot/index.html @@ -537,7 +537,7 @@
Dashboard
Link Playlists
-
Active Playlists
+
Injected Playlists
Configuration
API Analytics
@@ -652,7 +652,7 @@

- Active Spotify Playlists + Injected Spotify Playlists
@@ -660,13 +660,14 @@

- These are the Spotify playlists currently being monitored and filled with tracks from your music service. + These are the Spotify playlists currently being injected into Jellyfin with tracks from your music service.

+ @@ -675,7 +676,7 @@ - @@ -1545,7 +1546,7 @@ if (data.playlists.length === 0) { if (!silent) { - tbody.innerHTML = ''; + tbody.innerHTML = ''; } return; } @@ -1599,10 +1600,16 @@ // Debug logging console.log(`Progress bar for ${p.name}: local=${localPct}%, external=${externalPct}%, missing=${missingPct}%, total=${completionPct}%`); + const syncSchedule = p.syncSchedule || '0 8 * * 1'; + return ` +
Name Spotify IDSync Schedule Tracks Completion Cache Age
+ Loading playlists...
No playlists configured. Link playlists from the Jellyfin Playlists tab.
No playlists configured. Link playlists from the Jellyfin Playlists tab.
${escapeHtml(p.name)} ${p.id || '-'} + ${escapeHtml(syncSchedule)} + + ${statsHtml}${breakdown}
@@ -2025,7 +2032,14 @@ const res = await fetch('/api/admin/spotify/user-playlists'); if (!res.ok) { const error = await res.json(); - console.error('Failed to fetch Spotify playlists:', error); + console.error('Failed to fetch Spotify playlists:', res.status, error); + + // Show user-friendly error message + if (res.status === 429) { + showToast('Spotify rate limit reached. Please wait a moment and try again.', 'warning', 5000); + } else if (res.status === 401) { + showToast('Spotify authentication failed. Check your sp_dc cookie.', 'error', 5000); + } return []; } const data = await res.json(); @@ -2563,6 +2577,39 @@ } } + async function editPlaylistSchedule(playlistName, currentSchedule) { + const newSchedule = prompt(`Edit sync schedule for "${playlistName}"\n\nCron format: minute hour day month dayofweek\nExamples:\n• 0 8 * * 1 = Monday 8 AM\n• 0 6 * * * = Daily 6 AM\n• 0 20 * * 5 = Friday 8 PM`, currentSchedule); + + if (!newSchedule || newSchedule === currentSchedule) return; + + // Validate cron format + const cronParts = newSchedule.trim().split(/\s+/); + if (cronParts.length !== 5) { + showToast('Invalid cron format. Expected: minute hour day month dayofweek', 'error'); + return; + } + + try { + const res = await fetch(`/api/admin/playlists/${encodeURIComponent(playlistName)}/schedule`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ syncSchedule: newSchedule.trim() }) + }); + + if (res.ok) { + showToast('Sync schedule updated!', 'success'); + showRestartBanner(); + fetchPlaylists(); + } else { + const error = await res.json(); + showToast(error.error || 'Failed to update schedule', 'error'); + } + } catch (error) { + console.error('Failed to update schedule:', error); + showToast('Failed to update schedule', 'error'); + } + } + async function removePlaylist(name) { if (!confirm(`Remove playlist "${name}"?`)) return;