fix(series): show mixed-media books in every group

Classify series entries from every available format so combined editions appear in each relevant section.

Fixes #1566
This commit is contained in:
neon
2026-09-29 00:00:13 +00:00
parent 826584e5da
commit 0085ffd11e
3 changed files with 71 additions and 23 deletions
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { BookCard } from '@bookorbit/types'
import { getSeriesBookMediaGroupKey, groupSeriesBooksByMedia } from './useSeriesBookMediaGroups'
import { getSeriesBookMediaGroupKeys, groupSeriesBooksByMedia } from './useSeriesBookMediaGroups'
function makeBook(format: string | null | undefined, overrides: Partial<BookCard> = {}): BookCard {
return {
@@ -40,18 +40,18 @@ function makeBook(format: string | null | undefined, overrides: Partial<BookCard
describe('series book media groups', () => {
it.each(['epub', 'pdf', 'mobi', 'azw', 'azw3', 'fb2', 'unknown', null])('groups %s as Books', (format) => {
expect(getSeriesBookMediaGroupKey(makeBook(format))).toBe('books')
expect(getSeriesBookMediaGroupKeys(makeBook(format))).toEqual(['books'])
})
it.each(['m4b', 'mp3', 'm4a', 'opus', 'ogg', 'flac'])('groups %s as Audiobooks', (format) => {
expect(getSeriesBookMediaGroupKey(makeBook(format))).toBe('audiobooks')
expect(getSeriesBookMediaGroupKeys(makeBook(format))).toEqual(['audiobooks'])
})
it.each(['cbz', 'cbr', 'cb7'])('groups %s as Comics', (format) => {
expect(getSeriesBookMediaGroupKey(makeBook(format))).toBe('comics')
expect(getSeriesBookMediaGroupKeys(makeBook(format))).toEqual(['comics'])
})
it('uses the primary file before secondary files', () => {
it('groups a book by every media kind it contains regardless of its primary file', () => {
const book = makeBook('epub', {
files: [
{ id: 1, format: 'mp3', role: 'secondary', sizeBytes: null },
@@ -60,31 +60,54 @@ describe('series book media groups', () => {
],
})
expect(getSeriesBookMediaGroupKey(book)).toBe('books')
expect(getSeriesBookMediaGroupKeys(book)).toEqual(['books', 'audiobooks', 'comics'])
})
it('uses the first formatted file when no primary file exists', () => {
it('does not add a book to the same group more than once', () => {
const book = makeBook(undefined, {
files: [
{ id: 1, format: null, role: 'secondary', sizeBytes: null },
{ id: 1, format: 'mp3', role: 'primary', sizeBytes: null },
{ id: 2, format: 'm4b', role: 'secondary', sizeBytes: null },
{ id: 3, format: ' M4A ', role: 'secondary', sizeBytes: null },
],
})
expect(getSeriesBookMediaGroupKey(book)).toBe('audiobooks')
expect(getSeriesBookMediaGroupKeys(book)).toEqual(['audiobooks'])
})
it('falls back to Books when files are missing', () => {
expect(getSeriesBookMediaGroupKey(makeBook(undefined))).toBe('books')
})
it.each([{ files: [] }, { files: [{ id: 1, format: null, role: 'primary', sizeBytes: null }] }])(
'falls back to Books when no file identifies a media kind',
({ files }) => {
expect(getSeriesBookMediaGroupKeys(makeBook(undefined, { files }))).toEqual(['books'])
},
)
it('returns ordered groups with matching books', () => {
const books = [makeBook('mp3', { id: 1 }), makeBook('cbz', { id: 2 }), makeBook('pdf', { id: 3 }), makeBook('epub', { id: 4 })]
it('returns ordered groups, includes mixed-media books in each match, and preserves input order', () => {
const books = [
makeBook('mp3', { id: 1 }),
makeBook('cbz', { id: 2 }),
makeBook('epub', {
id: 3,
files: [
{ id: 30, format: 'epub', role: 'primary', sizeBytes: null },
{ id: 31, format: 'm4b', role: 'secondary', sizeBytes: null },
],
}),
makeBook('pdf', {
id: 4,
files: [
{ id: 40, format: 'pdf', role: 'primary', sizeBytes: null },
{ id: 41, format: 'flac', role: 'secondary', sizeBytes: null },
{ id: 42, format: 'cbr', role: 'secondary', sizeBytes: null },
],
}),
makeBook(undefined, { id: 5 }),
]
expect(groupSeriesBooksByMedia(books).map((group) => [group.label, group.books.map((book) => book.id)])).toEqual([
['Books', [3, 4]],
['Audiobooks', [1]],
['Comics', [2]],
['Books', [3, 4, 5]],
['Audiobooks', [1, 3, 4]],
['Comics', [2, 4]],
])
})
})
@@ -14,18 +14,24 @@ export type SeriesBookMediaGroup = {
books: BookCard[]
}
export function getSeriesBookMediaGroupKey(book: BookCard): SeriesBookMediaGroupKey {
const primaryMediaKind = getBookMediaProfile(book.files).primaryMediaKind
if (primaryMediaKind === 'audiobook') return 'audiobooks'
if (primaryMediaKind === 'comic') return 'comics'
return 'books'
export function getSeriesBookMediaGroupKeys(book: BookCard): SeriesBookMediaGroupKey[] {
const profile = getBookMediaProfile(book.files)
const keys: SeriesBookMediaGroupKey[] = []
if (profile.hasEbook) keys.push('books')
if (profile.hasAudio) keys.push('audiobooks')
if (profile.hasComic) keys.push('comics')
return keys.length > 0 ? keys : ['books']
}
export function groupSeriesBooksByMedia(books: BookCard[]): SeriesBookMediaGroup[] {
const grouped = new Map<SeriesBookMediaGroupKey, BookCard[]>(SERIES_BOOK_MEDIA_GROUP_DEFS.map((group) => [group.key, []]))
for (const book of books) {
grouped.get(getSeriesBookMediaGroupKey(book))?.push(book)
for (const key of getSeriesBookMediaGroupKeys(book)) {
grouped.get(key)?.push(book)
}
}
return SERIES_BOOK_MEDIA_GROUP_DEFS.map((group) => ({
@@ -359,6 +359,25 @@ describe('SeriesDetailView', () => {
expect(comicsGroup.get('[data-testid="virtual-book-grid"]').attributes('data-book-ids')).toBe('10')
})
it('shows a mixed ebook and audiobook in both media sections', async () => {
mocks.items = ref([
makeBook({
id: 7,
files: [
{ id: 1, format: 'epub', role: 'primary', sizeBytes: null },
{ id: 2, format: 'm4b', role: 'content', sizeBytes: null },
],
}),
])
const wrapper = mountView()
await nextTick()
expect(wrapper.get('[data-testid="series-media-group-books"] [data-testid="virtual-book-grid"]').attributes('data-book-ids')).toBe('7')
expect(wrapper.get('[data-testid="series-media-group-audiobooks"] [data-testid="virtual-book-grid"]').attributes('data-book-ids')).toBe('7')
expect(wrapper.find('[data-testid="series-media-group-comics"]').exists()).toBe(false)
})
it('switches between grouped and plain grid rendering', async () => {
mocks.items = ref([
makeBook({ id: 7, files: [{ id: 1, format: 'epub', role: 'primary', sizeBytes: null }] }),