mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(settings): add Prowlarr and SABnzbd support (#1416)
* feat(settings): add SABnzbd download client Support API-key authentication, NZB uploads, queue monitoring, reconciliation, and completed-download imports through SABnzbd. Expose the client in request settings and validate its required category and credentials. Ref #1312 * feat(settings): add Prowlarr indexer management Manage synchronized torrent and Usenet sources through a shared Prowlarr connection. Ref #1312 * fix(settings): correct SABnzbd adoption and managed search retries Address four defects found in review of the Prowlarr and SABnzbd work. SABnzbd keeps a failed job in its history until somebody clears it, and BookOrbit removes a client download only on an explicit action. Because the client key is a stable digest, re-grabbing the same release adopted that dead history entry, never uploaded the NZB, and failed instantly and forever. Adopt only an entry that is not already failed; a queue entry still wins, so a fresh upload reports correctly. The category-expansion retry reused the title-and-author query, making it strictly narrower on text than the title-only attempt that had just returned nothing. A release matching only a title-only search with expanded categories was therefore unreachable. Drop the author there so each rung of the ladder is genuinely broader than the last. Changing the download client type through the create form's select bypassed the picker's handler, so a username typed for qBittorrent was still sent when SABnzbd was chosen. The server stores it, the API-key form hides it, and it can no longer be edited away. Watch the credential kind instead, so both controls clear it. Finally, a swallowed managed-release refresh failure left nothing behind at all; give it its own fail log while still reporting the original refusal to the caller. * fix(requests): cast managed search health flags to boolean The bulk search-health update bound each case branch as an untyped parameter, so Postgres could not resolve it against the boolean column. Cast every branch explicitly and assert the cast in the repository test. * fix(requests): close review gaps in Prowlarr and SABnzbd support Inventory the eight indexer-manager routes so the authorization matrix probes them, resolve a managed indexer without reading its manager's whole source list, keep an unreadable credential out of the test-failed mapping, translate manager error codes, and show per-source search health. Documents Prowlarr connections in docs/BOOK_REQUESTS.md. * i18n(client): keep the retired sources empty-state key Dropping bodyOneWay from en.json forced pruning it from five Crowdin target catalogs, which the localization policy check rejects outright. Leave the now-unused key in place; nothing validates unreferenced English source keys, so Crowdin can retire it on its own schedule. * db: regenerate the SABnzbd and Prowlarr migration onto main Rebasing collided with main's own 0091 and 0092, which left this branch's two migrations on disk with no journal entry referencing them. Drop both and regenerate a single migration on main's lineage; the three affected tables are schema-identical to the pre-rebase snapshot.
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", "nzbget"] as const;
|
||||
export const DOWNLOAD_CLIENT_TYPES = ["qbittorrent", "transmission", "deluge", "nzbget", "sabnzbd"] as const;
|
||||
export type DownloadClientType = (typeof DOWNLOAD_CLIENT_TYPES)[number];
|
||||
|
||||
/**
|
||||
@@ -21,6 +21,18 @@ export const DOWNLOAD_CLIENT_DELIVERY: Record<DownloadClientType, DownloadDelive
|
||||
transmission: "torrent",
|
||||
deluge: "torrent",
|
||||
nzbget: "usenet",
|
||||
sabnzbd: "usenet",
|
||||
};
|
||||
|
||||
export type DownloadClientCredentialKind = "usernamePassword" | "apiKey";
|
||||
|
||||
/** Shapes the settings form without making one daemon's authentication model universal. */
|
||||
export const DOWNLOAD_CLIENT_CREDENTIAL_KIND: Record<DownloadClientType, DownloadClientCredentialKind> = {
|
||||
qbittorrent: "usernamePassword",
|
||||
transmission: "usernamePassword",
|
||||
deluge: "usernamePassword",
|
||||
nzbget: "usernamePassword",
|
||||
sabnzbd: "apiKey",
|
||||
};
|
||||
|
||||
export interface DownloadClientPathMapping {
|
||||
@@ -46,6 +58,7 @@ export const DOWNLOAD_CLIENT_ERROR_CODES = [
|
||||
"DOWNLOAD_CLIENT_URL_PRIVATE",
|
||||
"DOWNLOAD_CLIENT_PATH_NOT_ABSOLUTE",
|
||||
"DOWNLOAD_CLIENT_MAPPING_REQUIRED",
|
||||
"DOWNLOAD_CLIENT_CREDENTIAL_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",
|
||||
|
||||
@@ -43,6 +43,7 @@ export * from "./book-request-profile";
|
||||
export * from "./book-request-review";
|
||||
export * from "./download-client";
|
||||
export * from "./indexer";
|
||||
export * from "./indexer-manager";
|
||||
export * from "./request-credential";
|
||||
export * from "./file-write";
|
||||
export * from "./epub";
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { IndexerColor } from "./indexer";
|
||||
import type { NetworkProfile } from "./network-profile";
|
||||
|
||||
export const INDEXER_MANAGER_TYPES = ["prowlarr"] as const;
|
||||
export type IndexerManagerType = (typeof INDEXER_MANAGER_TYPES)[number];
|
||||
|
||||
export interface IndexerManagerSourceItem {
|
||||
id: number;
|
||||
externalId: string;
|
||||
name: string;
|
||||
color: IndexerColor | null;
|
||||
implementation: string | null;
|
||||
protocol: "torrent" | "usenet";
|
||||
adapterType: "torznab" | "newznab";
|
||||
enabled: boolean;
|
||||
available: boolean;
|
||||
priority: number | null;
|
||||
lastSeenAt: string | null;
|
||||
lastSearchAt: string | null;
|
||||
lastSearchOk: boolean | null;
|
||||
lastSearchError: string | null;
|
||||
searchFailureStreak: number;
|
||||
}
|
||||
|
||||
export interface IndexerManagerItem {
|
||||
id: number;
|
||||
name: string;
|
||||
color: IndexerColor | null;
|
||||
type: IndexerManagerType;
|
||||
enabled: boolean;
|
||||
baseUrl: string;
|
||||
hasCredential: boolean;
|
||||
allowPrivateAddress: boolean;
|
||||
syncNewIndexers: boolean;
|
||||
perIndexerTimeoutSeconds: number;
|
||||
overallSearchBudgetSeconds: number;
|
||||
autoExpandCategories: boolean;
|
||||
inheritSeedLimits: boolean;
|
||||
networkProfile: NetworkProfile | null;
|
||||
lastTestedAt: string | null;
|
||||
lastTestOk: boolean | null;
|
||||
lastErrorMessage: string | null;
|
||||
lastSyncedAt: string | null;
|
||||
lastSyncOk: boolean | null;
|
||||
lastSyncError: string | null;
|
||||
version: string | null;
|
||||
sources: IndexerManagerSourceItem[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface IndexerManagerListResult {
|
||||
managers: IndexerManagerItem[];
|
||||
encryptionConfigured: boolean;
|
||||
}
|
||||
|
||||
export interface CreateIndexerManagerPayload {
|
||||
name: string;
|
||||
type: IndexerManagerType;
|
||||
baseUrl: string;
|
||||
color?: IndexerColor | null;
|
||||
credential: string;
|
||||
enabled?: boolean;
|
||||
allowPrivateAddress?: boolean;
|
||||
syncNewIndexers?: boolean;
|
||||
perIndexerTimeoutSeconds?: number;
|
||||
overallSearchBudgetSeconds?: number;
|
||||
autoExpandCategories?: boolean;
|
||||
inheritSeedLimits?: boolean;
|
||||
networkProfile?: NetworkProfile | null;
|
||||
}
|
||||
|
||||
export type UpdateIndexerManagerPayload = Partial<Omit<CreateIndexerManagerPayload, "type">>;
|
||||
|
||||
export interface UpdateIndexerManagerSourcePayload {
|
||||
enabled?: boolean;
|
||||
color?: IndexerColor | null;
|
||||
}
|
||||
|
||||
export interface IndexerManagerTestResult {
|
||||
success: boolean;
|
||||
version?: string;
|
||||
sourceCount?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface IndexerManagerSyncResult {
|
||||
manager: IndexerManagerItem;
|
||||
discovered: number;
|
||||
created: number;
|
||||
updated: number;
|
||||
unavailable: number;
|
||||
}
|
||||
Reference in New Issue
Block a user