@@ -1460,7 +1639,7 @@
if (data.playlists.length === 0) {
if (!silent) {
- tbody.innerHTML = '
| No playlists configured. Link playlists from the Jellyfin Playlists tab. |
';
+ tbody.innerHTML = '
| No playlists configured. Link playlists from the Jellyfin Playlists tab. |
';
}
return;
}
@@ -1491,13 +1670,13 @@
// Show breakdown with color coding
let breakdownParts = [];
if (localCount > 0) {
- breakdownParts.push(`
${localCount} local`);
+ breakdownParts.push(`
${localCount} Local`);
}
if (externalMatched > 0) {
- breakdownParts.push(`
${externalMatched} matched`);
+ breakdownParts.push(`
${externalMatched} External`);
}
if (externalMissing > 0) {
- breakdownParts.push(`
${externalMissing} missing`);
+ breakdownParts.push(`
${externalMissing} Missing`);
}
const breakdown = breakdownParts.length > 0
@@ -1514,25 +1693,31 @@
// 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 `
| ${escapeHtml(p.name)} |
${p.id || '-'} |
+
+ ${escapeHtml(syncSchedule)}
+
+ |
${statsHtml}${breakdown} |
|
${p.cacheAge || '-'} |
-
-
+
+
|
@@ -1776,6 +1961,23 @@
const res = await fetch('/api/admin/config');
const data = await res.json();
+ // Core settings
+ document.getElementById('config-backend-type').textContent = data.backendType || 'Jellyfin';
+ document.getElementById('config-music-service').textContent = data.musicService || 'SquidWTF';
+ document.getElementById('config-storage-mode').textContent = data.library?.storageMode || 'Cache';
+ document.getElementById('config-cache-duration-hours').textContent = data.library?.cacheDurationHours || '24';
+ document.getElementById('config-download-mode').textContent = data.library?.downloadMode || 'Track';
+ document.getElementById('config-explicit-filter').textContent = data.explicitFilter || 'All';
+ document.getElementById('config-enable-external-playlists').textContent = data.enableExternalPlaylists ? 'Yes' : 'No';
+ document.getElementById('config-playlists-directory').textContent = data.playlistsDirectory || '(not set)';
+ document.getElementById('config-redis-enabled').textContent = data.redisEnabled ? 'Yes' : 'No';
+
+ // Show/hide cache duration based on storage mode
+ const cacheDurationRow = document.getElementById('cache-duration-row');
+ if (cacheDurationRow) {
+ cacheDurationRow.style.display = data.library?.storageMode === 'Cache' ? 'grid' : 'none';
+ }
+
// Spotify API settings
document.getElementById('config-spotify-enabled').textContent = data.spotifyApi.enabled ? 'Yes' : 'No';
document.getElementById('config-spotify-cookie').textContent = data.spotifyApi.sessionCookie;
@@ -1817,10 +2019,21 @@
document.getElementById('config-kept-path').textContent = data.library?.keptPath || '/app/kept';
// Sync settings
- const syncHour = data.spotifyImport.syncStartHour;
- const syncMin = data.spotifyImport.syncStartMinute;
- document.getElementById('config-sync-time').textContent = `${String(syncHour).padStart(2, '0')}:${String(syncMin).padStart(2, '0')}`;
- document.getElementById('config-sync-window').textContent = data.spotifyImport.syncWindowHours + ' hours';
+ document.getElementById('config-spotify-import-enabled').textContent = data.spotifyImport?.enabled ? 'Yes' : 'No';
+ document.getElementById('config-matching-interval').textContent = (data.spotifyImport?.matchingIntervalHours || 24) + ' hours';
+
+ // Cache settings
+ if (data.cache) {
+ document.getElementById('config-cache-search').textContent = data.cache.searchResultsMinutes || '120';
+ document.getElementById('config-cache-playlist-images').textContent = data.cache.playlistImagesHours || '168';
+ document.getElementById('config-cache-spotify-items').textContent = data.cache.spotifyPlaylistItemsHours || '168';
+ document.getElementById('config-cache-matched-tracks').textContent = data.cache.spotifyMatchedTracksDays || '30';
+ document.getElementById('config-cache-lyrics').textContent = data.cache.lyricsDays || '14';
+ document.getElementById('config-cache-genres').textContent = data.cache.genreDays || '30';
+ document.getElementById('config-cache-metadata').textContent = data.cache.metadataDays || '7';
+ document.getElementById('config-cache-odesli').textContent = data.cache.odesliLookupDays || '60';
+ document.getElementById('config-cache-proxy-images').textContent = data.cache.proxyImagesDays || '14';
+ }
} catch (error) {
console.error('Failed to fetch config:', error);
}
@@ -1896,23 +2109,138 @@
}
}
- function openLinkPlaylist(jellyfinId, name) {
+ let currentLinkMode = 'select'; // 'select' or 'manual'
+ let spotifyUserPlaylists = []; // Cache of user playlists
+
+ function switchLinkMode(mode) {
+ currentLinkMode = mode;
+
+ const selectGroup = document.getElementById('link-select-group');
+ const manualGroup = document.getElementById('link-manual-group');
+ const selectBtn = document.getElementById('select-mode-btn');
+ const manualBtn = document.getElementById('manual-mode-btn');
+
+ if (mode === 'select') {
+ selectGroup.style.display = 'block';
+ manualGroup.style.display = 'none';
+ selectBtn.classList.add('primary');
+ manualBtn.classList.remove('primary');
+ } else {
+ selectGroup.style.display = 'none';
+ manualGroup.style.display = 'block';
+ selectBtn.classList.remove('primary');
+ manualBtn.classList.add('primary');
+ }
+ }
+
+ async function fetchSpotifyUserPlaylists() {
+ try {
+ const res = await fetch('/api/admin/spotify/user-playlists');
+ if (!res.ok) {
+ const error = await res.json();
+ 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();
+ return data.playlists || [];
+ } catch (error) {
+ console.error('Failed to fetch Spotify playlists:', error);
+ return [];
+ }
+ }
+
+ async function openLinkPlaylist(jellyfinId, name) {
document.getElementById('link-jellyfin-id').value = jellyfinId;
document.getElementById('link-jellyfin-name').value = name;
document.getElementById('link-spotify-id').value = '';
+
+ // Reset to select mode
+ switchLinkMode('select');
+
+ // Fetch user playlists if not already cached
+ if (spotifyUserPlaylists.length === 0) {
+ const select = document.getElementById('link-spotify-select');
+ select.innerHTML = '';
+
+ spotifyUserPlaylists = await fetchSpotifyUserPlaylists();
+
+ // Filter out already-linked playlists
+ const availablePlaylists = spotifyUserPlaylists.filter(p => !p.isLinked);
+
+ if (availablePlaylists.length === 0) {
+ if (spotifyUserPlaylists.length > 0) {
+ select.innerHTML = '';
+ } else {
+ select.innerHTML = '';
+ }
+ // Switch to manual mode if no available playlists
+ switchLinkMode('manual');
+ } else {
+ // Populate dropdown with only unlinked playlists
+ select.innerHTML = '' +
+ availablePlaylists.map(p =>
+ ``
+ ).join('');
+ }
+ } else {
+ // Re-filter in case playlists were linked since last fetch
+ const select = document.getElementById('link-spotify-select');
+ const availablePlaylists = spotifyUserPlaylists.filter(p => !p.isLinked);
+
+ if (availablePlaylists.length === 0) {
+ select.innerHTML = '';
+ switchLinkMode('manual');
+ } else {
+ select.innerHTML = '' +
+ availablePlaylists.map(p =>
+ ``
+ ).join('');
+ }
+ }
+
openModal('link-playlist-modal');
}
async function linkPlaylist() {
const jellyfinId = document.getElementById('link-jellyfin-id').value;
const name = document.getElementById('link-jellyfin-name').value;
- const spotifyId = document.getElementById('link-spotify-id').value.trim();
+ const syncSchedule = document.getElementById('link-sync-schedule').value.trim();
- if (!spotifyId) {
- showToast('Spotify Playlist ID is required', 'error');
+ // Validate sync schedule (basic cron format check)
+ if (!syncSchedule) {
+ showToast('Sync schedule is required', 'error');
return;
}
+ const cronParts = syncSchedule.split(/\s+/);
+ if (cronParts.length !== 5) {
+ showToast('Invalid cron format. Expected: minute hour day month dayofweek', 'error');
+ return;
+ }
+
+ // Get Spotify ID based on current mode
+ let spotifyId = '';
+ if (currentLinkMode === 'select') {
+ spotifyId = document.getElementById('link-spotify-select').value;
+ if (!spotifyId) {
+ showToast('Please select a Spotify playlist', 'error');
+ return;
+ }
+ } else {
+ spotifyId = document.getElementById('link-spotify-id').value.trim();
+ if (!spotifyId) {
+ showToast('Spotify Playlist ID is required', 'error');
+ return;
+ }
+ }
+
// Extract ID from various Spotify formats:
// - spotify:playlist:37i9dQZF1DXcBWIGoYBM5M
// - https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M
@@ -1935,7 +2263,11 @@
const res = await fetch(`/api/admin/jellyfin/playlists/${encodeURIComponent(jellyfinId)}/link`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ name, spotifyPlaylistId: cleanSpotifyId })
+ body: JSON.stringify({
+ name,
+ spotifyPlaylistId: cleanSpotifyId,
+ syncSchedule: syncSchedule
+ })
});
const data = await res.json();
@@ -1945,6 +2277,9 @@
showRestartBanner();
closeModal('link-playlist-modal');
+ // Clear the Spotify playlists cache so it refreshes next time
+ spotifyUserPlaylists = [];
+
// Update UI state without refetching all playlists
const playlistsTable = document.getElementById('jellyfinPlaylistsTable');
if (playlistsTable) {
@@ -1982,6 +2317,9 @@
showToast('Playlist unlinked.', 'success');
showRestartBanner();
+ // Clear the Spotify playlists cache so it refreshes next time
+ spotifyUserPlaylists = [];
+
// Update UI state without refetching all playlists
const playlistsTable = document.getElementById('jellyfinPlaylistsTable');
if (playlistsTable) {
@@ -2020,18 +2358,18 @@
}
async function clearPlaylistCache(name) {
- if (!confirm(`Clear cache and rebuild for "${name}"?\n\nThis will:\n• Clear Redis cache\n• Delete file caches\n• Rebuild with latest Spotify IDs\n\nThis may take a minute.`)) return;
+ 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;
try {
// Show warning banner
document.getElementById('matching-warning-banner').style.display = 'block';
- showToast(`Clearing cache for ${name}...`, 'info');
+ showToast(`Rebuilding ${name} from scratch...`, 'info');
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(name)}/clear-cache`, { method: 'POST' });
const data = await res.json();
if (res.ok) {
- showToast(`✓ ${data.message} (Cleared ${data.clearedKeys} cache keys, ${data.clearedFiles} files)`, 'success', 5000);
+ showToast(`✓ ${data.message}`, 'success', 5000);
// Refresh the playlists table after a delay to show updated counts
setTimeout(() => {
fetchPlaylists();
@@ -2039,7 +2377,7 @@
document.getElementById('matching-warning-banner').style.display = 'none';
}, 3000);
} else {
- showToast(data.error || 'Failed to clear cache', 'error');
+ showToast(data.error || 'Failed to rebuild playlist', 'error');
document.getElementById('matching-warning-banner').style.display = 'none';
}
} catch (error) {
@@ -2053,7 +2391,7 @@
// Show warning banner
document.getElementById('matching-warning-banner').style.display = 'block';
- showToast(`Matching tracks for ${name}...`, 'success');
+ showToast(`Re-matching local tracks for ${name}...`, 'info');
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(name)}/match`, { method: 'POST' });
const data = await res.json();
@@ -2066,17 +2404,17 @@
document.getElementById('matching-warning-banner').style.display = 'none';
}, 2000);
} else {
- showToast(data.error || 'Failed to match tracks', 'error');
+ showToast(data.error || 'Failed to re-match tracks', 'error');
document.getElementById('matching-warning-banner').style.display = 'none';
}
} catch (error) {
- showToast('Failed to match tracks', 'error');
+ showToast('Failed to re-match tracks', 'error');
document.getElementById('matching-warning-banner').style.display = 'none';
}
}
async function matchAllPlaylists() {
- if (!confirm('Match tracks for ALL playlists? This may take a few minutes.')) return;
+ if (!confirm('Re-match local tracks for ALL playlists?\n\nUse this when your local library has changed.\n\nThis may take a few minutes.')) return;
try {
// Show warning banner
@@ -2345,6 +2683,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\n\nUse https://crontab.guru/ to build your schedule`, 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;
@@ -2374,8 +2745,23 @@
try {
const res = await fetch('/api/admin/playlists/' + encodeURIComponent(name) + '/tracks');
+
+ if (!res.ok) {
+ console.error('Failed to fetch tracks:', res.status, res.statusText);
+ document.getElementById('tracks-list').innerHTML = 'Failed to load tracks: ' + res.status + ' ' + res.statusText + '
';
+ return;
+ }
+
const data = await res.json();
+ console.log('Tracks data received:', data);
+
+ if (!data || !data.tracks) {
+ console.error('Invalid data structure:', data);
+ document.getElementById('tracks-list').innerHTML = 'Invalid data received from server
';
+ return;
+ }
+
if (data.tracks.length === 0) {
document.getElementById('tracks-list').innerHTML = 'No tracks found
';
return;
@@ -2399,7 +2785,7 @@
}
} else if (t.isLocal === false) {
const provider = capitalizeProvider(t.externalProvider) || 'External';
- statusBadge = `${escapeHtml(provider)}`;
+ statusBadge = `${escapeHtml(provider)}`;
// Add manual mapping indicator for external tracks
if (t.isManualMapping && t.manualMappingType === 'external') {
statusBadge += 'Manual';
@@ -2422,7 +2808,7 @@
style="margin-left:4px;font-size:0.75rem;padding:4px 8px;background:var(--warning);border-color:var(--warning);">Map to External`;
} else {
// isLocal is null/undefined - track is missing (not found locally or externally)
- statusBadge = 'Missing';
+ statusBadge = 'Missing';
// Add both mapping buttons for missing tracks
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
mapButton = `