Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion docs/features/library.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 11 additions & 1 deletion src-tauri/crates/app/src/commands/library.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool>,
) -> AppResult<RescanSummary> {
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);
Expand All @@ -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 {
Expand Down
40 changes: 30 additions & 10 deletions src/components/common/ScanProgressToast.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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)
Expand All @@ -84,22 +87,39 @@ export function ScanProgressToast() {
<div className="flex items-start gap-3">
<div
className={`shrink-0 w-9 h-9 rounded-full flex items-center justify-center ${
done
? "bg-emerald-500/15 text-emerald-500"
: "bg-emerald-500/10 text-emerald-500"
partial
? "bg-amber-500/15 text-amber-500"
: done
? "bg-emerald-500/15 text-emerald-500"
: "bg-emerald-500/10 text-emerald-500"
}`}
>
{done ? (
{partial ? (
<AlertTriangle size={18} />
) : done ? (
<CheckCircle2 size={18} />
) : (
<FolderSearch size={18} className="animate-pulse" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
{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. */}
<div
className={`text-sm font-semibold ${
partial
? "text-amber-700 dark:text-amber-500"
: "text-zinc-900 dark:text-zinc-100"
}`}
>
{partial
? t("scanProgress.doneErrors", { count: errors })
: done
? t("scanProgress.doneTitle")
: t("scanProgress.runningTitle")}
</div>
<div className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">
{done
Expand Down
111 changes: 103 additions & 8 deletions src/components/views/LibraryView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
Tags,
Folder,
RefreshCcw,
FileSearch,
Clock,
LayoutList,
AlignJustify,
Expand Down Expand Up @@ -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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// 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<number | null>(
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
Expand Down Expand Up @@ -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,
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
if (failedFolders > 0) {
console.warn(
`[LibraryView] deep rescan finished with ${failedFolders} folder error(s)`,
);
}
} finally {
setIsDeepRescanning(false);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
};

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);
Expand Down Expand Up @@ -511,7 +575,7 @@ export function LibraryView({
<button
type="button"
onClick={handleRescan}
disabled={libraries.length === 0 || isRescanning}
disabled={libraries.length === 0 || isAnyRescanActive}
aria-label={t("library.actions.rescan")}
aria-busy={isRescanning}
className="p-2 rounded-lg transition-colors hover:bg-zinc-100 text-zinc-500 hover:text-zinc-800 dark:hover:bg-zinc-700 dark:text-zinc-400 dark:hover:text-white disabled:opacity-50 disabled:cursor-not-allowed"
Expand All @@ -522,6 +586,31 @@ export function LibraryView({
/>
</button>
</Tooltip>
{/* 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. */}
<Tooltip
label={
isDeepRescanning
? t("library.actions.deepRescanning")
: t("library.actions.deepRescan")
}
>
<button
type="button"
onClick={handleDeepRescanLibrary}
disabled={libraries.length === 0 || isAnyRescanActive}
aria-label={t("library.actions.deepRescan")}
aria-busy={isDeepRescanning}
className="p-2 rounded-lg transition-colors hover:bg-zinc-100 text-zinc-500 hover:text-zinc-800 dark:hover:bg-zinc-700 dark:text-zinc-400 dark:hover:text-white disabled:opacity-50 disabled:cursor-not-allowed"
>
<FileSearch
size={18}
className={isDeepRescanning ? "animate-pulse" : ""}
/>
</button>
</Tooltip>
</div>
</div>
</div>
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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({
Expand All @@ -2257,6 +2351,7 @@ function FolderList({
onRemove,
onDeepRescan,
deepRescanFolderId,
isAnyRescanActive,
}: FolderListProps) {
const [openMenuFolderId, setOpenMenuFolderId] = useState<number | null>(null);
// Two-step delete: first click arms the confirm state, second click
Expand Down Expand Up @@ -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 ${
Expand Down
4 changes: 2 additions & 2 deletions src/contexts/LibraryContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
},
Expand Down
4 changes: 3 additions & 1 deletion src/hooks/useLibrary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ interface LibraryContextValue {
/** Permanently delete a library and everything it owns. */
deleteLibrary: (libraryId: number) => Promise<void>;
/** Re-walk every folder of the library and sync the DB with disk. */
rescanLibrary: (libraryId: number) => Promise<RescanSummary>;
/** `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<RescanSummary>;
/**
* Register a folder inside a library and immediately scan it. Returns the
* summary so the UI can surface counts to the user.
Expand Down
12 changes: 10 additions & 2 deletions src/i18n/locales/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,9 @@
"edit": "تعديل المكتبة",
"delete": "حذف المكتبة",
"deleteConfirm": "انقر مرة أخرى للتأكيد",
"importing": "جارٍ الاستيراد…"
"importing": "جارٍ الاستيراد…",
"deepRescan": "فحص عميق (يعيد قراءة كل ملف، أبطأ)",
"deepRescanning": "جارٍ الفحص العميق…"
},
"changeCover": "تغيير الغلاف",
"searchDeezer": "البحث في Deezer",
Expand Down Expand Up @@ -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": {
Expand Down
8 changes: 6 additions & 2 deletions src/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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": {
Expand Down
Loading