mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -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')
|
||||
})
|
||||
|
||||
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')
|
||||
|
||||
Reference in New Issue
Block a user