mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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. */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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"],
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user