Files
Nele-Till-Hochzeit/src/lib/scanner/index.ts
T

206 lines
5.9 KiB
TypeScript

/**
* Filesystem scanner — recursively scans media directory,
* detects new/changed/removed files, and syncs with the database.
*/
import fs from 'fs';
import path from 'path';
import { prisma } from '@/lib/db';
import { getMediaType, getMimeType, isExcludedFile, isExcludedDirectory } from '@/lib/media/types';
import { getRelativePath } from '@/lib/security/path';
import { enqueueProcessing } from '@/lib/jobs/queue';
const MEDIA_ROOT = process.env.MEDIA_ROOT || '/media';
const STABILITY_DELAY = parseInt(process.env.FILE_STABILITY_DELAY_MS || '3000', 10);
interface ScannedFile {
relativePath: string;
absolutePath: string;
filename: string;
directory: string;
ext: string;
size: bigint;
mtime: Date;
}
/**
* Run a full filesystem scan and reconcile with the database.
*/
export async function runFullScan(): Promise<{
added: number;
removed: number;
unchanged: number;
errors: string[];
}> {
console.log('[Scanner] Starting full scan of', MEDIA_ROOT);
const startTime = Date.now();
const errors: string[] = [];
// 1. Walk filesystem and collect all files
const scannedFiles = new Map<string, ScannedFile>();
await walkDirectory(MEDIA_ROOT, scannedFiles, errors);
console.log(`[Scanner] Found ${scannedFiles.size} files on disk`);
// 2. Get all known files from database
const dbItems = await prisma.mediaItem.findMany({
select: { id: true, relativePath: true, fileSize: true, lastModified: true },
});
const dbPaths = new Map(dbItems.map((item) => [item.relativePath, item]));
let added = 0;
let removed = 0;
let unchanged = 0;
// 3. Find new and changed files
for (const [relPath, file] of scannedFiles) {
const existing = dbPaths.get(relPath);
if (!existing) {
// New file — check stability first
const isStable = await checkFileStability(file.absolutePath);
if (!isStable) {
console.log(`[Scanner] Skipping unstable file: ${relPath}`);
continue;
}
try {
const mediaType = getMediaType(file.ext);
const mimeType = getMimeType(file.ext);
await prisma.mediaItem.create({
data: {
relativePath: relPath,
filename: file.filename,
directory: file.directory,
mediaType,
mimeType,
fileSize: file.size,
lastModified: file.mtime,
lastScanned: new Date(),
published: false,
},
});
// Enqueue processing jobs
await enqueueProcessing(relPath, mediaType);
added++;
console.log(`[Scanner] Added: ${relPath}`);
} catch (error) {
const msg = `Failed to add ${relPath}: ${error instanceof Error ? error.message : String(error)}`;
errors.push(msg);
console.error(`[Scanner] ${msg}`);
}
} else {
unchanged++;
dbPaths.delete(relPath); // Mark as seen
}
}
// 4. Find removed files (remaining in dbPaths = not found on disk)
for (const [relPath, item] of dbPaths) {
try {
await prisma.mediaItem.delete({ where: { id: item.id } });
removed++;
console.log(`[Scanner] Removed: ${relPath}`);
} catch (error) {
const msg = `Failed to remove ${relPath}: ${error instanceof Error ? error.message : String(error)}`;
errors.push(msg);
console.error(`[Scanner] ${msg}`);
}
}
// 5. Update scan timestamp
await prisma.appSettings.upsert({
where: { id: 1 },
update: { lastScanAt: new Date() },
create: { id: 1, lastScanAt: new Date() },
});
const elapsed = Date.now() - startTime;
console.log(
`[Scanner] Scan complete in ${elapsed}ms: +${added} -${removed} =${unchanged} errors=${errors.length}`
);
return { added, removed, unchanged, errors };
}
/**
* Recursively walk a directory and collect all eligible files.
*/
async function walkDirectory(
dirPath: string,
results: Map<string, ScannedFile>,
errors: string[]
): Promise<void> {
let entries: fs.Dirent[];
try {
entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
} catch (error) {
const msg = `Cannot read directory ${dirPath}: ${error instanceof Error ? error.message : String(error)}`;
errors.push(msg);
console.error(`[Scanner] ${msg}`);
return;
}
for (const entry of entries) {
try {
if (entry.isDirectory()) {
if (isExcludedDirectory(entry.name)) continue;
await walkDirectory(path.join(dirPath, entry.name), results, errors);
} else if (entry.isFile()) {
if (isExcludedFile(entry.name)) continue;
const absolutePath = path.join(dirPath, entry.name);
const relativePath = getRelativePath(absolutePath, MEDIA_ROOT);
if (!relativePath) continue; // Safety check
const ext = path.extname(entry.name);
const stat = await fs.promises.stat(absolutePath);
// Determine directory name (first level under media root)
const parts = relativePath.split('/');
const directory = parts.length > 1 ? parts[0] : '';
results.set(relativePath, {
relativePath,
absolutePath,
filename: entry.name,
directory,
ext,
size: BigInt(stat.size),
mtime: stat.mtime,
});
}
} catch (error) {
const msg = `Error processing ${entry.name}: ${error instanceof Error ? error.message : String(error)}`;
errors.push(msg);
console.error(`[Scanner] ${msg}`);
// Continue scanning other files
}
}
}
/**
* Check if a file has stabilized (not still being transferred by Syncthing).
* Compares size and mtime at two intervals.
*/
async function checkFileStability(filePath: string): Promise<boolean> {
try {
const stat1 = await fs.promises.stat(filePath);
await new Promise((resolve) => setTimeout(resolve, STABILITY_DELAY));
const stat2 = await fs.promises.stat(filePath);
return (
stat1.size === stat2.size &&
stat1.mtimeMs === stat2.mtimeMs
);
} catch {
return false;
}
}