diff --git a/CLAUDE.md b/CLAUDE.md index da9c58eb..e5c9495f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,7 +112,7 @@ Playlist sort dropdown (custom / title / artist / album / recently added / durat ### Integrations ([`docs/features/integrations.md`](docs/features/integrations.md)) -Deezer enrichment (pictures, covers, fans — cached 30 days in `metadata_artist` / `metadata_album` in `app.db`, hashes point into shared `metadata_artwork/.jpg` so artwork renders offline; **Web Radio now-playing artwork** is the one Deezer path that is NOT disk-cached — [`fetch_radio_artwork`](src-tauri/crates/app/src/commands/deezer.rs) resolves an album cover URL from the ICY `Artist - Title` via `search_track` and returns the remote CDN link directly because a radio now-playing line is ephemeral; `PlayerContext` swaps it into `currentTrack.artwork_path` over the station favicon, token + `isRadioTrack`-guarded so a stale fetch or a library track that started meanwhile is never clobbered) · artist bio source selector (Settings → Integrations: **Last.fm** default vs **TheAudioDB** multi-language via [`metadata::theaudiodb`](src-tauri/crates/core/src/metadata/theaudiodb.rs), `app_setting['metadata.bio_source']` + `['metadata.bio_language']`; `enrich_artist_deezer` branches on it and stores `bio_source`/`bio_language` in `metadata_artist` so a switch invalidates the cached bio) · **wide artist fanart** (issue #482 — the same TheAudioDB `search.php` response carries `strArtistFanart*` / `strArtistWideThumb` / `strArtistBanner`, so `TheAudioDbClient::artist_info` returns bio **and** `fanart_url` from ONE lookup; `enrich_artist_deezer` calls it **regardless of `metadata.bio_source`** because Last.fm has no equivalent image and gating it would leave Last.fm users with no artist hero at all, cached in `metadata_artist.background_{url,hash}` + `background_fetched_at` — the "we already looked" marker without which a NULL hash is indistinguishable from "never queried" and every fanart-less artist would re-hit a rate-limited API per page visit; stamped when the API was *reached*, left NULL on a transport error so a blip retries instead of caching for the 30-day TTL) · Last.fm (bios, similar artists with Deezer picture backfill — Last.fm's `artist.getSimilar` returns generic star placeholders for every image since their artist-image API was killed in 2019, so [`similar::enrich_with_deezer_pictures`](src-tauri/crates/app/src/commands/similar.rs) joins the result against `app.metadata_artist` and fans out parallel Deezer `search_artist` calls for any cache miss before responding; scrobbler) · **per-artist offline overrides** (issue #323: `artist.custom_bio` + library-scoped `artist_similar_custom` table, both per-profile; [`commands/artist_overrides.rs`](src-tauri/crates/app/src/commands/artist_overrides.rs) write/read commands, edited from Artist Detail → "Edit info" [`ArtistMetadataEditorModal`](src/components/common/ArtistMetadataEditorModal.tsx); `enrich_artist_deezer` swaps the custom bio onto the returned payload and `get_similar_artists` short-circuits to the curated list before any cache/network, so both work offline and survive enrichment passes) · Discord RPC · Native OS track-change toast notifications ([`notifications.rs`](src-tauri/crates/app/src/notifications.rs) — `tauri-plugin-notification` bridge to Windows Action Center / macOS Notification Center / libnotify, opt-in `app_setting['notifications.track_change']` default OFF) · DLNA / UPnP MediaServer ([`docs/features/dlna.md`](docs/features/dlna.md)) · MPD protocol server ([`docs/features/mpd.md`](docs/features/mpd.md) — opt-in LAN control surface for existing MPD clients; control + queue mutation only, library browsing deliberately out of scope for v1) · **offline Web Radio catalogue** ([`commands/web_radio_catalogue.rs`](src-tauri/crates/app/src/commands/web_radio_catalogue.rs)) — the `web-radio` WASM plugin queries radio-browser live and can't host SQLite, so the offline catalogue is a NATIVE side path: `download_radio_catalogue` snapshots the ~35k-station directory into an app.db `radio_station` table + contentless FTS5 index (user-triggered from Settings → Data), and `resolve_radio_catalogue` answers the SAME opaque query tokens as the plugin (`top` / `tag:x` / `country:xx` / free text) returning the SAME `PluginTrack` shape. [`WebRadioView`](src/components/views/WebRadioView.tsx) routes browse/search through it when `offline_mode` is on OR the `radio.catalogue.local_first` setting is enabled with a catalogue present; the stream url rides inside the track id (`url:`) so `plugin_stream_url` + playback stay network-free regardless. +Deezer enrichment (pictures, covers, fans — cached 30 days in `metadata_artist` / `metadata_album` in `app.db`, hashes point into shared `metadata_artwork/.jpg` so artwork renders offline; **album covers are only disk-cached when the local album lacks its own art** (`album.artwork_id IS NULL`, issue #493) — the album page's auto-enrich (label/release-date only) and Discord (remote `cover_url`) never show the downloaded file, so caching it for albums the user already has covers for just bloated the shared cache; **Web Radio now-playing artwork** is the one Deezer path that is NOT disk-cached — [`fetch_radio_artwork`](src-tauri/crates/app/src/commands/deezer.rs) resolves an album cover URL from the ICY `Artist - Title` via `search_track` and returns the remote CDN link directly because a radio now-playing line is ephemeral; `PlayerContext` swaps it into `currentTrack.artwork_path` over the station favicon, token + `isRadioTrack`-guarded so a stale fetch or a library track that started meanwhile is never clobbered) · artist bio source selector (Settings → Integrations: **Last.fm** default vs **TheAudioDB** multi-language via [`metadata::theaudiodb`](src-tauri/crates/core/src/metadata/theaudiodb.rs), `app_setting['metadata.bio_source']` + `['metadata.bio_language']`; `enrich_artist_deezer` branches on it and stores `bio_source`/`bio_language` in `metadata_artist` so a switch invalidates the cached bio) · **wide artist fanart** (issue #482 — the same TheAudioDB `search.php` response carries `strArtistFanart*` / `strArtistWideThumb` / `strArtistBanner`, so `TheAudioDbClient::artist_info` returns bio **and** `fanart_url` from ONE lookup; `enrich_artist_deezer` calls it **regardless of `metadata.bio_source`** because Last.fm has no equivalent image and gating it would leave Last.fm users with no artist hero at all, cached in `metadata_artist.background_{url,hash}` + `background_fetched_at` — the "we already looked" marker without which a NULL hash is indistinguishable from "never queried" and every fanart-less artist would re-hit a rate-limited API per page visit; stamped when the API was *reached*, left NULL on a transport error so a blip retries instead of caching for the 30-day TTL) · Last.fm (bios, similar artists with Deezer picture backfill — Last.fm's `artist.getSimilar` returns generic star placeholders for every image since their artist-image API was killed in 2019, so [`similar::enrich_with_deezer_pictures`](src-tauri/crates/app/src/commands/similar.rs) joins the result against `app.metadata_artist` and fans out parallel Deezer `search_artist` calls for any cache miss before responding; scrobbler) · **per-artist offline overrides** (issue #323: `artist.custom_bio` + library-scoped `artist_similar_custom` table, both per-profile; [`commands/artist_overrides.rs`](src-tauri/crates/app/src/commands/artist_overrides.rs) write/read commands, edited from Artist Detail → "Edit info" [`ArtistMetadataEditorModal`](src/components/common/ArtistMetadataEditorModal.tsx); `enrich_artist_deezer` swaps the custom bio onto the returned payload and `get_similar_artists` short-circuits to the curated list before any cache/network, so both work offline and survive enrichment passes) · Discord RPC · Native OS track-change toast notifications ([`notifications.rs`](src-tauri/crates/app/src/notifications.rs) — `tauri-plugin-notification` bridge to Windows Action Center / macOS Notification Center / libnotify, opt-in `app_setting['notifications.track_change']` default OFF) · DLNA / UPnP MediaServer ([`docs/features/dlna.md`](docs/features/dlna.md)) · MPD protocol server ([`docs/features/mpd.md`](docs/features/mpd.md) — opt-in LAN control surface for existing MPD clients; control + queue mutation only, library browsing deliberately out of scope for v1) · **offline Web Radio catalogue** ([`commands/web_radio_catalogue.rs`](src-tauri/crates/app/src/commands/web_radio_catalogue.rs)) — the `web-radio` WASM plugin queries radio-browser live and can't host SQLite, so the offline catalogue is a NATIVE side path: `download_radio_catalogue` snapshots the ~35k-station directory into an app.db `radio_station` table + contentless FTS5 index (user-triggered from Settings → Data), and `resolve_radio_catalogue` answers the SAME opaque query tokens as the plugin (`top` / `tag:x` / `country:xx` / free text) returning the SAME `PluginTrack` shape. [`WebRadioView`](src/components/views/WebRadioView.tsx) routes browse/search through it when `offline_mode` is on OR the `radio.catalogue.local_first` setting is enabled with a catalogue present; the stream url rides inside the track id (`url:`) so `plugin_stream_url` + playback stay network-free regardless. ### Preferences & maintenance diff --git a/docs/features/integrations.md b/docs/features/integrations.md index 1bdcf6c2..e9686b17 100644 --- a/docs/features/integrations.md +++ b/docs/features/integrations.md @@ -14,6 +14,8 @@ A single global toggle — Settings → Intégrations → "Mode hors-ligne" — - Album covers (`enrich_album_deezer`, `search_albums_deezer`, `set_album_artwork_from_deezer`, `batch_fetch_missing_album_covers`) - Label / fan-count metadata +> **Album cover disk-caching is gated on the album lacking local art (issue #493).** `enrich_album_inner` fires automatically on every album-page open (which only reads `label` + `release_date`) and from the Discord presence (which reads the remote `cover_url`); neither displays the downloaded file, and the album grid / detail header render the *local* artwork. So the cover image is written to the shared `metadata_artwork` cache **only when `album.artwork_id IS NULL`** — otherwise the cache filled with never-shown Deezer covers for albums the user already had covers for. The remote `cover_url` still rides through for Discord + the cache row, and the deliberate `batch_fetch_missing_album_covers` (which iterates only `artwork_id IS NULL` albums) is unaffected. + Results are cached in the `deezer_artist` / `deezer_album` tables of the **shared** `app.db` (one cache across every profile) with a 30-day `expires_at` TTL. Cache-first: zero network round-trips when the row is fresh. Failures are non-fatal — the UI degrades to local-only artwork and an empty enrichment payload. **Auto-enrichment on play.** [`PlayerProvider`](../../src/contexts/PlayerContext.tsx) fires `enrich_artist_deezer(currentTrack.artist_id)` (fire-and-forget) on every track-change. Cache hits are ~10 ms so the duplicate call done by `NowPlayingPanel` when it renders is harmless; the point is to populate the cache for views the user _isn't_ looking at right now (e.g. the artist grid in `LibraryView`) so a tile gets its picture as soon as the user plays one of that artist's tracks, regardless of whether the Now Playing panel is open. diff --git a/src-tauri/crates/app/src/commands/deezer.rs b/src-tauri/crates/app/src/commands/deezer.rs index bc16c049..8841e7bf 100644 --- a/src-tauri/crates/app/src/commands/deezer.rs +++ b/src-tauri/crates/app/src/commands/deezer.rs @@ -87,6 +87,18 @@ pub async fn enrich_album_deezer( enrich_album_inner(&pool, &artwork_dir, album_id).await } +/// Whether a fresh cached `metadata_album` row is a usable hit **for the needs +/// of the album being enriched**. A row with no `cover_hash` still counts as +/// complete when the local album has its own artwork (it will never need the +/// Deezer cover). But for an art-less album a cover-less row is *incomplete* — +/// it must trigger a re-fetch instead of serving a permanent miss. Without this +/// the #493 download-skip would poison the shared cache: a fresh cover-less row +/// (art removed, a different profile sharing the cache, or a prior failed +/// download) would block the cover from ever being fetched until the TTL lapsed. +fn metadata_album_cache_complete(cover_hash: Option<&str>, has_local_art: bool) -> bool { + cover_hash.is_some() || has_local_art +} + pub(crate) async fn enrich_album_inner( pool: &SqlitePool, artwork_dir: &Path, @@ -94,9 +106,10 @@ pub(crate) async fn enrich_album_inner( ) -> AppResult { let now = now_ms(); - // 1. Read the local album + its existing deezer_id. - let local: Option<(String, Option, Option)> = sqlx::query_as( - "SELECT al.title, ar.name, al.deezer_id + // 1. Read the local album + its existing deezer_id + whether it already + // has local artwork (issue #493 — see the cover-download guard below). + let local: Option<(String, Option, Option, Option)> = sqlx::query_as( + "SELECT al.title, ar.name, al.deezer_id, al.artwork_id FROM album al LEFT JOIN artist ar ON ar.id = al.artist_id WHERE al.id = ?", ) @@ -104,7 +117,7 @@ pub(crate) async fn enrich_album_inner( .fetch_optional(pool) .await?; - let Some((album_title, artist_name, existing_deezer_id)) = local else { + let Some((album_title, artist_name, existing_deezer_id, local_artwork_id)) = local else { return Ok(DeezerAlbumEnrichment::empty()); }; @@ -125,7 +138,15 @@ pub(crate) async fn enrich_album_inner( .await?; if let Some((label, release_date, cover_url, cover_hash, expires_at)) = cached { - if expires_at > now { + // A fresh row is a usable hit only when it's also complete for this + // album's needs — otherwise a cover-less row for an art-less album + // would block a re-fetch until the TTL lapsed (issue #493). + let usable = expires_at > now + && metadata_album_cache_complete( + cover_hash.as_deref(), + local_artwork_id.is_some(), + ); + if usable { let cover_path = cover_hash .as_deref() .and_then(|h| metadata_artwork::existing_path(artwork_dir, h)); @@ -187,10 +208,21 @@ pub(crate) async fn enrich_album_inner( let cover_url = hit.cover_xl.clone().or_else(|| hit.cover_big.clone()); - // 4. Download artwork into the shared cache (best-effort). - let cover_hash = match cover_url.as_deref() { - Some(url) => metadata_artwork::download_and_cache(url, artwork_dir).await, - None => None, + // 4. Download artwork into the shared cache (best-effort) — but ONLY for an + // album that has NO local cover of its own (issue #493). This function is + // fired automatically every time an album page opens (which only reads + // `label` + `release_date`) and by the Discord presence (which reads the + // remote `cover_url`); neither uses the downloaded file, and the album + // grid / detail header render the LOCAL artwork. Without this guard the + // shared `metadata_artwork` cache filled up with Deezer covers for albums + // the user already has artwork for — never displayed. The deliberate + // paths still work: `batch_fetch_missing_album_covers` only iterates + // `artwork_id IS NULL` albums, and a genuinely cover-less album still + // gets its fallback. `cover_url` always rides through for Discord + the + // cache row regardless. + let cover_hash = match (local_artwork_id.is_none(), cover_url.as_deref()) { + (true, Some(url)) => metadata_artwork::download_and_cache(url, artwork_dir).await, + _ => None, }; let cover_path = cover_hash .as_deref() @@ -210,7 +242,13 @@ pub(crate) async fn enrich_album_inner( title = excluded.title, release_date = excluded.release_date, cover_url = excluded.cover_url, - cover_hash = excluded.cover_hash, + -- Only overwrite the cached hash on a NEW successful download. + -- `excluded.cover_hash` is NULL when the download was skipped + -- (art-having album, #493) or failed transiently — in both cases + -- keep whatever cover was already cached rather than dropping a good + -- one over a network blip. The #493 cleanup is what deliberately + -- clears art-having albums' covers, not this best-effort upsert. + cover_hash = COALESCE(excluded.cover_hash, cover_hash), label = excluded.label, fetched_at = excluded.fetched_at, expires_at = excluded.expires_at", @@ -1172,3 +1210,23 @@ async fn download_image_bytes(url: &str) -> AppResult> { } Ok(bytes) } + +#[cfg(test)] +mod tests { + use super::metadata_album_cache_complete; + + #[test] + fn album_cache_completeness_gates_refetch() { + // Art-less album (`has_local_art = false`): only a cached cover makes + // the row usable. A cover-less fresh row must NOT be served — it has to + // re-fetch (art was removed, another profile shares the cache, or a + // prior download failed), which is the #493 regression this guards. + assert!(metadata_album_cache_complete(Some("hash"), false)); + assert!(!metadata_album_cache_complete(None, false)); + + // Art-having album never needs the Deezer cover, so a cover-less row is + // a complete hit — no wasteful re-download every time the page opens. + assert!(metadata_album_cache_complete(None, true)); + assert!(metadata_album_cache_complete(Some("hash"), true)); + } +} diff --git a/src-tauri/crates/app/src/commands/maintenance.rs b/src-tauri/crates/app/src/commands/maintenance.rs index 4aefef20..bbb4d19f 100644 --- a/src-tauri/crates/app/src/commands/maintenance.rs +++ b/src-tauri/crates/app/src/commands/maintenance.rs @@ -1,12 +1,14 @@ //! Maintenance commands. Bulk operations a user can trigger from the //! Settings screen (regenerate thumbnails, prune orphan covers, …). +use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::atomic::Ordering; use std::sync::Arc; use std::time::Duration; use chrono::Utc; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; use tauri::AppHandle; use crate::{ @@ -55,6 +57,164 @@ pub async fn regenerate_thumbnails(state: tauri::State<'_, AppState>) -> AppResu Ok(total) } +/// Result of [`prune_cached_album_covers`] — surfaced to the Settings UI. +#[derive(Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AlbumCoverCleanup { + /// `metadata_album` rows whose `cover_hash` was cleared. + pub rows_cleared: u64, + /// Cache files (`.jpg` + `_1x`/`_2x` thumbnails) deleted. + pub files_deleted: u64, + /// Bytes reclaimed on disk. + pub bytes_freed: u64, +} + +/// One-time cleanup companion to issue #493: drop the Deezer album covers the +/// shared cache accumulated for albums that already have their own local +/// artwork. Those files were downloaded as a side effect of opening an album +/// page (which only reads `label`/`release_date`) and were never displayed. +/// +/// **Cross-profile safe.** The metadata cache is app-wide (one row per +/// `deezer_id`, shared across every profile), so a cover may be pruned only +/// when its album has local art in EVERY profile that carries it — an album +/// still art-less in *some* profile keeps its cover so that profile isn't left +/// blank (offline especially). And the cache is content-addressed + shared with +/// artist pictures, so a hash is unlinked from disk only once it is referenced +/// by NO remaining `metadata_album.cover_hash`, `metadata_artist.picture_hash` +/// or `background_hash`. The `UPDATE` + reference re-check run in a single +/// transaction for a consistent snapshot. +#[tauri::command] +pub async fn prune_cached_album_covers( + state: tauri::State<'_, AppState>, +) -> AppResult { + let artwork_dir = state.paths.metadata_artwork_dir.clone(); + + // 1. `deezer_id`s that must KEEP their cover: any album still WITHOUT local + // art in ANY profile. The cache is app-wide, so a single-profile view + // isn't enough — read every profile's `album` table. Open read-write but + // WITHOUT running migrations (no migrator call); a read-only open of a + // WAL db with no live writer can fail to create its `-shm`. + // Propagate a read failure — an empty list here would leave `needed` empty + // and mark EVERY cached cover prunable, mass-deleting covers other profiles + // still need. Bail instead. + let profile_ids: Vec = sqlx::query_scalar("SELECT id FROM profile") + .fetch_all(&state.app_db) + .await?; + let mut needed: HashSet = HashSet::new(); + for pid in &profile_ids { + let path = state.paths.profile_db(*pid); + let opts = SqliteConnectOptions::new() + .filename(&path) + .create_if_missing(false); + let ppool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(opts) + .await + .map_err(|e| AppError::Other(format!("open profile {pid} db: {e}")))?; + let ids: Vec = sqlx::query_scalar( + "SELECT deezer_id FROM album WHERE artwork_id IS NULL AND deezer_id IS NOT NULL", + ) + .fetch_all(&ppool) + .await + .map_err(|e| AppError::Other(format!("read profile {pid} albums: {e}")))?; + needed.extend(ids); + ppool.close().await; + } + + // 2. Candidate covers: every cached album cover whose album is art-having in + // every profile that has it (its `deezer_id` is not in `needed`). Queried + // straight off `app.db`. + let rows: Vec<(i64, String)> = sqlx::query_as( + "SELECT deezer_id, cover_hash FROM metadata_album WHERE cover_hash IS NOT NULL", + ) + .fetch_all(&state.app_db) + .await?; + let prunable: Vec<(i64, String)> = rows + .into_iter() + .filter(|(did, _)| !needed.contains(did)) + .collect(); + if prunable.is_empty() { + return Ok(AlbumCoverCleanup { + rows_cleared: 0, + files_deleted: 0, + bytes_freed: 0, + }); + } + let prune_ids: Vec = prunable.iter().map(|(d, _)| *d).collect(); + let candidate_hashes: HashSet = prunable.into_iter().map(|(_, h)| h).collect(); + + // 3. Clear those covers and re-check remaining references in ONE + // transaction, so the reference set is consistent with the clear. + let mut tx = state.app_db.begin().await?; + let mut rows_cleared = 0u64; + for chunk in prune_ids.chunks(400) { + let placeholders = std::iter::repeat("?") + .take(chunk.len()) + .collect::>() + .join(","); + // SQL-safe: only bind placeholders are interpolated (never user data), + // mirroring the ATTACH statement in `db::profile_db`. + let sql = format!( + "UPDATE metadata_album SET cover_hash = NULL WHERE deezer_id IN ({placeholders})" + ); + let mut q = sqlx::query(sqlx::AssertSqlSafe(sql)); + for id in chunk { + q = q.bind(*id); + } + rows_cleared += q.execute(&mut *tx).await?.rows_affected(); + } + let referenced: HashSet = sqlx::query_scalar::<_, String>( + "SELECT cover_hash FROM metadata_album WHERE cover_hash IS NOT NULL + UNION SELECT picture_hash FROM metadata_artist WHERE picture_hash IS NOT NULL + UNION SELECT background_hash FROM metadata_artist WHERE background_hash IS NOT NULL", + ) + .fetch_all(&mut *tx) + .await? + .into_iter() + .collect(); + tx.commit().await?; + + let deletable: Vec = candidate_hashes + .into_iter() + .filter(|h| !referenced.contains(h)) + .collect(); + + // Blocking fs work off the runtime. + let (files_deleted, bytes_freed) = + tokio::task::spawn_blocking(move || delete_cover_files(&artwork_dir, &deletable)) + .await + .map_err(|e| AppError::Other(format!("prune_cached_album_covers join: {e}")))?; + + Ok(AlbumCoverCleanup { + rows_cleared, + files_deleted, + bytes_freed, + }) +} + +/// Delete `.jpg` + `_1x.jpg` + `_2x.jpg` for each hash, +/// summing the bytes actually reclaimed. Best-effort per file (a locked / +/// missing file is skipped, not fatal). +fn delete_cover_files(dir: &Path, hashes: &[String]) -> (u64, u64) { + let mut files_deleted = 0u64; + let mut bytes_freed = 0u64; + for hash in hashes { + for name in [ + format!("{hash}.jpg"), + format!("{hash}_1x.jpg"), + format!("{hash}_2x.jpg"), + ] { + let path = dir.join(name); + let len = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0); + if std::fs::remove_file(&path).is_ok() { + files_deleted += 1; + bytes_freed += len; + } + } + } + (files_deleted, bytes_freed) +} + /// Factory reset. Wipes every profile, library, playlist, cache and /// app-wide setting, then restarts the binary into a fresh /// onboarding flow. diff --git a/src-tauri/crates/app/src/lib.rs b/src-tauri/crates/app/src/lib.rs index 39261998..d7a345ce 100644 --- a/src-tauri/crates/app/src/lib.rs +++ b/src-tauri/crates/app/src/lib.rs @@ -875,6 +875,7 @@ pub fn run() { commands::stats::stats_listening_by_day, commands::stats::stats_listening_by_hour, commands::maintenance::regenerate_thumbnails, + commands::maintenance::prune_cached_album_covers, commands::maintenance::reset_app, commands::backup::get_backup_config, commands::backup::set_backup_config, diff --git a/src/components/views/SettingsView.tsx b/src/components/views/SettingsView.tsx index 965fe9cb..afa05432 100644 --- a/src/components/views/SettingsView.tsx +++ b/src/components/views/SettingsView.tsx @@ -130,6 +130,7 @@ import { useProfile } from "../../hooks/useProfile"; import { invoke } from "@tauri-apps/api/core"; import { regenerateThumbnails, + pruneCachedAlbumCovers, rescanLocalArtistImages, } from "../../lib/tauri/library"; import { @@ -1162,6 +1163,57 @@ export function SettingsView({ onNavigate }: SettingsViewProps) { } }, [isRegeneratingThumbs, t]); + const [isPruningCovers, setIsPruningCovers] = useState(false); + const [pruneCoversStatus, setPruneCoversStatus] = useState<{ + ok: boolean; + text: string; + } | null>(null); + // Auto-clear timer for the prune status — kept in a ref so a rapid re-run (or + // an unmount) cancels the previous one instead of clearing a newer result. + const pruneCoversTimeoutRef = useRef(null); + useEffect( + () => () => { + if (pruneCoversTimeoutRef.current != null) { + window.clearTimeout(pruneCoversTimeoutRef.current); + } + }, + [], + ); + + const handlePruneCachedCovers = useCallback(async () => { + if (isPruningCovers) return; + setIsPruningCovers(true); + setPruneCoversStatus(null); + try { + const r = await pruneCachedAlbumCovers(); + // Locale-aware one-decimal MB (e.g. "12,5" in fr) rather than the + // always-`.` `toFixed`. + const mb = new Intl.NumberFormat( + i18n.resolvedLanguage ?? i18n.language, + { minimumFractionDigits: 1, maximumFractionDigits: 1 }, + ).format(r.bytesFreed / (1024 * 1024)); + setPruneCoversStatus({ + ok: true, + text: t("settings.pruneAlbumCoversDone", { files: r.filesDeleted, mb }), + }); + } catch (err) { + console.error("[SettingsView] prune cached covers failed", err); + setPruneCoversStatus({ + ok: false, + text: t("settings.pruneAlbumCoversFailed"), + }); + } finally { + setIsPruningCovers(false); + if (pruneCoversTimeoutRef.current != null) { + window.clearTimeout(pruneCoversTimeoutRef.current); + } + pruneCoversTimeoutRef.current = window.setTimeout( + () => setPruneCoversStatus(null), + 5000, + ); + } + }, [isPruningCovers, t, i18n.language, i18n.resolvedLanguage]); + // Audio settings — hydrated from backend at mount. const [normalize, setNormalize] = useState(false); const [mono, setMono] = useState(false); @@ -3630,6 +3682,54 @@ export function SettingsView({ onNavigate }: SettingsViewProps) { + {/* Prune cached Deezer album covers (issue #493) — reclaim the + shared cache filled with covers for albums that already have + local artwork. */} +
+
+
+ +
+ {/* Profile export / import — packages the per-profile DB + manual artwork into a single .waveflow archive. Useful for backups + machine migration. Shared metadata cache diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 0e6bd125..c736a98e 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -1733,6 +1733,11 @@ "regenerateThumbnailsSubtitle": "إعادة بناء نسخ 1x/2x المفقودة لجميع الأغلفة", "regenerateThumbnailsAction": "إعادة التوليد", "regenerateThumbnailsDone": "تم إعادة توليد {{count}} صورة مصغرة", + "pruneAlbumCovers": "إزالة أغلفة ألبومات Deezer غير المستخدمة", + "pruneAlbumCoversSubtitle": "يحذف أغلفة Deezer المخزَّنة مؤقتًا للألبومات التي لديها غلافها الخاص (لا تُعرض أبدًا)", + "pruneAlbumCoversAction": "تنظيف", + "pruneAlbumCoversDone": "تم حذف {{files}} ملفات · تحرير {{mb}} ميغابايت", + "pruneAlbumCoversFailed": "فشل تنظيف الأغلفة", "reset": { "title": "إعادة ضبط التطبيق", "subtitle": "حذف جميع البيانات واستعادة إعدادات المصنع", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index c6120e16..910a3af5 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1594,6 +1594,11 @@ "regenerateThumbnailsSubtitle": "Fehlende 1x-/2x-Varianten für alle Cover neu erstellen", "regenerateThumbnailsAction": "Neu erzeugen", "regenerateThumbnailsDone": "{{count}} Vorschaubilder neu erzeugt", + "pruneAlbumCovers": "Ungenutzte Deezer-Albumcover entfernen", + "pruneAlbumCoversSubtitle": "Löscht zwischengespeicherte Deezer-Albumcover für Alben, die bereits ein eigenes Cover haben (nie angezeigt)", + "pruneAlbumCoversAction": "Entfernen", + "pruneAlbumCoversDone": "{{files}} Dateien gelöscht · {{mb}} MB freigegeben", + "pruneAlbumCoversFailed": "Cover-Bereinigung fehlgeschlagen", "reset": { "title": "App zurücksetzen", "subtitle": "Alle Daten löschen und auf Werkseinstellungen zurücksetzen", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index a5a64e43..816a1fd8 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1594,6 +1594,11 @@ "regenerateThumbnailsSubtitle": "Rebuild the missing 1x/2x variants for all covers", "regenerateThumbnailsAction": "Regenerate", "regenerateThumbnailsDone": "{{count}} thumbnails regenerated", + "pruneAlbumCovers": "Prune unused Deezer album covers", + "pruneAlbumCoversSubtitle": "Delete cached Deezer album covers for albums that already have their own artwork (never shown)", + "pruneAlbumCoversAction": "Prune", + "pruneAlbumCoversDone": "Deleted {{files}} files · freed {{mb}} MB", + "pruneAlbumCoversFailed": "Cover cleanup failed", "reset": { "title": "Reset the app", "subtitle": "Delete all data and restore to factory settings", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 7a243e48..f48d53f1 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1594,6 +1594,11 @@ "regenerateThumbnailsSubtitle": "Reconstruye las variantes 1x/2x que faltan para todas las portadas", "regenerateThumbnailsAction": "Regenerar", "regenerateThumbnailsDone": "{{count}} miniaturas regeneradas", + "pruneAlbumCovers": "Purgar carátulas de Deezer sin usar", + "pruneAlbumCoversSubtitle": "Elimina las carátulas de Deezer en caché de álbumes que ya tienen su propia carátula (nunca mostradas)", + "pruneAlbumCoversAction": "Purgar", + "pruneAlbumCoversDone": "{{files}} archivos eliminados · {{mb}} MB liberados", + "pruneAlbumCoversFailed": "Error al purgar las carátulas", "reset": { "title": "Restablecer la app", "subtitle": "Borra todos los datos y restablece la configuración de fábrica", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 84ae4ba2..3a815ac3 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1680,6 +1680,11 @@ "regenerateThumbnailsSubtitle": "Reconstruire les variantes 1x/2x manquantes pour toutes les pochettes", "regenerateThumbnailsAction": "Régénérer", "regenerateThumbnailsDone": "{{count}} vignettes régénérées", + "pruneAlbumCovers": "Purger les pochettes Deezer inutilisées", + "pruneAlbumCoversSubtitle": "Supprime les pochettes d'albums Deezer mises en cache pour des albums qui ont déjà leur propre pochette (jamais affichées)", + "pruneAlbumCoversAction": "Purger", + "pruneAlbumCoversDone": "{{files}} fichiers supprimés · {{mb}} Mo libérés", + "pruneAlbumCoversFailed": "Échec de la purge des pochettes", "reset": { "title": "Réinitialiser l'application", "subtitle": "Supprimer toutes les données et revenir à l'état initial", diff --git a/src/i18n/locales/hi.json b/src/i18n/locales/hi.json index 9e01e794..0a5b8f34 100644 --- a/src/i18n/locales/hi.json +++ b/src/i18n/locales/hi.json @@ -1615,6 +1615,11 @@ "regenerateThumbnailsSubtitle": "सभी कवर के लिए गायब 1x/2x वेरिएंट्स का पुनर्निर्माण करें।", "regenerateThumbnailsAction": "पुनर्निर्माण करें", "regenerateThumbnailsDone": "{{count}} थंबनेल पुनर्निर्मित", + "pruneAlbumCovers": "अप्रयुक्त Deezer एल्बम कवर हटाएँ", + "pruneAlbumCoversSubtitle": "उन एल्बमों के लिए कैश की गई Deezer कवर हटाता है जिनके पास पहले से अपना कवर है (कभी नहीं दिखाया गया)", + "pruneAlbumCoversAction": "हटाएँ", + "pruneAlbumCoversDone": "{{files}} फ़ाइलें हटाई गईं · {{mb}} MB मुक्त किए गए", + "pruneAlbumCoversFailed": "कवर सफ़ाई विफल", "reset": { "title": "ऐप को रीसेट करें", "subtitle": "सभी डेटा मिटाएँ और फ़ैक्टरी सेटिंग्स पर पुनर्स्थापित करें।", diff --git a/src/i18n/locales/id.json b/src/i18n/locales/id.json index 6e03064b..d7e9c32f 100644 --- a/src/i18n/locales/id.json +++ b/src/i18n/locales/id.json @@ -1594,6 +1594,11 @@ "regenerateThumbnailsSubtitle": "Bangun ulang varian 1x/2x yang hilang untuk semua sampul", "regenerateThumbnailsAction": "Buat ulang", "regenerateThumbnailsDone": "{{count}} thumbnail dibuat ulang", + "pruneAlbumCovers": "Bersihkan sampul album Deezer yang tidak terpakai", + "pruneAlbumCoversSubtitle": "Menghapus sampul album Deezer yang tersimpan untuk album yang sudah punya sampul sendiri (tidak pernah ditampilkan)", + "pruneAlbumCoversAction": "Bersihkan", + "pruneAlbumCoversDone": "{{files}} file dihapus · {{mb}} MB dibebaskan", + "pruneAlbumCoversFailed": "Gagal membersihkan sampul", "reset": { "title": "Atur ulang aplikasi", "subtitle": "Hapus semua data dan kembalikan ke pengaturan awal", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 81a56805..ef153c20 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1594,6 +1594,11 @@ "regenerateThumbnailsSubtitle": "Ricostruisce le varianti 1x/2x mancanti per tutte le copertine", "regenerateThumbnailsAction": "Rigenera", "regenerateThumbnailsDone": "{{count}} miniature rigenerate", + "pruneAlbumCovers": "Rimuovi le copertine Deezer inutilizzate", + "pruneAlbumCoversSubtitle": "Elimina le copertine Deezer in cache per gli album che hanno già la propria copertina (mai mostrate)", + "pruneAlbumCoversAction": "Rimuovi", + "pruneAlbumCoversDone": "{{files}} file eliminati · {{mb}} MB liberati", + "pruneAlbumCoversFailed": "Pulizia delle copertine non riuscita", "reset": { "title": "Reimposta l'app", "subtitle": "Elimina tutti i dati e ripristina le impostazioni di fabbrica", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index a66cb6f2..2dd5d57d 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1553,6 +1553,11 @@ "regenerateThumbnailsSubtitle": "すべてのジャケットについて、欠落している1x/2xバージョンを再構築する", "regenerateThumbnailsAction": "再生成", "regenerateThumbnailsDone": "{{count}} 件のサムネイルを再生成しました", + "pruneAlbumCovers": "未使用の Deezer アルバムカバーを削除", + "pruneAlbumCoversSubtitle": "すでに独自のカバーを持つアルバムのためにキャッシュされた Deezer カバーを削除します(表示されません)", + "pruneAlbumCoversAction": "削除", + "pruneAlbumCoversDone": "{{files}} 個のファイルを削除 · {{mb}} MB を解放", + "pruneAlbumCoversFailed": "カバーの削除に失敗しました", "reset": { "title": "アプリをリセットする", "subtitle": "すべてのデータを削除し、初期状態に戻す", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 167aed61..d3e470f8 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -1615,6 +1615,11 @@ "regenerateThumbnailsSubtitle": "모든 앨범 아트에 대해 누락된 1x/2x 버전을 재생성합니다", "regenerateThumbnailsAction": "재생성", "regenerateThumbnailsDone": "썸네일 {{count}}개 재생성됨", + "pruneAlbumCovers": "사용하지 않는 Deezer 앨범 커버 정리", + "pruneAlbumCoversSubtitle": "이미 자체 커버가 있는 앨범을 위해 캐시된 Deezer 커버를 삭제합니다(표시되지 않음)", + "pruneAlbumCoversAction": "정리", + "pruneAlbumCoversDone": "파일 {{files}}개 삭제 · {{mb}} MB 확보", + "pruneAlbumCoversFailed": "커버 정리 실패", "reset": { "title": "앱 초기화", "subtitle": "모든 데이터를 삭제하고 초기 상태로 복원", diff --git a/src/i18n/locales/nl.json b/src/i18n/locales/nl.json index 4a2dd04e..14f9e968 100644 --- a/src/i18n/locales/nl.json +++ b/src/i18n/locales/nl.json @@ -1594,6 +1594,11 @@ "regenerateThumbnailsSubtitle": "Maakt de ontbrekende 1x-/2x-varianten van alle albumhoezen opnieuw aan", "regenerateThumbnailsAction": "Opnieuw genereren", "regenerateThumbnailsDone": "{{count}} miniaturen opnieuw gegenereerd", + "pruneAlbumCovers": "Ongebruikte Deezer-albumhoezen opruimen", + "pruneAlbumCoversSubtitle": "Verwijdert in cache opgeslagen Deezer-albumhoezen voor albums die al hun eigen hoes hebben (nooit getoond)", + "pruneAlbumCoversAction": "Opruimen", + "pruneAlbumCoversDone": "{{files}} bestanden verwijderd · {{mb}} MB vrijgemaakt", + "pruneAlbumCoversFailed": "Opruimen van hoezen mislukt", "reset": { "title": "App resetten", "subtitle": "Verwijder alle gegevens en herstel de fabrieksinstellingen", diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json index e647c51c..d87b4c58 100644 --- a/src/i18n/locales/pt-BR.json +++ b/src/i18n/locales/pt-BR.json @@ -1615,6 +1615,11 @@ "regenerateThumbnailsSubtitle": "Recriar as variantes 1x/2x que faltam para todas as capas", "regenerateThumbnailsAction": "Regenerar", "regenerateThumbnailsDone": "{{count}} miniaturas atualizadas", + "pruneAlbumCovers": "Remover capas de álbum do Deezer não usadas", + "pruneAlbumCoversSubtitle": "Exclui as capas do Deezer em cache de álbuns que já têm a própria capa (nunca exibidas)", + "pruneAlbumCoversAction": "Remover", + "pruneAlbumCoversDone": "{{files}} arquivos excluídos · {{mb}} MB liberados", + "pruneAlbumCoversFailed": "Falha ao remover as capas", "reset": { "title": "Redefinir o aplicativo", "subtitle": "Apaga todos os dados e restaura as configurações de fábrica", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 062a2fda..77309430 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1615,6 +1615,11 @@ "regenerateThumbnailsSubtitle": "Recriar as variantes 1x/2x em falta para todas as capas", "regenerateThumbnailsAction": "Regenerar", "regenerateThumbnailsDone": "{{count}} miniaturas atualizadas", + "pruneAlbumCovers": "Remover capas de álbum do Deezer não usadas", + "pruneAlbumCoversSubtitle": "Elimina as capas do Deezer em cache de álbuns que já têm a sua própria capa (nunca mostradas)", + "pruneAlbumCoversAction": "Remover", + "pruneAlbumCoversDone": "{{files}} ficheiros eliminados · {{mb}} MB libertados", + "pruneAlbumCoversFailed": "Falha ao remover as capas", "reset": { "title": "Repor a aplicação", "subtitle": "Apagar todos os dados e restaurar as definições de fábrica", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index c754713d..467cc907 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1682,6 +1682,11 @@ "regenerateThumbnailsSubtitle": "Пересоздаёт недостающие варианты 1x/2x для всех обложек", "regenerateThumbnailsAction": "Пересоздать", "regenerateThumbnailsDone": "Пересоздано миниатюр: {{count}}", + "pruneAlbumCovers": "Очистить неиспользуемые обложки Deezer", + "pruneAlbumCoversSubtitle": "Удаляет кэшированные обложки Deezer для альбомов, у которых уже есть своя обложка (никогда не отображались)", + "pruneAlbumCoversAction": "Очистить", + "pruneAlbumCoversDone": "Удалено файлов: {{files}} · освобождено {{mb}} МБ", + "pruneAlbumCoversFailed": "Не удалось очистить обложки", "reset": { "title": "Сбросить приложение", "subtitle": "Удалить все данные и вернуть заводские настройки", diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index 926990de..55262572 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -1594,6 +1594,11 @@ "regenerateThumbnailsSubtitle": "Tüm kapaklar için eksik 1x/2x varyantlarını yeniden inşa eder", "regenerateThumbnailsAction": "Yeniden oluştur", "regenerateThumbnailsDone": "{{count}} önizleme yeniden oluşturuldu", + "pruneAlbumCovers": "Kullanılmayan Deezer albüm kapaklarını temizle", + "pruneAlbumCoversSubtitle": "Zaten kendi kapağı olan albümler için önbelleğe alınmış Deezer kapaklarını siler (hiç gösterilmedi)", + "pruneAlbumCoversAction": "Temizle", + "pruneAlbumCoversDone": "{{files}} dosya silindi · {{mb}} MB boşaltıldı", + "pruneAlbumCoversFailed": "Kapak temizliği başarısız", "reset": { "title": "Uygulamayı sıfırla", "subtitle": "Tüm verileri sil ve fabrika ayarlarına geri yükle", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index f2da0d3f..1361b190 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1553,6 +1553,11 @@ "regenerateThumbnailsSubtitle": "为所有封面补全缺失的 1x/2x 版本", "regenerateThumbnailsAction": "重新生成", "regenerateThumbnailsDone": "已重新生成 {{count}} 张缩略图", + "pruneAlbumCovers": "清理未使用的 Deezer 专辑封面", + "pruneAlbumCoversSubtitle": "删除已有自己封面的专辑所缓存的 Deezer 封面(从未显示)", + "pruneAlbumCoversAction": "清理", + "pruneAlbumCoversDone": "已删除 {{files}} 个文件 · 释放 {{mb}} MB", + "pruneAlbumCoversFailed": "封面清理失败", "reset": { "title": "重置应用程序", "subtitle": "删除所有数据并恢复出厂设置", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index a88dd6ca..94c7bd90 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1553,6 +1553,11 @@ "regenerateThumbnailsSubtitle": "為所有封面重建缺失的 1x/2x 版本", "regenerateThumbnailsAction": "重新產生", "regenerateThumbnailsDone": "已重新產生 {{count}} 張縮圖", + "pruneAlbumCovers": "清理未使用的 Deezer 專輯封面", + "pruneAlbumCoversSubtitle": "刪除已有自己封面的專輯所快取的 Deezer 封面(從未顯示)", + "pruneAlbumCoversAction": "清理", + "pruneAlbumCoversDone": "已刪除 {{files}} 個檔案 · 釋放 {{mb}} MB", + "pruneAlbumCoversFailed": "封面清理失敗", "reset": { "title": "重置應用程式", "subtitle": "刪除所有資料並恢復至初始狀態", diff --git a/src/lib/tauri/library.ts b/src/lib/tauri/library.ts index 0d75e11a..d2a5ae03 100644 --- a/src/lib/tauri/library.ts +++ b/src/lib/tauri/library.ts @@ -217,6 +217,23 @@ export function regenerateThumbnails(): Promise { return invoke("regenerate_thumbnails"); } +export interface AlbumCoverCleanup { + rowsCleared: number; + filesDeleted: number; + bytesFreed: number; +} + +/** + * One-time cleanup (issue #493): delete the Deezer album covers the shared + * `metadata_artwork` cache accumulated for albums that already have their own + * local artwork — files that were downloaded as a side effect and never shown. + * Reference-counted, so a file shared with an artist picture or reused across + * albums is never removed. + */ +export function pruneCachedAlbumCovers(): Promise { + return invoke("prune_cached_album_covers"); +} + /** * Wipe every profile, library, playlist and cache, then restart the * app into onboarding. The backend never returns — `app.restart()`