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