fix(metadata): sync audiobook progress to Hardcover

Use audiobook progress and edition duration to send progress_seconds so Hardcover no longer leaves listened books at zero percent.

Fixes #1074
This commit is contained in:
neon
2026-08-24 23:57:43 +00:00
parent 3de365a0c9
commit 9989ba23e5
6 changed files with 211 additions and 37 deletions
@@ -47,7 +47,13 @@ describe('HardcoverBookMatchService', () => {
books: [{ id: 100, editions: [{ id: 200, pages: 320 }] }],
});
const result = await makeService().matchBook(1, 'tok', baseBook);
expect(result).toEqual({ hardcoverBookId: 100, hardcoverEditionId: 200, editionPages: 320, matchMethod: 'cached' });
expect(result).toEqual({
hardcoverBookId: 100,
hardcoverEditionId: 200,
editionPages: 320,
editionAudioSeconds: null,
matchMethod: 'cached',
});
expect(mockClient.query).toHaveBeenCalledTimes(1);
expect(mockRepo.upsertBookState).not.toHaveBeenCalled();
});
@@ -72,10 +78,35 @@ describe('HardcoverBookMatchService', () => {
const result = await makeService().matchBook(1, 'tok', baseBook);
expect(result).toEqual({ hardcoverBookId: 100, hardcoverEditionId: 200, editionPages: null, matchMethod: 'cached' });
expect(result).toEqual({
hardcoverBookId: 100,
hardcoverEditionId: 200,
editionPages: null,
editionAudioSeconds: null,
matchMethod: 'cached',
});
expect(mockRepo.upsertBookState).not.toHaveBeenCalled();
});
it('returns the cached audiobook edition duration for progress sync', async () => {
mockRepo.findBookState.mockResolvedValue({
hardcoverBookId: 100,
hardcoverEditionId: 200,
matchError: null,
});
mockClient.query.mockResolvedValue({
books: [{ id: 100, editions: [{ id: 200, pages: null, audio_seconds: 3600 }] }],
});
await expect(makeService().matchBook(1, 'tok', { ...baseBook, format: 'm4b' })).resolves.toEqual({
hardcoverBookId: 100,
hardcoverEditionId: 200,
editionPages: null,
editionAudioSeconds: 3600,
matchMethod: 'cached',
});
});
it('re-points a cached match when the cached edition no longer exists on Hardcover', async () => {
mockRepo.findBookState.mockResolvedValue({
hardcoverBookId: 100,
@@ -118,7 +149,13 @@ describe('HardcoverBookMatchService', () => {
const result = await makeService().matchBook(1, 'tok', book);
expect(result).toEqual({ hardcoverBookId: 686104, hardcoverEditionId: 30673405, editionPages: 382, matchMethod: 'metadata_id' });
expect(result).toEqual({
hardcoverBookId: 686104,
hardcoverEditionId: 30673405,
editionPages: 382,
editionAudioSeconds: null,
matchMethod: 'metadata_id',
});
expect(mockClient.query).toHaveBeenCalledWith(
1,
'tok',
@@ -168,7 +205,13 @@ describe('HardcoverBookMatchService', () => {
const result = await makeService().matchBook(1, 'tok', baseBook);
expect(result).toEqual({ hardcoverBookId: 123, hardcoverEditionId: 30, editionPages: null, matchMethod: 'title' });
expect(result).toEqual({
hardcoverBookId: 123,
hardcoverEditionId: 30,
editionPages: null,
editionAudioSeconds: null,
matchMethod: 'title',
});
});
it('returns null and stores no_match when all strategies fail', async () => {
@@ -222,7 +265,13 @@ describe('HardcoverBookMatchService', () => {
const book = { ...baseBook, hardcoverMetadataId: '700', isbn13: '9781234567890', isbn10: null, pageCount: 512, format: 'epub' };
const result = await makeService().matchBook(1, 'tok', book);
expect(result).toEqual({ hardcoverBookId: 700, hardcoverEditionId: 901, editionPages: 512, matchMethod: 'metadata_id' });
expect(result).toEqual({
hardcoverBookId: 700,
hardcoverEditionId: 901,
editionPages: 512,
editionAudioSeconds: null,
matchMethod: 'metadata_id',
});
});
it('selects the closest page-count, non-audiobook edition on a title match', async () => {
@@ -243,7 +292,13 @@ describe('HardcoverBookMatchService', () => {
const book = { ...baseBook, isbn13: null, isbn10: null, pageCount: 400, format: 'epub' };
const result = await makeService().matchBook(1, 'tok', book);
expect(result).toEqual({ hardcoverBookId: 123, hardcoverEditionId: 801, editionPages: 405, matchMethod: 'title' });
expect(result).toEqual({
hardcoverBookId: 123,
hardcoverEditionId: 801,
editionPages: 405,
editionAudioSeconds: null,
matchMethod: 'title',
});
});
describe('listEditions', () => {
@@ -11,6 +11,7 @@ export interface HardcoverBookMatch {
hardcoverBookId: number;
hardcoverEditionId: number | null;
editionPages: number | null;
editionAudioSeconds: number | null;
matchMethod: 'isbn' | 'title' | 'cached' | 'metadata_id';
}
@@ -21,6 +22,7 @@ query FindBookByISBN13($isbn: String!) {
editions(where: { isbn_13: { _eq: $isbn } }, limit: 1) {
id
pages
audio_seconds
}
}
}`;
@@ -32,6 +34,7 @@ query FindBookByISBN10($isbn: String!) {
editions(where: { isbn_10: { _eq: $isbn } }, limit: 1) {
id
pages
audio_seconds
}
}
}`;
@@ -230,6 +233,7 @@ export class HardcoverBookMatchService {
hardcoverBookId: cached.hardcoverBookId,
hardcoverEditionId: cachedMatch.hardcoverEditionId,
editionPages: cachedMatch.editionPages,
editionAudioSeconds: cachedMatch.editionAudioSeconds,
matchMethod: 'cached',
};
}
@@ -288,6 +292,7 @@ export class HardcoverBookMatchService {
hardcoverBookId: hardcoverBook.id,
hardcoverEditionId: edition?.id ?? null,
editionPages: this.normalizeEditionPages(edition?.pages),
editionAudioSeconds: this.normalizeEditionAudioSeconds(edition?.audio_seconds),
matchMethod: 'metadata_id',
};
} catch (err) {
@@ -309,6 +314,7 @@ export class HardcoverBookMatchService {
hardcoverBookId: hardcoverBook.id,
hardcoverEditionId: edition?.id ?? null,
editionPages: this.normalizeEditionPages(edition?.pages),
editionAudioSeconds: this.normalizeEditionAudioSeconds(edition?.audio_seconds),
matchMethod: 'metadata_id',
};
} catch (err) {
@@ -331,6 +337,7 @@ export class HardcoverBookMatchService {
hardcoverBookId: hardcoverBook.id,
hardcoverEditionId: edition?.id ?? null,
editionPages: this.normalizeEditionPages(edition?.pages),
editionAudioSeconds: this.normalizeEditionAudioSeconds(edition?.audio_seconds),
matchMethod: 'isbn',
};
} catch (err) {
@@ -363,6 +370,7 @@ export class HardcoverBookMatchService {
hardcoverBookId: hardcoverBook.id,
hardcoverEditionId: edition?.id ?? null,
editionPages: this.normalizeEditionPages(edition?.pages),
editionAudioSeconds: this.normalizeEditionAudioSeconds(edition?.audio_seconds),
matchMethod: 'title',
};
} catch (err) {
@@ -428,37 +436,45 @@ export class HardcoverBookMatchService {
book: BookSyncData,
hardcoverBookId: number,
cachedEditionId: number | null,
): Promise<{ hardcoverEditionId: number | null; editionPages: number | null }> {
): Promise<{ hardcoverEditionId: number | null; editionPages: number | null; editionAudioSeconds: number | null }> {
try {
const data = await this.client.query<BooksQueryResult>(userId, token, FIND_BOOK_EDITIONS_BY_HARDCOVER_ID_QUERY, {
id: hardcoverBookId,
});
const hardcoverBook = data.books?.[0];
if (!hardcoverBook) {
return { hardcoverEditionId: cachedEditionId, editionPages: null };
return { hardcoverEditionId: cachedEditionId, editionPages: null, editionAudioSeconds: null };
}
const editions = hardcoverBook.editions ?? [];
// A missing page count is not a reason to silently re-point the user's edition.
// A missing progress metric is not a reason to silently re-point the user's edition.
if (cachedEditionId != null) {
const cachedEdition = editions.find((edition) => edition.id === cachedEditionId);
if (cachedEdition) {
return { hardcoverEditionId: cachedEditionId, editionPages: this.normalizeEditionPages(cachedEdition.pages) };
return {
hardcoverEditionId: cachedEditionId,
editionPages: this.normalizeEditionPages(cachedEdition.pages),
editionAudioSeconds: this.normalizeEditionAudioSeconds(cachedEdition.audio_seconds),
};
}
}
const edition = this.pickBestEdition(editions, book);
if (!edition) {
return { hardcoverEditionId: cachedEditionId, editionPages: null };
return { hardcoverEditionId: cachedEditionId, editionPages: null, editionAudioSeconds: null };
}
return { hardcoverEditionId: edition.id, editionPages: this.normalizeEditionPages(edition.pages) };
return {
hardcoverEditionId: edition.id,
editionPages: this.normalizeEditionPages(edition.pages),
editionAudioSeconds: this.normalizeEditionAudioSeconds(edition.audio_seconds),
};
} catch (err) {
const error = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[hardcover.book_match] [fail] userId=${userId} bookId=${book.bookId} method=cached_pages error="${error}" - cached edition pages lookup failed`,
`[hardcover.book_match] [fail] userId=${userId} bookId=${book.bookId} method=cached_edition error="${error}" - cached edition metrics lookup failed`,
);
return { hardcoverEditionId: cachedEditionId, editionPages: null };
return { hardcoverEditionId: cachedEditionId, editionPages: null, editionAudioSeconds: null };
}
}
@@ -525,4 +541,9 @@ export class HardcoverBookMatchService {
if (typeof pages !== 'number' || !Number.isFinite(pages) || pages <= 0) return null;
return Math.round(pages);
}
private normalizeEditionAudioSeconds(seconds: number | null | undefined): number | null {
if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds <= 0) return null;
return Math.round(seconds);
}
}
@@ -148,6 +148,62 @@ describe('HardcoverSyncService', () => {
expect(mockRepo.upsertBookState).toHaveBeenCalledWith(expect.objectContaining({ hardcoverUserBookId: 55, hardcoverReadId: 77 }));
});
it('syncs audiobook progress in seconds using the matched edition duration', async () => {
mockSettingsService.getTokenForUser.mockResolvedValue('tok');
mockRepo.findBookSyncData.mockResolvedValue({ ...readingBook, format: 'm4b' });
mockMatchService.matchBook.mockResolvedValue({
hardcoverBookId: 10,
hardcoverEditionId: 20,
editionPages: null,
editionAudioSeconds: 3600,
matchMethod: 'cached',
});
mockClient.query
.mockResolvedValueOnce({ insert_user_book: { user_book: { id: 55 }, error: null } })
.mockResolvedValueOnce({ update_user_book: { user_book: { id: 55 }, error: null } })
.mockResolvedValueOnce({ user_book_reads: [] })
.mockResolvedValueOnce({ insert_user_book_read: { user_book_read: { id: 77 }, error: null } });
await expect(makeService().syncBook(1, 1)).resolves.toBe('synced');
expect(mockClient.query).toHaveBeenNthCalledWith(
4,
1,
'tok',
expect.stringContaining('mutation InsertUserBookRead'),
expect.objectContaining({
object: expect.objectContaining({
progress_seconds: 1512,
edition_id: 20,
}),
}),
);
expect(mockClient.query.mock.calls[3]?.[3]?.object).not.toHaveProperty('progress_pages');
expect(mockRepo.upsertBookState).toHaveBeenCalledWith(expect.objectContaining({ lastSyncedProgress: 42, syncError: null }));
});
it('fails audiobook progress sync when the matched edition has no duration', async () => {
const errorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
mockSettingsService.getTokenForUser.mockResolvedValue('tok');
mockRepo.findBookSyncData.mockResolvedValue({ ...readingBook, format: 'm4b' });
mockMatchService.matchBook.mockResolvedValue({
hardcoverBookId: 10,
hardcoverEditionId: 20,
editionPages: null,
editionAudioSeconds: null,
matchMethod: 'cached',
});
mockClient.query
.mockResolvedValueOnce({ insert_user_book: { user_book: { id: 55 }, error: null } })
.mockResolvedValueOnce({ update_user_book: { user_book: { id: 55 }, error: null } });
await expect(makeService().syncBook(1, 1)).resolves.toBe('failed');
expect(mockRepo.upsertBookState).toHaveBeenCalledWith(expect.objectContaining({ syncError: 'missing_edition_audio_seconds' }));
expect(mockClient.query).not.toHaveBeenCalledWith(1, 'tok', expect.stringContaining('UserBookRead'), expect.anything());
errorSpy.mockRestore();
});
it('fails when progress is present but the matched edition has no page count', async () => {
const errorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
mockSettingsService.getTokenForUser.mockResolvedValue('tok');
@@ -345,7 +401,7 @@ describe('HardcoverSyncService', () => {
expect.objectContaining({ id: 899, object: expect.objectContaining({ progress_pages: 126 }) }),
);
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('[hardcover.sync_progress] [end] userId=1 bookId=1'));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('progress=42 progressPages=126 - progress sent to Hardcover'));
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('progress=42 progressPages=126 progressSeconds=null - progress sent to Hardcover'));
logSpy.mockRestore();
});
@@ -1,13 +1,14 @@
import type {
HardcoverActiveSyncStatus,
HardcoverBookSyncState,
HardcoverEditionsResult,
HardcoverLinkedBooksResult,
HardcoverSettings,
HardcoverSyncPendingSummary,
ReadStatus,
SetHardcoverEditionResult,
UpdateHardcoverBookSyncPayload,
import {
type HardcoverActiveSyncStatus,
type HardcoverBookSyncState,
type HardcoverEditionsResult,
type HardcoverLinkedBooksResult,
type HardcoverSettings,
type HardcoverSyncPendingSummary,
isAudioFormat,
type ReadStatus,
type SetHardcoverEditionResult,
type UpdateHardcoverBookSyncPayload,
} from '@bookorbit/types';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
@@ -107,6 +108,7 @@ query FindUserBookReads($userBookId: Int!) {
started_at
finished_at
progress_pages
progress_seconds
}
}`;
@@ -116,7 +118,13 @@ type FindUserBookResult = { me: { user_books: { id: number }[] } };
type InsertUserBookReadResult = { insert_user_book_read: { user_book_read: { id: number } | null; error: string | null } };
type UpdateUserBookReadResult = { update_user_book_read: { user_book_read: { id: number } | null; error: string | null } };
type FindUserBookReadsResult = {
user_book_reads: Array<{ id: number; started_at: string | null; finished_at: string | null; progress_pages: number | null }>;
user_book_reads: Array<{
id: number;
started_at: string | null;
finished_at: string | null;
progress_pages: number | null;
progress_seconds: number | null;
}>;
};
export type HardcoverSyncBookResult = 'synced' | 'skipped' | 'failed';
@@ -514,14 +522,21 @@ export class HardcoverSyncService {
(attempt) => attempt.id !== primaryAttempt?.id && attempt.outcome !== 'skimmed' && attempt.outcome !== 'abandoned',
);
const syncsPrimaryRead = startDate !== null || endDate !== null || book.progress != null;
if (syncsPrimaryRead && book.progress != null && !match.editionPages) {
throw new Error('missing_edition_pages');
const syncsAudioProgress = book.format != null && isAudioFormat(book.format);
if (syncsPrimaryRead && book.progress != null) {
if (syncsAudioProgress && !match.editionAudioSeconds) throw new Error('missing_edition_audio_seconds');
if (!syncsAudioProgress && !match.editionPages) throw new Error('missing_edition_pages');
}
const reads = syncsPrimaryRead || historyAttempts.length > 0 ? await this.findUserBookReads(userId, token, userBookId) : [];
if (syncsPrimaryRead) {
const progressPages = book.progress != null && match.editionPages ? Math.round((book.progress / 100) * match.editionPages) : undefined;
progressSynced = book.progress == null || progressPages != null;
const progressPages =
book.progress != null && !syncsAudioProgress && match.editionPages ? Math.round((book.progress / 100) * match.editionPages) : undefined;
const progressSeconds =
book.progress != null && syncsAudioProgress && match.editionAudioSeconds
? Math.round((book.progress / 100) * match.editionAudioSeconds)
: undefined;
progressSynced = book.progress == null || progressPages != null || progressSeconds != null;
hardcoverReadId = await this.syncAttemptRead(userId, token, {
userBookId,
@@ -534,10 +549,11 @@ export class HardcoverSyncService {
startedAt: startDate,
finishedAt: endDate,
progressPages,
progressSeconds,
editionId: match.hardcoverEditionId ?? undefined,
});
if (progressPages != null && hardcoverReadId != null) {
if ((progressPages != null || progressSeconds != null) && hardcoverReadId != null) {
await this.updateAdditionalOpenReads(userId, token, {
reads,
primaryReadId: hardcoverReadId,
@@ -545,11 +561,12 @@ export class HardcoverSyncService {
startedAt: startDate,
finishedAt: endDate,
progressPages,
progressSeconds,
editionId: match.hardcoverEditionId ?? undefined,
});
this.logger.log(
`[hardcover.sync_progress] [end] userId=${userId} bookId=${book.bookId} hardcoverBookId=${match.hardcoverBookId} hardcoverReadId=${hardcoverReadId} durationMs=${Date.now() - startedAt} progress=${book.progress} progressPages=${progressPages} - progress sent to Hardcover`,
`[hardcover.sync_progress] [end] userId=${userId} bookId=${book.bookId} hardcoverBookId=${match.hardcoverBookId} hardcoverReadId=${hardcoverReadId} durationMs=${Date.now() - startedAt} progress=${book.progress} progressPages=${progressPages ?? null} progressSeconds=${progressSeconds ?? null} - progress sent to Hardcover`,
);
}
}
@@ -671,6 +688,7 @@ export class HardcoverSyncService {
startedAt?: string | null;
finishedAt?: string | null;
progressPages?: number;
progressSeconds?: number;
editionId?: number;
},
): Promise<number | null> {
@@ -678,6 +696,7 @@ export class HardcoverSyncService {
if (input.startedAt) object['started_at'] = input.startedAt;
if (input.finishedAt) object['finished_at'] = input.finishedAt;
if (input.progressPages != null) object['progress_pages'] = input.progressPages;
if (input.progressSeconds != null) object['progress_seconds'] = input.progressSeconds;
if (input.editionId != null) object['edition_id'] = input.editionId;
if (input.existingReadId) {
@@ -734,6 +753,7 @@ export class HardcoverSyncService {
startedAt?: string | null;
finishedAt?: string | null;
progressPages?: number;
progressSeconds?: number;
editionId?: number;
},
): Promise<number | null> {
@@ -745,6 +765,7 @@ export class HardcoverSyncService {
startedAt: input.startedAt,
finishedAt: input.finishedAt,
progressPages: input.progressPages,
progressSeconds: input.progressSeconds,
editionId: input.editionId,
});
@@ -791,7 +812,8 @@ export class HardcoverSyncService {
reservedReadIds: ReadonlySet<number>;
startedAt?: string | null;
finishedAt?: string | null;
progressPages: number;
progressPages?: number;
progressSeconds?: number;
editionId?: number;
},
): Promise<void> {
@@ -806,7 +828,8 @@ export class HardcoverSyncService {
this.updateUserBookRead(userId, token, readId, {
...(input.startedAt ? { started_at: input.startedAt } : {}),
...(input.finishedAt ? { finished_at: input.finishedAt } : {}),
progress_pages: input.progressPages,
...(input.progressPages != null ? { progress_pages: input.progressPages } : {}),
...(input.progressSeconds != null ? { progress_seconds: input.progressSeconds } : {}),
...(input.editionId != null ? { edition_id: input.editionId } : {}),
}).catch(() => undefined),
),
@@ -184,6 +184,19 @@ describe('HardcoverRepository', () => {
expect(mainSelect).toHaveProperty('format');
});
it('uses audiobook progress for an audiobook sync snapshot', async () => {
const { repo, db } = makeRepository();
const chain: Record<string, unknown> = {};
for (const method of ['from', 'innerJoin', 'leftJoin', 'where', 'groupBy', 'as']) {
chain[method] = vi.fn().mockReturnValue(chain);
}
chain.then = (resolve: (rows: unknown[]) => void) => resolve([{ bookId: 42, format: 'm4b', readingProgress: null, audioProgress: 37.5 }]);
db.select.mockImplementation(() => chain);
await expect(repo.findSyncableBooks(7)).resolves.toEqual([{ bookId: 42, format: 'm4b', progress: 37.5 }]);
expect(chain.leftJoin).toHaveBeenCalledWith(schema.audiobookProgress, expect.anything());
});
it('findSyncableBook returns a book from findSyncableBooks', async () => {
const { repo } = makeRepository();
const findSyncableBooksForUser = vi.spyOn(repo as any, 'findSyncableBooksForUser');
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { Permission } from '@bookorbit/types';
import { isAudioFormat, Permission } from '@bookorbit/types';
import type { ContentFilterRules, ReadStatus } from '@bookorbit/types';
import { and, asc, eq, inArray, isNotNull, isNull, ne, or, sql } from 'drizzle-orm';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
@@ -259,7 +259,8 @@ export class HardcoverRepository {
startedAt: schema.userBookStatus.startedAt,
finishedAt: schema.userBookStatus.finishedAt,
rating: schema.userBookRatings.rating,
progress: maxProgressSq.maxProgress,
readingProgress: maxProgressSq.maxProgress,
audioProgress: schema.audiobookProgress.percentage,
attemptsUpdatedAt: attemptsSq.updatedAt,
})
.from(schema.books)
@@ -267,6 +268,7 @@ export class HardcoverRepository {
.leftJoin(schema.bookMetadata, eq(schema.bookMetadata.bookId, schema.books.id))
.leftJoin(schema.userBookRatings, and(eq(schema.userBookRatings.bookId, schema.books.id), eq(schema.userBookRatings.userId, userId)))
.leftJoin(maxProgressSq, eq(maxProgressSq.bookId, schema.books.id))
.leftJoin(schema.audiobookProgress, and(eq(schema.audiobookProgress.bookId, schema.books.id), eq(schema.audiobookProgress.userId, userId)))
.leftJoin(firstAuthorSq, eq(firstAuthorSq.bookId, schema.books.id))
.leftJoin(attemptsSq, eq(attemptsSq.bookId, schema.books.id))
.leftJoin(schema.bookFiles, eq(schema.bookFiles.id, schema.books.primaryFileId));
@@ -280,7 +282,11 @@ export class HardcoverRepository {
? await filtered
: await filtered.orderBy(asc(schema.bookMetadata.title), asc(schema.books.id)).limit(options.limit);
return rows as BookSyncData[];
return rows.map(({ readingProgress, audioProgress, ...row }) => ({
...row,
status: row.status as string,
progress: row.format && isAudioFormat(row.format) ? audioProgress : readingProgress,
}));
}
async findReadingAttempts(userId: number, bookId: number) {