feat: add bulk tag update and improve selection action bar

This commit is contained in:
neon
2026-04-15 00:58:50 -06:00
parent 94af42cbbf
commit dd9fd205d6
34 changed files with 1261 additions and 212 deletions
+14 -11
View File
@@ -6,6 +6,9 @@
"private": true,
"license": "AGPL-3.0",
"scripts": {
"prestart": "pnpm --filter @projectx/types build",
"prestart:dev": "pnpm --filter @projectx/types build",
"prestart:debug": "pnpm --filter @projectx/types build",
"prebuild": "pnpm --filter @projectx/types build",
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
@@ -39,17 +42,17 @@
"@fastify/helmet": "^13.0.2",
"@fastify/multipart": "^10.0.0",
"@fastify/static": "^9.1.0",
"@nestjs/common": "^11.1.18",
"@nestjs/common": "^11.1.19",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.1.18",
"@nestjs/core": "^11.1.19",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-fastify": "^11.1.18",
"@nestjs/platform-socket.io": "^11.1.18",
"@nestjs/platform-fastify": "^11.1.19",
"@nestjs/platform-socket.io": "^11.1.19",
"@nestjs/schedule": "^6.1.1",
"@nestjs/terminus": "^11.1.1",
"@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.1.18",
"@nestjs/websockets": "^11.1.19",
"@parcel/watcher": "^2.5.6",
"@projectx/types": "workspace:*",
"@types/archiver": "^7.0.0",
@@ -59,9 +62,9 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"drizzle-orm": "^0.45.2",
"fast-xml-parser": "^5.5.11",
"fast-xml-parser": "^5.5.12",
"fastest-levenshtein": "^1.0.16",
"fastify": "^5.8.4",
"fastify": "^5.8.5",
"jose": "^6.2.2",
"mediainfo.js": "^0.3.7",
"ms": "^2.1.3",
@@ -87,9 +90,9 @@
"@eslint/js": "^10.0.1",
"@nestjs/cli": "^11.0.19",
"@nestjs/schematics": "^11.0.10",
"@nestjs/testing": "^11.1.18",
"@nestjs/testing": "^11.1.19",
"@swc/cli": "^0.8.1",
"@swc/core": "^1.15.24",
"@swc/core": "^1.15.26",
"@types/bcrypt": "^6.0.0",
"@types/express": "^5.0.6",
"@types/node": "^25.6.0",
@@ -105,7 +108,7 @@
"eslint-plugin-prettier": "^5.5.5",
"globals": "^17.5.0",
"pino-pretty": "^13.1.3",
"prettier": "^3.8.2",
"prettier": "^3.8.3",
"source-map-support": "^0.5.21",
"supertest": "^7.2.2",
"ts-loader": "^9.5.7",
@@ -113,7 +116,7 @@
"tsconfig-paths": "^4.2.0",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.58.1",
"typescript-eslint": "^8.58.2",
"vitest": "^4.1.4"
}
}
@@ -17,4 +17,10 @@ export interface AuditableOptions {
description: string | ((req: AuditRequest, responseBody: unknown) => string);
}
export const Auditable = (options: AuditableOptions) => SetMetadata(AUDITABLE_KEY, options);
export const Auditable = (options: AuditableOptions) => {
if (!options.action) {
throw new Error('Auditable decorator requires a defined audit action');
}
return SetMetadata(AUDITABLE_KEY, options);
};
@@ -39,4 +39,13 @@ describe('common decorators', () => {
expect(Reflect.getMetadata(LIBRARY_ACCESS_KEY, DecoratedController.prototype.withLibraryAccess)).toBe('viewer');
expect(Reflect.getMetadata(AUDITABLE_KEY, DecoratedController.prototype.audited)).toEqual(auditableOptions);
});
it('throws when audit action is undefined at decorator creation time', () => {
expect(() =>
Auditable({
action: undefined as never,
description: 'Missing action',
}),
).toThrow('Auditable decorator requires a defined audit action');
});
});
@@ -38,4 +38,13 @@ export class BookMetadataLockRepository {
.values({ bookId, lockedFields, updatedAt: now })
.onConflictDoUpdate({ target: bookMetadata.bookId, set: { lockedFields, updatedAt: now } });
}
async bulkReplaceLockedFields(bookIds: number[], lockedFields: BookMetadataLockField[]): Promise<void> {
if (bookIds.length === 0) return;
const now = new Date();
await this.db
.insert(bookMetadata)
.values(bookIds.map((bookId) => ({ bookId, lockedFields, updatedAt: now })))
.onConflictDoUpdate({ target: bookMetadata.bookId, set: { lockedFields, updatedAt: now } });
}
}
@@ -70,6 +70,10 @@ export class BookMetadataLockService {
return normalized;
}
async bulkReplaceLockedFields(bookIds: number[], lockedFields: BookMetadataLockField[]): Promise<void> {
await this.lockRepo.bulkReplaceLockedFields(bookIds, lockedFields);
}
async getCoverLockedBookIds(bookIds: number[]): Promise<Set<number>> {
const fieldsMap = await this.lockRepo.findLockedFieldsByBookIds(bookIds);
const locked = new Set<number>();
@@ -29,6 +29,10 @@ import { FileWriteService } from '../file-write/file-write.service';
import { BookService } from './book.service';
import { BookQueryPipe } from './pipes/book-query.pipe';
import { BulkBookIdsDto } from './dto/bulk-book-ids.dto';
import { BulkSetStatusDto } from './dto/bulk-set-status.dto';
import { BulkSetRatingDto } from './dto/bulk-set-rating.dto';
import { BulkUpdateTagsDto } from './dto/bulk-update-tags.dto';
import { BulkSetMetadataLockDto } from './dto/bulk-set-metadata-lock.dto';
import { DeleteBooksDto } from './dto/delete-books.dto';
import { ExportBooksDto } from './dto/export-books.dto';
import { SaveProgressDto } from './dto/save-progress.dto';
@@ -492,6 +496,69 @@ export class BookController {
return this.bookService.setReadStatus(id, dto.status, user);
}
@Post('bulk-set-status')
@HttpCode(HttpStatus.NO_CONTENT)
@Auditable({
action: AuditAction.BookBulkSetStatus,
resource: AuditResource.Book,
description: (req) => {
const body = req.body as { bookIds?: number[]; status?: string };
const count = body?.bookIds?.length ?? 0;
return `Bulk set status to ${body?.status ?? 'unknown'} for ${count} book${count !== 1 ? 's' : ''}`;
},
})
bulkSetStatus(@Body() dto: BulkSetStatusDto, @CurrentUser() user: RequestUser) {
return this.bookService.bulkSetStatus(dto.bookIds, dto.status, user);
}
@Post('bulk-set-rating')
@HttpCode(HttpStatus.NO_CONTENT)
@RequirePermission(Permission.LibraryEditMetadata)
@Auditable({
action: AuditAction.BookBulkSetRating,
resource: AuditResource.Book,
description: (req) => {
const body = req.body as { bookIds?: number[]; rating?: number | null };
const count = body?.bookIds?.length ?? 0;
return `Bulk set rating to ${body?.rating ?? 'null'} for ${count} book${count !== 1 ? 's' : ''}`;
},
})
bulkSetRating(@Body() dto: BulkSetRatingDto, @CurrentUser() user: RequestUser) {
return this.bookService.bulkSetRating(dto.bookIds, dto.rating ?? null, user);
}
@Post('bulk-update-tags')
@HttpCode(HttpStatus.NO_CONTENT)
@RequirePermission(Permission.LibraryEditMetadata)
@Auditable({
action: AuditAction.BookBulkUpdateTags,
resource: AuditResource.Book,
description: (req) => {
const body = req.body as { bookIds?: number[]; mode?: string };
const count = body?.bookIds?.length ?? 0;
return `Bulk ${body?.mode ?? 'update'} tags for ${count} book${count !== 1 ? 's' : ''}`;
},
})
bulkUpdateTags(@Body() dto: BulkUpdateTagsDto, @CurrentUser() user: RequestUser) {
return this.bookService.bulkUpdateTags(dto.bookIds, dto.mode, dto.tags, user);
}
@Post('bulk-set-metadata-lock')
@HttpCode(HttpStatus.NO_CONTENT)
@RequirePermission(Permission.LibraryEditMetadata)
@Auditable({
action: AuditAction.BookBulkSetMetadataLock,
resource: AuditResource.Book,
description: (req) => {
const body = req.body as { bookIds?: number[]; locked?: boolean };
const count = body?.bookIds?.length ?? 0;
return `Bulk ${body?.locked ? 'locked' : 'unlocked'} metadata for ${count} book${count !== 1 ? 's' : ''}`;
},
})
bulkSetMetadataLock(@Body() dto: BulkSetMetadataLockDto, @CurrentUser() user: RequestUser) {
return this.bookService.bulkSetMetadataLock(dto.bookIds, dto.locked, user);
}
@Get(':id')
getDetail(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser) {
return this.bookService.getDetail(id, user);
@@ -31,6 +31,7 @@ import {
type Db = NodePgDatabase<typeof schema>;
type DbTransaction = Parameters<Parameters<Db['transaction']>[0]>[0];
type MetadataUpdateExecutor = Pick<Db, 'update'>;
type MetadataReadExecutor = Pick<Db, 'select'>;
type PatternMetadataRow = {
bookId: number;
title: string | null;
@@ -70,6 +71,7 @@ export class BookRepository {
language: bookMetadata.language,
rating: bookMetadata.rating,
coverSource: bookMetadata.coverSource,
lockedFields: bookMetadata.lockedFields,
})
.from(books)
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
@@ -482,6 +484,27 @@ export class BookRepository {
await this.db.delete(books).where(inArray(books.id, bookIds));
}
async bulkSetRating(bookIds: number[], rating: number | null): Promise<void> {
if (bookIds.length === 0) return;
await this.db.update(bookMetadata).set({ rating, updatedAt: new Date() }).where(inArray(bookMetadata.bookId, bookIds));
}
async findTagsByBookIds(bookIds: number[], executor: MetadataReadExecutor = this.db): Promise<Map<number, string[]>> {
if (bookIds.length === 0) return new Map();
const rows = await executor
.select({ bookId: bookTags.bookId, name: tags.name })
.from(bookTags)
.innerJoin(tags, eq(bookTags.tagId, tags.id))
.where(inArray(bookTags.bookId, bookIds));
const result = new Map<number, string[]>();
for (const row of rows) {
const existing = result.get(row.bookId) ?? [];
existing.push(row.name);
result.set(row.bookId, existing);
}
return result;
}
async updateMetadataFields(
bookId: number,
fields: Partial<typeof bookMetadata.$inferInsert>,
@@ -82,6 +82,7 @@ function makeService() {
findLibraryIdsByBookIds: vi.fn(),
findPrimaryFilesByBookIds: vi.fn(),
findAllFilesByBookIds: vi.fn(),
findTagsByBookIds: vi.fn(),
findPrimaryFile: vi.fn(),
findById: vi.fn(),
findCollectionsByBookId: vi.fn(),
@@ -95,6 +96,7 @@ function makeService() {
upsertProgress: vi.fn(),
findAudioProgress: vi.fn(),
upsertAudioProgress: vi.fn(),
bulkSetRating: vi.fn(),
updateMetadataFields: vi.fn(),
withTransaction: vi.fn(),
deleteByIds: vi.fn(),
@@ -189,6 +191,7 @@ function makeService() {
libraryService,
queryBuilder,
metadataService,
scoreService,
pipeline,
config,
appSettings,
@@ -1490,6 +1493,51 @@ describe('BookService', () => {
});
});
describe('bulk metadata actions', () => {
it('bulkSetRating updates ratings and queues file writes and score recalculation', async () => {
const { service, bookRepo, fileWriteService, scoreService } = makeService();
const user = makeUser({ id: 42 });
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockResolvedValue(undefined);
await service.bulkSetRating([3, 5], 4, user);
expect(bookRepo.bulkSetRating).toHaveBeenCalledWith([3, 5], 4);
expect(fileWriteService.scheduleWrite).toHaveBeenNthCalledWith(1, 3, 'auto', 42);
expect(fileWriteService.scheduleWrite).toHaveBeenNthCalledWith(2, 5, 'auto', 42);
expect(scoreService.calculateAndSave).toHaveBeenNthCalledWith(1, 3);
expect(scoreService.calculateAndSave).toHaveBeenNthCalledWith(2, 5);
});
it('bulkUpdateTags performs all tag changes in one transaction and queues follow-up work', async () => {
const { service, bookRepo, metadataService, fileWriteService, scoreService } = makeService();
const user = makeUser({ id: 11 });
const tx = { select: vi.fn(), delete: vi.fn(), insert: vi.fn() };
bookRepo.withTransaction.mockImplementation(async (callback: (value: unknown) => Promise<unknown>) => callback(tx));
bookRepo.findTagsByBookIds.mockResolvedValue(new Map([[7, ['existing']]]));
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockResolvedValue(undefined);
await service.bulkUpdateTags([7], 'add', ['new'], user);
expect(bookRepo.findTagsByBookIds).toHaveBeenCalledWith([7], tx);
expect(metadataService.replaceTags).toHaveBeenCalledWith(7, ['existing', 'new'], { executor: tx });
expect(fileWriteService.scheduleWrite).toHaveBeenCalledWith(7, 'auto', 11);
expect(scoreService.calculateAndSave).toHaveBeenCalledWith(7);
});
it('bulkUpdateTags replaces tags inside the transaction executor', async () => {
const { service, bookRepo, metadataService } = makeService();
const user = makeUser();
const tx = { select: vi.fn(), delete: vi.fn(), insert: vi.fn() };
bookRepo.withTransaction.mockImplementation(async (callback: (value: unknown) => Promise<unknown>) => callback(tx));
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockResolvedValue(undefined);
await service.bulkUpdateTags([9], 'replace', ['fresh'], user);
expect(bookRepo.findTagsByBookIds).not.toHaveBeenCalled();
expect(metadataService.replaceTags).toHaveBeenCalledWith(9, ['fresh'], { executor: tx });
});
});
describe('getDetail and metadata extraction', () => {
it('throws NotFoundException when detail lookup has no result', async () => {
const { service, bookRepo } = makeService();
+80 -2
View File
@@ -20,8 +20,15 @@ import { parseMobiFile } from '../metadata/lib/mobi-parser';
import { parsePdfFile, type PdfParseWarning } from '../metadata/lib/pdf-parser';
import { basename, extname, join } from 'path';
import { DEFAULT_DOWNLOAD_PATTERN, MetadataProviderKey, Permission, isAudioFormat, resolveUploadPath } from '@projectx/types';
import type { AudiobookChapter, BookKoboState, BookQuery, BooksPage, MetadataField, ReadStatus } from '@projectx/types';
import {
BOOK_METADATA_LOCK_FIELDS,
DEFAULT_DOWNLOAD_PATTERN,
MetadataProviderKey,
Permission,
isAudioFormat,
resolveUploadPath,
} from '@projectx/types';
import type { AudiobookChapter, BookKoboState, BookMetadataLockField, BookQuery, BooksPage, MetadataField, ReadStatus } from '@projectx/types';
import { assembleBookCards } from './utils/assemble-book-cards';
import type { RequestUser } from '../../common/types/request-user';
import { AppSettingsService } from '../app-settings/app-settings.service';
@@ -716,6 +723,68 @@ export class BookService {
await this.userBookStatusService.setManual(user.id, bookId, status);
}
async bulkSetStatus(bookIds: number[], status: ReadStatus, user: RequestUser): Promise<void> {
const event = 'book.bulk.set_status';
const startedAt = Date.now();
this.logger.log(`[${event}] [start] userId=${user.id} count=${bookIds.length} status=${status} - bulk set status started`);
await this.verifyLibraryAccessForBookIds(bookIds, user);
await this.userBookStatusService.bulkSetManual(user.id, bookIds, status);
this.logger.log(
`[${event}] [end] userId=${user.id} count=${bookIds.length} status=${status} durationMs=${Date.now() - startedAt} - bulk set status completed`,
);
}
async bulkSetRating(bookIds: number[], rating: number | null, user: RequestUser): Promise<void> {
const event = 'book.bulk.set_rating';
const startedAt = Date.now();
this.logger.log(`[${event}] [start] userId=${user.id} count=${bookIds.length} rating=${rating ?? 'null'} - bulk set rating started`);
await this.verifyLibraryAccessForBookIds(bookIds, user);
await this.bookRepo.bulkSetRating(bookIds, rating);
this.triggerPostMetadataUpdateEffects(bookIds, user.id);
this.logger.log(
`[${event}] [end] userId=${user.id} count=${bookIds.length} rating=${rating ?? 'null'} durationMs=${Date.now() - startedAt} - bulk set rating completed`,
);
}
async bulkUpdateTags(bookIds: number[], mode: 'add' | 'remove' | 'replace', tags: string[], user: RequestUser): Promise<void> {
const event = 'book.bulk.update_tags';
const startedAt = Date.now();
this.logger.log(`[${event}] [start] userId=${user.id} count=${bookIds.length} mode=${mode} tagCount=${tags.length} - bulk update tags started`);
await this.verifyLibraryAccessForBookIds(bookIds, user);
await this.bookRepo.withTransaction(async (tx) => {
if (mode === 'replace') {
for (const bookId of bookIds) {
await this.metadataService.replaceTags(bookId, tags, { executor: tx });
}
return;
}
const currentTagsMap = await this.bookRepo.findTagsByBookIds(bookIds, tx);
for (const bookId of bookIds) {
const current = currentTagsMap.get(bookId) ?? [];
const next = mode === 'add' ? [...new Set([...current, ...tags])] : current.filter((t) => !tags.includes(t));
await this.metadataService.replaceTags(bookId, next, { executor: tx });
}
});
this.triggerPostMetadataUpdateEffects(bookIds, user.id);
this.logger.log(
`[${event}] [end] userId=${user.id} count=${bookIds.length} mode=${mode} tagCount=${tags.length} durationMs=${Date.now() - startedAt} - bulk update tags completed`,
);
}
async bulkSetMetadataLock(bookIds: number[], locked: boolean, user: RequestUser): Promise<void> {
const event = 'book.bulk.set_metadata_lock';
const startedAt = Date.now();
this.logger.log(`[${event}] [start] userId=${user.id} count=${bookIds.length} locked=${locked} - bulk set metadata lock started`);
await this.verifyLibraryAccessForBookIds(bookIds, user);
const fields: BookMetadataLockField[] = locked ? [...BOOK_METADATA_LOCK_FIELDS] : [];
await this.bookMetadataLockService.bulkReplaceLockedFields(bookIds, fields);
this.logger.log(
`[${event}] [end] userId=${user.id} count=${bookIds.length} locked=${locked} durationMs=${Date.now() - startedAt} - bulk set metadata lock completed`,
);
}
async getKoboState(id: number, user: RequestUser): Promise<BookKoboState> {
await this.verifyBookAccess(id, user);
@@ -1341,6 +1410,15 @@ export class BookService {
);
}
private triggerPostMetadataUpdateEffects(bookIds: number[], userId: number): void {
for (const bookId of bookIds) {
this.fileWriteService?.scheduleWrite(bookId, 'auto', userId);
void this.scoreService
.calculateAndSave(bookId)
.catch((err: Error) => this.logger.warn(`Score calculation failed for book ${bookId}: ${err.message}`));
}
}
private resolveChapters(
stored: AudiobookChapter[] | null | undefined,
fileRows: { absolutePath: string; format: string | null; durationSeconds: number | null }[],
@@ -0,0 +1,11 @@
import { ArrayNotEmpty, IsArray, IsBoolean, IsInt } from 'class-validator';
export class BulkSetMetadataLockDto {
@IsArray()
@ArrayNotEmpty()
@IsInt({ each: true })
bookIds: number[];
@IsBoolean()
locked!: boolean;
}
@@ -0,0 +1,14 @@
import { ArrayNotEmpty, IsArray, IsInt, IsOptional, Max, Min } from 'class-validator';
export class BulkSetRatingDto {
@IsArray()
@ArrayNotEmpty()
@IsInt({ each: true })
bookIds: number[];
@IsOptional()
@IsInt()
@Min(1)
@Max(5)
rating?: number | null;
}
@@ -0,0 +1,13 @@
import { ArrayNotEmpty, IsArray, IsIn, IsInt } from 'class-validator';
import type { ReadStatus } from '@projectx/types';
import { READ_STATUSES } from '../../user-book-status/user-book-status.constants';
export class BulkSetStatusDto {
@IsArray()
@ArrayNotEmpty()
@IsInt({ each: true })
bookIds: number[];
@IsIn(READ_STATUSES)
status!: ReadStatus;
}
@@ -0,0 +1,18 @@
import { ArrayNotEmpty, IsArray, IsIn, IsInt, IsString } from 'class-validator';
const TAG_MODES = ['add', 'remove', 'replace'] as const;
export type TagUpdateMode = (typeof TAG_MODES)[number];
export class BulkUpdateTagsDto {
@IsArray()
@ArrayNotEmpty()
@IsInt({ each: true })
bookIds: number[];
@IsIn(TAG_MODES)
mode!: TagUpdateMode;
@IsArray()
@IsString({ each: true })
tags: string[];
}
@@ -13,6 +13,7 @@ function makeBookRow(id: number, overrides?: Partial<Parameters<typeof assembleB
language: null,
rating: null,
coverSource: null,
lockedFields: null,
...overrides,
};
}
@@ -140,6 +141,22 @@ describe('assembleBookCards', () => {
expect(card.hasCover).toBe(true);
});
it('marks cards with metadata locks', () => {
const rows = [makeBookRow(1, { lockedFields: ['title'] })];
const [card] = assembleBookCards(rows, [], [], [], []);
expect(card.hasMetadataLocks).toBe(true);
});
it('marks cards without metadata locks as unlocked', () => {
const rows = [makeBookRow(1, { lockedFields: [] })];
const [card] = assembleBookCards(rows, [], [], [], []);
expect(card.hasMetadataLocks).toBe(false);
});
it('includes all optional metadata fields', () => {
const rows = [makeBookRow(1, { seriesName: 'Dune', seriesIndex: 1, publishedYear: 1965, language: 'en', rating: 5 })];
@@ -15,6 +15,7 @@ type BookRow = {
language: string | null;
rating: number | null;
coverSource: string | null;
lockedFields: string[] | null;
};
type NameRow = { bookId: number; name: string };
@@ -101,6 +102,7 @@ export function assembleBookCards(
readStatus: statusByBookId.get(row.id) ?? null,
addedAt: row.addedAt.toISOString(),
hasCover: row.coverSource != null,
hasMetadataLocks: (row.lockedFields?.length ?? 0) > 0,
};
});
}
@@ -234,13 +234,13 @@ describe('ScannerRepository', () => {
const { repo, queues } = makeRepo();
await expect(repo.findBookCardData([])).resolves.toEqual({ rows: [], authorRows: [], fileRows: [], genreRows: [] });
queues.select.push([{ id: 1, title: 'Book One' }]);
queues.select.push([{ id: 1, title: 'Book One', lockedFields: [] }]);
queues.select.push([{ bookId: 1, name: 'Author One' }]);
queues.select.push([{ bookId: 1, id: 10, format: 'epub', role: 'content' }]);
queues.select.push([{ bookId: 1, name: 'Fiction' }]);
await expect(repo.findBookCardData([1])).resolves.toEqual({
rows: [{ id: 1, title: 'Book One' }],
rows: [{ id: 1, title: 'Book One', lockedFields: [] }],
authorRows: [{ bookId: 1, name: 'Author One' }],
fileRows: [{ bookId: 1, id: 10, format: 'epub', role: 'content' }],
genreRows: [{ bookId: 1, name: 'Fiction' }],
@@ -348,6 +348,7 @@ export class ScannerRepository {
language: bookMetadata.language,
rating: bookMetadata.rating,
coverSource: bookMetadata.coverSource,
lockedFields: bookMetadata.lockedFields,
})
.from(books)
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
@@ -17,6 +17,14 @@ export class UserBookStatusService {
await this.repo.upsert(userId, bookId, status, 'manual', new Date());
}
async bulkSetManual(userId: number, bookIds: number[], status: ReadStatus): Promise<void> {
if (bookIds.length === 0) return;
const now = new Date();
const existing = await this.repo.findByBookIds(userId, bookIds);
const existingMap = new Map(existing.map((row) => [row.bookId, row]));
await Promise.all(bookIds.map((bookId) => this.repo.upsert(userId, bookId, status, 'manual', now, existingMap.get(bookId))));
}
async autoUpdate(
userId: number,
bookId: number,