From ea14ec579934a2ac5d1db409407cf5d146120685 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 9 Aug 2026 02:08:16 +0200 Subject: [PATCH 1/7] fix(deezer): only disk-cache an album cover when the album lacks local art (#493) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `enrich_album_inner` downloaded and cached the Deezer album cover into the shared `metadata_artwork` cache unconditionally. But it fires automatically on every album-page open — which reads only `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 cache steadily filled with never-shown Deezer covers for albums the user already had covers for (reported: a `metadata_artwork` folder full of album art the user never asked for). Gate the `download_and_cache` on `album.artwork_id IS NULL`: only a genuinely cover-less album gets its Deezer cover written to disk. The remote `cover_url` still rides through for Discord + the cache row, `label`/`release_date` are unchanged, and the deliberate `batch_fetch_missing_album_covers` (which iterates only `artwork_id IS NULL` albums) keeps working. No display regression — nothing rendered the downloaded file for an art-having album. Note: this stops new leaks; it does not prune covers already cached. A one-time cleanup (drop `metadata_album.cover_hash` files for albums with local art) can follow if wanted. --- CLAUDE.md | 2 +- docs/features/integrations.md | 2 ++ src-tauri/crates/app/src/commands/deezer.rs | 28 +++++++++++++++------ 3 files changed, 23 insertions(+), 9 deletions(-) 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..df7ebc99 100644 --- a/src-tauri/crates/app/src/commands/deezer.rs +++ b/src-tauri/crates/app/src/commands/deezer.rs @@ -94,9 +94,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 +105,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()); }; @@ -187,10 +188,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() From d9db65939cf767a31481b3ee766577a519f8c3cf Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 9 Aug 2026 02:21:56 +0200 Subject: [PATCH 2/7] feat(maintenance): one-time cleanup of cached album covers for art-having albums (#493) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to the #493 fix, which stops NEW accumulation but leaves whatever the shared cache already collected. `prune_cached_album_covers` reclaims it: - Finds Deezer covers cached for albums that already have local artwork (`album.artwork_id IS NOT NULL`), clears their `metadata_album.cover_hash`, and deletes the `.jpg` + `_1x`/`_2x` files. - Reference-counted before any deletion: the cache is content-addressed and shared between artist pictures and album covers, so a hash is only unlinked once no `metadata_album.cover_hash` / `metadata_artist.picture_hash` / `background_hash` still points at it. Blocking fs work runs on spawn_blocking. - Scoped to the active profile; over-cleaning is self-healing since the #493 fix re-fetches a cover-less album's cover on next view. Surfaced as a "Prune unused Deezer album covers" card under Settings → Data (reports files deleted + MB freed). Frontend `pruneCachedAlbumCovers` wrapper + i18n `settings.pruneAlbumCovers*` ×17. Docs already covered by the #493 commit. --- .../crates/app/src/commands/maintenance.rs | 116 ++++++++++++++++++ src-tauri/crates/app/src/lib.rs | 1 + src/components/views/SettingsView.tsx | 64 ++++++++++ src/i18n/locales/ar.json | 4 + src/i18n/locales/de.json | 4 + src/i18n/locales/en.json | 4 + src/i18n/locales/es.json | 4 + src/i18n/locales/fr.json | 4 + src/i18n/locales/hi.json | 4 + src/i18n/locales/id.json | 4 + src/i18n/locales/it.json | 4 + src/i18n/locales/ja.json | 4 + src/i18n/locales/ko.json | 4 + src/i18n/locales/nl.json | 4 + src/i18n/locales/pt-BR.json | 4 + src/i18n/locales/pt.json | 4 + src/i18n/locales/ru.json | 4 + src/i18n/locales/tr.json | 4 + src/i18n/locales/zh-CN.json | 4 + src/i18n/locales/zh-TW.json | 4 + src/lib/tauri/library.ts | 17 +++ 21 files changed, 266 insertions(+) diff --git a/src-tauri/crates/app/src/commands/maintenance.rs b/src-tauri/crates/app/src/commands/maintenance.rs index 4aefef20..2477d985 100644 --- a/src-tauri/crates/app/src/commands/maintenance.rs +++ b/src-tauri/crates/app/src/commands/maintenance.rs @@ -55,6 +55,122 @@ 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. +/// +/// Safe by construction — the cache is content-addressed and shared between +/// artist pictures and album covers, so a hash is only unlinked from disk once +/// it is referenced by NO remaining `metadata_album.cover_hash`, +/// `metadata_artist.picture_hash` or `metadata_artist.background_hash`. Scoped +/// to the active profile's albums; if a *different* profile still lacks art for +/// one of these albums, its cover simply re-downloads on next view (the #493 +/// fix re-fetches for art-less albums), so over-cleaning is self-healing. +#[tauri::command] +pub async fn prune_cached_album_covers( + state: tauri::State<'_, AppState>, +) -> AppResult { + let pool = state.require_profile_pool().await?; + let artwork_dir = state.paths.metadata_artwork_dir.clone(); + + // Covers cached for albums that DO have local artwork (this profile). + let candidates: Vec = sqlx::query_scalar( + "SELECT DISTINCT ma.cover_hash + FROM app.metadata_album ma + JOIN album al ON al.deezer_id = ma.deezer_id + WHERE al.artwork_id IS NOT NULL AND ma.cover_hash IS NOT NULL", + ) + .fetch_all(&*pool) + .await?; + + if candidates.is_empty() { + return Ok(AlbumCoverCleanup { + rows_cleared: 0, + files_deleted: 0, + bytes_freed: 0, + }); + } + + // Forget the cover on those rows first, so the reference check below no + // longer counts them — the #493 fix keeps them from re-downloading. + let rows_cleared = sqlx::query( + "UPDATE app.metadata_album + SET cover_hash = NULL + WHERE deezer_id IN ( + SELECT ma.deezer_id + FROM app.metadata_album ma + JOIN album al ON al.deezer_id = ma.deezer_id + WHERE al.artwork_id IS NOT NULL AND ma.cover_hash IS NOT NULL)", + ) + .execute(&*pool) + .await? + .rows_affected(); + + // Every hash still referenced anywhere in the shared cache — must survive. + let referenced: std::collections::HashSet = sqlx::query_scalar::<_, String>( + "SELECT cover_hash FROM app.metadata_album WHERE cover_hash IS NOT NULL + UNION SELECT picture_hash FROM app.metadata_artist WHERE picture_hash IS NOT NULL + UNION SELECT background_hash FROM app.metadata_artist WHERE background_hash IS NOT NULL", + ) + .fetch_all(&*pool) + .await? + .into_iter() + .collect(); + + let deletable: Vec = candidates + .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..cc7deead 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,29 @@ export function SettingsView({ onNavigate }: SettingsViewProps) { } }, [isRegeneratingThumbs, t]); + const [isPruningCovers, setIsPruningCovers] = useState(false); + const [pruneCoversStatus, setPruneCoversStatus] = useState( + null, + ); + + const handlePruneCachedCovers = useCallback(async () => { + if (isPruningCovers) return; + setIsPruningCovers(true); + setPruneCoversStatus(null); + try { + const r = await pruneCachedAlbumCovers(); + const mb = (r.bytesFreed / (1024 * 1024)).toFixed(1); + setPruneCoversStatus( + t("settings.pruneAlbumCoversDone", { files: r.filesDeleted, mb }), + ); + } catch (err) { + console.error("[SettingsView] prune cached covers failed", err); + } finally { + setIsPruningCovers(false); + window.setTimeout(() => setPruneCoversStatus(null), 5000); + } + }, [isPruningCovers, t]); + // Audio settings — hydrated from backend at mount. const [normalize, setNormalize] = useState(false); const [mono, setMono] = useState(false); @@ -3630,6 +3654,46 @@ 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..7014ede4 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -1733,6 +1733,10 @@ "regenerateThumbnailsSubtitle": "إعادة بناء نسخ 1x/2x المفقودة لجميع الأغلفة", "regenerateThumbnailsAction": "إعادة التوليد", "regenerateThumbnailsDone": "تم إعادة توليد {{count}} صورة مصغرة", + "pruneAlbumCovers": "إزالة أغلفة ألبومات Deezer غير المستخدمة", + "pruneAlbumCoversSubtitle": "يحذف أغلفة Deezer المخزَّنة مؤقتًا للألبومات التي لديها غلافها الخاص (لا تُعرض أبدًا)", + "pruneAlbumCoversAction": "تنظيف", + "pruneAlbumCoversDone": "تم حذف {{files}} ملفات · تحرير {{mb}} ميغابايت", "reset": { "title": "إعادة ضبط التطبيق", "subtitle": "حذف جميع البيانات واستعادة إعدادات المصنع", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index c6120e16..44e3e144 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1594,6 +1594,10 @@ "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", "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..13e9e176 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1594,6 +1594,10 @@ "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", "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..692c8a24 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1594,6 +1594,10 @@ "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", "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..ff44139f 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1680,6 +1680,10 @@ "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", "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..640530e6 100644 --- a/src/i18n/locales/hi.json +++ b/src/i18n/locales/hi.json @@ -1615,6 +1615,10 @@ "regenerateThumbnailsSubtitle": "सभी कवर के लिए गायब 1x/2x वेरिएंट्स का पुनर्निर्माण करें।", "regenerateThumbnailsAction": "पुनर्निर्माण करें", "regenerateThumbnailsDone": "{{count}} थंबनेल पुनर्निर्मित", + "pruneAlbumCovers": "अप्रयुक्त Deezer एल्बम कवर हटाएँ", + "pruneAlbumCoversSubtitle": "उन एल्बमों के लिए कैश की गई Deezer कवर हटाता है जिनके पास पहले से अपना कवर है (कभी नहीं दिखाया गया)", + "pruneAlbumCoversAction": "हटाएँ", + "pruneAlbumCoversDone": "{{files}} फ़ाइलें हटाई गईं · {{mb}} MB मुक्त किए गए", "reset": { "title": "ऐप को रीसेट करें", "subtitle": "सभी डेटा मिटाएँ और फ़ैक्टरी सेटिंग्स पर पुनर्स्थापित करें।", diff --git a/src/i18n/locales/id.json b/src/i18n/locales/id.json index 6e03064b..716f6bfb 100644 --- a/src/i18n/locales/id.json +++ b/src/i18n/locales/id.json @@ -1594,6 +1594,10 @@ "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", "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..ead8ce29 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1594,6 +1594,10 @@ "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", "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..f2eea951 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1553,6 +1553,10 @@ "regenerateThumbnailsSubtitle": "すべてのジャケットについて、欠落している1x/2xバージョンを再構築する", "regenerateThumbnailsAction": "再生成", "regenerateThumbnailsDone": "{{count}} 件のサムネイルを再生成しました", + "pruneAlbumCovers": "未使用の Deezer アルバムカバーを削除", + "pruneAlbumCoversSubtitle": "すでに独自のカバーを持つアルバムのためにキャッシュされた Deezer カバーを削除します(表示されません)", + "pruneAlbumCoversAction": "削除", + "pruneAlbumCoversDone": "{{files}} 個のファイルを削除 · {{mb}} MB を解放", "reset": { "title": "アプリをリセットする", "subtitle": "すべてのデータを削除し、初期状態に戻す", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 167aed61..e4e11da6 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -1615,6 +1615,10 @@ "regenerateThumbnailsSubtitle": "모든 앨범 아트에 대해 누락된 1x/2x 버전을 재생성합니다", "regenerateThumbnailsAction": "재생성", "regenerateThumbnailsDone": "썸네일 {{count}}개 재생성됨", + "pruneAlbumCovers": "사용하지 않는 Deezer 앨범 커버 정리", + "pruneAlbumCoversSubtitle": "이미 자체 커버가 있는 앨범을 위해 캐시된 Deezer 커버를 삭제합니다(표시되지 않음)", + "pruneAlbumCoversAction": "정리", + "pruneAlbumCoversDone": "파일 {{files}}개 삭제 · {{mb}} MB 확보", "reset": { "title": "앱 초기화", "subtitle": "모든 데이터를 삭제하고 초기 상태로 복원", diff --git a/src/i18n/locales/nl.json b/src/i18n/locales/nl.json index 4a2dd04e..339b57bf 100644 --- a/src/i18n/locales/nl.json +++ b/src/i18n/locales/nl.json @@ -1594,6 +1594,10 @@ "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", "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..9f44dc26 100644 --- a/src/i18n/locales/pt-BR.json +++ b/src/i18n/locales/pt-BR.json @@ -1615,6 +1615,10 @@ "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", "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..fe5d2a52 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1615,6 +1615,10 @@ "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", "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..d014900e 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1682,6 +1682,10 @@ "regenerateThumbnailsSubtitle": "Пересоздаёт недостающие варианты 1x/2x для всех обложек", "regenerateThumbnailsAction": "Пересоздать", "regenerateThumbnailsDone": "Пересоздано миниатюр: {{count}}", + "pruneAlbumCovers": "Очистить неиспользуемые обложки Deezer", + "pruneAlbumCoversSubtitle": "Удаляет кэшированные обложки Deezer для альбомов, у которых уже есть своя обложка (никогда не отображались)", + "pruneAlbumCoversAction": "Очистить", + "pruneAlbumCoversDone": "Удалено файлов: {{files}} · освобождено {{mb}} МБ", "reset": { "title": "Сбросить приложение", "subtitle": "Удалить все данные и вернуть заводские настройки", diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index 926990de..8e0e22dd 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -1594,6 +1594,10 @@ "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ı", "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..1e55776e 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1553,6 +1553,10 @@ "regenerateThumbnailsSubtitle": "为所有封面补全缺失的 1x/2x 版本", "regenerateThumbnailsAction": "重新生成", "regenerateThumbnailsDone": "已重新生成 {{count}} 张缩略图", + "pruneAlbumCovers": "清理未使用的 Deezer 专辑封面", + "pruneAlbumCoversSubtitle": "删除已有自己封面的专辑所缓存的 Deezer 封面(从未显示)", + "pruneAlbumCoversAction": "清理", + "pruneAlbumCoversDone": "已删除 {{files}} 个文件 · 释放 {{mb}} MB", "reset": { "title": "重置应用程序", "subtitle": "删除所有数据并恢复出厂设置", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index a88dd6ca..bcb8bcdb 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1553,6 +1553,10 @@ "regenerateThumbnailsSubtitle": "為所有封面重建缺失的 1x/2x 版本", "regenerateThumbnailsAction": "重新產生", "regenerateThumbnailsDone": "已重新產生 {{count}} 張縮圖", + "pruneAlbumCovers": "清理未使用的 Deezer 專輯封面", + "pruneAlbumCoversSubtitle": "刪除已有自己封面的專輯所快取的 Deezer 封面(從未顯示)", + "pruneAlbumCoversAction": "清理", + "pruneAlbumCoversDone": "已刪除 {{files}} 個檔案 · 釋放 {{mb}} MB", "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()` From 94badcf728943e0cd46c94fb911ca48b09821401 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 9 Aug 2026 02:29:19 +0200 Subject: [PATCH 3/7] fix(deezer): don't let a cover-less cache row block a re-fetch (#493 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit round on #494. The download-skip introduced a cache-poisoning edge: `enrich_album_inner` returned any fresh `metadata_album` row as a hit, including one with `cover_hash = NULL`. So once an art-having album cached a cover-less row, a later need for its cover — the local art was removed, another profile shares the app-wide cache, or `batch_fetch_missing_album_covers` runs — was served the empty row and never re-fetched until the 30-day TTL lapsed. Gate the cache-hit on a completeness check: a fresh row is usable only when it carries a cover OR the album has its own local art (so it never needs the Deezer cover). An art-less, cover-less fresh row now falls through to Deezer. Extracted `metadata_album_cache_complete` + unit test. Skipped the paired "add an is_offline() guard before download_and_cache" suggestion: offline mode already returns at step 3, before the API call and the download, so that branch is unreachable offline — a second check would be dead code. --- src-tauri/crates/app/src/commands/deezer.rs | 42 ++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/src-tauri/crates/app/src/commands/deezer.rs b/src-tauri/crates/app/src/commands/deezer.rs index df7ebc99..b2fe5882 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, @@ -126,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)); @@ -1184,3 +1204,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)); + } +} From 73b44822658dd228433ed509eb3f11b446c55c51 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 9 Aug 2026 02:46:07 +0200 Subject: [PATCH 4/7] fix(deezer): cross-profile-safe cover prune + preserve hash on skipped download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit round on #494. - deezer upsert: `cover_hash = COALESCE(excluded.cover_hash, cover_hash)` so a skipped (#493 art-having) or transiently-failed download no longer drops a good cached cover — only a NEW successful download overwrites the hash. - prune_cached_album_covers is now cross-profile safe: the metadata cache is app-wide, so a cover is prunable only when its album has local art in EVERY profile that carries it (read each profile's `album` table, build the "still needed" deezer_id set, exclude those). Prevents nulling a shared reference a different profile still relies on. The clear + reference re-check run in one transaction for a consistent snapshot. - Settings "Prune covers": the catch now shows a distinct red failure status (`settings.pruneAlbumCoversFailed`, ×17) instead of leaving it blank. Skipped, with reasons: - offline guard before download_and_cache: unreachable — the command already returns at the offline check (step 3) before the API call and the download. - SQLITE_BUSY/LOCKED retry loop: this is a foreground one-shot, not a background batch writer; a transient busy now surfaces via the failure status and the user retries — a retry loop would be disproportionate here. - integration test for the upsert / prune paths: the `waveflow` app-crate tests don't start locally (STATUS_ENTRYPOINT_NOT_FOUND, Tauri DLL) and both paths are DB+network bound with no injection point; CI runs them. --- src-tauri/crates/app/src/commands/deezer.rs | 8 +- .../crates/app/src/commands/maintenance.rs | 122 ++++++++++++------ src/components/views/SettingsView.tsx | 28 ++-- src/i18n/locales/ar.json | 1 + src/i18n/locales/de.json | 1 + src/i18n/locales/en.json | 1 + src/i18n/locales/es.json | 1 + src/i18n/locales/fr.json | 1 + src/i18n/locales/hi.json | 1 + src/i18n/locales/id.json | 1 + src/i18n/locales/it.json | 1 + src/i18n/locales/ja.json | 1 + src/i18n/locales/ko.json | 1 + src/i18n/locales/nl.json | 1 + src/i18n/locales/pt-BR.json | 1 + src/i18n/locales/pt.json | 1 + src/i18n/locales/ru.json | 1 + src/i18n/locales/tr.json | 1 + src/i18n/locales/zh-CN.json | 1 + src/i18n/locales/zh-TW.json | 1 + 20 files changed, 126 insertions(+), 49 deletions(-) diff --git a/src-tauri/crates/app/src/commands/deezer.rs b/src-tauri/crates/app/src/commands/deezer.rs index b2fe5882..8841e7bf 100644 --- a/src-tauri/crates/app/src/commands/deezer.rs +++ b/src-tauri/crates/app/src/commands/deezer.rs @@ -242,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", diff --git a/src-tauri/crates/app/src/commands/maintenance.rs b/src-tauri/crates/app/src/commands/maintenance.rs index 2477d985..bd968ee3 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::{ @@ -72,65 +74,105 @@ pub struct AlbumCoverCleanup { /// artwork. Those files were downloaded as a side effect of opening an album /// page (which only reads `label`/`release_date`) and were never displayed. /// -/// Safe by construction — the cache is content-addressed and shared between -/// artist pictures and album covers, so a hash is only unlinked from disk once -/// it is referenced by NO remaining `metadata_album.cover_hash`, -/// `metadata_artist.picture_hash` or `metadata_artist.background_hash`. Scoped -/// to the active profile's albums; if a *different* profile still lacks art for -/// one of these albums, its cover simply re-downloads on next view (the #493 -/// fix re-fetches for art-less albums), so over-cleaning is self-healing. +/// **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 pool = state.require_profile_pool().await?; let artwork_dir = state.paths.metadata_artwork_dir.clone(); - // Covers cached for albums that DO have local artwork (this profile). - let candidates: Vec = sqlx::query_scalar( - "SELECT DISTINCT ma.cover_hash - FROM app.metadata_album ma - JOIN album al ON al.deezer_id = ma.deezer_id - WHERE al.artwork_id IS NOT NULL AND ma.cover_hash IS NOT NULL", + // 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`. + let profile_ids: Vec = sqlx::query_scalar("SELECT id FROM profile") + .fetch_all(&state.app_db) + .await + .unwrap_or_default(); + 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(&*pool) + .fetch_all(&state.app_db) .await?; - - if candidates.is_empty() { + 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, }); } - - // Forget the cover on those rows first, so the reference check below no - // longer counts them — the #493 fix keeps them from re-downloading. - let rows_cleared = sqlx::query( - "UPDATE app.metadata_album - SET cover_hash = NULL - WHERE deezer_id IN ( - SELECT ma.deezer_id - FROM app.metadata_album ma - JOIN album al ON al.deezer_id = ma.deezer_id - WHERE al.artwork_id IS NOT NULL AND ma.cover_hash IS NOT NULL)", - ) - .execute(&*pool) - .await? - .rows_affected(); - - // Every hash still referenced anywhere in the shared cache — must survive. - let referenced: std::collections::HashSet = sqlx::query_scalar::<_, String>( - "SELECT cover_hash FROM app.metadata_album WHERE cover_hash IS NOT NULL - UNION SELECT picture_hash FROM app.metadata_artist WHERE picture_hash IS NOT NULL - UNION SELECT background_hash FROM app.metadata_artist WHERE background_hash IS NOT NULL", + 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(&*pool) + .fetch_all(&mut *tx) .await? .into_iter() .collect(); + tx.commit().await?; - let deletable: Vec = candidates + let deletable: Vec = candidate_hashes .into_iter() .filter(|h| !referenced.contains(h)) .collect(); diff --git a/src/components/views/SettingsView.tsx b/src/components/views/SettingsView.tsx index cc7deead..f7ea6139 100644 --- a/src/components/views/SettingsView.tsx +++ b/src/components/views/SettingsView.tsx @@ -1164,9 +1164,10 @@ export function SettingsView({ onNavigate }: SettingsViewProps) { }, [isRegeneratingThumbs, t]); const [isPruningCovers, setIsPruningCovers] = useState(false); - const [pruneCoversStatus, setPruneCoversStatus] = useState( - null, - ); + const [pruneCoversStatus, setPruneCoversStatus] = useState<{ + ok: boolean; + text: string; + } | null>(null); const handlePruneCachedCovers = useCallback(async () => { if (isPruningCovers) return; @@ -1175,11 +1176,16 @@ export function SettingsView({ onNavigate }: SettingsViewProps) { try { const r = await pruneCachedAlbumCovers(); const mb = (r.bytesFreed / (1024 * 1024)).toFixed(1); - setPruneCoversStatus( - t("settings.pruneAlbumCoversDone", { files: r.filesDeleted, mb }), - ); + 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); window.setTimeout(() => setPruneCoversStatus(null), 5000); @@ -3669,8 +3675,14 @@ export function SettingsView({ onNavigate }: SettingsViewProps) { {t("settings.pruneAlbumCovers")} {pruneCoversStatus ? ( -
- {pruneCoversStatus} +
+ {pruneCoversStatus.text}
) : (
diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 7014ede4..c736a98e 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -1737,6 +1737,7 @@ "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 44e3e144..910a3af5 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1598,6 +1598,7 @@ "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 13e9e176..816a1fd8 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1598,6 +1598,7 @@ "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 692c8a24..f48d53f1 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1598,6 +1598,7 @@ "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 ff44139f..3a815ac3 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1684,6 +1684,7 @@ "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 640530e6..0a5b8f34 100644 --- a/src/i18n/locales/hi.json +++ b/src/i18n/locales/hi.json @@ -1619,6 +1619,7 @@ "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 716f6bfb..d7e9c32f 100644 --- a/src/i18n/locales/id.json +++ b/src/i18n/locales/id.json @@ -1598,6 +1598,7 @@ "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 ead8ce29..ef153c20 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1598,6 +1598,7 @@ "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 f2eea951..2dd5d57d 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1557,6 +1557,7 @@ "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 e4e11da6..d3e470f8 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -1619,6 +1619,7 @@ "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 339b57bf..14f9e968 100644 --- a/src/i18n/locales/nl.json +++ b/src/i18n/locales/nl.json @@ -1598,6 +1598,7 @@ "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 9f44dc26..d87b4c58 100644 --- a/src/i18n/locales/pt-BR.json +++ b/src/i18n/locales/pt-BR.json @@ -1619,6 +1619,7 @@ "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 fe5d2a52..77309430 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1619,6 +1619,7 @@ "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 d014900e..467cc907 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1686,6 +1686,7 @@ "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 8e0e22dd..55262572 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -1598,6 +1598,7 @@ "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 1e55776e..1361b190 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1557,6 +1557,7 @@ "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 bcb8bcdb..94c7bd90 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1557,6 +1557,7 @@ "pruneAlbumCoversSubtitle": "刪除已有自己封面的專輯所快取的 Deezer 封面(從未顯示)", "pruneAlbumCoversAction": "清理", "pruneAlbumCoversDone": "已刪除 {{files}} 個檔案 · 釋放 {{mb}} MB", + "pruneAlbumCoversFailed": "封面清理失敗", "reset": { "title": "重置應用程式", "subtitle": "刪除所有資料並恢復至初始狀態", From 1b12cc6829235b70364acc04933729660ceb109c Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 9 Aug 2026 03:00:44 +0200 Subject: [PATCH 5/7] fix(maintenance): fail the cover prune if the profile list can't be read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit round on #494. `SELECT id FROM profile` used `unwrap_or_default()`: on a transient `app.db` read error the profile list fell back to empty, leaving the "still needed" set empty and marking EVERY cached cover prunable — a mass-deletion of covers other profiles need. Propagate the error instead. --- src-tauri/crates/app/src/commands/maintenance.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src-tauri/crates/app/src/commands/maintenance.rs b/src-tauri/crates/app/src/commands/maintenance.rs index bd968ee3..bbb4d19f 100644 --- a/src-tauri/crates/app/src/commands/maintenance.rs +++ b/src-tauri/crates/app/src/commands/maintenance.rs @@ -94,10 +94,12 @@ pub async fn prune_cached_album_covers( // 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 - .unwrap_or_default(); + .await?; let mut needed: HashSet = HashSet::new(); for pid in &profile_ids { let path = state.paths.profile_db(*pid); From e748c11da3dc0f39648ec1b1c797a256e4f17c36 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 9 Aug 2026 03:35:19 +0200 Subject: [PATCH 6/7] fix(settings): locale-aware MB formatting in the cover-prune status CodeRabbit round on #494. Format the freed megabytes with Intl.NumberFormat (i18n.resolvedLanguage ?? i18n.language) instead of toFixed(1), so locales that use a comma decimal separator render correctly (e.g. "12,5" in fr). Keeps the one-decimal precision; adds the language deps to the useCallback. --- src/components/views/SettingsView.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/components/views/SettingsView.tsx b/src/components/views/SettingsView.tsx index f7ea6139..b36d0b38 100644 --- a/src/components/views/SettingsView.tsx +++ b/src/components/views/SettingsView.tsx @@ -1175,7 +1175,12 @@ export function SettingsView({ onNavigate }: SettingsViewProps) { setPruneCoversStatus(null); try { const r = await pruneCachedAlbumCovers(); - const mb = (r.bytesFreed / (1024 * 1024)).toFixed(1); + // 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 }), @@ -1190,7 +1195,7 @@ export function SettingsView({ onNavigate }: SettingsViewProps) { setIsPruningCovers(false); window.setTimeout(() => setPruneCoversStatus(null), 5000); } - }, [isPruningCovers, t]); + }, [isPruningCovers, t, i18n.language, i18n.resolvedLanguage]); // Audio settings — hydrated from backend at mount. const [normalize, setNormalize] = useState(false); From 848add8215031868278bd47924ec852e5d869f68 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sun, 9 Aug 2026 03:44:27 +0200 Subject: [PATCH 7/7] fix(settings): tidy the cover-prune status timer + announce it to a11y CodeRabbit round on #494. Two small SettingsView fixes: - Keep the auto-clear timeout in a ref, cancelling any prior one before scheduling a new one and on unmount, so a rapid re-run (or unmount) can't have a stale timer wipe a newer prune result / fire after teardown. - Give the prune status container role="status" + aria-live="polite" so both the success and failure outcomes are announced to assistive tech. --- src/components/views/SettingsView.tsx | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/components/views/SettingsView.tsx b/src/components/views/SettingsView.tsx index b36d0b38..afa05432 100644 --- a/src/components/views/SettingsView.tsx +++ b/src/components/views/SettingsView.tsx @@ -1168,6 +1168,17 @@ export function SettingsView({ onNavigate }: SettingsViewProps) { 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; @@ -1193,7 +1204,13 @@ export function SettingsView({ onNavigate }: SettingsViewProps) { }); } finally { setIsPruningCovers(false); - window.setTimeout(() => setPruneCoversStatus(null), 5000); + if (pruneCoversTimeoutRef.current != null) { + window.clearTimeout(pruneCoversTimeoutRef.current); + } + pruneCoversTimeoutRef.current = window.setTimeout( + () => setPruneCoversStatus(null), + 5000, + ); } }, [isPruningCovers, t, i18n.language, i18n.resolvedLanguage]); @@ -3681,6 +3698,8 @@ export function SettingsView({ onNavigate }: SettingsViewProps) {
{pruneCoversStatus ? (