feat(books): include comics in non-audio requests

Keep books and comic archives under the existing ebook request kind so searches, ownership checks,
format preferences and release profiles agree without changing the API contract. Cover the combined
formats and library access rules with unit and end-to-end tests.

Ref #1748
This commit is contained in:
neon
2026-10-10 21:47:09 +00:00
parent e6c0275fdf
commit c91648eaac
14 changed files with 233 additions and 42 deletions
@@ -70,6 +70,13 @@ describe('BookMissingFormatRequest', () => {
expect(wrapper.get('button').attributes('aria-label')).toBe('Request e-book')
})
it.each(['cbz', 'cbr', 'cb7', 'CBZ'])('recognizes %s as the non-audio half', async (format) => {
const wrapper = await render([format])
expect(wrapper.get('button').attributes('aria-label')).toBe('Request audiobook')
const [, init] = state.api.mock.calls[0]!
expect(JSON.parse(init!.body as string).items[0]).toMatchObject({ mediaKind: 'audiobook' })
})
it('asks for the format against this book, so it joins it rather than becoming another', async () => {
const wrapper = await render(['epub'])
state.api.mockResolvedValueOnce(json({ request: { id: 77, status: 'pending' }, subscribed: false }))
@@ -105,7 +112,7 @@ describe('BookMissingFormatRequest', () => {
it.each([
['a book holding both formats', ['epub', 'm4b'], true, true],
['a comic', ['cbz'], true, true],
['a comic holding audio too', ['cbz', 'mp3'], true, true],
['somebody without request access', ['epub'], false, true],
['somebody who hid requests', ['epub'], true, false],
])('stays out of the way for %s', async (_label, formats, permitted, visible) => {
@@ -347,6 +347,29 @@ describe('RequestSearchPanel provider sources', () => {
)
})
it('offers comic archives beside book formats and sends them through the non-audio request', async () => {
state.submit.mockResolvedValue(null)
const wrapper = await render()
await wrapper
.findAll('button')
.find((button) => button.text().includes('More options'))!
.trigger('click')
expect(wrapper.find('button[aria-label="Prefer EPUB"]').exists()).toBe(true)
for (const format of ['CBZ', 'CBR', 'CB7', 'CBX']) {
expect(wrapper.find(`button[aria-label="Prefer ${format}"]`).exists()).toBe(true)
}
expect(wrapper.find('button[aria-label="Prefer MP3"]').exists()).toBe(false)
await wrapper.get('button[aria-label="Prefer CBZ"]').trigger('click')
await wrapper
.findAll('button')
.find((button) => button.text() === 'Request')!
.trigger('click')
expect(state.mediaKind.value).toBe('ebook')
expect(state.submit).toHaveBeenCalledWith(state.groups.value[0]!.candidate, expect.objectContaining({ preferredFormats: ['cbz'] }))
})
it('shows the recommended ISBN and explains the deterministic ranking', async () => {
state.hasPermission.mockImplementation((permission: string) => permission === Permission.BookRequestSelfFulfill)
state.defaultFor.mockReturnValue({ libraryId: 4, libraryName: 'Novels', folderId: null })
@@ -18,10 +18,9 @@ import {
Users,
} from '@lucide/vue'
import {
AUDIO_FORMAT_LIST,
BOOK_REQUEST_FORMATS_BY_MEDIA_KIND,
BOOK_REQUEST_MEDIA_KINDS,
canonicalizeBookRequestIsbn,
EBOOK_FORMAT_LIST,
isBookRequestFulfiller,
isGrabbableBookRequestStatus,
MAX_BOOK_REQUEST_SEARCH_ISBNS,
@@ -113,10 +112,7 @@ const searchChoice = computed<SearchChoice>(() => (pairMode.value ? 'pair' : med
* What each medium may be asked for, taken from the shared lists rather than restated here, so the
* form can never offer a format the scorer does not know how to recognise.
*/
const FORMATS_BY_MEDIA: Record<BookRequestMediaKind, readonly string[]> = {
ebook: EBOOK_FORMAT_LIST,
audiobook: AUDIO_FORMAT_LIST,
}
const FORMATS_BY_MEDIA = BOOK_REQUEST_FORMATS_BY_MEDIA_KIND
const title = ref('')
const author = ref('')
@@ -1,5 +1,5 @@
import { computed, ref, watch, type Ref } from 'vue'
import { getBookMediaProfile } from '@bookorbit/types'
import { BOOK_REQUEST_FORMATS_BY_MEDIA_KIND } from '@bookorbit/types'
import type { BookDetail, BookRequestAvailability, BookRequestMediaKind, BookRequestSubmitResult, CreateBookRequestPayload } from '@bookorbit/types'
import { api } from '@/lib/api'
import { pairHalfState, type PairHalfState } from '../requestPair'
@@ -23,9 +23,13 @@ export function useMissingFormatRequest(book: Ref<BookDetail | null>, enabled: R
const missing = computed<BookRequestMediaKind | null>(() => {
if (!book.value) return null
const profile = getBookMediaProfile(book.value.files)
if (profile.hasEbook && !profile.hasAudio) return 'audiobook'
if (profile.hasAudio && !profile.hasEbook) return 'ebook'
const formats = book.value.files
.filter((file) => file.role === 'content' || file.role === 'primary')
.map((file) => file.format?.toLowerCase() ?? '')
const hasReadable = formats.some((format) => BOOK_REQUEST_FORMATS_BY_MEDIA_KIND.ebook.includes(format))
const hasAudio = formats.some((format) => BOOK_REQUEST_FORMATS_BY_MEDIA_KIND.audiobook.includes(format))
if (hasReadable && !hasAudio) return 'audiobook'
if (hasAudio && !hasReadable) return 'ebook'
return null
})
@@ -332,6 +332,23 @@ describe('RequestAutomationPanel', () => {
return wrapper
}
it('offers comic formats in the non-audio profile and saves the selected archive format', async () => {
vi.useFakeTimers()
try {
const wrapper = await profilePanel()
const chips = wrapper.findAll('button[aria-pressed]')
for (const format of ['epub', 'cbz', 'cbr', 'cb7', 'cbx']) {
expect(chips.some((chip) => chip.text() === format)).toBe(true)
}
expect(chips.some((chip) => chip.text() === 'mp3')).toBe(false)
await chips.find((chip) => chip.text() === 'cbz')!.trigger('click')
await vi.advanceTimersByTimeAsync(1000)
expect(lastBody()).toEqual({ profiles: { ebook: [{ id: 'tier-1', name: 'Retail', conditions: { formats: ['cbz'] } }] } })
} finally {
vi.useRealTimers()
}
})
/** The chip presses land on the editor, and one request lands on the server. */
it('collapses a burst of tier edits into one save', async () => {
vi.useFakeTimers()
@@ -2,7 +2,7 @@
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ArrowDown, ArrowUp, Plus, X } from '@lucide/vue'
import { AUDIO_FORMAT_LIST, EBOOK_FORMAT_LIST, MAX_RELEASE_TIER_NAME_LENGTH, MAX_RELEASE_TIERS, REQUEST_LANGUAGE_CODES } from '@bookorbit/types'
import { BOOK_REQUEST_FORMATS_BY_MEDIA_KIND, MAX_RELEASE_TIER_NAME_LENGTH, MAX_RELEASE_TIERS, REQUEST_LANGUAGE_CODES } from '@bookorbit/types'
import type {
BookRequestMediaKind,
IndexerAdapterDescriptor,
@@ -66,10 +66,7 @@ function clone(list: ReleaseTier[]): ReleaseTier[] {
}
/** Offered from the same lists the matcher accepts, so a form can never state an impossible tier. */
const FORMATS: Record<BookRequestMediaKind, readonly string[]> = {
ebook: EBOOK_FORMAT_LIST,
audiobook: AUDIO_FORMAT_LIST,
}
const FORMATS = BOOK_REQUEST_FORMATS_BY_MEDIA_KIND
/** Bitrate and channels describe an encode, which an ebook does not have. */
const isAudio = computed(() => props.mediaKind === 'audiobook')
+7
View File
@@ -1,9 +1,16 @@
import type { BookRequestDownloadItem } from "./download-client";
import { AUDIO_FORMAT_LIST, COMIC_FORMAT_LIST, EBOOK_FORMAT_LIST } from "./book";
/** What a book can be requested as: an ebook or an audiobook, alone or as a pair. */
export const BOOK_REQUEST_MEDIA_KINDS = ["ebook", "audiobook"] as const;
export type BookRequestMediaKind = (typeof BOOK_REQUEST_MEDIA_KINDS)[number];
/** The `ebook` API kind covers readable books and comics; audio stays a separate request. */
export const BOOK_REQUEST_FORMATS_BY_MEDIA_KIND: Readonly<Record<BookRequestMediaKind, readonly string[]>> = {
ebook: [...EBOOK_FORMAT_LIST, ...COMIC_FORMAT_LIST],
audiobook: AUDIO_FORMAT_LIST,
};
/**
* A medium requests no longer offer, still carried by requests made before it was retired. Those
* rows keep loading, and can be cancelled, hidden or deleted; nothing creates one any more, and
+1 -1
View File
@@ -42,7 +42,7 @@ export function isContentBookFile(file: { format: string | null; role: string })
return (file.role === "content" || file.role === "primary") && isBookFormat(file.format);
}
/** What BookOrbit accepts as an ebook, and what an ebook tier may therefore ask for. */
/** Text and document formats, excluding comic archives and audio. */
export const EBOOK_FORMAT_LIST = ["epub", "kepub", "mobi", "azw3", "azw", "fb2", "pdf", "djvu"] as const;
export const READ_STATUSES = ["unread", "want_to_read", "reading", "on_hold", "rereading", "read", "skimmed", "abandoned"] as const;
@@ -1,8 +1,53 @@
import type { SQL } from 'drizzle-orm';
import { PgDialect } from 'drizzle-orm/pg-core';
import { drizzle } from 'drizzle-orm/node-postgres';
import { BookRequestRepository } from './book-request.repository';
describe('BookRequestRepository non-audio ownership', () => {
it.each(['cbz', 'cbr', 'cb7', 'cbx', 'CBZ', 'epub', 'pdf'])('recognizes an existing %s file as the non-audio half', async (format) => {
const query = vi.fn().mockResolvedValue({ rows: [[format]] });
const repo = new BookRequestRepository(drizzle({ query } as never) as never);
expect(await repo.findBookMediaKinds(42)).toEqual(new Set(['ebook']));
expect(query.mock.calls[0]?.[0].text).toContain('"book_id" =');
expect(query.mock.calls[0]?.[1]).toEqual([42, 'content']);
});
it('keeps audio separate and ignores unknown formats', async () => {
const query = vi.fn().mockResolvedValue({ rows: [['mp3'], [null], ['jpg']] });
const repo = new BookRequestRepository(drizzle({ query } as never) as never);
expect(await repo.findBookMediaKinds(42)).toEqual(new Set(['audiobook']));
});
it.each(['ebook', 'audiobook'] as const)('checks %s ownership by content format within the accessible libraries', async (mediaKind) => {
const query = vi.fn().mockResolvedValue({ rows: [] });
const repo = new BookRequestRepository(drizzle({ query } as never) as never);
await repo.findOwnedMatches(['9781632150694'], ['saga'], [77], mediaKind);
expect(query).toHaveBeenCalledTimes(2);
for (const [statement, params] of query.mock.calls) {
expect(statement.text).toContain('exists');
expect(statement.text).toContain('"books"."library_id" in');
expect(params).toEqual(expect.arrayContaining(['present', 'content', 77]));
for (const format of ['cbz', 'cbr', 'cb7', 'cbx', 'epub', 'pdf']) {
if (mediaKind === 'ebook') expect(params).toContain(format);
else expect(params).not.toContain(format);
}
if (mediaKind === 'ebook') expect(params).not.toContain('mp3');
else expect(params).toContain('mp3');
}
});
it('does not probe ownership when the user cannot access any library', async () => {
const query = vi.fn();
const repo = new BookRequestRepository(drizzle({ query } as never) as never);
expect(await repo.findOwnedMatches(['9781632150694'], ['saga'], [], 'ebook')).toEqual({ byIsbn13: new Map(), byTitle: new Map() });
expect(query).not.toHaveBeenCalled();
});
});
function makeDb() {
const chainable = {
update: vi.fn(),
@@ -23,9 +23,8 @@ import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { alias } from 'drizzle-orm/pg-core';
import {
ACTIVE_BOOK_REQUEST_STATUSES,
AUDIO_FORMAT_LIST,
BOOK_REQUEST_FORMATS_BY_MEDIA_KIND,
BOOK_REQUEST_MEDIA_KINDS,
EBOOK_FORMAT_LIST,
WORKER_WRITABLE_BOOK_REQUEST_STATUSES,
} from '@bookorbit/types';
import type {
@@ -112,11 +111,6 @@ const ABANDONABLE_SELF_SERVE_STATUSES: readonly BookRequestStatus[] = ['approved
*/
const SELF_SERVE_CAP_LOCK_NAMESPACE = 248_001;
const OWNED_FORMATS_BY_MEDIA_KIND: Record<BookRequestMediaKind, readonly string[]> = {
ebook: EBOOK_FORMAT_LIST,
audiobook: AUDIO_FORMAT_LIST,
};
export interface ListRequestsOptions {
page: number;
limit: number;
@@ -507,7 +501,7 @@ export class BookRequestRepository {
.from(bookFiles)
.where(and(eq(bookFiles.bookId, bookId), eq(bookFiles.role, 'content')));
const formats = new Set(rows.map((row) => row.format?.toLowerCase()).filter((format): format is string => Boolean(format)));
return new Set(BOOK_REQUEST_MEDIA_KINDS.filter((kind) => OWNED_FORMATS_BY_MEDIA_KIND[kind].some((format) => formats.has(format))));
return new Set(BOOK_REQUEST_MEDIA_KINDS.filter((kind) => BOOK_REQUEST_FORMATS_BY_MEDIA_KIND[kind].some((format) => formats.has(format))));
}
private async insertWithAliases(tx: RequestWriteExecutor, data: NewBookRequestRow, aliasKeys: string[]): Promise<BookRequestRow> {
@@ -970,7 +964,11 @@ export class BookRequestRepository {
.select({ id: bookFiles.id })
.from(bookFiles)
.where(
and(eq(bookFiles.bookId, books.id), eq(bookFiles.role, 'content'), inArray(bookFiles.format, [...OWNED_FORMATS_BY_MEDIA_KIND[mediaKind]])),
and(
eq(bookFiles.bookId, books.id),
eq(bookFiles.role, 'content'),
inArray(bookFiles.format, [...BOOK_REQUEST_FORMATS_BY_MEDIA_KIND[mediaKind]]),
),
),
);
@@ -114,6 +114,27 @@ function makeService(
}
describe('IndexerSearchService', () => {
it('keeps books and comic archives in the same non-audio search without returning audiobooks', async () => {
const adapter = {
mediaKinds: ['ebook', 'audiobook'],
search: vi
.fn()
.mockResolvedValue([
release({ guid: 'comic', format: 'cbz', title: 'Saga - Brian K Vaughan [CBZ]' }),
release({ guid: 'book', format: 'epub', title: 'Saga - Brian K Vaughan [EPUB]' }),
release({ guid: 'audio', format: 'mp3', title: 'Saga - Brian K Vaughan [MP3]' }),
]),
};
const { service } = makeService([indexer({ adapterType: pluginType('example-plugin') })], { 'example-plugin': adapter });
const result = await service.search(request({ title: 'Saga', authors: ['Brian K Vaughan'], preferredFormats: ['cbz'] }));
expect(adapter.search).toHaveBeenCalledWith(expect.objectContaining({ mediaKind: 'ebook' }), expect.anything(), expect.any(AbortSignal));
expect(result.releases.map((item) => item.guid)).toEqual(['comic', 'book']);
expect(result.releases[0]).toMatchObject({ format: 'cbz', formats: ['cbz'] });
expect(result.indexers[0]).toMatchObject({ ok: true, count: 2, filtered: 1 });
});
/** The picker for an old comic request says why, rather than asking every tracker for nothing. */
it('refuses to search for a request made for the retired comic medium', async () => {
const search = vi.fn();
@@ -46,6 +46,39 @@ describe('rejectRelease', () => {
expect(rejectRelease(release({ format: 'pdf' }), request({ mediaKind: 'ebook' }))).toBeNull();
});
it.each(['cbz', 'cbr', 'cb7', 'cbx'])('accepts %s for non-audio requests but rejects it for audiobooks', (format) => {
const candidate = release({ format, title: `Saga [${format.toUpperCase()}]` });
expect(rejectRelease(candidate, request())).toBeNull();
expect(rejectRelease(candidate, request({ mediaKind: 'audiobook' }))).toContain('not a audiobook format');
});
it('recognizes comic archives from scene names when the provider omits the format', () => {
const candidate = release({ title: 'Saga - Brian K Vaughan [CBZ]', format: undefined });
const criteria = request({ title: 'Saga', authors: ['Brian K Vaughan'], preferredFormats: ['cbz'] });
expect(rejectRelease(candidate, criteria)).toBeNull();
expect(toReleaseItem(scoreRelease(candidate, criteria), 'example', criteria)).toMatchObject({ format: 'cbz', formats: ['cbz'] });
});
it('accepts mixed book and comic formats while keeping audio-only releases out', () => {
expect(rejectRelease(release({ format: 'CBR PDF' }), request())).toBeNull();
expect(rejectRelease(release({ format: 'CBZ EPUB' }), request())).toBeNull();
expect(rejectRelease(release({ format: 'm4b mp3', title: 'Saga' }), request())).toContain('not a ebook format');
});
it('matches a comic format preference and release profile for a non-audio request', () => {
const criteria = request({
title: 'Saga',
authors: ['Brian K Vaughan'],
preferredFormats: ['cbz'],
tiers: [{ id: 'comic', name: 'Comic archives', conditions: { formats: ['cbz', 'cbr'] } }],
});
const candidate = release({ format: 'cbz', title: 'Saga - Brian K Vaughan [CBZ]' });
expect(rejectRelease(candidate, criteria)).toBeNull();
const scored = scoreRelease(candidate, criteria);
expect(pointsFor(scored, 'preferredFormat')).toBeGreaterThan(0);
expect(toReleaseItem(scored, 'example', criteria)).toMatchObject({ tier: 0, formats: ['cbz'] });
});
it('compares only the language subtag, so eng and en agree', () => {
expect(rejectRelease(release({ language: 'ENG' }), request({ language: 'en' }))).toBeNull();
expect(rejectRelease(release({ language: 'de' }), request({ language: 'en' }))).toContain('not en');
@@ -1,11 +1,8 @@
import {
AUDIO_FORMAT_LIST,
BOOK_REQUEST_FORMATS_BY_MEDIA_KIND,
bookRequestIsbn10To13,
COMIC_FORMAT_LIST,
EBOOK_FORMAT_LIST,
explainReleaseProfileMismatch,
ISO_639_2_TO_1,
isAudioFormat,
isValidBookRequestIsbn10,
languagesAgree,
matchReleaseTier,
@@ -75,18 +72,12 @@ const SIZE_RANGES: Record<BookRequestMediaKind, { min: number; max: number }> =
audiobook: { min: 20 * MEGABYTE, max: 8 * 1024 * MEGABYTE },
};
/** Whether a format is a usable one for the requested medium. */
const FORMATS_BY_MEDIA_KIND: Record<BookRequestMediaKind, (format: string) => boolean> = {
ebook: (format) => (EBOOK_FORMAT_LIST as readonly string[]).includes(format),
audiobook: (format) => isAudioFormat(format),
};
/**
* Formats a scene name spells out, so a release with no format field still classifies. Derived
* from the three shared lists rather than restated: written out by hand it had already lost `cbx`,
* so a comic release naming that format read as having no format at all.
*/
const FORMAT_TOKENS: readonly string[] = [...EBOOK_FORMAT_LIST, ...AUDIO_FORMAT_LIST, ...COMIC_FORMAT_LIST];
const FORMAT_TOKENS: readonly string[] = Object.values(BOOK_REQUEST_FORMATS_BY_MEDIA_KIND).flat();
const SCENE_LANGUAGE_NAMES: ReadonlyMap<string, string> = new Map([
['chinese', 'zh'],
@@ -151,7 +142,7 @@ export function rejectRelease(candidate: ReleaseCandidate, request: ScoringReque
// A release may state several, as MyAnonaMouse does with "m4a mp3". One usable format is enough.
const formats = resolveFormats(candidate);
if (formats.length > 0 && !formats.some((format) => FORMATS_BY_MEDIA_KIND[request.mediaKind](format))) {
if (formats.length > 0 && !formats.some((format) => BOOK_REQUEST_FORMATS_BY_MEDIA_KIND[request.mediaKind].includes(format))) {
return `${formats.join('/')} is not a ${request.mediaKind} format`;
}
+56 -4
View File
@@ -545,8 +545,8 @@ describe('Book requests (e2e)', () => {
return response.json() as BookRequestPairSubmitResult;
}
/** A book holding one ebook file, in a library that keeps one book per folder. */
async function seedEbookBook(target: CreatedLibrary): Promise<number> {
/** One content file, in a library that keeps one book per folder. */
async function seedEbookBook(target: CreatedLibrary, format = 'epub'): Promise<number> {
const folderPath = `${target.folderPath}/${randomUUID()}`;
const [book] = await ctx.db
.insert(schema.books)
@@ -555,9 +555,9 @@ describe('Book requests (e2e)', () => {
await ctx.db.insert(schema.bookFiles).values({
bookId: book!.id,
libraryFolderId: target.libraryFolderId,
absolutePath: `${folderPath}/book.epub`,
absolutePath: `${folderPath}/book.${format}`,
ino: BigInt(Math.floor(Math.random() * 1_000_000_000)),
format: 'epub',
format,
role: 'content',
});
return book!.id;
@@ -676,6 +676,58 @@ describe('Book requests (e2e)', () => {
SCENARIO_TIMEOUT_MS,
);
it.each(['cbz', 'cbr', 'cb7', 'cbx'])(
'recognizes an existing %s comic as the non-audio half and still allows its audiobook',
async (format) => {
const folderLibrary = await createLibraryWithFolder(ctx, { mode: 'book_per_folder', name: `book-requests-comic-${randomUUID()}` });
await grantLibraryAccess(ctx, requester.userId, folderLibrary.libraryId, 'viewer');
const bookId = await seedEbookBook(folderLibrary, format);
const duplicate = await inject('POST', '/book-requests', requester, {
title: `Comic ${randomUUID()}`,
mediaKind: 'ebook',
preferredFormats: [format],
targetBookId: bookId,
});
expect(duplicate.statusCode).toBe(400);
expect(duplicate.json()).toMatchObject({ errorCode: 'SUBMIT_TARGET_BOOK_HAS_MEDIUM' });
const added = await inject('POST', '/book-requests', requester, {
title: `Comic ${randomUUID()}`,
mediaKind: 'audiobook',
targetBookId: bookId,
});
expect(added.statusCode).toBe(201);
expect(added.json()).toMatchObject({ request: { mediaKind: 'audiobook', targetBookId: bookId, targetLibraryId: folderLibrary.libraryId } });
},
SCENARIO_TIMEOUT_MS,
);
it(
'accepts comic preferences on ebook requests and scopes comic availability to accessible libraries',
async () => {
const title = `Comic ${randomUUID()}`;
const created = await submitOk(requester, { title, preferredFormats: ['cbz', 'epub'] });
expect(created.request).toMatchObject({ mediaKind: 'ebook', preferredFormats: ['cbz', 'epub'] });
const bookId = await seedEbookBook(library, 'cbz');
const ownedTitle = `Owned comic ${randomUUID()}`;
await ctx.db.insert(schema.bookMetadata).values({ bookId, title: ownedTitle });
const queries = [
{ title: ownedTitle, mediaKind: 'ebook' },
{ title: ownedTitle, mediaKind: 'audiobook' },
];
const visible = await inject('POST', '/book-requests/availability', requester, { items: queries });
expect(visible.statusCode).toBe(200);
expect(visible.json()).toMatchObject([{ ownedBookId: bookId }, { ownedBookId: null }]);
const stranger = await createUserAndLogin(ctx, { permissions: REQUESTER_PERMISSIONS });
const hidden = await inject('POST', '/book-requests/availability', stranger, { items: queries });
expect(hidden.statusCode).toBe(200);
expect(hidden.json()).toMatchObject([{ ownedBookId: null }, { ownedBookId: null }]);
},
SCENARIO_TIMEOUT_MS,
);
it(
'treats a book in a library the requester cannot reach as one that does not exist',
async () => {