mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -70,6 +70,13 @@ describe('BookMissingFormatRequest', () => {
|
|||||||
expect(wrapper.get('button').attributes('aria-label')).toBe('Request e-book')
|
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 () => {
|
it('asks for the format against this book, so it joins it rather than becoming another', async () => {
|
||||||
const wrapper = await render(['epub'])
|
const wrapper = await render(['epub'])
|
||||||
state.api.mockResolvedValueOnce(json({ request: { id: 77, status: 'pending' }, subscribed: false }))
|
state.api.mockResolvedValueOnce(json({ request: { id: 77, status: 'pending' }, subscribed: false }))
|
||||||
@@ -105,7 +112,7 @@ describe('BookMissingFormatRequest', () => {
|
|||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['a book holding both formats', ['epub', 'm4b'], true, true],
|
['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 without request access', ['epub'], false, true],
|
||||||
['somebody who hid requests', ['epub'], true, false],
|
['somebody who hid requests', ['epub'], true, false],
|
||||||
])('stays out of the way for %s', async (_label, formats, permitted, visible) => {
|
])('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 () => {
|
it('shows the recommended ISBN and explains the deterministic ranking', async () => {
|
||||||
state.hasPermission.mockImplementation((permission: string) => permission === Permission.BookRequestSelfFulfill)
|
state.hasPermission.mockImplementation((permission: string) => permission === Permission.BookRequestSelfFulfill)
|
||||||
state.defaultFor.mockReturnValue({ libraryId: 4, libraryName: 'Novels', folderId: null })
|
state.defaultFor.mockReturnValue({ libraryId: 4, libraryName: 'Novels', folderId: null })
|
||||||
|
|||||||
@@ -18,10 +18,9 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
} from '@lucide/vue'
|
} from '@lucide/vue'
|
||||||
import {
|
import {
|
||||||
AUDIO_FORMAT_LIST,
|
BOOK_REQUEST_FORMATS_BY_MEDIA_KIND,
|
||||||
BOOK_REQUEST_MEDIA_KINDS,
|
BOOK_REQUEST_MEDIA_KINDS,
|
||||||
canonicalizeBookRequestIsbn,
|
canonicalizeBookRequestIsbn,
|
||||||
EBOOK_FORMAT_LIST,
|
|
||||||
isBookRequestFulfiller,
|
isBookRequestFulfiller,
|
||||||
isGrabbableBookRequestStatus,
|
isGrabbableBookRequestStatus,
|
||||||
MAX_BOOK_REQUEST_SEARCH_ISBNS,
|
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
|
* 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.
|
* form can never offer a format the scorer does not know how to recognise.
|
||||||
*/
|
*/
|
||||||
const FORMATS_BY_MEDIA: Record<BookRequestMediaKind, readonly string[]> = {
|
const FORMATS_BY_MEDIA = BOOK_REQUEST_FORMATS_BY_MEDIA_KIND
|
||||||
ebook: EBOOK_FORMAT_LIST,
|
|
||||||
audiobook: AUDIO_FORMAT_LIST,
|
|
||||||
}
|
|
||||||
|
|
||||||
const title = ref('')
|
const title = ref('')
|
||||||
const author = ref('')
|
const author = ref('')
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { computed, ref, watch, type Ref } from 'vue'
|
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 type { BookDetail, BookRequestAvailability, BookRequestMediaKind, BookRequestSubmitResult, CreateBookRequestPayload } from '@bookorbit/types'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { pairHalfState, type PairHalfState } from '../requestPair'
|
import { pairHalfState, type PairHalfState } from '../requestPair'
|
||||||
@@ -23,9 +23,13 @@ export function useMissingFormatRequest(book: Ref<BookDetail | null>, enabled: R
|
|||||||
|
|
||||||
const missing = computed<BookRequestMediaKind | null>(() => {
|
const missing = computed<BookRequestMediaKind | null>(() => {
|
||||||
if (!book.value) return null
|
if (!book.value) return null
|
||||||
const profile = getBookMediaProfile(book.value.files)
|
const formats = book.value.files
|
||||||
if (profile.hasEbook && !profile.hasAudio) return 'audiobook'
|
.filter((file) => file.role === 'content' || file.role === 'primary')
|
||||||
if (profile.hasAudio && !profile.hasEbook) return 'ebook'
|
.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
|
return null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -332,6 +332,23 @@ describe('RequestAutomationPanel', () => {
|
|||||||
return wrapper
|
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. */
|
/** 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 () => {
|
it('collapses a burst of tier edits into one save', async () => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { ArrowDown, ArrowUp, Plus, X } from '@lucide/vue'
|
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 {
|
import type {
|
||||||
BookRequestMediaKind,
|
BookRequestMediaKind,
|
||||||
IndexerAdapterDescriptor,
|
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. */
|
/** Offered from the same lists the matcher accepts, so a form can never state an impossible tier. */
|
||||||
const FORMATS: Record<BookRequestMediaKind, readonly string[]> = {
|
const FORMATS = BOOK_REQUEST_FORMATS_BY_MEDIA_KIND
|
||||||
ebook: EBOOK_FORMAT_LIST,
|
|
||||||
audiobook: AUDIO_FORMAT_LIST,
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bitrate and channels describe an encode, which an ebook does not have. */
|
/** Bitrate and channels describe an encode, which an ebook does not have. */
|
||||||
const isAudio = computed(() => props.mediaKind === 'audiobook')
|
const isAudio = computed(() => props.mediaKind === 'audiobook')
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import type { BookRequestDownloadItem } from "./download-client";
|
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. */
|
/** 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 const BOOK_REQUEST_MEDIA_KINDS = ["ebook", "audiobook"] as const;
|
||||||
export type BookRequestMediaKind = (typeof BOOK_REQUEST_MEDIA_KINDS)[number];
|
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
|
* 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
|
* rows keep loading, and can be cancelled, hidden or deleted; nothing creates one any more, and
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function isContentBookFile(file: { format: string | null; role: string })
|
|||||||
return (file.role === "content" || file.role === "primary") && isBookFormat(file.format);
|
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 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;
|
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 type { SQL } from 'drizzle-orm';
|
||||||
import { PgDialect } from 'drizzle-orm/pg-core';
|
import { PgDialect } from 'drizzle-orm/pg-core';
|
||||||
|
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||||
|
|
||||||
import { BookRequestRepository } from './book-request.repository';
|
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() {
|
function makeDb() {
|
||||||
const chainable = {
|
const chainable = {
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
|
|||||||
@@ -23,9 +23,8 @@ import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
|||||||
import { alias } from 'drizzle-orm/pg-core';
|
import { alias } from 'drizzle-orm/pg-core';
|
||||||
import {
|
import {
|
||||||
ACTIVE_BOOK_REQUEST_STATUSES,
|
ACTIVE_BOOK_REQUEST_STATUSES,
|
||||||
AUDIO_FORMAT_LIST,
|
BOOK_REQUEST_FORMATS_BY_MEDIA_KIND,
|
||||||
BOOK_REQUEST_MEDIA_KINDS,
|
BOOK_REQUEST_MEDIA_KINDS,
|
||||||
EBOOK_FORMAT_LIST,
|
|
||||||
WORKER_WRITABLE_BOOK_REQUEST_STATUSES,
|
WORKER_WRITABLE_BOOK_REQUEST_STATUSES,
|
||||||
} from '@bookorbit/types';
|
} from '@bookorbit/types';
|
||||||
import type {
|
import type {
|
||||||
@@ -112,11 +111,6 @@ const ABANDONABLE_SELF_SERVE_STATUSES: readonly BookRequestStatus[] = ['approved
|
|||||||
*/
|
*/
|
||||||
const SELF_SERVE_CAP_LOCK_NAMESPACE = 248_001;
|
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 {
|
export interface ListRequestsOptions {
|
||||||
page: number;
|
page: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
@@ -507,7 +501,7 @@ export class BookRequestRepository {
|
|||||||
.from(bookFiles)
|
.from(bookFiles)
|
||||||
.where(and(eq(bookFiles.bookId, bookId), eq(bookFiles.role, 'content')));
|
.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)));
|
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> {
|
private async insertWithAliases(tx: RequestWriteExecutor, data: NewBookRequestRow, aliasKeys: string[]): Promise<BookRequestRow> {
|
||||||
@@ -970,7 +964,11 @@ export class BookRequestRepository {
|
|||||||
.select({ id: bookFiles.id })
|
.select({ id: bookFiles.id })
|
||||||
.from(bookFiles)
|
.from(bookFiles)
|
||||||
.where(
|
.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', () => {
|
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. */
|
/** 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 () => {
|
it('refuses to search for a request made for the retired comic medium', async () => {
|
||||||
const search = vi.fn();
|
const search = vi.fn();
|
||||||
|
|||||||
@@ -46,6 +46,39 @@ describe('rejectRelease', () => {
|
|||||||
expect(rejectRelease(release({ format: 'pdf' }), request({ mediaKind: 'ebook' }))).toBeNull();
|
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', () => {
|
it('compares only the language subtag, so eng and en agree', () => {
|
||||||
expect(rejectRelease(release({ language: 'ENG' }), request({ language: 'en' }))).toBeNull();
|
expect(rejectRelease(release({ language: 'ENG' }), request({ language: 'en' }))).toBeNull();
|
||||||
expect(rejectRelease(release({ language: 'de' }), request({ language: 'en' }))).toContain('not en');
|
expect(rejectRelease(release({ language: 'de' }), request({ language: 'en' }))).toContain('not en');
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
AUDIO_FORMAT_LIST,
|
BOOK_REQUEST_FORMATS_BY_MEDIA_KIND,
|
||||||
bookRequestIsbn10To13,
|
bookRequestIsbn10To13,
|
||||||
COMIC_FORMAT_LIST,
|
|
||||||
EBOOK_FORMAT_LIST,
|
|
||||||
explainReleaseProfileMismatch,
|
explainReleaseProfileMismatch,
|
||||||
ISO_639_2_TO_1,
|
ISO_639_2_TO_1,
|
||||||
isAudioFormat,
|
|
||||||
isValidBookRequestIsbn10,
|
isValidBookRequestIsbn10,
|
||||||
languagesAgree,
|
languagesAgree,
|
||||||
matchReleaseTier,
|
matchReleaseTier,
|
||||||
@@ -75,18 +72,12 @@ const SIZE_RANGES: Record<BookRequestMediaKind, { min: number; max: number }> =
|
|||||||
audiobook: { min: 20 * MEGABYTE, max: 8 * 1024 * MEGABYTE },
|
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
|
* 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`,
|
* 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.
|
* 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([
|
const SCENE_LANGUAGE_NAMES: ReadonlyMap<string, string> = new Map([
|
||||||
['chinese', 'zh'],
|
['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.
|
// A release may state several, as MyAnonaMouse does with "m4a mp3". One usable format is enough.
|
||||||
const formats = resolveFormats(candidate);
|
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`;
|
return `${formats.join('/')} is not a ${request.mediaKind} format`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -545,8 +545,8 @@ describe('Book requests (e2e)', () => {
|
|||||||
return response.json() as BookRequestPairSubmitResult;
|
return response.json() as BookRequestPairSubmitResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A book holding one ebook file, in a library that keeps one book per folder. */
|
/** One content file, in a library that keeps one book per folder. */
|
||||||
async function seedEbookBook(target: CreatedLibrary): Promise<number> {
|
async function seedEbookBook(target: CreatedLibrary, format = 'epub'): Promise<number> {
|
||||||
const folderPath = `${target.folderPath}/${randomUUID()}`;
|
const folderPath = `${target.folderPath}/${randomUUID()}`;
|
||||||
const [book] = await ctx.db
|
const [book] = await ctx.db
|
||||||
.insert(schema.books)
|
.insert(schema.books)
|
||||||
@@ -555,9 +555,9 @@ describe('Book requests (e2e)', () => {
|
|||||||
await ctx.db.insert(schema.bookFiles).values({
|
await ctx.db.insert(schema.bookFiles).values({
|
||||||
bookId: book!.id,
|
bookId: book!.id,
|
||||||
libraryFolderId: target.libraryFolderId,
|
libraryFolderId: target.libraryFolderId,
|
||||||
absolutePath: `${folderPath}/book.epub`,
|
absolutePath: `${folderPath}/book.${format}`,
|
||||||
ino: BigInt(Math.floor(Math.random() * 1_000_000_000)),
|
ino: BigInt(Math.floor(Math.random() * 1_000_000_000)),
|
||||||
format: 'epub',
|
format,
|
||||||
role: 'content',
|
role: 'content',
|
||||||
});
|
});
|
||||||
return book!.id;
|
return book!.id;
|
||||||
@@ -676,6 +676,58 @@ describe('Book requests (e2e)', () => {
|
|||||||
SCENARIO_TIMEOUT_MS,
|
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(
|
it(
|
||||||
'treats a book in a library the requester cannot reach as one that does not exist',
|
'treats a book in a library the requester cannot reach as one that does not exist',
|
||||||
async () => {
|
async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user