Drop redundant standalone "Your Spotify Library" section on Discover

Discover page used to show two near-identical sections:
- "Your Albums" — cross-source aggregator across Spotify / Deezer /
  etc with a gear button to configure sources, search, status filter,
  sort options, and a download-missing action.
- "Your Spotify Library" — Spotify-only with the same grid UI, same
  refresh / download-missing buttons, same filter / sort controls.

The Spotify-only section was a strict subset of what Your Albums
already covers (Spotify is one of the configurable sources). User
flagged the redundancy when scoping the upcoming Discogs integration
and asked for the duplicate to be removed.

Removal scope:
- `webui/index.html` — drop the `#spotify-library-section` block (42
  lines).
- `webui/static/discover.js` — drop the dead JS (~335 lines): state
  vars `spotifyLibraryAlbums` / `spotifyLibraryPage` / etc, all the
  loaders / renderers / pagination / click handlers, and the
  `loadSpotifyLibrarySection()` call in `loadDiscoverPage`'s
  Promise.all.
- `webui/static/helper.js` — drop the helper annotation entry at
  `#spotify-library-section` and the matching guided-tour entry.

Backend untouched. The Spotify saved-albums cache
(`spotify_library_albums` table + watchlist_scanner upsert/cleanup
+ `/api/discover/spotify-library` endpoint + the DAO methods) is
shared infrastructure that Your Albums reads from when Spotify is
one of its configured sources. Removing the UI section just removes
the duplicate surface — Spotify saved albums still appear in Your
Albums via the existing source dispatch.

CSS class names (`.spotify-library-grid`, `.spotify-library-search`,
`.spotify-library-pagination`) intentionally remain on the surviving
Your Albums elements — they share the same visual styling and
renaming would be churn for no benefit.

Verified: full suite 1813 pass (no new tests — pure UI/dead-code
removal). Backend endpoint behavior unchanged. WHATS_NEW entry
under '2.4.2' dev cycle.
pull/488/head
Broque Thomas 3 weeks ago
parent 3dc27034e5
commit e84d187e76

@ -2913,48 +2913,6 @@
<div class="spotify-library-pagination" id="your-albums-pagination" style="display: none;"></div>
</div>
<!-- Spotify Library Section -->
<div class="discover-section" id="spotify-library-section" style="display: none;">
<div class="discover-section-header">
<div>
<h2 class="discover-section-title">Your Spotify Library</h2>
<p class="discover-section-subtitle" id="spotify-library-subtitle">Your saved albums on Spotify</p>
</div>
<div class="spotify-library-header-actions">
<button class="spotify-library-action-btn spotify-library-refresh-btn" onclick="refreshSpotifyLibraryCache()" title="Refresh from Spotify">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/></svg>
Refresh
</button>
<button class="spotify-library-action-btn spotify-library-download-btn" id="spotify-library-download-missing-btn" onclick="downloadMissingSpotifyLibraryAlbums()" style="display: none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>
Download Missing
</button>
</div>
</div>
<div class="spotify-library-filters" id="spotify-library-filters" style="display: none;">
<input type="text" class="spotify-library-search" id="spotify-library-search"
placeholder="Search by artist or album..." oninput="debouncedSpotifyLibrarySearch()">
<select id="spotify-library-status-filter" class="spotify-library-select" onchange="loadSpotifyLibraryAlbums()">
<option value="all">All Albums</option>
<option value="missing">Missing</option>
<option value="owned">Owned</option>
</select>
<select id="spotify-library-sort" class="spotify-library-select" onchange="loadSpotifyLibraryAlbums()">
<option value="date_saved">Date Saved</option>
<option value="artist_name">Artist</option>
<option value="album_name">Album</option>
<option value="release_date">Release Date</option>
</select>
</div>
<div class="spotify-library-grid" id="spotify-library-grid">
<div class="discover-loading">
<div class="loading-spinner"></div>
<p>Loading your Spotify library...</p>
</div>
</div>
<div class="spotify-library-pagination" id="spotify-library-pagination" style="display: none;"></div>
</div>
<!-- Recent Releases Section -->
<div class="discover-section">
<div class="discover-section-header">

@ -33,7 +33,6 @@ async function loadDiscoverPage() {
loadDiscoverHero(),
loadYourArtists(),
loadYourAlbums(),
loadSpotifyLibrarySection(),
loadDiscoverRecentReleases(),
loadSeasonalContent(), // Seasonal discovery
loadPersonalizedRecentlyAdded(), // NEW: Recently added from library
@ -1292,341 +1291,6 @@ async function downloadMissingYourAlbums() {
}
}
// ===============================
// SPOTIFY LIBRARY SECTION
// ===============================
let spotifyLibraryAlbums = [];
let spotifyLibraryPage = 0;
let spotifyLibraryTotal = 0;
const SPOTIFY_LIBRARY_PAGE_SIZE = 48;
let _spotifyLibrarySearchTimeout = null;
function debouncedSpotifyLibrarySearch() {
clearTimeout(_spotifyLibrarySearchTimeout);
_spotifyLibrarySearchTimeout = setTimeout(() => {
spotifyLibraryPage = 0;
loadSpotifyLibraryAlbums();
}, 400);
}
async function loadSpotifyLibrarySection() {
try {
const section = document.getElementById('spotify-library-section');
if (!section) return;
const response = await fetch(`/api/discover/spotify-library?offset=0&limit=${SPOTIFY_LIBRARY_PAGE_SIZE}`);
if (!response.ok) throw new Error('Failed to fetch');
const data = await response.json();
if (!data.success || !data.albums || data.albums.length === 0) {
section.style.display = 'none';
return;
}
section.style.display = '';
spotifyLibraryAlbums = data.albums;
spotifyLibraryTotal = data.total;
spotifyLibraryPage = 0;
// Update subtitle with stats
const subtitle = document.getElementById('spotify-library-subtitle');
if (subtitle && data.stats) {
const s = data.stats;
subtitle.textContent = `${s.total} albums \u00B7 ${s.owned} owned \u00B7 ${s.missing} missing`;
}
// Show download missing button if there are missing albums
const dlBtn = document.getElementById('spotify-library-download-missing-btn');
if (dlBtn && data.stats && data.stats.missing > 0) {
dlBtn.style.display = '';
}
// Show filters
const filters = document.getElementById('spotify-library-filters');
if (filters) filters.style.display = '';
renderSpotifyLibraryGrid(data.albums);
renderSpotifyLibraryPagination(data.total, 0);
} catch (error) {
console.error('Error loading Spotify library section:', error);
const section = document.getElementById('spotify-library-section');
if (section) section.style.display = 'none';
}
}
async function loadSpotifyLibraryAlbums() {
const grid = document.getElementById('spotify-library-grid');
if (!grid) return;
grid.innerHTML = '<div class="discover-loading"><div class="loading-spinner"></div><p>Loading...</p></div>';
try {
const search = (document.getElementById('spotify-library-search')?.value || '').trim();
const status = document.getElementById('spotify-library-status-filter')?.value || 'all';
const sort = document.getElementById('spotify-library-sort')?.value || 'date_saved';
const offset = spotifyLibraryPage * SPOTIFY_LIBRARY_PAGE_SIZE;
const params = new URLSearchParams({
offset, limit: SPOTIFY_LIBRARY_PAGE_SIZE, sort, sort_dir: 'desc', status
});
if (search) params.set('search', search);
const response = await fetch(`/api/discover/spotify-library?${params}`);
const data = await response.json();
if (!data.success) throw new Error(data.error);
spotifyLibraryAlbums = data.albums;
spotifyLibraryTotal = data.total;
// Update subtitle
const subtitle = document.getElementById('spotify-library-subtitle');
if (subtitle && data.stats) {
const s = data.stats;
subtitle.textContent = `${s.total} albums \u00B7 ${s.owned} owned \u00B7 ${s.missing} missing`;
}
renderSpotifyLibraryGrid(data.albums);
renderSpotifyLibraryPagination(data.total, offset);
} catch (error) {
console.error('Error loading Spotify library albums:', error);
grid.innerHTML = '<div class="spotify-library-empty"><p>Failed to load albums</p></div>';
}
}
function renderSpotifyLibraryGrid(albums) {
const grid = document.getElementById('spotify-library-grid');
if (!grid) return;
if (!albums || albums.length === 0) {
grid.innerHTML = '<div class="spotify-library-empty"><p>No albums found</p></div>';
return;
}
let html = '';
albums.forEach((album, index) => {
const coverUrl = album.image_url || '/static/placeholder-album.png';
const year = album.release_date ? album.release_date.substring(0, 4) : '';
const badgeClass = album.in_library ? 'owned' : 'missing';
const badgeIcon = album.in_library ? '\u2713' : '\u2193';
const trackInfo = album.total_tracks ? `${album.total_tracks} tracks` : '';
const meta = [year, trackInfo].filter(Boolean).join(' \u00B7 ');
html += `
<div class="spotify-library-card" onclick="openSpotifyLibraryAlbumDownload(${index})" title="${album.album_name} — ${album.artist_name}">
<div class="spotify-library-card-img">
<img src="${coverUrl}" alt="${album.album_name}" loading="lazy">
<div class="spotify-library-card-badge ${badgeClass}">${badgeIcon}</div>
</div>
<div class="spotify-library-card-info">
<p class="spotify-library-card-title">${album.album_name}</p>
<p class="spotify-library-card-artist">${album.artist_name}</p>
<p class="spotify-library-card-meta">${meta}</p>
</div>
</div>
`;
});
grid.innerHTML = html;
}
function renderSpotifyLibraryPagination(total, offset) {
const container = document.getElementById('spotify-library-pagination');
if (!container) return;
if (total <= SPOTIFY_LIBRARY_PAGE_SIZE) {
container.style.display = 'none';
return;
}
container.style.display = '';
const totalPages = Math.ceil(total / SPOTIFY_LIBRARY_PAGE_SIZE);
const currentPage = Math.floor(offset / SPOTIFY_LIBRARY_PAGE_SIZE) + 1;
const showEnd = Math.min(offset + SPOTIFY_LIBRARY_PAGE_SIZE, total);
container.innerHTML = `
<button class="spotify-library-page-btn" onclick="spotifyLibraryPrevPage()" ${currentPage <= 1 ? 'disabled' : ''}>&larr; Previous</button>
<span class="spotify-library-page-info">${offset + 1}\u2013${showEnd} of ${total}</span>
<button class="spotify-library-page-btn" onclick="spotifyLibraryNextPage()" ${currentPage >= totalPages ? 'disabled' : ''}>Next &rarr;</button>
`;
}
function spotifyLibraryPrevPage() {
if (spotifyLibraryPage > 0) {
spotifyLibraryPage--;
loadSpotifyLibraryAlbums();
}
}
function spotifyLibraryNextPage() {
const totalPages = Math.ceil(spotifyLibraryTotal / SPOTIFY_LIBRARY_PAGE_SIZE);
if (spotifyLibraryPage < totalPages - 1) {
spotifyLibraryPage++;
loadSpotifyLibraryAlbums();
}
}
async function openSpotifyLibraryAlbumDownload(index) {
const album = spotifyLibraryAlbums[index];
if (!album) {
showToast('Album data not found', 'error');
return;
}
console.log(`\u{1F4E5} Opening download modal for Spotify library album: ${album.album_name}`);
showLoadingOverlay(`Loading tracks for ${album.album_name}...`);
try {
const _params = new URLSearchParams({ name: album.album_name || '', artist: album.artist_name || '' });
const response = await fetch(`/api/discover/album/spotify/${album.spotify_album_id}?${_params}`);
if (!response.ok) throw new Error('Failed to fetch album tracks');
const albumData = await response.json();
if (!albumData.tracks || albumData.tracks.length === 0) {
throw new Error('No tracks found in album');
}
const spotifyTracks = albumData.tracks.map(track => {
let artists = track.artists || albumData.artists || [{ name: album.artist_name }];
if (Array.isArray(artists)) {
artists = artists.map(a => a.name || a);
}
return {
id: track.id,
name: track.name,
artists: artists,
album: {
id: albumData.id,
name: albumData.name,
album_type: albumData.album_type || 'album',
total_tracks: albumData.total_tracks || 0,
release_date: albumData.release_date || '',
images: albumData.images || []
},
duration_ms: track.duration_ms || 0,
track_number: track.track_number || 0
};
});
const virtualPlaylistId = `spotify_library_${album.spotify_album_id}`;
const artistContext = {
id: album.artist_id,
name: album.artist_name,
source: 'spotify'
};
const albumContext = {
id: albumData.id,
name: albumData.name,
album_type: albumData.album_type || 'album',
total_tracks: albumData.total_tracks || 0,
release_date: albumData.release_date || '',
images: albumData.images || []
};
await openDownloadMissingModalForYouTube(virtualPlaylistId, albumData.name, spotifyTracks, artistContext, albumContext);
hideLoadingOverlay();
} catch (error) {
console.error('Error opening Spotify library album download:', error);
showToast(`Failed to load album: ${error.message}`, 'error');
hideLoadingOverlay();
}
}
async function refreshSpotifyLibraryCache() {
try {
showToast('Refreshing Spotify library...', 'info');
const response = await fetch('/api/discover/spotify-library/refresh', { method: 'POST' });
const data = await response.json();
if (data.success) {
showToast('Spotify library refresh started — will update shortly', 'success');
// Reload after a delay to let the sync run
setTimeout(() => loadSpotifyLibrarySection(), 10000);
} else {
showToast(`Error: ${data.error}`, 'error');
}
} catch (error) {
showToast(`Error: ${error.message}`, 'error');
}
}
async function downloadMissingSpotifyLibraryAlbums() {
// Fetch all missing albums (no pagination limit)
try {
const response = await fetch('/api/discover/spotify-library?status=missing&limit=500&offset=0');
const data = await response.json();
if (!data.success || !data.albums || data.albums.length === 0) {
showToast('No missing albums to download', 'info');
return;
}
const missing = data.albums.filter(a => !a.in_library);
if (missing.length === 0) {
showToast('All albums are already in your library!', 'success');
return;
}
if (!confirm(`Download ${missing.length} missing album${missing.length > 1 ? 's' : ''} from your Spotify library?`)) {
return;
}
showToast(`Starting download for ${missing.length} albums...`, 'info');
// Download one at a time to avoid overwhelming the system
for (let i = 0; i < missing.length; i++) {
const album = missing[i];
try {
showToast(`Queuing ${i + 1}/${missing.length}: ${album.album_name}`, 'info');
const _params = new URLSearchParams({ name: album.album_name || '', artist: album.artist_name || '' });
const response = await fetch(`/api/discover/album/spotify/${album.spotify_album_id}?${_params}`);
if (!response.ok) continue;
const albumData = await response.json();
if (!albumData.tracks || albumData.tracks.length === 0) continue;
const spotifyTracks = albumData.tracks.map(track => {
let artists = track.artists || albumData.artists || [{ name: album.artist_name }];
if (Array.isArray(artists)) artists = artists.map(a => a.name || a);
return {
id: track.id,
name: track.name,
artists: artists,
album: {
id: albumData.id,
name: albumData.name,
album_type: albumData.album_type || 'album',
total_tracks: albumData.total_tracks || 0,
release_date: albumData.release_date || '',
images: albumData.images || []
},
duration_ms: track.duration_ms || 0,
track_number: track.track_number || 0
};
});
const virtualPlaylistId = `spotify_library_${album.spotify_album_id}`;
await openDownloadMissingModalForYouTube(virtualPlaylistId, albumData.name, spotifyTracks, {
id: album.artist_id, name: album.artist_name, source: 'spotify'
}, {
id: albumData.id, name: albumData.name, album_type: albumData.album_type || 'album',
total_tracks: albumData.total_tracks || 0, release_date: albumData.release_date || '',
images: albumData.images || []
});
} catch (err) {
console.error(`Error downloading album ${album.album_name}:`, err);
}
}
} catch (error) {
console.error('Error downloading missing Spotify library albums:', error);
showToast(`Error: ${error.message}`, 'error');
}
}
async function loadDiscoverReleaseRadar() {
try {

@ -1230,17 +1230,6 @@ const HELPER_CONTENT = {
description: 'Click to browse tracks in this genre from your discovery pool.',
},
// Spotify Library
'#spotify-library-section': {
title: 'Your Spotify Library',
description: 'Albums saved in your Spotify account. Browse, search, and download albums you\'ve saved on Spotify but don\'t have locally.',
tips: [
'Search and filter by status (All/Missing/Owned)',
'Sort by date saved, artist, album name, or release date',
'"Download Missing" downloads all albums not in your library'
],
},
// Playlist Sync/Download buttons (generic — matches all discover playlist sections)
'.discover-section-actions .action-button.primary': {
title: 'Sync to Media Server',
@ -2485,7 +2474,6 @@ const HELPER_TOURS = {
{ page: 'discover', selector: '#discover-hero-view-all', title: 'View All Recommendations', description: 'Opens a modal with all recommended artists at once. "Watch All" adds every recommended artist to your watchlist in one click.' },
// Content sections (top to bottom)
{ page: 'discover', selector: '#spotify-library-section', title: 'Your Spotify Library', description: 'If Spotify is connected, this shows all your saved albums. Filter by Missing/Owned, sort by date, and click "Download Missing" to grab everything you don\'t have yet. Only visible with Spotify connected.' },
{ page: 'discover', selector: '#recent-releases-carousel', title: 'Recent Releases', description: 'New music from artists in your watchlist. Album cards show cover art — click any to open the download modal. Updates automatically when watchlist scans find new releases.' },
{ page: 'discover', selector: '#seasonal-albums-section', title: 'Seasonal Content', description: 'Season-aware sections that appear automatically — Christmas albums in December, summer vibes in July. Includes curated albums and a Seasonal Mix playlist you can sync to your server.' },
@ -3444,6 +3432,7 @@ const WHATS_NEW = {
'2.4.2': [
// --- post-2.4.1 dev work — entries hidden by _getLatestWhatsNewVersion until the build version bumps ---
{ date: 'Unreleased — 2.4.2 dev cycle' },
{ title: 'Drop Redundant "Your Spotify Library" Section on Discover', desc: 'discover page used to show two near-identical sections: "Your Albums" (cross-source aggregator across spotify/deezer/etc) AND "Your Spotify Library" (spotify-only). same UI, same grid, same filter / sort / download-missing controls — the spotify-only one was a strict subset of what your albums already covers. removed it. spotify saved albums still surface via the your albums section with spotify as one of its configured sources (gear button → configure sources). backend collection / storage is unchanged — the watchlist scanner still populates the spotify_library_albums cache for your albums to read.', page: 'discover' },
{ title: 'Library Disk Usage on Stats Page', desc: 'discord request (samuel [KC]): show how much disk space the library takes. new card on stats → system statistics shows total bytes + per-format breakdown (FLAC vs MP3 vs M4A bars). data comes from `tracks.file_size` populated during deep scan from whatever the media server already returns (plex MediaPart.size, jellyfin MediaSources[].Size, navidrome song.size, soulsync standalone os.path.getsize) — zero filesystem walk overhead. existing libraries see "Run a Deep Scan to populate" until the next deep scan fills in sizes; partial coverage shown as "X tracks measured (+Y pending)". migration is additive (NULL on legacy rows) so upgrading users have nothing to do.', page: 'stats' },
{ title: 'Fix: ReplayGain Wrote Same +52 dB Gain to Every Track', desc: 'noticed every downloaded track came out with `replaygain_track_gain: +52.00 dB` regardless of actual loudness. cause: parser used `re.search` which returned the FIRST `I:` (integrated loudness) reading from ffmpeg\'s ebur128 output. that\'s the per-window measurement at t=0.5s — almost always ~-70 LUFS because tracks start with silence/encoder padding. -18 (RG2 reference) - (-70) = +52 dB on every track. fix: parser now anchors to the `Summary:` block at the end of ffmpeg\'s output and reads the actual integrated loudness from there, not the silent-intro partial. defensive fallback uses the LAST per-window reading if Summary is missing (still better than the first). gains now reflect real per-track loudness.', page: 'downloads' },
{ title: 'Fix: Tracks Showed Completed When File Was Quarantined', desc: 'caught downloading kendrick mr morale: three tracks (rich interlude, savior interlude, savior) showed ✅ completed in the modal but were missing on disk. two layered bugs. (1) the post-process verification wrapper had a fallback that assumed success when no `_final_processed_path` was in context — but integrity-rejected files (which get quarantined instead of moved) leave that path unset, so the wrapper marked them complete. now wrapper explicitly checks `_integrity_failure_msg` and `_race_guard_failed` markers before the assume-success fallback. failed integrity = task marked failed, batch tracker notified with success=false. (2) acoustid skip-logic was too lenient — when fingerprint confidence was very high and either title OR artist matched a bit, it skipped verification with reason "likely same song in different language/script." that fired for english-vs-english by the same artist with the word "interlude" in both — same artist + 0.55 title sim = skip = wrong file accepted. tightened: skip now requires non-ASCII chars present (real language/script case) AND artist match, OR very high title similarity (≥0.80) AND artist match. english-vs-english with very different titles by same artist no longer skipped — verification correctly returns FAIL and the wrong file gets quarantined.', page: 'downloads' },

Loading…
Cancel
Save