feat(book-requests): add automated request fulfillment

Let users request books, route approvals, search configured sources, and import fulfilled releases through the Book Dock. Include scoped permissions, automation, download-client integrations, plugin support, requester visibility, and one consolidated schema migration.

Closes #248
Ref #1115
This commit is contained in:
neon
2026-08-31 16:33:40 +00:00
parent 0f5429131a
commit fc6e6f03d5
443 changed files with 87915 additions and 1730 deletions
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@bookorbit/plugin-api",
"version": "1.0.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"devDependencies": {
"typescript": "^6.0.3"
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch --preserveWatchOutput"
}
}
+224
View File
@@ -0,0 +1,224 @@
/**
* The contract between BookOrbit and an indexer plugin loaded from disk.
*
* This package exists so that a plugin maintained in another repository can typecheck against a
* versioned surface instead of against BookOrbit's internals. Nothing here is tracker-specific,
* and BookOrbit ships no plugin of its own: the loader is the extension point, the plugins are
* somebody else's.
*
* Deliberately dependency-free and free of runtime classes. A plugin is imported at runtime and
* therefore gets its own copy of anything it imports, so `instanceof` across the boundary cannot
* be relied on; every failure travels through `host.fail` instead.
*/
/** 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";
/** Distinct enough to act on: a rejected credential must not look like "nothing found". */
export type PluginSearchFailure = "unauthorized" | "throttled" | "timeout" | "unreachable" | "unsupportedMedium" | "error";
/** What an indexer's credential field holds, or null where the source needs no account. */
export type PluginCredentialKind = "apiKey" | "sessionId" | null;
export interface PluginCategoryMap {
ebook: number[];
audiobook: number[];
comic: number[];
}
/** What a request is looking for, already normalised by BookOrbit. */
export interface PluginReleaseQuery {
title: string;
author: string | null;
isbn13: string | null;
/** Compatibility alias containing either the active ISBN or no values. */
isbn13s: string[];
mediaKind: PluginMediaKind;
/** The language the request asked for. A hard filter, but only where a release states one. */
language: string | null;
limit: number;
}
/** One configured indexer row, with its credential already decrypted. Never log this. */
export interface PluginIndexerConfig {
id: number;
name: string;
baseUrl: string;
credential: string | null;
allowPrivateAddress: boolean;
categories: PluginCategoryMap;
/** Whatever the plugin declared in `settingsFields`, already validated against that schema. */
settings: Record<string, unknown> | null;
}
/** Audio characteristics, where a source publishes them. Shown and sorted on, never scored. */
export interface PluginAudioInfo {
bitrateKbps: number | null;
bitrateMode: string | null;
channels: number | null;
samplingRateHz: number | null;
durationSeconds: number | null;
chapterCount: number | null;
}
/**
* One release as a source describes it. Everything past `title` is optional because sources
* differ wildly in what they expose, and scoring degrades rather than rejecting a sparse row.
*/
export interface PluginReleaseCandidate {
/** Stable within this indexer, and how a grab names the release it wants. */
guid: string;
title: string;
/** The bare work title where the source publishes one; scored against, never displayed. */
bookTitle?: string;
downloadUrl?: string;
magnet?: string;
infoHash?: string;
sizeBytes: number | null;
/**
* Null means the source reported no swarm counts, which must not be read as zero: the
* zero-seeder hard filter would then drop every release from a source that omits the field.
*/
seeders: number | null;
leechers: number | null;
format?: string;
language?: string;
author?: string;
/**
* An identifier the source states for the edition, where it states one. Never trusted to be an
* ISBN: MyAnonaMouse returns `ASIN:B08G9PRS1K` in the same field, which normalisation rejects.
*/
isbn?: string;
freeleech?: boolean;
/**
* The account behind this indexer has already downloaded this release, where the source says so.
* Advisory only: sources report it on a delay, so it marks a row and never filters one.
*/
alreadyGrabbed?: boolean;
/** The source will refuse the download outright unless the account is privileged. */
vipOnly?: boolean;
publishedAt?: string;
audio?: PluginAudioInfo;
/** Primary-format files this release carries, where the source exposes a real file list. */
primaryFileCount?: number;
/** Total files, where the source reports only a count. A scoring penalty, not a filter. */
fileCount?: number;
seedRatioGoal?: number;
seedTimeMinutes?: number;
}
/** One file, for a source that serves the file itself rather than a torrent. */
export interface PluginReleaseFile {
url: string;
fileName: string;
sizeBytes: number | null;
format: string;
}
export interface PluginTestResult {
success: boolean;
/** What answered, when the source names itself. */
indexerName?: string;
error?: string;
}
/** One configurable field a plugin wants on the settings form, beyond the shared ones. */
export interface PluginSettingsField {
key: string;
type: "boolean" | "string" | "number";
/** Untranslated English: plugin strings cannot go through the translation workflow. */
label: string;
hint?: string;
default?: boolean | string | number;
/** Edit a comma-separated string as a collection instead of exposing its punctuation. */
format?: "list";
/** When present on a list, the UI only permits these canonical values. */
options?: readonly string[];
/** Smallest permitted list size. Requires `options` so the limit can be enforced safely. */
minItems?: number;
}
/**
* Everything BookOrbit lends a plugin. A plugin must reach for these rather than Node's own
* globals: `host.fetch` is the only path that enforces the address checks, the size limits and
* the per-request deadline that keep a hostile or misconfigured source from reaching the network
* it should not.
*/
export interface PluginHost {
/**
* Checked against the indexer's private-address policy, given the caller's deadline, and
* bounded. Throws whatever `fail` produces on a refusal.
*/
fetch(url: string, init?: PluginRequestInit): Promise<Response>;
/** Values interpolated into a message are sanitized by the host before they reach a log. */
logger: {
log(message: string): void;
warn(message: string): void;
};
/**
* The search text BookOrbit would use itself: parentheticals stripped, author appended. Sources
* that AND their terms answer a decorated title with nothing at all.
*/
buildSearchText(query: PluginReleaseQuery): string;
/**
* Store a credential the source rotated out from under the stored one. Without this a rotated
* session lives only in memory and stops working at the next restart.
*/
saveCredential(credential: string): Promise<void>;
/**
* Build the error a plugin should throw. A function rather than an exported class because a
* plugin imported at runtime holds a different copy of any class, so `instanceof` would not
* survive the boundary and the failure code would be lost.
*/
fail(failure: PluginSearchFailure, message: string): Error;
}
export interface PluginRequestInit {
method?: string;
headers?: Record<string, string>;
body?: string;
/** Defaults to following redirects, each hop checked. */
redirect?: "follow" | "manual";
}
/**
* What a plugin module's default export must look like. Everything the settings form needs is
* declared here, so a plugin-supplied source is indistinguishable from a built-in one to the
* client.
*/
export interface IndexerPlugin {
/** Must equal `PLUGIN_API_VERSION`; the loader refuses a mismatch and says so. */
apiVersion: number;
/** The plugin release, without a leading `v`. Optional for plugins written before versions were exposed. */
version?: string;
/** A slug. May not collide with a built-in type name, and must match `^[a-z0-9][a-z0-9-]{0,29}$`. */
type: string;
/** Untranslated English, shown in the indexer type list. */
label: string;
requiresCredential: boolean;
credentialKind: PluginCredentialKind;
/** Which media this source can answer for at all. */
mediaKinds: readonly PluginMediaKind[];
/** Whether this plugin uses `isbn13` when present instead of the title and author text. */
supportsIsbnSearch?: boolean;
/** Whether the source is searched by numeric category, which decides if the editor shows. */
usesCategories: boolean;
defaultCategories?: PluginCategoryMap;
/** Whether a grab joins a swarm, which decides whether seed goals mean anything. */
seedsBack: boolean;
/** Prefilled into the form when adding one. */
defaultBaseUrl?: string;
baseUrlHint?: string;
settingsFields?: readonly PluginSettingsField[];
search(query: PluginReleaseQuery, config: PluginIndexerConfig, host: PluginHost, signal: AbortSignal): Promise<PluginReleaseCandidate[]>;
test(config: PluginIndexerConfig, host: PluginHost): Promise<PluginTestResult>;
/** A source whose download link is credentialed and must be fetched with its own session. */
fetchTorrentFile?(release: PluginReleaseCandidate, config: PluginIndexerConfig, host: PluginHost): Promise<Uint8Array>;
/** A source that serves the file itself. Declare this or `fetchTorrentFile`, never both. */
resolveFile?(release: PluginReleaseCandidate, config: PluginIndexerConfig, host: PluginHost, signal: AbortSignal): Promise<PluginReleaseFile>;
/** Sources whose session expires on its own clock, called on a schedule while enabled. */
keepalive?(config: PluginIndexerConfig, host: PluginHost): Promise<void>;
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"declaration": true,
"declarationMap": true,
"rootDir": "./src",
"outDir": "./dist",
"strict": true,
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+24
View File
@@ -73,6 +73,27 @@ export enum AuditAction {
BookDockFinalize = "book_dock.finalize",
BookRequestCreate = "book_request.create",
BookRequestApprove = "book_request.approve",
BookRequestReject = "book_request.reject",
BookRequestCancel = "book_request.cancel",
BookRequestFulfill = "book_request.fulfill",
BookRequestGrab = "book_request.grab",
BookRequestImport = "book_request.import",
BookRequestRemoveDownload = "book_request.remove_download",
BookRequestDelete = "book_request.delete",
DownloadClientCreate = "download_client.create",
DownloadClientUpdate = "download_client.update",
DownloadClientDelete = "download_client.delete",
RequestIndexerCreate = "request_indexer.create",
RequestIndexerUpdate = "request_indexer.update",
RequestIndexerDelete = "request_indexer.delete",
RequestIndexerPluginInspect = "request_indexer.plugin.inspect",
RequestIndexerPluginInstall = "request_indexer.plugin.install",
RequestIndexerPluginRemove = "request_indexer.plugin.remove",
AuthorUpdate = "author.update",
AuthorDelete = "author.delete",
AuthorMerge = "author.merge",
@@ -128,6 +149,9 @@ export enum AuditResource {
Collection = "collection",
SmartScope = "smart_scope",
BookDockFile = "book_dock_file",
BookRequest = "book_request",
DownloadClient = "download_client",
RequestIndexer = "request_indexer",
Author = "author",
AppSettings = "app_settings",
Genre = "genre",
+24 -1
View File
@@ -68,6 +68,15 @@ export interface BookDockMetadata {
comicMetadata?: ComicMetadataFields | null;
}
/** One file of a multi-file dock unit. Read-only in the UI: a unit is finalized whole. */
export interface BookDockUnitFile {
fileName: string;
fileSize: number | null;
format: string | null;
role: "content" | "cover" | "metadata" | "supplement";
sortOrder: number | null;
}
export interface BookDockFile {
id: number;
fileName: string;
@@ -85,6 +94,11 @@ export interface BookDockFile {
metadataEditedAt: string | null;
createdAt: string;
updatedAt: string;
/**
* The files this entry is made of, in playback or format order. Empty for the ordinary loose
* single file, which is what `fileName` and `fileSize` already describe.
*/
unitFiles: BookDockUnitFile[];
}
export interface BookDockFilesPage {
@@ -146,7 +160,16 @@ export interface BookDockFinalizeResult {
}
export type BookDockFinalizePreviewStatus =
"ready" | "duplicate" | "destination_conflict" | "missing_destination" | "invalid_target" | "access_denied" | "invalid_format" | "error";
| "ready"
| "duplicate"
| "destination_conflict"
| "missing_destination"
| "invalid_target"
| "access_denied"
| "invalid_format"
/** A multi-file unit the target library's organization mode cannot represent. */
| "unsupported_layout"
| "error";
export interface BookDockFinalizePreviewItem {
fileId: number;
@@ -0,0 +1,192 @@
import { BOOK_REQUEST_MEDIA_KINDS, type BookRequestMediaKind } from "./book-request";
import { emptyRequestDestinationDefaults, type RequestDestinationDefaults } from "./book-request-destination";
import { emptyReleaseProfiles, type ReleaseProfiles } from "./book-request-profile";
/**
* Instance-level automation for book requests: whether BookOrbit may grab a release without an
* approver looking at the list, how good that release has to be, how many times it may try again
* after a failure, and how close an imported file has to be to the request before it is filed.
*
* Stored as individual `app_settings` rows rather than one JSON blob, so a single knob can be
* changed without a read-modify-write over the others.
*/
export const BOOK_REQUEST_AUTOMATION_SETTING_KEYS = {
AUTO_GRAB_ENABLED: "book_request_auto_grab_enabled",
AUTO_GRAB_MIN_SCORE: "book_request_auto_grab_min_score",
AUTO_RETRY_ENABLED: "book_request_auto_retry_enabled",
MAX_AUTO_GRAB_ATTEMPTS: "book_request_max_auto_grab_attempts",
VERIFICATION_ENABLED: "book_request_verification_enabled",
VERIFICATION_THRESHOLD: "book_request_verification_threshold",
IMPORT_FORMATS: "book_request_import_formats",
AUTO_SEARCH_ENABLED: "book_request_auto_search_enabled",
AUTO_SEARCH_INTERVAL_HOURS: "book_request_auto_search_interval_hours",
AUTO_SEARCH_MAX_AGE_DAYS: "book_request_auto_search_max_age_days",
} as const;
/**
* 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
* decides how many there are, and a medium added later should not need six more literals here.
*/
export function bookRequestDefaultLibraryKey(mediaKind: BookRequestMediaKind): string {
return `book_request_default_library_${mediaKind}`;
}
export function bookRequestDefaultFolderKey(mediaKind: BookRequestMediaKind): string {
return `book_request_default_folder_${mediaKind}`;
}
export const BOOK_REQUEST_DESTINATION_SETTING_KEYS: string[] = BOOK_REQUEST_MEDIA_KINDS.flatMap((kind) => [
bookRequestDefaultLibraryKey(kind),
bookRequestDefaultFolderKey(kind),
]);
/**
* The release profile for one medium, as a single JSON row.
*
* This is the one deliberate exception to the one-row-per-knob rule above. A tier list is one
* value: reordering it or editing a condition rewrites the whole list either way, so splitting it
* across rows would buy nothing and cost the ordering guarantee that makes a profile mean anything.
*/
export function bookRequestReleaseProfileKey(mediaKind: BookRequestMediaKind): string {
return `book_request_release_profile_${mediaKind}`;
}
export const BOOK_REQUEST_PROFILE_SETTING_KEYS: string[] = BOOK_REQUEST_MEDIA_KINDS.map(bookRequestReleaseProfileKey);
export const BOOK_REQUEST_IMPORT_FORMATS = ["all", "preferred"] as const;
export type BookRequestImportFormats = (typeof BOOK_REQUEST_IMPORT_FORMATS)[number];
export interface BookRequestAutomationSettings {
/**
* Off by default, and deliberately so: book matching is hard enough that the approver picks the
* release until an operator says otherwise.
*/
autoGrabEnabled: boolean;
/** A release scoring below this is never grabbed unattended; the approver still sees the list. */
autoGrabMinScore: number;
/** Whether a failed automatic grab falls back to the next-best release on its own. */
autoRetryEnabled: boolean;
/** Total automatic grabs allowed per request, first attempt included. */
maxAutoGrabAttempts: number;
/**
* Whether an approved request nothing could be found for is searched for again later.
*
* Without it a request is looked for exactly once. A book declined for want of a good enough
* release, or one whose first release is posted next month, then sits at `approved` forever
* unless a person reopens the picker for it - which is the one hole the whole pipeline has no
* other answer to.
*
* A separate flag rather than an interval of zero, so switching it off and on again does not
* lose the interval the operator tuned. Does nothing while `autoGrabEnabled` is off: there is
* nothing for an unattended search to do with what it finds.
*/
autoSearchEnabled: boolean;
/**
* How long after its last attempt an unfulfilled request is searched for again.
*
* The base interval rather than the whole rule: a request that has been waiting weeks is searched
* progressively less often, so a long backlog does not turn into daily traffic against every
* tracker forever.
*/
autoSearchIntervalHours: number;
/** How long a request keeps being searched for before it is left to a person. */
autoSearchMaxAgeDays: number;
/**
* Whether an imported file is checked against the request before it is filed. On, a poor match
* waits in the Book Dock for a human. Off, whatever was downloaded goes straight into the target
* library: no wait, and no guard against a mislabelled release landing there unnoticed.
*
* A separate flag rather than a threshold of zero, so turning the check off and on again does
* not lose the threshold the operator tuned.
*/
verificationEnabled: boolean;
/** Below this, an imported file is held in the Book Dock for review instead of being filed. */
verificationThreshold: number;
/**
* What to keep when a release carries the same book in several formats.
*
* `all` keeps every format, which in a one-book-per-folder library is one book with several
* files and in a one-book-per-file library is one book per format, that mode's normal state
* rather than a duplicate. `preferred` keeps only the highest-priority format for the target
* library.
*
* Never applies to a multipart audiobook: its parts are one book, not competing editions.
*/
importFormats: BookRequestImportFormats;
/**
* Where a request goes when nobody picked anywhere: per medium, so audiobooks need not land in
* the library the ebooks do.
*
* The lowest rung of the ladder, under the requester's own pinned destination and under an
* explicit choice at request or approval time. Without it a request that names no library can
* never be approved and is never grabbed unattended, which is what made auto-approval unusable
* for anyone who left the picker alone.
*/
destinations: RequestDestinationDefaults;
/**
* The edition an operator wants, per medium, as an ordered tier list. An empty list is the
* ordinary state and means no profile: the tier axis disengages entirely and auto-grab is
* decided by score alone, exactly as it was before profiles existed.
*/
profiles: ReleaseProfiles;
}
export const DEFAULT_BOOK_REQUEST_AUTOMATION_SETTINGS: BookRequestAutomationSettings = {
autoGrabEnabled: false,
autoGrabMinScore: 80,
autoRetryEnabled: true,
maxAutoGrabAttempts: 3,
// Off, like auto-grab itself. This one reaches every configured tracker on a timer rather than
// when somebody asked for something, so it is a decision an operator makes rather than inherits.
autoSearchEnabled: false,
autoSearchIntervalHours: 24,
autoSearchMaxAgeDays: 60,
verificationEnabled: true,
verificationThreshold: 70,
// Keeping what the release carried is the less surprising default: dropping formats silently is
// the change an operator would have to notice to undo.
importFormats: "all",
// Unset, because guessing which of an operator's libraries holds audiobooks is how a book ends
// up somewhere nobody looks. Until one is set the destination stays a decision a human makes.
destinations: emptyRequestDestinationDefaults(),
// Empty, so switching to a build that has profiles changes nothing about what gets grabbed.
// A profile only starts constraining once an operator has written one.
profiles: emptyReleaseProfiles(),
};
/**
* A floor this low would grab almost anything the scorer did not hard-filter, which is not a
* setting so much as a way to fill a library with the wrong books.
*/
export const MIN_AUTO_GRAB_SCORE_FLOOR = 50;
export const MAX_AUTO_GRAB_ATTEMPTS_LIMIT = 10;
/**
* An hour is already often for a book that did not exist an hour ago, and a week is the point past
* which "keep looking" stops meaning anything.
*/
export const MIN_AUTO_SEARCH_INTERVAL_HOURS = 1;
export const MAX_AUTO_SEARCH_INTERVAL_HOURS = 168;
export const MIN_AUTO_SEARCH_MAX_AGE_DAYS = 1;
export const MAX_AUTO_SEARCH_MAX_AGE_DAYS = 365;
/**
* How far the interval is allowed to stretch as a request ages. It doubles for each week the
* request has been waiting, so a daily search becomes weekly after three weeks and stops there:
* past that the cap does the work the lifetime limit was going to do anyway.
*/
export const AUTO_SEARCH_BACKOFF_WEEK_MS = 7 * 24 * 60 * 60 * 1000;
export const MAX_AUTO_SEARCH_BACKOFF_FACTOR = 8;
/**
* Destinations are partial a second time over: one medium can be changed without resending the
* other two, matching how every scalar knob above is sent on its own.
*/
export type UpdateBookRequestAutomationSettingsPayload = Partial<Omit<BookRequestAutomationSettings, "destinations" | "profiles">> & {
destinations?: Partial<RequestDestinationDefaults>;
/** Partial for the same reason destinations are: one medium's list can be saved on its own. */
profiles?: Partial<ReleaseProfiles>;
};
@@ -0,0 +1,47 @@
import { BOOK_REQUEST_MEDIA_KINDS, type BookRequestMediaKind } from "./book-request";
/**
* Where a request's book is filed once it lands: a library, and a folder inside it.
*
* Both halves are nullable and travel together. A folder without a library is not a destination,
* and a library whose folder was deleted still files the book, into that library's first folder.
*/
export interface RequestDestination {
libraryId: number | null;
folderId: number | null;
}
/**
* A destination per medium, which is the finest grain a *default* can be keyed on.
*
* The medium is chosen before anything is searched, so it is the only thing about the eventual
* file that is known at the moment a default has to fill the destination in. The file's format is
* not: a request becomes an epub, an m4b or a folder of mp3s only once a release has been picked
* and unpacked, long after the approver has already been asked where it goes.
*/
export type RequestDestinationDefaults = Record<BookRequestMediaKind, RequestDestination>;
export const NO_REQUEST_DESTINATION: RequestDestination = { libraryId: null, folderId: null };
export function emptyRequestDestinationDefaults(): RequestDestinationDefaults {
return Object.fromEntries(BOOK_REQUEST_MEDIA_KINDS.map((kind) => [kind, { ...NO_REQUEST_DESTINATION }])) as RequestDestinationDefaults;
}
/**
* A resolved instance default, as the request form shows it. Carries the library's name because
* the form has to say where an unpicked request will land, and the requester may have no access
* to that library and so no other way to learn what it is called.
*/
export interface ResolvedRequestDestination {
libraryId: number | null;
libraryName: string | null;
folderId: number | null;
}
export type ResolvedRequestDestinations = Record<BookRequestMediaKind, ResolvedRequestDestination>;
export const NO_RESOLVED_REQUEST_DESTINATION: ResolvedRequestDestination = { libraryId: null, libraryName: null, folderId: null };
export function emptyResolvedRequestDestinations(): ResolvedRequestDestinations {
return Object.fromEntries(BOOK_REQUEST_MEDIA_KINDS.map((kind) => [kind, { ...NO_RESOLVED_REQUEST_DESTINATION }])) as ResolvedRequestDestinations;
}
@@ -0,0 +1,32 @@
/**
* What a user's requests start with, so it is not re-picked on every request.
*
* Destinations are deliberately not here. They were, pinned per user and then per medium, but the
* instance default under Settings > System > Requests answers the same question for everybody
* without each person having to find a pin button, and two mechanisms answering it with an
* invisible precedence rule between them was worse than one.
*/
export interface BookRequestPreferences {
/**
* The language a new request asks for, as a two-letter code.
*
* Null means "whatever the chosen edition happens to be", which is what every request did before
* this existed: the edition's language rode along silently and decided the outcome, so a request
* for a book could come back as a translation nobody asked for.
*
* Still per user, unlike the destination, because there is no instance-level answer to it: the
* language someone reads in is theirs, not the server's.
*/
defaultLanguage: string | null;
}
export const DEFAULT_BOOK_REQUEST_PREFERENCES: BookRequestPreferences = {
defaultLanguage: null,
};
/**
* Fields this category used to hold: first one destination for every medium, then one per medium.
* Read only to be discarded, so a row written by either older build still parses and keeps its
* language instead of failing strict validation and reverting to the defaults.
*/
export const RETIRED_BOOK_REQUEST_PREFERENCE_FIELDS = ["defaultLibraryId", "defaultFolderId", "destinations"] as const;
+198
View File
@@ -0,0 +1,198 @@
import { BOOK_REQUEST_MEDIA_KINDS, type BookRequestMediaKind } from "./book-request";
import { languagesAgree } from "./language";
/**
* Release profiles: the edition an operator wants, expressed once, so a release can be grabbed
* without a person looking at the list.
*
* A profile is an ORDERED list of tiers, best first. The first tier a release matches is its tier,
* and that tier is a **separate axis from the release score**, never folded into it. Auto-grab
* takes the highest tier that has any candidate and then the best score within it. Folding tier
* points into the score was considered and rejected: enough points to make a format preference
* meaningful is also enough to outrank a genuine title-match difference, which is exactly what the
* 55-point match weight exists to prevent.
*
* A release matching no tier keeps `tier: null`. It stays visible in the picker, sorted last, and
* a person may still pick it; the automation refuses it and hands the request back.
*/
/** Whether a release is one book file or several. Named because the picker facets on it too. */
export type ReleaseFileLayout = "single" | "multi";
/**
* How many entries a release may carry and still be **one book file**.
*
* The count a search returns is every entry in the torrent: the book, its `.cue`, its cover art,
* its `.nfo`. So an exact test for one file matches almost nothing real - testing `=== 1` made
* "single file" match no MyAnonaMouse release at all, including `Shroud.m4b` shipped beside a cue,
* a jpg and an nfo, which is four entries and one book.
*
* Five is where observed packaging tops out (book, cue, artwork, nfo, and one spare). It is a
* threshold, not a fact: this cannot be answered exactly from a search, because the count of
* *content* files lives only in the manifest and reading that costs a credentialed fetch per
* release, which is why inspection is on demand. A five-part audiobook therefore reads as single
* here, and a single book wrapped in six sidecars reads as multi.
*/
export const SINGLE_FILE_MAX_ENTRIES = 5;
/**
* Which layout a release has, or null where the source stated no count at all. Shared so the
* picker's facet and a tier's condition can never disagree about what "single" means.
*/
export function classifyFileLayout(fileCount: number | null): ReleaseFileLayout | null {
if (fileCount === null) return null;
return fileCount <= SINGLE_FILE_MAX_ENTRIES ? "single" : "multi";
}
/** One axis a tier constrains. Every field is optional; an omitted field constrains nothing. */
export interface ReleaseTierConditions {
/** Matches when the release carries ANY of these. Empty or absent matches every format. */
formats?: string[];
/** Whether the release is one file or several. Absent matches either. */
fileLayout?: ReleaseFileLayout;
/** Audio only, and only meaningful where the source published a bitrate. */
minBitrateKbps?: number;
/** Audio only. 1 for mono, 2 for stereo. */
channels?: number;
/** Matches when the release states ANY of these languages. */
languages?: string[];
/** Matches when the release came from ANY of these configured indexers. */
indexerIds?: number[];
minSeeders?: number;
maxSizeBytes?: number;
/** Only releases that cost no ratio. */
freeleechOnly?: boolean;
/** Excludes releases the source will refuse without a privileged account. */
excludeVipOnly?: boolean;
}
export interface ReleaseTier {
/** Stable across reorders, so a stored tier survives being dragged up the list. */
id: string;
/** The operator's own words, shown on the release row. Never translated. */
name: string;
conditions: ReleaseTierConditions;
}
/** One ordered tier list per medium. Empty means no profile, which disables the tier axis. */
export type ReleaseProfiles = Record<BookRequestMediaKind, ReleaseTier[]>;
export function emptyReleaseProfiles(): ReleaseProfiles {
return Object.fromEntries(BOOK_REQUEST_MEDIA_KINDS.map((kind) => [kind, []])) as ReleaseProfiles;
}
/**
* The release facts a tier is matched against. Structurally satisfied by `ReleaseCandidateItem`,
* so the picker can show a tier without a second round trip and the server can evaluate the same
* rules against its own candidate shape.
*/
export interface ReleaseTierInput {
formats: string[];
/** Null where the source reported no count, which must not be read as one file. */
fileCount: number | null;
audio: { bitrateKbps: number | null; channels: number | null } | null;
language: string | null;
indexerId: number;
freeleech: boolean;
vipOnly: boolean;
seeders: number | null;
sizeBytes: number | null;
}
/**
* Whether one release satisfies every condition a tier states.
*
* Where the line falls on **unstated** facts depends on whether the source could reasonably have
* published one, and the two cases are genuinely different:
*
* - **Bitrate and channels come from MediaInfo, which is optional per torrent and usually absent.**
* MyAnonaMouse returned `{}` for every release of three separate books, so a bitrate floor that
* excluded unmeasured releases excluded everything and made a profile unusable on that tracker.
* These conditions therefore reject only a value that was stated and fell short. A floor reads as
* "nothing measured below this", not "nothing unmeasured".
* - **File count and size are properties of the torrent itself**, published wherever the source
* publishes anything, so silence there is a genuinely unknown release rather than an unmeasured
* one, and it does not satisfy a condition about it.
*
* Seeders sit with the first group for a different reason: a source with no swarm at all reports
* null, and holding that against it would bar every direct download from every tier wanting seeds.
*/
export function releaseMatchesTier(release: ReleaseTierInput, conditions: ReleaseTierConditions): boolean {
const { formats, fileLayout, minBitrateKbps, channels, languages, indexerIds, minSeeders, maxSizeBytes } = conditions;
if (formats && formats.length > 0) {
const wanted = formats.map((format) => format.toLowerCase());
if (!release.formats.some((format) => wanted.includes(format.toLowerCase()))) return false;
}
if (fileLayout && classifyFileLayout(release.fileCount) !== fileLayout) return false;
// Stated and short fails; unstated passes. See the note above: MediaInfo is optional per torrent.
if (minBitrateKbps !== undefined && release.audio?.bitrateKbps != null && release.audio.bitrateKbps < minBitrateKbps) {
return false;
}
if (channels !== undefined && release.audio?.channels != null && release.audio.channels !== channels) return false;
if (languages && languages.length > 0) {
if (!release.language) return false;
// Compared by subtag rather than as strings: a tier stores the two-letter code the form
// offers, while a source states whatever it states. MyAnonaMouse reports "ENG", which no
// amount of lowercasing makes equal to "en", so a raw comparison put every one of its
// releases outside every tier that named a language.
const stated = release.language;
if (!languages.some((language) => languagesAgree(language, stated))) return false;
}
if (indexerIds && indexerIds.length > 0 && !indexerIds.includes(release.indexerId)) return false;
// Seeders are the one exception to "unstated never matches": a source with no swarm at all
// reports null, and holding that against a direct download would exclude every such source from
// every tier that asks for a healthy swarm.
if (minSeeders !== undefined && release.seeders !== null && release.seeders < minSeeders) return false;
if (maxSizeBytes !== undefined) {
if (release.sizeBytes === null) return false;
if (release.sizeBytes > maxSizeBytes) return false;
}
if (conditions.freeleechOnly === true && !release.freeleech) return false;
if (conditions.excludeVipOnly === true && release.vipOnly) return false;
return true;
}
/**
* The index of the first tier this release matches, or null for none.
*
* An empty tier list returns null for everything, which is what makes adopting this feature safe:
* `releaseProfileIsActive` is false, the tier axis disengages, and auto-grab keeps behaving exactly
* as it did before any profile existed.
*/
export function matchReleaseTier(release: ReleaseTierInput, tiers: readonly ReleaseTier[]): number | null {
const index = tiers.findIndex((tier) => releaseMatchesTier(release, tier.conditions));
return index === -1 ? null : index;
}
/** Whether a medium has a profile at all. False means score alone decides, as it always did. */
export function releaseProfileIsActive(tiers: readonly ReleaseTier[] | undefined): boolean {
return (tiers?.length ?? 0) > 0;
}
/**
* Orders two releases the way the picker and the automation both must: by tier first, then by
* whatever the caller was already comparing. Untiered sorts after every tier.
*
* Shared so the list an approver reads and the list the automation walks cannot disagree about
* which release is best, which is the same reason `findGrabRefusal` is shared.
*/
export function compareByTier(a: number | null, b: number | null): number {
if (a === b) return 0;
if (a === null) return 1;
if (b === null) return -1;
return a - b;
}
/** Bounds on a stored profile, enforced at the DTO and echoed in the settings form. */
export const MAX_RELEASE_TIERS = 12;
export const MAX_RELEASE_TIER_NAME_LENGTH = 60;
+61
View File
@@ -0,0 +1,61 @@
import type { BookDockUnitFile } from "./book-dock";
/** The fields the import is compared on. Order is the order the drawer lists them in. */
export const BOOK_REQUEST_VERIFICATION_FIELDS = ["title", "authors", "isbn13"] as const;
export type BookRequestVerificationField = (typeof BOOK_REQUEST_VERIFICATION_FIELDS)[number];
/**
* `unknown` is not a soft mismatch: it means one side never carried the field, so it neither
* earned nor cost anything. Showing it as a mismatch would blame a missing ISBN for a shortfall
* the title actually caused.
*/
export type BookRequestVerificationVerdict = "match" | "mismatch" | "unknown";
export interface BookRequestVerificationRow {
field: BookRequestVerificationField;
requested: string | null;
imported: string | null;
verdict: BookRequestVerificationVerdict;
}
/**
* Why the check landed where it did, as a code rather than a sentence. The server's prose is
* still stored on the request for logs and for older clients, but the drawer localizes from this.
*/
export const BOOK_REQUEST_VERIFICATION_REASONS = ["isbn_match", "above_threshold", "below_threshold", "author_mismatch", "no_title"] as const;
export type BookRequestVerificationReason = (typeof BOOK_REQUEST_VERIFICATION_REASONS)[number];
export interface BookRequestVerification {
score: number;
threshold: number;
passed: boolean;
reason: BookRequestVerificationReason;
rows: BookRequestVerificationRow[];
}
/** One file of the imported entry. Names only: a dock path is a server path and never leaves. */
export type BookRequestReviewFile = Pick<BookDockUnitFile, "fileName" | "fileSize" | "format" | "role">;
/**
* Everything an approver needs to answer "what actually landed, and why is it waiting on me".
*
* The verification is recomputed against the dock entry as it stands rather than replayed from
* what was stored at hold time, so correcting the metadata in the Book Dock and reopening the
* request shows the corrected score.
*/
export interface BookRequestReview {
requestId: number;
/**
* Null when the entry this request was held over is no longer in the Book Dock. Both pointers
* to it are `on delete set null`, so filing or discarding it by hand empties them and leaves the
* request held over a file that does not exist. The drawer has to say so rather than go blank.
*/
bookDockFileId: number | null;
/** Null when the hold was not a scoring decision: no destination folder, or the dock refused it. */
verification: BookRequestVerification | null;
/** Every file the entry is made of, in unit order. A loose single file is one row. Empty once the dock entry is gone. */
files: BookRequestReviewFile[];
totalSizeBytes: number | null;
/** False when the request has no library to be filed into, which force-filing cannot invent. */
canFile: boolean;
}
+573
View File
@@ -0,0 +1,573 @@
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;
/** Bounds exact indexer passes before the title-and-author fallback. */
export const MAX_BOOK_REQUEST_SEARCH_ISBNS = 8;
/**
* A .torrent is a few kilobytes of metadata; anything larger is not one. Shared so the grab dialog
* refuses a file before uploading it rather than learning the ceiling from a 400.
*/
export const MAX_TORRENT_FILE_BYTES = 2 * 1024 * 1024;
export const BOOK_REQUEST_STATUSES = [
"pending",
"approved",
"rejected",
"cancelled",
"searching",
"grabbed",
"downloading",
"importing",
"needs_review",
"available",
"failed",
] as const;
export type BookRequestStatus = (typeof BOOK_REQUEST_STATUSES)[number];
/**
* Statuses that hold a claim on the requested work. A second request for the same work is
* folded into the existing one while it is in one of these; once it leaves, the work can be
* requested again (a rejected request may be re-argued, an available book may be removed).
* Kept in sync with the partial unique index on `book_requests.dedupe_key`.
*/
export const ACTIVE_BOOK_REQUEST_STATUSES: readonly BookRequestStatus[] = [
"pending",
"approved",
"searching",
"grabbed",
"downloading",
"importing",
"needs_review",
];
/**
* Grouping token for a title or an author name. Shared so the search list collapses exactly the
* candidates the server would fold into one request, rather than approximating that rule twice.
*
* The allowlist is every Unicode letter and number rather than `a-z0-9`. An ASCII title tokenizes
* identically either way; a Cyrillic, CJK, Greek or Arabic one used to lose every character and
* come back empty, which on a multilingual app meant every non-Latin title of a medium shared one
* key. Diacritics are still folded first, so "Les Misérables" and "les miserables" agree.
*/
export function normalizeWorkToken(value: string): string {
return value
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^\p{L}\p{N}]+/gu, "");
}
function fnv1a(value: string): string {
let hash = 0x811c9dc5;
for (const char of value) {
hash = Math.imul(hash ^ char.codePointAt(0)!, 0x01000193) >>> 0;
}
return hash.toString(16);
}
/**
* A title no allowlist can tokenize - punctuation, symbols or emoji alone - hashed so it still
* identifies itself.
*
* Rare, and the alternative is what makes it worth handling: a token that comes back empty either
* collapses every such title onto one shared key, or leaves the work with no key at all. Both are
* wrong in the same direction, and the second requester of the day pays for it.
*/
function rawTitleToken(title: string): string {
return `x${fnv1a(title.trim().toLowerCase())}`;
}
/**
* The width of `book_requests.dedupe_key`. The submission DTO allows a 500-character title beside a
* 255-character author, so an unbounded work key overflowed the column and reached the requester as
* a Postgres 22001 dressed up as a 500.
*/
const MAX_DEDUPE_KEY = 500;
/** How much of one token a shortened work key spends, chosen so two of them plus the frame fit. */
const MAX_WORK_KEY_TOKEN = 160;
/**
* Truncated with the full token's hash appended rather than simply cut. Two long titles sharing
* their first hundred and sixty characters - a series with a common prefix, a subtitle-heavy
* edition - would otherwise collapse onto one key, and dedupe would fold two different books into
* one request. `~` cannot appear in a token, so a shortened key can never collide with a whole one.
*/
function boundToken(token: string): string {
return token.length <= MAX_WORK_KEY_TOKEN ? token : `${token.slice(0, MAX_WORK_KEY_TOKEN)}~${fnv1a(token)}`;
}
/**
* The key two records share when they describe the same work in the same medium.
*
* Shortened only when the whole key would not fit the column, rather than at a fixed token width.
* A stored key is only useful while the probe still computes it: shortening one that already fits
* would change what every existing request of a long-titled work is filed under, and the next
* person to ask for that book would open a second request instead of joining theirs.
*/
export function bookRequestWorkKey(title: string, author: string | null | undefined, mediaKind: BookRequestMediaKind): string {
const titleToken = normalizeWorkToken(title) || rawTitleToken(title);
const authorToken = normalizeWorkToken(author ?? "");
const key = `work:${titleToken}:${authorToken}:${mediaKind}`;
if (key.length <= MAX_DEDUPE_KEY) return key;
return `work:${boundToken(titleToken)}:${boundToken(authorToken)}:${mediaKind}`;
}
/**
* Statuses a release may be grabbed from. `failed` is included so a bad release can be replaced
* without re-approving. Shared so a card cannot offer a button the grab endpoint would refuse.
*/
export const GRABBABLE_BOOK_REQUEST_STATUSES: readonly BookRequestStatus[] = ["approved", "searching", "failed"];
export function isGrabbableBookRequestStatus(status: BookRequestStatus): boolean {
return GRABBABLE_BOOK_REQUEST_STATUSES.includes(status);
}
/**
* Whether one person is the one a request is theirs to fulfil.
*
* Usually the requester, because a self-serve row is created by the person about to fulfil it.
* Not always: one live request per work, so a self-fulfiller whose submission collides with
* somebody else's undriven request takes that row on rather than opening a second one, and
* `fulfillerUserId` records that it is now theirs. Null means nobody took it on, and the
* requester is the fulfiller by construction - which is also every row created before the
* column existed.
*
* Shared so the UI cannot offer an action `assertCanFulfil` would refuse, and holds no permission
* check: the caller still needs `book_request_self_fulfill`, or to be moderating the queue.
*/
export function isBookRequestFulfiller(request: Pick<BookRequestItem, "userId" | "fulfillerUserId">, userId: number | null | undefined): boolean {
if (userId === null || userId === undefined) return false;
return (request.fulfillerUserId ?? request.userId) === userId;
}
/** Statuses an approver may close by pointing at a book or Book Dock file. */
export const FULFILLABLE_BOOK_REQUEST_STATUSES: readonly BookRequestStatus[] = [
"pending",
"approved",
"searching",
"grabbed",
"downloading",
"importing",
"needs_review",
"failed",
];
export function isFulfillableBookRequestStatus(status: BookRequestStatus): boolean {
return FULFILLABLE_BOOK_REQUEST_STATUSES.includes(status);
}
/**
* Statuses a request can still be walked back from: everything that has not settled. Stopping a
* grab mid-flight is the only exit a request that stalled in `downloading` or `needs_review` ever
* gets, so the list is deliberately wider than the pending-or-approved pair it started as.
*/
export const CANCELLABLE_BOOK_REQUEST_STATUSES: readonly BookRequestStatus[] = [
"pending",
"approved",
"searching",
"grabbed",
"downloading",
"importing",
"needs_review",
"failed",
];
export function isCancellableBookRequestStatus(status: BookRequestStatus): boolean {
return CANCELLABLE_BOOK_REQUEST_STATUSES.includes(status);
}
/**
* Statuses background fulfilment work may still write to. The three that are missing are the
* decisions a person made and expects to stand: a poll that was already in flight, an import that
* was already extracting or a watchdog sweep must not drag a cancelled, rejected or already-filed
* request back into the pipeline.
*/
export const WORKER_WRITABLE_BOOK_REQUEST_STATUSES: readonly BookRequestStatus[] = [
"pending",
"approved",
"searching",
"grabbed",
"downloading",
"importing",
"needs_review",
"failed",
];
/**
* Statuses nothing is still working on, which is what makes hiding or deleting one safe. `failed`
* is both settled and cancellable on purpose: it can be retried, given up on, or tidied away.
*/
export const SETTLED_BOOK_REQUEST_STATUSES: readonly BookRequestStatus[] = ["rejected", "cancelled", "available", "failed"];
export function isSettledBookRequestStatus(status: BookRequestStatus): boolean {
return SETTLED_BOOK_REQUEST_STATUSES.includes(status);
}
/**
* Why automation handed a request back to a person, as a stable value rather than as the prose
* that accompanies it. The prose is written in English at the point of failure and stays as the
* fallback for the reasons nothing has classified; the code is what the UI translates.
*
* Parameters live in `failureMeta` rather than being interpolated into the code, so a translator
* can put the number wherever their language wants it.
*/
export const BOOK_REQUEST_HANDBACK_CODES = [
/** Automatic grabbing is switched off, so nothing was ever going to look for a release. */
"AUTOMATION_DISABLED",
/** The request has nowhere to file a book, which grab refuses before downloading anything. */
"NO_DESTINATION",
/** The per-request budget of automated attempts is spent. Carries `attempts`. */
"ATTEMPT_LIMIT",
/** The indexer search itself failed rather than coming back empty. Carries `detail`. */
"SEARCH_FAILED",
/**
* Nothing was searched, because no indexer is configured at all. Kept apart from the two states
* below it: the score floor and the profile are both answers about releases, and reporting one
* of those for a search that never ran sends the operator to tune a number that had no bearing
* on anything.
*/
"NO_SOURCES_CONFIGURED",
/** Nothing was searched, because every configured indexer is switched off. A toggle away. */
"NO_SOURCES_ENABLED",
/** Indexers are enabled, but not one of them carries the requested medium. */
"MEDIUM_UNCOVERED",
/** A release profile is active for this medium and nothing the search returned fell in a tier. */
"PROFILE_EXCLUDED_ALL",
/** Everything good enough has already been tried for this request. */
"ALL_TRIED",
/** Nothing cleared the operator's score floor. Carries `floor`. */
"BELOW_SCORE_FLOOR",
/** Everything good enough was ruled out by an earlier refusal in the same pass. */
"ALL_BLOCKED",
/**
* Not automation at all: a self-serve request whose picker was opened and never acted on. Swept
* so it stops holding the dedupe claim on a work nobody is actually fetching.
*/
"ABANDONED",
] as const;
export type BookRequestHandbackCode = (typeof BOOK_REQUEST_HANDBACK_CODES)[number];
/** Values a handback message interpolates. Deliberately flat: these are message parameters. */
export type BookRequestFailureMeta = Record<string, string | number>;
/**
* Why a submission was turned down, as a stable value rather than as the sentence beside it.
*
* Every refusal the submit endpoint raises is copy this application wrote about a rule this
* instance applies, so a client that can only repeat the English is a client that cannot
* translate any of them. A refusal that originates outside - a tracker saying no - deliberately
* carries no code, because there is nothing to translate there either.
*
* Parameters live in `errorMeta` for the same reason handback parameters live in `failureMeta`:
* a translator has to be able to put the number where their language wants it.
*/
export const BOOK_REQUEST_SUBMIT_ERROR_CODES = [
/** The title was blank once trimmed, so there is no work to ask for. */
"SUBMIT_TITLE_REQUIRED",
/** Self-fulfilment was asked for by somebody who does not hold the permission. */
"SUBMIT_SELF_FULFIL_FORBIDDEN",
/** The requester named a destination library they cannot reach. */
"SUBMIT_LIBRARY_FORBIDDEN",
/** A folder was named with no library for it to sit in. */
"SUBMIT_FOLDER_NEEDS_LIBRARY",
/** The named folder belongs to some other library than the named destination. */
"SUBMIT_FOLDER_NOT_IN_LIBRARY",
/** A self-server fell through to an instance default they cannot reach. */
"SUBMIT_DEFAULT_LIBRARY_UNREACHABLE",
/** Nobody decides on this request later, so it needs a destination now. */
"SUBMIT_DESTINATION_REQUIRED",
/** The cap on self-serve downloads in flight is full. Carries `limit`. */
"SUBMIT_SELF_SERVE_LIMIT",
/** Somebody else was named as the requester by a caller who may not file on their behalf. */
"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",
] as const;
export type BookRequestSubmitErrorCode = (typeof BOOK_REQUEST_SUBMIT_ERROR_CODES)[number];
/** The code a submission was refused with, where it carried one. Null is "we do not know". */
export function bookRequestSubmitErrorCode(value: unknown): BookRequestSubmitErrorCode | null {
return typeof value === "string" && (BOOK_REQUEST_SUBMIT_ERROR_CODES as readonly string[]).includes(value)
? (value as BookRequestSubmitErrorCode)
: null;
}
/** Stable reasons an action against an existing request was refused. */
export const BOOK_REQUEST_ACTION_ERROR_CODES = [
/** Approve and reject only apply while a request is pending. Carries `action` and `status`. */
"BOOK_REQUEST_NOT_PENDING",
/** The request has already reached a state that cannot be cancelled. Carries `status`. */
"BOOK_REQUEST_NOT_CANCELLABLE",
/** Approval cannot continue until a destination library is selected. */
"BOOK_REQUEST_DESTINATION_REQUIRED",
/** The caller is not allowed to perform the named lifecycle action. Carries `action`. */
"BOOK_REQUEST_ACTION_FORBIDDEN",
/** Another transition committed after the action read the row. Carries `action` and `status`. */
"BOOK_REQUEST_STALE_TRANSITION",
] as const;
export type BookRequestActionErrorCode = (typeof BOOK_REQUEST_ACTION_ERROR_CODES)[number];
export function bookRequestActionErrorCode(value: unknown): BookRequestActionErrorCode | null {
return typeof value === "string" && (BOOK_REQUEST_ACTION_ERROR_CODES as readonly string[]).includes(value)
? (value as BookRequestActionErrorCode)
: null;
}
/** One metadata record that contributed to a grouped work result. */
export interface BookRequestMetadataSource {
providerKey: string;
providerId: string;
providerLabel: string;
isbn10: string | null;
isbn13: string | null;
}
export function normalizeBookRequestIsbn(value: string | null | undefined): string | null {
const normalized = (value ?? "").replace(/[^0-9Xx]/g, "").toUpperCase();
return normalized || null;
}
export function isValidBookRequestIsbn10(value: string): boolean {
if (!/^\d{9}[\dX]$/.test(value)) return false;
let sum = 0;
for (let index = 0; index < 10; index += 1) {
const digit = index === 9 && value[index] === "X" ? 10 : Number(value[index]);
sum += digit * (10 - index);
}
return sum % 11 === 0;
}
export function isValidBookRequestIsbn13(value: string): boolean {
if (!/^\d{13}$/.test(value) || (!value.startsWith("978") && !value.startsWith("979"))) return false;
let sum = 0;
for (let index = 0; index < 13; index += 1) {
sum += Number(value[index]) * (index % 2 === 0 ? 1 : 3);
}
return sum % 10 === 0;
}
export function bookRequestIsbn10To13(value: string): string {
const body = `978${value.slice(0, 9)}`;
let sum = 0;
for (let index = 0; index < body.length; index += 1) {
sum += Number(body[index]) * (index % 2 === 0 ? 1 : 3);
}
return `${body}${(10 - (sum % 10)) % 10}`;
}
/** Canonical ISBN-13, including a converted ISBN-10 where that is the only valid identifier. */
export function canonicalizeBookRequestIsbn(isbn10: string | null | undefined, isbn13: string | null | undefined): string | null {
const normalized13 = normalizeBookRequestIsbn(isbn13);
if (normalized13 && isValidBookRequestIsbn13(normalized13)) return normalized13;
const normalized10 = normalizeBookRequestIsbn(isbn10);
if (normalized10 && isValidBookRequestIsbn10(normalized10)) return bookRequestIsbn10To13(normalized10);
return null;
}
/** The work as it looked at request time, so the request stays readable if the provider moves on. */
export interface BookRequestWorkSnapshot {
title: string;
subtitle: string | null;
authors: string[];
seriesName: string | null;
seriesIndex: number | null;
isbn10: string | null;
isbn13: string | null;
publishedYear: number | null;
language: string | null;
coverUrl: string | null;
providerKey: string | null;
providerId: string | null;
metadataSources: BookRequestMetadataSource[];
}
export interface BookRequestSubscriber {
userId: number;
username: string;
name: string;
}
export interface BookRequestItem extends BookRequestWorkSnapshot {
id: number;
userId: number;
requesterUsername: string;
requesterName: string;
mediaKind: BookRequestMediaKind;
status: BookRequestStatus;
preferredFormats: string[];
note: string | null;
targetLibraryId: number | null;
targetLibraryName: string | null;
targetFolderId: number | null;
decidedByUserId: number | null;
decidedByUsername: string | null;
decidedAt: string | null;
decisionNote: string | null;
matchedBookId: number | null;
bookDockFileId: number | null;
/**
* Created by somebody fulfilling it themselves rather than asking for it. Badged in the
* moderation queue, and never waiting on a decision.
*/
selfServe: boolean;
/**
* Who may drive fulfilment when that is not the requester, which happens when a self-fulfiller's
* own submission collides with somebody else's undriven request and they take it on. Null
* everywhere else, including on a self-serve row whose requester is its own fulfiller.
*/
fulfillerUserId: number | null;
statusReason: string | null;
/**
* Set only where a failure has been classified, which today means automation handing a request
* back. Null elsewhere, and the UI falls back to `statusReason` when it is.
*/
failureCode: BookRequestHandbackCode | null;
failureMeta: BookRequestFailureMeta | null;
subscribers: BookRequestSubscriber[];
/** The most recent grab attempt, so a card can show live progress. Null before the first grab. */
download: BookRequestDownloadItem | null;
/** Whether the signed-in user has hidden this from their own list. Never shared between users. */
dismissed: boolean;
createdAt: string;
updatedAt: string;
}
export interface BookRequestPage {
items: BookRequestItem[];
total: number;
}
export interface BookRequestRequesterOption {
userId: number;
username: string;
name: string;
}
export interface CreateBookRequestPayload extends Partial<BookRequestWorkSnapshot> {
title: string;
mediaKind: BookRequestMediaKind;
targetLibraryId?: number | null;
targetFolderId?: number | null;
preferredFormats?: string[];
note?: string | null;
/**
* Fulfil this one yourself instead of queueing it. Refused server-side without
* `book_request_self_fulfill`, and never inferred from the permission.
*/
selfServe?: boolean;
/**
* File this on somebody else's behalf. Refused without `manage_book_requests`, and every rule
* the submission applies then reads from the subject rather than the caller. Omitted is the
* ordinary case: the caller is the requester.
*/
userId?: number;
}
/**
* What submitting answers with. `subscribed` is the whole reason this is not just the request:
* one live request per work, so a submission for a work somebody already asked for attaches the
* caller to that row rather than opening a second, and the form says something different for each.
*/
export interface BookRequestSubmitResult {
request: BookRequestItem;
/** True when the work was already requested and the caller was attached to it instead. */
subscribed: boolean;
}
export interface DecideBookRequestPayload {
decisionNote?: string | null;
/** Approver may reroute the request to a different library than the requester picked. */
targetLibraryId?: number | null;
targetFolderId?: number | null;
}
export interface FulfillBookRequestPayload {
bookDockFileId?: number | null;
matchedBookId?: number | null;
note?: string | null;
}
/**
* What the request UI needs to annotate a metadata search result before anything is submitted:
* whether the library already has it, and whether someone already asked for it.
*/
export interface BookRequestAvailabilityQuery {
isbn13?: string | null;
title: string;
author?: string | null;
mediaKind: BookRequestMediaKind;
providerKey?: string | null;
providerId?: string | null;
}
export interface BookRequestAvailability {
ownedBookId: number | null;
existingRequestId: number | null;
existingRequestStatus: BookRequestStatus | null;
/** True when the signed-in user is the requester or already a subscriber on that request. */
alreadySubscribed: boolean;
}
export interface BookRequestSummary {
pending: number;
active: number;
mine: number;
/** Unfiltered, non-dismissed totals for the request-page tabs. */
mineTotal: number;
allTotal: number;
}
/**
* Columns the request list can be ordered by. Shared so the table cannot offer a sort the query
* does not know how to build, and so a rename breaks the client at compile time.
*/
export const BOOK_REQUEST_SORT_FIELDS = ["createdAt", "title", "mediaKind", "requester", "status"] as const;
export type BookRequestSortField = (typeof BOOK_REQUEST_SORT_FIELDS)[number];
export const BOOK_REQUEST_SORT_DIRECTIONS = ["asc", "desc"] as const;
export type BookRequestSortDirection = (typeof BOOK_REQUEST_SORT_DIRECTIONS)[number];
/** One page of the queue is the most a selection can hold, so the batch stays bounded. */
export const BOOK_REQUEST_BULK_LIMIT = 100;
export interface BulkBookRequestsPayload {
ids: number[];
}
/**
* The one bulk action that carries more than ids. A rejection is a sentence to the people who
* asked, and refusing forty requests for the same reason is exactly when writing it forty times
* is the wrong shape.
*/
export interface BulkRejectBookRequestsPayload extends BulkBookRequestsPayload {
decisionNote?: string | null;
}
export interface BookRequestBulkFailure {
id: number;
/** Named rather than numbered, because the message that carries this is read by a person. */
title: string;
reason: string;
errorCode: BookRequestActionErrorCode | BookRequestSubmitErrorCode | null;
errorMeta: BookRequestFailureMeta | null;
}
/**
* A bulk action is not atomic. One request may have no destination library while the rest are
* fine, and failing the whole batch over it would be worse than reporting it, so every id comes
* back in exactly one of the two lists.
*/
export interface BookRequestBulkResult {
updated: BookRequestItem[];
failed: BookRequestBulkFailure[];
}
+13 -3
View File
@@ -13,16 +13,22 @@ import type { SeriesIndex } from "./series-index";
export const BOOK_FORMATS = DEFAULT_FORMAT_PRIORITY;
export type BookFormat = (typeof BOOK_FORMATS)[number];
const AUDIO_FORMATS = new Set<string>(["m4b", "mp3", "m4a", "opus", "ogg", "flac"]);
/** Exported as an ordered list too, so a form offering these cannot drift from what matches them. */
export const AUDIO_FORMAT_LIST = ["m4b", "mp3", "m4a", "opus", "ogg", "flac"] as const;
const AUDIO_FORMATS = new Set<string>(AUDIO_FORMAT_LIST);
export function isAudioFormat(format: string): boolean {
return AUDIO_FORMATS.has(format.toLowerCase());
}
const COMIC_FORMATS = new Set<string>(["cbz", "cbr", "cb7", "cbx"]);
export const COMIC_FORMAT_LIST = ["cbz", "cbr", "cb7", "cbx"] as const;
const COMIC_FORMATS = new Set<string>(COMIC_FORMAT_LIST);
export function isComicFormat(format: string): boolean {
return COMIC_FORMATS.has(format.toLowerCase());
}
/** What BookOrbit accepts as an ebook, and what an ebook tier may therefore ask for. */
export const EBOOK_FORMAT_LIST = ["epub", "kepub", "mobi", "azw3", "azw", "fb2", "pdf", "djvu"] as const;
export const READ_STATUSES = ["unread", "want_to_read", "reading", "on_hold", "rereading", "read", "skimmed", "abandoned"] as const;
export type ReadStatus = (typeof READ_STATUSES)[number];
export type ReadStatusSource = "auto" | "manual";
@@ -80,7 +86,11 @@ export type BookFileRef = {
sizeBytes: number | null;
};
export type BookMediaKind = "ebook" | "audiobook" | "comic" | "unknown";
/** The kinds a real file can be. `BookMediaKind` adds the case where no format identifies one. */
export const CONCRETE_BOOK_MEDIA_KINDS = ["ebook", "audiobook", "comic"] as const;
export type ConcreteBookMediaKind = (typeof CONCRETE_BOOK_MEDIA_KINDS)[number];
export type BookMediaKind = ConcreteBookMediaKind | "unknown";
export type BookMediaProfile = {
primaryMediaKind: BookMediaKind;
+8
View File
@@ -3,6 +3,13 @@ export interface ContentFilterRules {
excludeTagIds: number[];
includeGenreIds: number[];
excludeGenreIds: number[];
/**
* When set, a book that fulfilled a request this user made or joined is exempt from every rule
* above.
* Carried on the rules rather than passed alongside them so the ~30 call sites that already
* thread a `ContentFilterRules` through need no change; absent means today's behaviour.
*/
exemptRequestsFromUserId?: number;
}
export interface ContentFilterNamedItem {
@@ -15,6 +22,7 @@ export interface ContentFilterRulesWithNames {
excludeTags: ContentFilterNamedItem[];
includeGenres: ContentFilterNamedItem[];
excludeGenres: ContentFilterNamedItem[];
seeOwnRequestedBooks: boolean;
}
export const EMPTY_CONTENT_FILTER_RULES: ContentFilterRules = {
+370
View File
@@ -0,0 +1,370 @@
import { REQUEST_CREDENTIAL_ERROR_CODES } from "./request-credential";
import type { IndexerColor, ReleaseUnitChoice } from "./indexer";
/**
* External clients an operator configures. A direct file is fetched by BookOrbit itself and is
* deliberately absent: it has no address, no credentials and nothing to choose, so making it a
* configurable client type would only ask an operator to create a record of nothing.
*/
export const DOWNLOAD_CLIENT_TYPES = ["qbittorrent", "transmission", "deluge"] as const;
export type DownloadClientType = (typeof DOWNLOAD_CLIENT_TYPES)[number];
/**
* What a client can be handed. A torrent client cannot fetch a URL and a plain downloader cannot
* join a swarm, so this is what decides which configured client a given grab goes to rather than
* simply taking the highest-priority enabled row.
*/
export type DownloadDelivery = "torrent" | "file";
export const DOWNLOAD_CLIENT_DELIVERY: Record<DownloadClientType, DownloadDelivery> = {
qbittorrent: "torrent",
transmission: "torrent",
deluge: "torrent",
};
export interface DownloadClientPathMapping {
id: number;
/** The prefix the download client reports, in its own filesystem namespace. */
remotePath: string;
/** What BookOrbit has to open instead. Also the containment root for anything under it. */
localPath: string;
}
export interface DownloadClientPathMappingInput {
remotePath: string;
localPath: string;
}
/**
* Stable codes for the failures the settings form has to explain in the operator's own language.
* The English `message` alongside them stays useful in logs and for anything unmapped.
*/
export const DOWNLOAD_CLIENT_ERROR_CODES = [
"DOWNLOAD_CLIENT_NAME_TAKEN",
"DOWNLOAD_CLIENT_URL_UNSAFE",
"DOWNLOAD_CLIENT_URL_PRIVATE",
"DOWNLOAD_CLIENT_PATH_NOT_ABSOLUTE",
"DOWNLOAD_CLIENT_MAPPING_REQUIRED",
/** The test ran and the client refused or could not be reached. Carries the adapter's reason. */
"DOWNLOAD_CLIENT_TEST_FAILED",
"DOWNLOAD_CLIENT_RECONCILIATION_UNSUPPORTED",
"DOWNLOAD_CLIENT_RECONCILIATION_NOT_ORPHAN",
"DOWNLOAD_CLIENT_RECONCILIATION_NOT_ADOPTABLE",
...REQUEST_CREDENTIAL_ERROR_CODES,
] as const;
export type DownloadClientErrorCode = (typeof DOWNLOAD_CLIENT_ERROR_CODES)[number];
export interface DownloadClientItem {
id: number;
name: string;
color: IndexerColor | null;
adapterType: DownloadClientType;
enabled: boolean;
priority: number;
baseUrl: string;
username: string | null;
/** The password itself never leaves the server; this only says whether one is stored. */
hasPassword: boolean;
category: string;
useHardlinks: boolean;
/** Download clients usually sit on the LAN, so reaching a private address is opt-in per row. */
allowPrivateAddress: boolean;
lastTestedAt: string | null;
lastTestOk: boolean | null;
lastErrorMessage: string | null;
pathMappings: DownloadClientPathMapping[];
createdAt: string;
updatedAt: string;
}
/**
* What an approver is allowed to see: enough to pick a client, and nothing about how to reach it.
* Managing the rows themselves needs `ManageAppSettings`; picking one needs only
* `ManageBookRequests`, and a name is not a credential.
*/
export interface DownloadClientSummary {
id: number;
name: string;
color: IndexerColor | null;
}
/**
* The list plus the one piece of instance state the form needs up front: without an encryption
* key a password cannot be stored, and saying so beats a 400 after the operator has typed one.
*/
export interface DownloadClientListResult {
clients: DownloadClientItem[];
encryptionConfigured: boolean;
}
export interface DownloadClientReconciliationAttempt {
downloadId: number;
requestId: number;
requestTitle: string;
status: BookRequestDownloadStatus;
}
export interface DownloadClientReconciliationItem {
infoHash: string;
name: string;
state: "queued" | "downloading" | "completed" | "failed" | "unknown";
progressPercent: number;
trackedAttempt: DownloadClientReconciliationAttempt | null;
adoptableAttempts: DownloadClientReconciliationAttempt[];
}
export interface DownloadClientReconciliationResult {
clientId: number;
supported: boolean;
ownershipMarker: string;
truncated: boolean;
items: DownloadClientReconciliationItem[];
missingAttempts: DownloadClientReconciliationAttempt[];
}
export interface AdoptDownloadClientItemPayload {
downloadId: number;
}
export interface RemoveOrphanedDownloadClientItemPayload {
deleteFiles?: boolean;
}
export interface CreateDownloadClientPayload {
name: string;
color?: IndexerColor | null;
adapterType: DownloadClientType;
baseUrl: string;
username?: string | null;
password?: string | null;
enabled?: boolean;
priority?: number;
category?: string;
useHardlinks?: boolean;
allowPrivateAddress?: boolean;
pathMappings?: DownloadClientPathMappingInput[];
}
export type UpdateDownloadClientPayload = Partial<CreateDownloadClientPayload>;
export interface DownloadClientTestResult {
success: boolean;
/** Client-reported version on success, so the operator can see what answered. */
version?: string;
error?: string;
}
/**
* Whether a hardlink from this mapping into the Book Dock actually works, established by making
* one rather than by comparing device ids: two bind mounts of one filesystem share a device and
* still refuse the link. A refusal means every import copies instead, which is supported but
* doubles the space.
*/
export const PATH_MAPPING_HARDLINK_FAILURES = ["download_dir_unwritable", "link_refused"] as const;
/** Why a link could not be made, as a stable code so the settings form can translate it. */
export type PathMappingHardlinkFailure = (typeof PATH_MAPPING_HARDLINK_FAILURES)[number];
export interface PathMappingHardlinkTestResult {
localPathExists: boolean;
bookDockPathExists: boolean;
hardlinkWorks: boolean;
failure?: PathMappingHardlinkFailure;
/** The OS error behind `failure`, EXDEV or EPERM say. Shown verbatim; not translated. */
errorCode?: string;
/** Set only when the request itself failed, so the form can say something rather than nothing. */
error?: string;
}
export const BOOK_REQUEST_DOWNLOAD_STATUSES = [
"queued",
"downloading",
"completed",
"importing",
/** Imported, but the verification score held it in the Book Dock for a human to look at. */
"needs_review",
"imported",
"failed",
] as const;
export type BookRequestDownloadStatus = (typeof BOOK_REQUEST_DOWNLOAD_STATUSES)[number];
/** Statuses the poll loop still has to ask the download client about. */
export const ACTIVE_BOOK_REQUEST_DOWNLOAD_STATUSES: readonly BookRequestDownloadStatus[] = ["queued", "downloading"];
/**
* Statuses that still have work behind them: a transfer running, or finished bytes waiting on an
* import. Removing one of these from its client takes the attempt with it, because nothing is
* going to finish it.
*/
export const IN_FLIGHT_BOOK_REQUEST_DOWNLOAD_STATUSES: readonly BookRequestDownloadStatus[] = ["queued", "downloading", "completed", "importing"];
/**
* Statuses an attempt has not settled in. `imported` is finished with, and a `failed` one keeps
* the reason it first failed for, which is the one that explains what happened; every write that
* moves an attempt between states is conditional on it still being in one of these.
*/
export const UNSETTLED_BOOK_REQUEST_DOWNLOAD_STATUSES: readonly BookRequestDownloadStatus[] = [
...IN_FLIGHT_BOOK_REQUEST_DOWNLOAD_STATUSES,
"needs_review",
];
export const BOOK_REQUEST_DOWNLOAD_SOURCES = ["magnet", "torrent_file", "direct_url"] as const;
export type BookRequestDownloadSource = (typeof BOOK_REQUEST_DOWNLOAD_SOURCES)[number];
/** Which kind of client can carry out a grab of each source. */
export const DELIVERY_BY_DOWNLOAD_SOURCE: Record<BookRequestDownloadSource, DownloadDelivery> = {
magnet: "torrent",
torrent_file: "torrent",
direct_url: "file",
};
export interface BookRequestDownloadItem {
id: number;
requestId: number;
downloadClientId: number | null;
downloadClientName: string | null;
downloadClientColor: IndexerColor | null;
source: BookRequestDownloadSource;
/** Where the release came from, and null once that indexer row has been deleted. */
indexerId: number | null;
indexerName: string | null;
indexerColor: IndexerColor | null;
/** True when the automation picked this release, which is also what makes it eligible to retry. */
automated: boolean;
releaseTitle: string;
releaseSizeBytes: number | null;
/** Null for an attempt a source refused before there was anything to download. */
clientHash: string | null;
status: BookRequestDownloadStatus;
progressPercent: number;
downloadedBytes: number;
totalBytes: number | null;
errorMessage: string | null;
grabbedAt: string | null;
completedAt: string | null;
importedAt: string | null;
createdAt: string;
/**
* Set only while the attempt is held because the release resolved to several distinct books.
* Null everywhere else, including once one of them has been chosen.
*/
releaseUnits: ReleaseUnitChoice[] | null;
}
export interface SelectReleaseUnitPayload {
unitIndex: number;
}
/**
* What the download client says about a torrent it is still seeding. Read live when an approver
* opens a request rather than polled: a seed outlives the import by weeks, and a background poll
* for every finished download would grow without bound.
*/
export interface BookRequestSeedStatus {
downloadId: number;
downloadClientId: number;
downloadClientName: string | null;
clientHash: string;
/** False once the client has stopped the torrent, whether by goal, by pause or by error. */
seeding: boolean;
ratio: number | null;
/** What the client was told to aim for at add time, when it reports one back. */
ratioGoal: number | null;
seedingTimeSeconds: number | null;
seedingTimeGoalMinutes: number | null;
uploadedBytes: number | null;
}
/**
* Deleting the files is opt-in and separate: the seeded copy is the one BookOrbit hardlinked
* from, so removing it is a real choice rather than tidying up.
*/
export interface RemoveBookRequestDownloadPayload {
deleteFiles?: boolean;
}
export interface GrabBookRequestPayload {
/**
* Exactly one of: a picked release (`indexerId` plus `releaseGuid`), a magnet, or a .torrent.
*
* A picked release is named, never linked: the server resolves the download URL from its own
* search results, so a client cannot point the download client at an address it chose.
*/
indexerId?: number;
releaseGuid?: string;
magnet?: string;
torrentFileBase64?: string;
torrentFileName?: string;
downloadClientId?: number;
}
/**
* Why a grab could not be started, in terms of how far the refusal reaches. The English `message`
* says what happened; this says what a second attempt should do about it, which is the part a
* failover and the picker both have to act on without reading tracker prose.
*/
export const GRAB_FAILURE_CODES = [
/** The source refused this account outright, so every other release it holds is refused too. */
"GRAB_SOURCE_REFUSED",
/** The source refused a VIP-only release. Its ordinary releases are still grabbable. */
"GRAB_VIP_REQUIRED",
/** The source did not answer. Nothing else from it is worth trying in the same breath. */
"GRAB_SOURCE_UNAVAILABLE",
/** The download client would not take it, so nothing needing that client will start either. */
"GRAB_CLIENT_REFUSED",
/** The download client was temporarily unreachable or answered with a server failure. */
"GRAB_CLIENT_UNAVAILABLE",
/** This release alone: gone from the results, unimportable, or already downloading. */
"GRAB_RELEASE_REFUSED",
] as const;
export type GrabFailureCode = (typeof GRAB_FAILURE_CODES)[number];
export function grabFailureCode(value: unknown): GrabFailureCode | null {
return typeof value === "string" && (GRAB_FAILURE_CODES as readonly string[]).includes(value) ? (value as GrabFailureCode) : null;
}
/**
* Whether the refusal is a property of the source rather than of the one release. Both the
* failover and the picker skip the rest of a source's releases on these, and only these.
*/
export const SOURCE_WIDE_GRAB_FAILURE_CODES: readonly GrabFailureCode[] = ["GRAB_SOURCE_REFUSED", "GRAB_SOURCE_UNAVAILABLE"];
/** One release that was handed over and refused, kept for what it rules out about the others. */
export interface GrabRefusal {
/** Null for a hand-pasted magnet or .torrent, which came from no source in the list. */
indexerId: number | null;
code: GrabFailureCode;
}
/**
* The earlier refusal that already answers for this release, if one does, so nothing is handed
* over to be told the same thing twice.
*
* Shared because the failover and the picker have to agree about the same list on the same
* screen: the automation stops trying what this rules out, and the picker stops offering it.
* `seedsBack` is what makes a release depend on a download client, which is why a client refusing
* one torrent says nothing about a source BookOrbit downloads from itself.
*/
export function findGrabRefusal(
release: { indexerId: number; vipOnly: boolean; seedsBack: boolean },
refusals: readonly GrabRefusal[],
): GrabRefusal | null {
return (
refusals.find((refusal) => {
if (refusal.code === "GRAB_CLIENT_REFUSED" || refusal.code === "GRAB_CLIENT_UNAVAILABLE") return release.seedsBack;
if (refusal.indexerId !== release.indexerId) return false;
if (refusal.code === "GRAB_VIP_REQUIRED") return release.vipOnly;
return SOURCE_WIDE_GRAB_FAILURE_CODES.includes(refusal.code);
}) ?? null
);
}
/** Pushed over the request gateway so a card can move without polling. */
export interface BookRequestProgressEvent {
requestId: number;
downloadId: number;
status: BookRequestDownloadStatus;
progressPercent: number;
downloadedBytes: number;
totalBytes: number | null;
}
+11
View File
@@ -1,8 +1,10 @@
export * from "./audit";
export * from "./network-profile";
export * from "./account-activity";
export * from "./auth";
export * from "./theme";
export * from "./display-preferences";
export * from "./language";
export * from "./locale";
export * from "./permissions";
export * from "./content-filter";
@@ -31,6 +33,15 @@ export * from "./opds";
export * from "./kobo";
export * from "./koreader";
export * from "./book-dock";
export * from "./book-request";
export * from "./book-request-automation";
export * from "./book-request-destination";
export * from "./book-request-preferences";
export * from "./book-request-profile";
export * from "./book-request-review";
export * from "./download-client";
export * from "./indexer";
export * from "./request-credential";
export * from "./file-write";
export * from "./epub";
export * from "./author";
+599
View File
@@ -0,0 +1,599 @@
import type { BookRequestMediaKind } from "./book-request";
import type { BookRequestDownloadSource } from "./download-client";
import type { NetworkProfile } from "./network-profile";
import { REQUEST_CREDENTIAL_ERROR_CODES } from "./request-credential";
/**
* Adapter *types* live in code; configured *instances* are database rows. Nothing here is
* tracker-specific beyond the type name: BookOrbit ships adapter code, never a tracker, never a
* credential, and no indexer is preconfigured or enabled by default.
*
* Only the generic protocol is built in. Every named source, open library or otherwise, is a
* plugin loaded from disk and maintained outside this repository.
*/
export const INDEXER_ADAPTER_TYPES = ["torznab"] as const;
export type IndexerAdapterType = (typeof INDEXER_ADAPTER_TYPES)[number];
/** Maximum size of an opaque release identifier accepted from an indexer feed and grab request. */
export const MAX_INDEXER_RELEASE_GUID_LENGTH = 4_096;
/**
* A built-in type name, or the slug an externally loaded adapter chose for itself. Anywhere a
* value arrives from the database or the wire it is this, not the closed union: a row can name an
* adapter this build has never heard of, and pretending otherwise is how that row goes missing.
*/
export type IndexerAdapterTypeName = IndexerAdapterType | (string & {});
/**
* Hues an operator can assign to a source, so a release row says where it came from before the
* name is read. Stored as a slug rather than a colour: each one resolves to a token tuned per
* theme, which a hex value chosen by eye in one theme cannot be.
*
* Each slug resolves independently from the protocol colors, so the source remains identifiable
* even when a release also carries a torrent or direct-download badge.
*/
export const INDEXER_COLORS = ["blue", "indigo", "purple", "pink", "red", "orange", "yellow", "lime", "green", "teal"] as const;
export type IndexerColor = (typeof INDEXER_COLORS)[number];
/** Picks an unused source color until every palette entry has been assigned at least once. */
export function pickUnusedIndexerColor(usedColors: Iterable<IndexerColor | null | undefined>): IndexerColor {
const used = new Set(usedColors);
const unused = INDEXER_COLORS.filter((color) => !used.has(color));
const choices = unused.length > 0 ? unused : INDEXER_COLORS;
return choices[Math.floor(Math.random() * choices.length)] ?? INDEXER_COLORS[0];
}
/** Which of the indexer's own categories to search for each medium a request can ask for. */
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
* `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] },
};
/** What the credential field holds. Null where the source needs none, as an open library does. */
export const INDEXER_CREDENTIAL_KINDS: Record<IndexerAdapterType, "apiKey" | "sessionId" | null> = {
torznab: "apiKey",
};
/** Whether the adapter searches by numeric category at all, which decides if the editor shows. */
export const INDEXER_USES_CATEGORIES: Record<IndexerAdapterType, boolean> = {
torznab: true,
};
/**
* Whether a grab from this source joins a swarm. Seed goals are handed to the download client at
* add time, so for a source that serves the file itself they are not a default to fall back on,
* they are meaningless, and the form must not offer them.
*/
export const INDEXER_SEEDS_BACK: Record<IndexerAdapterType, boolean> = {
torznab: true,
};
/**
* Which media a source can actually answer for. A source that does not serve a medium is reported
* 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"],
};
/**
* Prefilled into the form where a source has one canonical address. Still editable, for mirrors.
* Empty while torznab is the only built-in: its address is the operator's own proxy, and a plugin
* carries its own `defaultBaseUrl` rather than being listed here.
*/
export const INDEXER_DEFAULT_BASE_URLS: Partial<Record<IndexerAdapterType, string>> = {};
/**
* Adapter-specific knobs. An open record rather than a fixed shape, because an adapter loaded at
* runtime declares its own fields and no compile-time type could know them. What may be stored is
* decided per adapter by its `settingsFields`, validated in the service rather than by the pipe.
*/
export type IndexerSettings = Record<string, unknown>;
/**
* One configurable field an adapter wants on the settings form beyond the shared ones. Built-in
* adapters declare these too, so the form has a single way to render both.
*/
export interface IndexerSettingsField {
key: string;
type: "boolean" | "string" | "number";
/** For a plugin this is untranslated English; a built-in prefers its own message key. */
label: string;
hint?: string;
default?: boolean | string | number;
/**
* A `string` field that holds a comma-separated list, so the form can edit it as chips rather
* than as one long line the operator has to punctuate by hand. Opt-in: it cannot be inferred
* from the value, because plenty of single values legitimately contain a comma.
*/
format?: "list";
/** When present on a list, the settings form offers choices instead of accepting arbitrary text. */
options?: readonly string[];
/** Smallest permitted list size. Constrained lists can use this to prevent an unusable empty value. */
minItems?: number;
}
/**
* Everything the settings form needs to render one adapter, served at runtime rather than
* compiled into the client, so an adapter that arrived from a plugin looks like any other.
*/
export interface IndexerAdapterDescriptor {
type: IndexerAdapterTypeName;
label: string;
/** False for an adapter loaded from the plugin directory, which the settings page says plainly. */
builtIn: boolean;
/** The plugin release. Omitted by built-ins and legacy plugins that do not declare one. */
version?: string;
requiresCredential: boolean;
credentialKind: "apiKey" | "sessionId" | null;
mediaKinds: BookRequestMediaKind[];
usesCategories: boolean;
seedsBack: boolean;
/**
* Whether this adapter can search an ISBN at all. The operator decides per source whether it
* should, because a catalogue that answers an ISBN badly is worse than one that cannot.
*/
supportsIsbnSearch: boolean;
defaultCategories: IndexerCategoryMap;
defaultBaseUrl?: string;
/** Only a plugin sends one; a built-in has a translated message keyed on its type. */
baseUrlHint?: string;
settingsFields: IndexerSettingsField[];
}
/**
* What an uploaded plugin says it is, read in a child process before the file is kept anywhere.
*
* Carries the source back with it on purpose: installing a plugin runs its code in the BookOrbit
* process, and one dependency-free file is exactly what makes reading it first realistic.
*/
export interface PluginInspection {
type: string;
label: string;
/** The plugin release. Omitted by plugins written before versions were exposed. */
version?: string;
requiresCredential: boolean;
credentialKind: "apiKey" | "sessionId" | null;
mediaKinds: BookRequestMediaKind[];
usesCategories: boolean;
seedsBack: boolean;
defaultBaseUrl?: string;
baseUrlHint?: string;
settingsFields: IndexerSettingsField[];
/** The file as uploaded, for the operator to read before confirming. */
source: string;
/** Whether a plugin of this type is already installed, so the confirmation can say "replace". */
replaces: boolean;
}
/**
* The outcome of an install. A plugin is put to work in the running process as soon as it is
* written, so `active` is normally true and the page needs to say nothing about restarting; it is
* false only when the file is on disk but would not load here, which a restart will retry.
*/
export interface PluginInstallResult extends PluginInspection {
active: boolean;
}
/** A plugin that would not load, so the settings page can say which file failed and why. */
export interface IndexerPluginFailure {
directory: string;
reason: string;
}
export const INDEXER_ERROR_CODES = [
"INDEXER_NAME_TAKEN",
"INDEXER_URL_UNSAFE",
"INDEXER_URL_PRIVATE",
"INDEXER_CREDENTIAL_REQUIRED",
"INDEXER_SETTINGS_INVALID",
/** The test ran and the source refused or could not be reached. Carries the adapter's reason. */
"INDEXER_TEST_FAILED",
...REQUEST_CREDENTIAL_ERROR_CODES,
] as const;
export type IndexerErrorCode = (typeof INDEXER_ERROR_CODES)[number];
export interface IndexerItem {
id: number;
name: string;
/** The operator's own colour for this source, or null while it has not been given one. */
color: IndexerColor | null;
adapterType: IndexerAdapterTypeName;
enabled: boolean;
baseUrl: string;
/** The key itself never leaves the server; this only says whether one is stored. */
hasCredential: boolean;
/**
* Off by default, unlike a download client: a public tracker has no business resolving to a
* private address. A self-hosted torznab proxy such as Jackett or Prowlarr is the one common
* exception, so it is an explicit per-row opt-in rather than a blanket relaxation.
*/
allowPrivateAddress: boolean;
categories: IndexerCategoryMap;
/**
* Media this source is not to be searched for, on the operator's say-so rather than the
* adapter's. An audiobook-only tracker behind a general torznab proxy is still declared as
* carrying all three, and searching it for ebooks costs a request and a slot in the merge.
*
* Stated as an opt-out so a row written before a new medium existed is not silently excluded
* from it, and because "search everything" is the state an operator who has never opened this
* expects. An empty category list cannot express it: torznab reads that as "send no `cat`",
* which most trackers answer with their whole catalogue.
*/
disabledMediaKinds: BookRequestMediaKind[];
/**
* Whether to stop handing this source an ISBN when the request states one, leaving it the title
* and author every other source gets.
*
* An opt-out for the same reason media kinds are: searching the identifier is the better default
* where a catalogue indexes it well. It is worth turning off where one does not, because an ISBN
* a catalogue holds against the wrong row returns a confidently wrong book, and no adapter can
* tell that from a right one. Ignored by a source whose adapter never searches an ISBN.
*/
isbnSearchDisabled: boolean;
/** Adapter-specific knobs. Always an object, so the form never guards on null before reading. */
settings: IndexerSettings;
/** How the server reaches this source, where the default path does not work. */
networkProfile: NetworkProfile | null;
lastTestedAt: string | null;
lastTestOk: boolean | null;
lastErrorMessage: string | null;
/**
* How the last real search against this source went, which is a different question from the
* last time somebody pressed Test: a caps call can succeed against a tracker that has refused
* every search for a week. Null until this source has been searched at least once.
*/
lastSearchAt: string | null;
lastSearchOk: boolean | null;
lastSearchError: string | null;
/** Consecutive failed searches, reset by any success. One failure is noise; a run of them is not. */
searchFailureStreak: number;
createdAt: string;
updatedAt: string;
}
/** The list plus the one instance fact the form needs up front: can a credential be stored at all. */
export interface IndexerListResult {
indexers: IndexerItem[];
encryptionConfigured: boolean;
}
/** What this install can offer, and what it tried and failed to offer. */
export interface IndexerAdapterListResult {
adapters: IndexerAdapterDescriptor[];
pluginFailures: IndexerPluginFailure[];
}
export interface CreateIndexerPayload {
name: string;
adapterType: IndexerAdapterTypeName;
baseUrl: string;
/** Null clears the colour, returning the source to the neutral chip. */
color?: IndexerColor | null;
credential?: string | null;
enabled?: boolean;
allowPrivateAddress?: boolean;
categories?: Partial<IndexerCategoryMap>;
disabledMediaKinds?: BookRequestMediaKind[];
isbnSearchDisabled?: boolean;
settings?: IndexerSettings;
networkProfile?: NetworkProfile | null;
}
export type UpdateIndexerPayload = Partial<CreateIndexerPayload>;
export interface IndexerTestResult {
success: boolean;
/** What answered, when the indexer names itself. */
indexerName?: string;
error?: string;
}
/**
* Why a release scored the way it did, as a code plus its signed contribution, so the picker can
* show the reasoning in the reader's own language rather than an English sentence from the server.
*/
export const RELEASE_SCORE_REASONS = [
"isbnMatch",
"titleMatch",
"authorMatch",
"preferredFormat",
"knownFormat",
"unknownFormat",
"expectedSize",
"suspiciousSize",
"seeders",
"freeleech",
"likelySeveralBooks",
] as const;
export type ReleaseScoreReasonCode = (typeof RELEASE_SCORE_REASONS)[number];
export interface ReleaseScoreReason {
code: ReleaseScoreReasonCode;
/** Signed contribution to the final score, already rounded. */
points: number;
/** A value the copy interpolates, such as a format or a seeder count. Never translated. */
detail?: string;
}
/**
* What an indexer publishes about a release's audio, where it publishes anything at all. Every
* field is independently optional: coverage is partial even on a tracker that reports it, and a
* missing figure must read as "not stated" rather than as a bad one.
*/
export interface ReleaseAudioInfo {
bitrateKbps: number | null;
/** "CBR" or "VBR", as the indexer words it. Never translated. */
bitrateMode: string | null;
channels: number | null;
samplingRateHz: number | null;
/**
* Only where it describes the whole release. A tracker scans one file of a multi-file set and
* reports seventeen seconds for a sixteen-hour book, so an implausible figure is dropped.
*/
durationSeconds: number | null;
chapterCount: number | null;
}
/**
* A scored release as the picker sees it. Deliberately carries no download URL: a grab names the
* indexer and the release, and the server resolves the URL from its own search results, so a
* client can never point the download client at an address it chose.
*/
export interface ReleaseCandidateItem {
indexerId: number;
indexerName: string;
guid: string;
title: string;
sizeBytes: number | null;
/** Null where the indexer did not report a swarm count at all, which is not the same as zero. */
seeders: number | null;
leechers: number | null;
/** The primary format, preferred-first, for anywhere a single value is all that fits. */
format: string | null;
/**
* Every format the release claims. A tracker that lists "azw3 epub mobi" is describing one book
* in three formats, and carrying only the first hid such a release from a filter on a format it
* actually contains, while labelling it with whichever one the tracker happened to list first.
*/
formats: string[];
language: string | null;
/**
* How many files the release carries, where the indexer states a count. Null is "not stated",
* which is not one file: a tier asking for a single file must not match a release whose source
* publishes no count at all.
*
* Counts everything the release holds, cover art and sidecars included, so it is a signal rather
* than a promise. The exact primary-file count needs the manifest, which costs a credentialed
* fetch per release and so belongs to inspection, not to search.
*/
fileCount: number | null;
freeleech: boolean;
/** The tracker will refuse the download outright unless the account is VIP. */
vipOnly: boolean;
/**
* The account behind this indexer has already snatched this release, where the source says so.
* Marked on the row and never filtered on: sources report it on a delay of minutes.
*/
alreadyGrabbed: boolean;
publishedAt: string | null;
/** Null where the indexer said nothing about the audio, which includes every ebook release. */
audio: ReleaseAudioInfo | null;
score: number;
/**
* Which profile tier this release fell into, best being 0, or null for none. Null where the
* medium has no profile at all, in which case the picker shows no tier and score alone orders
* the list, exactly as before profiles existed.
*/
tier: number | null;
/** The operator's own name for that tier, carried so the row need not resolve it. */
tierName: string | null;
reasons: ReleaseScoreReason[];
}
/** Distinct enough to act on: an expired tracker session must not look like "nothing found". */
export const INDEXER_SEARCH_FAILURES = ["unauthorized", "throttled", "timeout", "unreachable", "unsupportedMedium", "error"] as const;
export type IndexerSearchFailure = (typeof INDEXER_SEARCH_FAILURES)[number];
export interface IndexerSearchQuery {
kind: "isbn" | "titleAuthor";
/** The exact ISBN or text sent to this indexer. */
value: string;
}
export interface IndexerSearchStatus {
indexerId: number;
indexerName: string;
/**
* The source's assigned colour, carried alongside the name for the same reason `seedsBack` is:
* it belongs to the source, not to the release, and repeating it on all hundred merged releases
* would send the same slug a hundred times. Null where the operator has assigned none.
*/
color: IndexerColor | null;
ok: boolean;
/** Releases this indexer contributed after the hard filters. */
count: number;
/** Releases it returned that the hard filters dropped, so an empty list stays explainable. */
filtered: number;
/** The one query this indexer executed. Omitted only when no matching adapter was installed. */
query?: IndexerSearchQuery;
failure?: IndexerSearchFailure;
error?: string;
/**
* Whether a grab from this source joins a swarm, read off the adapter rather than the row. It
* lives here rather than on every release because it is a property of the source: it lets the
* picker tell "this release omitted its seeder count" from "this source publishes none", which
* are the same silence and completely different facts.
*/
seedsBack: boolean;
}
/** Request metadata used across indexer retrieval, hard filters, and release scoring. */
export interface ReleaseSearchCriteria {
title: string;
authors: string[];
isbn10: string | null;
isbn13: string | null;
/** The one canonical ISBN-13 used for this search, or null for a title and author search. */
activeIsbn: string | null;
/** Other canonical ISBN-13 values retained as explicit searches the user can try next. */
isbns: string[];
mediaKind: BookRequestMediaKind;
language: string | null;
preferredFormats: string[];
}
/** Explicit search inputs supplied from the release picker instead of the request snapshot. */
export interface ReleaseSearchOverrides {
title?: string;
authors?: string[];
/** One canonical ISBN-13. Null explicitly selects a title and author search. */
isbn?: string | null;
/** Null explicitly removes the request language filter. */
language?: string | null;
preferredFormats?: string[];
}
export interface ReleaseSearchResult {
releases: ReleaseCandidateItem[];
criteria: ReleaseSearchCriteria;
/** One entry per indexer that was actually searched, including the ones that failed. */
indexers: IndexerSearchStatus[];
/**
* Enabled indexers that do not carry the requested medium, and so were never searched. Neither a
* status nor a failure: nothing went wrong, and the mismatch is a permanent property of the
* source known before any request. Counted only, so an empty list can distinguish "nothing you
* have configured carries this" from "this book was not found".
*/
uncoveredIndexerCount: number;
/**
* Enabled indexers at the time of the search, counted before the medium filter. Zero means
* nothing was searched at all, which is a different sentence from "this book was not found" and
* the only way the picker can tell them apart: the indexer list itself is admin-only.
*/
enabledIndexerCount: number;
/**
* Indexer rows that exist, enabled or not. Separates "none configured" from "all switched off",
* because the second is a toggle away and telling that operator to add a source is telling them
* to duplicate one they already have.
*/
configuredIndexerCount: number;
/**
* Whether the requested medium has a release profile at all.
*
* The picker cannot work this out from the rows. A profile that nothing matched returns every
* release untiered, which is byte-for-byte what no profile returns - and those two states need
* opposite explanations, because one of them means the automation will grab nothing.
*/
profileActive: boolean;
searchedAt: string;
/** True when this came from the short-lived cache rather than a fresh hit on every tracker. */
cached: boolean;
}
/**
* How many search sources this instance has, readable by anybody who may file a request.
*
* Counts only, deliberately: names, addresses and credentials stay behind `ManageAppSettings`,
* and a requester needs no more than this to be told whether their request has anywhere to be
* fulfilled from.
*/
export interface BookRequestSourceStatus {
/** Indexer rows that exist, enabled or not. */
configured: number;
/** Of those, the ones switched on. Zero means nothing will ever be searched. */
enabled: number;
}
export interface InspectBookRequestReleasePayload {
indexerId: number;
releaseGuid: string;
}
export const RELEASE_FILE_INSPECTION_STATUSES = [
"ready",
"no_supported_file",
"multiple_supported_files",
"metadata_unavailable",
/** Packaged as RAR, ZIP or 7z: the layout is unknowable until it has been extracted. */
"contents_unknown",
] as const;
export type ReleaseFileInspectionStatus = (typeof RELEASE_FILE_INSPECTION_STATUSES)[number];
/**
* Whether a release in this state must not be sent to a download client. Only one is: a release
* with no book file in it can never become an import, so sending it only wastes a download.
*
* Everything else is sent. A magnet's layout is unknowable until the swarm answers and an
* archive's until it is opened, and a release holding several books is a question an approver
* answers once it has landed rather than a reason to throw the download away.
*
* Shared so the button that hides a grab and the guard that refuses one cannot drift apart.
*/
export function releaseInspectionBlocksGrab(status: ReleaseFileInspectionStatus | undefined): boolean {
return status === "no_supported_file";
}
export interface ReleaseManifestFile {
/** A path from untrusted torrent metadata, sanitized and bounded before it reaches the client. */
path: string;
sizeBytes: number | null;
bookFile: boolean;
}
export type ReleaseUnitMediaKind = "ebook" | "audiobook" | "comic";
/**
* One book the release resolves to. This is what lets the picker say "one audiobook of 31 tracks"
* rather than "31 supported book files", and it is what a later chooser selects between.
*/
export interface ReleaseUnitSummary {
mediaKind: ReleaseUnitMediaKind;
/** Best guess from the folder or file name, for display only. Never used for matching. */
title: string | null;
/** Content files only. Exact, and not derived from the bounded `files` display list. */
contentFileCount: number;
/** Content files plus the artwork and sidecars that travel with them. */
totalFileCount: number;
sizeBytes: number | null;
}
/**
* One candidate a release resolved to when it holds several distinct books. Carried on the
* download attempt so an approver can choose between them after the bytes have landed: failing
* instead would throw away a finished download over a question a human answers in seconds.
*/
export interface ReleaseUnitChoice extends ReleaseUnitSummary {
/** Position in the stored list. What the chooser route selects by. */
index: number;
/**
* The unit's primary file, relative to the release root. This is what a re-read of the release
* is matched against, so a choice survives the list being rebuilt. A path within the release, not
* a path on the server.
*/
primaryPath: string;
}
/** The safe, credential-free result of inspecting one selected release server-side. */
export interface ReleaseFileInspection {
source: BookRequestDownloadSource;
status: ReleaseFileInspectionStatus;
files: ReleaseManifestFile[];
/** Exact when non-null, including entries omitted from the bounded display list. */
totalFiles: number | null;
primaryFileCount: number | null;
truncated: boolean;
/** Bounded for display; `unitCount` is the exact figure. Empty when the layout is unknown. */
units: ReleaseUnitSummary[];
unitCount: number;
/** Files the interpreter discarded: samples, padding, junk directories. */
ignoredFileCount: number;
/** Archives found in the release. Non-zero with no units is what `contents_unknown` means. */
containerCount: number;
}
+123
View File
@@ -0,0 +1,123 @@
/**
* Language codes shared by the release matcher and by anything that has to offer a language to
* choose from.
*
* Here rather than in the server, because the two have to agree exactly. A request states a
* language, the matcher compares every release against it as a hard filter, and a picker offers
* the choice: if the offered list and the comparable set ever drift apart, a user picks a language
* that silently matches nothing and the request comes back empty for a book that is right there.
*/
/**
* Three-letter codes that do not simply truncate to their two-letter form.
*
* Sources state either. A metadata provider writes "spa" where a request states "es", and
* truncating "spa" to "sp" would hard-filter out every Spanish release. Both the bibliographic and
* terminological variants are listed, since sources use either.
*/
export const ISO_639_2_TO_1: Record<string, string> = {
alb: "sq",
sqi: "sq",
ara: "ar",
arm: "hy",
hye: "hy",
baq: "eu",
eus: "eu",
ben: "bn",
bul: "bg",
bur: "my",
mya: "my",
cat: "ca",
chi: "zh",
zho: "zh",
cze: "cs",
ces: "cs",
dan: "da",
dut: "nl",
nld: "nl",
est: "et",
fin: "fi",
geo: "ka",
kat: "ka",
ger: "de",
deu: "de",
gre: "el",
ell: "el",
heb: "he",
hin: "hi",
hrv: "hr",
hun: "hu",
ice: "is",
isl: "is",
ind: "id",
ita: "it",
jpn: "ja",
kor: "ko",
lav: "lv",
lit: "lt",
mac: "mk",
mkd: "mk",
mao: "mi",
mri: "mi",
may: "ms",
msa: "ms",
nor: "no",
per: "fa",
fas: "fa",
pol: "pl",
por: "pt",
rum: "ro",
ron: "ro",
rus: "ru",
slo: "sk",
slk: "sk",
slv: "sl",
spa: "es",
srp: "sr",
swe: "sv",
tha: "th",
tib: "bo",
bod: "bo",
tur: "tr",
ukr: "uk",
vie: "vi",
wel: "cy",
cym: "cy",
};
/**
* Languages that never needed a mapping, because their three-letter form truncates to the right
* two letters on its own. Listed because the map is not a catalogue of supported languages, only of
* the awkward ones, so deriving the offered list from it alone would omit these.
*/
const SELF_TRUNCATING_CODES = ["en", "fr", "af", "ga", "gl", "la", "nn", "sw", "ta", "te", "ur", "bs", "eo", "lb", "mt", "nb"];
/** The two-letter codes offered when choosing a language. */
export const REQUEST_LANGUAGE_CODES: readonly string[] = [...new Set([...Object.values(ISO_639_2_TO_1), ...SELF_TRUNCATING_CODES])].sort();
/** Compares only the language subtag, so "en" and "eng" and "en-GB" all agree. */
export function normalizeLanguage(value: string): string {
const subtag = value.toLowerCase().split(/[-_]/)[0];
return ISO_639_2_TO_1[subtag] ?? subtag.slice(0, 2);
}
/** Whether a release's stated language is one the request can accept. */
export function languagesAgree(requested: string, released: string): boolean {
return normalizeLanguage(requested) === normalizeLanguage(released);
}
/**
* A language as a code the request can carry, or null.
*
* Deliberately not restricted to `REQUEST_LANGUAGE_CODES`. That list is what a person is *offered*,
* and it is curated; this is what a *provider* may already have stated. Narrowing here to the
* offered list would silently drop the language from any request for a book in something the
* dropdown happens not to list, turning a filter the matcher handled perfectly well into no filter
* at all. Anything that normalises to a plausible subtag is kept, and the matcher compares it the
* same way it always did.
*/
export function toRequestLanguage(value: string | null | undefined): string | null {
if (!value) return null;
const code = normalizeLanguage(value.trim());
return /^[a-z]{2}$/.test(code) ? code : null;
}
+2
View File
@@ -99,6 +99,8 @@ export interface MetadataProviderInfo {
label: string;
identifiable: boolean;
selectedByFieldRules?: boolean;
/** Zero-based priority for the effective Cover field rule. Absent when the provider is not used for covers. */
coverPriority?: number;
}
/**
+26
View File
@@ -0,0 +1,26 @@
/**
* How the server should reach one configured source, where the default path does not work.
*
* Operator configuration, never something a plugin or an adapter chooses for itself: an adapter
* asks for a URL and the host decides how to get there. That split is what keeps the containment
* check meaningful, because the address actually connected to is still checked against the same
* private-address policy whichever resolver or proxy produced it.
*/
export interface NetworkProfile {
/**
* DNS servers to use instead of the system resolver, as plain addresses. For a source whose
* domain the host's own resolver refuses, or filters.
*/
resolvers?: string[];
/**
* Egress through an HTTP proxy, as `http://host:port` with optional credentials. SOCKS is not
* supported: it needs a different dispatcher, and a proxy container that speaks HTTP is the
* common self-hosted shape.
*/
proxyUrl?: string;
}
/** Nothing configured is the normal case, and must cost nothing at all. */
export function hasNetworkProfile(profile: NetworkProfile | null | undefined): boolean {
return Boolean(profile && ((profile.resolvers && profile.resolvers.length > 0) || profile.proxyUrl));
}
+15
View File
@@ -7,6 +7,13 @@ export const NotificationType = {
MetadataFetchFailed: "metadata_fetch_failed",
BookDockFinalized: "book_dock_finalized",
BookDockFinalizedWithErrors: "book_dock_finalized_with_errors",
BookRequestSubmitted: "book_request_submitted",
BookRequestApproved: "book_request_approved",
BookRequestRejected: "book_request_rejected",
BookRequestAvailable: "book_request_available",
BookRequestNeedsReview: "book_request_needs_review",
BookRequestNeedsRelease: "book_request_needs_release",
BookRequestFailed: "book_request_failed",
AuthorEnrichmentCompleted: "author_enrichment_completed",
AuthorEnrichmentFailed: "author_enrichment_failed",
EmailSent: "email_sent",
@@ -41,6 +48,7 @@ export const NOTIFICATION_CATEGORY_IDS = [
"scanning",
"metadata",
"bookDock",
"bookRequests",
"authorEnrichment",
"email",
"migration",
@@ -71,6 +79,13 @@ export const NOTIFICATION_TYPE_META: Record<NotificationType, NotificationTypeMe
[NotificationType.MetadataFetchFailed]: { category: "metadata", severity: "warning" },
[NotificationType.BookDockFinalized]: { category: "bookDock", severity: "success" },
[NotificationType.BookDockFinalizedWithErrors]: { category: "bookDock", severity: "warning" },
[NotificationType.BookRequestSubmitted]: { category: "bookRequests", severity: "success" },
[NotificationType.BookRequestApproved]: { category: "bookRequests", severity: "success" },
[NotificationType.BookRequestRejected]: { category: "bookRequests", severity: "warning" },
[NotificationType.BookRequestAvailable]: { category: "bookRequests", severity: "success" },
[NotificationType.BookRequestNeedsReview]: { category: "bookRequests", severity: "warning" },
[NotificationType.BookRequestNeedsRelease]: { category: "bookRequests", severity: "warning" },
[NotificationType.BookRequestFailed]: { category: "bookRequests", severity: "error" },
[NotificationType.AuthorEnrichmentCompleted]: { category: "authorEnrichment", severity: "success" },
[NotificationType.AuthorEnrichmentFailed]: { category: "authorEnrichment", severity: "warning" },
[NotificationType.EmailSent]: { category: "email", severity: "success" },
+39
View File
@@ -5,6 +5,7 @@ export enum Permission {
LibraryEditMetadata = "library_edit_metadata",
LibraryDeleteBooks = "library_delete_books",
BookDockAccess = "book_dock_access",
BookRequestAccess = "book_request_access",
DemoRestricted = "demo_restricted",
// Devices & Access
@@ -25,6 +26,9 @@ export enum Permission {
ManageIcons = "manage_icons",
ManageAppSettings = "manage_app_settings",
ManageBookDock = "manage_book_dock",
ManageBookRequests = "manage_book_requests",
BookRequestAutoApprove = "book_request_auto_approve",
BookRequestSelfFulfill = "book_request_self_fulfill",
ManageUsers = "manage_users",
ViewUserActivity = "view_user_activity",
ViewAuditLog = "view_audit_log",
@@ -39,6 +43,7 @@ export const PERMISSION_LABELS: Record<Permission, string> = {
[Permission.LibraryEditMetadata]: "Edit metadata",
[Permission.LibraryDeleteBooks]: "Delete books",
[Permission.BookDockAccess]: "Book Dock",
[Permission.BookRequestAccess]: "Request books",
[Permission.DemoRestricted]: "Demo restricted",
[Permission.KoboSync]: "Kobo sync",
[Permission.KoreaderSync]: "KOReader sync",
@@ -53,8 +58,42 @@ export const PERMISSION_LABELS: Record<Permission, string> = {
[Permission.ManageIcons]: "Manage icons",
[Permission.ManageAppSettings]: "App settings",
[Permission.ManageBookDock]: "Manage Book Dock",
[Permission.ManageBookRequests]: "Manage book requests",
[Permission.BookRequestAutoApprove]: "Auto-approve requests",
[Permission.BookRequestSelfFulfill]: "Download books directly",
[Permission.ManageUsers]: "Manage users",
[Permission.ViewUserActivity]: "View user activity",
[Permission.ViewAuditLog]: "View audit log",
[Permission.NotificationAccess]: "Notifications",
};
/**
* Permissions that are inert without another one, and the dependency is enforced when permissions
* are assigned rather than implied when they are checked.
*
* Implying at check time would be the shorter fix and the wrong one: a token holding only
* `BookRequestSelfFulfill` would start passing every `BookRequestAccess` route, which is exactly
* the claim the authorization matrix exists to make and would no longer be true.
*/
export const PERMISSION_REQUIRES: Partial<Record<Permission, readonly Permission[]>> = {
// Self-fulfilment adds a way to fulfil a request. Listing, viewing and the live queue all
// answer to `BookRequestAccess`, so granting one without the other buys nothing at all.
[Permission.BookRequestSelfFulfill]: [Permission.BookRequestAccess],
// Moderating the queue is a superset of using it, not a separate thing: the summary, the detail
// route, the websocket, and the moderator branches of cancel and language all sit behind
// `BookRequestAccess`. Granted alone this produced an approver with no sidebar entry who was
// refused by every route except the queue list.
[Permission.ManageBookRequests]: [Permission.BookRequestAccess],
// Auto-approve only decides what happens to a request the holder submits, so it says nothing
// at all without the permission that lets them submit one.
[Permission.BookRequestAutoApprove]: [Permission.BookRequestAccess],
};
/** Every permission the given selection implies must also be held, itself included. */
export function withRequiredPermissions(permissions: readonly Permission[]): Permission[] {
const resolved = new Set<Permission>(permissions);
for (const permission of permissions) {
for (const required of PERMISSION_REQUIRES[permission] ?? []) resolved.add(required);
}
return [...resolved];
}
+8
View File
@@ -0,0 +1,8 @@
/**
* Failures raised by the shared credential encryption used for every third-party secret this
* feature holds - tracker API keys and download client passwords alike. They live on their own
* rather than inside either area's list because one service raises them for both, and a settings
* form that only knew the download-client spelling would show an indexer save failing in English.
*/
export const REQUEST_CREDENTIAL_ERROR_CODES = ["REQUEST_ENCRYPTION_KEY_MISSING", "REQUEST_ENCRYPTION_KEY_CHANGED"] as const;
export type RequestCredentialErrorCode = (typeof REQUEST_CREDENTIAL_ERROR_CODES)[number];