206 lines
5.9 KiB
TypeScript
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;
|
|
}
|
|
}
|