feat(book-requests): add newznab and nzbget support

Support Usenet indexers via the built-in Newznab adapter and Usenet downloads via NZBGet. Generalize active download tracking from torrent client hashes to protocol-agnostic client keys, add NZB release scoring and file handling, and expose Newznab indexer and NZBGet client options in settings and grab flows.

Closes #1312
This commit is contained in:
neon
2026-09-13 06:47:06 +00:00
parent 7a203a03d7
commit fcdd72a384
76 changed files with 20792 additions and 542 deletions
+18 -11
View File
@@ -6,7 +6,7 @@ import type { IndexerColor, ReleaseUnitChoice } from "./indexer";
* 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 const DOWNLOAD_CLIENT_TYPES = ["qbittorrent", "transmission", "deluge", "nzbget"] as const;
export type DownloadClientType = (typeof DOWNLOAD_CLIENT_TYPES)[number];
/**
@@ -14,12 +14,13 @@ export type DownloadClientType = (typeof DOWNLOAD_CLIENT_TYPES)[number];
* 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 type DownloadDelivery = "torrent" | "file" | "usenet";
export const DOWNLOAD_CLIENT_DELIVERY: Record<DownloadClientType, DownloadDelivery> = {
qbittorrent: "torrent",
transmission: "torrent",
deluge: "torrent",
nzbget: "usenet",
};
export interface DownloadClientPathMapping {
@@ -86,6 +87,7 @@ export interface DownloadClientSummary {
id: number;
name: string;
color: IndexerColor | null;
delivery: DownloadDelivery;
}
/**
@@ -105,7 +107,7 @@ export interface DownloadClientReconciliationAttempt {
}
export interface DownloadClientReconciliationItem {
infoHash: string;
clientKey: string;
name: string;
state: "queued" | "downloading" | "completed" | "failed" | "unknown";
progressPercent: number;
@@ -145,7 +147,7 @@ export interface CreateDownloadClientPayload {
pathMappings?: DownloadClientPathMappingInput[];
}
export type UpdateDownloadClientPayload = Partial<CreateDownloadClientPayload>;
export type UpdateDownloadClientPayload = Partial<Omit<CreateDownloadClientPayload, "adapterType">>;
export interface DownloadClientTestResult {
success: boolean;
@@ -208,7 +210,7 @@ export const UNSETTLED_BOOK_REQUEST_DOWNLOAD_STATUSES: readonly BookRequestDownl
"needs_review",
];
export const BOOK_REQUEST_DOWNLOAD_SOURCES = ["magnet", "torrent_file", "direct_url"] as const;
export const BOOK_REQUEST_DOWNLOAD_SOURCES = ["magnet", "torrent_file", "direct_url", "nzb_file"] as const;
export type BookRequestDownloadSource = (typeof BOOK_REQUEST_DOWNLOAD_SOURCES)[number];
/** Which kind of client can carry out a grab of each source. */
@@ -216,6 +218,7 @@ export const DELIVERY_BY_DOWNLOAD_SOURCE: Record<BookRequestDownloadSource, Down
magnet: "torrent",
torrent_file: "torrent",
direct_url: "file",
nzb_file: "usenet",
};
export interface BookRequestDownloadItem {
@@ -234,7 +237,7 @@ export interface BookRequestDownloadItem {
releaseTitle: string;
releaseSizeBytes: number | null;
/** Null for an attempt a source refused before there was anything to download. */
clientHash: string | null;
clientKey: string | null;
status: BookRequestDownloadStatus;
progressPercent: number;
downloadedBytes: number;
@@ -264,7 +267,7 @@ export interface BookRequestSeedStatus {
downloadId: number;
downloadClientId: number;
downloadClientName: string | null;
clientHash: string;
clientKey: string;
/** False once the client has stopped the torrent, whether by goal, by pause or by error. */
seeding: boolean;
ratio: number | null;
@@ -333,6 +336,8 @@ export const SOURCE_WIDE_GRAB_FAILURE_CODES: readonly GrabFailureCode[] = ["GRAB
export interface GrabRefusal {
/** Null for a hand-pasted magnet or .torrent, which came from no source in the list. */
indexerId: number | null;
/** The delivery that failed. Client-wide refusals only apply to another release using this delivery. */
delivery: DownloadDelivery;
code: GrabFailureCode;
}
@@ -342,16 +347,18 @@ export interface GrabRefusal {
*
* 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.
* A client refusal is scoped to its delivery. A torrent client being unavailable says nothing
* about an NZBGet release or a source BookOrbit downloads itself.
*/
export function findGrabRefusal(
release: { indexerId: number; vipOnly: boolean; seedsBack: boolean },
release: { indexerId: number; vipOnly: boolean; delivery: DownloadDelivery },
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.code === "GRAB_CLIENT_REFUSED" || refusal.code === "GRAB_CLIENT_UNAVAILABLE") {
return release.delivery !== "file" && refusal.delivery === release.delivery;
}
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);
+18 -3
View File
@@ -1,5 +1,5 @@
import type { BookRequestMediaKind } from "./book-request";
import type { BookRequestDownloadSource } from "./download-client";
import type { BookRequestDownloadSource, DownloadDelivery } from "./download-client";
import type { NetworkProfile } from "./network-profile";
import { REQUEST_CREDENTIAL_ERROR_CODES } from "./request-credential";
@@ -8,10 +8,10 @@ import { REQUEST_CREDENTIAL_ERROR_CODES } from "./request-credential";
* 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
* Only generic protocols are 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 const INDEXER_ADAPTER_TYPES = ["torznab", "newznab"] 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. */
@@ -53,16 +53,19 @@ export type IndexerCategoryMap = Record<BookRequestMediaKind, number[]>;
*/
export const DEFAULT_INDEXER_CATEGORIES: Record<IndexerAdapterType, IndexerCategoryMap> = {
torznab: { ebook: [7020], audiobook: [3030], comic: [7030] },
newznab: { 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",
newznab: "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,
newznab: true,
};
/**
@@ -72,6 +75,13 @@ export const INDEXER_USES_CATEGORIES: Record<IndexerAdapterType, boolean> = {
*/
export const INDEXER_SEEDS_BACK: Record<IndexerAdapterType, boolean> = {
torznab: true,
newznab: false,
};
/** How a selected release from each built-in reaches BookOrbit. */
export const INDEXER_DELIVERY: Record<IndexerAdapterType, DownloadDelivery> = {
torznab: "torrent",
newznab: "usenet",
};
/**
@@ -80,6 +90,7 @@ export const INDEXER_SEEDS_BACK: Record<IndexerAdapterType, boolean> = {
*/
export const INDEXER_MEDIA_KINDS: Record<IndexerAdapterType, readonly BookRequestMediaKind[]> = {
torznab: ["ebook", "audiobook", "comic"],
newznab: ["ebook", "audiobook", "comic"],
};
/**
@@ -135,6 +146,8 @@ export interface IndexerAdapterDescriptor {
mediaKinds: BookRequestMediaKind[];
usesCategories: boolean;
seedsBack: boolean;
/** Which kind of downloader can accept a release from this source. */
delivery: DownloadDelivery;
/**
* 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.
@@ -432,6 +445,8 @@ export interface IndexerSearchStatus {
* are the same silence and completely different facts.
*/
seedsBack: boolean;
/** Which downloader capability a selected release from this source requires. */
delivery: DownloadDelivery;
}
/** Request metadata used across indexer retrieval, hard filters, and release scoring. */