mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user