From a02d14a23aca6ed19ad8647b57c8c8314b6bda4f Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Mon, 16 Mar 2026 10:35:45 -0700
Subject: [PATCH] Add URL history pills for YouTube, Deezer, and Spotify Link
sync tabs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Saves successfully loaded playlist URLs to localStorage and displays
them as clickable pills between the input bar and playlist container.
Clicking a pill re-loads that URL; X button removes it. Max 10 per
source, most recent first. Source-colored hover accents match each
tab's brand styling.
Also fixes duplicate playlist bug — YouTube and Spotify Public now
check for already-loaded playlists before making API calls, preventing
broken duplicate cards when the same URL is entered twice.
---
webui/index.html | 3 +
webui/static/script.js | 153 +++++++++++++++++++++++++++++++++++++++++
webui/static/style.css | 99 ++++++++++++++++++++++++++
3 files changed, 255 insertions(+)
diff --git a/webui/index.html b/webui/index.html
index ba08ee3b..21d0f88d 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -1044,6 +1044,7 @@
placeholder="Paste Deezer Playlist URL...">
Load Playlist
+
@@ -1056,6 +1057,7 @@
placeholder="Paste YouTube Music Playlist URL...">
Parse Playlist
+
@@ -1068,6 +1070,7 @@
placeholder="Paste Spotify Playlist or Album URL...">
Load
+
diff --git a/webui/static/script.js b/webui/static/script.js
index 67454e2c..a6d19949 100644
--- a/webui/static/script.js
+++ b/webui/static/script.js
@@ -7611,6 +7611,9 @@ async function loadSyncData() {
// Load Beatport charts from backend (always refresh to get latest state)
await loadBeatportChartsFromBackend();
+
+ // Render saved URL histories for YouTube, Deezer, Spotify Link tabs
+ initUrlHistories();
}
async function checkForActiveProcesses() {
@@ -22377,6 +22380,9 @@ async function loadDeezerPlaylist() {
})), { owner: playlist.owner, image_url: playlist.image_url, description: rawUrl });
}
+ // Save to URL history
+ saveUrlHistory('deezer', rawUrl, playlist.name);
+
renderDeezerPlaylists();
await loadDeezerPlaylistStatesFromBackend();
@@ -26479,6 +26485,13 @@ async function parseSpotifyPublicUrl() {
return;
}
+ // Check if already loaded
+ if (_isUrlAlreadyLoaded('spotify-public', url)) {
+ showToast('This playlist is already loaded', 'info');
+ urlInput.value = '';
+ return;
+ }
+
const parseBtn = document.getElementById('spotify-public-parse-btn');
if (parseBtn) {
parseBtn.disabled = true;
@@ -26523,6 +26536,9 @@ async function parseSpotifyPublicUrl() {
})), { owner: result.subtitle || '', image_url: '', description: result.url || '' });
}
+ // Save to URL history
+ saveUrlHistory('spotify-public', url, result.name);
+
renderSpotifyPublicPlaylists();
await loadSpotifyPublicPlaylistStatesFromBackend();
@@ -27441,6 +27457,133 @@ async function startSpotifyPublicDownloadMissing(urlHash) {
}
}
+// ===============================
+// URL HISTORY (Saved playlist URLs)
+// ===============================
+
+const URL_HISTORY_MAX = 10;
+const URL_HISTORY_SOURCES = {
+ youtube: { key: 'soulsync-url-history-youtube', icon: '▶', inputId: 'youtube-url-input', containerId: 'youtube-url-history', loadFn: () => parseYouTubePlaylist() },
+ deezer: { key: 'soulsync-url-history-deezer', icon: '🎵', inputId: 'deezer-url-input', containerId: 'deezer-url-history', loadFn: () => loadDeezerPlaylist() },
+ 'spotify-public': { key: 'soulsync-url-history-spotify-public', icon: '🎧', inputId: 'spotify-public-url-input', containerId: 'spotify-public-url-history', loadFn: () => parseSpotifyPublicUrl() }
+};
+
+function getUrlHistory(source) {
+ try {
+ const cfg = URL_HISTORY_SOURCES[source];
+ if (!cfg) return [];
+ const raw = localStorage.getItem(cfg.key);
+ return raw ? JSON.parse(raw) : [];
+ } catch { return []; }
+}
+
+function saveUrlHistory(source, url, name) {
+ const cfg = URL_HISTORY_SOURCES[source];
+ if (!cfg || !url) return;
+ let history = getUrlHistory(source);
+ // Remove duplicate (same URL)
+ history = history.filter(h => h.url !== url);
+ // Add to front
+ history.unshift({ url, name: name || url, ts: Date.now() });
+ // Cap
+ if (history.length > URL_HISTORY_MAX) history = history.slice(0, URL_HISTORY_MAX);
+ localStorage.setItem(cfg.key, JSON.stringify(history));
+ renderUrlHistory(source);
+}
+
+function removeUrlHistoryEntry(source, url) {
+ const cfg = URL_HISTORY_SOURCES[source];
+ if (!cfg) return;
+ let history = getUrlHistory(source);
+ history = history.filter(h => h.url !== url);
+ localStorage.setItem(cfg.key, JSON.stringify(history));
+ renderUrlHistory(source);
+}
+
+function renderUrlHistory(source) {
+ const cfg = URL_HISTORY_SOURCES[source];
+ if (!cfg) return;
+ const container = document.getElementById(cfg.containerId);
+ if (!container) return;
+ const history = getUrlHistory(source);
+ if (history.length === 0) {
+ container.style.display = 'none';
+ container.innerHTML = '';
+ return;
+ }
+ container.style.display = 'flex';
+ container.innerHTML = `Recent ` +
+ history.map(h => {
+ const rawName = h.name.length > 30 ? h.name.substring(0, 28) + '...' : h.name;
+ const safeName = escapeHtml(rawName);
+ const safeTitle = escapeHtml(h.name);
+ const safeUrl = h.url.replace(/"/g, '"');
+ return `
+ ${cfg.icon}
+ ${safeName}
+ ×
+
`;
+ }).join('');
+
+ // Pill click → fill input and load (skip if already loaded)
+ container.querySelectorAll('.url-history-pill').forEach(pill => {
+ pill.addEventListener('click', (e) => {
+ // Don't trigger if clicking the X button
+ if (e.target.classList.contains('url-history-pill-remove')) return;
+ const pillUrl = pill.dataset.url;
+ if (_isUrlAlreadyLoaded(source, pillUrl)) {
+ showToast('This playlist is already loaded', 'info');
+ return;
+ }
+ const input = document.getElementById(cfg.inputId);
+ if (input) input.value = pillUrl;
+ cfg.loadFn();
+ });
+ });
+
+ // X button click → remove entry
+ container.querySelectorAll('.url-history-pill-remove').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ removeUrlHistoryEntry(btn.dataset.source, btn.dataset.url);
+ });
+ });
+}
+
+function _isUrlAlreadyLoaded(source, url) {
+ if (source === 'youtube') {
+ // Check for existing YouTube card with this URL
+ const container = document.getElementById('youtube-playlist-container');
+ if (container) {
+ const cards = container.querySelectorAll('.youtube-playlist-card[data-url]');
+ for (const card of cards) {
+ if (card.dataset.url === url) return true;
+ }
+ }
+ return false;
+ } else if (source === 'deezer') {
+ // Extract playlist ID from URL and check deezerPlaylists array
+ const match = url.match(/deezer\.com\/(?:[a-z]{2}\/)?playlist\/(\d+)/i);
+ const id = match ? match[1] : (/^\d+$/.test(url) ? url : null);
+ if (id && deezerPlaylists.find(p => String(p.id) === String(id))) return true;
+ return false;
+ } else if (source === 'spotify-public') {
+ // Extract Spotify ID from URL and compare against loaded playlists
+ const spMatch = url.match(/open\.spotify\.com\/(playlist|album)\/([a-zA-Z0-9]+)/);
+ const spId = spMatch ? spMatch[2] : null;
+ if (spId && spotifyPublicPlaylists.some(p => p.id === spId)) return true;
+ // Fallback: direct URL comparison
+ return spotifyPublicPlaylists.some(p => p.url === url);
+ }
+ return false;
+}
+
+function initUrlHistories() {
+ for (const source of Object.keys(URL_HISTORY_SOURCES)) {
+ renderUrlHistory(source);
+ }
+}
+
// ===============================
// YOUTUBE PLAYLIST FUNCTIONALITY
// ===============================
@@ -27460,6 +27603,13 @@ async function parseYouTubePlaylist() {
return;
}
+ // Check if already loaded
+ if (_isUrlAlreadyLoaded('youtube', url)) {
+ showToast('This playlist is already loaded', 'info');
+ urlInput.value = '';
+ return;
+ }
+
try {
console.log('🎬 Parsing YouTube playlist:', url);
@@ -27485,6 +27635,9 @@ async function parseYouTubePlaylist() {
console.log('✅ YouTube playlist parsed:', result.name, `(${result.tracks.length} tracks)`);
+ // Save to URL history
+ saveUrlHistory('youtube', url, result.name);
+
// Update card with parsed data and stay in 'fresh' phase
updateYouTubeCardData(result.url_hash, result);
updateYouTubeCardPhase(result.url_hash, 'fresh');
diff --git a/webui/static/style.css b/webui/static/style.css
index 28bc6ada..d722b963 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -10422,6 +10422,105 @@ body {
padding: 40px;
}
+/* URL History Bar (saved playlist URLs) */
+.url-history-bar {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 2px;
+ overflow-x: auto;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+ flex-shrink: 0;
+}
+
+.url-history-bar::-webkit-scrollbar {
+ display: none;
+}
+
+.url-history-bar-label {
+ font-size: 11px;
+ color: rgba(255, 255, 255, 0.35);
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ white-space: nowrap;
+ flex-shrink: 0;
+ padding-left: 4px;
+}
+
+.url-history-pill {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 10px 4px 8px;
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 16px;
+ cursor: pointer;
+ white-space: nowrap;
+ flex-shrink: 0;
+ transition: all 0.2s ease;
+ max-width: 220px;
+}
+
+.url-history-pill:hover {
+ background: rgba(255, 255, 255, 0.1);
+ border-color: rgba(255, 255, 255, 0.15);
+}
+
+.url-history-pill-icon {
+ font-size: 12px;
+ flex-shrink: 0;
+}
+
+.url-history-pill-name {
+ font-size: 12px;
+ font-weight: 500;
+ color: rgba(255, 255, 255, 0.75);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.url-history-pill:hover .url-history-pill-name {
+ color: rgba(255, 255, 255, 0.95);
+}
+
+.url-history-pill-remove {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ font-size: 10px;
+ color: rgba(255, 255, 255, 0.3);
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ flex-shrink: 0;
+ transition: all 0.15s ease;
+ padding: 0;
+ line-height: 1;
+}
+
+.url-history-pill-remove:hover {
+ color: #ff4444;
+ background: rgba(255, 68, 68, 0.15);
+}
+
+/* Source-specific pill accent on hover */
+#youtube-url-history .url-history-pill:hover {
+ border-color: rgba(255, 32, 32, 0.3);
+}
+#deezer-url-history .url-history-pill:hover {
+ border-color: rgba(162, 56, 255, 0.3);
+}
+#spotify-public-url-history .url-history-pill:hover {
+ border-color: rgba(29, 185, 84, 0.3);
+}
+
/* Playlist URL input section (YouTube, Deezer) */
.youtube-input-section {
display: flex;