diff --git a/pr_description.md b/pr_description.md index da9aeb6c..510794f5 100644 --- a/pr_description.md +++ b/pr_description.md @@ -1,56 +1,57 @@ ## Summary -Adds a new **Manual Library Match** tool that lets users map a wishlist/sync source track to an existing library track. Once saved, SoulSync can treat that source track as already owned/found instead of repeatedly trying to download it. +Adds source-aware artist detail deep links so artist pages can be opened directly as `/artist-detail/:source/:id`, including metadata-source artists from Spotify, Deezer, iTunes, Discogs, Amazon, Hydrabase, and existing library artists. + +This also fixes Discover/download modal artist links that were falling back to `/artist-detail/library/` or using the wrong album/card ID as an artist ID. ## What Changed -- Added a centralized Manual Library Match tool on the Tools page and a shortcut from the Sync page. -- Added side-by-side source-track and library-track search UI, plus an existing matches table with removal support. -- Added `manual_library_track_matches` persistence with profile/source/source-track scoping. -- Added backend search/list/save/delete endpoints for manual matches. -- Integrated manual matches into download analysis so matched source tracks are marked found and skipped. -- Preserved the distinction between wishlist internal force mode and explicit user Force Download All: - - Wishlist/internal `force_download_all` still honors manual matches. - - User-facing Force Download All ignores manual matches and downloads anyway. -- Updated wishlist cleanup so manual matches are removed from the wishlist through: - - manual wishlist cleanup, - - automatic cleanup after DB update, - - wishlist download analysis. -- Prevented manually matched source tracks from being re-added to the wishlist in the common database add path. -- Polished the Tools page card so Manual Library Match visually matches the Discovery Pool tool card. +- Added canonical artist detail routes in the SPA: + - `/artist-detail/library/` + - `/artist-detail/spotify/` + - `/artist-detail/deezer/` + - `/artist-detail/itunes/` + - plus other supported metadata sources. +- Preserved legacy `/artist-detail/` behavior as a library fallback. +- Updated shell routing and deep-link activation so refresh/direct navigation works for nested artist-detail URLs. +- Updated artist detail navigation to carry `artistSource` through the SPA instead of relying only on artist name/id. +- Improved source-only artist detail loading so provider-fetched artist names are used when the URL only contains the source ID. +- Prevented source-only artist pages from running library-only ownership/enhancement checks. +- Treats unknown ownership on source-only discographies as missing/clickable instead of leaving cards stuck on "still checking ownership." +- Uses release artwork as a generic artist-detail hero fallback when an artist portrait is missing or fails to load. +- Preserved source/artist IDs from Discover album modals, seasonal albums, cached discovery albums, recent releases, and download modal hero links. +- Prevented modal artist links from falling back to fake library routes when a real source artist ID is unavailable. +- Returned seasonal album `source` from cached seasonal album rows so seasonal modal links retain their provider context. ## Behavior -- Manual match saved: - - `source + source_track_id + profile_id -> library_track_id` -- Wishlist cleanup: - - removes the item if a manual match exists. -- Wishlist add: - - skips inserting the item if a manual match already exists, returning a harmless success/no-op. -- Wishlist download: - - checks manual match first, marks found, skips download, and attempts wishlist removal. -- Normal download with Force Download All off: - - checks manual match before normal library matching. -- Normal download with Force Download All on: - - skips manual matches and downloads selected tracks intentionally. +- Clicking a Spotify artist result can now land on: + - `/artist-detail/spotify/2YZyLoL8N0Wb9xBt1NhZWg` +- Clicking a Deezer artist result can now land on: + - `/artist-detail/deezer/525046` +- Existing library artist links continue to resolve through the library path. +- If a source artist resolves to an existing library artist, the page upgrades to the library-backed artist and keeps library-only tools/checks available. +- If a source artist is not in the library, the page shows source discography as missing/clickable and skips library-only endpoints. +- If a modal lacks a trustworthy source artist ID, it shows a warning instead of navigating to an invalid library artist URL. ## Tests -Verified by user: +Frontend route tests: ```bash -./.venv/bin/python -m pytest tests/test_manual_library_match.py tests/downloads/test_downloads_master.py +cd webui +npm.cmd test -- --run src/platform/shell/route-manifest.test.ts src/platform/shell/bridge.test.ts ``` Result: ```text -43 passed in 10.29s +2 test files passed +10 tests passed ``` -Additional local verification: +Recommended backend verification: -```text -py_compile passed for touched Python files -node --check passed for touched JS +```bash +./.venv/bin/python -m pytest tests/test_spa_deep_linking.py tests/metadata/test_artist_source_detail.py ``` diff --git a/webui/static/library.js b/webui/static/library.js index e1ea24d3..19818b16 100644 --- a/webui/static/library.js +++ b/webui/static/library.js @@ -1349,15 +1349,34 @@ function updateArtistHeaderStats(albumCount, trackCount) { console.log("📊 Using new hero section instead of old header stats"); } +function _isUsableArtistHeroImageUrl(url) { + return typeof url === 'string' && url.trim() !== '' && url !== 'null'; +} + +function _getArtistHeroReleaseImage(discography) { + for (const bucket of ['albums', 'eps', 'singles']) { + for (const release of (discography?.[bucket] || [])) { + if (_isUsableArtistHeroImageUrl(release?.image_url)) { + return release.image_url; + } + } + } + return ''; +} + function updateArtistHeroSection(artist, discography) { console.log("🖼️ Updating artist hero section"); + const artistImageUrl = _isUsableArtistHeroImageUrl(artist.image_url) ? artist.image_url : ''; + const releaseImageUrl = _getArtistHeroReleaseImage(discography); + const primaryHeroImageUrl = artistImageUrl || releaseImageUrl; + // Blurred background image (inline-Artists hero treatment) — set whenever // we have an image_url; falls back to clearing the bg if not. const heroBg = document.getElementById("artist-detail-hero-bg"); if (heroBg) { - if (artist.image_url && artist.image_url.trim() !== "" && artist.image_url !== "null") { - heroBg.style.backgroundImage = `url('${artist.image_url}')`; + if (primaryHeroImageUrl) { + heroBg.style.backgroundImage = `url('${primaryHeroImageUrl}')`; } else { heroBg.style.backgroundImage = ''; } @@ -1374,9 +1393,11 @@ function updateArtistHeroSection(artist, discography) { console.log(` - Image element:`, imageElement); console.log(` - Fallback element:`, fallbackElement); - if (artist.image_url && artist.image_url.trim() !== "" && artist.image_url !== "null") { - console.log(`✅ Setting image src to: ${artist.image_url}`); - imageElement.src = artist.image_url; + if (primaryHeroImageUrl) { + console.log(`✅ Setting image src to: ${primaryHeroImageUrl}`); + imageElement.dataset.triedDeezer = ''; + imageElement.dataset.triedReleaseFallback = artistImageUrl ? '' : 'true'; + imageElement.src = primaryHeroImageUrl; imageElement.alt = artist.name; imageElement.style.display = "block"; if (fallbackElement) { @@ -1388,11 +1409,14 @@ function updateArtistHeroSection(artist, discography) { }; imageElement.onerror = () => { - console.error(`❌ Failed to load artist image: ${artist.image_url}`); - // Try Deezer fallback before emoji + console.error(`❌ Failed to load artist image: ${imageElement.src}`); + // Try Deezer fallback, then release art, before the generic icon. if (artist.deezer_id && !imageElement.dataset.triedDeezer) { imageElement.dataset.triedDeezer = 'true'; imageElement.src = `https://api.deezer.com/artist/${artist.deezer_id}/image?size=big`; + } else if (releaseImageUrl && imageElement.src !== releaseImageUrl && !imageElement.dataset.triedReleaseFallback) { + imageElement.dataset.triedReleaseFallback = 'true'; + imageElement.src = releaseImageUrl; } else { imageElement.style.display = "none"; if (fallbackElement) {