From c4f25acbbd02f01e1b13c62d0d88e2624b423fa0 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Wed, 29 Jul 2026 18:50:11 +0200 Subject: [PATCH 1/4] feat(library): deep rescan for the whole library, not just one folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported in #457: genre edits made in Mp3tag never showed up after "Rescan the library", while an earlier artist rename had worked. The scan fast path skips a file whose `(mtime, size)` is unchanged. Mp3tag preserves the modification date, and an ID3 rewrite usually fits in the existing padding so the size doesn't move either — the file looks untouched and its new tags are never read. (The artist rename likely grew the tag past its padding, changing the size.) The bypass for this already existed — `scan_folder(deep: true)`, added for the same root cause in #366 — but only per folder. `rescan_library` hard-coded `false`, so the library-wide button users actually reach for could never see these edits. Thread `deep` through `rescan_library` and add a second header button next to Rescan, mirroring the per-folder one. Separate control rather than a modifier on the existing button: a full re-read is markedly slower, so it should be chosen deliberately. i18n across all 17 locales. Docs: library.md claimed a tag edit "is already detected, since it moves that file's mtime". That is the assumption this bug disproves, so it now states the caveat and documents both deep-rescan entry points. Refs #457 --- docs/features/library.md | 13 ++++- src-tauri/crates/app/src/commands/library.rs | 12 ++++- src/components/views/LibraryView.tsx | 53 +++++++++++++++++++- src/contexts/LibraryContext.tsx | 4 +- src/hooks/useLibrary.ts | 4 +- 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 | 15 +++++- 23 files changed, 144 insertions(+), 25 deletions(-) diff --git a/docs/features/library.md b/docs/features/library.md index 7a4eaa52..237fd2b4 100644 --- a/docs/features/library.md +++ b/docs/features/library.md @@ -26,7 +26,18 @@ Directory resolution (`read_dir` + read + blake3 per directory, potentially hund Because that walk can take seconds, the candidate list is a snapshot that may be stale by the time it is written. The update is therefore a compare-and-swap that also re-asserts the source allowlist (`link_folder_cover_if_eligible`): an album whose cover changed mid-walk — the user uploaded one, or a concurrent scan resolved a fresher sidecar — is left alone, and the count of refreshed covers comes from `rows_affected` so a skipped album never inflates the scan summary. -A tag edit that rewrites the audio file _is_ already detected, since it moves that file's mtime — this pass is specifically about the sidecar case. +A tag edit that rewrites the audio file is normally detected, since it moves that file's mtime — this pass is specifically about the sidecar case. **But that is not guaranteed**: taggers commonly offer to preserve the modification date (Mp3tag ships that behaviour), and an ID3 rewrite often fits in the existing padding, so `size` doesn't move either. The file then looks untouched to the fast path and its new tags are never read — reported as issue #457 for a batch of genre edits. + +### Deep rescan + +A **deep rescan** bypasses `(mtime, size)` entirely and re-hashes + re-reads every file. It is the escape hatch for exactly the case above, and it is opt-in because it costs a full re-read of the library. + +Two entry points, both in **My music → Folders**: + +- **per folder** — the magnifier button on a folder row (`scan_folder` with `deep: true`); +- **whole library** — the second button next to Rescan in the header (`rescan_library` with `deep: true`), added in issue #457. Until then the bypass existed per folder only, so the library-wide button users actually reach for could never see mtime-preserving edits. + +Note the interaction with **Split this artist** below: a deep rescan re-reads tags authoritatively and will undo an in-place split, since it no longer sees the split as deliberate. ## Audio analysis diff --git a/src-tauri/crates/app/src/commands/library.rs b/src-tauri/crates/app/src/commands/library.rs index 6febf031..94f629ed 100644 --- a/src-tauri/crates/app/src/commands/library.rs +++ b/src-tauri/crates/app/src/commands/library.rs @@ -320,7 +320,17 @@ pub async fn rescan_library( app: tauri::AppHandle, state: tauri::State<'_, AppState>, library_id: i64, + // Same escape hatch `scan_folder` has carried since issue #366: + // an external tagger can rewrite tags while preserving the file's + // mtime (Mp3tag's "preserve modification time" is on by default), + // and the `(mtime, size)` fast path then skips the file forever. + // Until this existed the bypass was reachable per folder only, so + // the library-wide button users actually reach for could never see + // those edits (issue #457). Opt-in: a deep pass re-hashes and + // re-reads every file. + deep: Option, ) -> AppResult { + let deep = deep.unwrap_or(false); let pool = state.require_profile_pool().await?; let profile_id = state.require_profile_id().await?; let artwork_dir = state.paths.profile_artwork_dir(profile_id); @@ -334,7 +344,7 @@ pub async fn rescan_library( }; for folder_id in folder_ids { - match scan_folder_inner(&pool, &artwork_dir, folder_id, Some(&app), false).await { + match scan_folder_inner(&pool, &artwork_dir, folder_id, Some(&app), deep).await { Ok(summary) => { total.folders += 1; let ScanSummary { diff --git a/src/components/views/LibraryView.tsx b/src/components/views/LibraryView.tsx index a7a76f51..d8e455cb 100644 --- a/src/components/views/LibraryView.tsx +++ b/src/components/views/LibraryView.tsx @@ -14,6 +14,7 @@ import { Tags, Folder, RefreshCcw, + FileSearch, Clock, LayoutList, AlignJustify, @@ -149,6 +150,9 @@ export function LibraryView({ } = usePlaylist(); const [isImporting, setIsImporting] = useState(false); const [isRescanning, setIsRescanning] = useState(false); + // Separate from `isRescanning` so the two buttons disable each other + // without either claiming the other's spinner. + const [isDeepRescanning, setIsDeepRescanning] = useState(false); // Which folder a deep rescan (issue #366) is currently running against, // if any — drives the spinner on that row's action only, since it's a // per-folder action rather than the global "Rescan" button above. @@ -430,6 +434,24 @@ export function LibraryView({ } }; + const handleDeepRescanLibrary = async () => { + // Whole-library counterpart of the per-folder deep rescan below. + // Same rationale (issue #457): the normal pass trusts (mtime, size) + // and therefore cannot see tags an external editor rewrote while + // preserving mtime. + if (isRescanning || isDeepRescanning) return; + setIsDeepRescanning(true); + try { + for (const lib of libraries) { + await rescanLibrary(lib.id, true); + } + } catch (err) { + console.error("[LibraryView] deep rescan (library) failed", err); + } finally { + setIsDeepRescanning(false); + } + }; + const handleRescan = async () => { if (isRescanning) return; setIsRescanning(true); @@ -511,7 +533,9 @@ export function LibraryView({ + {/* Deep pass, mirroring the per-folder button in the folder + list. Separate control rather than a modifier on the one + above: it is markedly slower, so it should be chosen, not + triggered by accident. */} + + + diff --git a/src/contexts/LibraryContext.tsx b/src/contexts/LibraryContext.tsx index 714ada38..79eff627 100644 --- a/src/contexts/LibraryContext.tsx +++ b/src/contexts/LibraryContext.tsx @@ -191,8 +191,8 @@ export function LibraryProvider({ children }: { children: ReactNode }) { ); const rescanLibrary = useCallback( - async (libraryId: number) => { - const summary = await apiRescanLibrary(libraryId); + async (libraryId: number, deep = false) => { + const summary = await apiRescanLibrary(libraryId, deep); await refresh(); return summary; }, diff --git a/src/hooks/useLibrary.ts b/src/hooks/useLibrary.ts index 85e954d8..f5a835f8 100644 --- a/src/hooks/useLibrary.ts +++ b/src/hooks/useLibrary.ts @@ -38,7 +38,9 @@ interface LibraryContextValue { /** Permanently delete a library and everything it owns. */ deleteLibrary: (libraryId: number) => Promise; /** Re-walk every folder of the library and sync the DB with disk. */ - rescanLibrary: (libraryId: number) => Promise; + /** `deep` re-reads every file instead of trusting the (mtime, size) + * fast path — see `rescanLibrary` in `lib/tauri/library`. */ + rescanLibrary: (libraryId: number, deep?: boolean) => Promise; /** * Register a folder inside a library and immediately scan it. Returns the * summary so the UI can surface counts to the user. diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index bafa51d5..7b88bc45 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -430,7 +430,9 @@ "edit": "تعديل المكتبة", "delete": "حذف المكتبة", "deleteConfirm": "انقر مرة أخرى للتأكيد", - "importing": "جارٍ الاستيراد…" + "importing": "جارٍ الاستيراد…", + "deepRescan": "فحص عميق (يعيد قراءة كل ملف، أبطأ)", + "deepRescanning": "جارٍ الفحص العميق…" }, "changeCover": "تغيير الغلاف", "searchDeezer": "البحث في Deezer", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 64a4dc40..40e097fe 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -389,7 +389,9 @@ "edit": "Bibliothek bearbeiten", "delete": "Bibliothek löschen", "deleteConfirm": "Zur Bestätigung erneut klicken", - "importing": "Wird importiert…" + "importing": "Wird importiert…", + "deepRescan": "Tiefen-Scan (liest jede Datei neu, langsamer)", + "deepRescanning": "Tiefen-Scan läuft…" }, "changeCover": "Cover ändern", "searchDeezer": "Auf Deezer suchen", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b7548a1c..ddaf3b52 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -389,7 +389,9 @@ "edit": "Edit the library", "delete": "Delete the library", "deleteConfirm": "Click again to confirm", - "importing": "Importing…" + "importing": "Importing…", + "deepRescan": "Deep rescan (re-reads every file, slower)", + "deepRescanning": "Deep rescan in progress…" }, "changeCover": "Change cover", "searchDeezer": "Search Deezer", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 31b2ea3f..46cff467 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -389,7 +389,9 @@ "edit": "Editar la biblioteca", "delete": "Eliminar la biblioteca", "deleteConfirm": "Haz clic de nuevo para confirmar", - "importing": "Importando…" + "importing": "Importando…", + "deepRescan": "Reanálisis profundo (relee todos los archivos, más lento)", + "deepRescanning": "Reanálisis profundo en curso…" }, "changeCover": "Cambiar portada", "searchDeezer": "Buscar en Deezer", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 9748d229..5bb2b6d3 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -389,7 +389,9 @@ "changeArtwork": "Changer la pochette (bientôt)", "edit": "Modifier la bibliothèque", "delete": "Supprimer la bibliothèque", - "deleteConfirm": "Cliquez à nouveau pour confirmer" + "deleteConfirm": "Cliquez à nouveau pour confirmer", + "deepRescan": "Rescan approfondi (relit tous les fichiers, plus lent)", + "deepRescanning": "Rescan approfondi en cours…" }, "changeCover": "Changer la pochette", "searchDeezer": "Recherche Deezer", diff --git a/src/i18n/locales/hi.json b/src/i18n/locales/hi.json index 2355c242..0ca251b9 100644 --- a/src/i18n/locales/hi.json +++ b/src/i18n/locales/hi.json @@ -389,7 +389,9 @@ "edit": "लाइब्रेरी संपादित करें", "delete": "लाइब्रेरी हटाएँ", "deleteConfirm": "पुष्टि करने के लिए फिर से क्लिक करें", - "importing": "इम्पोर्ट हो रहा है…" + "importing": "इम्पोर्ट हो रहा है…", + "deepRescan": "गहन पुनःस्कैन (हर फ़ाइल दोबारा पढ़ता है, धीमा)", + "deepRescanning": "गहन पुनःस्कैन जारी…" }, "changeCover": "कवर बदलें", "searchDeezer": "Deezer में खोजें", diff --git a/src/i18n/locales/id.json b/src/i18n/locales/id.json index d79e3b74..f455ad37 100644 --- a/src/i18n/locales/id.json +++ b/src/i18n/locales/id.json @@ -389,7 +389,9 @@ "edit": "Sunting pustaka", "delete": "Hapus pustaka", "deleteConfirm": "Klik lagi untuk konfirmasi", - "importing": "Mengimpor…" + "importing": "Mengimpor…", + "deepRescan": "Pemindaian mendalam (membaca ulang semua berkas, lebih lambat)", + "deepRescanning": "Pemindaian mendalam berlangsung…" }, "changeCover": "Ganti sampul", "searchDeezer": "Cari di Deezer", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 21cf7dc4..67f2419b 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -389,7 +389,9 @@ "edit": "Modifica la libreria", "delete": "Elimina la libreria", "deleteConfirm": "Clicca di nuovo per confermare", - "importing": "Importazione…" + "importing": "Importazione…", + "deepRescan": "Riscansione approfondita (rilegge tutti i file, più lenta)", + "deepRescanning": "Riscansione approfondita in corso…" }, "changeCover": "Cambia copertina", "searchDeezer": "Cerca su Deezer", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index e70a272c..40b4a358 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -389,7 +389,9 @@ "edit": "ライブラリを変更する", "delete": "ライブラリを削除する", "deleteConfirm": "もう一度クリックして確認してください", - "importing": "インポート中…" + "importing": "インポート中…", + "deepRescan": "詳細再スキャン(全ファイルを読み直すため低速)", + "deepRescanning": "詳細再スキャン中…" }, "changeCover": "ジャケットを変更する", "searchDeezer": "Deezerの検索", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index fb65371b..660d5a7b 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -389,7 +389,9 @@ "edit": "라이브러리 수정", "delete": "라이브러리 삭제", "deleteConfirm": "확인을 위해 다시 클릭하세요", - "importing": "가져오는 중…" + "importing": "가져오는 중…", + "deepRescan": "정밀 재검색 (모든 파일을 다시 읽으므로 느림)", + "deepRescanning": "정밀 재검색 중…" }, "changeCover": "앨범 아트 변경", "searchDeezer": "Deezer 검색", diff --git a/src/i18n/locales/nl.json b/src/i18n/locales/nl.json index 8b9fd3e7..0dd08039 100644 --- a/src/i18n/locales/nl.json +++ b/src/i18n/locales/nl.json @@ -389,7 +389,9 @@ "edit": "Bibliotheek bewerken", "delete": "Bibliotheek verwijderen", "deleteConfirm": "Klik nogmaals om te bevestigen", - "importing": "Importeren…" + "importing": "Importeren…", + "deepRescan": "Diepe herscan (leest elk bestand opnieuw, trager)", + "deepRescanning": "Diepe herscan bezig…" }, "changeCover": "Albumhoes wijzigen", "searchDeezer": "Zoeken op Deezer", diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json index 25b7afbd..444be7e5 100644 --- a/src/i18n/locales/pt-BR.json +++ b/src/i18n/locales/pt-BR.json @@ -389,7 +389,9 @@ "edit": "Editar a biblioteca", "delete": "Excluir a biblioteca", "deleteConfirm": "Clique novamente para confirmar", - "importing": "Importando…" + "importing": "Importando…", + "deepRescan": "Reescaneamento profundo (relê todos os arquivos, mais lento)", + "deepRescanning": "Reescaneamento profundo em andamento…" }, "changeCover": "Trocar a capa", "searchDeezer": "Pesquisar no Deezer", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 01a27238..26df0d22 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -389,7 +389,9 @@ "edit": "Editar a biblioteca", "delete": "Eliminar a biblioteca", "deleteConfirm": "Clica novamente para confirmar", - "importing": "A importar…" + "importing": "A importar…", + "deepRescan": "Reanálise profunda (relê todos os ficheiros, mais lenta)", + "deepRescanning": "Reanálise profunda em curso…" }, "changeCover": "Mudar a capa", "searchDeezer": "Pesquisar no Deezer", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index dbf9ee08..a9d39bf4 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -415,7 +415,9 @@ "edit": "Изменить библиотеку", "delete": "Удалить библиотеку", "deleteConfirm": "Нажмите ещё раз для подтверждения", - "importing": "Импорт…" + "importing": "Импорт…", + "deepRescan": "Глубокое сканирование (перечитывает все файлы, медленнее)", + "deepRescanning": "Идёт глубокое сканирование…" }, "changeCover": "Сменить обложку", "searchDeezer": "Поиск в Deezer", diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index a838aa34..8a727478 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -389,7 +389,9 @@ "edit": "Kütüphaneyi düzenle", "delete": "Kütüphaneyi sil", "deleteConfirm": "Onaylamak için tekrar tıkla", - "importing": "İçe aktarılıyor…" + "importing": "İçe aktarılıyor…", + "deepRescan": "Derin tarama (her dosyayı yeniden okur, daha yavaş)", + "deepRescanning": "Derin tarama sürüyor…" }, "changeCover": "Kapağı değiştir", "searchDeezer": "Deezer'da ara", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index d306abca..1d413530 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -389,7 +389,9 @@ "edit": "编辑音乐库", "delete": "删除音乐库", "deleteConfirm": "再次点击以确认", - "importing": "正在导入…" + "importing": "正在导入…", + "deepRescan": "深度重新扫描(重新读取每个文件,较慢)", + "deepRescanning": "正在深度重新扫描…" }, "changeCover": "更换封面", "searchDeezer": "搜索 Deezer", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index c36a0a03..16468c90 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -389,7 +389,9 @@ "edit": "編輯音樂庫", "delete": "刪除音樂庫", "deleteConfirm": "請再次點擊以確認", - "importing": "正在匯入…" + "importing": "正在匯入…", + "deepRescan": "深度重新掃描(重新讀取每個檔案,較慢)", + "deepRescanning": "正在深度重新掃描…" }, "changeCover": "更換封面", "searchDeezer": "搜尋Deezer", diff --git a/src/lib/tauri/library.ts b/src/lib/tauri/library.ts index 792d61c8..0d75e11a 100644 --- a/src/lib/tauri/library.ts +++ b/src/lib/tauri/library.ts @@ -81,8 +81,19 @@ export function deleteLibrary(libraryId: number): Promise { return invoke("delete_library", { libraryId }); } -export function rescanLibrary(libraryId: number): Promise { - return invoke("rescan_library", { libraryId }); +/** + * Rescan every folder of a library. + * + * `deep` bypasses the `(mtime, size)` fast path so every file is + * re-hashed and re-read. Needed when an external tagger rewrote tags + * while preserving mtime — Mp3tag does that by default — which the + * normal pass cannot see (issue #457). Slower, so it stays opt-in. + */ +export function rescanLibrary( + libraryId: number, + deep = false, +): Promise { + return invoke("rescan_library", { libraryId, deep }); } export function addFolderToLibrary( From bca21dfaebdcf996e1a53006eb89075961bea175 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Wed, 29 Jul 2026 20:12:32 +0200 Subject: [PATCH 2/4] fix(library): one shared lock for every rescan control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings on #460. **Concurrent scans were possible.** The two header buttons only guarded against each other, leaving the per-folder deep-rescan button free to start a second scan alongside a library-wide one. `scan_folder_inner` writes and SQLite takes one writer at a time, so that is a real overlap, not just a confusing UI. All three controls now derive their disabled state and their early-return guard from one `isAnyRescanActive`. **One failing library stranded the rest.** The loop wrapped every iteration in a single try, so a drive that went away aborted the pass for the libraries after it. Errors are handled per library now. The plain rescan loop had the identical flaw right next to it and gets the same treatment — leaving one of two adjacent loops with the bug would be worse than the small extra diff. **Docs placed both entry points in the same screen.** The per-folder magnifier is under My music → Folders, but the library-wide button sits in the My music header and is reachable from any tab. Refs #457 --- docs/features/library.md | 8 ++-- src/components/views/LibraryView.tsx | 58 ++++++++++++++++++++-------- 2 files changed, 46 insertions(+), 20 deletions(-) diff --git a/docs/features/library.md b/docs/features/library.md index 237fd2b4..38833d16 100644 --- a/docs/features/library.md +++ b/docs/features/library.md @@ -32,10 +32,12 @@ A tag edit that rewrites the audio file is normally detected, since it moves tha A **deep rescan** bypasses `(mtime, size)` entirely and re-hashes + re-reads every file. It is the escape hatch for exactly the case above, and it is opt-in because it costs a full re-read of the library. -Two entry points, both in **My music → Folders**: +Two entry points, in different places: -- **per folder** — the magnifier button on a folder row (`scan_folder` with `deep: true`); -- **whole library** — the second button next to Rescan in the header (`rescan_library` with `deep: true`), added in issue #457. Until then the bypass existed per folder only, so the library-wide button users actually reach for could never see mtime-preserving edits. +- **per folder** — the magnifier button on a folder row, under **My music → Folders** (`scan_folder` with `deep: true`). Appears on row hover; +- **whole library** — the second button next to Rescan in the **My music header**, so it is reachable from any tab, not just Folders (`rescan_library` with `deep: true`). Added in issue #457: until then the bypass existed per folder only, so the library-wide button users actually reach for could never see mtime-preserving edits. + +Both are mutually exclusive with each other and with a plain rescan — `scan_folder_inner` writes, and SQLite takes one writer at a time. Note the interaction with **Split this artist** below: a deep rescan re-reads tags authoritatively and will undo an in-place split, since it no longer sees the split as deliberate. diff --git a/src/components/views/LibraryView.tsx b/src/components/views/LibraryView.tsx index d8e455cb..ab26445c 100644 --- a/src/components/views/LibraryView.tsx +++ b/src/components/views/LibraryView.tsx @@ -159,6 +159,14 @@ export function LibraryView({ const [deepRescanFolderId, setDeepRescanFolderId] = useState( null, ); + + // Any scan in flight, whichever control started it. `scan_folder_inner` + // is a writer and SQLite takes one writer at a time, so a folder-level + // deep pass and a library-wide one must not overlap — before this, the + // global buttons only guarded against each other and left the + // per-folder button free to start a second concurrent scan. + const isAnyRescanActive = + isRescanning || isDeepRescanning || deepRescanFolderId != null; const [isCreatePlaylistModalOpen, setIsCreatePlaylistModalOpen] = useState(false); // When the create-playlist modal is opened from a popover's "+ New @@ -439,36 +447,50 @@ export function LibraryView({ // Same rationale (issue #457): the normal pass trusts (mtime, size) // and therefore cannot see tags an external editor rewrote while // preserving mtime. - if (isRescanning || isDeepRescanning) return; + if (isAnyRescanActive) return; setIsDeepRescanning(true); try { + // Per-library error handling: one unreadable library (a drive + // that went away, a permission change) must not strand the ones + // after it — the user asked for a full pass. for (const lib of libraries) { - await rescanLibrary(lib.id, true); + try { + await rescanLibrary(lib.id, true); + } catch (err) { + console.error( + `[LibraryView] deep rescan failed for library ${lib.id}`, + err, + ); + } } - } catch (err) { - console.error("[LibraryView] deep rescan (library) failed", err); } finally { setIsDeepRescanning(false); } }; const handleRescan = async () => { - if (isRescanning) return; + if (isAnyRescanActive) return; setIsRescanning(true); try { - // Rescan every library the profile owns. + // Rescan every library the profile owns, one failure at a time — + // same reasoning as the deep pass above. for (const lib of libraries) { - await rescanLibrary(lib.id); + try { + await rescanLibrary(lib.id); + } catch (err) { + console.error( + `[LibraryView] rescan failed for library ${lib.id}`, + err, + ); + } } - } catch (err) { - console.error("[LibraryView] rescan failed", err); } finally { setIsRescanning(false); } }; const handleDeepRescanFolder = async (folderId: number) => { - if (deepRescanFolderId != null) return; + if (isAnyRescanActive) return; setDeepRescanFolderId(folderId); try { await scanFolder(folderId, true); @@ -533,9 +555,7 @@ export function LibraryView({