Files
bookorbit/server/src/modules/scanner/lib/walk.ts
T
bobberb 17aa7425f8 feat(library): per-library added_at source (imported/file_modified/file_created) (#1239)
* feat(scanner): set book added_at from earliest on-disk file mtime

A book's "date added" now reflects when its content files first landed on
disk (the earliest mtime across its content-role files) instead of the
moment BookOrbit imported it. Cover/metadata/supplement sidecars are
excluded. When no content file has a usable mtime, addedAt is left unset so
the DB defaultNow() fallback (import time) still applies. Only set at
initial book creation; re-scan/restore paths do not touch it.

* feat(library): add per-library added_at source with recompute

Add an addedAtSource setting to libraries so "date added" can follow the
book's files on disk rather than the moment the scanner happened to see
them. The setting takes imported (unchanged default), file_modified, or
file_created, guarded by a check constraint on the column.

Expose a recompute action so the chosen source can be applied to books
that are already imported. file_modified runs as a single set-based
update over the stored mtimes; file_created has to stat the files, since
birthtime is not persisted, so it batches those reads.

Leave every existing library on imported so behavior is unchanged unless
a user opts in.

* feat(library): add added_at source control to the library editor

Surface the new addedAtSource setting in the library editor as a radio
group, alongside a button that triggers the recompute for the library
being edited.

Keep the recompute out of the save path so changing the setting stays
cheap: the setting alone only affects books imported from then on, and
applying it to existing books is an explicit choice.

* i18n(library): translate added_at source strings (de/nl/pt/sl)

* fix(library): correct create payload, recompute floor and failure logging

The library creator posts its whole form, so omitting addedAtSource from
CreateLibraryDto made the global whitelist reject every create request
with a 400. Declare the field, validate it against the same source list
as the update DTO, and persist it so the initial scan uses the requested
source rather than silently falling back to imported.

Apply the scanner's usable-time floor to the mtime recompute query too.
It previously excluded only NULL, so a filesystem reporting epoch 0 would
rewrite added_at to 1970 instead of leaving the import time in place. The
floor now lives in library.constants rather than being spelled out twice.

Wrap the recompute in try/catch so a database or stat failure emits the
[fail] log the logging conventions require instead of propagating with
only a [start] line recorded.

* fix(library): gate recompute on the saved source and label the radios

The recompute button stayed enabled while the selected source differed
from the stored one. Because the request carries no source, clicking it
recomputed against the old stored value while the UI showed the new
selection. A hint already said to save first; disable the control as well
so the mismatch is not actionable.

Give the source list radiogroup semantics with aria-checked so screen
readers can tell which option is active, since selection was conveyed by
styling alone.

* test(library): assert the create DTO under the real validation options

The previous comment claimed the case exercised whitelist and
forbidNonWhitelisted, but the shared helper calls validate without them,
so it only proved @IsIn rejects an unknown value.

Drop the misleading comment and add a case that validates with the same
options main.ts gives ValidationPipe, so an undeclared addedAtSource
fails the way it would in a real request.

* docs(library): remove redundant date-added documentation

---------

Co-authored-by: Bob Bobber <user@example.com>
Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com>
2026-09-10 10:43:47 -06:00

475 lines
18 KiB
TypeScript

import { usableFileTime } from '../../../common/utils/file-time.utils';
import { readdir, stat } from 'fs/promises';
import { basename, dirname, join, relative } from 'path';
import { naturalCompare } from '../../../common/utils/natural-sort.utils';
import { classifyFile, isPrimaryFormat, isAudioFormat, type FileRole } from './classify';
export interface FileStat {
absolutePath: string;
relPath: string; // relative to library folder root
ino: bigint;
sizeBytes: number;
mtime: Date;
birthtime: Date;
format: string | null;
role: FileRole;
}
export interface BookCandidate {
folderPath: string; // absolute path — unique key for a book in the DB
files: FileStat[]; // all files in this folder
}
export interface WalkResult {
candidates: BookCandidate[];
skippedDirs: Set<string>;
unchangedDirs: Set<string>;
dirMtimes: Map<string, number>;
}
/**
* Derive a book's "date added" from the earliest on-disk time of its content
* files. This approximates when the book first landed on disk, rather than when
* BookOrbit happened to import it. Cover/metadata/supplement sidecars are
* excluded because they can be added later without meaning the book is "newer".
*
* For 'file_modified' the earliest valid mtime is used. For 'file_created' the
* earliest valid birthtime is used, falling back to that same file's mtime when
* its birthtime is missing or invalid (some filesystems do not track creation
* time). Returns undefined when no content file yields a usable time, so callers
* can fall back to the DB defaultNow() (import time).
*/
export function earliestContentTime(files: FileStat[], source: 'file_modified' | 'file_created'): Date | undefined {
let earliest: Date | undefined;
for (const file of files) {
if (file.role !== 'content') continue;
let candidate: Date | undefined;
if (source === 'file_modified') {
candidate = usableFileTime(file.mtime);
} else {
candidate = usableFileTime(file.birthtime) ?? usableFileTime(file.mtime);
}
if (candidate === undefined) continue;
if (earliest === undefined || candidate < earliest) earliest = candidate;
}
return earliest;
}
const MAX_PATH_LENGTH = 4096;
const DIR_CONCURRENCY_LIMIT = 50;
// Matches common disc subdirectory names: "CD 1", "Disc 2", "Disk03", "Part A", "Side IV"
// but avoids broad matches like "Discography".
const DISC_DIR_PATTERN = /^(?:cd|disc|disk|part|pt|side)(?:[\s_-]*(?:\d+|[A-Za-z]|[IVXLCM]+))$/i;
export function isDiscDirectory(name: string): boolean {
return DISC_DIR_PATTERN.test(name);
}
// Returns the filename stem (basename without the last extension).
export function stemOf(name: string): string {
const i = name.lastIndexOf('.');
return i > 0 ? name.slice(0, i) : name;
}
function buildExcludeMatcher(patterns: string[]): (name: string) => boolean {
if (patterns.length === 0) return () => false;
const compiled = patterns.map((p) => {
if (!p.includes('*')) return { literal: p, regex: null as RegExp | null };
const escaped = p.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
return { literal: null as string | null, regex: new RegExp(`^${escaped}$`) };
});
return (name: string) => {
for (const { literal, regex } of compiled) {
if (literal !== null ? name === literal : regex!.test(name)) return true;
}
return false;
};
}
async function statFilesIntoAcc(
filePaths: string[],
dir: string,
libraryRoot: string,
acc: Map<string, FileStat[]>,
logger?: (msg: string) => void,
): Promise<void> {
const statResults = await Promise.all(
filePaths.map(async (full) => {
const s = await stat(full, { bigint: true }).catch((err: NodeJS.ErrnoException) => {
if (err.code === 'ENOENT') return null;
throw err;
});
return s ? { full, s } : null;
}),
);
for (const entry of statResults) {
if (!entry) continue;
const { full, s } = entry;
const ino = s.ino;
if (s.ino === 0n) {
logger?.(`File has inode 0 (likely network mount), rename detection disabled: ${full}`);
}
if (!acc.has(dir)) acc.set(dir, []);
const { format, role } = classifyFile(full);
acc.get(dir)!.push({
absolutePath: full,
relPath: relative(libraryRoot, full),
ino,
sizeBytes: Number(s.size),
mtime: s.mtime,
birthtime: s.birthtime,
format,
role,
});
}
}
// Recursively collect files, grouped by their parent directory.
async function collectByDir(
dir: string,
libraryRoot: string,
acc: Map<string, FileStat[]>,
shouldExclude: (name: string) => boolean,
skippedDirs: Set<string>,
logger?: (msg: string) => void,
knownDirMtimes?: Map<string, number>,
unchangedDirs?: Set<string>,
dirMtimes?: Map<string, number>,
): Promise<void> {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'EACCES' || code === 'EPERM') {
logger?.(`Permission denied reading folder, skipping: ${dir}`);
skippedDirs.add(dir);
return;
}
throw err;
}
// Record this directory's mtime for incremental scan state
if (dirMtimes) {
try {
const dirStat = await stat(dir);
dirMtimes.set(dir, Math.round(dirStat.mtimeMs));
} catch {
// Dir was readable (readdir succeeded) but stat failed - unusual, just skip mtime tracking
}
}
const subdirs: string[] = [];
const filePaths: string[] = [];
for (const entry of entries) {
const full = join(dir, entry.name);
if (entry.name.startsWith('.')) continue;
if (shouldExclude(entry.name)) continue;
if (entry.isDirectory() && !entry.isSymbolicLink()) {
subdirs.push(full);
} else if (entry.isFile() && !entry.isSymbolicLink()) {
if (full.length > MAX_PATH_LENGTH) {
logger?.(`Path exceeds ${MAX_PATH_LENGTH} characters, skipping: ${full}`);
continue;
}
filePaths.push(full);
}
}
// Leaf-level mtime skip: if this dir has files, check if its mtime is unchanged.
// Safety rules:
// 1. Never skip disc dirs (they get flattened into parent)
// 2. Never skip dirs with disc subdirs (flattening merges disc files into parent,
// so parent must always have its own files scanned for a complete candidate)
// 3. Never skip non-root dirs if parent dir's mtime changed (parent may have new files
// that turn this dir into a stem-named subdir needing flattening).
// Root is exempt: root-level files are never stem-merged into a parent (see
// buildBookCandidates: parent === libraryFolderPath is explicitly skipped).
const hasDiscSubdirs = subdirs.some((s) => isDiscDirectory(basename(s)));
const canSkip = knownDirMtimes && unchangedDirs && dirMtimes && filePaths.length > 0 && !isDiscDirectory(basename(dir)) && !hasDiscSubdirs;
if (canSkip) {
// For non-root dirs, also require parent mtime to be unchanged (guards stem-named merging).
// Root has no library parent, so this check is skipped — root-level files are never
// stem-merged and the stem-flattening loop explicitly skips root children.
let parentChanged = false;
if (dir !== libraryRoot) {
const parentDir = dirname(dir);
const parentStoredMtime = knownDirMtimes!.get(parentDir);
const parentCurrentMtime = dirMtimes!.get(parentDir);
parentChanged = parentStoredMtime === undefined || parentCurrentMtime === undefined || parentStoredMtime !== parentCurrentMtime;
}
const storedMtime = knownDirMtimes!.get(dir);
const currentMtime = dirMtimes!.get(dir);
if (!parentChanged && storedMtime !== undefined && currentMtime !== undefined && storedMtime === currentMtime) {
unchangedDirs!.add(dir);
} else {
await statFilesIntoAcc(filePaths, dir, libraryRoot, acc, logger);
}
} else {
if (filePaths.length > 0) {
await statFilesIntoAcc(filePaths, dir, libraryRoot, acc, logger);
}
}
// Bounded concurrency: process subdirs in chunks to avoid EMFILE
for (let i = 0; i < subdirs.length; i += DIR_CONCURRENCY_LIMIT) {
const chunk = subdirs.slice(i, i + DIR_CONCURRENCY_LIMIT);
await Promise.all(
chunk.map((full) => collectByDir(full, libraryRoot, acc, shouldExclude, skippedDirs, logger, knownDirMtimes, unchangedDirs, dirMtimes)),
);
}
}
/**
* Walk a library folder and return book candidates.
*
* Rules:
* - Root-level primary file → its own BookCandidate (folderPath = absolutePath).
* - Disc subdirectories (e.g. "CD 1", "Disc 2") are flattened into their parent
* before any other grouping logic runs.
* - Subdirectory where any primary file is an audio format → one BookCandidate
* for the whole folder, files natural-sorted by basename.
* - Subdirectory where all primary files share the same stem
* (e.g. book.epub + book.mobi = same book in two formats) → one BookCandidate,
* folderPath = dir, files = everything in the dir.
* - Subdirectory with primary files of DIFFERENT stems (e.g. series folder with one
* epub per book) → one BookCandidate per stem, folderPath = join(dir, stem),
* files = primary files for that stem + any non-primary files with matching stem.
* - Directories with no primary files are skipped (author/grouping folders).
*/
export async function findBookCandidates(
libraryFolderPath: string,
excludePatterns: string[] = [],
logger?: (msg: string) => void,
knownDirMtimes?: Map<string, number>,
): Promise<WalkResult> {
const byDir = new Map<string, FileStat[]>();
const shouldExclude = buildExcludeMatcher(excludePatterns);
const skippedDirs = new Set<string>();
const unchangedDirs = new Set<string>();
const dirMtimes = new Map<string, number>();
await collectByDir(libraryFolderPath, libraryFolderPath, byDir, shouldExclude, skippedDirs, logger, knownDirMtimes, unchangedDirs, dirMtimes);
// Flatten disc subdirectories (e.g. "CD 1", "Disc 2") into their parent.
// Collect disc dirs first to avoid mutating the map while iterating.
const discDirs: string[] = [];
for (const [dir] of byDir) {
if (isDiscDirectory(basename(dir))) {
discDirs.push(dir);
}
}
for (const discDir of discDirs) {
const files = byDir.get(discDir)!;
const parent = dirname(discDir);
if (!byDir.has(parent)) byDir.set(parent, []);
byDir.get(parent)!.push(...files);
byDir.delete(discDir);
}
// Stem-named subfolder flattening: if a subdirectory's name exactly matches the
// stem of a sibling file in its parent, treat it as part of the parent book.
// This handles the common pattern of ebooks alongside a same-named audio folder
// (e.g. "Book.epub" + "Book/" containing mp3 tracks).
// Skipped when the parent is the library root — root-level files are always
// individual candidates and should not absorb subfolder content.
const stemNamedDirs: string[] = [];
for (const [dir] of byDir) {
const parent = dirname(dir);
if (parent === libraryFolderPath || !byDir.has(parent)) continue;
const parentStems = new Set(byDir.get(parent)!.map((f) => stemOf(basename(f.absolutePath))));
if (parentStems.has(basename(dir))) stemNamedDirs.push(dir);
}
for (const stemDir of stemNamedDirs) {
const files = byDir.get(stemDir)!;
const parent = dirname(stemDir);
if (!byDir.has(parent)) byDir.set(parent, []);
byDir.get(parent)!.push(...files);
byDir.delete(stemDir);
}
const candidates: BookCandidate[] = [];
for (const [dir, files] of byDir) {
const primaryFiles = files.filter((f) => isPrimaryFormat(f.absolutePath));
if (primaryFiles.length === 0) continue;
if (dir === libraryFolderPath) {
// Root-level: each primary file is its own book. Sidecar files (non-primary)
// are grouped with their primary by matching stem (e.g. book.epub + book.opf + book.jpg).
const sidecarsByStem = new Map<string, FileStat[]>();
for (const file of files) {
if (isPrimaryFormat(file.absolutePath)) continue;
const stem = stemOf(basename(file.absolutePath));
const existing = sidecarsByStem.get(stem);
if (existing) existing.push(file);
else sidecarsByStem.set(stem, [file]);
}
for (const file of primaryFiles) {
const stem = stemOf(basename(file.absolutePath));
const sidecars = sidecarsByStem.get(stem) ?? [];
sidecarsByStem.delete(stem);
candidates.push({ folderPath: file.absolutePath, files: [file, ...sidecars] });
}
continue;
}
// If any primary file is an audio format, treat the entire folder as one audiobook.
// Files are natural-sorted by basename so playback order is deterministic.
const hasAudio = primaryFiles.some((f) => {
return f.format !== null && f.format !== undefined && isAudioFormat(f.format);
});
if (hasAudio) {
const sorted = [...files].sort((a, b) => naturalCompare(basename(a.absolutePath), basename(b.absolutePath)));
candidates.push({ folderPath: dir, files: sorted });
continue;
}
// One folder = one book. All formats and sidecar files belong to the same book.
candidates.push({ folderPath: dir, files });
}
return { candidates, skippedDirs, unchangedDirs, dirMtimes };
}
/**
* Walk a library folder and return one BookCandidate per primary content file,
* regardless of folder depth. Used when `organizationMode === 'book_per_file'`.
*
* Each candidate has:
* folderPath = absolutePath of the file (the unique book key in the DB)
* files = [that single file]
*
* Non-primary files (covers, sidecars, NFO, etc.) are intentionally excluded
* because in this mode there is no unambiguous way to associate a sidecar with
* a specific book without folder co-location.
*/
export async function findLooseFileCandidates(
libraryFolderPath: string,
excludePatterns: string[] = [],
logger?: (msg: string) => void,
knownDirMtimes?: Map<string, number>,
): Promise<WalkResult> {
const byDir = new Map<string, FileStat[]>();
const shouldExclude = buildExcludeMatcher(excludePatterns);
const skippedDirs = new Set<string>();
const unchangedDirs = new Set<string>();
const dirMtimes = new Map<string, number>();
await collectByDir(libraryFolderPath, libraryFolderPath, byDir, shouldExclude, skippedDirs, logger, knownDirMtimes, unchangedDirs, dirMtimes);
const candidates: BookCandidate[] = [];
for (const files of byDir.values()) {
for (const fileStat of files) {
if (isPrimaryFormat(fileStat.absolutePath)) {
candidates.push({ folderPath: fileStat.absolutePath, files: [fileStat] });
}
}
}
return { candidates, skippedDirs, unchangedDirs, dirMtimes };
}
/**
* Build a single BookCandidate for a known book folder without treating it as a
* library root. Reads only direct children of folderPath plus any disc subdirectories
* (CD 1, Disc 2, etc.). Returns null if the folder is unreadable or has no primary files.
*
* This is used by targeted folder scans triggered by file-system events so that files
* in the book folder are never misclassified as root-level loose-file books.
*/
export async function buildSingleBookCandidate(
folderPath: string,
libraryFolderPath: string,
excludePatterns: string[] = [],
logger?: (msg: string) => void,
): Promise<BookCandidate | null> {
let entries;
try {
entries = await readdir(folderPath, { withFileTypes: true });
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code !== 'ENOENT') logger?.(`Cannot read folder ${folderPath}: ${(err as Error).message}`);
return null;
}
const shouldExclude = buildExcludeMatcher(excludePatterns);
const filePaths: string[] = [];
const discDirs: string[] = [];
const nonDiscDirs: { name: string; path: string }[] = [];
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
if (shouldExclude(entry.name)) continue;
const full = join(folderPath, entry.name);
if (entry.isDirectory() && !entry.isSymbolicLink()) {
if (isDiscDirectory(entry.name)) {
discDirs.push(full);
} else {
nonDiscDirs.push({ name: entry.name, path: full });
}
} else if (entry.isFile() && !entry.isSymbolicLink() && full.length <= MAX_PATH_LENGTH) {
filePaths.push(full);
}
}
for (const discDir of discDirs) {
const discEntries = await readdir(discDir, { withFileTypes: true }).catch(() => []);
for (const entry of discEntries) {
if (!entry.isFile() || entry.isSymbolicLink() || entry.name.startsWith('.')) continue;
if (shouldExclude(entry.name)) continue;
const full = join(discDir, entry.name);
if (full.length <= MAX_PATH_LENGTH) filePaths.push(full);
}
}
if (filePaths.length === 0) return null;
// Stem-named non-disc subdirectory flattening: same logic as findBookCandidates.
// If a subdir's name matches the stem of a direct sibling file, include its files.
const fileStems = new Set(filePaths.map((fp) => stemOf(basename(fp))));
for (const { name, path: stemDir } of nonDiscDirs) {
if (!fileStems.has(name)) continue;
const stemEntries = await readdir(stemDir, { withFileTypes: true }).catch(() => []);
for (const entry of stemEntries) {
if (!entry.isFile() || entry.name.startsWith('.')) continue;
if (shouldExclude(entry.name)) continue;
const full = join(stemDir, entry.name);
if (full.length <= MAX_PATH_LENGTH) filePaths.push(full);
}
}
const stats = await Promise.all(
filePaths.map(async (full) => {
const s = await stat(full, { bigint: true }).catch(() => null);
if (!s) return null;
const { format, role } = classifyFile(full);
return {
absolutePath: full,
relPath: relative(libraryFolderPath, full),
ino: s.ino,
sizeBytes: Number(s.size),
mtime: s.mtime,
birthtime: s.birthtime,
format,
role,
} satisfies FileStat;
}),
);
const allFiles = stats.filter((f): f is FileStat => f !== null);
if (!allFiles.some((f) => isPrimaryFormat(f.absolutePath))) return null;
return {
folderPath,
files: allFiles.sort((a, b) => naturalCompare(basename(a.absolutePath), basename(b.absolutePath))),
};
}