mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(books): count played read-along narration in continue listening
Continue Listening listed only books with audiobook progress, so a book whose only audio is a read-along EPUB never appeared there, on the web, on the phone or in the car, however much of its narration was heard. The shelf now also takes read-along EPUBs whose narration progress was saved by playing it, which is when its seconds are set. Reading on any device carries the narration marker along and clears the seconds, so a book only read stays in Continue Reading. A book with both an audiobook and narration under way is listed once, at its latest listening. The web and the iOS Home shelf show these as ordinary book cards. iOS CarPlay builds before this change leave them out of Continue, which there lists only audiobooks; the current build plays their narration. Ref #1847
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { PgDialect } from 'drizzle-orm/pg-core';
|
||||
import type { SQL } from 'drizzle-orm';
|
||||
|
||||
import * as schema from '../../db/schema';
|
||||
import { DashboardRepository } from './dashboard.repository';
|
||||
import { audiobookProgress, bookFiles, books, readingProgress, userBookStatus } from '../../db/schema';
|
||||
|
||||
@@ -105,25 +107,58 @@ describe('DashboardRepository', () => {
|
||||
expect(listChain.limit).toHaveBeenCalledWith(10);
|
||||
});
|
||||
|
||||
it('maps continue-listening rows and excludes unread plus terminal read statuses', async () => {
|
||||
const listChain = makeLimitChain([{ id: 41 }, { id: 10 }]);
|
||||
const db = { select: vi.fn().mockReturnValue(listChain) };
|
||||
const repo = new DashboardRepository(db as never);
|
||||
describe('continue listening', () => {
|
||||
/** A repository over a database with no client: it records the SQL each query compiles to and answers with `rows`. */
|
||||
function repositoryAnswering(rows: unknown[]) {
|
||||
const db = drizzle.mock({ schema });
|
||||
const queries: { sql: string; params: unknown[] }[] = [];
|
||||
vi.spyOn((db as unknown as { session: { prepareQuery: (...args: unknown[]) => unknown } }).session, 'prepareQuery').mockImplementation(
|
||||
(query: unknown) => {
|
||||
const { sql, params } = query as { sql: string; params: unknown[] };
|
||||
queries.push({ sql: sql.replaceAll(/\s+/g, ' '), params });
|
||||
const prepared = { setToken: () => prepared, execute: () => Promise.resolve(rows) };
|
||||
return prepared;
|
||||
},
|
||||
);
|
||||
return { repo: new DashboardRepository(db as never), queries };
|
||||
}
|
||||
|
||||
const result = await repo.findContinueListeningBookIds([8], 55, 10);
|
||||
const whereArg = listChain.where.mock.calls[0]?.[0];
|
||||
const whereValues = collectValues(whereArg);
|
||||
it('lists audiobooks and played read-along narration, newest listening first, without unread or terminal statuses', async () => {
|
||||
const { repo, queries } = repositoryAnswering([{ id: 41 }, { id: 10 }]);
|
||||
|
||||
expect(result).toEqual([41, 10]);
|
||||
expect(listChain.innerJoin).toHaveBeenCalledTimes(2);
|
||||
expect(listChain.innerJoin.mock.calls[0]?.[0]).toBe(audiobookProgress);
|
||||
expect(listChain.innerJoin.mock.calls[1]?.[0]).toBe(bookFiles);
|
||||
expect(listChain.leftJoin).toHaveBeenCalledTimes(1);
|
||||
expect(listChain.leftJoin).toHaveBeenCalledWith(userBookStatus, expect.anything());
|
||||
expect(collectValues(listChain.leftJoin.mock.calls[0]?.[1])).toContain(55);
|
||||
expect(whereValues).toEqual(expect.arrayContaining(['unread', 'read', 'skimmed', 'abandoned']));
|
||||
expect(listChain.orderBy).toHaveBeenCalledTimes(1);
|
||||
expect(listChain.limit).toHaveBeenCalledWith(10);
|
||||
await expect(repo.findContinueListeningBookIds([8], 55, 10)).resolves.toEqual([41, 10]);
|
||||
|
||||
const { sql, params } = queries[0]!;
|
||||
expect(sql).toContain('"audiobook_progress"');
|
||||
expect(sql).toContain(' union all ');
|
||||
expect(sql).toContain('"book_files"."media_overlay_available" = $');
|
||||
expect(sql).toContain('lower("book_files"."format") = \'epub\'');
|
||||
expect(sql).toContain('"reading_progress"."narration_percentage" > 0 and "reading_progress"."narration_percentage" < 100');
|
||||
expect(sql).toContain('group by "books"."id"');
|
||||
expect(sql).toContain('order by max("listened_at") desc, "books"."id" desc');
|
||||
expect(params).toEqual(expect.arrayContaining([55, 8, 10, 'unread', 'read', 'skimmed', 'abandoned']));
|
||||
});
|
||||
|
||||
// Reading moves the narration marker to the page and clears its seconds. Only playing the
|
||||
// narration leaves them, so only that counts as listening.
|
||||
it('counts narration only when playing it saved the position', async () => {
|
||||
const { repo, queries } = repositoryAnswering([{ id: 41 }]);
|
||||
|
||||
await repo.findContinueListeningBookIds([8], 55, 10);
|
||||
|
||||
const narration = queries[0]!.sql.split(' union all ')[1] ?? '';
|
||||
expect(narration).toContain('"reading_progress"."position_seconds" is not null');
|
||||
expect(narration).toContain('"reading_progress"."narration_updated_at" is not null');
|
||||
});
|
||||
|
||||
it('counts each book once when it has an audiobook and narration under way', async () => {
|
||||
const { repo, queries } = repositoryAnswering([{ value: 3 }]);
|
||||
|
||||
await expect(repo.countContinueListeningBooks([8], 55)).resolves.toBe(3);
|
||||
|
||||
expect(queries[0]!.sql).toContain('count(distinct "books"."id")');
|
||||
expect(queries[0]!.sql).toContain(' union all ');
|
||||
});
|
||||
});
|
||||
|
||||
it('maps want-to-read rows to id list and joins user status', async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { and, asc, desc, eq, gte, inArray, isNull, lt, max, min, notExists, notInArray, or, sql, type SQL } from 'drizzle-orm';
|
||||
import { and, asc, desc, eq, gte, inArray, isNotNull, isNull, lt, max, min, notExists, notInArray, or, sql, type SQL } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import { BOOK_FORMATS, isAudioFormat, type ContentFilterRules, type ReadStatus } from '@bookorbit/types';
|
||||
|
||||
@@ -129,30 +129,22 @@ export class DashboardRepository {
|
||||
if (accessibleLibraryIds.length === 0) return [];
|
||||
|
||||
const cfClauses = contentFilters ? buildContentFilterClauses(contentFilters, this.db) : [];
|
||||
const activity = this.listeningActivity(userId);
|
||||
const rows = await this.db
|
||||
.select({ id: books.id })
|
||||
.from(books)
|
||||
.innerJoin(audiobookProgress, and(eq(audiobookProgress.bookId, books.id), eq(audiobookProgress.userId, userId)))
|
||||
.innerJoin(
|
||||
bookFiles,
|
||||
and(
|
||||
eq(bookFiles.id, audiobookProgress.currentFileId),
|
||||
eq(bookFiles.bookId, books.id),
|
||||
eq(bookFiles.role, 'content'),
|
||||
inArray(bookFiles.format, AUDIO_FORMATS),
|
||||
),
|
||||
)
|
||||
.innerJoin(activity, eq(activity.bookId, books.id))
|
||||
.leftJoin(userBookStatus, and(eq(userBookStatus.bookId, books.id), eq(userBookStatus.userId, userId)))
|
||||
.where(
|
||||
and(
|
||||
inArray(books.libraryId, accessibleLibraryIds),
|
||||
eq(books.status, 'present'),
|
||||
sql`${audiobookProgress.percentage} > 0 and ${audiobookProgress.percentage} < 100`,
|
||||
or(isNull(userBookStatus.bookId), notInArray(userBookStatus.status, [...CONTINUE_SCROLLER_EXCLUDED_READ_STATUSES])),
|
||||
...cfClauses,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(audiobookProgress.updatedAt), desc(books.id))
|
||||
.groupBy(books.id)
|
||||
.orderBy(desc(max(activity.listenedAt)), desc(books.id))
|
||||
.limit(limit);
|
||||
|
||||
return rows.map((row) => row.id);
|
||||
@@ -161,29 +153,60 @@ export class DashboardRepository {
|
||||
async countContinueListeningBooks(accessibleLibraryIds: number[], userId: number, contentFilters?: ContentFilterRules): Promise<number> {
|
||||
if (accessibleLibraryIds.length === 0) return 0;
|
||||
const cfClauses = contentFilters ? buildContentFilterClauses(contentFilters, this.db) : [];
|
||||
const activity = this.listeningActivity(userId);
|
||||
const rows = await this.db
|
||||
.select({ value: sql<number>`count(*)::int` })
|
||||
.select({ value: sql<number>`count(distinct ${books.id})::int` })
|
||||
.from(books)
|
||||
.innerJoin(audiobookProgress, and(eq(audiobookProgress.bookId, books.id), eq(audiobookProgress.userId, userId)))
|
||||
.innerJoin(activity, eq(activity.bookId, books.id))
|
||||
.where(and(inArray(books.libraryId, accessibleLibraryIds), eq(books.status, 'present'), ...cfClauses));
|
||||
|
||||
return rows[0]?.value ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row per thing the user is part way through listening to, with when they last listened: an
|
||||
* audiobook, or a read-along EPUB's narration. A book with both can appear twice; callers group.
|
||||
*
|
||||
* Narration counts only when playing it wrote the marker, which is when `position_seconds` is
|
||||
* set. Reading on any device carries the marker along to the page and clears the seconds
|
||||
* (`AudiobookEbookProgressSyncService.advanceNarrationToText`), and a book only read that way
|
||||
* belongs to Continue Reading.
|
||||
*/
|
||||
private listeningActivity(userId: number) {
|
||||
const audiobooks = this.db
|
||||
.select({ bookId: audiobookProgress.bookId, listenedAt: sql<Date>`${audiobookProgress.updatedAt}`.as('listened_at') })
|
||||
.from(audiobookProgress)
|
||||
.innerJoin(
|
||||
bookFiles,
|
||||
and(
|
||||
eq(bookFiles.id, audiobookProgress.currentFileId),
|
||||
eq(bookFiles.bookId, books.id),
|
||||
eq(bookFiles.bookId, audiobookProgress.bookId),
|
||||
eq(bookFiles.role, 'content'),
|
||||
inArray(bookFiles.format, AUDIO_FORMATS),
|
||||
),
|
||||
)
|
||||
.where(and(eq(audiobookProgress.userId, userId), sql`${audiobookProgress.percentage} > 0 and ${audiobookProgress.percentage} < 100`));
|
||||
const narrations = this.db
|
||||
.select({ bookId: bookFiles.bookId, listenedAt: sql<Date>`${readingProgress.narrationUpdatedAt}`.as('listened_at') })
|
||||
.from(readingProgress)
|
||||
.innerJoin(
|
||||
bookFiles,
|
||||
and(
|
||||
eq(bookFiles.id, readingProgress.bookFileId),
|
||||
eq(bookFiles.role, 'content'),
|
||||
eq(bookFiles.mediaOverlayAvailable, true),
|
||||
sql`lower(${bookFiles.format}) = 'epub'`,
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
inArray(books.libraryId, accessibleLibraryIds),
|
||||
eq(books.status, 'present'),
|
||||
sql`${audiobookProgress.percentage} > 0 and ${audiobookProgress.percentage} < 100`,
|
||||
...cfClauses,
|
||||
eq(readingProgress.userId, userId),
|
||||
isNotNull(readingProgress.positionSeconds),
|
||||
isNotNull(readingProgress.narrationUpdatedAt),
|
||||
sql`${readingProgress.narrationPercentage} > 0 and ${readingProgress.narrationPercentage} < 100`,
|
||||
),
|
||||
);
|
||||
|
||||
return rows[0]?.value ?? 0;
|
||||
return audiobooks.unionAll(narrations).as('listening_activity');
|
||||
}
|
||||
|
||||
async countWantToReadBooks(accessibleLibraryIds: number[], userId: number, contentFilters?: ContentFilterRules): Promise<number> {
|
||||
|
||||
Reference in New Issue
Block a user