From 0b0398ed4efeb073d7c0d6d69bda26f8ec712202 Mon Sep 17 00:00:00 2001 From: KodeStar Date: Wed, 14 Oct 2026 10:07:49 +0100 Subject: [PATCH 1/2] Series ranges: numbering an omnibus, and the wire fields User Guide: how to number an omnibus or box set (a range such as 1-3 in Series number, or Omnibus #1-3 in Other series), what the Series page and the players show, and that the next book after it is the one after its end. Developer Docs: series_index_end and series_list[].position_end on the player's Book, the series_index and more_series rules for a range, the next book and rail placement with ranges, positions inside a range on /series, the export's series_position "1-3", the match run's range positions, and migration 0045. --- docs-developers/server/api/reference.md | 77 ++++++++++++++++++------- docs-developers/server/data-model.md | 16 +++-- docs-developers/server/web-ui.md | 7 ++- docs-users/admin/books.md | 26 ++++++++- docs-users/listening/browsing.md | 2 + docs-users/listening/end-of-book.md | 2 +- 6 files changed, 102 insertions(+), 28 deletions(-) diff --git a/docs-developers/server/api/reference.md b/docs-developers/server/api/reference.md index 6dc0bce..7e403dc 100644 --- a/docs-developers/server/api/reference.md +++ b/docs-developers/server/api/reference.md @@ -814,7 +814,23 @@ probed; `added_at` when unknown. Every book response (lists, search, recent, | `published` | string | the publication date, `YYYY`, `YYYY-MM` or `YYYY-MM-DD`: the effective value from an [admin edit](#patch-apiv1adminlibrariesidbook) or an accepted community match | | `cover_color` | object | the cover's palette, lowercase `#rrggbb`: `bg` is its dominant colour; `accent` a vibrant colour of the cover adjusted to a WCAG contrast of at least 4.5:1 against `bg`, and `on_accent` (`#ffffff` or `#000000`) the text colour on it. `accent` and `on_accent` are omitted together when the cover has no usable vibrant colour | | `cover_version` | string | an opaque token (10 characters) for the cover art. Append it to a cover URL as `v=` so a client cache refetches a replaced cover | -| `series_list` | array | every series the book is in, `[{"name", "position"}]` (position `0` = unnumbered): its main `series` first (at `series_index`), then the others an [admin edit](#patch-apiv1adminlibrariesidbook) or a community match added. Sent only for a book in more than one series; otherwise `series`/`series_index` say it all | +| `series_index_end` | number | the end of a main series position that is a **range** (an omnibus of books 1-3: `series_index` `1`, `series_index_end` `3`). `series_index` stays the start, so a client that doesn't know the field reads the omnibus as book 1. Omitted for a single position | +| `series_list` | array | every series the book is in, `[{"name", "position", "position_end"?}]` (position `0` = unnumbered; `position_end` only for a range, as `series_index_end`): its main `series` first (at `series_index`), then the others an [admin edit](#patch-apiv1adminlibrariesidbook) or a community match added. Sent only for a book in more than one series; otherwise `series`/`series_index` say it all | + +A book at a range, here as the omnibus of Mistborn 1-3 that is also book 1 of a +box-set series: + +```json +{ + "series": "Mistborn", + "series_index": 1, + "series_index_end": 3, + "series_list": [ + { "name": "Mistborn", "position": 1, "position_end": 3 }, + { "name": "Cosmere Box Sets", "position": 1 } + ] +} +``` `cover_version` is on every indexed book: a short hash of the book's cover art identity (`books.cover_art`). That identity starts from index data alone (a custom @@ -842,7 +858,8 @@ found, with any admin metadata edits layered on top. A book with a `series` and no position takes the one its title gives when the title is the series' name and then a number ("He Who Fights with Monsters 4: A LitRPG Adventure" is `series_index` 4); a number run on from the name, a range ("1-12") or one -followed by more words gives none. The shape is unchanged; a +followed by more words gives none. A range comes only from an edit, a community +match or a `series-part` tag such as `1-3` (see `series_index_end`). The shape is unchanged; a player simply sees the edited value. The same holds for every book-shaped response, search, the `/fs` annotations and the export. List responses omit `files`, `chapters`, and `direct_playable` (single-book @@ -900,7 +917,10 @@ scope. `author` is the most common author among the series' books; `positions` lists the distinct non-zero series positions held, ascending (so a client can mark the -gaps). Sorted case-insensitively by name; books with no series are not counted. +gaps). A book at a range holds its start, every whole number inside it and its +end (an omnibus of 1-3 adds `1, 2, 3`; one of 2.5-4 adds `2.5, 3, 4`), so an +older player shows no gaps inside an omnibus it has. Sorted case-insensitively +by name; books with no series are not counted. Same status codes as `/authors`. **One series, any spelling.** Series names that differ only in case, accents, @@ -1197,8 +1217,10 @@ Otherwise the local steps decide, in order, and the first one with an answer win ignoring case, accents, punctuation and spacing), then rails named like its other series in list order, then the rest as the envelope lists them. On each the server reads the entry after the current work on the rail's **main view** - (the smallest numeric position above the current one; unnumbered entries such - as an omnibus `1-3` are skipped), passing over any entry that comes at or + (the smallest numeric position above the current one; an entry at a range + such as an omnibus `1-3` counts by its start, and a current work at a range + by its end, so after the omnibus comes `4`; a single entry wins a tie with a + range starting at the same number; unnumbered entries are skipped), passing over any entry that comes at or before the current work on a rail ranked above it, so two orders of the same books (a chronological series and a publication-order one) can't step backwards or loop. The **first rail with a next entry decides**, and a later @@ -1219,9 +1241,13 @@ Otherwise the local steps decide, in order, and the first one with an answer win each, the candidate is the book of that series (in any spelling, as [`/series`](#get-apiv1librariesidseries) groups them) in the same library (by its main series or one of its other series, at its position in that - series) with the smallest higher position (ties by path), within the caller's - scope, passing over any book that comes at or before the current book in a - series ranked above in which both books are numbered. The first series with a + series) with the smallest higher position (ties: a single position before a + range starting there, then by path), within the caller's scope, passing over + any book that comes at or before the current book in a series ranked above in + which both books are numbered. A book at a range counts from its end (after an + omnibus of 1-3, the next is 4) and a candidate by its start (from book 2, an + omnibus of 1-3 is no step forward); a book inside the current omnibus in a + series ranked above is passed over. The first series with a later book that isn't passed over answers. When none has a later book but some other book in one of them is numbered, the answer is `{"source": "series"}`, the end of the series; when nothing else is numbered the step falls through. @@ -1544,7 +1570,10 @@ Expanse!`). Each entry gets at most one book, the first that applies: upstream call, so placement doesn't change with a restart); 3. in an alternate order, the book the main view placed for the same work; 4. a book of a series named like this view (the rail's or ordering's own name) - whose `series_index` equals the entry's numeric position. + whose `series_index` equals the entry's numeric position. An entry at a range + (`"1-3"`) takes a book at that same range (`series_index` 1, + `series_index_end` 3); an entry at one number takes a book at it, else a book + whose range starts there. A book known to be a particular work is placed by that identity only, never by its number, so a novella numbered like the next volume holds no slot. When two @@ -3624,7 +3653,8 @@ Per-book notes: two words. That keeps suffixed names such as `Alexandre Dumas, pere` and surname-first forms such as `Dumas, Alexandre` whole. - `series_position` is a string so half-positions survive: `2` for book two, - `2.5` for a novella between books. Omitted when there is no position. + `2.5` for a novella between books, `1-3` for an omnibus numbered as a range + (the metadata site's importer reads it as one). Omitted when there is no position. - `runtime_min` is the book's duration in whole minutes (rounded); `chapters` is the number of indexed chapters. Both are omitted when unknown. - `asin` / `isbn` come from the book's identifiers, including any attached via @@ -3858,8 +3888,8 @@ back to the scanned value, **revert** it instead. |---|---| | `title` | required (cannot be emptied), at most 500 characters, no control characters | | `author`, `narrator`, `series` | at most 500 characters, no control characters | -| `series_index` | a number from 0 to 100000 (`2`, `2.5`); stored in its shortest form, `""` for none | -| `more_series` | the other series the book is in beyond `series`: a JSON list of `{"name": string, "position": number}` (position 0 = none, else 0 to 100000), at most 20; names trimmed and checked like `series`, blank names dropped, each name kept once (the first). Stored as canonical JSON (`[{"name":"City Watch","position":1}]`), `""` for none. Only an edit or a community match sets it; tags give one series | +| `series_index` | a number from 0 to 100000 (`2`, `2.5`), or a **range** for an omnibus: two numbers apart by a hyphen or an en dash, spaces allowed (`1-3`, `1 - 3`, `1–3`), with 0 < start < end ≤ 100000 and end - start ≤ 100. Stored in its shortest form (`2.5`, `1-3`), `""` for none. Refused: `invalid_override` with the reason `must be a number from 0 to 100000, or a range like 1-3` | +| `more_series` | the other series the book is in beyond `series`: a JSON list of `{"name": string, "position": number, "position_end"?: number}` (position 0 = none, else 0 to 100000; `position_end` a range's end, by the `series_index` rules), at most 20; names trimmed and checked like `series`, blank names dropped, each name kept once (the first). Stored as canonical JSON (`[{"name":"City Watch","position":1}]`, `[{"name":"Box Sets","position":1,"position_end":3}]`), `""` for none. Only an edit or a community match sets it; tags give one series | | `published` | `YYYY`, `YYYY-MM` or `YYYY-MM-DD`, and a real date | | `description` | at most 20000 characters; line breaks and tabs kept, other control characters refused | | `asin` | 10 letters or digits (uppercased) | @@ -3974,9 +4004,10 @@ is a `400` too (`too many format or codec values`). are omitted when empty or `0`. `path` is the book path (the player's `rel_path`). - `custom_cover` - an admin uploaded a cover; `has_cover` includes it. -- `series_list` - every series the book is in, `[{"name", "position"}]`: its main +- `series_list` - every series the book is in, `[{"name", "position", "position_end"?}]`: its main `series` first (at `series_index`), then its `more_series`, each name once; `[]` - for none. + for none. `series_index_end` (omitted otherwise) and an entry's `position_end` + are a range's end, as on the player's `Book`. - `cover_color` - the colours read from the cover art, as on the player's `Book` (`bg`, plus `accent` and `on_accent` when the art has a vibrant colour that reads on it). Omitted until the server has read the current art's colour, from a @@ -4142,7 +4173,7 @@ authors. `author` is the most common author among the series' books; `positions` lists the distinct non-zero series positions held, ascending (so a client can mark the -gaps). A book counts in every series it is in, its main `series` and each of its +gaps; a range holds every whole number inside it, as on the player's `/series`). A book counts in every series it is in, its main `series` and each of its `more_series`, at its position there; `extra_books` is how many of `books` are in the series through `more_series`. Spellings of one series are one entry, named as on the [player's `/series`](#get-apiv1librariesidseries); the console loads each @@ -4267,7 +4298,8 @@ book path). Each has the effective `value`, its `source`, the `scanned` value (what the scan found - the revert target), `locked` (an admin edit holds it), and for an edit `edited_by` (the username; omitted once that account is deleted) and - `edited_at`. `series_index` is a string here (`"2.5"`, `""` for none). + `edited_at`. `series_index` is a string here (`"2.5"`, `"1-3"` for a range, + `""` for none). - `chapters` - every chapter with its effective `title`, the `scanned_title` and whether an edit renames it. - `files` - each audio file (a single-file book lists itself), with its own @@ -4375,7 +4407,9 @@ or revert `more_series` itself, the server swaps the two: the old main series, at its old `series_index`, takes that entry's place in `more_series` (a book with no main series just drops the entry, and another entry already naming the old main series gives way), and `series_index` becomes the entry's position (unless -the edit sets or reverts `series_index` itself). Reverting `series` swaps back +the edit sets or reverts `series_index` itself). A range moves with it both ways: +a main series at `1-3` becomes an entry with `position_end` 3, and an entry with +one becomes `series_index` `"1-3"`. Reverting `series` swaps back the same way when the series it returns to is listed. The swapped `more_series` and `series_index` are written as the edit's own overrides, so they read as edited and can be reverted like any other edit, except that a value equal to what the book has without an edit goes back to @@ -4750,8 +4784,8 @@ A page of the run's books in id order: `?outcome=auto|review|none|error` `overwrite`; a `refresh` run's `fill` and `overwrite` (it writes no identifier); a `repick` run's only `ids` (its ASIN, whatever the scope). When a scope changes the book's series, its change also carries `series`: the book's - series `from` (now) and `to` (after), each `[{ "name", "position" }]` with the - main series first (`position` 0 = none), so a series it removes is visible: + series `from` (now) and `to` (after), each `[{ "name", "position", "position_end"? }]` with the + main series first (`position` 0 = none; `position_end` a range's end), so a series it removes is visible: ```json "overwrite": { @@ -4790,7 +4824,10 @@ set). Only `overwrite` reverts, and only community values: a series an admin typed stays, and a revert never removes an edit made after the run looked (a [`PATCH …/book`](#patch-apiv1adminlibrariesidbook) revert sent with `source: "community"` removes only a community value). A series position is put back from -the files only beside the files' own series. A +the files only beside the files' own series. A community position that is a +range (an omnibus, `"1-3"`) is written as one: `series_index` `"1-3"`, or a +`more_series` entry's `position_end`; one the field refuses (a span over 100) is +no position. A retired work id is a redirect the server follows, so a merged record's series are the ones written. A repick writes its ASIN whatever the scope, and only while the book's ASIN is still the community override it found; a refresh takes only diff --git a/docs-developers/server/data-model.md b/docs-developers/server/data-model.md index f87b648..ed7ac2d 100644 --- a/docs-developers/server/data-model.md +++ b/docs-developers/server/data-model.md @@ -247,15 +247,18 @@ covered in [Auth & security](auth-and-security.md#authorization-shares--scope). `scan_error_file`, `scan_error_detail` and `suspect_parts` in 0017; `split_parent` in 0022; `cover_art` and `cover_color` in 0023; `chapters_source`, `chapters_fit`, `scanned_chapters` and `chapters_hash` in 0035; `released` and `released_checked` -in 0037; `series_key` in 0041)* - one row per book, +in 0037; `series_key` in 0041; `series_index_end` in 0045)* - one row per book, `UNIQUE (library_id, rel_path)`. Columns: `is_folder` (folder book vs single-file book), identity metadata (`title`, `author`, `series`, -`series_index`, `narrator`), `duration`, `asin`/`isbn` (optional external ids - +`series_index`, `narrator`; `series_index_end` is the end of a main series +position that is a range, an omnibus of books 1-3 at `series_index` 1 and +`series_index_end` 3, `0` for none: the `series_index` override holds the text +`"1-3"` and `refreshEffective` writes its start and end), `duration`, `asin`/`isbn` (optional external ids - present so enrichment/metadata services can attach data without reshaping the schema), `published` (`YYYY[-MM[-DD]]`) and `description` (only an admin edit or an accepted community match supplies these today; the scanner reads neither), `more_series` (the effective list of the other series the book is in beyond -`series`, as JSON `[{"name","position"}]`, `'[]'` for none: only an edit or a +`series`, as JSON `[{"name","position"}]` plus `"position_end"` for a range, `'[]'` for none: only an edit or a community match supplies it, an override like any other field; an admin edit that sets or reverts `series` to one of these swaps the two, writing the list and `series_index` as overrides (a value the scan already gives is reverted to @@ -412,8 +415,10 @@ other edits, never copied onto a joined book. **`book_more_series`** *(0041)* - a book's other series by key: one row per series in `more_series` that isn't its main one (`book_id` → `books` `ON DELETE -CASCADE`, `library_id`, `series_key`, `position`; primary key `(book_id, -series_key)`, `WITHOUT ROWID`, index `(series_key, library_id, position)`). +CASCADE`, `library_id`, `series_key`, `position`, `position_end` (0045: a +range's end, `0` for none); primary key `(book_id, series_key)`, `WITHOUT ROWID`, +index `(series_key, library_id, position, position_end)`, which covers the next +book's read of a member's place). Rewritten from the effective values by `refreshEffective` (`keyOtherSeries`), as `books.series_key` is, so a series filter with memberships, the series order and the next book find a book through its list by an index seek, and the rails' @@ -1032,6 +1037,7 @@ The migration history so far: | 0042 | `household_sharing` | `household_sharing`, each person's opt-in to Listening in the house (no row = off) | | 0043 | `cast_tokens` | `tokens.kind` `'cast'` (cast tokens for a Chromecast receiver) and `cast_grants` (each one's book and parent token), with `idx_cast_grants_parent` and the partial `idx_tokens_cast_expiry` | | 0044 | `household_members` | household members: `users.managed_by` (FK `ON DELETE SET NULL`), `users.member_kind` and `users.member_access` (each with a column CHECK), `users.can_add_members` (`DEFAULT 1`) and `users.display_name` (`DEFAULT ''`), with the partial `idx_users_managed_by`; `tokens.created_on_token_id` (FK `ON DELETE SET NULL`) with the partial `idx_tokens_created_on`. No data moves: every existing account reads as no member, allowed to add members | +| 0045 | `series_ranges` | `books.series_index_end` and `book_more_series.position_end` (`REAL NOT NULL DEFAULT 0`), the end of a series position that is a range (an omnibus, `1-3`); `idx_book_more_series_key` is rebuilt to cover `position_end`. No data moves: every existing row holds a single position | ## SQLite choices diff --git a/docs-developers/server/web-ui.md b/docs-developers/server/web-ui.md index 89831ec..e9cf2d9 100644 --- a/docs-developers/server/web-ui.md +++ b/docs-developers/server/web-ui.md @@ -365,7 +365,12 @@ CSP-sensitive work against a real build served by Go. The console's own gate is `createBatcher` as the covers) and places each book on the rail entry of its work, falling back to `series_index` for a book that doesn't resolve; a book known to be another work is drawn after the rail and fills no gap - (`placeBooks` in `features/library/series/series-model.ts`). Gaps wait for the + (`placeBooks` in `features/library/series/series-model.ts`). A position may be a + range (an omnibus, `series_index_end` / `position_end` on the book, `"1-3"` on a + rail entry): a book at a range holds every rail entry inside it, and a range + entry is a slot of its own only on a rail that numbers none of the books inside + it (`railEntries`, `holds`); the range grammar is `parseSeriesRange` in + `features/book/field-rules.ts`, mirroring the server's. Gaps wait for the works to answer (the card shows its plain "N books" line until then); a resolved or cleanly unmatched answer is kept for the session, a failed one is asked again no sooner than two minutes later. diff --git a/docs-users/admin/books.md b/docs-users/admin/books.md index 83dc673..7b66691 100644 --- a/docs-users/admin/books.md +++ b/docs-users/admin/books.md @@ -132,6 +132,23 @@ book's number after a `#` and the series apart by `;`, for example `Discworld: City Watch #1; Omnibus`. Matching with community metadata fills it in for a book in several series. +#### Numbering an omnibus or box set + +A book that holds several books of a series (an omnibus of books 1 to 3, say) +takes a range as its **Series number**: type `1-3`. Spaces around the dash, or an +en dash (`1–3`), are fine too. The first number must be above 0, the second above +the first, and the two at most 100 apart. In **Other series**, write the range +after the `#` the same way: `Box Sets #2-4`. + +The console then shows the book as `#1-3`, and on the +[Series](#series) page the book fills every place from 1 to 3, so they aren't +listed as missing. In the players, an up-to-date app shows it as `#1-3` and +places it on books 1 to 3 of the series page, and the next book after it is +book 4, not book 2. An older app shows it as book 1. Matching with community +metadata writes a range too, when the community catalogue lists the book that +way. A file whose series tag holds a range (a `series-part` of `1-3`) is read as +one, but a range in a file or folder name isn't, so type it in here. + Click any value to edit it in place. Enter keeps the change (in **Description**, where Enter starts a new line, use Ctrl+Enter or ⌘+Enter), Escape cancels. @@ -483,7 +500,9 @@ Adventure", "The Expanse, Book 4" and "Discworld #4". A title where the number runs straight on from the name ("Catch-22"), reads as a range ("Dork Diaries 1-12") or is followed by more words ("Jack Reacher 61 Hours") gives no number. The book's page shows that number as coming from its file tags (or its folders, when the -title came from them), and your own **Series number** always wins. +title came from them), and your own **Series number** always wins. For an +omnibus, type the range into **Series number** yourself (see +[Numbering an omnibus or box set](#numbering-an-omnibus-or-box-set)). Each spine takes its colours from the book's cover, and a longer book stands taller. AudioSilo reads the cover colours in the background after it indexes @@ -524,6 +543,11 @@ towards "You have ..." instead of being listed as missing. A matched book the ca different book from any in the series (a companion story filed under the series name, for example) sits at the end of the shelf and doesn't fill a gap. +An omnibus fills every place it covers: a book numbered `1-3`, or matched to a +community entry such as "Books 1-3", counts towards "You have 1-3 ..." and its +spine is labelled `1-3`. When the community series lists only the omnibus and +not the books inside it, the omnibus is one place of its own ("missing 1-3"). + While a card is still looking its books up, it shows just the number of books ("5 books") with no gaps, so it never flashes a gap that then disappears. A book the community catalogue couldn't answer for is placed by its series number diff --git a/docs-users/listening/browsing.md b/docs-users/listening/browsing.md index 454af72..64a3050 100644 --- a/docs-users/listening/browsing.md +++ b/docs-users/listening/browsing.md @@ -185,6 +185,8 @@ A series page is the series laid out like a bookcase: The full bookcase, with ghost spines and reading orders, comes from the [community metadata database](../community/meta-site.md) and needs your server to have the lookup switched on. Without it, the page shows the books you have in that series, with a dashed **Book 3** placeholder wherever your copies skip a number. +An omnibus your server numbers as a range (books **1-3**, say; see [Numbering an omnibus](../admin/books.md#numbering-an-omnibus-or-box-set)) shows as `#1-3` and fills the places of the books inside it, so they don't show as missing. An older app shows it as book 1 instead. + ## A book's page Tap a book anywhere to open its page: where you are in it, **Resume** (or **Start listening**), the download, Up next, favourite and collection buttons, About and other versions, and tabs for the chapters, your bookmarks, history and notes, the book's files, and the community's recaps, characters and series. It has a page of its own: [A book's page](book-page.md). diff --git a/docs-users/listening/end-of-book.md b/docs-users/listening/end-of-book.md index a96f93e..a0adb60 100644 --- a/docs-users/listening/end-of-book.md +++ b/docs-users/listening/end-of-book.md @@ -25,7 +25,7 @@ You can open the end credits early from the full player's [three-dot menu](full- AudioSilo picks the next book the same way everywhere: 1. The **first book in your [Up next](up-next.md) queue**, skipping any you've finished. -2. Else the **next book in the series**: your server works it out from the community's reading order, then the series numbers, then the next book in the same folder. A book in more than one series (Guards! Guards! is Discworld book 8 and City Watch book 1) follows its main series first, then its other series, so when you have no later Discworld book, the next City Watch book you have plays instead (as long as your server numbers that book in City Watch). +2. Else the **next book in the series**: your server works it out from the community's reading order, then the series numbers, then the next book in the same folder. A book in more than one series (Guards! Guards! is Discworld book 8 and City Watch book 1) follows its main series first, then its other series, so when you have no later Discworld book, the next City Watch book you have plays instead (as long as your server numbers that book in City Watch). After an omnibus numbered as a range (books 1-3), the next book is book 4; and from book 2, the next is book 3, never the omnibus that starts at book 1. 3. On an older server, the **next book in the same folder** (in natural order, so *Book 2* comes before *Book 10*). When it starts, it leaves Up next - it's the book you're on now. From 07ec302f503d1380b52a09b07e247a425caf10ed Mon Sep 17 00:00:00 2001 From: KodeStar Date: Wed, 14 Oct 2026 11:11:05 +0100 Subject: [PATCH 2/2] API reference: favourites and folder entries carry series_index_end --- docs-developers/server/api/reference.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs-developers/server/api/reference.md b/docs-developers/server/api/reference.md index 7e403dc..bb1c5dc 100644 --- a/docs-developers/server/api/reference.md +++ b/docs-developers/server/api/reference.md @@ -745,7 +745,8 @@ with indexed-book metadata attached where available. Offset-paginated. ``` `next_offset` is present when more entries remain. The book annotation fields -(`is_book`, `title`, `author`, `series`, `series_index`, `duration`) are +(`is_book`, `title`, `author`, `series`, `series_index`, `series_index_end` for a +range as on a book, `duration`) are omitted for plain directories/files; `override` (`"book"` or `"collection"`) appears when an explicit folder-detection override is set (admin concern - see [Scanner](../scanner.md)). `split_discs: true` marks a folder whose audio is @@ -2287,7 +2288,8 @@ first, enriched from the index where a book exists at the path: ``` A favourite may also be a plain navigation folder - then `is_book` is `false` -and the book fields are empty (render it by its path leaf). +and the book fields are empty (render it by its path leaf). A book at a range +position also carries `series_index_end`, as on a [book](#get-apiv1librariesidbooks). ### `POST /api/v1/libraries/{id}/favourites` · `DELETE /api/v1/libraries/{id}/favourites`