@ctrl/sabnzbd 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Scott Cooper
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,207 @@
1
+ # SABnzbd
2
+
3
+ > TypeScript api wrapper for [SABnzbd](https://sabnzbd.org/) using [ofetch](https://github.com/unjs/ofetch)
4
+
5
+ ### Overview
6
+
7
+ Includes the normalized usenet API shared with `@ctrl/nzbget`:
8
+
9
+ - [`getAllData()`](#getalldata)
10
+ - [`getQueue()`](#getqueue)
11
+ - [`getHistory()`](#gethistory)
12
+ - [`getQueueJob(id)`](#getqueuejobid)
13
+ - [`getHistoryJob(id)`](#gethistoryjobid)
14
+ - [`findJob(id)`](#findjobid)
15
+ - [`addNzbFile(...)` / `addNzbUrl(...)`](#addnzbfile--addnzburl)
16
+ - [`normalizedAddNzb(...)`](#normalizedaddnzb)
17
+ - queue control methods return `boolean`
18
+ - `addNzbFile` and `addNzbUrl` return the normalized queue id as a `string`
19
+
20
+ Use the normalized methods by default. Drop to the native SABnzbd methods only when you need SAB-specific behavior such as script changes, post-process changes, rename operations, or raw queue/history responses.
21
+
22
+ ### Install
23
+
24
+ ```console
25
+ npm install @ctrl/sabnzbd
26
+ ```
27
+
28
+ ### Use
29
+
30
+ ```ts
31
+ import { Sabnzbd } from '@ctrl/sabnzbd';
32
+
33
+ const client = new Sabnzbd({
34
+ baseUrl: 'http://localhost:8080/',
35
+ apiKey: 'api-key',
36
+ });
37
+
38
+ async function main() {
39
+ const data = await client.getAllData();
40
+ console.log(data.queue);
41
+ }
42
+ ```
43
+
44
+ ### Normalized Example
45
+
46
+ ```ts
47
+ import { Sabnzbd, UsenetNotFoundError, UsenetPriority } from '@ctrl/sabnzbd';
48
+
49
+ const client = new Sabnzbd({
50
+ baseUrl: 'http://localhost:8080/',
51
+ apiKey: 'api-key',
52
+ });
53
+
54
+ async function main() {
55
+ const id = await client.addNzbUrl('https://example.test/release.nzb', {
56
+ category: 'movies',
57
+ priority: UsenetPriority.high,
58
+ startPaused: false,
59
+ });
60
+
61
+ try {
62
+ const job = await client.getQueueJob(id);
63
+ console.log(job.state, job.progress);
64
+ } catch (error) {
65
+ if (error instanceof UsenetNotFoundError) {
66
+ console.log('job missing', error.id);
67
+ }
68
+ }
69
+ }
70
+ ```
71
+
72
+ ### API
73
+
74
+ Docs: https://sabnzbd.ep.workers.dev
75
+ SABnzbd API Docs: https://sabnzbd.org/wiki/configuration/4.5/api
76
+
77
+ ### Normalized Methods
78
+
79
+ ##### `getAllData()`
80
+
81
+ Returns queue, history, categories, scripts, and status in normalized form. This is the broadest normalized read and fits best when you want an overview in one call.
82
+
83
+ ##### `getQueue()`
84
+
85
+ Returns normalized active queue items.
86
+
87
+ ##### `getHistory()`
88
+
89
+ Returns normalized history items.
90
+
91
+ ##### `getQueueJob(id)`
92
+
93
+ Returns one normalized active queue item. Missing ids throw `UsenetNotFoundError`.
94
+
95
+ ##### `getHistoryJob(id)`
96
+
97
+ Returns one normalized history item. Missing ids throw `UsenetNotFoundError`.
98
+
99
+ ##### `findJob(id)`
100
+
101
+ Searches queue first, then history, and returns `{ source, job }` or `null`. It is the convenient path when you do not know which side the id should be on.
102
+
103
+ ##### `addNzbFile(...)` / `addNzbUrl(...)`
104
+
105
+ Add an NZB and return the normalized queue id as a `string`. These are the lighter add helpers when an id is enough.
106
+ The normalized add option names are `category`, `priority`, `postProcess`, `postProcessScript`, `name`, `password`, and `startPaused`.
107
+
108
+ ##### `normalizedAddNzb(...)`
109
+
110
+ Add an NZB from either a URL or file content and return the created normalized queue item. This is the higher-level add helper when you want the normalized job back immediately.
111
+
112
+ ##### Normalized state labels
113
+
114
+ `stateMessage` uses the shared `UsenetStateMessage` vocabulary:
115
+ `Grabbing`, `Queued`, `Downloading`, `Paused`, `Post-processing`, `Completed`, `Failed`, `Warning`, `Deleted`, and `Unknown`.
116
+
117
+ ### Native API
118
+
119
+ SABnzbd-specific methods are still available when you need the raw client surface.
120
+
121
+ Connection and discovery:
122
+
123
+ - `auth()`
124
+ - `getVersion()`
125
+ - `getFullStatus()`
126
+ - `getWarnings()`
127
+ - `clearWarnings()`
128
+ - `getServerStats()`
129
+ - `listQueue(query?)`
130
+ - `listHistory(query?)`
131
+ - `getCategories()`
132
+ - `getScripts()`
133
+ - `getFiles(id)`
134
+
135
+ Queue and job mutation:
136
+
137
+ - `deleteJob(id, deleteFiles?)`
138
+ - `shutdown()`
139
+ - `restart()`
140
+ - `restartRepair()`
141
+ - `pausePostProcessing()`
142
+ - `resumePostProcessing()`
143
+ - `fetchRss()`
144
+ - `scanWatchedFolder()`
145
+ - `resetQuota()`
146
+ - `changeCategory(id, category)`
147
+ - `changeScript(id, script)`
148
+ - `changePriority(id, priority)`
149
+ - `changePostProcess(id, postProcess)`
150
+ - `renameJob(id, name, password?)`
151
+ - `setSpeedLimit(limit)`
152
+
153
+ Raw add methods:
154
+
155
+ - `addUrl(url, options?)`
156
+ - `addFile(nzb, options?)`
157
+
158
+ ##### export and create from state
159
+
160
+ ```ts
161
+ const state = client.exportState();
162
+ const restored = Sabnzbd.createFromState(config, state);
163
+ ```
164
+
165
+ ### Local Integration Testing
166
+
167
+ Use a disposable SABnzbd instance on `localhost:8080` with its config mounted at `/tmp/sabnzbd-local-test`.
168
+
169
+ ```console
170
+ docker run -d --name sabnzbd-local-test \
171
+ -p 8080:8080 \
172
+ -v /tmp/sabnzbd-local-test:/config \
173
+ lscr.io/linuxserver/sabnzbd:latest
174
+ ```
175
+
176
+ Wait for first-run setup to create `sabnzbd.ini`:
177
+
178
+ ```console
179
+ ls -l /tmp/sabnzbd-local-test/sabnzbd.ini
180
+ ```
181
+
182
+ Read the generated API key:
183
+
184
+ ```console
185
+ docker exec sabnzbd-local-test sed -n 's/^api_key = //p' /config/sabnzbd.ini
186
+ ```
187
+
188
+ Run only the integration spec:
189
+
190
+ ```console
191
+ TEST_SABNZBD_URL=http://127.0.0.1:8080 \
192
+ TEST_SABNZBD_API_KEY=$(docker exec sabnzbd-local-test sed -n 's/^api_key = //p' /config/sabnzbd.ini) \
193
+ pnpm test test/integration.spec.ts
194
+ ```
195
+
196
+ Run the full test suite:
197
+
198
+ ```console
199
+ TEST_SABNZBD_URL=http://127.0.0.1:8080 \
200
+ TEST_SABNZBD_API_KEY=$(docker exec sabnzbd-local-test sed -n 's/^api_key = //p' /config/sabnzbd.ini) \
201
+ pnpm test
202
+ ```
203
+
204
+ The integration spec in [`test/integration.spec.ts`](/Users/scooper/gh/sabnzbd/test/integration.spec.ts) defaults to this exact setup:
205
+
206
+ - `baseUrl` defaults to `http://127.0.0.1:8080`
207
+ - `apiKey` is read from `/tmp/sabnzbd-local-test/sabnzbd.ini` if `TEST_SABNZBD_API_KEY` is unset
@@ -0,0 +1,4 @@
1
+ export * from '@ctrl/shared-usenet';
2
+ export * from './types.js';
3
+ export * from './normalizeUsenetData.js';
4
+ export * from './sabnzbd.js';
@@ -0,0 +1,4 @@
1
+ export * from '@ctrl/shared-usenet';
2
+ export * from './types.js';
3
+ export * from './normalizeUsenetData.js';
4
+ export * from './sabnzbd.js';
@@ -0,0 +1,7 @@
1
+ import { type NormalizedUsenetHistoryItem, type NormalizedUsenetJob, type NormalizedUsenetStatus, UsenetPriority } from '@ctrl/shared-usenet';
2
+ import type { SabFullStatus, SabHistorySlot, SabPriorityValue, SabRawPriorityValue, SabQueue, SabQueueSlot } from './types.js';
3
+ export declare function sabPriorityToNormalized(priority: SabRawPriorityValue | undefined): UsenetPriority;
4
+ export declare function normalizedPriorityToSab(priority: UsenetPriority | undefined): SabPriorityValue;
5
+ export declare function normalizeSabJob(slot: SabQueueSlot): NormalizedUsenetJob;
6
+ export declare function normalizeSabHistoryItem(item: SabHistorySlot): NormalizedUsenetHistoryItem;
7
+ export declare function normalizeSabStatus(queue: SabQueue, fullStatus: SabFullStatus): NormalizedUsenetStatus;
@@ -0,0 +1,231 @@
1
+ import { UsenetJobState, UsenetPriority, UsenetStateMessage, } from '@ctrl/shared-usenet';
2
+ const BYTES_PER_MEGABYTE = 1024 * 1024;
3
+ function toNumber(value) {
4
+ const parsed = Number.parseFloat(String(value ?? '0').replaceAll(',', ''));
5
+ return Number.isFinite(parsed) ? parsed : 0;
6
+ }
7
+ function megabytesToBytes(value) {
8
+ return Math.round(toNumber(value) * BYTES_PER_MEGABYTE);
9
+ }
10
+ function parseSabDuration(value) {
11
+ if (!value) {
12
+ return 0;
13
+ }
14
+ const parts = value.split(':').map(part => Number.parseInt(part, 10));
15
+ if (parts.some(part => Number.isNaN(part))) {
16
+ return 0;
17
+ }
18
+ if (parts.length === 3) {
19
+ const [hours = 0, minutes = 0, seconds = 0] = parts;
20
+ return hours * 3600 + minutes * 60 + seconds;
21
+ }
22
+ if (parts.length === 2) {
23
+ const [minutes = 0, seconds = 0] = parts;
24
+ return minutes * 60 + seconds;
25
+ }
26
+ return parts[0] ?? 0;
27
+ }
28
+ function normalizeIsoDate(value) {
29
+ if (typeof value === 'number' || (typeof value === 'string' && /^\d+$/.test(value))) {
30
+ const numericValue = Number(value);
31
+ if (numericValue > 0) {
32
+ const timestamp = numericValue > 1_000_000_000_000 ? numericValue : numericValue * 1000;
33
+ return new Date(timestamp).toISOString();
34
+ }
35
+ }
36
+ if (typeof value === 'string' && value.trim()) {
37
+ const parsed = new Date(value);
38
+ if (!Number.isNaN(parsed.valueOf())) {
39
+ return parsed.toISOString();
40
+ }
41
+ }
42
+ return undefined;
43
+ }
44
+ export function sabPriorityToNormalized(priority) {
45
+ const normalized = Number.parseInt(String(priority ?? UsenetPriority.default), 10);
46
+ switch (normalized) {
47
+ case -4: {
48
+ return UsenetPriority.stopped;
49
+ }
50
+ case -3: {
51
+ return UsenetPriority.duplicate;
52
+ }
53
+ case -2: {
54
+ return UsenetPriority.paused;
55
+ }
56
+ case -1: {
57
+ return UsenetPriority.low;
58
+ }
59
+ case 0: {
60
+ return UsenetPriority.normal;
61
+ }
62
+ case 1: {
63
+ return UsenetPriority.high;
64
+ }
65
+ case 2: {
66
+ return UsenetPriority.force;
67
+ }
68
+ case -100: {
69
+ return UsenetPriority.default;
70
+ }
71
+ default: {
72
+ return UsenetPriority.default;
73
+ }
74
+ }
75
+ }
76
+ export function normalizedPriorityToSab(priority) {
77
+ switch (priority) {
78
+ case UsenetPriority.stopped: {
79
+ return -4;
80
+ }
81
+ case UsenetPriority.duplicate: {
82
+ return -3;
83
+ }
84
+ case UsenetPriority.paused: {
85
+ return -2;
86
+ }
87
+ case UsenetPriority.veryLow: {
88
+ return -1;
89
+ }
90
+ case UsenetPriority.low: {
91
+ return -1;
92
+ }
93
+ case UsenetPriority.normal: {
94
+ return 0;
95
+ }
96
+ case UsenetPriority.high: {
97
+ return 1;
98
+ }
99
+ case UsenetPriority.veryHigh: {
100
+ return 1;
101
+ }
102
+ case UsenetPriority.force: {
103
+ return 2;
104
+ }
105
+ case UsenetPriority.default:
106
+ default: {
107
+ return -100;
108
+ }
109
+ }
110
+ }
111
+ function mapSabStatus(status, failMessage = '') {
112
+ switch (status) {
113
+ case 'Grabbing': {
114
+ return { state: UsenetJobState.grabbing, stateMessage: UsenetStateMessage.grabbing };
115
+ }
116
+ case 'Queued': {
117
+ return { state: UsenetJobState.queued, stateMessage: UsenetStateMessage.queued };
118
+ }
119
+ case 'Paused': {
120
+ return { state: UsenetJobState.paused, stateMessage: UsenetStateMessage.paused };
121
+ }
122
+ case 'Downloading': {
123
+ return {
124
+ state: UsenetJobState.downloading,
125
+ stateMessage: UsenetStateMessage.downloading,
126
+ };
127
+ }
128
+ case 'Fetching': {
129
+ return {
130
+ state: UsenetJobState.downloading,
131
+ stateMessage: UsenetStateMessage.downloading,
132
+ };
133
+ }
134
+ case 'Propagating': {
135
+ return {
136
+ state: UsenetJobState.downloading,
137
+ stateMessage: UsenetStateMessage.downloading,
138
+ };
139
+ }
140
+ case 'Checking':
141
+ case 'QuickCheck':
142
+ case 'Verifying':
143
+ case 'Repairing':
144
+ case 'Extracting':
145
+ case 'Moving':
146
+ case 'Running': {
147
+ return {
148
+ state: UsenetJobState.postProcessing,
149
+ stateMessage: UsenetStateMessage.postProcessing,
150
+ };
151
+ }
152
+ case 'Completed': {
153
+ return { state: UsenetJobState.completed, stateMessage: UsenetStateMessage.completed };
154
+ }
155
+ case 'Failed': {
156
+ return {
157
+ state: failMessage ? UsenetJobState.error : UsenetJobState.warning,
158
+ stateMessage: failMessage ? UsenetStateMessage.failed : UsenetStateMessage.warning,
159
+ };
160
+ }
161
+ case 'Deleted': {
162
+ return { state: UsenetJobState.deleted, stateMessage: UsenetStateMessage.deleted };
163
+ }
164
+ default: {
165
+ return { state: UsenetJobState.unknown, stateMessage: UsenetStateMessage.unknown };
166
+ }
167
+ }
168
+ }
169
+ export function normalizeSabJob(slot) {
170
+ const { state, stateMessage } = mapSabStatus(slot.status);
171
+ const totalSize = megabytesToBytes(slot.mb);
172
+ const remainingSize = megabytesToBytes(slot.mbleft);
173
+ const progress = toNumber(slot.percentage);
174
+ return {
175
+ id: slot.nzo_id,
176
+ name: slot.filename,
177
+ progress,
178
+ isCompleted: progress >= 100,
179
+ category: slot.cat || '',
180
+ priority: sabPriorityToNormalized(slot.priority),
181
+ state,
182
+ stateMessage,
183
+ downloadSpeed: 0,
184
+ eta: parseSabDuration(slot.timeleft),
185
+ queuePosition: slot.index,
186
+ totalSize,
187
+ remainingSize,
188
+ savePath: undefined,
189
+ postProcessScript: slot.script,
190
+ raw: slot,
191
+ };
192
+ }
193
+ export function normalizeSabHistoryItem(item) {
194
+ const { state, stateMessage } = mapSabStatus(item.status, item.fail_message);
195
+ const totalSize = toNumber(item.bytes);
196
+ const succeeded = state === UsenetJobState.completed;
197
+ return {
198
+ id: item.nzo_id,
199
+ name: item.name || item.nzb_name || item.nzo_id,
200
+ progress: succeeded ? 100 : 0,
201
+ isCompleted: succeeded,
202
+ category: item.category || '',
203
+ priority: undefined,
204
+ state,
205
+ stateMessage,
206
+ downloadSpeed: 0,
207
+ eta: 0,
208
+ queuePosition: -1,
209
+ totalSize,
210
+ remainingSize: 0,
211
+ savePath: item.storage,
212
+ dateCompleted: normalizeIsoDate(item.completed),
213
+ postProcessScript: item.script,
214
+ failureMessage: item.fail_message,
215
+ storagePath: item.storage,
216
+ succeeded,
217
+ raw: item,
218
+ };
219
+ }
220
+ export function normalizeSabStatus(queue, fullStatus) {
221
+ return {
222
+ isDownloadPaused: Boolean(queue.paused),
223
+ speedBytesPerSecond: Math.round(toNumber(queue.kbpersec) * 1024),
224
+ totalRemainingSize: megabytesToBytes(queue.mbleft),
225
+ completeDir: fullStatus.completedir,
226
+ raw: {
227
+ queue,
228
+ fullStatus,
229
+ },
230
+ };
231
+ }