@astrofoundry/pi-astro 0.29.1 → 0.31.0
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/README.md +5 -5
- package/agents/media.md +24 -0
- package/extensions/astro-discord/admin.ts +1 -0
- package/extensions/astro-discord/commands.ts +1 -0
- package/extensions/astro-discord/config.ts +24 -0
- package/extensions/astro-discord/daily-report.test.ts +109 -0
- package/extensions/astro-discord/daily-report.ts +122 -0
- package/extensions/astro-discord/index.ts +240 -15
- package/extensions/astro-discord/lifecycle.test.ts +7 -0
- package/extensions/astro-discord/media.test.ts +243 -0
- package/extensions/astro-discord/media.ts +311 -0
- package/extensions/astro-discord/open-work.ts +2 -1
- package/extensions/astro-discord/rest.ts +38 -0
- package/extensions/specialist-gate/approvals.test.ts +2 -0
- package/extensions/specialist-gate/index.ts +1 -0
- package/package.json +1 -1
- package/skills/arcane/SKILL.md +2 -0
- package/skills/media/SKILL.md +60 -0
- package/specialists/AGENTS.md +4 -3
- package/specialists/README.md +15 -6
- package/specialists/entry/specialist-cli.sh +1 -1
- package/specialists/install/install.sh +2 -2
- package/specialists/media/run.ts +268 -0
- package/specialists/wrappers.test.ts +122 -2
|
@@ -11,9 +11,9 @@ bin="$root/bin"
|
|
|
11
11
|
|
|
12
12
|
[ -d "$src/specialists/lib" ] || { echo "not a pi-astro package: $src" >&2; exit 2; }
|
|
13
13
|
mkdir -p "$bin" "$root/config" "$root/secrets" "$root/work"
|
|
14
|
-
rm -rf "$bin/lib" "$bin/arcane" "$bin/identity" "$bin/network" "$bin/dns" "$bin/edge" "$bin/security" "$bin/backup" "$bin/proxmox" "$bin/inference"
|
|
14
|
+
rm -rf "$bin/lib" "$bin/arcane" "$bin/identity" "$bin/network" "$bin/dns" "$bin/edge" "$bin/security" "$bin/backup" "$bin/proxmox" "$bin/inference" "$bin/media"
|
|
15
15
|
cp -R "$src/specialists/lib" "$bin/lib"
|
|
16
|
-
for s in arcane identity network dns edge security backup proxmox inference; do
|
|
16
|
+
for s in arcane identity network dns edge security backup proxmox inference media; do
|
|
17
17
|
mkdir -p "$bin/$s"
|
|
18
18
|
cp "$src/specialists/$s/run.ts" "$bin/$s/run.ts"
|
|
19
19
|
done
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { readConfig } from "../lib/config.ts";
|
|
4
|
+
import { ServiceError, UsageError } from "../lib/errors.ts";
|
|
5
|
+
import { main } from "../lib/main.ts";
|
|
6
|
+
import { printJson, printRaw } from "../lib/output.ts";
|
|
7
|
+
import { workDir } from "../lib/paths.ts";
|
|
8
|
+
import { run } from "../lib/proc.ts";
|
|
9
|
+
import { readSecret } from "../lib/secrets.ts";
|
|
10
|
+
|
|
11
|
+
const SERVICE = "media";
|
|
12
|
+
|
|
13
|
+
interface MediaConfig extends Record<string, string | number> {
|
|
14
|
+
/** Absolute path of trendarr's `dist/cli.js`; run with the node that runs this wrapper. */
|
|
15
|
+
trendarrCli: string;
|
|
16
|
+
radarrUrl: string;
|
|
17
|
+
radarrQualityProfileId: number;
|
|
18
|
+
radarrRootFolderPath: string;
|
|
19
|
+
sonarrUrl: string;
|
|
20
|
+
sonarrQualityProfileId: number;
|
|
21
|
+
sonarrRootFolderPath: string;
|
|
22
|
+
/** Sonarr monitor mode for new series: all, future, missing, firstSeason, none. */
|
|
23
|
+
sonarrMonitor: string;
|
|
24
|
+
sabnzbdUrl: string;
|
|
25
|
+
maxEntries: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const SHAPE = {
|
|
29
|
+
trendarrCli: "string",
|
|
30
|
+
radarrUrl: "string",
|
|
31
|
+
radarrQualityProfileId: "number",
|
|
32
|
+
radarrRootFolderPath: "string",
|
|
33
|
+
sonarrUrl: "string",
|
|
34
|
+
sonarrQualityProfileId: "number",
|
|
35
|
+
sonarrRootFolderPath: "string",
|
|
36
|
+
sonarrMonitor: "string",
|
|
37
|
+
sabnzbdUrl: "string",
|
|
38
|
+
maxEntries: "number",
|
|
39
|
+
} as const;
|
|
40
|
+
|
|
41
|
+
export interface MediaSecrets {
|
|
42
|
+
tmdbAccessToken: string;
|
|
43
|
+
omdbApiKey: string;
|
|
44
|
+
radarrApiKey: string;
|
|
45
|
+
sonarrApiKey: string;
|
|
46
|
+
sabnzbdApiKey: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** trendarr's `~/.trendarr/config.json`, field for field (its `src/config.ts` schema). */
|
|
50
|
+
export interface TrendarrConfig {
|
|
51
|
+
tmdb: { accessToken: string };
|
|
52
|
+
omdb: { apiKey: string };
|
|
53
|
+
radarr: { url: string; apiKey: string; qualityProfileId: number; rootFolderPath: string };
|
|
54
|
+
sonarr: { url: string; apiKey: string; qualityProfileId: number; rootFolderPath: string; monitor: string };
|
|
55
|
+
sabnzbd: { url: string; apiKey: string };
|
|
56
|
+
defaults: { maxEntries: number };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function trendarrConfig(config: MediaConfig, secrets: MediaSecrets): TrendarrConfig {
|
|
60
|
+
return {
|
|
61
|
+
tmdb: { accessToken: secrets.tmdbAccessToken },
|
|
62
|
+
omdb: { apiKey: secrets.omdbApiKey },
|
|
63
|
+
radarr: { url: config.radarrUrl, apiKey: secrets.radarrApiKey, qualityProfileId: config.radarrQualityProfileId, rootFolderPath: config.radarrRootFolderPath },
|
|
64
|
+
sonarr: { url: config.sonarrUrl, apiKey: secrets.sonarrApiKey, qualityProfileId: config.sonarrQualityProfileId, rootFolderPath: config.sonarrRootFolderPath, monitor: config.sonarrMonitor },
|
|
65
|
+
sabnzbd: { url: config.sabnzbdUrl, apiKey: secrets.sabnzbdApiKey },
|
|
66
|
+
defaults: { maxEntries: config.maxEntries },
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readMediaSecrets(): MediaSecrets {
|
|
71
|
+
return {
|
|
72
|
+
tmdbAccessToken: readSecret(SERVICE, "TMDB_ACCESS_TOKEN"),
|
|
73
|
+
omdbApiKey: readSecret(SERVICE, "OMDB_API_KEY"),
|
|
74
|
+
radarrApiKey: readSecret(SERVICE, "RADARR_API_KEY"),
|
|
75
|
+
sonarrApiKey: readSecret(SERVICE, "SONARR_API_KEY"),
|
|
76
|
+
sabnzbdApiKey: readSecret(SERVICE, "SABNZBD_API_KEY"),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Most IDs one add call takes; a batch runs one Radarr or Sonarr lookup and add per ID. */
|
|
81
|
+
export const MAX_ADD_IDS = 20;
|
|
82
|
+
const MAX_ENTRIES = 50;
|
|
83
|
+
const MAX_QUERY = 200;
|
|
84
|
+
const MINUTE = 60_000;
|
|
85
|
+
const IMDB_ID = /^tt\d{1,12}$/;
|
|
86
|
+
const TYPES = ["movies", "series"];
|
|
87
|
+
|
|
88
|
+
export type CallKind = "list" | "add" | "queue";
|
|
89
|
+
|
|
90
|
+
export interface TrendarrCall {
|
|
91
|
+
argv: string[];
|
|
92
|
+
kind: CallKind;
|
|
93
|
+
timeoutMs: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const HELP = `media specialist (trendarr: TMDB trending, OMDb ratings, Radarr and Sonarr adds, SABnzbd queue)
|
|
97
|
+
|
|
98
|
+
trending [--type movies|series] [--min-rating N] [--max-entries N]
|
|
99
|
+
TMDB weekly trending with IMDb ratings and in_library; movies under 6.5 are dropped unless --min-rating 0
|
|
100
|
+
search <query> [--type movies|series] [--max-entries N]
|
|
101
|
+
title search, ordered by relevance; use imdb_id from here or from trending for adds
|
|
102
|
+
add-movies <imdbId...> add to Radarr monitored and start a search (at most ${MAX_ADD_IDS} IDs)
|
|
103
|
+
add-series <imdbId...> add to Sonarr monitored and start a search (at most ${MAX_ADD_IDS} IDs)
|
|
104
|
+
sabnzbd status|pause|resume read or toggle the SABnzbd queue pause state
|
|
105
|
+
|
|
106
|
+
Output is trendarr's JSON plus "warnings" (trendarr's stderr warnings, such as an unreachable Radarr).
|
|
107
|
+
An add exits 0 even when an ID fails: read each results[].status (added, already_exists, not_found, error).
|
|
108
|
+
trendarr init and its configuration are operator tasks.`;
|
|
109
|
+
|
|
110
|
+
function option(args: string[], i: number, name: string): string {
|
|
111
|
+
const value = args[i + 1];
|
|
112
|
+
if (value === undefined || value.startsWith("--")) throw new UsageError(`${name} needs a value`);
|
|
113
|
+
return value;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function parseCount(value: string): number {
|
|
117
|
+
const n = Number(value);
|
|
118
|
+
if (!Number.isInteger(n) || n < 1 || n > MAX_ENTRIES) throw new UsageError(`--max-entries must be an integer from 1 to ${MAX_ENTRIES}`);
|
|
119
|
+
return n;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function parseRating(value: string): number {
|
|
123
|
+
const n = Number(value);
|
|
124
|
+
if (value.trim() === "" || !Number.isFinite(n) || n < 0 || n > 10) throw new UsageError("--min-rating must be a number from 0 to 10");
|
|
125
|
+
return n;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Flags shared by trending and search; returns the trendarr flags and the remaining positional words. */
|
|
129
|
+
function parseListFlags(args: string[], allowRating: boolean): { flags: string[]; words: string[] } {
|
|
130
|
+
const flags: string[] = [];
|
|
131
|
+
const words: string[] = [];
|
|
132
|
+
for (let i = 0; i < args.length; i++) {
|
|
133
|
+
const arg = args[i];
|
|
134
|
+
if (arg === "--type") {
|
|
135
|
+
const type = option(args, i, arg);
|
|
136
|
+
if (!TYPES.includes(type)) throw new UsageError("--type must be movies or series");
|
|
137
|
+
flags.push("--type", type);
|
|
138
|
+
i++;
|
|
139
|
+
} else if (arg === "--max-entries") {
|
|
140
|
+
flags.push("--max-entries", String(parseCount(option(args, i, arg))));
|
|
141
|
+
i++;
|
|
142
|
+
} else if (arg === "--min-rating" && allowRating) {
|
|
143
|
+
flags.push("--min-rating", String(parseRating(option(args, i, arg))));
|
|
144
|
+
i++;
|
|
145
|
+
} else if (arg.startsWith("--")) {
|
|
146
|
+
throw new UsageError(`unknown option ${arg}`);
|
|
147
|
+
} else {
|
|
148
|
+
words.push(arg);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { flags, words };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** IMDb IDs from the arguments; commas and spaces separate them, duplicates collapse. */
|
|
155
|
+
export function parseImdbIds(args: string[]): string[] {
|
|
156
|
+
const ids: string[] = [];
|
|
157
|
+
for (const piece of args.flatMap((a) => a.split(/[\s,]+/))) {
|
|
158
|
+
if (piece.length === 0) continue;
|
|
159
|
+
if (!IMDB_ID.test(piece)) throw new UsageError(`not an IMDb id: ${piece.slice(0, 40)} (expected tt followed by digits)`);
|
|
160
|
+
if (!ids.includes(piece)) ids.push(piece);
|
|
161
|
+
}
|
|
162
|
+
if (ids.length === 0) throw new UsageError("give at least one IMDb id, e.g. tt0111161");
|
|
163
|
+
if (ids.length > MAX_ADD_IDS) throw new UsageError(`at most ${MAX_ADD_IDS} IDs per call`);
|
|
164
|
+
return ids;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The trendarr argv for one wrapper call, with `--json` always last. */
|
|
168
|
+
export function buildTrendarrCall(args: string[]): TrendarrCall {
|
|
169
|
+
const [name, ...rest] = args;
|
|
170
|
+
if (name === "trending") {
|
|
171
|
+
const { flags, words } = parseListFlags(rest, true);
|
|
172
|
+
if (words.length > 0) throw new UsageError(`trending takes no words: ${words.join(" ").slice(0, 60)}`);
|
|
173
|
+
return { argv: ["trending", ...flags, "--json"], kind: "list", timeoutMs: 2 * MINUTE };
|
|
174
|
+
}
|
|
175
|
+
if (name === "search") {
|
|
176
|
+
const { flags, words } = parseListFlags(rest, false);
|
|
177
|
+
const query = words.join(" ").trim();
|
|
178
|
+
if (query.length === 0) throw new UsageError("search <query>: give a title");
|
|
179
|
+
if (query.length > MAX_QUERY) throw new UsageError(`query longer than ${MAX_QUERY} characters`);
|
|
180
|
+
if (query.startsWith("-")) throw new UsageError("query must not start with '-'");
|
|
181
|
+
if (/\p{Cc}/u.test(query)) throw new UsageError("query must not contain control characters");
|
|
182
|
+
return { argv: ["search", query, ...flags, "--json"], kind: "list", timeoutMs: 2 * MINUTE };
|
|
183
|
+
}
|
|
184
|
+
if (name === "add-movies" || name === "add-series") {
|
|
185
|
+
return { argv: [name, ...parseImdbIds(rest), "--json"], kind: "add", timeoutMs: 10 * MINUTE };
|
|
186
|
+
}
|
|
187
|
+
if (name === "sabnzbd") {
|
|
188
|
+
if (rest.length !== 1) throw new UsageError("sabnzbd status|pause|resume");
|
|
189
|
+
const action = rest[0] === "unpause" ? "resume" : rest[0];
|
|
190
|
+
if (!["status", "pause", "resume"].includes(action)) throw new UsageError("sabnzbd status|pause|resume");
|
|
191
|
+
return { argv: ["sabnzbd", action, "--json"], kind: "queue", timeoutMs: MINUTE };
|
|
192
|
+
}
|
|
193
|
+
if (name === "init") throw new UsageError("init is not available: the wrapper writes trendarr's configuration for each call from the media note and media.json");
|
|
194
|
+
throw new UsageError(`unknown command: ${name ?? "(none)"}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Warning lines trendarr wrote to stderr, without the prefix. */
|
|
198
|
+
export function trendarrWarnings(stderr: string): string[] {
|
|
199
|
+
return stderr
|
|
200
|
+
.split("\n")
|
|
201
|
+
.map((l) => l.trim())
|
|
202
|
+
.filter((l) => l.startsWith("warning: "))
|
|
203
|
+
.map((l) => l.slice("warning: ".length));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** trendarr's error text: every stderr line that is not a warning. */
|
|
207
|
+
export function trendarrError(stderr: string): string {
|
|
208
|
+
return stderr
|
|
209
|
+
.split("\n")
|
|
210
|
+
.map((l) => l.trim())
|
|
211
|
+
.filter((l) => l.length > 0 && !l.startsWith("warning: "))
|
|
212
|
+
.join("; ")
|
|
213
|
+
.slice(0, 500);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function jsonObject(text: string): Record<string, unknown> | null {
|
|
217
|
+
try {
|
|
218
|
+
const value: unknown = JSON.parse(text);
|
|
219
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
|
220
|
+
} catch {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The wrapper's answer for one trendarr run. An add reports per ID and exits 1
|
|
227
|
+
* when any ID failed; its JSON is still the answer, so it passes as success.
|
|
228
|
+
*/
|
|
229
|
+
export function interpretRun(call: TrendarrCall, result: { stdout: string; stderr: string; code: number }): Record<string, unknown> {
|
|
230
|
+
const parsed = jsonObject(result.stdout);
|
|
231
|
+
const warnings = trendarrWarnings(result.stderr);
|
|
232
|
+
if (call.kind === "add" && parsed && Array.isArray(parsed.results)) return { ...parsed, warnings };
|
|
233
|
+
if (result.code !== 0) throw new ServiceError(trendarrError(result.stderr) || `trendarr exited ${result.code}`);
|
|
234
|
+
if (!parsed) throw new ServiceError("trendarr printed no JSON");
|
|
235
|
+
return { ...parsed, warnings };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Runs trendarr with a private HOME that holds only this call's configuration, removed afterwards. */
|
|
239
|
+
async function runTrendarr(config: MediaConfig, call: TrendarrCall): Promise<{ stdout: string; stderr: string; code: number }> {
|
|
240
|
+
if (!config.trendarrCli.startsWith("/")) throw new ServiceError("trendarrCli must be an absolute path to trendarr's dist/cli.js");
|
|
241
|
+
const secrets = readMediaSecrets();
|
|
242
|
+
const base = workDir(SERVICE);
|
|
243
|
+
mkdirSync(base, { recursive: true, mode: 0o700 });
|
|
244
|
+
const home = mkdtempSync(join(base, "home-"));
|
|
245
|
+
try {
|
|
246
|
+
chmodSync(home, 0o700);
|
|
247
|
+
mkdirSync(join(home, ".trendarr"), { mode: 0o700 });
|
|
248
|
+
writeFileSync(join(home, ".trendarr", "config.json"), `${JSON.stringify(trendarrConfig(config, secrets))}\n`, { mode: 0o600 });
|
|
249
|
+
return await run(process.execPath, [config.trendarrCli, ...call.argv], { env: { HOME: home }, timeoutMs: call.timeoutMs });
|
|
250
|
+
} finally {
|
|
251
|
+
rmSync(home, { recursive: true, force: true });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export async function command(args: string[]): Promise<number> {
|
|
256
|
+
if (args.length === 0 || args[0] === "--help" || args[0] === "help") {
|
|
257
|
+
printRaw(HELP);
|
|
258
|
+
return 0;
|
|
259
|
+
}
|
|
260
|
+
const call = buildTrendarrCall(args);
|
|
261
|
+
const config = readConfig<MediaConfig>(SERVICE, SHAPE);
|
|
262
|
+
printJson(interpretRun(call, await runTrendarr(config, call)));
|
|
263
|
+
return 0;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) {
|
|
267
|
+
await main(SERVICE, command);
|
|
268
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
@@ -8,7 +8,8 @@ import { parseParams, parseRecordFilters, technitiumRequest, command as dnsComma
|
|
|
8
8
|
import { buildVpsRemote, command as edgeCommand, isTransientSshFailure } from "./edge/run.ts";
|
|
9
9
|
import { API_PREFIXES, buildDmzRemote, validateApiPath, command as identityCommand } from "./identity/run.ts";
|
|
10
10
|
import { buildInferenceRemote, compactReleases, command as inferenceCommand } from "./inference/run.ts";
|
|
11
|
-
import {
|
|
11
|
+
import { MAX_ADD_IDS, buildTrendarrCall, interpretRun, parseImdbIds, trendarrConfig, trendarrError, trendarrWarnings, command as mediaCommand } from "./media/run.ts";
|
|
12
|
+
import { ServiceError, UsageError } from "./lib/errors.ts";
|
|
12
13
|
import { safeRepoPath, safeWritePath } from "./lib/repo.ts";
|
|
13
14
|
import { POLICY_REQUIRED, buildPulsarRemote, parseQuery, policyWrite, unifiPath, command as networkCommand } from "./network/run.ts";
|
|
14
15
|
import { buildHostRemote, needsConfirm, parseSetPairs, parseVmid, validateApiPath as validatePvePath, command as proxmoxCommand } from "./proxmox/run.ts";
|
|
@@ -464,3 +465,122 @@ describe("wrapper config errors", () => {
|
|
|
464
465
|
await expect(inferenceCommand(["nexus-status"])).rejects.toThrow(/inference\.json/);
|
|
465
466
|
});
|
|
466
467
|
});
|
|
468
|
+
|
|
469
|
+
describe("media wrapper", () => {
|
|
470
|
+
it("builds trendarr argv with --json and validated options", () => {
|
|
471
|
+
expect(buildTrendarrCall(["trending"])).toEqual({ argv: ["trending", "--json"], kind: "list", timeoutMs: 120_000 });
|
|
472
|
+
expect(buildTrendarrCall(["trending", "--type", "movies", "--min-rating", "0", "--max-entries", "5"]).argv).toEqual(["trending", "--type", "movies", "--min-rating", "0", "--max-entries", "5", "--json"]);
|
|
473
|
+
expect(buildTrendarrCall(["search", "breaking", "bad", "--type", "series"]).argv).toEqual(["search", "breaking bad", "--type", "series", "--json"]);
|
|
474
|
+
expect(buildTrendarrCall(["add-movies", "tt1160419", "tt0468569,tt1160419"])).toMatchObject({ argv: ["add-movies", "tt1160419", "tt0468569", "--json"], kind: "add" });
|
|
475
|
+
expect(buildTrendarrCall(["add-series", "tt0903747"]).argv).toEqual(["add-series", "tt0903747", "--json"]);
|
|
476
|
+
expect(buildTrendarrCall(["sabnzbd", "unpause"])).toMatchObject({ argv: ["sabnzbd", "resume", "--json"], kind: "queue" });
|
|
477
|
+
expect(buildTrendarrCall(["sabnzbd", "status"]).argv).toEqual(["sabnzbd", "status", "--json"]);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it("refuses anything trendarr would not accept or should not run", () => {
|
|
481
|
+
expect(() => buildTrendarrCall(["init"])).toThrow(/init is not available/);
|
|
482
|
+
expect(() => buildTrendarrCall(["trending", "--type", "films"])).toThrow(/movies or series/);
|
|
483
|
+
expect(() => buildTrendarrCall(["trending", "--min-rating", "11"])).toThrow(/0 to 10/);
|
|
484
|
+
expect(() => buildTrendarrCall(["trending", "--min-rating", ""])).toThrow(/0 to 10/);
|
|
485
|
+
expect(() => buildTrendarrCall(["trending", "--max-entries", "0"])).toThrow(/1 to 50/);
|
|
486
|
+
expect(() => buildTrendarrCall(["trending", "--max-entries"])).toThrow(/needs a value/);
|
|
487
|
+
expect(() => buildTrendarrCall(["trending", "dune"])).toThrow(/no words/);
|
|
488
|
+
expect(() => buildTrendarrCall(["trending", "--discord"])).toThrow(/unknown option/);
|
|
489
|
+
expect(() => buildTrendarrCall(["search", "--min-rating", "5", "dune"])).toThrow(/unknown option/);
|
|
490
|
+
expect(() => buildTrendarrCall(["search"])).toThrow(/give a title/);
|
|
491
|
+
expect(() => buildTrendarrCall(["search", "-rf"])).toThrow(/must not start/);
|
|
492
|
+
expect(() => buildTrendarrCall(["search", "a\u0007b"])).toThrow(/control characters/);
|
|
493
|
+
expect(() => buildTrendarrCall(["search", "x".repeat(201)])).toThrow(/longer than/);
|
|
494
|
+
expect(() => buildTrendarrCall(["sabnzbd", "delete"])).toThrow(/status\|pause\|resume/);
|
|
495
|
+
expect(() => buildTrendarrCall(["sabnzbd"])).toThrow(/status\|pause\|resume/);
|
|
496
|
+
expect(() => buildTrendarrCall(["delete-movie", "tt1"])).toThrow(/unknown command/);
|
|
497
|
+
expect(() => parseImdbIds(["tt12x"])).toThrow(UsageError);
|
|
498
|
+
expect(() => parseImdbIds([" , "])).toThrow(/at least one/);
|
|
499
|
+
expect(() => parseImdbIds(Array.from({ length: MAX_ADD_IDS + 1 }, (_, i) => `tt${i + 1}`))).toThrow(/at most/);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it("writes trendarr's configuration in its own schema", () => {
|
|
503
|
+
const config = { trendarrCli: "/x/cli.js", radarrUrl: "http://r:7878", radarrQualityProfileId: 7, radarrRootFolderPath: "/media/movies", sonarrUrl: "http://s:8989", sonarrQualityProfileId: 7, sonarrRootFolderPath: "/media/tv", sonarrMonitor: "all", sabnzbdUrl: "http://z:8084", maxEntries: 10 };
|
|
504
|
+
expect(trendarrConfig(config, { tmdbAccessToken: "t", omdbApiKey: "o", radarrApiKey: "r", sonarrApiKey: "s", sabnzbdApiKey: "z" })).toEqual({
|
|
505
|
+
tmdb: { accessToken: "t" },
|
|
506
|
+
omdb: { apiKey: "o" },
|
|
507
|
+
radarr: { url: "http://r:7878", apiKey: "r", qualityProfileId: 7, rootFolderPath: "/media/movies" },
|
|
508
|
+
sonarr: { url: "http://s:8989", apiKey: "s", qualityProfileId: 7, rootFolderPath: "/media/tv", monitor: "all" },
|
|
509
|
+
sabnzbd: { url: "http://z:8084", apiKey: "z" },
|
|
510
|
+
defaults: { maxEntries: 10 },
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it("keeps add results on a partial failure and turns other failures into errors", () => {
|
|
515
|
+
const add = buildTrendarrCall(["add-movies", "tt1"]);
|
|
516
|
+
const list = buildTrendarrCall(["trending"]);
|
|
517
|
+
const partial = JSON.stringify({ results: [{ imdb_id: "tt1", title: null, status: "not_found", detail: null }], sabnzbd_paused: false });
|
|
518
|
+
expect(interpretRun(add, { stdout: partial, stderr: "warning: SABnzbd slow\n", code: 1 })).toMatchObject({ results: [{ status: "not_found" }], warnings: ["SABnzbd slow"] });
|
|
519
|
+
expect(interpretRun(list, { stdout: '{"movies":[],"series":[]}', stderr: "warning: Radarr unreachable — in_library unknown\n", code: 0 })).toEqual({ movies: [], series: [], warnings: ["Radarr unreachable — in_library unknown"] });
|
|
520
|
+
expect(() => interpretRun(list, { stdout: "", stderr: "API key rejected (401) by https://api.themoviedb.org/3/trending\n", code: 1 })).toThrow(/API key rejected/);
|
|
521
|
+
expect(() => interpretRun(add, { stdout: "", stderr: "Config missing/invalid — run 'trendarr init'.\n", code: 1 })).toThrow(ServiceError);
|
|
522
|
+
expect(() => interpretRun(list, { stdout: "not json", stderr: "", code: 0 })).toThrow(/no JSON/);
|
|
523
|
+
expect(trendarrWarnings("warning: a\nboom\nwarning: b")).toEqual(["a", "b"]);
|
|
524
|
+
expect(trendarrError("warning: a\nboom\n\nbang")).toBe("boom; bang");
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it("prints help without touching config", async () => {
|
|
528
|
+
const write = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
529
|
+
expect(await mediaCommand(["--help"])).toBe(0);
|
|
530
|
+
expect(String(write.mock.calls[0][0])).toContain("add-movies <imdbId...>");
|
|
531
|
+
write.mockRestore();
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
describe("run", () => {
|
|
535
|
+
let home: string;
|
|
536
|
+
const originalHome = process.env.HOME;
|
|
537
|
+
|
|
538
|
+
beforeEach(() => {
|
|
539
|
+
home = mkdtempSync(join(tmpdir(), "spec-media-"));
|
|
540
|
+
process.env.HOME = home;
|
|
541
|
+
const secrets = join(home, ".specialists", "secrets", "media");
|
|
542
|
+
mkdirSync(secrets, { recursive: true });
|
|
543
|
+
for (const field of ["TMDB_ACCESS_TOKEN", "OMDB_API_KEY", "RADARR_API_KEY", "SONARR_API_KEY", "SABNZBD_API_KEY"]) writeFileSync(join(secrets, field), `${field.toLowerCase()}\n`, { mode: 0o600 });
|
|
544
|
+
const fake = join(home, "fake-trendarr.mjs");
|
|
545
|
+
writeFileSync(
|
|
546
|
+
fake,
|
|
547
|
+
[
|
|
548
|
+
'import { readFileSync, statSync } from "node:fs";',
|
|
549
|
+
'const path = `${process.env.HOME}/.trendarr/config.json`;',
|
|
550
|
+
"const config = JSON.parse(readFileSync(path, 'utf8'));",
|
|
551
|
+
'process.stderr.write("warning: fake warning\\n");',
|
|
552
|
+
"process.stdout.write(JSON.stringify({ argv: process.argv.slice(2), home: process.env.HOME, mode: statSync(path).mode & 0o777, sonarrKey: config.sonarr.apiKey }));",
|
|
553
|
+
].join("\n"),
|
|
554
|
+
);
|
|
555
|
+
mkdirSync(join(home, ".specialists", "config"), { recursive: true });
|
|
556
|
+
writeFileSync(
|
|
557
|
+
join(home, ".specialists", "config", "media.json"),
|
|
558
|
+
JSON.stringify({ trendarrCli: fake, radarrUrl: "http://r", radarrQualityProfileId: 7, radarrRootFolderPath: "/m", sonarrUrl: "http://s", sonarrQualityProfileId: 7, sonarrRootFolderPath: "/t", sonarrMonitor: "all", sabnzbdUrl: "http://z", maxEntries: 10 }),
|
|
559
|
+
);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
afterEach(() => {
|
|
563
|
+
process.env.HOME = originalHome;
|
|
564
|
+
rmSync(home, { recursive: true, force: true });
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
it("runs trendarr with a private HOME and removes it afterwards", async () => {
|
|
568
|
+
const write = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
569
|
+
expect(await mediaCommand(["sabnzbd", "status"])).toBe(0);
|
|
570
|
+
const printed = JSON.parse(String(write.mock.calls[0][0])) as { argv: string[]; home: string; mode: number; sonarrKey: string; warnings: string[] };
|
|
571
|
+
write.mockRestore();
|
|
572
|
+
expect(printed.argv).toEqual(["sabnzbd", "status", "--json"]);
|
|
573
|
+
expect(printed.home.startsWith(join(home, ".specialists", "work", "media", "home-"))).toBe(true);
|
|
574
|
+
expect(printed.mode).toBe(0o600);
|
|
575
|
+
expect(printed.sonarrKey).toBe("sonarr_api_key");
|
|
576
|
+
expect(printed.warnings).toEqual(["fake warning"]);
|
|
577
|
+
expect(existsSync(printed.home)).toBe(false);
|
|
578
|
+
expect(readdirSync(join(home, ".specialists", "work", "media"))).toEqual([]);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it("fails clearly when a secret is missing", async () => {
|
|
582
|
+
rmSync(join(home, ".specialists", "secrets", "media", "OMDB_API_KEY"));
|
|
583
|
+
await expect(mediaCommand(["trending"])).rejects.toThrow(/OMDB_API_KEY for media is missing/);
|
|
584
|
+
});
|
|
585
|
+
});
|
|
586
|
+
});
|