Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 61 additions & 22 deletions docs-developers/server/api/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -814,7 +815,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
Expand Down Expand Up @@ -842,7 +859,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
Expand Down Expand Up @@ -900,7 +918,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,
Expand Down Expand Up @@ -1197,8 +1218,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
Expand All @@ -1219,9 +1242,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.
Expand Down Expand Up @@ -1544,7 +1571,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
Expand Down Expand Up @@ -2258,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`

Expand Down Expand Up @@ -3624,7 +3655,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
Expand Down Expand Up @@ -3858,8 +3890,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) |
Expand Down Expand Up @@ -3974,9 +4006,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
Expand Down Expand Up @@ -4142,7 +4175,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
Expand Down Expand Up @@ -4267,7 +4300,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
Expand Down Expand Up @@ -4375,7 +4409,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
Expand Down Expand Up @@ -4750,8 +4786,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": {
Expand Down Expand Up @@ -4790,7 +4826,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
Expand Down
16 changes: 11 additions & 5 deletions docs-developers/server/data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading