Files
bookorbit/server/src/modules/book/book.repository.ts
T
neon d66f853cad feat(settings): add read-aloud filename token
Expose media-overlay-aware naming across file creation and delivery paths while preserving existing client contracts. Keep settings router mocks compatible with the application router exports.

Closes #1590
2026-09-29 01:29:25 +00:00

2844 lines
106 KiB
TypeScript

import { BadRequestException, Inject, Injectable, Optional } from '@nestjs/common';
import { SQL, and, asc, count, eq, inArray, isNotNull, isNull, lte, ne, or, sql } from 'drizzle-orm';
import { SUPPORTED_BOOK_FORMATS } from '../upload/upload-validator.service';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type {
ContentFilterRules,
CustomMetadataFieldTypeMap,
JumpBucketKind,
JumpBucketsResponse,
SortField,
SortSpec,
TemporalJumpBucketPrecision,
TemporalJumpBucketUnit,
} from '@bookorbit/types';
import type { UnscopedBookRecommendation } from '@bookorbit/types';
import { isAudioFormat, isComicFormat, normalizeCoverAspectRatio } from '@bookorbit/types';
import { buildContentFilterClauses } from '../../common/utils/content-filter-sql.utils';
import { rankFileRowsByBook } from '../../common/utils/primary-file-selection.utils';
import { accentInsensitiveIlike } from '../../common/utils/accent-insensitive-search.utils';
import { advanceIsoTimestamp } from '../../common/utils/iso-timestamp.utils';
import { parsePgTimestamptz } from '../../common/utils/pg-timestamp.utils';
import { scanStateInvalidationPaths } from '../../common/utils/scan-state-paths.utils';
import { seriesIndexSortKeySql } from '../../common/utils/series-index-sql.utils';
import { hasReachedProgressThreshold } from '../../common/utils/progress-threshold.utils';
import { SeriesIdentityService } from '../../common/services/series-identity.service';
import { SeriesMembershipService } from '../../common/services/series-membership.service';
import { BookQueryBuilder } from './book-query-builder.service';
import { letterJumpBucketExpr } from './jump-bucket-expr';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import {
authors,
bookAuthors,
bookCommunityRatings,
bookFiles,
bookGenres,
bookMetadata,
bookNarrators,
bookSeries,
bookSeriesMemberships,
books,
bookTags,
collectionBooks,
collections,
genres,
koboDevices,
koboLibrarySnapshots,
koboReadingStates,
koboSnapshotBooks,
koboSyncSettings,
koreaderDeviceProgress,
koreaderProgressResets,
libraries,
libraryFolders,
narrators,
audiobookProgress,
koreaderBookHashLinks,
readingProgress,
userBookReadAloudSyncSettings,
userBookRatings,
tags,
userBookStatus,
} from '../../db/schema';
type Db = NodePgDatabase<typeof schema>;
type DbTransaction = Parameters<Parameters<Db['transaction']>[0]>[0];
type MetadataUpdateExecutor = Pick<Db, 'delete' | 'insert' | 'select' | 'update'>;
type MetadataReadExecutor = Pick<Db, 'select'>;
type JsonObj = Record<string, unknown>;
type BookRepositoryTx = Parameters<Parameters<NodePgDatabase<typeof schema>['transaction']>[0]>[0];
type CollapsedRawRow = {
id: number | null;
status: string;
cover_aspect_ratio: string;
primary_file_id: number | null;
folder_path: string;
added_at: string;
updated_at: string;
title: string | null;
series_id: number | null;
series_name: string | null;
series_index: string | null;
published_date: string | null;
published_year: number | null;
language: string | null;
rating: number | null;
metadata_score: number | null;
cover_source: string | null;
locked_fields: string[] | null;
subtitle: string | null;
isbn13: string | null;
hardcover_id: string | null;
hardcover_edition_id: string | null;
publisher: string | null;
page_count: number | null;
sort_title: string | null;
sort_added_at: string | null;
book_count: string | null;
read_count: string | null;
cover_book_ids: number[] | null;
cover_updated_at_by_book_id: JsonObj | null;
first_volume_book_id: number | null;
latest_volume_book_id: number | null;
first_unread_book_id: number | null;
total_count: string;
book_total: string;
};
type PatternMetadataRow = {
bookId: number;
libraryName: string;
title: string | null;
subtitle: string | null;
publisher: string | null;
publishedDate: string | null;
publishedYear: number | null;
language: string | null;
seriesId: number | null;
seriesName: string | null;
seriesIndex: string | null;
isbn13: string | null;
authors: string[];
narrators: string[];
};
function parseDateByBookId(value: JsonObj | null | undefined): Record<number, Date | null> {
const result: Record<number, Date | null> = {};
for (const [rawBookId, rawValue] of Object.entries(value ?? {})) {
const bookId = Number(rawBookId);
if (!Number.isInteger(bookId) || bookId <= 0) continue;
if (rawValue instanceof Date) {
result[bookId] = Number.isNaN(rawValue.getTime()) ? null : rawValue;
continue;
}
if (typeof rawValue === 'string' || typeof rawValue === 'number') {
const parsed = new Date(rawValue);
result[bookId] = Number.isNaN(parsed.getTime()) ? null : parsed;
continue;
}
result[bookId] = null;
}
return result;
}
const PROGRESS_EPSILON = 0.0001;
// Shared between the collapsed listing query and the collapsed jump-buckets
// query: both must pick the exact same series representative or bucket indexes
// drift from listing offsets. Guarded by the jump-buckets invariant e2e test.
const COLLAPSE_GROUP_KEY_SQL = `base.library_id, COALESCE(base.series_id::text, 'book_' || base.id::text)`;
const COLLAPSE_REPRESENTATIVE_PICK_SQL = `${COLLAPSE_GROUP_KEY_SQL},
${seriesIndexSortKeySql('base.series_index')} ASC NULLS LAST,
base.series_index COLLATE "C" ASC NULLS LAST,
base.added_at ASC,
base.id ASC`;
type JumpBucketRawRow = {
bucket: string;
item_index: number | string;
total: number | string;
};
type TemporalJumpBucketRawRow = JumpBucketRawRow & {
is_unknown: boolean;
unit: TemporalJumpBucketUnit;
step: number | string;
};
type DiscreteJumpBucketRawRow = JumpBucketRawRow & {
is_unknown: boolean;
};
type FlatDiscreteSourceParts = {
prefixCte: SQL;
join: SQL;
value: SQL;
};
type CollapsedDiscreteSourceParts = {
prefixCte: SQL;
baseJoin: SQL;
baseExtraSelect: SQL;
representativeExtraSelect: SQL;
value: SQL;
};
type TemporalSourceParts = {
prefixCte: SQL;
join: SQL;
value: SQL;
baseExtraSelect: SQL;
representativeExtraSelect: SQL;
};
function flatDiscreteSourceParts(field: SortField, userId: number): FlatDiscreteSourceParts | null {
const empty = sql.raw('');
const direct = (value: SQL): FlatDiscreteSourceParts => ({ prefixCte: empty, join: empty, value });
switch (field) {
case 'title':
return direct(sql`${bookMetadata.title}`);
case 'author':
return direct(sql`${books.primaryAuthorSortName}`);
case 'series':
return direct(sql`${bookMetadata.seriesName}`);
case 'publisher':
return direct(sql`${bookMetadata.publisher}`);
case 'language':
return direct(sql`${bookMetadata.language}`);
case 'format':
return {
prefixCte: empty,
join: sql`LEFT JOIN ${bookFiles} rail_primary_file ON rail_primary_file.id = ${books.primaryFileId}`,
value: sql.raw('rail_primary_file.format'),
};
case 'readStatus':
return {
prefixCte: empty,
join: sql`LEFT JOIN ${userBookStatus} rail_ubs ON rail_ubs.book_id = ${books.id} AND rail_ubs.user_id = ${userId}`,
value: sql`coalesce(rail_ubs.status::text, 'unread')`,
};
default:
return null;
}
}
function collapsedDiscreteSourceParts(field: SortField, userId: number): CollapsedDiscreteSourceParts | null {
const empty = sql.raw('');
const direct = (value: SQL): CollapsedDiscreteSourceParts => ({
prefixCte: empty,
baseJoin: empty,
baseExtraSelect: empty,
representativeExtraSelect: empty,
value,
});
switch (field) {
case 'title':
case 'series':
return direct(sql.raw('r.sort_title'));
case 'author':
return direct(sql.raw('r.author_sort_name'));
case 'publisher':
return direct(sql.raw('r.publisher'));
case 'language':
return {
prefixCte: empty,
baseJoin: empty,
baseExtraSelect: sql`, ${bookMetadata.language} AS language`,
representativeExtraSelect: sql`, base.language`,
value: sql.raw('r.language'),
};
case 'format':
return {
prefixCte: empty,
baseJoin: sql`LEFT JOIN ${bookFiles} rail_primary_file ON rail_primary_file.id = ${books.primaryFileId}`,
baseExtraSelect: sql`, rail_primary_file.format AS rail_discrete_value`,
representativeExtraSelect: sql`, base.rail_discrete_value`,
value: sql.raw('r.rail_discrete_value'),
};
case 'readStatus':
return {
prefixCte: empty,
baseJoin: sql`LEFT JOIN ${userBookStatus} rail_ubs ON rail_ubs.book_id = ${books.id} AND rail_ubs.user_id = ${userId}`,
baseExtraSelect: sql`, coalesce(rail_ubs.status::text, 'unread') AS rail_discrete_value`,
representativeExtraSelect: sql`, base.rail_discrete_value`,
value: sql.raw('r.rail_discrete_value'),
};
default:
return null;
}
}
function discreteBucketsQuery(opts: { prefixCte: SQL; orderedRows: SQL; maxBuckets: number }): SQL {
return sql`
WITH ${opts.prefixCte}
ordered AS MATERIALIZED (${opts.orderedRows}),
grouped AS (
SELECT bucket, is_unknown, min(item_index)::int AS item_index, (SELECT count(*) FROM ordered)::int AS total
FROM ordered
WHERE bucket IS NOT NULL
GROUP BY bucket, is_unknown
),
ranked AS (
SELECT
grouped.*,
row_number() OVER (ORDER BY item_index)::int AS bucket_ordinal,
count(*) OVER ()::int AS bucket_count
FROM grouped
)
SELECT bucket, item_index, total, is_unknown
FROM ranked
WHERE bucket_count <= ${opts.maxBuckets}
OR bucket_ordinal = 1
OR bucket_ordinal = bucket_count
OR mod(
bucket_ordinal - 1,
GREATEST(ceil((bucket_count - 1)::numeric / GREATEST(${opts.maxBuckets} - 1, 1))::int, 1)
) = 0
ORDER BY item_index
LIMIT ${opts.maxBuckets}
`;
}
function temporalSourceParts(field: SortField, userId: number, timeZone: string, collapsed: boolean): TemporalSourceParts | null {
const empty = sql.raw('');
const direct = (value: SQL): TemporalSourceParts => ({
prefixCte: empty,
join: empty,
value,
baseExtraSelect: empty,
representativeExtraSelect: empty,
});
if (collapsed) {
switch (field) {
case 'addedAt':
return direct(sql`timezone(${timeZone}, r.sort_added_at)`);
case 'updatedAt':
return direct(sql`timezone(${timeZone}, r.updated_at)`);
case 'publishedDate':
return direct(sql`coalesce(r.published_date, make_date(r.published_year, 1, 1))::timestamp`);
case 'publishedYear':
return direct(sql`make_date(r.published_year, 1, 1)::timestamp`);
case 'startedAt':
case 'finishedAt': {
const column = field === 'startedAt' ? sql.raw('rail_ubs.started_at') : sql.raw('rail_ubs.finished_at');
return {
prefixCte: empty,
join: sql`LEFT JOIN ${userBookStatus} rail_ubs ON rail_ubs.book_id = books.id AND rail_ubs.user_id = ${userId}`,
value: sql`timezone(${timeZone}, r.rail_temporal_value)`,
baseExtraSelect: sql`, ${column} AS rail_temporal_value`,
representativeExtraSelect: sql`, base.rail_temporal_value`,
};
}
case 'lastReadAt':
return {
prefixCte: sql`rail_last_read AS MATERIALIZED (
SELECT rail_bf.book_id, max(rail_rp.last_read_at) AS value
FROM ${readingProgress} rail_rp
INNER JOIN ${bookFiles} rail_bf ON rail_bf.id = rail_rp.book_file_id
WHERE rail_rp.user_id = ${userId}
GROUP BY rail_bf.book_id
),`,
join: sql`LEFT JOIN rail_last_read ON rail_last_read.book_id = books.id`,
value: sql`timezone(${timeZone}, r.rail_temporal_value)`,
baseExtraSelect: sql`, rail_last_read.value AS rail_temporal_value`,
representativeExtraSelect: sql`, base.rail_temporal_value`,
};
default:
return null;
}
}
switch (field) {
case 'addedAt':
return direct(sql`timezone(${timeZone}, ${books.addedAt})`);
case 'updatedAt':
return direct(sql`timezone(${timeZone}, ${books.updatedAt})`);
case 'publishedDate':
return direct(sql`coalesce(${bookMetadata.publishedDate}, make_date(${bookMetadata.publishedYear}, 1, 1))::timestamp`);
case 'publishedYear':
return direct(sql`make_date(${bookMetadata.publishedYear}, 1, 1)::timestamp`);
case 'startedAt':
case 'finishedAt': {
const column = field === 'startedAt' ? sql.raw('rail_ubs.started_at') : sql.raw('rail_ubs.finished_at');
return {
prefixCte: empty,
join: sql`LEFT JOIN ${userBookStatus} rail_ubs ON rail_ubs.book_id = ${books.id} AND rail_ubs.user_id = ${userId}`,
value: sql`timezone(${timeZone}, ${column})`,
baseExtraSelect: empty,
representativeExtraSelect: empty,
};
}
case 'lastReadAt':
return {
prefixCte: sql`rail_last_read AS MATERIALIZED (
SELECT rail_bf.book_id, max(rail_rp.last_read_at) AS value
FROM ${readingProgress} rail_rp
INNER JOIN ${bookFiles} rail_bf ON rail_bf.id = rail_rp.book_file_id
WHERE rail_rp.user_id = ${userId}
GROUP BY rail_bf.book_id
),`,
join: sql`LEFT JOIN rail_last_read ON rail_last_read.book_id = ${books.id}`,
value: sql`timezone(${timeZone}, rail_last_read.value)`,
baseExtraSelect: empty,
representativeExtraSelect: empty,
};
default:
return null;
}
}
function temporalBucketsQuery(opts: {
prefixCte: SQL;
temporalRows: SQL;
precision: TemporalJumpBucketPrecision;
direction: 'asc' | 'desc';
maxBuckets: number;
}): SQL {
const direction = sql.raw(opts.direction === 'desc' ? 'DESC' : 'ASC');
const unit =
opts.precision === 'year'
? sql`'year'::text`
: sql`CASE
WHEN day_span <= known_capacity THEN 'day'
WHEN month_span <= known_capacity THEN 'month'
ELSE 'year'
END`;
return sql`
WITH ${opts.prefixCte}
temporal_rows AS MATERIALIZED (${opts.temporalRows}),
bounds AS (
SELECT
min(value::date) AS min_date,
max(value::date) AS max_date,
count(*)::int AS total,
count(value)::int AS known_total,
(count(DISTINCT extract(year FROM value)) FILTER (WHERE value IS NOT NULL))::int AS known_year_count
FROM temporal_rows
),
spans AS (
SELECT
bounds.*,
GREATEST(${opts.maxBuckets} - CASE WHEN total > known_total THEN 1 ELSE 0 END, 2)::int AS known_capacity,
COALESCE((max_date - min_date) + 1, 1)::int AS day_span,
COALESCE(
((extract(year FROM max_date)::int - extract(year FROM min_date)::int) * 12)
+ extract(month FROM max_date)::int - extract(month FROM min_date)::int + 1,
1
)::int AS month_span,
COALESCE(extract(year FROM max_date)::int - extract(year FROM min_date)::int + 1, 1)::int AS year_span
FROM bounds
),
resolution_seed AS (
SELECT spans.*, ${unit} AS unit
FROM spans
),
resolution_raw AS (
SELECT
resolution_seed.*,
CASE
WHEN unit <> 'year' OR year_span <= known_capacity OR known_year_count <= known_capacity THEN 1
ELSE GREATEST(1, ceil(year_span::numeric / GREATEST(known_capacity - 1, 1))::int)
END AS raw_year_step
FROM resolution_seed
),
resolution_scale AS (
SELECT
resolution_raw.*,
power(10::numeric, floor(log(GREATEST(raw_year_step, 1)::numeric)))::int AS step_scale
FROM resolution_raw
),
resolution AS (
SELECT
min_date,
max_date,
total,
known_total,
known_capacity,
unit,
CASE
WHEN raw_year_step <= step_scale THEN step_scale
WHEN raw_year_step <= step_scale * 2 THEN step_scale * 2
WHEN raw_year_step <= step_scale * 5 THEN step_scale * 5
ELSE step_scale * 10
END::int AS step
FROM resolution_scale
),
bucketed AS (
SELECT
temporal_rows.value,
resolution.unit,
resolution.step,
CASE resolution.unit
WHEN 'day' THEN to_char(temporal_rows.value::date, 'YYYY-MM-DD')
WHEN 'month' THEN to_char(date_trunc('month', temporal_rows.value)::date, 'YYYY-MM')
ELSE (floor(extract(year FROM temporal_rows.value)::numeric / resolution.step) * resolution.step)::int::text
END AS bucket
FROM temporal_rows
CROSS JOIN resolution
WHERE temporal_rows.value IS NOT NULL
),
known_groups AS (
SELECT bucket, min(value) AS bucket_sort, count(*)::int AS item_count, min(unit) AS unit, min(step)::int AS step
FROM bucketed
GROUP BY bucket
),
known_ranked AS (
SELECT
bucket,
COALESCE(
sum(item_count) OVER (
ORDER BY bucket_sort ${direction}
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
),
0
)::int AS item_index,
unit,
step
FROM known_groups
),
result_rows AS (
SELECT
known_ranked.bucket,
known_ranked.item_index,
bounds.total,
false AS is_unknown,
known_ranked.unit,
known_ranked.step
FROM known_ranked
CROSS JOIN bounds
UNION ALL
SELECT
'__unknown__' AS bucket,
bounds.known_total AS item_index,
bounds.total,
true AS is_unknown,
resolution.unit,
resolution.step
FROM bounds
CROSS JOIN resolution
WHERE bounds.total > bounds.known_total
)
SELECT bucket, item_index, total, is_unknown, unit, step
FROM result_rows
ORDER BY item_index
`;
}
@Injectable()
export class BookRepository {
constructor(
@Inject(DB) private readonly db: Db,
@Optional() private readonly seriesIdentity?: SeriesIdentityService,
@Optional() private readonly seriesMemberships?: SeriesMembershipService,
) {}
private visibleWhere(where: SQL | undefined): SQL {
return where ? and(where, ne(books.status, 'processing'))! : ne(books.status, 'processing');
}
async withTransaction<T>(callback: (tx: DbTransaction) => Promise<T>): Promise<T> {
return this.db.transaction((tx) => callback(tx));
}
async findCards(opts: { where: SQL | undefined; orderBy: SQL[]; limit: number; offset: number; userId: number }) {
const { where, orderBy, limit, offset, userId } = opts;
const visibleWhere = this.visibleWhere(where);
const rows = await this.db
.select({
id: books.id,
status: books.status,
coverAspectRatio: libraries.coverAspectRatio,
primaryFileId: books.primaryFileId,
folderPath: books.folderPath,
addedAt: books.addedAt,
updatedAt: books.updatedAt,
title: bookMetadata.title,
seriesId: bookMetadata.seriesId,
seriesName: bookMetadata.seriesName,
seriesIndex: bookMetadata.seriesIndex,
publishedDate: bookMetadata.publishedDate,
publishedYear: bookMetadata.publishedYear,
language: bookMetadata.language,
rating: userBookRatings.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,
metadataScore: bookMetadata.metadataScore,
_total: sql<number>`count(*) over()`.as('_total'),
})
.from(books)
.innerJoin(libraries, eq(libraries.id, books.libraryId))
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.leftJoin(userBookRatings, and(eq(userBookRatings.bookId, books.id), eq(userBookRatings.userId, userId)))
.where(visibleWhere)
.orderBy(...orderBy)
.limit(limit)
.offset(offset);
const total = rows.length > 0 ? rows[0]._total : await this.countWhere(visibleWhere);
const bookRefs = rows.map((r) => ({ id: r.id, primaryFileId: r.primaryFileId ?? null }));
const enrichment = await this.enrichBookIds(bookRefs, userId);
return { rows, ...enrichment, total: Number(total) };
}
async findCardIds(opts: { where: SQL | undefined; orderBy: SQL[]; limit: number; offset: number; userId: number }): Promise<number[]> {
const rows = await this.db
.select({ id: books.id })
.from(books)
.innerJoin(libraries, eq(libraries.id, books.libraryId))
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.leftJoin(userBookRatings, and(eq(userBookRatings.bookId, books.id), eq(userBookRatings.userId, opts.userId)))
.where(this.visibleWhere(opts.where))
.orderBy(...opts.orderBy)
.limit(opts.limit)
.offset(opts.offset);
return rows.map((row) => row.id);
}
/**
* Card files in edition order, so every card, row and search result lists a book's formats the
* way its library ranks them. Libraries are few, so their priorities come in one small query.
*/
private async rankCardFileRows<
T extends { bookId: number; id: number; format: string | null; role: string; sizeBytes: number | null; mediaOverlayAvailable: boolean },
>(fileRows: T[], bookRefs: Array<{ id: number; primaryFileId: number | null }>): Promise<T[]> {
if (fileRows.length === 0) return fileRows;
const bookIds = [...new Set(fileRows.map((row) => row.bookId))];
const priorityRows = await this.db
.select({ bookId: books.id, formatPriority: libraries.formatPriority })
.from(books)
.innerJoin(libraries, eq(libraries.id, books.libraryId))
.where(inArray(books.id, bookIds));
const priorityByBook = new Map(priorityRows.map((row) => [row.bookId, row.formatPriority as string[] | null]));
const contextByBook = new Map(
bookRefs.map((book) => [book.id, { formatPriority: priorityByBook.get(book.id), primaryFileId: book.primaryFileId }]),
);
return rankFileRowsByBook(fileRows, contextByBook);
}
private async enrichBookIds(bookRefs: Array<{ id: number; primaryFileId: number | null }>, userId: number) {
const bookIds = bookRefs.map((book) => book.id);
const primaryFileIds = bookRefs.map((book) => book.primaryFileId).filter((id): id is number => id != null);
if (bookIds.length === 0) {
return {
authorRows: [] as { bookId: number; name: string }[],
fileRows: [] as {
bookId: number;
id: number;
format: string | null;
role: string;
sizeBytes: number | null;
mediaOverlayAvailable: boolean;
mediaOverlayDurationSeconds: number | null;
mediaOverlayCheckedAt: Date | null;
}[],
genreRows: [] as { bookId: number; name: string }[],
tagRows: [] as { bookId: number; name: string }[],
progressRows: [] as { bookFileId: number; percentage: number }[],
statusRows: [] as {
bookId: number;
status: string;
source: string;
startedAt: Date | null;
finishedAt: Date | null;
updatedAt: Date;
}[],
narratorRows: [] as { bookId: number; name: string }[],
seriesMembershipRows: [] as {
bookId: number;
seriesId: number;
seriesName: string;
seriesIndex: string | null;
displayOrder: number;
expectedBookCount: number | null;
}[],
};
}
// Keep book-card hydration below the database pool's capacity when list requests overlap.
// Three small batches retain useful parallelism without allowing one request to claim nine connections.
const [authorRows, unrankedFileRows, genreRows] = await Promise.all([
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 = await this.rankCardFileRows(unrankedFileRows, bookRefs);
const [tagRows, narratorRows, seriesMembershipRows] = await Promise.all([
this.db
.select({ bookId: bookTags.bookId, name: tags.name })
.from(bookTags)
.innerJoin(tags, eq(tags.id, bookTags.tagId))
.where(inArray(bookTags.bookId, bookIds)),
this.db
.select({ bookId: bookNarrators.bookId, name: narrators.name })
.from(bookNarrators)
.innerJoin(narrators, eq(narrators.id, bookNarrators.narratorId))
.where(inArray(bookNarrators.bookId, bookIds))
.orderBy(bookNarrators.displayOrder),
this.db
.select({
bookId: bookSeriesMemberships.bookId,
seriesId: bookSeriesMemberships.seriesId,
seriesName: bookSeries.name,
seriesIndex: bookSeriesMemberships.seriesIndex,
displayOrder: bookSeriesMemberships.displayOrder,
expectedBookCount: bookSeries.expectedBookCount,
})
.from(bookSeriesMemberships)
.innerJoin(bookSeries, eq(bookSeries.id, bookSeriesMemberships.seriesId))
.where(inArray(bookSeriesMemberships.bookId, bookIds))
.orderBy(asc(bookSeriesMemberships.bookId), asc(bookSeriesMemberships.displayOrder), asc(bookSeriesMemberships.seriesId)),
]);
const [statusRows, fileProgressRows, audiobookProgressRows] = await Promise.all([
this.db
.select({
bookId: userBookStatus.bookId,
status: userBookStatus.status,
source: userBookStatus.source,
startedAt: userBookStatus.startedAt,
finishedAt: userBookStatus.finishedAt,
updatedAt: userBookStatus.updatedAt,
})
.from(userBookStatus)
.where(and(eq(userBookStatus.userId, userId), inArray(userBookStatus.bookId, bookIds))),
primaryFileIds.length > 0
? this.db
.select({
bookFileId: readingProgress.bookFileId,
percentage: readingProgress.percentage,
lastReadAt: readingProgress.lastReadAt,
})
.from(readingProgress)
.where(and(eq(readingProgress.userId, userId), inArray(readingProgress.bookFileId, primaryFileIds)))
: Promise.resolve([] as { bookFileId: number; percentage: number; lastReadAt: Date }[]),
this.db
.select({
bookId: audiobookProgress.bookId,
percentage: audiobookProgress.percentage,
updatedAt: audiobookProgress.updatedAt,
})
.from(audiobookProgress)
.where(and(eq(audiobookProgress.userId, userId), inArray(audiobookProgress.bookId, bookIds))),
]);
const fileProgressById = new Map(fileProgressRows.map((row) => [row.bookFileId, row]));
const audiobookProgressByBookId = new Map(audiobookProgressRows.map((row) => [row.bookId, row]));
const progressRows = bookRefs.flatMap((book) => {
if (book.primaryFileId == null) return [];
const fileProgress = fileProgressById.get(book.primaryFileId);
const audioProgress = audiobookProgressByBookId.get(book.id);
if (!fileProgress && !audioProgress) return [];
const mergedPercentage =
fileProgress && audioProgress
? fileProgress.lastReadAt >= audioProgress.updatedAt
? fileProgress.percentage
: audioProgress.percentage
: (fileProgress?.percentage ?? audioProgress?.percentage ?? null);
return [{ bookFileId: book.primaryFileId, percentage: mergedPercentage }];
});
return { authorRows, fileRows, genreRows, tagRows, progressRows, statusRows, narratorRows, seriesMembershipRows };
}
async findCardsByBookIds(bookIds: number[], userId: number) {
if (bookIds.length === 0) {
return {
rows: [],
authorRows: [],
fileRows: [],
genreRows: [],
tagRows: [],
progressRows: [],
statusRows: [],
narratorRows: [],
seriesMembershipRows: [],
total: 0,
};
}
return this.findCards({
where: inArray(books.id, bookIds),
orderBy: [],
limit: bookIds.length,
offset: 0,
userId,
});
}
async findCardsCollapsed(opts: {
where: SQL | undefined;
sort: SortSpec[];
limit: number;
offset: number;
userId: number;
customFieldTypes?: CustomMetadataFieldTypeMap;
defaultCollectionId?: number;
randomSeed?: number;
}): Promise<{
rows: Array<{
id: number;
status: string;
coverAspectRatio: string;
primaryFileId: number | null;
folderPath: string;
addedAt: Date;
updatedAt: Date;
title: string | null;
seriesName: string | null;
seriesId: number | null;
seriesIndex: string | null;
publishedDate: string | null;
publishedYear: number | null;
language: string | null;
rating: number | null;
metadataScore: number | null;
coverSource: string | null;
lockedFields: string[] | null;
subtitle: string | null;
publisher: string | null;
pageCount: number | null;
isbn13: string | null;
hardcoverId: string | null;
hardcoverEditionId: string | null;
bookCount: number | null;
readCount: number | null;
coverBookIds: number[] | null;
coverUpdatedAtByBookId: Record<number, Date | null> | null;
seriesLatestAddedAt: Date | null;
firstVolumeBookId: number | null;
latestVolumeBookId: number | null;
firstUnreadBookId: number | null;
}>;
authorRows: { bookId: number; name: string }[];
fileRows: {
bookId: number;
id: number;
format: string | null;
role: string;
sizeBytes: number | null;
mediaOverlayAvailable: boolean;
mediaOverlayDurationSeconds: number | null;
mediaOverlayCheckedAt: Date | null;
}[];
genreRows: { bookId: number; name: string }[];
tagRows: { bookId: number; name: string }[];
progressRows: { bookFileId: number; percentage: number | null }[];
statusRows: {
bookId: number;
status: string;
source: string;
startedAt: Date | null;
finishedAt: Date | null;
updatedAt: Date;
}[];
narratorRows: { bookId: number; name: string }[];
seriesMembershipRows: {
bookId: number;
seriesId: number;
seriesName: string;
seriesIndex: string | null;
displayOrder: number;
expectedBookCount: number | null;
}[];
total: number;
bookTotal: number;
}> {
const { where, sort, limit, offset, userId, defaultCollectionId } = opts;
if (defaultCollectionId !== undefined && (!Number.isSafeInteger(defaultCollectionId) || defaultCollectionId <= 0)) {
throw new BadRequestException('Invalid default collection id');
}
const whereFragment = this.visibleWhere(where);
const orderBy = BookQueryBuilder.buildCollapseOrderBy(sort, userId, opts.customFieldTypes, { randomSeed: opts.randomSeed });
// The collectionOrder branch of the order by names sort_collection_position, so the column has
// to exist even for the library and smart scope queries that can never sort on it.
const collectionPosition =
defaultCollectionId === undefined
? sql`NULL::bigint`
: sql`(
SELECT ${collectionBooks.position}
FROM ${collectionBooks}
WHERE ${collectionBooks.collectionId} = ${defaultCollectionId}
AND ${collectionBooks.bookId} = ${books.id}
)`;
const result = await this.db.execute<CollapsedRawRow>(sql`
WITH base_rows AS (
SELECT
books.id,
books.library_id,
books.status,
libraries.cover_aspect_ratio,
books.primary_file_id,
books.primary_author_sort_name,
books.folder_path,
books.added_at,
books.updated_at,
book_metadata.title,
book_metadata.series_id,
book_metadata.series_name,
book_metadata.series_index,
book_metadata.published_date,
book_metadata.published_year,
book_metadata.language,
book_metadata.cover_source,
book_metadata.locked_fields,
book_metadata.publisher,
book_metadata.page_count,
book_metadata.subtitle,
book_metadata.isbn13,
book_metadata.hardcover_id,
book_metadata.hardcover_edition_id,
book_metadata.metadata_score,
${collectionPosition} AS collection_position,
NULLIF(lower(btrim(book_metadata.series_name)), '') AS norm_series
FROM books
INNER JOIN libraries ON libraries.id = books.library_id
LEFT JOIN book_metadata ON book_metadata.book_id = books.id
WHERE ${whereFragment}
),
series_agg AS (
SELECT
base.series_id,
base.library_id,
COUNT(*) AS book_count,
SUM(CASE WHEN user_book_status.status = 'read' THEN 1 ELSE 0 END) AS read_count,
MAX(base.added_at) AS latest_added_at,
MIN(base.collection_position) AS first_collection_position
FROM base_rows base
LEFT JOIN user_book_status ON user_book_status.book_id = base.id AND user_book_status.user_id = ${userId}
WHERE base.series_id IS NOT NULL
GROUP BY base.series_id, base.library_id
),
series_cover_candidates AS (
SELECT
base.series_id,
base.library_id,
base.id,
base.series_index,
base.added_at,
ROW_NUMBER() OVER (
PARTITION BY base.series_id, base.library_id
ORDER BY ${sql.raw(seriesIndexSortKeySql('base.series_index'))} ASC NULLS LAST,
base.series_index COLLATE "C" ASC NULLS LAST, base.added_at ASC, base.id ASC
) AS rn
FROM base_rows base
WHERE base.series_id IS NOT NULL
),
series_covers AS (
SELECT
scc.series_id,
scc.library_id,
COALESCE(
ARRAY_AGG(scc.id ORDER BY ${sql.raw(seriesIndexSortKeySql('scc.series_index'))} ASC NULLS LAST,
scc.series_index COLLATE "C" ASC NULLS LAST, scc.added_at ASC, scc.id ASC) FILTER (WHERE scc.rn <= 4),
ARRAY[]::int[]
) AS cover_book_ids
FROM series_cover_candidates scc
GROUP BY scc.series_id, scc.library_id
),
series_first_volume AS (
SELECT scc.series_id, scc.library_id, scc.id AS first_volume_book_id
FROM series_cover_candidates scc
WHERE scc.rn = 1
),
series_latest_volume AS (
SELECT slv.series_id, slv.library_id, slv.id AS latest_volume_book_id
FROM (
SELECT
base.series_id,
base.library_id,
base.id,
ROW_NUMBER() OVER (
PARTITION BY base.series_id, base.library_id
ORDER BY ${sql.raw(seriesIndexSortKeySql('base.series_index'))} DESC NULLS LAST,
base.series_index COLLATE "C" DESC NULLS LAST, base.added_at DESC, base.id DESC
) AS rn
FROM base_rows base
WHERE base.series_id IS NOT NULL
) slv
WHERE slv.rn = 1
),
series_first_unread AS (
SELECT sfu.series_id, sfu.library_id, sfu.id AS first_unread_book_id
FROM (
SELECT
base.series_id,
base.library_id,
base.id,
ROW_NUMBER() OVER (
PARTITION BY base.series_id, base.library_id
ORDER BY ${sql.raw(seriesIndexSortKeySql('base.series_index'))} ASC NULLS LAST,
base.series_index COLLATE "C" ASC NULLS LAST, base.added_at ASC, base.id ASC
) AS rn
FROM base_rows base
LEFT JOIN user_book_status ubs ON ubs.book_id = base.id AND ubs.user_id = ${userId}
WHERE base.series_id IS NOT NULL
AND ubs.status IS DISTINCT FROM 'read'
) sfu
WHERE sfu.rn = 1
),
series_cover_version_ids AS (
SELECT scc.series_id, scc.library_id, scc.id
FROM series_cover_candidates scc
WHERE scc.rn <= 4
UNION
SELECT sfv.series_id, sfv.library_id, sfv.first_volume_book_id AS id
FROM series_first_volume sfv
UNION
SELECT slv.series_id, slv.library_id, slv.latest_volume_book_id AS id
FROM series_latest_volume slv
UNION
SELECT sfu.series_id, sfu.library_id, sfu.first_unread_book_id AS id
FROM series_first_unread sfu
),
series_cover_versions AS (
SELECT
scvi.series_id,
scvi.library_id,
COALESCE(JSONB_OBJECT_AGG(scvi.id::text, base.updated_at), '{}'::jsonb) AS cover_updated_at_by_book_id
FROM series_cover_version_ids scvi
INNER JOIN base_rows base
ON base.id = scvi.id
AND base.series_id = scvi.series_id
AND base.library_id = scvi.library_id
GROUP BY scvi.series_id, scvi.library_id
),
representatives AS (
SELECT DISTINCT ON (${sql.raw(COLLAPSE_GROUP_KEY_SQL)})
base.id,
base.status,
base.cover_aspect_ratio,
base.primary_file_id,
base.folder_path,
base.added_at,
base.updated_at,
base.title,
base.series_id,
base.series_name,
base.series_index,
base.published_date,
base.published_year,
base.language,
ubr.rating,
base.cover_source,
base.locked_fields,
base.publisher,
base.page_count,
base.subtitle,
base.isbn13,
base.hardcover_id,
base.hardcover_edition_id,
base.metadata_score,
base.primary_author_sort_name AS author_sort_name,
COALESCE(base.norm_series, lower(base.title)) AS sort_title,
COALESCE(sa.latest_added_at, base.added_at) AS sort_added_at,
COALESCE(sa.first_collection_position, base.collection_position) AS sort_collection_position,
sa.book_count,
sa.read_count,
sc.cover_book_ids,
scv.cover_updated_at_by_book_id,
sfv.first_volume_book_id,
slv2.latest_volume_book_id,
sfu2.first_unread_book_id
FROM base_rows base
LEFT JOIN user_book_ratings ubr ON ubr.book_id = base.id AND ubr.user_id = ${userId}
LEFT JOIN series_agg sa
ON sa.series_id = base.series_id
AND sa.library_id = base.library_id
LEFT JOIN series_covers sc
ON sc.series_id = sa.series_id
AND sc.library_id = sa.library_id
LEFT JOIN series_cover_versions scv
ON scv.series_id = sa.series_id
AND scv.library_id = sa.library_id
LEFT JOIN series_first_volume sfv
ON sfv.series_id = base.series_id
AND sfv.library_id = base.library_id
LEFT JOIN series_latest_volume slv2
ON slv2.series_id = base.series_id
AND slv2.library_id = base.library_id
LEFT JOIN series_first_unread sfu2
ON sfu2.series_id = base.series_id
AND sfu2.library_id = base.library_id
ORDER BY ${sql.raw(COLLAPSE_REPRESENTATIVE_PICK_SQL)}
),
totals AS (
SELECT
COUNT(*) AS total_count,
COALESCE(SUM(COALESCE(book_count, 1)), 0) AS book_total
FROM representatives
)
SELECT r.*, totals.total_count, totals.book_total
FROM totals
LEFT JOIN LATERAL (
SELECT r.*
FROM representatives r
ORDER BY ${sql.raw(orderBy)}
LIMIT ${limit} OFFSET ${offset}
) r ON true
ORDER BY ${sql.raw(orderBy)}
`);
const queryRows = result.rows as CollapsedRawRow[];
const total = Number(queryRows[0]?.total_count ?? 0);
const bookTotal = Number(queryRows[0]?.book_total ?? 0);
const rawRows = queryRows.filter((row): row is CollapsedRawRow & { id: number } => row.id !== null);
const mappedRows = rawRows.map((r) => ({
id: r.id,
status: r.status,
coverAspectRatio: r.cover_aspect_ratio,
primaryFileId: r.primary_file_id,
folderPath: r.folder_path,
addedAt: parsePgTimestamptz(r.added_at),
updatedAt: parsePgTimestamptz(r.updated_at),
title: r.title,
seriesId: r.series_id,
seriesName: r.series_name,
seriesIndex: r.series_index,
publishedDate: r.published_date,
publishedYear: r.published_year,
language: r.language,
rating: r.rating,
metadataScore: r.metadata_score !== null ? Number(r.metadata_score) : null,
coverSource: r.cover_source,
lockedFields: r.locked_fields,
subtitle: r.subtitle,
publisher: r.publisher,
pageCount: r.page_count !== null ? Number(r.page_count) : null,
isbn13: r.isbn13,
hardcoverId: r.hardcover_id,
hardcoverEditionId: r.hardcover_edition_id,
bookCount: r.book_count !== null ? Number(r.book_count) : null,
readCount: r.read_count !== null ? Number(r.read_count) : null,
coverBookIds: r.cover_book_ids,
coverUpdatedAtByBookId: parseDateByBookId(r.cover_updated_at_by_book_id),
seriesLatestAddedAt: r.sort_added_at ? parsePgTimestamptz(r.sort_added_at) : null,
firstVolumeBookId: r.first_volume_book_id ?? null,
latestVolumeBookId: r.latest_volume_book_id ?? null,
firstUnreadBookId: r.first_unread_book_id ?? null,
}));
const bookRefs = mappedRows.map((row) => ({ id: row.id, primaryFileId: row.primaryFileId ?? null }));
const enrichment = await this.enrichBookIds(bookRefs, userId);
return { rows: mappedRows, ...enrichment, total, bookTotal };
}
async findJumpBuckets(opts: {
where: SQL | undefined;
field: SortField;
kind: Exclude<JumpBucketKind, 'temporal'>;
userId: number;
maxBuckets: number;
orderBy: SQL[];
}): Promise<JumpBucketsResponse> {
const source = flatDiscreteSourceParts(opts.field, opts.userId);
if (!source) return { buckets: [], total: 0, kind: opts.kind, granularity: null };
const visibleWhere = this.visibleWhere(opts.where);
const bucketExpr = opts.kind === 'letter' ? letterJumpBucketExpr(source.value) : sql`coalesce((${source.value})::text, '__unknown__')`;
const isUnknownExpr = opts.kind === 'category' ? sql`${source.value} IS NULL` : sql`false`;
const result = await this.db.execute<DiscreteJumpBucketRawRow>(
discreteBucketsQuery({
prefixCte: source.prefixCte,
orderedRows: sql`
SELECT
${bucketExpr} AS bucket,
${isUnknownExpr} AS is_unknown,
(ROW_NUMBER() OVER (ORDER BY ${sql.join(opts.orderBy, sql`, `)}) - 1) AS item_index
FROM ${books}
LEFT JOIN ${bookMetadata} ON ${bookMetadata.bookId} = ${books.id}
${source.join}
WHERE ${visibleWhere}
`,
maxBuckets: opts.maxBuckets,
}),
);
return this.mapDiscreteJumpBucketRows(result.rows, opts.kind);
}
async findTemporalJumpBuckets(opts: {
where: SQL | undefined;
field: SortField;
direction: 'asc' | 'desc';
precision: TemporalJumpBucketPrecision;
userId: number;
timeZone: string;
maxBuckets: number;
}): Promise<JumpBucketsResponse> {
const source = temporalSourceParts(opts.field, opts.userId, opts.timeZone, false);
if (!source) return { buckets: [], total: 0, kind: 'temporal', granularity: null };
const visibleWhere = this.visibleWhere(opts.where);
const temporalRows = sql`
SELECT ${source.value} AS value
FROM ${books}
LEFT JOIN ${bookMetadata} ON ${bookMetadata.bookId} = ${books.id}
${source.join}
WHERE ${visibleWhere}
`;
const result = await this.db.execute<TemporalJumpBucketRawRow>(
temporalBucketsQuery({
prefixCte: source.prefixCte,
temporalRows,
precision: opts.precision,
direction: opts.direction,
maxBuckets: opts.maxBuckets,
}),
);
return this.mapTemporalJumpBucketRows(result.rows);
}
// The representatives CTE mirrors findCardsCollapsed's representative
// selection (same group key, same pick order) but only projects the columns
// buildCollapseOrderBy can reference as aliases or via r.* correlated
// subqueries.
async findJumpBucketsCollapsed(opts: {
where: SQL | undefined;
field: SortField;
kind: Exclude<JumpBucketKind, 'temporal'>;
sort: SortSpec[];
userId: number;
maxBuckets: number;
customFieldTypes?: CustomMetadataFieldTypeMap;
randomSeed?: number;
}): Promise<JumpBucketsResponse> {
const source = collapsedDiscreteSourceParts(opts.field, opts.userId);
if (!source) return { buckets: [], total: 0, kind: opts.kind, granularity: null };
const whereFragment = this.visibleWhere(opts.where);
const orderBy = BookQueryBuilder.buildCollapseOrderBy(opts.sort, opts.userId, opts.customFieldTypes, { randomSeed: opts.randomSeed });
const bucketExpr = opts.kind === 'letter' ? letterJumpBucketExpr(source.value) : sql`coalesce((${source.value})::text, '__unknown__')`;
const isUnknownExpr = opts.kind === 'category' ? sql`${source.value} IS NULL` : sql`false`;
const result = await this.db.execute<DiscreteJumpBucketRawRow>(
discreteBucketsQuery({
prefixCte: sql`
${source.prefixCte}
base_rows AS MATERIALIZED (
SELECT
books.id,
books.library_id,
books.primary_file_id,
books.primary_author_sort_name,
books.added_at,
books.updated_at,
book_metadata.title,
book_metadata.series_id,
book_metadata.series_name,
book_metadata.series_index,
book_metadata.published_date,
book_metadata.published_year,
book_metadata.publisher,
book_metadata.page_count,
NULLIF(lower(btrim(book_metadata.series_name)), '') AS norm_series
${source.baseExtraSelect}
FROM books
LEFT JOIN book_metadata ON book_metadata.book_id = books.id
${source.baseJoin}
WHERE ${whereFragment}
),
series_latest AS (
SELECT
base.series_id,
base.library_id,
MAX(base.added_at) AS latest_added_at
FROM base_rows base
WHERE base.series_id IS NOT NULL
GROUP BY base.series_id, base.library_id
),
representatives AS (
SELECT DISTINCT ON (${sql.raw(COLLAPSE_GROUP_KEY_SQL)})
base.id,
base.primary_file_id,
base.updated_at,
base.series_index,
base.published_date,
base.published_year,
base.publisher,
base.page_count,
ubr.rating,
base.primary_author_sort_name AS author_sort_name,
COALESCE(base.norm_series, lower(base.title)) AS sort_title,
COALESCE(sl.latest_added_at, base.added_at) AS sort_added_at
${source.representativeExtraSelect}
FROM base_rows base
LEFT JOIN user_book_ratings ubr ON ubr.book_id = base.id AND ubr.user_id = ${opts.userId}
LEFT JOIN series_latest sl
ON sl.series_id = base.series_id
AND sl.library_id = base.library_id
ORDER BY ${sql.raw(COLLAPSE_REPRESENTATIVE_PICK_SQL)}
),
`,
orderedRows: sql`
SELECT
${bucketExpr} AS bucket,
${isUnknownExpr} AS is_unknown,
(ROW_NUMBER() OVER (ORDER BY ${sql.raw(orderBy)}) - 1) AS item_index
FROM representatives r
`,
maxBuckets: opts.maxBuckets,
}),
);
return this.mapDiscreteJumpBucketRows(result.rows, opts.kind);
}
async findTemporalJumpBucketsCollapsed(opts: {
where: SQL | undefined;
field: SortField;
direction: 'asc' | 'desc';
precision: TemporalJumpBucketPrecision;
userId: number;
timeZone: string;
maxBuckets: number;
}): Promise<JumpBucketsResponse> {
const source = temporalSourceParts(opts.field, opts.userId, opts.timeZone, true);
if (!source) return { buckets: [], total: 0, kind: 'temporal', granularity: null };
const whereFragment = this.visibleWhere(opts.where);
const empty = sql.raw('');
const usesSeriesLatest = opts.field === 'addedAt';
const seriesLatestCte = usesSeriesLatest
? sql`series_latest AS (
SELECT base.series_id, base.library_id, max(base.added_at) AS latest_added_at
FROM base_rows base
WHERE base.series_id IS NOT NULL
GROUP BY base.series_id, base.library_id
),`
: empty;
const seriesLatestJoin = usesSeriesLatest
? sql`LEFT JOIN series_latest sl ON sl.series_id = base.series_id AND sl.library_id = base.library_id`
: empty;
const sortAddedAt = usesSeriesLatest ? sql`coalesce(sl.latest_added_at, base.added_at)` : sql`base.added_at`;
const prefixCte = sql`
${source.prefixCte}
base_rows AS MATERIALIZED (
SELECT
${books.id} AS id,
${books.libraryId} AS library_id,
${books.addedAt} AS added_at,
${books.updatedAt} AS updated_at,
${bookMetadata.seriesId} AS series_id,
${bookMetadata.seriesIndex} AS series_index,
${bookMetadata.publishedDate} AS published_date,
${bookMetadata.publishedYear} AS published_year
${source.baseExtraSelect}
FROM ${books}
LEFT JOIN ${bookMetadata} ON ${bookMetadata.bookId} = ${books.id}
${source.join}
WHERE ${whereFragment}
),
${seriesLatestCte}
representatives AS (
SELECT DISTINCT ON (${sql.raw(COLLAPSE_GROUP_KEY_SQL)})
base.id,
base.updated_at,
base.published_date,
base.published_year,
${sortAddedAt} AS sort_added_at
${source.representativeExtraSelect}
FROM base_rows base
${seriesLatestJoin}
ORDER BY ${sql.raw(COLLAPSE_REPRESENTATIVE_PICK_SQL)}
),
`;
const result = await this.db.execute<TemporalJumpBucketRawRow>(
temporalBucketsQuery({
prefixCte,
temporalRows: sql`SELECT ${source.value} AS value FROM representatives r`,
precision: opts.precision,
direction: opts.direction,
maxBuckets: opts.maxBuckets,
}),
);
return this.mapTemporalJumpBucketRows(result.rows);
}
private mapDiscreteJumpBucketRows(rows: DiscreteJumpBucketRawRow[], kind: Exclude<JumpBucketKind, 'temporal'>): JumpBucketsResponse {
return {
buckets: rows.map((row) => ({
key: row.bucket,
label: row.bucket,
index: Number(row.item_index),
...(row.is_unknown ? { isUnknown: true } : {}),
})),
total: rows.length > 0 ? Number(rows[0].total) : 0,
kind,
granularity: null,
};
}
private mapTemporalJumpBucketRows(rows: TemporalJumpBucketRawRow[]): JumpBucketsResponse {
const first = rows[0];
return {
buckets: rows.map((row) => ({
key: row.bucket,
label: row.bucket,
index: Number(row.item_index),
...(row.is_unknown ? { isUnknown: true } : {}),
})),
total: first ? Number(first.total) : 0,
kind: 'temporal',
granularity: first ? { unit: first.unit, step: Number(first.step) } : null,
};
}
async findById(id: number) {
const [book] = await this.db
.select()
.from(books)
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.leftJoin(libraries, eq(libraries.id, books.libraryId))
.where(eq(books.id, id))
.limit(1);
if (!book) return null;
const [authorRows, genreRows, tagRows, fileRows, narratorRows, seriesMembershipRows, communityRatingRows] = await Promise.all([
this.db
.select({ id: authors.id, name: authors.name, sortName: authors.sortName })
.from(bookAuthors)
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
.where(eq(bookAuthors.bookId, id))
.orderBy(bookAuthors.displayOrder),
this.db.select({ name: genres.name }).from(bookGenres).innerJoin(genres, eq(genres.id, bookGenres.genreId)).where(eq(bookGenres.bookId, id)),
this.db.select({ name: tags.name }).from(bookTags).innerJoin(tags, eq(tags.id, bookTags.tagId)).where(eq(bookTags.bookId, id)),
this.db
.select({
id: bookFiles.id,
format: bookFiles.format,
role: bookFiles.role,
sizeBytes: bookFiles.sizeBytes,
absolutePath: bookFiles.absolutePath,
createdAt: bookFiles.createdAt,
durationSeconds: bookFiles.durationSeconds,
sortOrder: bookFiles.sortOrder,
mediaOverlayAvailable: bookFiles.mediaOverlayAvailable,
mediaOverlayDurationSeconds: bookFiles.mediaOverlayDurationSeconds,
mediaOverlayCheckedAt: bookFiles.mediaOverlayCheckedAt,
})
.from(bookFiles)
.where(eq(bookFiles.bookId, id))
.orderBy(asc(bookFiles.sortOrder), asc(bookFiles.id)),
this.db
.select({ id: narrators.id, name: narrators.name, sortName: narrators.sortName, displayOrder: bookNarrators.displayOrder })
.from(bookNarrators)
.innerJoin(narrators, eq(narrators.id, bookNarrators.narratorId))
.where(eq(bookNarrators.bookId, id))
.orderBy(bookNarrators.displayOrder),
this.db
.select({
seriesId: bookSeriesMemberships.seriesId,
seriesName: bookSeries.name,
seriesIndex: bookSeriesMemberships.seriesIndex,
displayOrder: bookSeriesMemberships.displayOrder,
expectedBookCount: bookSeries.expectedBookCount,
})
.from(bookSeriesMemberships)
.innerJoin(bookSeries, eq(bookSeries.id, bookSeriesMemberships.seriesId))
.where(eq(bookSeriesMemberships.bookId, id))
.orderBy(asc(bookSeriesMemberships.displayOrder), asc(bookSeriesMemberships.seriesId)),
this.db
.select({
provider: bookCommunityRatings.provider,
rating: bookCommunityRatings.rating,
ratingCount: bookCommunityRatings.ratingCount,
updatedAt: bookCommunityRatings.updatedAt,
})
.from(bookCommunityRatings)
.where(eq(bookCommunityRatings.bookId, id))
.orderBy(asc(bookCommunityRatings.provider)),
]);
return { book, authorRows, genreRows, tagRows, fileRows, narratorRows, seriesMembershipRows, communityRatingRows };
}
async findRatingByBookAndUser(bookId: number, userId: number): Promise<number | null> {
const [row] = await this.db
.select({ rating: userBookRatings.rating })
.from(userBookRatings)
.where(and(eq(userBookRatings.bookId, bookId), eq(userBookRatings.userId, userId)))
.limit(1);
return row?.rating ?? null;
}
async findCollectionsByBookId(bookId: number, userId: number): Promise<{ id: number; name: string }[]> {
return this.db
.select({ id: collections.id, name: collections.name })
.from(collectionBooks)
.innerJoin(collections, and(eq(collections.id, collectionBooks.collectionId), eq(collections.userId, userId)))
.where(eq(collectionBooks.bookId, bookId))
.orderBy(collections.name);
}
async findReadAloudSyncMode(userId: number, bookId: number): Promise<'auto' | 'disabled'> {
const [row] = await this.db
.select({ mode: userBookReadAloudSyncSettings.mode })
.from(userBookReadAloudSyncSettings)
.where(and(eq(userBookReadAloudSyncSettings.userId, userId), eq(userBookReadAloudSyncSettings.bookId, bookId)))
.limit(1);
return row?.mode ?? 'auto';
}
async upsertReadAloudSyncMode(userId: number, bookId: number, mode: 'auto' | 'disabled'): Promise<void> {
await this.db
.insert(userBookReadAloudSyncSettings)
.values({ userId, bookId, mode, updatedAt: new Date() })
.onConflictDoUpdate({
target: [userBookReadAloudSyncSettings.userId, userBookReadAloudSyncSettings.bookId],
set: { mode, updatedAt: new Date() },
});
}
async upsertKoreaderBookHashLink(userId: number, hash: string, bookFileId: number): Promise<void> {
const now = new Date();
await this.db
.insert(koreaderBookHashLinks)
.values({ userId, hash, bookFileId, updatedAt: now })
.onConflictDoUpdate({
target: [koreaderBookHashLinks.userId, koreaderBookHashLinks.hash],
set: { bookFileId, updatedAt: now },
});
}
async findLibraryIdByBookId(bookId: number): Promise<number | null> {
const [row] = await this.db.select({ libraryId: books.libraryId }).from(books).where(eq(books.id, bookId)).limit(1);
return row?.libraryId ?? null;
}
async checkBookPassesContentFilters(bookId: number, contentFilters: ContentFilterRules): Promise<boolean> {
const filterClauses = buildContentFilterClauses(contentFilters, this.db);
if (filterClauses.length === 0) return true;
const [row] = await this.db
.select({ id: books.id })
.from(books)
.where(and(eq(books.id, bookId), ...filterClauses))
.limit(1);
return !!row;
}
async findFileById(fileId: number) {
const [file] = await this.db
.select({
id: bookFiles.id,
absolutePath: bookFiles.absolutePath,
relPath: bookFiles.relPath,
format: bookFiles.format,
role: bookFiles.role,
bookId: bookFiles.bookId,
libraryId: books.libraryId,
libraryFolderPath: libraryFolders.path,
fileHash: bookFiles.fileHash,
sizeBytes: bookFiles.sizeBytes,
durationSeconds: bookFiles.durationSeconds,
mediaOverlayAvailable: bookFiles.mediaOverlayAvailable,
})
.from(bookFiles)
.innerJoin(books, eq(books.id, bookFiles.bookId))
.innerJoin(libraryFolders, eq(libraryFolders.id, bookFiles.libraryFolderId))
.where(eq(bookFiles.id, fileId))
.limit(1);
return file ?? null;
}
async deleteBookFile(fileId: number): Promise<void> {
await this.db.delete(bookFiles).where(eq(bookFiles.id, fileId));
}
async updateBookFile(
fileId: number,
data: {
format?: string | null;
role?: string;
absolutePath?: string;
relPath?: string | null;
sizeBytes?: number;
mediaOverlayAvailable?: boolean | null;
mediaOverlayDurationSeconds?: number | null;
mediaOverlayCheckedAt?: Date | null;
},
): Promise<void> {
await this.db
.update(bookFiles)
.set({ ...data, updatedAt: new Date() })
.where(eq(bookFiles.id, fileId));
}
async updateBookPrimaryFile(bookId: number, primaryFileId: number | null): Promise<void> {
await this.db.update(books).set({ primaryFileId, updatedAt: new Date() }).where(eq(books.id, bookId));
}
async findFilesForBook(bookId: number) {
return this.db
.select({
id: bookFiles.id,
role: bookFiles.role,
format: bookFiles.format,
sizeBytes: bookFiles.sizeBytes,
mediaOverlayAvailable: bookFiles.mediaOverlayAvailable,
})
.from(bookFiles)
.where(eq(bookFiles.bookId, bookId));
}
async findAudioEbookProgressSyncFiles(bookId: number) {
const [book] = await this.db.select({ primaryFileId: books.primaryFileId }).from(books).where(eq(books.id, bookId)).limit(1);
if (!book) return null;
const files = await this.db
.select({
id: bookFiles.id,
absolutePath: bookFiles.absolutePath,
format: bookFiles.format,
role: bookFiles.role,
sortOrder: bookFiles.sortOrder,
durationSeconds: bookFiles.durationSeconds,
mediaOverlayAvailable: bookFiles.mediaOverlayAvailable,
mediaOverlayDurationSeconds: bookFiles.mediaOverlayDurationSeconds,
})
.from(bookFiles)
.where(eq(bookFiles.bookId, bookId))
.orderBy(asc(bookFiles.sortOrder), asc(bookFiles.id));
return { primaryFileId: book.primaryFileId, files };
}
async findBookBase(bookId: number) {
const [row] = await this.db.select().from(books).where(eq(books.id, bookId)).limit(1);
return row ?? null;
}
async findProgress(userId: number, fileId: number) {
const [row] = await this.db
.select()
.from(readingProgress)
.where(and(eq(readingProgress.bookFileId, fileId), eq(readingProgress.userId, userId)))
.limit(1);
return row ?? null;
}
async findProgressByBook(userId: number, bookId: number) {
return this.db
.select({
fileId: bookFiles.id,
cfi: readingProgress.cfi,
pageNumber: readingProgress.pageNumber,
positionSeconds: readingProgress.positionSeconds,
mediaOverlayFragment: readingProgress.mediaOverlayFragment,
mediaOverlaySectionIndex: readingProgress.mediaOverlaySectionIndex,
percentage: readingProgress.percentage,
koboLocationSource: readingProgress.koboLocationSource,
koboLocationType: readingProgress.koboLocationType,
koboLocationValue: readingProgress.koboLocationValue,
koboContentSourceProgressPercent: readingProgress.koboContentSourceProgressPercent,
koreaderProgress: readingProgress.koreaderProgress,
updatedAt: readingProgress.updatedAt,
})
.from(bookFiles)
.leftJoin(readingProgress, and(eq(readingProgress.bookFileId, bookFiles.id), eq(readingProgress.userId, userId)))
.where(eq(bookFiles.bookId, bookId))
.orderBy(asc(bookFiles.sortOrder), asc(bookFiles.id));
}
async findProgressByBooks(userId: number, bookIds: number[]) {
if (bookIds.length === 0) return [];
return this.db
.select({
bookId: bookFiles.bookId,
fileId: bookFiles.id,
percentage: readingProgress.percentage,
koreaderProgress: readingProgress.koreaderProgress,
updatedAt: readingProgress.updatedAt,
})
.from(bookFiles)
.innerJoin(readingProgress, and(eq(readingProgress.bookFileId, bookFiles.id), eq(readingProgress.userId, userId)))
.where(inArray(bookFiles.bookId, bookIds));
}
async findAudioProgress(userId: number, bookId: number) {
const [row] = await this.db
.select()
.from(audiobookProgress)
.where(and(eq(audiobookProgress.userId, userId), eq(audiobookProgress.bookId, bookId)))
.limit(1);
return row ?? null;
}
async upsertAudioProgress(
userId: number,
bookId: number,
currentFileId: number,
positionSeconds: number,
percentage: number,
sourceUpdatedAt = new Date(),
) {
const now = new Date();
const [row] = await this.db
.insert(audiobookProgress)
.values({
userId,
bookId,
currentFileId,
positionSeconds,
percentage,
capturedAt: sourceUpdatedAt,
operationId: null,
manifestRevision: null,
updatedAt: now,
})
.onConflictDoUpdate({
target: [audiobookProgress.userId, audiobookProgress.bookId],
set: {
currentFileId,
positionSeconds,
percentage,
revision: sql`${audiobookProgress.revision} + 1`,
capturedAt: sourceUpdatedAt,
operationId: null,
manifestRevision: null,
updatedAt: now,
},
setWhere: lte(audiobookProgress.capturedAt, sourceUpdatedAt),
})
.returning();
return row;
}
async findKoboReadingState(userId: number, bookId: number) {
const [row] = await this.db
.select({
createdAtKobo: koboReadingStates.createdAtKobo,
lastModifiedKobo: koboReadingStates.lastModifiedKobo,
priorityTimestamp: koboReadingStates.priorityTimestamp,
currentBookmark: koboReadingStates.currentBookmark,
statistics: koboReadingStates.statistics,
statusInfo: koboReadingStates.statusInfo,
updatedAt: koboReadingStates.updatedAt,
})
.from(koboReadingStates)
.where(and(eq(koboReadingStates.userId, userId), eq(koboReadingStates.bookId, bookId)))
.limit(1);
return row ?? null;
}
async findKoboSnapshotStates(userId: number, bookId: number) {
return this.db
.select({
deviceId: koboDevices.id,
deviceName: koboDevices.name,
snapshotId: koboLibrarySnapshots.id,
snapshotUpdatedAt: koboLibrarySnapshots.updatedAt,
synced: koboSnapshotBooks.synced,
pendingDelete: koboSnapshotBooks.pendingDelete,
isNew: koboSnapshotBooks.isNew,
removedByDevice: koboSnapshotBooks.removedByDevice,
fileHash: koboSnapshotBooks.fileHash,
metadataHash: koboSnapshotBooks.metadataHash,
})
.from(koboLibrarySnapshots)
.innerJoin(koboDevices, and(eq(koboDevices.id, koboLibrarySnapshots.deviceId), eq(koboDevices.userId, userId)))
.leftJoin(koboSnapshotBooks, and(eq(koboSnapshotBooks.snapshotId, koboLibrarySnapshots.id), eq(koboSnapshotBooks.bookId, bookId)))
.where(eq(koboLibrarySnapshots.userId, userId))
.orderBy(asc(koboDevices.createdAt), asc(koboDevices.id));
}
async findKoboSyncCollectionNamesForBook(userId: number, bookId: number): Promise<string[]> {
const rows = await this.db
.select({ name: collections.name })
.from(collectionBooks)
.innerJoin(collections, and(eq(collections.id, collectionBooks.collectionId), eq(collections.userId, userId), eq(collections.syncToKobo, true)))
.where(eq(collectionBooks.bookId, bookId));
return rows.map((r) => r.name);
}
async searchAcrossLibraries(libraryIds: number[], q: string, limit: number, contentFilters?: ContentFilterRules) {
if (libraryIds.length === 0) return [];
const pattern = '%' + q + '%';
const matchedAuthors = this.db
.selectDistinct({ bookId: bookAuthors.bookId })
.from(bookAuthors)
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
.where(accentInsensitiveIlike(authors.name, pattern))
.as('matched_authors');
const matchedSeries = this.db
.selectDistinct({ bookId: bookSeriesMemberships.bookId })
.from(bookSeriesMemberships)
.innerJoin(bookSeries, eq(bookSeries.id, bookSeriesMemberships.seriesId))
.where(accentInsensitiveIlike(bookSeries.name, pattern))
.as('matched_series');
const contentFilterClauses = contentFilters ? buildContentFilterClauses(contentFilters, this.db) : [];
const rows = await this.db
.select({
id: books.id,
title: bookMetadata.title,
seriesId: bookMetadata.seriesId,
seriesName: bookMetadata.seriesName,
libraryId: books.libraryId,
libraryName: libraries.name,
updatedAt: books.updatedAt,
})
.from(books)
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.innerJoin(libraries, eq(libraries.id, books.libraryId))
.leftJoin(matchedAuthors, eq(matchedAuthors.bookId, books.id))
.leftJoin(matchedSeries, eq(matchedSeries.bookId, books.id))
.where(
and(
inArray(books.libraryId, libraryIds),
ne(books.status, 'processing'),
or(
accentInsensitiveIlike(bookMetadata.title, pattern),
accentInsensitiveIlike(bookMetadata.subtitle, pattern),
accentInsensitiveIlike(bookMetadata.seriesName, pattern),
isNotNull(matchedAuthors.bookId),
isNotNull(matchedSeries.bookId),
),
...contentFilterClauses,
),
)
.orderBy(bookMetadata.title)
.limit(limit);
const bookIds = rows.map((r) => r.id);
const authorRows =
bookIds.length > 0
? await 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)
: [];
const authorsByBook = new Map<number, string[]>();
for (const row of authorRows) {
const list = authorsByBook.get(row.bookId) ?? [];
list.push(row.name);
authorsByBook.set(row.bookId, list);
}
const formatRows =
bookIds.length > 0
? await this.db
.select({ bookId: bookFiles.bookId, format: bookFiles.format, mediaOverlayAvailable: bookFiles.mediaOverlayAvailable })
.from(bookFiles)
.where(and(inArray(bookFiles.bookId, bookIds), inArray(bookFiles.format, [...SUPPORTED_BOOK_FORMATS])))
: [];
const formatsByBook = new Map<number, string[]>();
const hasReadAlongByBook = new Map<number, boolean>();
for (const row of formatRows) {
if (row.format) {
const list = formatsByBook.get(row.bookId) ?? [];
if (!list.includes(row.format)) list.push(row.format);
formatsByBook.set(row.bookId, list);
}
if (row.mediaOverlayAvailable) hasReadAlongByBook.set(row.bookId, true);
}
return rows.map((r) => {
const hasReadAlong = hasReadAlongByBook.get(r.id) === true;
return {
id: r.id,
title: r.title,
seriesName: r.seriesName,
authors: authorsByBook.get(r.id) ?? [],
libraryId: r.libraryId,
libraryName: r.libraryName,
updatedAt: r.updatedAt?.toISOString() ?? null,
formats: formatsByBook.get(r.id) ?? [],
...(hasReadAlong ? { hasReadAlong } : {}),
};
});
}
async countWhere(where: SQL | undefined): Promise<number> {
const [{ total }] = await this.db
.select({ total: count() })
.from(books)
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(this.visibleWhere(where));
return Number(total);
}
async findLibraryIdsByBookIds(bookIds: number[]): Promise<{ id: number; libraryId: number }[]> {
if (bookIds.length === 0) return [];
return this.db.select({ id: books.id, libraryId: books.libraryId }).from(books).where(inArray(books.id, bookIds));
}
async findDeletionAuditBooksByIds(bookIds: number[]): Promise<{ id: number; title: string | null }[]> {
if (bookIds.length === 0) return [];
return this.db
.select({ id: books.id, title: bookMetadata.title })
.from(books)
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(inArray(books.id, bookIds));
}
async findRecommendationTitlesByBookIds(bookIds: number[]): Promise<UnscopedBookRecommendation[]> {
if (bookIds.length === 0) return [];
const [rows, authorRows] = await Promise.all([
this.db
.select({
id: books.id,
title: bookMetadata.title,
coverAspectRatio: libraries.coverAspectRatio,
updatedAt: books.updatedAt,
coverSource: bookMetadata.coverSource,
primaryFormat: bookFiles.format,
})
.from(books)
.innerJoin(libraries, eq(libraries.id, books.libraryId))
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.leftJoin(bookFiles, eq(bookFiles.id, books.primaryFileId))
.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)),
]);
const authorsByBook = new Map<number, string[]>();
for (const row of authorRows) {
const names = authorsByBook.get(row.bookId) ?? [];
names.push(row.name);
authorsByBook.set(row.bookId, names);
}
return rows.map((r) => ({
id: r.id,
title: r.title,
coverAspectRatio: normalizeCoverAspectRatio(r.coverAspectRatio),
updatedAt: r.updatedAt?.toISOString() ?? null,
hasCover: r.coverSource !== null,
authors: authorsByBook.get(r.id) ?? [],
isAudiobook: r.primaryFormat != null ? isAudioFormat(r.primaryFormat) : false,
isComic: r.primaryFormat != null ? isComicFormat(r.primaryFormat) : false,
}));
}
async findPatternMetadataByBookIds(bookIds: number[]): Promise<PatternMetadataRow[]> {
if (bookIds.length === 0) return [];
const [metaRows, authorRows, narratorRows] = await Promise.all([
this.db
.select({
bookId: books.id,
libraryName: libraries.name,
title: bookMetadata.title,
subtitle: bookMetadata.subtitle,
publisher: bookMetadata.publisher,
publishedDate: bookMetadata.publishedDate,
publishedYear: bookMetadata.publishedYear,
language: bookMetadata.language,
seriesId: bookMetadata.seriesId,
seriesName: bookMetadata.seriesName,
seriesIndex: bookMetadata.seriesIndex,
isbn13: bookMetadata.isbn13,
})
.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: bookNarrators.bookId, name: narrators.name })
.from(bookNarrators)
.innerJoin(narrators, eq(narrators.id, bookNarrators.narratorId))
.where(inArray(bookNarrators.bookId, bookIds))
.orderBy(bookNarrators.displayOrder),
]);
const groupByBookId = (rows: { bookId: number; name: string }[]): Map<number, string[]> => {
const byBookId = new Map<number, string[]>();
for (const row of rows) {
const list = byBookId.get(row.bookId) ?? [];
list.push(row.name);
byBookId.set(row.bookId, list);
}
return byBookId;
};
const authorsByBookId = groupByBookId(authorRows);
const narratorsByBookId = groupByBookId(narratorRows);
return metaRows.map((row) => ({
...row,
authors: authorsByBookId.get(row.bookId) ?? [],
narrators: narratorsByBookId.get(row.bookId) ?? [],
}));
}
async findAllIds(): Promise<number[]> {
const rows = await this.db.select({ id: books.id }).from(books);
return rows.map((r) => r.id);
}
async findIdsByWhere(where: SQL | undefined): Promise<number[]> {
const rows = await this.db
.select({ id: books.id })
.from(books)
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(this.visibleWhere(where));
return rows.map((r) => r.id);
}
async findPrimaryFile(bookId: number): Promise<{ absolutePath: string; format: string | null } | null> {
const [row] = await this.db
.select({ absolutePath: bookFiles.absolutePath, format: bookFiles.format })
.from(books)
.innerJoin(bookFiles, eq(bookFiles.id, books.primaryFileId))
.where(eq(books.id, bookId))
.limit(1);
return row ?? null;
}
async findPrimaryFilesByBookIds(
bookIds: number[],
): Promise<{ bookId: number; absolutePath: string; format: string | null; sizeBytes: number | null; mediaOverlayAvailable: boolean }[]> {
if (bookIds.length === 0) return [];
return this.db
.select({
bookId: books.id,
absolutePath: bookFiles.absolutePath,
format: bookFiles.format,
sizeBytes: bookFiles.sizeBytes,
mediaOverlayAvailable: bookFiles.mediaOverlayAvailable,
})
.from(books)
.innerJoin(bookFiles, eq(bookFiles.id, books.primaryFileId))
.where(inArray(books.id, bookIds))
.orderBy(asc(books.id));
}
async findPrimaryReaderFilesByBookIds(
bookIds: number[],
): Promise<{ id: number; bookId: number; absolutePath: string; format: string | null; fileHash: string | null; sizeBytes: number | null }[]> {
if (bookIds.length === 0) return [];
return this.db
.select({
id: bookFiles.id,
bookId: books.id,
absolutePath: bookFiles.absolutePath,
format: bookFiles.format,
fileHash: bookFiles.fileHash,
sizeBytes: bookFiles.sizeBytes,
})
.from(books)
.innerJoin(bookFiles, eq(bookFiles.id, books.primaryFileId))
.where(inArray(books.id, bookIds))
.orderBy(asc(books.id));
}
async findAllFilesByBookIds(bookIds: number[]): Promise<
{
bookId: number;
absolutePath: string;
format: string | null;
sizeBytes: number | null;
sortOrder: number;
mediaOverlayAvailable: boolean;
}[]
> {
if (bookIds.length === 0) return [];
return this.db
.select({
bookId: bookFiles.bookId,
absolutePath: bookFiles.absolutePath,
format: bookFiles.format,
sizeBytes: bookFiles.sizeBytes,
sortOrder: bookFiles.sortOrder,
mediaOverlayAvailable: bookFiles.mediaOverlayAvailable,
})
.from(bookFiles)
.where(inArray(bookFiles.bookId, bookIds))
.orderBy(asc(bookFiles.bookId), asc(bookFiles.sortOrder), asc(bookFiles.id));
}
async findCoverSourceFilesByBookIds(bookIds: number[]): Promise<
{
id: number;
bookId: number;
absolutePath: string;
format: string | null;
role: string;
sizeBytes: number | null;
mediaOverlayAvailable: boolean;
formatPriority: string[];
}[]
> {
if (bookIds.length === 0) return [];
return this.db
.select({
id: bookFiles.id,
bookId: bookFiles.bookId,
absolutePath: bookFiles.absolutePath,
format: bookFiles.format,
role: bookFiles.role,
sizeBytes: bookFiles.sizeBytes,
mediaOverlayAvailable: bookFiles.mediaOverlayAvailable,
formatPriority: libraries.formatPriority,
})
.from(bookFiles)
.innerJoin(books, eq(books.id, bookFiles.bookId))
.innerJoin(libraries, eq(libraries.id, books.libraryId))
.where(inArray(bookFiles.bookId, bookIds))
.orderBy(asc(bookFiles.bookId), asc(bookFiles.sortOrder), asc(bookFiles.id));
}
async deleteByIds(bookIds: number[]): Promise<void> {
await this.db.delete(books).where(inArray(books.id, bookIds));
}
async deleteByIdsAndInvalidateScanState(bookIds: number[]): Promise<void> {
const uniqueBookIds = [...new Set(bookIds)].sort((a, b) => a - b);
if (uniqueBookIds.length === 0) return;
await this.db.transaction(async (tx) => {
const rows = await tx
.select({ id: books.id, libraryFolderId: books.libraryFolderId, folderPath: books.folderPath })
.from(books)
.where(inArray(books.id, uniqueBookIds))
.orderBy(asc(books.id))
.for('update');
if (rows.length === 0) return;
const libraryFolderIds = [...new Set(rows.map((row) => row.libraryFolderId))].sort((a, b) => a - b);
await tx
.select({ id: libraryFolders.id })
.from(libraryFolders)
.where(inArray(libraryFolders.id, libraryFolderIds))
.orderBy(asc(libraryFolders.id))
.for('update');
await tx
.update(libraryFolders)
.set({ scanStateVersion: sql`${libraryFolders.scanStateVersion} + 1` })
.where(inArray(libraryFolders.id, libraryFolderIds));
const pathsByLibraryFolder = new Map<number, Set<string>>();
for (const row of rows) {
let paths = pathsByLibraryFolder.get(row.libraryFolderId);
if (!paths) {
paths = new Set<string>();
pathsByLibraryFolder.set(row.libraryFolderId, paths);
}
for (const path of scanStateInvalidationPaths(row.folderPath)) paths.add(path);
}
const chunkSize = 500;
for (const [libraryFolderId, paths] of pathsByLibraryFolder) {
const pathList = [...paths];
for (let offset = 0; offset < pathList.length; offset += chunkSize) {
await tx
.delete(schema.libraryDirScanState)
.where(
and(
eq(schema.libraryDirScanState.libraryFolderId, libraryFolderId),
inArray(schema.libraryDirScanState.dirPath, pathList.slice(offset, offset + chunkSize)),
),
);
}
}
await tx.delete(books).where(
inArray(
books.id,
rows.map((row) => row.id),
),
);
});
}
async bulkSetRating(bookIds: number[], rating: number | null, userId: number): Promise<void> {
if (bookIds.length === 0) return;
await this.db
.insert(userBookRatings)
.values(bookIds.map((bookId) => ({ userId, bookId, rating })))
.onConflictDoUpdate({
target: [userBookRatings.userId, userBookRatings.bookId],
set: { rating, updatedAt: new Date() },
});
}
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 findAuthorsByBookIds(bookIds: number[], executor: MetadataReadExecutor = this.db): Promise<Map<number, string[]>> {
if (bookIds.length === 0) return new Map();
const rows = await executor
.select({ bookId: bookAuthors.bookId, name: authors.name })
.from(bookAuthors)
.innerJoin(authors, eq(bookAuthors.authorId, authors.id))
.where(inArray(bookAuthors.bookId, bookIds))
.orderBy(asc(bookAuthors.bookId), asc(bookAuthors.displayOrder));
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 findGenresByBookIds(bookIds: number[], executor: MetadataReadExecutor = this.db): Promise<Map<number, string[]>> {
if (bookIds.length === 0) return new Map();
const rows = await executor
.select({ bookId: bookGenres.bookId, name: genres.name })
.from(bookGenres)
.innerJoin(genres, eq(bookGenres.genreId, genres.id))
.where(inArray(bookGenres.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 findNarratorsByBookIds(bookIds: number[], executor: MetadataReadExecutor = this.db): Promise<Map<number, string[]>> {
if (bookIds.length === 0) return new Map();
const rows = await executor
.select({ bookId: bookNarrators.bookId, name: narrators.name })
.from(bookNarrators)
.innerJoin(narrators, eq(bookNarrators.narratorId, narrators.id))
.where(inArray(bookNarrators.bookId, bookIds))
.orderBy(asc(bookNarrators.bookId), asc(bookNarrators.displayOrder));
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>,
executor: MetadataUpdateExecutor = this.db,
): Promise<void> {
const shouldSyncSeries =
Object.prototype.hasOwnProperty.call(fields, 'seriesName') || Object.prototype.hasOwnProperty.call(fields, 'seriesIndex');
const patch = (await this.seriesIdentity?.resolveMetadataPatch(fields, executor)) ?? fields;
await executor.update(bookMetadata).set(patch).where(eq(bookMetadata.bookId, bookId));
if (shouldSyncSeries) {
await this.seriesMemberships?.syncPrimaryFromMetadata(bookId, executor);
}
await executor.update(books).set({ updatedAt: new Date() }).where(eq(books.id, bookId));
}
// Only fills a missing shared edition id - never overwrites a value already set by someone
// else (via metadata edit/refresh) so a per-user sync pick can't silently change shared metadata.
async setHardcoverEditionIdIfEmpty(bookId: number, hardcoverEditionId: string): Promise<boolean> {
const [row] = await this.db
.update(bookMetadata)
.set({ hardcoverEditionId, updatedAt: new Date() })
.where(and(eq(bookMetadata.bookId, bookId), isNull(bookMetadata.hardcoverEditionId)))
.returning({ bookId: bookMetadata.bookId });
return row != null;
}
async updateAddedAt(bookId: number, addedAt: Date): Promise<void> {
await this.db.update(books).set({ addedAt, updatedAt: new Date() }).where(eq(books.id, bookId));
}
async replaceCommunityRatings(
bookId: number,
ratings: Array<{ provider: string; rating: number; ratingCount: number | null }>,
executor: MetadataUpdateExecutor = this.db,
): Promise<void> {
const now = new Date();
await executor.delete(bookCommunityRatings).where(eq(bookCommunityRatings.bookId, bookId));
if (ratings.length > 0) {
await executor.insert(bookCommunityRatings).values(
ratings.map((rating) => ({
bookId,
provider: rating.provider,
rating: rating.rating,
ratingCount: rating.ratingCount,
updatedAt: now,
})),
);
}
await executor.update(books).set({ updatedAt: now }).where(eq(books.id, bookId));
}
async bulkUpdateMetadataFields(
bookIds: number[],
fields: Partial<typeof bookMetadata.$inferInsert>,
executor: MetadataUpdateExecutor = this.db,
): Promise<void> {
if (bookIds.length === 0) return;
const shouldSyncSeries =
Object.prototype.hasOwnProperty.call(fields, 'seriesName') || Object.prototype.hasOwnProperty.call(fields, 'seriesIndex');
const patch = (await this.seriesIdentity?.resolveMetadataPatch(fields, executor)) ?? fields;
await executor.update(bookMetadata).set(patch).where(inArray(bookMetadata.bookId, bookIds));
if (shouldSyncSeries) {
await this.seriesMemberships?.syncPrimaryFromMetadataForBooks(bookIds, executor);
}
await executor.update(books).set({ updatedAt: new Date() }).where(inArray(books.id, bookIds));
}
async upsertProgress(
userId: number,
fileId: number,
cfi: string | null,
pageNumber: number | null,
percentage: number,
positionSeconds?: number | null,
mediaOverlayFragment?: string | null,
mediaOverlaySectionIndex?: number | null,
koboLocationSource?: string | null,
koboLocationType?: string | null,
koboLocationValue?: string | null,
koboContentSourceProgressPercent?: number | null,
koreaderProgress?: string | null,
/**
* The narration position, when this write moved it. Kept apart from `percentage` and `cfi`,
* which describe where the reader's eyes were: the two positions drift, and folding them
* together let a read-along resume erase a page the reader had reached.
*/
narration?: { percentage: number; updatedAt: Date } | null,
/** Set when this write moved the text position, so clients can tell which one is fresher. */
textUpdatedAt?: Date | null,
) {
const now = new Date();
const normalizedKoboLocationSource = this.normalizeKoboLocationPart(koboLocationSource);
const normalizedKoboLocationType = this.normalizeKoboLocationPart(koboLocationType);
const normalizedKoboLocationValue = this.normalizeKoboLocationPart(koboLocationValue);
const normalizedKoboContentSourceProgressPercent = this.clampNullableProgressPercentage(koboContentSourceProgressPercent);
const normalizedKoreaderProgress = this.normalizeKoreaderProgress(koreaderProgress);
const narrationColumns = narration
? { narrationPercentage: this.clampProgressPercentage(narration.percentage), narrationUpdatedAt: narration.updatedAt }
: {};
const textColumns = textUpdatedAt ? { textUpdatedAt } : {};
await this.db
.insert(readingProgress)
.values({
userId,
bookFileId: fileId,
cfi,
pageNumber,
percentage,
positionSeconds: positionSeconds ?? null,
mediaOverlayFragment: mediaOverlayFragment ?? null,
mediaOverlaySectionIndex: mediaOverlaySectionIndex ?? null,
koboLocationSource: normalizedKoboLocationSource,
koboLocationType: normalizedKoboLocationType,
koboLocationValue: normalizedKoboLocationValue,
koboContentSourceProgressPercent: normalizedKoboContentSourceProgressPercent,
koreaderProgress: normalizedKoreaderProgress,
updatedAt: now,
...narrationColumns,
...textColumns,
})
.onConflictDoUpdate({
target: [readingProgress.bookFileId, readingProgress.userId],
set: {
cfi,
pageNumber,
percentage,
positionSeconds: positionSeconds ?? null,
mediaOverlayFragment: mediaOverlayFragment ?? null,
mediaOverlaySectionIndex: mediaOverlaySectionIndex ?? null,
koboLocationSource: normalizedKoboLocationSource,
koboLocationType: normalizedKoboLocationType,
koboLocationValue: normalizedKoboLocationValue,
koboContentSourceProgressPercent: normalizedKoboContentSourceProgressPercent,
koreaderProgress: normalizedKoreaderProgress,
updatedAt: now,
// Absent halves keep whatever is stored: a text write must not blank the narration
// position, and a narration write must not blank the text one.
...narrationColumns,
...textColumns,
},
});
// Reading in BookOrbit is fresher intent than the reset that came before it, and it is
// the way out for a device that never pulls and would otherwise have every push held
// back indefinitely.
await this.db.delete(koreaderProgressResets).where(and(eq(koreaderProgressResets.userId, userId), eq(koreaderProgressResets.bookFileId, fileId)));
}
async upsertSyncedEpubProgressIfNewer(params: {
userId: number;
fileId: number;
cfi: string;
percentage: number;
positionSeconds: number | null;
mediaOverlayFragment: string | null;
mediaOverlaySectionIndex: number | null;
koreaderProgress: string | null;
sourceUpdatedAt: Date;
}): Promise<boolean> {
const rows = await this.db
.insert(readingProgress)
.values({
userId: params.userId,
bookFileId: params.fileId,
cfi: params.cfi,
pageNumber: null,
percentage: params.percentage,
positionSeconds: params.positionSeconds,
mediaOverlayFragment: params.mediaOverlayFragment,
mediaOverlaySectionIndex: params.mediaOverlaySectionIndex,
koreaderProgress: this.normalizeKoreaderProgress(params.koreaderProgress),
updatedAt: params.sourceUpdatedAt,
lastReadAt: params.sourceUpdatedAt,
textUpdatedAt: params.sourceUpdatedAt,
})
.onConflictDoUpdate({
target: [readingProgress.bookFileId, readingProgress.userId],
set: {
cfi: params.cfi,
pageNumber: null,
percentage: params.percentage,
positionSeconds: params.positionSeconds,
mediaOverlayFragment: params.mediaOverlayFragment,
mediaOverlaySectionIndex: params.mediaOverlaySectionIndex,
koreaderProgress: this.normalizeKoreaderProgress(params.koreaderProgress),
updatedAt: params.sourceUpdatedAt,
lastReadAt: params.sourceUpdatedAt,
textUpdatedAt: params.sourceUpdatedAt,
},
setWhere: lte(readingProgress.updatedAt, params.sourceUpdatedAt),
})
.returning({ fileId: readingProgress.bookFileId });
if (rows.length === 0) return false;
await this.db
.delete(koreaderProgressResets)
.where(and(eq(koreaderProgressResets.userId, params.userId), eq(koreaderProgressResets.bookFileId, params.fileId)));
return true;
}
async syncKoboReadingStateFromProgress(
userId: number,
fileId: number,
percentage: number,
koboLocationSource?: string | null,
koboLocationType?: string | null,
koboLocationValue?: string | null,
koboContentSourceProgressPercent?: number | null,
): Promise<boolean> {
const [file] = await this.db
.select({
bookId: bookFiles.bookId,
primaryFileId: books.primaryFileId,
format: bookFiles.format,
markAsFinishedPercentComplete: libraries.markAsFinishedPercentComplete,
})
.from(bookFiles)
.innerJoin(books, eq(books.id, bookFiles.bookId))
.innerJoin(libraries, eq(libraries.id, books.libraryId))
.where(eq(bookFiles.id, fileId))
.limit(1);
if (!file || file.primaryFileId !== fileId || file.format !== 'epub') return false;
const clampedPercentage = this.clampProgressPercentage(percentage);
const normalizedKoboLocationSource = this.normalizeKoboLocationPart(koboLocationSource);
const normalizedKoboLocationType = this.normalizeKoboLocationPart(koboLocationType);
const normalizedKoboLocationValue = this.normalizeKoboLocationPart(koboLocationValue);
const normalizedKoboContentSourceProgressPercent = this.clampNullableProgressPercentage(koboContentSourceProgressPercent);
// Location values are optional: without them the bookmark advances percent-only and the
// precise KoboSpan Location is computed server-side at delivery time.
const hasLocation = Boolean(normalizedKoboLocationSource && normalizedKoboLocationType === 'KoboSpan' && normalizedKoboLocationValue);
const now = new Date();
const [existing] = await this.db
.select({
entitlementId: koboReadingStates.entitlementId,
createdAtKobo: koboReadingStates.createdAtKobo,
lastModifiedKobo: koboReadingStates.lastModifiedKobo,
priorityTimestamp: koboReadingStates.priorityTimestamp,
currentBookmark: koboReadingStates.currentBookmark,
statistics: koboReadingStates.statistics,
statusInfo: koboReadingStates.statusInfo,
})
.from(koboReadingStates)
.where(and(eq(koboReadingStates.userId, userId), eq(koboReadingStates.bookId, file.bookId)))
.limit(1);
const existingBookmark = this.asJsonObj(existing?.currentBookmark);
const existingStatusInfo = this.asJsonObj(existing?.statusInfo);
const nowIso = advanceIsoTimestamp(
now,
existing?.lastModifiedKobo,
existing?.priorityTimestamp,
typeof existingBookmark?.LastModified === 'string' ? existingBookmark.LastModified : null,
typeof existingStatusInfo?.LastModified === 'string' ? existingStatusInfo.LastModified : null,
);
if (
hasLocation &&
this.isKoboBookmarkCurrent(
existingBookmark,
clampedPercentage,
normalizedKoboLocationSource,
normalizedKoboLocationType,
normalizedKoboLocationValue,
normalizedKoboContentSourceProgressPercent,
)
) {
return true;
}
if (!hasLocation && this.isKoboBookmarkPercentCurrent(existingBookmark, clampedPercentage)) {
return true;
}
const currentBookmark: JsonObj = {
...(existingBookmark ?? {}),
LastModified: nowIso,
ProgressPercent: clampedPercentage,
};
if (hasLocation) {
currentBookmark.Location = {
Source: normalizedKoboLocationSource,
Type: normalizedKoboLocationType,
Value: normalizedKoboLocationValue,
};
if (normalizedKoboContentSourceProgressPercent !== null) {
currentBookmark.ContentSourceProgressPercent = normalizedKoboContentSourceProgressPercent;
} else {
delete currentBookmark.ContentSourceProgressPercent;
}
} else {
// The hub position moved and no KoboSpan describes it. Keeping the previous Location
// would ship a bookmark whose position contradicts its own percent, and the device
// resumes from Location: it opens at the stale spot and pushes that percent back,
// undoing the hub progress. Percent-only is the honest degradation; the reading-state
// pull path fills the Location back in whenever it can convert the cfi.
delete currentBookmark.Location;
delete currentBookmark.ContentSourceProgressPercent;
}
const statusInfo = {
...(existingStatusInfo ?? {}),
LastModified: nowIso,
Status: this.deriveKoboStatus(clampedPercentage, file.markAsFinishedPercentComplete),
};
const statistics = this.asJsonObj(existing?.statistics) ?? { LastModified: nowIso };
await this.db
.insert(koboReadingStates)
.values({
userId,
bookId: file.bookId,
entitlementId: existing?.entitlementId ?? String(file.bookId),
createdAtKobo: existing?.createdAtKobo ?? nowIso,
lastModifiedKobo: nowIso,
priorityTimestamp: nowIso,
currentBookmark,
statistics,
statusInfo,
updatedAt: now,
})
.onConflictDoUpdate({
target: [koboReadingStates.userId, koboReadingStates.bookId],
set: {
lastModifiedKobo: nowIso,
priorityTimestamp: nowIso,
currentBookmark,
statistics,
statusInfo,
updatedAt: now,
},
});
await this.markKoboSnapshotBookUnsyncedForReadingState(userId, file.bookId);
return true;
}
async isKoboTwoWayProgressSyncEnabled(userId: number): Promise<boolean> {
const [settings] = await this.db
.select({ twoWayProgressSync: koboSyncSettings.twoWayProgressSync })
.from(koboSyncSettings)
.where(eq(koboSyncSettings.userId, userId))
.limit(1);
return settings?.twoWayProgressSync === true;
}
async clearFileProgress(userId: number, fileId: number): Promise<void> {
const [file] = await this.db
.select({ bookId: bookFiles.bookId, primaryFileId: books.primaryFileId })
.from(bookFiles)
.innerJoin(books, eq(books.id, bookFiles.bookId))
.where(eq(bookFiles.id, fileId))
.limit(1);
await this.db.transaction(async (tx) => {
await tx.delete(readingProgress).where(and(eq(readingProgress.userId, userId), eq(readingProgress.bookFileId, fileId)));
await tx.delete(audiobookProgress).where(and(eq(audiobookProgress.userId, userId), eq(audiobookProgress.currentFileId, fileId)));
await this.clearExternalDeviceProgress(tx, userId, [fileId]);
// Kobo tracks the book through its primary file, so clearing a secondary file leaves
// the device's bookmark alone.
if (file && file.primaryFileId === fileId) await this.resetKoboReadingState(tx, userId, file.bookId);
});
}
async clearBookProgress(userId: number, bookId: number): Promise<void> {
await this.db.transaction(async (tx) => {
const files = await tx.select({ id: bookFiles.id }).from(bookFiles).where(eq(bookFiles.bookId, bookId));
const fileIds = files.map((file) => file.id);
await tx.delete(readingProgress).where(and(eq(readingProgress.userId, userId), inArray(readingProgress.bookFileId, fileIds)));
await tx.delete(audiobookProgress).where(and(eq(audiobookProgress.userId, userId), eq(audiobookProgress.bookId, bookId)));
await this.clearExternalDeviceProgress(tx, userId, fileIds);
await this.resetKoboReadingState(tx, userId, bookId);
});
}
/**
* Drops the KOReader per-device positions for these files and records the reset, so the
* cleared position survives contact with a device. Deleting the shared row alone is not
* enough: the sync path would keep serving the device row it left behind, and a device
* that has not been told about the reset would push its own position straight back.
*
* Reading statistics are deliberately untouched. Clearing a position says nothing about
* the time already spent in the book; only the full reading-state reset discards that.
*/
private async clearExternalDeviceProgress(tx: BookRepositoryTx, userId: number, fileIds: number[]): Promise<void> {
if (fileIds.length === 0) return;
await tx
.delete(koreaderDeviceProgress)
.where(
and(
eq(koreaderDeviceProgress.userId, userId),
inArray(koreaderDeviceProgress.bookFileId, fileIds),
eq(koreaderDeviceProgress.orphaned, false),
),
);
// Deleted rather than upserted so a repeat reset cascades away the per-device convergence
// rows: every device has to take the new reset, including ones that took the last one.
await tx
.delete(koreaderProgressResets)
.where(and(eq(koreaderProgressResets.userId, userId), inArray(koreaderProgressResets.bookFileId, fileIds)));
await tx.insert(koreaderProgressResets).values(fileIds.map((bookFileId) => ({ userId, bookFileId })));
}
/**
* Winds the Kobo bookmark back to the start of the book, matching what the full reading
* state reset does. Without this a Kobo device resumes from its own bookmark and reports
* that position back, undoing the reset the same way KOReader does.
*/
private async resetKoboReadingState(tx: BookRepositoryTx, userId: number, bookId: number): Promise<void> {
const [existing] = await tx
.select({
lastModifiedKobo: koboReadingStates.lastModifiedKobo,
priorityTimestamp: koboReadingStates.priorityTimestamp,
currentBookmark: koboReadingStates.currentBookmark,
statistics: koboReadingStates.statistics,
statusInfo: koboReadingStates.statusInfo,
})
.from(koboReadingStates)
.where(and(eq(koboReadingStates.userId, userId), eq(koboReadingStates.bookId, bookId)))
.limit(1);
if (!existing) return;
const now = new Date();
const existingBookmark = this.asJsonObj(existing.currentBookmark);
const existingStatusInfo = this.asJsonObj(existing.statusInfo);
const nowIso = advanceIsoTimestamp(
now,
existing.lastModifiedKobo,
existing.priorityTimestamp,
typeof existingBookmark?.LastModified === 'string' ? existingBookmark.LastModified : null,
typeof existingStatusInfo?.LastModified === 'string' ? existingStatusInfo.LastModified : null,
);
await tx
.update(koboReadingStates)
.set({
lastModifiedKobo: nowIso,
priorityTimestamp: nowIso,
currentBookmark: { LastModified: nowIso, ProgressPercent: 0 },
statistics: { ...(this.asJsonObj(existing.statistics) ?? {}), LastModified: nowIso },
statusInfo: { ...(existingStatusInfo ?? {}), LastModified: nowIso, Status: 'ReadyToRead', TimesStartedReading: 0 },
updatedAt: now,
})
.where(and(eq(koboReadingStates.userId, userId), eq(koboReadingStates.bookId, bookId)));
await tx.execute(sql`
UPDATE ${koboSnapshotBooks} AS sb
SET synced = false,
is_new = false
FROM ${koboLibrarySnapshots} AS snap
WHERE snap.id = sb.snapshot_id
AND snap.user_id = ${userId}
AND sb.book_id = ${bookId}
AND sb.pending_delete = false
AND sb.removed_by_device = false
`);
}
private clampProgressPercentage(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(100, value));
}
private clampNullableProgressPercentage(value: number | null | undefined): number | null {
return typeof value === 'number' && Number.isFinite(value) ? this.clampProgressPercentage(value) : null;
}
private normalizeKoboLocationPart(value: string | null | undefined): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
private normalizeKoreaderProgress(value: string | null | undefined): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.startsWith('/body/DocFragment[') ? trimmed : null;
}
private asJsonObj(value: unknown): JsonObj | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
return value as JsonObj;
}
private extractKoboPercent(bookmark: JsonObj | null): number | null {
const candidate = bookmark?.ProgressPercent;
return typeof candidate === 'number' ? this.clampProgressPercentage(candidate) : null;
}
private extractKoboContentSourceProgressPercent(bookmark: JsonObj | null): number | null {
const candidate = bookmark?.ContentSourceProgressPercent;
return typeof candidate === 'number' ? this.clampProgressPercentage(candidate) : null;
}
private isKoboBookmarkCurrent(
bookmark: JsonObj | null,
percentage: number,
koboLocationSource: string | null,
koboLocationType: string | null,
koboLocationValue: string | null,
koboContentSourceProgressPercent: number | null,
): boolean {
const existingPercent = this.extractKoboPercent(bookmark);
if (existingPercent === null || Math.abs(existingPercent - percentage) >= PROGRESS_EPSILON) return false;
if (!koboLocationSource || !koboLocationType || !koboLocationValue) return !this.hasNonInternalBookmarkFields(bookmark);
const location = this.asJsonObj(bookmark?.Location);
if (location?.Source !== koboLocationSource || location.Type !== koboLocationType || location.Value !== koboLocationValue) return false;
if (koboContentSourceProgressPercent === null) {
return this.extractKoboContentSourceProgressPercent(bookmark) === null;
}
const existingSourcePercent = this.extractKoboContentSourceProgressPercent(bookmark);
return existingSourcePercent !== null && Math.abs(existingSourcePercent - koboContentSourceProgressPercent) < PROGRESS_EPSILON;
}
private hasNonInternalBookmarkFields(bookmark: JsonObj | null): boolean {
if (!bookmark) return false;
return Object.keys(bookmark).some((key) => key !== 'LastModified' && key !== 'ProgressPercent');
}
private isKoboBookmarkPercentCurrent(bookmark: JsonObj | null, percentage: number): boolean {
const existingPercent = this.extractKoboPercent(bookmark);
return existingPercent !== null && Math.abs(existingPercent - percentage) < PROGRESS_EPSILON;
}
/**
* Uses the library's finished threshold so the device agrees with the read status
* BookOrbit derives from the same percentage; requiring 100 left books BookOrbit
* calls read showing as Reading on Kobo.
*/
private deriveKoboStatus(percentage: number, markAsFinishedPercentComplete: number): string {
const threshold = Number.isFinite(markAsFinishedPercentComplete) ? Math.min(100, Math.max(1, markAsFinishedPercentComplete)) : 100;
if (hasReachedProgressThreshold(percentage, threshold)) return 'Finished';
return percentage > 0 ? 'Reading' : 'ReadyToRead';
}
private async markKoboSnapshotBookUnsyncedForReadingState(userId: number, bookId: number): Promise<void> {
await this.db.execute(sql`
UPDATE ${koboSnapshotBooks} AS sb
SET synced = false,
is_new = false
FROM ${koboLibrarySnapshots} AS snap
WHERE snap.id = sb.snapshot_id
AND snap.user_id = ${userId}
AND sb.book_id = ${bookId}
AND sb.synced = true
AND sb.pending_delete = false
AND sb.removed_by_device = false
`);
}
}