feat: request an ebook and audiobook as a pair

Link two ordinary requests as companions, filed into one book, with a Both search,
grouped list rows, a tabbed release picker and a book-page request for the missing
format. Retire comic requests, keeping old rows readable. File Book Dock covers by the
field rules with a shape check, and re-pick a moved book's primary by its new library.
This commit is contained in:
neon
2026-10-08 16:12:30 +00:00
parent 01b5638ea7
commit 39ac2f1efd
102 changed files with 28539 additions and 518 deletions
+6 -2
View File
@@ -14,7 +14,12 @@
/** Bumped when a change to this contract would break a plugin written against the old one. */
export const PLUGIN_API_VERSION = 1;
export type PluginMediaKind = "ebook" | "audiobook" | "comic";
/**
* What BookOrbit asks a plugin to search for. Comics are no longer requested: a plugin written
* before they were retired may still declare `comic` and its categories, and the loader accepts
* and ignores them, so nothing has to change for that plugin to keep working.
*/
export type PluginMediaKind = "ebook" | "audiobook";
/** Distinct enough to act on: a rejected credential must not look like "nothing found". */
export type PluginSearchFailure = "unauthorized" | "throttled" | "timeout" | "unreachable" | "unsupportedMedium" | "error";
@@ -25,7 +30,6 @@ export type PluginCredentialKind = "apiKey" | "sessionId" | null;
export interface PluginCategoryMap {
ebook: number[];
audiobook: number[];
comic: number[];
}
/** A signed update feed controlled by the plugin publisher. */
+9 -1
View File
@@ -5,7 +5,15 @@
*
* Named apart from `APP_FEATURES` in app-features.ts, which holds the build-time feature flags.
*/
export const SERVER_FEATURES = ["annotation-stars", "annotation-color-names", "annotation-trash", "book-journal", "bookmark-edit", "notebook-hub"] as const;
export const SERVER_FEATURES = [
"annotation-stars",
"annotation-color-names",
"annotation-trash",
"book-journal",
"bookmark-edit",
"notebook-hub",
"book-request-pairs",
] as const;
export type ServerFeature = (typeof SERVER_FEATURES)[number];
@@ -27,7 +27,7 @@ export const BOOK_REQUEST_AUTOMATION_SETTING_KEYS = {
* The instance default destination for one medium, as two flat rows rather than a JSON blob, so
* the destinations follow the same one-row-per-knob rule as the settings above.
*
* Derived from the medium rather than spelled out three times: `BOOK_REQUEST_MEDIA_KINDS` is what
* Derived from the medium rather than spelled out per medium: `BOOK_REQUEST_MEDIA_KINDS` is what
* decides how many there are, and a medium added later should not need six more literals here.
*/
export function bookRequestDefaultLibraryKey(mediaKind: BookRequestMediaKind): string {
+77 -4
View File
@@ -1,8 +1,22 @@
import { CONCRETE_BOOK_MEDIA_KINDS, type ConcreteBookMediaKind } from "./book";
import type { BookRequestDownloadItem } from "./download-client";
export const BOOK_REQUEST_MEDIA_KINDS = CONCRETE_BOOK_MEDIA_KINDS;
export type BookRequestMediaKind = ConcreteBookMediaKind;
/** What a book can be requested as: an ebook or an audiobook, alone or as a pair. */
export const BOOK_REQUEST_MEDIA_KINDS = ["ebook", "audiobook"] as const;
export type BookRequestMediaKind = (typeof BOOK_REQUEST_MEDIA_KINDS)[number];
/**
* A medium requests no longer offer, still carried by requests made before it was retired. Those
* rows keep loading, and can be cancelled, hidden or deleted; nothing creates one any more, and
* nothing fetches a release for one.
*/
export type LegacyBookRequestMediaKind = "comic";
/** Every medium a stored request can carry, including the retired one. */
export type StoredBookRequestMediaKind = BookRequestMediaKind | LegacyBookRequestMediaKind;
export function isBookRequestMediaKind(value: string): value is BookRequestMediaKind {
return (BOOK_REQUEST_MEDIA_KINDS as readonly string[]).includes(value);
}
/** Bounds exact indexer passes before the title-and-author fallback. */
export const MAX_BOOK_REQUEST_SEARCH_ISBNS = 8;
@@ -289,6 +303,10 @@ export const BOOK_REQUEST_SUBMIT_ERROR_CODES = [
"SUBMIT_ON_BEHALF_FORBIDDEN",
/** The named requester does not exist, or is not an account that could file this itself. */
"SUBMIT_ON_BEHALF_UNKNOWN_USER",
/** The book a request was asked to join is gone, or sits in a library the requester cannot reach. */
"SUBMIT_TARGET_BOOK_NOT_FOUND",
/** The book a request was asked to join already has the requested medium. Carries `mediaKind`. */
"SUBMIT_TARGET_BOOK_HAS_MEDIUM",
] as const;
export type BookRequestSubmitErrorCode = (typeof BOOK_REQUEST_SUBMIT_ERROR_CODES)[number];
@@ -402,7 +420,8 @@ export interface BookRequestItem extends BookRequestWorkSnapshot {
userId: number;
requesterUsername: string;
requesterName: string;
mediaKind: BookRequestMediaKind;
/** A retired medium only on a request made before it was retired; see `LegacyBookRequestMediaKind`. */
mediaKind: StoredBookRequestMediaKind;
status: BookRequestStatus;
preferredFormats: string[];
note: string | null;
@@ -438,10 +457,31 @@ export interface BookRequestItem extends BookRequestWorkSnapshot {
download: BookRequestDownloadItem | null;
/** Whether the signed-in user has hidden this from their own list. Never shared between users. */
dismissed: boolean;
/**
* The other half of an ebook and audiobook pair. Set on both halves, so either one names the
* other; null on every request made on its own.
*/
companionRequestId: number | null;
/**
* The book this request's files join when they arrive, rather than becoming a book of their own.
* Set when a missing format was requested from a book's page, and on the second half of a pair
* once the first half has landed.
*/
targetBookId: number | null;
/**
* The companion itself, present only on a list asked for with `groupPairs`, and only when the
* caller can see it. A pair is then one entry, led by its ebook half.
*/
companion?: BookRequestItem | null;
createdAt: string;
updatedAt: string;
}
/** The medium on the other side of a pair. */
export function bookRequestCompanionKind(mediaKind: BookRequestMediaKind): BookRequestMediaKind {
return mediaKind === "ebook" ? "audiobook" : "ebook";
}
export interface BookRequestPage {
items: BookRequestItem[];
total: number;
@@ -471,6 +511,39 @@ export interface CreateBookRequestPayload extends Partial<BookRequestWorkSnapsho
* ordinary case: the caller is the requester.
*/
userId?: number;
/**
* The book the arriving files should join, for a format that book is missing. Advertised by the
* `book-request-pairs` server feature.
*/
targetBookId?: number;
}
/** What one half of a pair may set for itself; everything else about the work is shared. */
export interface BookRequestPairHalfPayload {
targetLibraryId?: number | null;
targetFolderId?: number | null;
preferredFormats?: string[];
/** The cover this half shows, when it differs from the pair's: audiobook art is square. */
coverUrl?: string | null;
}
/**
* An ebook and an audiobook of one work, asked for together. Each half is submitted under the same
* rules a single request is, so either one may join a request that already exists or arrive
* already available, and the two are linked whichever way each one went.
*/
export interface CreateBookRequestPairPayload extends Partial<BookRequestWorkSnapshot> {
title: string;
note?: string | null;
selfServe?: boolean;
userId?: number;
ebook?: BookRequestPairHalfPayload;
audiobook?: BookRequestPairHalfPayload;
}
export interface BookRequestPairSubmitResult {
ebook: BookRequestSubmitResult;
audiobook: BookRequestSubmitResult;
}
/**
+5 -5
View File
@@ -49,12 +49,12 @@ export type IndexerCategoryMap = Record<BookRequestMediaKind, number[]>;
/**
* Starting points, not constraints. Torznab follows the Newznab category numbering (7020 ebooks,
* 7030 comics, 3030 audiobooks). A source with no categories at all declares an empty map, and
* 3030 audiobooks). A source with no categories at all declares an empty map, and
* `INDEXER_USES_CATEGORIES` hides the editor rather than showing boxes that do nothing.
*/
export const DEFAULT_INDEXER_CATEGORIES: Record<IndexerAdapterType, IndexerCategoryMap> = {
torznab: { ebook: [7020], audiobook: [3030], comic: [7030] },
newznab: { ebook: [7020], audiobook: [3030], comic: [7030] },
torznab: { ebook: [7020], audiobook: [3030] },
newznab: { ebook: [7020], audiobook: [3030] },
};
/** What the credential field holds. Null where the source needs none, as an open library does. */
@@ -93,8 +93,8 @@ export const INDEXER_DELIVERY: Record<IndexerAdapterType, DownloadDelivery> = {
* as such in the picker rather than contributing an empty result that reads as "not available".
*/
export const INDEXER_MEDIA_KINDS: Record<IndexerAdapterType, readonly BookRequestMediaKind[]> = {
torznab: ["ebook", "audiobook", "comic"],
newznab: ["ebook", "audiobook", "comic"],
torznab: ["ebook", "audiobook"],
newznab: ["ebook", "audiobook"],
};
/**