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..."> +
Paste a Deezer playlist URL above to get started.
@@ -1056,6 +1057,7 @@ placeholder="Paste YouTube Music Playlist URL..."> +
Parsed YouTube playlists will appear here.
@@ -1068,6 +1070,7 @@ placeholder="Paste Spotify Playlist or Album URL..."> +
Paste a Spotify playlist or album URL above to load tracks without needing Spotify API credentials.
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;