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