import { realignAudioTrackOrder } from '../../db/audio-track-order'; import { deleteBookFilesWithHashInvalidation, deleteBooksWithHashInvalidation } from '../../db/book-file-hash-history'; import { Inject, Injectable } from '@nestjs/common'; import { and, asc, desc, eq, gte, inArray, isNotNull, isNull, lt, ne, or, sql } from 'drizzle-orm'; import { NodePgDatabase } from 'drizzle-orm/node-postgres'; import { DB } from '../../db'; import * as schema from '../../db/schema'; import { audiobookProgress, authors, bookAuthors, bookFiles, bookmarks, bookGenres, bookMetadata, books, genres, koreaderDeviceProgress, libraries, libraryFolders, scanJobs, userLibraryAccess, users, } from '../../db/schema'; import { rankFileRowsByBook } from '../../common/utils/primary-file-selection.utils'; type Db = NodePgDatabase; type DbTransaction = Parameters[0]>[0]; type MoveBookToLibraryResult = Pick & { previousLibraryId: number; libraryChanged: boolean; }; @Injectable() export class ScannerRepository { constructor(@Inject(DB) private readonly db: Db) {} // ── Scan Jobs ────────────────────────────────────────────────────────────── async createScanJob(libraryId: number, triggeredBy: string) { const [job] = await this.db.insert(scanJobs).values({ libraryId, triggeredBy }).returning(); return job; } async completeScanJob(id: number, counts: { addedCount: number; updatedCount: number; missingCount: number }) { await this.db .update(scanJobs) .set({ ...counts, status: 'completed', completedAt: new Date() }) .where(eq(scanJobs.id, id)); } async failScanJob(id: number, errorMessage: string) { await this.db.update(scanJobs).set({ status: 'failed', errorMessage, completedAt: new Date() }).where(eq(scanJobs.id, id)); } async failAllRunningJobs(errorMessage: string): Promise { await this.db.update(scanJobs).set({ status: 'failed', errorMessage, completedAt: new Date() }).where(eq(scanJobs.status, 'running')); } /** Newest scan jobs for one library, for the history panel. */ async findRecentScanJobs(libraryId: number, limit: number) { return this.db .select({ id: scanJobs.id, status: scanJobs.status, triggeredBy: scanJobs.triggeredBy, startedAt: scanJobs.startedAt, completedAt: scanJobs.completedAt, addedCount: scanJobs.addedCount, updatedCount: scanJobs.updatedCount, missingCount: scanJobs.missingCount, errorMessage: scanJobs.errorMessage, }) .from(scanJobs) .where(eq(scanJobs.libraryId, libraryId)) .orderBy(desc(scanJobs.startedAt)) .limit(limit); } /** Most recent scan job per library, in one round trip. Libraries never scanned are simply absent. */ async findLatestScanJobs(libraryIds: number[]) { if (libraryIds.length === 0) return []; return this.db .selectDistinctOn([scanJobs.libraryId], { libraryId: scanJobs.libraryId, status: scanJobs.status, triggeredBy: scanJobs.triggeredBy, startedAt: scanJobs.startedAt, completedAt: scanJobs.completedAt, addedCount: scanJobs.addedCount, updatedCount: scanJobs.updatedCount, missingCount: scanJobs.missingCount, errorMessage: scanJobs.errorMessage, }) .from(scanJobs) .where(inArray(scanJobs.libraryId, libraryIds)) .orderBy(scanJobs.libraryId, desc(scanJobs.startedAt)); } // ── Library Folders ──────────────────────────────────────────────────────── async findLibraryFolders(libraryId: number) { return this.db.select().from(libraryFolders).where(eq(libraryFolders.libraryId, libraryId)); } async findLibrarySettings(libraryId: number) { const [row] = await this.db .select({ allowedFormats: libraries.allowedFormats, formatPriority: libraries.formatPriority, metadataPrecedence: libraries.metadataPrecedence, excludePatterns: libraries.excludePatterns, organizationMode: libraries.organizationMode, addedAtSource: libraries.addedAtSource, }) .from(libraries) .where(eq(libraries.id, libraryId)); return row ?? null; } async findLibraryFolderPath(libraryFolderId: number): Promise { const [row] = await this.db.select({ path: libraryFolders.path }).from(libraryFolders).where(eq(libraryFolders.id, libraryFolderId)).limit(1); return row?.path ?? null; } async findLibraryName(libraryId: number): Promise { const [row] = await this.db.select({ name: libraries.name }).from(libraries).where(eq(libraries.id, libraryId)).limit(1); return row?.name ?? null; } async findLibraryAccessibleUserIds(libraryId: number): Promise { const [accessRows, superuserRows] = await Promise.all([ this.db.select({ userId: userLibraryAccess.userId }).from(userLibraryAccess).where(eq(userLibraryAccess.libraryId, libraryId)), this.db.select({ userId: users.id }).from(users).where(eq(users.isSuperuser, true)), ]); const userIds = new Set(); for (const row of accessRows) userIds.add(row.userId); for (const row of superuserRows) userIds.add(row.userId); return [...userIds]; } // ── Books ────────────────────────────────────────────────────────────────── async findBooksByLibraryFolder(libraryFolderId: number) { return this.db .select({ id: books.id, status: books.status, folderPath: books.folderPath, primaryFileId: books.primaryFileId, }) .from(books) .where(eq(books.libraryFolderId, libraryFolderId)); } async findPrimaryBookFilesByLibrary(libraryId: number) { return this.db .select({ bookId: books.id, absolutePath: bookFiles.absolutePath, format: bookFiles.format }) .from(books) .innerJoin(bookFiles, eq(bookFiles.id, books.primaryFileId)) .where(and(eq(books.libraryId, libraryId), ne(books.status, 'missing'))); } async findPrimaryBookFilesByBookId(bookId: number) { return this.db .select({ id: bookFiles.id, bookId: bookFiles.bookId, libraryFolderId: bookFiles.libraryFolderId, absolutePath: bookFiles.absolutePath, relPath: bookFiles.relPath, ino: bookFiles.ino, sizeBytes: bookFiles.sizeBytes, mtime: bookFiles.mtime, fileHash: bookFiles.fileHash, role: bookFiles.role, sortOrder: bookFiles.sortOrder, durationSeconds: bookFiles.durationSeconds, createdAt: bookFiles.createdAt, updatedAt: bookFiles.updatedAt, }) .from(books) .innerJoin(bookFiles, eq(bookFiles.id, books.primaryFileId)) .where(eq(books.id, bookId)); } async createBook(data: typeof books.$inferInsert) { const rows = await this.db .insert(books) .values(data) .onConflictDoUpdate({ target: [books.libraryId, books.folderPath], set: { // Keep processing rows hidden until candidate processing completes. // Only restore missing rows to present on conflict. status: sql`CASE WHEN ${books.status} = 'missing' THEN 'present' ELSE ${books.status} END`, updatedAt: new Date(), }, }) .returning(); const book = rows[0]!; // Always create an empty metadata row so joins never return null. await this.db.insert(bookMetadata).values({ bookId: book.id }).onConflictDoNothing(); return book; } async updateBookStatus(id: number, status: 'present' | 'missing') { await this.db.update(books).set({ status, updatedAt: new Date() }).where(eq(books.id, id)); } async promoteProcessingBookToPresent(bookId: number): Promise { const rows = await this.db .update(books) .set({ status: 'present', updatedAt: new Date() }) .where(and(eq(books.id, bookId), eq(books.status, 'processing'))) .returning({ id: books.id }); return rows.length > 0; } async updateBookPrimaryFile(bookId: number, primaryFileId: number | null) { const primaryScope = primaryFileId == null ? sql`TRUE` : sql`EXISTS (SELECT 1 FROM ${bookFiles} WHERE ${bookFiles.id} = ${primaryFileId} AND ${bookFiles.bookId} = ${bookId})`; await this.db .update(books) .set({ primaryFileId, updatedAt: new Date() }) .where(and(eq(books.id, bookId), sql`${books.primaryFileId} IS DISTINCT FROM ${primaryFileId}`, primaryScope)); } async markBooksAsMissing(ids: number[]) { if (ids.length === 0) return; await this.db.update(books).set({ status: 'missing', updatedAt: new Date() }).where(inArray(books.id, ids)); } // ── Book Files ───────────────────────────────────────────────────────────── async findBookFilesByLibraryFolder(libraryFolderId: number) { return this.db .select({ id: bookFiles.id, bookId: bookFiles.bookId, absolutePath: bookFiles.absolutePath, relPath: bookFiles.relPath, ino: bookFiles.ino, sizeBytes: bookFiles.sizeBytes, mtime: bookFiles.mtime, fileHash: bookFiles.fileHash, format: bookFiles.format, sortOrder: bookFiles.sortOrder, durationSeconds: bookFiles.durationSeconds, mediaOverlayAvailable: bookFiles.mediaOverlayAvailable, mediaOverlayCheckedAt: bookFiles.mediaOverlayCheckedAt, }) .from(bookFiles) .where(eq(bookFiles.libraryFolderId, libraryFolderId)); } async findBookFileByHash(fileHash: string, libraryFolderId: number) { const [file] = await this.db .select() .from(bookFiles) .where(and(eq(bookFiles.fileHash, fileHash), eq(bookFiles.libraryFolderId, libraryFolderId))) .limit(1); return file ?? null; } async createBookFile(data: typeof bookFiles.$inferInsert) { const rows = await this.db.insert(bookFiles).values(data).returning(); const created = rows[0]!; if (created.fileHash) { await this.db .update(koreaderDeviceProgress) .set({ bookFileId: created.id, orphaned: false, orphanedHash: null }) .where(and(eq(koreaderDeviceProgress.orphanedHash, created.fileHash), eq(koreaderDeviceProgress.orphaned, true))); } return created; } async updateBookFile(id: number, data: Partial) { const [file] = await this.db .update(bookFiles) .set({ ...data, updatedAt: new Date() }) .where(eq(bookFiles.id, id)) .returning(); return file; } async findBookFileByAbsolutePath(absolutePath: string, libraryId?: number) { const whereClause = libraryId == null ? eq(bookFiles.absolutePath, absolutePath) : and(eq(bookFiles.absolutePath, absolutePath), eq(books.libraryId, libraryId)); const [row] = await this.db .select({ file: bookFiles, libraryId: books.libraryId, primaryFileId: books.primaryFileId, libraryFolderPath: libraryFolders.path, }) .from(bookFiles) .innerJoin(books, eq(books.id, bookFiles.bookId)) .innerJoin(libraryFolders, eq(libraryFolders.id, bookFiles.libraryFolderId)) .where(whereClause) .limit(1); return row ?? null; } async findBooksByFolderPath(folderPath: string, libraryId?: number) { const folderScope = or(eq(books.folderPath, folderPath), and(gte(books.folderPath, folderPath + '/'), lt(books.folderPath, folderPath + '0'))); const whereClause = libraryId == null ? folderScope : and(eq(books.libraryId, libraryId), folderScope); return this.db.select().from(books).where(whereClause); } async findMissingBookByFolderPath(folderPath: string, libraryId?: number) { const whereClause = libraryId == null ? and(eq(books.folderPath, folderPath), eq(books.status, 'missing')) : and(eq(books.libraryId, libraryId), eq(books.folderPath, folderPath), eq(books.status, 'missing')); const [row] = await this.db.select().from(books).where(whereClause).limit(1); return row ?? null; } async findMissingBooksByFolderPath(folderPath: string, libraryId?: number) { const folderScope = or(eq(books.folderPath, folderPath), and(gte(books.folderPath, folderPath + '/'), lt(books.folderPath, folderPath + '0'))); const whereClause = libraryId == null ? and(folderScope, eq(books.status, 'missing')) : and(eq(books.libraryId, libraryId), folderScope, eq(books.status, 'missing')); return this.db.select().from(books).where(whereClause); } async findMissingBooksForLibraries(libraryIds: number[]) { if (libraryIds.length === 0) return []; return this.db .select() .from(books) .where(and(inArray(books.libraryId, libraryIds), eq(books.status, 'missing'))); } async markBooksAsPresent(bookIds: number[]) { if (bookIds.length === 0) return; await this.db.update(books).set({ status: 'present', updatedAt: new Date() }).where(inArray(books.id, bookIds)); } async moveBookToLibrary(bookId: number, libraryId: number, libraryFolderId: number, folderPath: string) { return this.db.transaction(async (tx): Promise => { const [current] = await tx .select({ id: books.id, libraryId: books.libraryId, libraryFolderId: books.libraryFolderId, folderPath: books.folderPath, status: books.status, primaryFileId: books.primaryFileId, }) .from(books) .where(eq(books.id, bookId)) .for('update') .limit(1); if (!current) return null; const libraryChanged = current.libraryId !== libraryId; if (!libraryChanged && current.libraryFolderId === libraryFolderId && current.folderPath === folderPath && current.status === 'present') { return { ...current, previousLibraryId: current.libraryId, libraryChanged: false }; } const [book] = await tx .update(books) .set({ libraryId, libraryFolderId, folderPath, status: 'present', updatedAt: new Date(), }) .where(eq(books.id, bookId)) .returning({ id: books.id, libraryId: books.libraryId, libraryFolderId: books.libraryFolderId, folderPath: books.folderPath, status: books.status, primaryFileId: books.primaryFileId, }); return book ? { ...book, previousLibraryId: current.libraryId, libraryChanged } : null; }); } async findBookFilesByBookId(bookId: number) { return this.db.select().from(bookFiles).where(eq(bookFiles.bookId, bookId)); } async findBookFileFormats(bookId: number): Promise> { return this.db.select({ id: bookFiles.id, format: bookFiles.format }).from(bookFiles).where(eq(bookFiles.bookId, bookId)); } /** * Rewrites a book's stored audio positions after its track order changed: every user's audio * bookmarks, which hold a position from the start of the book, and the progress percentage, which * follows from it. Rows are locked so none is read under one order and written under the other. */ async remapAudioPositions( bookId: number, remapPosition: (positionSeconds: number) => number | null, percentageAt: (fileId: number, offsetSeconds: number) => number | null, ): Promise<{ bookmarks: number; progress: number }> { return this.db.transaction(async (tx) => { let bookmarksUpdated = 0; const bookmarkRows = await tx .select({ id: bookmarks.id, positionSeconds: bookmarks.positionSeconds }) .from(bookmarks) .where(and(eq(bookmarks.bookId, bookId), isNull(bookmarks.cfi), isNotNull(bookmarks.positionSeconds))) .for('update'); for (const row of bookmarkRows) { const positionSeconds = remapPosition(row.positionSeconds!); if (positionSeconds === null || positionSeconds === row.positionSeconds) continue; await tx.update(bookmarks).set({ positionSeconds }).where(eq(bookmarks.id, row.id)); bookmarksUpdated++; } let progressUpdated = 0; const progressRows = await tx .select({ userId: audiobookProgress.userId, currentFileId: audiobookProgress.currentFileId, positionSeconds: audiobookProgress.positionSeconds, percentage: audiobookProgress.percentage, }) .from(audiobookProgress) .where(eq(audiobookProgress.bookId, bookId)) .for('update'); for (const row of progressRows) { // A finished book stays finished: its last position was the end of the old order. if (row.percentage >= 100) continue; const percentage = percentageAt(row.currentFileId, row.positionSeconds); if (percentage === null || percentage === row.percentage) continue; await tx .update(audiobookProgress) .set({ percentage }) .where(and(eq(audiobookProgress.userId, row.userId), eq(audiobookProgress.bookId, bookId))); progressUpdated++; } return { bookmarks: bookmarksUpdated, progress: progressUpdated }; }); } realignAudioTrackOrder(bookId: number): Promise { return this.db.transaction((tx) => realignAudioTrackOrder(tx, bookId)); } async findBookFilesByBookIds(bookIds: number[]): Promise<(typeof bookFiles.$inferSelect)[]> { if (bookIds.length === 0) return []; return this.db.select().from(bookFiles).where(inArray(bookFiles.bookId, bookIds)); } async deleteBookFile(id: number) { const [file] = await this.db.select({ fileHash: bookFiles.fileHash }).from(bookFiles).where(eq(bookFiles.id, id)).limit(1); if (file?.fileHash) { await this.db .update(koreaderDeviceProgress) .set({ orphaned: true, orphanedHash: file.fileHash }) .where(and(eq(koreaderDeviceProgress.bookFileId, id), eq(koreaderDeviceProgress.orphaned, false))); } await deleteBookFilesWithHashInvalidation(this.db, eq(bookFiles.id, id)); } async findBookFileWithContextByIno(ino: bigint, libraryId?: number) { const whereClause = libraryId == null ? eq(bookFiles.ino, ino) : and(eq(bookFiles.ino, ino), eq(books.libraryId, libraryId)); const [row] = await this.db .select({ file: bookFiles, libraryId: books.libraryId, bookStatus: books.status, folderPath: books.folderPath, libraryFolderPath: libraryFolders.path, }) .from(bookFiles) .innerJoin(books, eq(books.id, bookFiles.bookId)) .innerJoin(libraryFolders, eq(libraryFolders.id, bookFiles.libraryFolderId)) .where(whereClause) .limit(1); return row ?? null; } async findMissingBookFileWithContextByIno(ino: bigint) { const [row] = await this.db .select({ file: bookFiles, libraryId: books.libraryId, bookStatus: books.status, folderPath: books.folderPath, libraryFolderPath: libraryFolders.path, }) .from(bookFiles) .innerJoin(books, eq(books.id, bookFiles.bookId)) .innerJoin(libraryFolders, eq(libraryFolders.id, bookFiles.libraryFolderId)) .where(and(eq(bookFiles.ino, ino), eq(books.status, 'missing'))) .limit(1); return row ?? null; } async findBookFileWithContextByHash(fileHash: string) { const [row] = await this.db .select({ file: bookFiles, libraryId: books.libraryId, bookStatus: books.status, folderPath: books.folderPath, libraryFolderPath: libraryFolders.path, }) .from(bookFiles) .innerJoin(books, eq(books.id, bookFiles.bookId)) .innerJoin(libraryFolders, eq(libraryFolders.id, bookFiles.libraryFolderId)) .where(eq(bookFiles.fileHash, fileHash)) .limit(1); return row ?? null; } async findPresentDuplicateBookFilesByHash(fileHash: string, sourceBookId: number) { return this.db .select({ file: bookFiles, bookId: books.id, libraryId: books.libraryId, libraryFolderId: books.libraryFolderId, folderPath: books.folderPath, bookAddedAt: books.addedAt, }) .from(bookFiles) .innerJoin(books, eq(books.id, bookFiles.bookId)) .where(and(eq(bookFiles.fileHash, fileHash), eq(bookFiles.role, 'content'), eq(books.status, 'present'), ne(books.id, sourceBookId))); } async replaceDuplicateBookWithMovedBook(input: { sourceBookId: number; sourceFileId: number; duplicateBookId: number; duplicateFileId: number; }): Promise<{ bookId: number; libraryId: number; duplicateBookId: number } | null> { return this.db.transaction(async (tx) => { const [source] = await tx .select({ bookId: books.id, fileId: bookFiles.id, }) .from(bookFiles) .innerJoin(books, eq(books.id, bookFiles.bookId)) .where(and(eq(books.id, input.sourceBookId), eq(bookFiles.id, input.sourceFileId), eq(books.status, 'missing'))) .limit(1); if (!source) return null; const [duplicate] = await tx .select({ file: bookFiles, bookId: books.id, libraryId: books.libraryId, libraryFolderId: books.libraryFolderId, folderPath: books.folderPath, }) .from(bookFiles) .innerJoin(books, eq(books.id, bookFiles.bookId)) .where(and(eq(books.id, input.duplicateBookId), eq(bookFiles.id, input.duplicateFileId), eq(books.status, 'present'))) .limit(1); if (!duplicate || duplicate.bookId === input.sourceBookId) return null; await deleteBooksWithHashInvalidation(tx, eq(books.id, input.duplicateBookId)); await tx .update(books) .set({ libraryId: duplicate.libraryId, libraryFolderId: duplicate.libraryFolderId, folderPath: duplicate.folderPath, status: 'present', updatedAt: new Date(), }) .where(eq(books.id, input.sourceBookId)); await tx .update(bookFiles) .set({ libraryFolderId: duplicate.libraryFolderId, absolutePath: duplicate.file.absolutePath, relPath: duplicate.file.relPath, ino: duplicate.file.ino, sizeBytes: duplicate.file.sizeBytes, mtime: duplicate.file.mtime, fileHash: duplicate.file.fileHash, format: duplicate.file.format, role: duplicate.file.role, sortOrder: duplicate.file.sortOrder, durationSeconds: duplicate.file.durationSeconds, updatedAt: new Date(), }) .where(eq(bookFiles.id, input.sourceFileId)); await tx .update(books) .set({ primaryFileId: input.sourceFileId, updatedAt: new Date(), }) .where(eq(books.id, input.sourceBookId)); return { bookId: input.sourceBookId, libraryId: duplicate.libraryId, duplicateBookId: input.duplicateBookId }; }); } async findMissingBookFileWithContextByHash(fileHash: string) { const [row] = await this.db .select({ file: bookFiles, libraryId: books.libraryId, bookStatus: books.status, folderPath: books.folderPath, libraryFolderPath: libraryFolders.path, }) .from(bookFiles) .innerJoin(books, eq(books.id, bookFiles.bookId)) .innerJoin(libraryFolders, eq(libraryFolders.id, bookFiles.libraryFolderId)) .where(and(eq(bookFiles.fileHash, fileHash), eq(books.status, 'missing'))) .limit(1); return row ?? null; } async updateBookFolderPath(bookId: number, folderPath: string) { await this.db.update(books).set({ folderPath, updatedAt: new Date() }).where(eq(books.id, bookId)); } async findBookById(bookId: number) { const [row] = await this.db.select().from(books).where(eq(books.id, bookId)).limit(1); return row ?? null; } async findBooksByIds(bookIds: number[]) { if (bookIds.length === 0) return []; return this.db .select({ id: books.id, libraryId: books.libraryId, status: books.status, }) .from(books) .where(inArray(books.id, bookIds)); } async findBookCardData(bookIds: number[]) { if (bookIds.length === 0) return { rows: [], authorRows: [], fileRows: [], genreRows: [] }; const [rows, authorRows, unrankedFileRows, genreRows] = await Promise.all([ this.db .select({ id: books.id, status: books.status, coverAspectRatio: libraries.coverAspectRatio, formatPriority: libraries.formatPriority, primaryFileId: books.primaryFileId, folderPath: books.folderPath, addedAt: books.addedAt, title: bookMetadata.title, seriesName: bookMetadata.seriesName, seriesIndex: bookMetadata.seriesIndex, publishedDate: bookMetadata.publishedDate, publishedYear: bookMetadata.publishedYear, language: bookMetadata.language, rating: bookMetadata.rating, coverSource: bookMetadata.coverSource, lockedFields: bookMetadata.lockedFields, subtitle: bookMetadata.subtitle, publisher: bookMetadata.publisher, pageCount: bookMetadata.pageCount, isbn13: bookMetadata.isbn13, hardcoverId: bookMetadata.hardcoverId, hardcoverEditionId: bookMetadata.hardcoverEditionId, }) .from(books) .innerJoin(libraries, eq(libraries.id, books.libraryId)) .leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id)) .where(inArray(books.id, bookIds)), this.db .select({ bookId: bookAuthors.bookId, name: authors.name }) .from(bookAuthors) .innerJoin(authors, eq(authors.id, bookAuthors.authorId)) .where(inArray(bookAuthors.bookId, bookIds)) .orderBy(bookAuthors.displayOrder), this.db .select({ bookId: bookFiles.bookId, id: bookFiles.id, format: bookFiles.format, role: bookFiles.role, sizeBytes: bookFiles.sizeBytes, mediaOverlayAvailable: bookFiles.mediaOverlayAvailable, mediaOverlayDurationSeconds: bookFiles.mediaOverlayDurationSeconds, mediaOverlayCheckedAt: bookFiles.mediaOverlayCheckedAt, }) .from(bookFiles) .where(inArray(bookFiles.bookId, bookIds)) .orderBy(asc(bookFiles.bookId), asc(bookFiles.sortOrder), asc(bookFiles.id)), this.db .select({ bookId: bookGenres.bookId, name: genres.name }) .from(bookGenres) .innerJoin(genres, eq(genres.id, bookGenres.genreId)) .where(inArray(bookGenres.bookId, bookIds)), ]); const fileRows = rankFileRowsByBook( unrankedFileRows, new Map(rows.map((row) => [row.id, { formatPriority: row.formatPriority as string[] | null, primaryFileId: row.primaryFileId }])), ); return { rows, authorRows, fileRows, genreRows }; } // ── Dir Scan State (Incremental Scan) ──────────────────────────────────── async findDirScanStateSnapshot(libraryFolderId: number): Promise<{ version: number; mtimes: Map } | null> { const rows = await this.db .select({ version: libraryFolders.scanStateVersion, dirPath: schema.libraryDirScanState.dirPath, lastSeenMtimeMs: schema.libraryDirScanState.lastSeenMtimeMs, }) .from(libraryFolders) .leftJoin(schema.libraryDirScanState, eq(schema.libraryDirScanState.libraryFolderId, libraryFolders.id)) .where(eq(libraryFolders.id, libraryFolderId)); const first = rows[0]; if (!first) return null; const mtimes = new Map(); for (const row of rows) { if (row.dirPath !== null && row.lastSeenMtimeMs !== null) mtimes.set(row.dirPath, row.lastSeenMtimeMs); } return { version: first.version, mtimes }; } async persistDirScanState( libraryFolderId: number, expectedVersion: number, entries: Array<{ dirPath: string; mtimeMs: number }>, ): Promise { return this.db.transaction(async (tx) => { const [folder] = await tx .select({ version: libraryFolders.scanStateVersion }) .from(libraryFolders) .where(eq(libraryFolders.id, libraryFolderId)) .for('update'); if (!folder || folder.version !== expectedVersion) return false; await this.upsertDirScanState(tx, libraryFolderId, entries); await this.deleteStaleDirScanState(tx, libraryFolderId, new Set(entries.map((entry) => entry.dirPath))); return true; }); } private async upsertDirScanState(tx: DbTransaction, libraryFolderId: number, entries: Array<{ dirPath: string; mtimeMs: number }>): Promise { if (entries.length === 0) return; const CHUNK = 500; for (let i = 0; i < entries.length; i += CHUNK) { const chunk = entries.slice(i, i + CHUNK); await tx .insert(schema.libraryDirScanState) .values(chunk.map((e) => ({ libraryFolderId, dirPath: e.dirPath, lastSeenMtimeMs: Math.round(e.mtimeMs) }))) .onConflictDoUpdate({ target: [schema.libraryDirScanState.libraryFolderId, schema.libraryDirScanState.dirPath], set: { lastSeenMtimeMs: sql`excluded.last_seen_mtime_ms` }, }); } } private async deleteStaleDirScanState(tx: DbTransaction, libraryFolderId: number, validPaths: Set): Promise { if (validPaths.size === 0) { await tx.delete(schema.libraryDirScanState).where(eq(schema.libraryDirScanState.libraryFolderId, libraryFolderId)); return; } const allRows = await tx .select({ id: schema.libraryDirScanState.id, dirPath: schema.libraryDirScanState.dirPath }) .from(schema.libraryDirScanState) .where(eq(schema.libraryDirScanState.libraryFolderId, libraryFolderId)); const staleIds = allRows.filter((r) => !validPaths.has(r.dirPath)).map((r) => r.id); if (staleIds.length === 0) return; const CHUNK = 500; for (let i = 0; i < staleIds.length; i += CHUNK) { await tx.delete(schema.libraryDirScanState).where(inArray(schema.libraryDirScanState.id, staleIds.slice(i, i + CHUNK))); } } async clearDirScanState(libraryFolderId: number): Promise { return this.db.transaction(async (tx) => { const [folder] = await tx .update(libraryFolders) .set({ scanStateVersion: sql`${libraryFolders.scanStateVersion} + 1` }) .where(eq(libraryFolders.id, libraryFolderId)) .returning({ version: libraryFolders.scanStateVersion }); if (!folder) return null; await tx.delete(schema.libraryDirScanState).where(eq(schema.libraryDirScanState.libraryFolderId, libraryFolderId)); return folder.version; }); } }