feat: Group multi-volume series into collapsible library stacks
Some checks are pending
CI / check (push) Waiting to run
CI / Lockfile change (push) Waiting to run
CI / Homebrew cask (push) Waiting to run

Extracted series metadata from EPUB files and remote sync providers to
fold multi-volume series into interactive stacks on the main shelf.
Added a dedicated series view so readers could browse ordered volumes
without cluttering the main library screen.

Signed-off-by: Chmouel Boudjnah <chmouel@chmouel.com>
This commit is contained in:
Chmouel Boudjnah 2026-09-01 08:36:48 +02:00
parent 4dafa0b6a0
commit 0a00ccb27b
27 changed files with 805 additions and 46 deletions

View file

@ -53,6 +53,12 @@ Library UX conventions carried over from Android:
a paper-and-leather surface with a faint cover wash in light mode, stronger
cover artwork in dark mode, spanning the same shelf width as the book row
- Book card: cover + badges (download/server/finished) + title + author
- Series with two or more volumes collapse into a stack in the same grid;
stacks file alongside standalone books and open to their ordered,
virtualized volume grid. A stack is keyed by its normalized name so local
and server copies remain together. liseur-sync memberships are preserved
in full: a book that belongs to multiple series appears in each relevant
stack.
The settings panel belongs to the application shell rather than to the
library, and paints over whichever screen is showing. Anything scoped to one

View file

@ -1,5 +1,10 @@
import { createSignal, For, onCleanup, Show, type JSX } from 'solid-js'
import type { LibraryFilter, LibrarySortKey } from '@shared/domain/types'
import { createEffect, createSignal, For, on, onCleanup, Show, type JSX } from 'solid-js'
import type {
LibraryEntry,
LibraryFilter,
LibrarySeriesEntry,
LibrarySortKey,
} from '@shared/domain/types'
import { useLibraryStore } from './store'
import { VirtualBookGrid } from './VirtualBookGrid'
import { ContinueReading } from './ContinueReading'
@ -29,10 +34,25 @@ export function LibraryScreen(props: { onOpenBook: (bookId: string) => void }):
const [statsOpen, setStatsOpen] = createSignal(false)
const [sortMenuOpen, setSortMenuOpen] = createSignal(false)
const [helpOpen, setHelpOpen] = createSignal(false)
const [openSeries, setOpenSeries] = createSignal<LibrarySeriesEntry | null>(null)
let searchInput: HTMLInputElement | undefined
let gridEl: HTMLDivElement | undefined
const vim = createVimSession<LibraryCommand>(LIBRARY_BINDINGS)
// Keep an open stack live when sync replaces one of its volumes. The store
// updates entries incrementally, so this does not need another SQL query.
createEffect(
on(
() => store.entries(),
(entries) => {
const current = openSeries()
if (!current) return
const fresh = entries.find((entry) => entry.kind === 'series' && entry.id === current.id)
if (fresh?.kind === 'series') setOpenSeries(fresh)
},
),
)
// Chips a library has nothing to say with are left off rather than shown
// inert: with no server every book is downloaded, and with nothing put
// away the archive drawer is empty.
@ -94,6 +114,12 @@ export function LibraryScreen(props: { onOpenBook: (bookId: string) => void }):
if (e.key === 'Escape' || (vimMode() && e.key === 'q')) setStatsOpen(false)
return
}
if (openSeries() && e.key === 'Escape') {
e.preventDefault()
setOpenSeries(null)
setSelectedIndex(-1)
return
}
if (sortMenuOpen() && e.key === 'Escape') {
setSortMenuOpen(false)
return
@ -115,7 +141,7 @@ export function LibraryScreen(props: { onOpenBook: (bookId: string) => void }):
return
}
const count = store.books().length
const count = visibleEntries().length
if (count === 0) return
switch (e.key) {
@ -132,7 +158,7 @@ export function LibraryScreen(props: { onOpenBook: (bookId: string) => void }):
setSelectedIndex((i) => Math.max(0, i - gridColumns()))
break
case 'Enter':
if (selectedIndex() >= 0) openBook(selectedIndex())
if (selectedIndex() >= 0) openEntry(selectedIndex())
break
default:
return
@ -152,7 +178,7 @@ export function LibraryScreen(props: { onOpenBook: (bookId: string) => void }):
/** Moves the selection, landing on the first book when nothing is picked. */
const moveSelection = (delta: number) => {
const count = store.books().length
const count = visibleEntries().length
if (count === 0) return
setSelectedIndex((i) => (i < 0 ? 0 : Math.min(count - 1, Math.max(0, i + delta))))
}
@ -166,7 +192,7 @@ export function LibraryScreen(props: { onOpenBook: (bookId: string) => void }):
}
function runVimCommand(command: LibraryCommand, count: number | null): void {
const books = store.books()
const entries = visibleEntries()
switch (command) {
case 'left':
times(count, () => moveSelection(-1))
@ -186,17 +212,19 @@ export function LibraryScreen(props: { onOpenBook: (bookId: string) => void }):
case 'rowEnd':
if (selectedIndex() >= 0) {
setSelectedIndex((i) =>
Math.min(books.length - 1, i - (i % gridColumns()) + gridColumns() - 1),
Math.min(entries.length - 1, i - (i % gridColumns()) + gridColumns() - 1),
)
}
break
case 'first':
if (books.length > 0) setSelectedIndex(0)
if (entries.length > 0) setSelectedIndex(0)
break
case 'last':
// `12G` is the twelfth book, as `12G` is the twelfth line.
if (books.length > 0) {
setSelectedIndex(count === null ? books.length - 1 : Math.min(books.length, count) - 1)
if (entries.length > 0) {
setSelectedIndex(
count === null ? entries.length - 1 : Math.min(entries.length, count) - 1,
)
}
break
case 'halfPageDown':
@ -206,7 +234,7 @@ export function LibraryScreen(props: { onOpenBook: (bookId: string) => void }):
moveSelection(-gridColumns() * Math.max(1, Math.floor(gridRows() / 2)))
break
case 'open':
if (selectedIndex() >= 0) openBook(selectedIndex())
if (selectedIndex() >= 0) openEntry(selectedIndex())
break
case 'continueReading': {
const book = store.continueReadingBook()
@ -248,16 +276,31 @@ export function LibraryScreen(props: { onOpenBook: (bookId: string) => void }):
setHelpOpen(true)
break
case 'closeOverlay':
if (sortMenuOpen()) setSortMenuOpen(false)
if (openSeries()) {
setOpenSeries(null)
setSelectedIndex(-1)
} else if (sortMenuOpen()) setSortMenuOpen(false)
else if (searchOpen()) closeSearch()
else setSelectedIndex(-1)
break
}
}
const openBook = (index: number) => {
const book = store.books()[index]
if (!book) return
const visibleEntries = (): LibraryEntry[] =>
openSeries()
? openSeries()!.books.map((book) => ({ kind: 'book', id: `book:${book.id}`, book }))
: store.entries()
const openEntry = (index: number) => {
const entry = visibleEntries()[index]
if (!entry) return
if (entry.kind === 'series') {
setOpenSeries(entry)
setSelectedIndex(-1)
gridEl?.scrollTo({ top: 0 })
return
}
const book = entry.book
// Nothing to open until a book has a file; remote books fetch on open.
if (!book.localPath && !book.remoteId) return
props.onOpenBook(book.id)
@ -451,21 +494,35 @@ export function LibraryScreen(props: { onOpenBook: (bookId: string) => void }):
</div>
</div>
<Show when={openSeries()}>
{(series) => (
<div class="series-header">
<button type="button" class="series-back" onClick={() => setOpenSeries(null)}>
← Library
</button>
<div>
<h1>{series().name}</h1>
<p>{series().books.length} books</p>
</div>
</div>
)}
</Show>
<main class="library-main">
<Show when={!store.loading() && store.books().length === 0}>
<Show when={!store.loading() && visibleEntries().length === 0}>
<p class="empty-state">
{store.searchText() ? `No books match “${store.searchText()}”.` : 'No books here yet.'}
</p>
</Show>
<VirtualBookGrid
books={store.books}
entries={visibleEntries}
selectedIndex={selectedIndex}
onSelect={setSelectedIndex}
onOpen={openBook}
onOpen={(_entry, index) => openEntry(index)}
gridRef={(el) => (gridEl = el)}
header={
<Show when={store.filter() === 'all' && !store.searchText()}>
<Show when={!openSeries() && store.filter() === 'all' && !store.searchText()}>
<ContinueReading
book={store.continueReadingBook()}
onOpen={() => {

View file

@ -1,5 +1,5 @@
import { createSignal, createEffect, onCleanup, onMount, For, type JSX } from 'solid-js'
import type { Book } from '@shared/domain/types'
import type { LibraryEntry } from '@shared/domain/types'
import { coverFor, requestRemoteCover } from './covers'
import {
computeColumns,
@ -23,10 +23,10 @@ const GAP = GRID_GAP
const ROW_HEIGHT = GRID_ROW_HEIGHT
interface Props {
books: () => Book[]
entries: () => LibraryEntry[]
selectedIndex: () => number
onSelect: (index: number) => void
onOpen: (index: number) => void
onOpen: (entry: LibraryEntry, index: number) => void
gridRef?: (el: HTMLDivElement) => void
/** Optional content that scrolls away above the grid (e.g. the
continue-reading banner). Its height is measured, so the row math
@ -62,7 +62,7 @@ export function VirtualBookGrid(props: Props): JSX.Element {
const range = () =>
computeRange(
props.books().length,
props.entries().length,
columns(),
ROW_HEIGHT,
scrollTop(),
@ -93,7 +93,7 @@ export function VirtualBookGrid(props: Props): JSX.Element {
}
})
const visible = () => props.books().slice(range().start, range().end)
const visible = () => props.entries().slice(range().start, range().end)
return (
<div
@ -105,7 +105,7 @@ export function VirtualBookGrid(props: Props): JSX.Element {
onScroll={onScroll}
role="grid"
aria-label="Library"
aria-rowcount={Math.ceil(props.books().length / columns())}
aria-rowcount={Math.ceil(props.entries().length / columns())}
>
{/* Always in the tree so one observer registration at mount is
enough: when the banner is gated off, the wrapper measures 0. */}
@ -127,8 +127,11 @@ export function VirtualBookGrid(props: Props): JSX.Element {
}}
>
<For each={visible()}>
{(book, localIndex) => {
{(entry, localIndex) => {
const index = () => range().start + localIndex()
const book = entry.kind === 'series' ? entry.cover : entry.book
const title = entry.kind === 'series' ? entry.name : book.title
const authors = entry.kind === 'series' ? entry.authors : book.authors
// Books that live on a server arrive with no cover art. Ask
// for it as the card is mounted, which virtualization already
// means is roughly "as it comes into view".
@ -137,16 +140,23 @@ export function VirtualBookGrid(props: Props): JSX.Element {
<button
type="button"
class="book-card"
classList={{ selected: props.selectedIndex() === index() }}
classList={{
selected: props.selectedIndex() === index(),
'series-card': entry.kind === 'series',
}}
// One click opens. Selection still moves so the arrow keys
// carry on from wherever the last click landed.
onClick={() => {
props.onSelect(index())
props.onOpen(index())
props.onOpen(entry, index())
}}
role="gridcell"
aria-selected={props.selectedIndex() === index()}
aria-label={`${book.title} by ${book.authors.join(', ')}`}
aria-label={
entry.kind === 'series'
? `${entry.name}, ${entry.books.length} books`
: `${book.title} by ${authors.join(', ')}`
}
>
<div class="book-cover-wrap">
<img
@ -159,6 +169,9 @@ export function VirtualBookGrid(props: Props): JSX.Element {
width={CARD_WIDTH}
height={GRID_COVER_HEIGHT}
/>
{entry.kind === 'series' && (
<span class="series-count">{entry.books.length}</span>
)}
{book.finished && <span class="badge badge-finished">Finished</span>}
{book.downloaded && (
<span class="badge badge-downloaded" title="Downloaded">
@ -171,8 +184,10 @@ export function VirtualBookGrid(props: Props): JSX.Element {
</span>
)}
</div>
<span class="book-title">{book.title}</span>
<span class="book-author">{book.authors.join(', ')}</span>
<span class="book-title">{title}</span>
<span class="book-author">
{entry.kind === 'series' ? `${entry.books.length} books` : authors.join(', ')}
</span>
</button>
)
}}

View file

@ -1,6 +1,7 @@
import { createSignal } from 'solid-js'
import type {
Book,
LibraryEntry,
LibraryFilter,
LibraryQueryResult,
LibrarySortKey,
@ -29,6 +30,7 @@ const [sort, setSortSignal] = createSignal<LibrarySortKey>(DEFAULT_QUERY.sort)
const [direction, setDirectionSignal] = createSignal<SortDirection>(DEFAULT_QUERY.direction)
const [books, setBooks] = createSignal<Book[]>([])
const [entries, setEntries] = createSignal<LibraryEntry[]>([])
const [totalCount, setTotalCount] = createSignal(0)
const [archivedCount, setArchivedCount] = createSignal(0)
const [hasServer, setHasServer] = createSignal(false)
@ -45,6 +47,7 @@ export function useLibraryStore() {
sort,
direction,
books,
entries,
totalCount,
archivedCount,
hasServer,
@ -92,6 +95,7 @@ async function refresh(): Promise<void> {
const result: LibraryQueryResult = await window.liseur.library.query(buildQuery())
if (myGeneration !== generation) return // stale — a newer query is in flight
setBooks(result.books)
setEntries(result.entries)
setTotalCount(result.totalCount)
setArchivedCount(result.archivedCount)
setLoading(false)
@ -112,6 +116,27 @@ async function refreshContinueReading(): Promise<void> {
let bookAddedDebounce: ReturnType<typeof setTimeout> | undefined
function replaceBookInEntries(current: LibraryEntry[], book: Book): LibraryEntry[] {
return current.map((entry) => {
if (entry.kind === 'book') {
return entry.book.id === book.id ? { ...entry, book } : entry
}
const index = entry.books.findIndex((volume) => volume.id === book.id)
if (index === -1) return entry
const books = entry.books.slice()
books[index] = book
const cover =
books.find((volume) => {
const progression = volume.progress?.progression ?? 0
return !volume.finished && progression > 0 && progression < 1
}) ??
books.find((volume) => !volume.finished) ??
books[0] ??
entry.cover
return { ...entry, books, cover }
})
}
export function initLibrary(): void {
void refresh()
void refreshContinueReading()
@ -127,6 +152,7 @@ export function initLibrary(): void {
next[index] = book
return next
})
setEntries((current) => replaceBookInEntries(current, book))
// The banner holds its own copy of the book. Closing the reader shows
// the library before the final position is saved (visual first), so the
// save lands here — without this the banner kept showing the position

View file

@ -305,6 +305,75 @@
bottom: 28px;
}
/* A stack keeps the familiar book silhouette, then earns its own small
count marker. The detail still uses the normal virtualized grid. */
.series-card .book-cover-wrap::before,
.series-card .book-cover-wrap::after {
content: '';
position: absolute;
inset: 5px -5px -5px 5px;
z-index: -1;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--bg-elevated);
}
.series-card .book-cover-wrap::after {
inset: 9px -9px -9px 9px;
z-index: -2;
}
.series-count {
position: absolute;
right: 6px;
bottom: 6px;
min-width: 22px;
padding: 2px 6px;
border-radius: 999px;
background: var(--accent);
color: var(--on-accent);
font-size: 12px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.series-header {
display: flex;
align-items: center;
gap: 14px;
padding: 10px 20px;
border-bottom: 1px solid var(--border);
}
.series-header h1,
.series-header p {
margin: 0;
}
.series-header h1 {
font-family: Georgia, serif;
font-size: 22px;
}
.series-header p {
color: var(--text-muted);
font-size: 13px;
}
.series-back {
border: none;
border-radius: 6px;
background: transparent;
color: var(--accent);
cursor: pointer;
font: inherit;
padding: 6px 8px;
}
.series-back:hover {
background: var(--bg-sunken);
}
/* --- Settings overlay (M7) --- */
.settings-overlay {

View file

@ -0,0 +1,21 @@
import type { SeriesMembership } from './types'
/** Parses the untrusted series payload used by sync and the library database. */
export function parseSeriesMemberships(value: unknown): SeriesMembership[] {
if (!Array.isArray(value)) return []
return value.flatMap((membership) => {
if (!membership || typeof membership !== 'object') return []
const raw = membership as { id?: unknown; name?: unknown; position?: unknown }
const name = typeof raw.name === 'string' ? raw.name.trim() : ''
if (!name) return []
const position =
typeof raw.position === 'number' && Number.isFinite(raw.position) ? raw.position : undefined
return [
{
...(typeof raw.id === 'string' && raw.id ? { id: raw.id } : {}),
name,
...(position !== undefined ? { position } : {}),
},
]
})
}

View file

@ -33,6 +33,15 @@ export interface ReadingProgress {
updatedAt: number
}
/** One series a book belongs to. A book may be in more than one. */
export interface SeriesMembership {
/** A server's stable id, retained for future series actions. */
id?: string | undefined
name: string
/** The volume number when the source knows it. */
position?: number | undefined
}
export interface Book {
id: BookId
title: string
@ -49,6 +58,29 @@ export interface Book {
addedAt: number
lastOpenedAt?: number
progress?: ReadingProgress
/** Effective series memberships from the file or catalog. */
series?: SeriesMembership[]
}
/** One card on the library shelf: either a book or a stack of volumes. */
export type LibraryEntry = LibraryBookEntry | LibrarySeriesEntry
export interface LibraryBookEntry {
kind: 'book'
id: string
book: Book
}
export interface LibrarySeriesEntry {
kind: 'series'
/** Stable while the series name remains the same. */
id: string
name: string
authors: string[]
/** Ordered volumes, including server-only books. */
books: Book[]
/** The cover for the next unread volume, or the first volume. */
cover: Book
}
export type LibraryFilter = 'all' | 'downloaded' | 'unread' | 'archived'
@ -66,6 +98,8 @@ export interface LibraryQuery {
export interface LibraryQueryResult {
books: Book[]
/** Grouped shelf entries, prepared by the worker so the renderer stays responsive. */
entries: LibraryEntry[]
totalCount: number
/**
* How many books are archived, whatever the filter asked for. The library

View file

@ -275,4 +275,13 @@ export const MIGRATIONS: readonly Migration[] = [
`)
},
},
{
version: 10,
name: 'book series memberships',
up: (db) => {
// A JSON array preserves liseur-sync's many memberships without
// flattening a book into a single, arbitrary series.
db.exec("ALTER TABLE books ADD COLUMN series TEXT NOT NULL DEFAULT '[]';")
},
},
]

View file

@ -15,6 +15,8 @@ export interface EpubMetadata {
authors: string[]
/** dc:identifier — ISBN or UUID when present; used for dedupe. */
identifier?: string | undefined
/** Calibre and EPUB 3 collection metadata, when the file supplies it. */
series?: { name: string; position?: number | undefined } | undefined
}
export interface EpubCover {
@ -89,6 +91,17 @@ function parseOpf(xml: string, opfDir: string): Opf {
const manifest = new Map<string, ManifestItem>()
const spine: { idref: string; linear: boolean }[] = []
let metaCoverId: string | undefined
let calibreSeries: string | undefined
let calibreSeriesPosition: number | undefined
let epubSeries: { id?: string; name?: string; position?: number | undefined } | undefined
const epubSeriesPositions = new Map<string, number>()
let collectingSeriesMeta:
| {
property: 'belongs-to-collection' | 'group-position'
id?: string | undefined
refines?: string | undefined
}
| undefined
// Depth stack so we only pick up text of the element we're inside.
const stack: string[] = []
@ -112,6 +125,21 @@ function parseOpf(xml: string, opfDir: string): Opf {
if (el.attributes['name'] === 'cover' && el.attributes['content']) {
metaCoverId = el.attributes['content']
}
if (el.attributes['name'] === 'calibre:series' && el.attributes['content']) {
calibreSeries = el.attributes['content'].trim() || undefined
}
if (el.attributes['name'] === 'calibre:series_index' && el.attributes['content']) {
const position = Number(el.attributes['content'])
calibreSeriesPosition = Number.isFinite(position) ? position : undefined
}
const property = el.attributes['property']
if (property === 'belongs-to-collection') {
collectingSeriesMeta = { property, id: el.attributes['id'] }
collector = textCollector()
} else if (property === 'group-position' && el.attributes['refines']) {
collectingSeriesMeta = { property, refines: el.attributes['refines'].replace(/^#/, '') }
collector = textCollector()
}
} else if (el.localName === 'item') {
const id = el.attributes['id']
const href = el.attributes['href']
@ -132,7 +160,27 @@ function parseOpf(xml: string, opfDir: string): Opf {
onText: (text) => collector?.onText(text),
onEnd: (_name, localName) => {
stack.pop()
if (
if (collector && collectingSeriesMeta && localName === 'meta') {
const value = collector.value()
const meta = collectingSeriesMeta
collector = undefined
collectingSeriesMeta = undefined
if (meta.property === 'belongs-to-collection') {
epubSeries = {
...(meta.id ? { id: meta.id } : {}),
name: value,
...(meta.id && epubSeriesPositions.has(meta.id)
? { position: epubSeriesPositions.get(meta.id) }
: {}),
}
} else {
const position = Number(value)
if (Number.isFinite(position) && meta.refines) {
epubSeriesPositions.set(meta.refines, position)
if (epubSeries?.id === meta.refines) epubSeries.position = position
}
}
} else if (
collector &&
(localName === 'title' || localName === 'creator' || localName === 'identifier')
) {
@ -175,6 +223,18 @@ function parseOpf(xml: string, opfDir: string): Opf {
title: titles[0],
authors: creators,
identifier,
...(calibreSeries || epubSeries?.name
? {
series: {
name: calibreSeries ?? epubSeries!.name!,
...(calibreSeriesPosition !== undefined
? { position: calibreSeriesPosition }
: epubSeries?.position !== undefined
? { position: epubSeries.position }
: {}),
},
}
: {}),
},
cover,
manifest,

View file

@ -1,5 +1,11 @@
import type { DatabaseSync } from 'node:sqlite'
import type { Book, LibraryQuery, ReadingProgress } from '../../shared/domain/types'
import type {
Book,
LibraryQuery,
ReadingProgress,
SeriesMembership,
} from '../../shared/domain/types'
import { parseSeriesMemberships } from '../../shared/domain/series'
/**
* Book repository — all SQL touching the library tables lives here.
@ -41,12 +47,13 @@ interface BookRow {
locator: string | null
progression: number | null
progress_updated_at: number | null
series: string
}
const BOOK_SELECT = `
SELECT b.id, b.title, b.authors, b.local_path, b.remote_id, b.server_id, b.cover_id,
b.finished, b.archived, b.downloaded, b.added_at, b.last_opened_at,
p.locator, p.progression, p.updated_at AS progress_updated_at
p.locator, p.progression, p.updated_at AS progress_updated_at, b.series
FROM books b
LEFT JOIN reading_progress p ON p.book_id = b.id
`
@ -95,6 +102,13 @@ export function rowToBook(row: BookRow): Book {
if (row.server_id !== null) book.serverId = row.server_id
if (row.cover_id !== null) book.coverId = row.cover_id
if (row.last_opened_at !== null) book.lastOpenedAt = row.last_opened_at
let series: SeriesMembership[] = []
try {
series = parseSeriesMemberships(JSON.parse(row.series))
} catch {
// A malformed legacy row should not make the whole library unreadable.
}
if (series.length > 0) book.series = series
if (row.locator !== null && row.progress_updated_at !== null) {
const progress: ReadingProgress = {
locator: JSON.parse(row.locator) as ReadingProgress['locator'],
@ -114,9 +128,13 @@ export class BookRepository {
const params: string[] = []
const term = query.search.trim()
if (term) {
sql += ` AND (b.title LIKE ? ESCAPE '\\' OR b.authors LIKE ? ESCAPE '\\')`
sql += ` AND (b.title LIKE ? ESCAPE '\\' OR b.authors LIKE ? ESCAPE '\\'
OR EXISTS (
SELECT 1 FROM json_each(b.series) AS series_membership
WHERE json_extract(series_membership.value, '$.name') LIKE ? ESCAPE '\\'
))`
const pattern = likePattern(term)
params.push(pattern, pattern)
params.push(pattern, pattern, pattern)
}
sql += ` ${orderBy(query)}`
const rows = this.db.prepare(sql).all(...params) as unknown as BookRow[]
@ -308,8 +326,8 @@ export class BookRepository {
const insertBook = this.db.prepare(`
INSERT INTO books (id, folder_id, title, authors, local_path, remote_id, cover_id,
finished, archived, downloaded, added_at, last_opened_at,
file_hash, epub_identifier, file_mtime, file_size)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
file_hash, epub_identifier, file_mtime, file_size, series)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
const insertProgress = this.db.prepare(`
INSERT INTO reading_progress (book_id, locator, progression, updated_at)
@ -336,6 +354,7 @@ export class BookRepository {
extra?.epubIdentifier ?? null,
extra?.fileMtime ?? null,
extra?.fileSize ?? null,
JSON.stringify(book.series ?? []),
)
if (book.progress) {
insertProgress.run(

View file

@ -169,6 +169,11 @@ export function generateFakeLibrary(size = FAKE_LIBRARY_SIZE, seed = FAKE_LIBRAR
addedAt,
}
// A stable pair makes the grouped-shelf behavior observable in the
// production-build browser test without changing how real libraries are
// seeded or requiring an extra test-only IPC operation.
if (i < 2) book.series = [{ name: 'The Test Sequence', position: i + 1 }]
if (rng() < 0.5) {
book.lastOpenedAt = now - Math.floor(rng() * 60) * day
}

View file

@ -0,0 +1,165 @@
import type {
Book,
LibraryEntry,
LibraryQuery,
LibrarySeriesEntry,
} from '../../shared/domain/types'
/**
* Folds the shelf exactly as Android does: a series belongs under its
* normalized display name, not a server id, so a downloaded copy and its
* catalog sibling do not become two stacks. A repeated title is the rare
* trade-off for keeping the common local-plus-server case together.
*/
function sortKey(value: string): string {
const folded = value
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.trim()
.toLocaleLowerCase()
for (const elision of ["l'", 'l’', "d'", 'd’']) {
if (folded.startsWith(elision) && folded.slice(elision.length).trim()) {
return folded.slice(elision.length).trimStart()
}
}
const article = /^(?:the|a|an|le|la|les|un|une|des|du|de)\s+/.exec(folded)
return article ? folded.slice(article[0].length) : folded
}
function seriesKey(name: string): string {
return sortKey(name)
}
function membershipFor(book: Book, key: string) {
return book.series?.find((membership) => seriesKey(membership.name) === key)
}
function commonest(values: readonly string[]): string {
const counts = new Map<string, number>()
for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1)
return values.reduce((best, value) =>
(counts.get(value) ?? 0) > (counts.get(best) ?? 0) ? value : best,
)
}
function volumeOrder(key: string, left: Book, right: Book): number {
const leftPosition = membershipFor(left, key)?.position
const rightPosition = membershipFor(right, key)?.position
const leftKnown = leftPosition !== undefined
const rightKnown = rightPosition !== undefined
if (leftKnown !== rightKnown) return leftKnown ? -1 : 1
if (leftPosition !== rightPosition) return (leftPosition ?? 0) - (rightPosition ?? 0)
return sortKey(left.title).localeCompare(sortKey(right.title)) || left.id.localeCompare(right.id)
}
function seriesEntries(books: readonly Book[]): LibrarySeriesEntry[] {
const bySeries = new Map<string, Book[]>()
for (const book of books) {
for (const membership of book.series ?? []) {
const key = seriesKey(membership.name)
if (!key) continue
const volumes = bySeries.get(key)
if (volumes) volumes.push(book)
else bySeries.set(key, [book])
}
}
return [...bySeries.entries()].flatMap(([key, volumes]) => {
// A series of one stays a normal book card. Opening a page that contains
// exactly the card it replaced adds friction without helping discovery.
if (volumes.length < 2) return []
const ordered = [...volumes].sort((left, right) => volumeOrder(key, left, right))
const cover =
ordered.find((book) => {
const progression = book.progress?.progression ?? 0
return !book.finished && progression > 0 && progression < 1
}) ??
ordered.find((book) => !book.finished) ??
ordered[0]
if (!cover) return []
const names = volumes.flatMap((book) =>
(book.series ?? [])
.filter((membership) => seriesKey(membership.name) === key)
.map((membership) => membership.name),
)
const authors = volumes.flatMap((book) => book.authors).filter(Boolean)
return [
{
kind: 'series' as const,
id: `series:${key}`,
name: commonest(names),
authors: authors.length > 0 ? [commonest(authors)] : [],
books: ordered,
cover,
},
]
})
}
function recentAt(book: Book): number {
return Math.max(book.lastOpenedAt ?? 0, book.progress?.updatedAt ?? 0, book.addedAt)
}
function compareEntries(query: LibraryQuery, left: LibraryEntry, right: LibraryEntry): number {
const title = (entry: LibraryEntry) => (entry.kind === 'series' ? entry.name : entry.book.title)
const authors = (entry: LibraryEntry) =>
entry.kind === 'series' ? entry.authors : entry.book.authors
const books = (entry: LibraryEntry) => (entry.kind === 'series' ? entry.books : [entry.book])
const direction = query.direction === 'asc' ? 1 : -1
let compared = 0
switch (query.sort) {
case 'recent':
compared = Math.max(...books(left).map(recentAt)) - Math.max(...books(right).map(recentAt))
break
case 'added':
compared =
Math.max(...books(left).map((book) => book.addedAt)) -
Math.max(...books(right).map((book) => book.addedAt))
break
case 'author': {
const leftAuthor = authors(left)[0]
const rightAuthor = authors(right)[0]
if (!leftAuthor || !rightAuthor) compared = leftAuthor ? -1 : rightAuthor ? 1 : 0
else compared = sortKey(leftAuthor).localeCompare(sortKey(rightAuthor))
break
}
case 'title':
compared = sortKey(title(left)).localeCompare(sortKey(title(right)))
break
}
compared *= direction
return (
compared ||
sortKey(title(left)).localeCompare(sortKey(title(right))) ||
left.id.localeCompare(right.id)
)
}
/**
* Produces the cards for one query. `visible` is the search result; `books`
* is the filter result before search, so a stack stays complete when one of
* its volumes matches the query.
*/
export function groupedLibraryEntries(
books: readonly Book[],
visible: readonly Book[],
query: LibraryQuery,
): LibraryEntry[] {
const stacks = seriesEntries(books)
const visibleIds = new Set(visible.map((book) => book.id))
const shownStacks = stacks.filter((stack) => stack.books.some((book) => visibleIds.has(book.id)))
const stackedSeries = new Set(
shownStacks.flatMap((stack) =>
stack.books.flatMap((book) =>
(book.series ?? []).map((membership) => seriesKey(membership.name)),
),
),
)
const singles: LibraryEntry[] = visible
.filter(
(book) =>
!(book.series ?? []).some((membership) => stackedSeries.has(seriesKey(membership.name))),
)
.map((book) => ({ kind: 'book', id: `book:${book.id}`, book }))
return [...shownStacks, ...singles].sort((left, right) => compareEntries(query, left, right))
}

View file

@ -136,6 +136,7 @@ export class IngestionService {
archived: false,
downloaded: true, // a local file is by definition on disk
addedAt: Date.now(),
...(metadata.series ? { series: [metadata.series] } : {}),
}
if (coverId) book.coverId = coverId

View file

@ -1,6 +1,7 @@
import type { DatabaseSync } from 'node:sqlite'
import type { Book, LibraryQuery, LibraryQueryResult, Locator } from '../../shared/domain/types'
import { BookRepository } from './book-repository'
import { groupedLibraryEntries } from './grouping'
/**
* Library query engine — worker side.
@ -18,8 +19,12 @@ export class LibraryService {
query(query: LibraryQuery, requestId: number): LibraryQueryResult {
const books = this.repository.query(query)
const groupingBooks = query.search.trim()
? this.repository.query({ ...query, search: '' })
: books
return {
books,
entries: groupedLibraryEntries(groupingBooks, books, query),
totalCount: books.length,
archivedCount: this.repository.countArchived(),
requestId,

View file

@ -22,10 +22,18 @@ const MAX_PAGES = 200
interface KomgaBookDto {
id: string
name?: string
seriesTitle?: string
seriesId?: string
oneshot?: boolean
sizeBytes?: number
lastModified?: string
media?: { pagesCount?: number }
metadata?: { title?: string; authors?: { name: string; role?: string }[] }
metadata?: {
title?: string
authors?: { name: string; role?: string }[]
numberSort?: number
number?: string
}
readProgress?: { page?: number; completed?: boolean; readDate?: string; deviceId?: string }
}
@ -40,6 +48,14 @@ function parseBook(dto: KomgaBookDto): RemoteBook {
.map((a) => a.name)
.filter(Boolean)
const pages = dto.media?.pagesCount
const seriesName = dto.oneshot ? undefined : dto.seriesTitle?.trim() || undefined
const number = Number(dto.metadata?.number)
const position =
typeof dto.metadata?.numberSort === 'number' && Number.isFinite(dto.metadata.numberSort)
? dto.metadata.numberSort
: Number.isFinite(number)
? number
: undefined
let progress: RemoteBook['progress']
if (dto.readProgress && pages && dto.readProgress.page !== undefined) {
progress = {
@ -60,6 +76,17 @@ function parseBook(dto: KomgaBookDto): RemoteBook {
downloadUrl: `/api/v1/books/${dto.id}/file`,
coverUrl: `/api/v1/books/${dto.id}/thumbnail`,
progress,
...(seriesName
? {
series: [
{
...(dto.seriesId ? { id: dto.seriesId } : {}),
name: seriesName,
...(position !== undefined ? { position } : {}),
},
],
}
: {}),
}
}

View file

@ -1,5 +1,6 @@
import { createHash } from 'node:crypto'
import type { Locator } from '../../shared/domain/types'
import { parseSeriesMemberships } from '../../shared/domain/series'
import { Http, type HttpResponse, type HttpResult } from './http'
import type {
ProgressRecord,
@ -72,6 +73,7 @@ interface CatalogBookPayload {
contributors?: unknown
size_bytes?: unknown
cover_url?: unknown
series?: unknown
}
function catalogBook(payload: CatalogBookPayload): RemoteBook | null {
@ -100,6 +102,7 @@ function catalogBook(payload: CatalogBookPayload): RemoteBook | null {
: undefined
const coverUrl =
typeof payload.cover_url === 'string' && payload.cover_url ? payload.cover_url : undefined
const series = parseSeriesMemberships(payload.series)
return {
remoteId,
title,
@ -107,6 +110,7 @@ function catalogBook(payload: CatalogBookPayload): RemoteBook | null {
sizeBytes,
downloadUrl: `/v1/books/${encodeURIComponent(remoteId)}/download`,
...(coverUrl ? { coverUrl } : {}),
...(series.length > 0 ? { series } : {}),
}
}

View file

@ -162,11 +162,12 @@ export class SyncRepository {
// reconstructable later).
this.db
.prepare(
'UPDATE books SET title = ?, authors = ?, download_url = ?, cover_url = ? WHERE id = ?',
'UPDATE books SET title = ?, authors = ?, series = ?, download_url = ?, cover_url = ? WHERE id = ?',
)
.run(
remote.title,
JSON.stringify(remote.authors),
JSON.stringify(remote.series ?? []),
remote.downloadUrl,
remote.coverUrl ?? null,
existing.id,
@ -181,8 +182,8 @@ export class SyncRepository {
.prepare(
`INSERT INTO books (id, folder_id, title, authors, local_path, remote_id, cover_id,
finished, archived, downloaded, added_at, server_id,
download_url, cover_url)
VALUES (?, NULL, ?, ?, NULL, ?, NULL, 0, 0, 0, ?, ?, ?, ?)`,
download_url, cover_url, series)
VALUES (?, NULL, ?, ?, NULL, ?, NULL, 0, 0, 0, ?, ?, ?, ?, ?)`,
)
.run(
id,
@ -193,6 +194,7 @@ export class SyncRepository {
serverId,
remote.downloadUrl,
remote.coverUrl ?? null,
JSON.stringify(remote.series ?? []),
)
const book = new BookRepository(this.db).getById(id)!
return { book, added: true }

View file

@ -1,4 +1,4 @@
import type { Locator } from '../../shared/domain/types'
import type { Locator, SeriesMembership } from '../../shared/domain/types'
/**
* Remote catalog & sync capability model (M7).
@ -42,6 +42,8 @@ export interface RemoteBook {
coverUrl?: string | undefined
/** Server-reported read progress when available (Komga). */
progress?: ProgressRecord | undefined
/** All effective series memberships the catalog reports. */
series?: SeriesMembership[] | undefined
}
export interface TestResult {

View file

@ -48,6 +48,35 @@ test('launches and renders the library shell', async () => {
await expect(page.locator('.book-card').first()).toBeVisible()
})
test('opens a grouped series stack and returns to the shelf', async () => {
await page.getByRole('button', { name: 'Search' }).click()
await page.getByRole('searchbox', { name: 'Search books' }).fill('test sequence')
const stack = page.locator('.series-card')
await expect(stack).toHaveCount(1)
await expect(stack).toHaveAccessibleName('The Test Sequence, 2 books')
await stack.click()
await expect(page.locator('.series-header h1')).toHaveText('The Test Sequence')
await expect(page.locator('.book-card')).toHaveCount(2)
// Statistics is above the series view, so Escape dismisses it first.
await page.getByRole('button', { name: 'Reading statistics' }).click()
await expect(page.locator('.stats-panel')).toBeVisible()
await page.keyboard.press('Escape')
await expect(page.locator('.stats-panel')).toHaveCount(0)
await expect(page.locator('.series-header')).toBeVisible()
await page.keyboard.press('Escape')
await expect(page.locator('.series-header')).toHaveCount(0)
await expect(page.locator('.series-card')).toHaveCount(1)
// Tests share the shelf: restore the unfiltered view before the next one
// asks it to scroll thousands of rows.
await page.getByRole('searchbox', { name: 'Search books' }).fill('')
await expect.poll(() => page.locator('.book-card').count()).toBeGreaterThan(2)
await page.keyboard.press('Escape')
})
test('the top bar carries the brand tile and the size of the shelf', async () => {
// The reading-scene art, matching the Android app's top bar. It must
// actually decode: a broken import still lays out as an <img>.

View file

@ -139,6 +139,16 @@ test.describe('vim mode on the shelf', () => {
await page.keyboard.press('Escape')
await page.keyboard.press('Escape')
await expect(search).toHaveCount(0)
// q closes the open series view as well as the other shelf overlays.
await page.keyboard.press('/')
await page.keyboard.type('test sequence')
const stack = page.locator('.series-card')
await expect(stack).toHaveCount(1)
await stack.click()
await expect(page.locator('.series-header')).toBeVisible()
await page.keyboard.press('q')
await expect(page.locator('.series-header')).toHaveCount(0)
})
})

View file

@ -86,6 +86,12 @@ export interface FixtureEpubOptions {
noCover?: boolean
/** Deflate the large entries to exercise the inflate path. */
deflate?: boolean
/** Calibre's widely used series metadata. */
series?: { name: string; position?: number }
/** EPUB 3 collection metadata used by non-Calibre publishers. */
epubSeries?: { name: string; position?: number }
/** Put group-position before belongs-to-collection to exercise OPF ordering. */
epubSeriesPositionFirst?: boolean
}
export function buildEpub(options: FixtureEpubOptions = {}): Buffer {
@ -96,9 +102,20 @@ export function buildEpub(options: FixtureEpubOptions = {}): Buffer {
epub2Cover = false,
noCover = false,
deflate = false,
series,
epubSeries,
epubSeriesPositionFirst = false,
} = options
const coverMeta = epub2Cover ? '<meta name="cover" content="cover-img"/>' : ''
const seriesMeta = series
? `<meta name="calibre:series" content="${series.name}"/>\n <meta name="calibre:series_index" content="${series.position ?? ''}"/>`
: ''
const epubSeriesMeta = epubSeries
? epubSeriesPositionFirst
? `<meta refines="#series" property="group-position">${epubSeries.position ?? ''}</meta>\n <meta id="series" property="belongs-to-collection">${epubSeries.name}</meta>`
: `<meta id="series" property="belongs-to-collection">${epubSeries.name}</meta>\n <meta refines="#series" property="group-position">${epubSeries.position ?? ''}</meta>`
: ''
const coverProps = epub2Cover ? '' : ' properties="cover-image"'
const coverItem = noCover
? ''
@ -112,6 +129,8 @@ export function buildEpub(options: FixtureEpubOptions = {}): Buffer {
<dc:identifier id="bookid">${identifier}</dc:identifier>
<dc:language>en</dc:language>
${coverMeta}
${seriesMeta}
${epubSeriesMeta}
</metadata>
<manifest>
<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>

View file

@ -22,6 +22,30 @@ describe('EpubFile', () => {
expect(meta.cover?.entryPath).toBe('OEBPS/images/cover.png')
})
it('reads Calibre series metadata', () => {
const meta = new EpubFile(
buildEpub({ series: { name: 'The Expanse', position: 2 } }),
).metadata()
expect(meta.series).toEqual({ name: 'The Expanse', position: 2 })
})
it('reads EPUB 3 collection metadata', () => {
const meta = new EpubFile(
buildEpub({ epubSeries: { name: 'The Expanse', position: 2 } }),
).metadata()
expect(meta.series).toEqual({ name: 'The Expanse', position: 2 })
})
it('reads EPUB 3 group position before the collection metadata', () => {
const meta = new EpubFile(
buildEpub({
epubSeries: { name: 'The Expanse', position: 2 },
epubSeriesPositionFirst: true,
}),
).metadata()
expect(meta.series).toEqual({ name: 'The Expanse', position: 2 })
})
it('reads deflated archives', () => {
const epub = new EpubFile(buildEpub({ deflate: true, title: 'Deflated' }))
expect(epub.metadata().title).toBe('Deflated')

106
tests/unit/grouping.test.ts Normal file
View file

@ -0,0 +1,106 @@
import { describe, expect, it } from 'vitest'
import type { Book, LibraryQuery } from '../../src/shared/domain/types'
import { groupedLibraryEntries } from '../../src/worker/library/grouping'
const query: LibraryQuery = { filter: 'all', sort: 'title', direction: 'asc', search: '' }
function book(partial: Partial<Book> & { id: string; title: string }): Book {
return {
authors: ['Author'],
finished: false,
archived: false,
downloaded: true,
addedAt: 1,
...partial,
}
}
describe('groupedLibraryEntries', () => {
it('folds two volumes into one stack ordered by their position', () => {
const one = book({
id: 'one',
title: 'Leviathan Wakes',
series: [{ name: 'The Expanse', position: 1 }],
})
const two = book({
id: 'two',
title: "Caliban's War",
series: [{ name: 'The Expanse', position: 2 }],
})
const entries = groupedLibraryEntries([one, two], [one, two], query)
expect(entries).toHaveLength(1)
expect(entries[0]).toMatchObject({ kind: 'series', name: 'The Expanse' })
expect(entries[0]?.kind === 'series' && entries[0].books.map((volume) => volume.id)).toEqual([
'one',
'two',
])
})
it('keeps a complete stack when one volume matches a search', () => {
const one = book({ id: 'one', title: 'Leviathan Wakes', series: [{ name: 'The Expanse' }] })
const two = book({ id: 'two', title: "Caliban's War", series: [{ name: 'The Expanse' }] })
const entries = groupedLibraryEntries([one, two], [one], { ...query, search: 'leviathan' })
expect(entries).toHaveLength(1)
expect(entries[0]?.kind === 'series' && entries[0].books).toEqual([two, one])
})
it('retains every liseur-sync membership instead of choosing the first', () => {
const shared = book({
id: 'shared',
title: 'Shared volume',
series: [
{ name: 'Main sequence', position: 1 },
{ name: 'Companions', position: 1 },
],
})
const main = book({
id: 'main',
title: 'Main two',
series: [{ name: 'Main sequence', position: 2 }],
})
const companion = book({
id: 'companion',
title: 'Companion two',
series: [{ name: 'Companions', position: 2 }],
})
const entries = groupedLibraryEntries(
[shared, main, companion],
[shared, main, companion],
query,
)
expect(entries.map((entry) => entry.kind === 'series' && entry.name)).toEqual([
'Companions',
'Main sequence',
])
})
it('groups case, accent, and leading-article variants together', () => {
const one = book({ id: 'one', title: 'One', series: [{ name: "L'Étranger", position: 1 }] })
const two = book({ id: 'two', title: 'Two', series: [{ name: 'etranger', position: 2 }] })
expect(groupedLibraryEntries([one, two], [one, two], query)).toHaveLength(1)
})
it('sorts recent and added entries in the requested direction', () => {
const old = book({ id: 'old', title: 'Old', addedAt: 1, lastOpenedAt: 10 })
const recent = book({ id: 'recent', title: 'Recent', addedAt: 2, lastOpenedAt: 20 })
expect(
groupedLibraryEntries([old, recent], [old, recent], {
...query,
sort: 'recent',
direction: 'desc',
}).map((entry) => entry.id),
).toEqual(['book:recent', 'book:old'])
expect(
groupedLibraryEntries([old, recent], [old, recent], {
...query,
sort: 'added',
direction: 'asc',
}).map((entry) => entry.id),
).toEqual(['book:old', 'book:recent'])
})
})

View file

@ -75,6 +75,23 @@ describe('IngestionService.ingestFile', () => {
expect((outcome as { status: 'added'; book: Book }).book.title).toBe('fallback-name')
})
it('persists the series from a Calibre EPUB', async () => {
const path = writeEpub('volume.epub', { series: { name: 'The Expanse', position: 2 } })
const outcome = await service().ingestFile(path)
expect(outcome.status).toBe('added')
expect((outcome as { status: 'added'; book: Book }).book.series).toEqual([
{ name: 'The Expanse', position: 2 },
])
expect(
new BookRepository(db).query({
filter: 'all',
sort: 'title',
direction: 'asc',
search: '',
})[0]?.series,
).toEqual([{ name: 'The Expanse', position: 2 }])
})
it('skips the exact same file content (duplicate hash)', async () => {
const a = writeEpub('a.epub')
const b = join(booksDir, 'b.epub')

View file

@ -97,6 +97,14 @@ describe('LibraryService.query', () => {
expect(query({ search: 'LEA' }).books.map((b) => b.id)).toEqual(['c', 'a'])
})
it('searches series names without matching JSON field names', () => {
new BookRepository(db).insertBooks([
book({ id: 'series', title: 'A Book', series: [{ name: 'The Expanse', position: 1 }] }),
])
expect(query({ search: 'expanse' }).books.map((b) => b.id)).toEqual(['series'])
expect(query({ search: 'position' }).books).toEqual([])
})
it('treats LIKE wildcards in search as literal characters', () => {
expect(query({ search: '%' }).books).toEqual([])
expect(query({ search: '_' }).books).toEqual([])

View file

@ -63,15 +63,27 @@ export function mockKomga(
{
id: 'book-1',
name: 'First Remote',
seriesTitle: 'Remote sequence',
seriesId: 'remote-sequence',
sizeBytes: 1000,
metadata: { title: 'First Remote', authors: [{ name: 'Remote Author', role: 'writer' }] },
metadata: {
title: 'First Remote',
numberSort: 1,
authors: [{ name: 'Remote Author', role: 'writer' }],
},
media: { pagesCount: 10 },
},
{
id: 'book-2',
name: 'Second Remote',
seriesTitle: 'Remote sequence',
seriesId: 'remote-sequence',
sizeBytes: 2000,
metadata: { title: 'Second Remote', authors: [{ name: 'Other Author' }] },
metadata: {
title: 'Second Remote',
numberSort: 2,
authors: [{ name: 'Other Author' }],
},
media: { pagesCount: 20 },
},
]

View file

@ -220,6 +220,7 @@ describe('SyncService against mock Komga', () => {
const shell = repo.findByRemoteId(server.id, 'book-1')
expect(shell).toBeDefined()
expect(shell?.localPath).toBeUndefined()
expect(shell?.series).toEqual([{ id: 'remote-sequence', name: 'Remote sequence', position: 1 }])
const downloaded = await service.downloadBook(shell!.id)
expect(downloaded?.localPath).toContain('downloads')
@ -1098,6 +1099,7 @@ describe('SyncService against mock Komga', () => {
{ name: 'A Writer', role: 'author' },
{ name: 'An Editor', role: 'editor' },
],
series: [{ id: 'expanse', name: 'The Expanse', position: 1, source: 'folder' }],
size_bytes: 11,
cover_url: '/v1/books/book-1/cover',
},
@ -1142,7 +1144,12 @@ describe('SyncService against mock Komga', () => {
const repo = new SyncRepository(db)
const first = repo.findByRemoteId(server.id, 'book-1')
expect(first).toMatchObject({ title: 'First book', authors: ['A Writer'], downloaded: false })
expect(first).toMatchObject({
title: 'First book',
authors: ['A Writer'],
series: [{ id: 'expanse', name: 'The Expanse', position: 1 }],
downloaded: false,
})
expect(repo.findByRemoteId(server.id, 'book-2')).toBeDefined()
expect(repo.findByRemoteId(server.id, 'book-3')).toBeDefined()
expect(requests).toContain('/v1/folders?limit=200')