diff --git a/docs/features/library.md b/docs/features/library.md index 7a4eaa52..38833d16 100644 --- a/docs/features/library.md +++ b/docs/features/library.md @@ -26,7 +26,20 @@ 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, in different places: + +- **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. ## 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/common/ScanProgressToast.tsx b/src/components/common/ScanProgressToast.tsx index 5a3ce343..3de3d439 100644 --- a/src/components/common/ScanProgressToast.tsx +++ b/src/components/common/ScanProgressToast.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { listen } from "@tauri-apps/api/event"; -import { FolderSearch, X, CheckCircle2 } from "lucide-react"; +import { FolderSearch, X, CheckCircle2, AlertTriangle } from "lucide-react"; interface ScanProgress { folder_id: number; @@ -64,10 +64,13 @@ export function ScanProgressToast() { if (progress == null || dismissed) return null; - const { current, total, added, updated, skipped, done, current_dir } = + const { current, total, added, updated, skipped, errors, done, current_dir } = progress; const percent = total > 0 ? Math.min(100, Math.round((current / total) * 100)) : 0; + // A finished scan that hit per-file failures. The backend reports it + // as done regardless, so this is the only signal the user gets. + const partial = done && errors > 0; // Show the last two path segments (…/Parent/Album) so the user sees the // scan walking through folders without the toast overflowing on a deep // absolute path; the full path rides in the title tooltip. (#430) @@ -84,22 +87,39 @@ export function ScanProgressToast() {
- {done ? ( + {partial ? ( + + ) : done ? ( ) : ( )}
-
- {done - ? t("scanProgress.doneTitle") - : t("scanProgress.runningTitle")} + {/* A scan that hit errors must not read as a plain success: + the backend reports it as complete either way, so the + failure count takes the headline (and the icon goes amber) + rather than trailing a green "Scan complete". The + added/updated/skipped line stays below as context. */} +
+ {partial + ? t("scanProgress.doneErrors", { count: errors }) + : done + ? t("scanProgress.doneTitle") + : t("scanProgress.runningTitle")}
{done diff --git a/src/components/views/LibraryView.tsx b/src/components/views/LibraryView.tsx index a7a76f51..33fdaed4 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,12 +150,23 @@ 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. 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 @@ -430,23 +442,75 @@ 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 (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. + // + // A rejected promise is only half the story: `rescan_library` + // swallows per-folder failures into `summary.errors` and still + // resolves, so a partially failed pass looks identical to a clean + // one unless the summary is inspected. + let failedFolders = 0; + for (const lib of libraries) { + try { + const summary = await rescanLibrary(lib.id, true); + failedFolders += summary.errors; + } catch (err) { + console.error( + `[LibraryView] deep rescan failed for library ${lib.id}`, + err, + ); + } + } + if (failedFolders > 0) { + console.warn( + `[LibraryView] deep rescan finished with ${failedFolders} folder error(s)`, + ); + } + } 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, and the same summary caveat, as the deep pass + // above. + let failedFolders = 0; for (const lib of libraries) { - await rescanLibrary(lib.id); + try { + const summary = await rescanLibrary(lib.id); + failedFolders += summary.errors; + } catch (err) { + console.error( + `[LibraryView] rescan failed for library ${lib.id}`, + err, + ); + } + } + if (failedFolders > 0) { + console.warn( + `[LibraryView] rescan finished with ${failedFolders} folder error(s)`, + ); } - } 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); @@ -511,7 +575,7 @@ 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. */} + + +
@@ -764,6 +853,7 @@ export function LibraryView({ }} onDeepRescan={handleDeepRescanFolder} deepRescanFolderId={deepRescanFolderId} + isAnyRescanActive={isAnyRescanActive} onToggleWatched={(folderId, enable) => { // Optimistic flip — the watcher hookup is fire-and- // forget on the backend so the UI shouldn't block on it. @@ -2244,6 +2334,10 @@ interface FolderListProps { onDeepRescan: (folderId: number) => void; /** The folder a deep rescan is currently running against, if any. */ deepRescanFolderId: number | null; + /** True while ANY scan is running — a library-wide pass included, not + * just a folder one. SQLite takes a single writer, so this row's + * button has to stand down for the global controls too. */ + isAnyRescanActive: boolean; } function FolderList({ @@ -2257,6 +2351,7 @@ function FolderList({ onRemove, onDeepRescan, deepRescanFolderId, + isAnyRescanActive, }: FolderListProps) { const [openMenuFolderId, setOpenMenuFolderId] = useState(null); // Two-step delete: first click arms the confirm state, second click @@ -2392,7 +2487,7 @@ function FolderList({ e.stopPropagation(); onDeepRescan(folder.id); }} - disabled={deepRescanFolderId != null} + disabled={isAnyRescanActive} aria-label={t("library.folderList.deepRescan")} aria-busy={deepRescanFolderId === folder.id} className={`p-1.5 rounded-full transition-colors text-zinc-400 hover:text-zinc-800 dark:hover:text-white hover:bg-zinc-100 dark:hover:bg-zinc-700 disabled:opacity-50 ${ 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..376cf868 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", @@ -2129,7 +2131,13 @@ "runningSubtitle": "{{current}} / {{total}} ملف", "doneTitle": "اكتمل الفحص", "doneSubtitle": "{{added}} مضاف · {{updated}} محدّث · {{skipped}} متخطّى", - "scanningIn": "جارٍ فحص {{dir}}" + "scanningIn": "جارٍ فحص {{dir}}", + "doneErrors_zero": "لم يفشل أي ملف", + "doneErrors_one": "فشل ملف واحد", + "doneErrors_two": "فشل ملفان", + "doneErrors_few": "فشل {{count}} ملفات", + "doneErrors_many": "فشل {{count}} ملفًا", + "doneErrors_other": "فشل {{count}} ملف" }, "system": { "tray": { diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 64a4dc40..ac6abf42 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", @@ -1980,7 +1982,9 @@ "runningSubtitle": "{{current}} / {{total}} Dateien", "doneTitle": "Scan abgeschlossen", "doneSubtitle": "{{added}} hinzugefügt · {{updated}} aktualisiert · {{skipped}} übersprungen", - "scanningIn": "Durchsuche {{dir}}" + "scanningIn": "Durchsuche {{dir}}", + "doneErrors_one": "{{count}} Datei fehlgeschlagen", + "doneErrors_other": "{{count}} Dateien fehlgeschlagen" }, "system": { "tray": { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b7548a1c..a5593180 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", @@ -1980,7 +1982,9 @@ "runningSubtitle": "{{current}} / {{total}} files", "doneTitle": "Scan complete", "doneSubtitle": "{{added}} added · {{updated}} updated · {{skipped}} skipped", - "scanningIn": "Scanning {{dir}}" + "scanningIn": "Scanning {{dir}}", + "doneErrors_one": "{{count}} file failed", + "doneErrors_other": "{{count}} files failed" }, "system": { "tray": { diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 31b2ea3f..12dc13f0 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", @@ -1980,7 +1982,9 @@ "runningSubtitle": "{{current}} / {{total}} archivos", "doneTitle": "Escaneo completado", "doneSubtitle": "{{added}} añadidos · {{updated}} actualizados · {{skipped}} omitidos", - "scanningIn": "Analizando {{dir}}" + "scanningIn": "Analizando {{dir}}", + "doneErrors_one": "{{count}} archivo con error", + "doneErrors_other": "{{count}} archivos con error" }, "system": { "tray": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 9748d229..79232622 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", @@ -1980,7 +1982,9 @@ "runningSubtitle": "{{current}} / {{total}} fichiers", "doneTitle": "Analyse terminée", "doneSubtitle": "{{added}} ajoutés · {{updated}} mis à jour · {{skipped}} ignorés", - "scanningIn": "Analyse de {{dir}}" + "scanningIn": "Analyse de {{dir}}", + "doneErrors_one": "{{count}} fichier en échec", + "doneErrors_other": "{{count}} fichiers en échec" }, "system": { "tray": { diff --git a/src/i18n/locales/hi.json b/src/i18n/locales/hi.json index 2355c242..03da125e 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 में खोजें", @@ -1980,7 +1982,9 @@ "runningSubtitle": "{{current}} / {{total}} फ़ाइलें", "doneTitle": "स्कैन पूर्ण", "doneSubtitle": "{{added}} जोड़े · {{updated}} अपडेट · {{skipped}} छोड़े", - "scanningIn": "{{dir}} स्कैन हो रहा है" + "scanningIn": "{{dir}} स्कैन हो रहा है", + "doneErrors_one": "{{count}} फ़ाइल विफल", + "doneErrors_other": "{{count}} फ़ाइलें विफल" }, "system": { "tray": { diff --git a/src/i18n/locales/id.json b/src/i18n/locales/id.json index d79e3b74..e020f90b 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", @@ -1980,7 +1982,9 @@ "runningSubtitle": "{{current}} / {{total}} berkas", "doneTitle": "Pemindaian selesai", "doneSubtitle": "{{added}} ditambahkan · {{updated}} diperbarui · {{skipped}} dilewati", - "scanningIn": "Memindai {{dir}}" + "scanningIn": "Memindai {{dir}}", + "doneErrors_one": "{{count}} berkas gagal", + "doneErrors_other": "{{count}} berkas gagal" }, "system": { "tray": { diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 21cf7dc4..a7237f0b 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", @@ -1980,7 +1982,9 @@ "runningSubtitle": "{{current}} / {{total}} file", "doneTitle": "Scansione completata", "doneSubtitle": "{{added}} aggiunti · {{updated}} aggiornati · {{skipped}} saltati", - "scanningIn": "Scansione di {{dir}}" + "scanningIn": "Scansione di {{dir}}", + "doneErrors_one": "{{count}} file non riuscito", + "doneErrors_other": "{{count}} file non riusciti" }, "system": { "tray": { diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index e70a272c..5bea50e6 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の検索", @@ -1966,7 +1968,9 @@ "runningSubtitle": "{{current}} / {{total}} ファイル", "doneTitle": "スキャン完了", "doneSubtitle": "{{added}} 追加 · {{updated}} 更新 · {{skipped}} スキップ", - "scanningIn": "{{dir}} をスキャン中" + "scanningIn": "{{dir}} をスキャン中", + "doneErrors_one": "{{count}} 件のファイルが失敗", + "doneErrors_other": "{{count}} 件のファイルが失敗" }, "system": { "tray": { diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index fb65371b..d2d11829 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 검색", @@ -1980,7 +1982,9 @@ "runningSubtitle": "{{current}} / {{total}}개 파일", "doneTitle": "스캔 완료", "doneSubtitle": "{{added}}개 추가 · {{updated}}개 업데이트 · {{skipped}}개 건너뜀", - "scanningIn": "{{dir}} 스캔 중" + "scanningIn": "{{dir}} 스캔 중", + "doneErrors_one": "{{count}}개 파일 실패", + "doneErrors_other": "{{count}}개 파일 실패" }, "system": { "tray": { diff --git a/src/i18n/locales/nl.json b/src/i18n/locales/nl.json index 8b9fd3e7..07686221 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", @@ -1980,7 +1982,9 @@ "runningSubtitle": "{{current}} / {{total}} bestanden", "doneTitle": "Scan voltooid", "doneSubtitle": "{{added}} toegevoegd · {{updated}} bijgewerkt · {{skipped}} overgeslagen", - "scanningIn": "Bezig met {{dir}}" + "scanningIn": "Bezig met {{dir}}", + "doneErrors_one": "{{count}} bestand mislukt", + "doneErrors_other": "{{count}} bestanden mislukt" }, "system": { "tray": { diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json index 25b7afbd..5455dd22 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", @@ -1980,7 +1982,9 @@ "runningSubtitle": "{{current}} / {{total}} arquivos", "doneTitle": "Escaneamento concluído", "doneSubtitle": "{{added}} adicionados · {{updated}} atualizados · {{skipped}} ignorados", - "scanningIn": "Analisando {{dir}}" + "scanningIn": "Analisando {{dir}}", + "doneErrors_one": "{{count}} arquivo falhou", + "doneErrors_other": "{{count}} arquivos falharam" }, "system": { "tray": { diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 01a27238..7a07b21e 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", @@ -1980,7 +1982,9 @@ "runningSubtitle": "{{current}} / {{total}} ficheiros", "doneTitle": "Análise concluída", "doneSubtitle": "{{added}} adicionados · {{updated}} atualizados · {{skipped}} ignorados", - "scanningIn": "A analisar {{dir}}" + "scanningIn": "A analisar {{dir}}", + "doneErrors_one": "{{count}} ficheiro falhou", + "doneErrors_other": "{{count}} ficheiros falharam" }, "system": { "tray": { diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index dbf9ee08..eda970a1 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", @@ -2072,7 +2074,11 @@ "runningSubtitle": "{{current}} / {{total}} файлов", "doneTitle": "Сканирование завершено", "doneSubtitle": "Добавлено: {{added}} · Обновлено: {{updated}} · Пропущено: {{skipped}}", - "scanningIn": "Сканирование {{dir}}" + "scanningIn": "Сканирование {{dir}}", + "doneErrors_one": "{{count}} файл не удалось обработать", + "doneErrors_few": "{{count}} файла не удалось обработать", + "doneErrors_many": "{{count}} файлов не удалось обработать", + "doneErrors_other": "{{count}} файлов не удалось обработать" }, "system": { "tray": { diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index a838aa34..416e5bdd 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", @@ -1980,7 +1982,9 @@ "runningSubtitle": "{{current}} / {{total}} dosya", "doneTitle": "Tarama tamamlandı", "doneSubtitle": "{{added}} eklendi · {{updated}} güncellendi · {{skipped}} atlandı", - "scanningIn": "Taranıyor: {{dir}}" + "scanningIn": "Taranıyor: {{dir}}", + "doneErrors_one": "{{count}} dosya başarısız", + "doneErrors_other": "{{count}} dosya başarısız" }, "system": { "tray": { diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index d306abca..d8a5a9d5 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", @@ -1966,7 +1968,9 @@ "runningSubtitle": "{{current}} / {{total}} 个文件", "doneTitle": "扫描完成", "doneSubtitle": "已添加 {{added}} · 已更新 {{updated}} · 已跳过 {{skipped}}", - "scanningIn": "正在扫描 {{dir}}" + "scanningIn": "正在扫描 {{dir}}", + "doneErrors_one": "{{count}} 个文件失败", + "doneErrors_other": "{{count}} 个文件失败" }, "system": { "tray": { diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index c36a0a03..084a7d76 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", @@ -1966,7 +1968,9 @@ "runningSubtitle": "{{current}} / {{total}} 個檔案", "doneTitle": "掃描完成", "doneSubtitle": "已新增 {{added}} · 已更新 {{updated}} · 已略過 {{skipped}}", - "scanningIn": "正在掃描 {{dir}}" + "scanningIn": "正在掃描 {{dir}}", + "doneErrors_one": "{{count}} 個檔案失敗", + "doneErrors_other": "{{count}} 個檔案失敗" }, "system": { "tray": { 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(