mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
688cfdeaec
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
800 lines
31 KiB
TypeScript
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;
|
|
});
|
|
}
|
|
}
|