Files
bookorbit/server/src/modules/scanner/scanner.repository.ts
T
neon 688cfdeaec fix(scanner): keep audiobook tracks in file name order
The player follows each track's stored sort order, which overlapping
folder scans, Add File, and imports joining a book could leave out of
step with the file names the Files tab shows. Realign a book's tracks
after each of those writes, under the book row lock.

Closes #1681
2026-10-12 05:05:13 +00:00

800 lines
31 KiB
TypeScript

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<typeof schema>;
type DbTransaction = Parameters<Parameters<Db['transaction']>[0]>[0];
type MoveBookToLibraryResult = Pick<typeof books.$inferSelect, 'id' | 'libraryId' | 'libraryFolderId' | 'folderPath' | 'status' | 'primaryFileId'> & {
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<void> {
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<string | null> {
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<string | null> {
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<number[]> {
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<number>();
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<boolean> {
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<typeof bookFiles.$inferInsert>) {
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<MoveBookToLibraryResult | null> => {
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<Array<{ id: number; format: string | null }>> {
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<number> {
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<string, number> } | 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<string, number>();
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<boolean> {
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<void> {
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<string>): Promise<void> {
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<number | null> {
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;
});
}
}