apps/web/scripts/record-replies.mjs
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env node
// Records what a running Weir server answers to the requests the web app makes, for the web's request tests
// (apps/web/src/test/recorded). Nothing it writes is typed by hand: it starts the built server on a scratch folder,
// makes the requests as the web app does, and keeps each answer's status and body as the server sent them.
//
// Usage (from the repository root, after `dotnet build apps/server/src/Weir.Host -c Release`):
// node apps/web/scripts/record-replies.mjs --work C:/weir-recorded-replies
//
// Options:
// --work <folder> Scratch folder for the server's data and the test media. Created, and removed when done.
// Its path appears in some recorded answers, so use a short, neutral one.
// --server <file> The built server, Weir.dll. Defaults to the Release build of apps/server/src/Weir.Host.
// --ffmpeg <file> FFmpeg, to make the two small test videos. Defaults to `ffmpeg` on the PATH.
// --port <number> Where the server listens on localhost. Defaults to 18911.
//
// The server is stopped by the process id this script started, never by name. The account it makes lives only in the
// scratch folder, with a password made fresh for each run.
import { spawn, spawnSync } from "node:child_process";
import { randomBytes } from "node:crypto";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(here, "..", "..", "..");
function option(name, fallback) {
const at = process.argv.indexOf(`--${name}`);
return at >= 0 ? process.argv[at + 1] : fallback;
}
const work = path.resolve(option("work", ""));
if (!option("work")) {
console.error(
"Give a scratch folder with --work, for example --work C:/weir-recorded-replies.",
);
process.exit(2);
}
const serverDll = option(
"server",
path.join(repoRoot, "apps/server/src/Weir.Host/bin/Release/net10.0/Weir.dll"),
);
const ffmpeg = option("ffmpeg", "ffmpeg");
const port = Number(option("port", "18911"));
const outDir = path.join(here, "..", "src", "test", "recorded");
const base = `http://localhost:${port}`;
const folder = (name) => path.join(work, "media", name).replaceAll("\\", "/");
/** What the server said, by `METHOD /path` (or a name where one route is asked more than one way). */
const recordings = new Map();
let cookies = {};
function keepCookies(response) {
for (const header of response.headers.getSetCookie()) {
const pair = header.split(";")[0];
const at = pair.indexOf("=");
cookies[pair.slice(0, at)] = pair.slice(at + 1);
}
}
const cookieHeader = () =>
Object.entries(cookies)
.map(([name, value]) => `${name}=${value}`)
.join("; ");
/** One request as the web app sends it: the token first, then the call with the token in its JSON body. */
async function send(method, route, body, name, tokenIn = "body") {
const headers = {
"X-Requested-With": "XMLHttpRequest",
Accept: "application/json",
};
let payload;
if (method !== "GET") {
const tokenResponse = await fetch(`${base}/api/v1/auth/csrf`, {
headers: { ...headers, Cookie: cookieHeader() },
});
keepCookies(tokenResponse);
const { csrf_token } = await tokenResponse.json();
if (tokenIn === "header") {
headers["X-CSRF-Token"] = csrf_token;
} else {
headers["Content-Type"] = "application/json";
payload = JSON.stringify({ ...body, csrf_token });
}
}
const response = await fetch(`${base}${route}`, {
method,
headers: { ...headers, Cookie: cookieHeader() },
body: payload,
});
keepCookies(response);
const text = await response.text();
const answer = {
status: response.status,
body: text ? JSON.parse(text) : null,
};
if (name !== false) {
recordings.set(name ?? `${method} ${route}`, answer);
console.log(
String(answer.status).padEnd(4),
method.padEnd(7),
(name ?? route).slice(0, 110),
);
}
return answer;
}
const read = (route, name) => send("GET", route, undefined, name);
const write = (method, route, body, name, tokenIn) =>
send(method, route, body ?? {}, name, tokenIn);
/** Polls until the check passes; the server's own queue decides when, and a ceiling ends a hang. */
async function until(check, what) {
for (let attempt = 0; attempt < 120; attempt += 1) {
if (await check()) return;
await new Promise((resolve) => setTimeout(resolve, 1000));
}
throw new Error(`Gave up waiting for ${what}.`);
}
/**
* A small test video with an English and a French track. The long one is raw video, which FFmpeg writes without encoding, so
* it is two and a half gigabytes that Weir takes a few seconds to copy: long enough for the stream to report its progress.
*/
function makeVideo(file, { long = false } = {}) {
mkdirSync(path.dirname(file), { recursive: true });
const run = spawnSync(
ffmpeg,
[
"-v",
"error",
"-y",
"-f",
"lavfi",
"-i",
long
? "testsrc=size=640x360:rate=25:duration=300"
: "testsrc=size=64x64:rate=5:duration=2",
"-f",
"lavfi",
"-i",
`sine=frequency=440:duration=${long ? 300 : 2}`,
"-f",
"lavfi",
"-i",
`sine=frequency=880:duration=${long ? 300 : 2}`,
"-map",
"0:v",
"-map",
"1:a",
"-map",
"2:a",
...(long
? ["-c:v", "rawvideo", "-pix_fmt", "yuv420p"]
: ["-c:v", "libx264", "-preset", "ultrafast"]),
"-c:a",
"aac",
"-b:a",
"16k",
"-metadata:s:a:0",
"language=eng",
"-metadata:s:a:1",
"language=fra",
file,
],
{ stdio: "inherit" },
);
if (run.status !== 0)
throw new Error("FFmpeg could not make the test video.");
}
/**
* Listens to the Activity stream from now on and keeps the first few frames of each kind it sends, as it sent them: the
* stream says the same thing every second, so a few of each show its shape without a file of repeats.
*/
function watchStream() {
const frames = [];
const kept = new Map();
const controller = new AbortController();
function keep(block) {
const fields = Object.fromEntries(
block
.split("\n")
.filter((line) => /^(event|data): /.test(line))
.map((line) => [
line.slice(0, line.indexOf(":")),
line.slice(line.indexOf(":") + 2),
]),
);
if (!fields.event || !fields.data) return;
const data = JSON.parse(fields.data);
const kind =
fields.event === "data.changed"
? `${fields.event} ${data.topic}`
: fields.event === "processing.progress" &&
Array.isArray(data.files) &&
data.files.length > 0
? `${fields.event} with a file`
: fields.event;
// A progress frame with no file in it says nothing of its shape, so only a frame that carries a file is kept in numbers.
const carriesFile = Array.isArray(data.files) && data.files.length > 0;
const limit =
fields.event === "processing.progress" && carriesFile ? 10 : 2;
if ((kept.get(kind) ?? 0) >= limit) return;
kept.set(kind, (kept.get(kind) ?? 0) + 1);
frames.push({ event: fields.event, data });
}
const listening = (async () => {
const response = await fetch(`${base}/api/v1/activity/stream`, {
headers: { Cookie: cookieHeader(), Accept: "text/event-stream" },
signal: controller.signal,
});
const decoder = new TextDecoder();
let pending = "";
try {
for await (const chunk of response.body) {
pending += decoder.decode(chunk, { stream: true });
for (
let end = pending.indexOf("\n\n");
end >= 0;
end = pending.indexOf("\n\n")
) {
keep(pending.slice(0, end));
pending = pending.slice(end + 2);
}
}
} catch (error) {
if (error.name !== "AbortError") throw error;
}
})();
return {
async stop() {
controller.abort();
await listening;
recordings.set("GET /api/v1/activity/stream", {
status: 200,
body: { frames },
});
},
};
}
async function record() {
const password = `T${randomBytes(12).toString("base64url")}!9`;
await write(
"POST",
"/api/v1/auth/bootstrap",
{ username: "recorded-admin", password },
false,
);
await read("/api/v1/auth/me");
const stream = watchStream();
// The workflow and its folders; the small test file would otherwise be skipped for being under the minimum size.
const [movies] = (await read("/api/v1/processing/libraries")).body;
const {
id,
display_order,
effective_max_concurrent_files,
manager_coverage,
manager_coverage_detail,
discovered_from_connection_id,
discovered_library_key,
folders_synced_from_connection_id,
active_job_count,
next_look_at,
periodic_scan,
next_scan_at,
updated_at,
...settings
} = movies;
await write("PUT", `/api/v1/processing/libraries/${id}`, {
...settings,
min_file_size_mb: 0,
ready_after_seconds: 1,
watched_folder: folder("watched"),
work_folder: folder("work"),
output_folder: folder("output"),
});
await write("PUT", `/api/v1/processing/libraries/${id}/library-settings`, {
library_folders: [folder("library")],
});
await write("POST", `/api/v1/processing/libraries/${id}/library-scan`);
await until(
async () =>
(await read(`/api/v1/processing/libraries/${id}/library-overview`, false))
.body.scan?.status === "completed",
"the library scan",
);
await write(
"POST",
"/api/v1/processing/jobs/watched-folder-remux-scan-dispatch/enqueue",
{
enqueue_remux_jobs: true,
media_scope: "movie",
library_id: id,
},
);
await until(async () => {
const { files } = (await read("/api/v1/processing/files", false)).body;
return (
files.length > 0 && files.every((file) => file.status === "processed")
);
}, "the watched file to be processed");
// What the screens read.
await read("/api/v1/processing/files");
await read(
"/api/v1/processing/files?library_id=1&file_status=processed&path_contains=Recorded&within_days=30&limit=50&sort=when&direction=desc",
);
const fileId = (await read("/api/v1/processing/files", false)).body.files[0]
.id;
await read(`/api/v1/processing/files/${fileId}/remove-options`);
await read(`/api/v1/processing/files/${fileId}/why-held`);
await read(`/api/v1/processing/files/${fileId}/log`);
await read(`/api/v1/processing/files/${fileId}/tracks`);
await read("/api/v1/processing/files-at-once");
await read("/api/v1/processing/files/rejected/summary");
await read("/api/v1/processing/files/rejected/summary?library_id=1");
await read("/api/v1/processing/kept-files");
await read("/api/v1/processing/libraries");
await read("/api/v1/processing/rule-sets");
await read("/api/v1/processing/direct-play/devices");
await write("PUT", "/api/v1/processing/direct-play/devices", {
selected: ["apple_tv_4k"],
});
await read("/api/v1/processing/libraries/1/folder-chain");
await read("/api/v1/processing/reject-support");
await read(
"/api/v1/processing/manager-setup?media_type=movie&watched_folder=" +
encodeURIComponent(folder("watched")) +
"&output_folder=" +
encodeURIComponent(folder("output")) +
"&remove_original_after_success=true",
);
await read("/api/v1/processing/library-suggestions");
await read("/api/v1/processing/maintenance");
await read("/api/v1/processing/operator-settings");
await read("/api/v1/processing/libraries/1/library-settings");
await read("/api/v1/processing/libraries/1/library-redownloads");
await read("/api/v1/processing/libraries/1/library-overview");
await read("/api/v1/processing/libraries/1/library-files");
await read(
"/api/v1/processing/libraries/1/library-files?status=needs_cleaning&q=Library&sort=size&direction=desc&page_size=25",
);
await write("POST", "/api/v1/processing/library-check", {
movie_watched_folder: folder("watched"),
movie_output_folder: folder("output"),
});
// A refusal the server gives for a file it does not know: the shape every refused request comes back in.
await read("/api/v1/processing/files/9999/why-held");
// Library files: the clean asks to be confirmed, then runs.
const libraryFile = (
await read("/api/v1/processing/libraries/1/library-files", false)
).body.files[0].path;
await write(
"POST",
"/api/v1/processing/libraries/1/library-files/leave-alone",
{ path: libraryFile, leave_alone: true },
);
await write(
"POST",
"/api/v1/processing/libraries/1/library-files/leave-alone",
{ path: libraryFile, leave_alone: false },
"POST /api/v1/processing/libraries/1/library-files/leave-alone (undone)",
);
await write(
"POST",
"/api/v1/processing/libraries/1/library-files/clean",
{ paths: [libraryFile], confirm_final_removal: false },
"POST /api/v1/processing/libraries/1/library-files/clean (to confirm)",
);
await write("POST", "/api/v1/processing/libraries/1/library-files/clean", {
paths: [libraryFile],
confirm_final_removal: true,
});
await until(
async () =>
(await read("/api/v1/processing/library-cleans", false)).body.cleans
.length > 0,
"the library clean",
);
await read("/api/v1/processing/library-cleans");
await read(
"/api/v1/processing/library-cleans?library_id=1&path_contains=Library&within_days=7&limit=20",
);
await write("POST", "/api/v1/processing/libraries/1/library-schedule", {
enabled: false,
confirm_final_removal: false,
});
await write("POST", "/api/v1/processing/libraries/1/library-redownloads", {
path: libraryFile,
confirm_destructive: true,
});
await read("/api/v1/activity/recent?limit=10");
await read(
"/api/v1/activity/recent?result=success&library_id=1&about=files&limit=20¤t_only=true&with_total=false",
);
// Previewing rules on a file, by its place in the watched folder and by its full path.
await write(
"POST",
"/api/v1/processing/libraries/1/preview",
{ absolute_path: libraryFile },
"POST /api/v1/processing/libraries/1/preview (full path)",
);
await write(
"POST",
"/api/v1/processing/libraries/1/preview",
{ relative_path: "Recorded Film (2020).mkv" },
"POST /api/v1/processing/libraries/1/preview (in the watched folder)",
);
// Rule sets (profiles).
const sets = (await read("/api/v1/processing/rule-sets", false)).body;
const {
id: firstSet,
used_by_library_count,
updated_at: setUpdated,
...ruleSet
} = sets[0];
const created = await write("POST", "/api/v1/processing/rule-sets", {
...ruleSet,
name: "Recorded profile",
primary_audio_lang: "fre",
secondary_audio_lang: "eng",
subtitle_mode: "keep_listed",
subtitle_langs_csv: "eng,fre",
});
const setId = created.body.id;
await write("PUT", `/api/v1/processing/rule-sets/${setId}`, {
...ruleSet,
name: "Recorded profile",
remove_commentary: false,
});
await write("DELETE", `/api/v1/processing/rule-sets/${setId}`);
await write(
"DELETE",
`/api/v1/processing/rule-sets/${firstSet}`,
undefined,
"DELETE /api/v1/processing/rule-sets/1 (in use)",
);
// Workflows.
const extra = await write("POST", "/api/v1/processing/libraries", {
name: "Recorded extra",
media_type: "movie",
});
const extraId = extra.body.id;
await write("POST", "/api/v1/processing/libraries/reorder", {
library_ids_in_order: [extraId, 1, 2],
});
await write("POST", `/api/v1/processing/libraries/${extraId}/unlink`);
await write("DELETE", `/api/v1/processing/libraries/${extraId}`);
// Notification channels.
const channel = await write("POST", "/api/v1/suite/notification-channels", {
label: "Recorded webhook",
provider: "webhook",
url: "https://hooks.example.invalid/weir",
events: ["job_failed"],
enabled: true,
});
await write("PUT", `/api/v1/suite/notification-channels/${channel.body.id}`, {
label: "Recorded webhook",
provider: "webhook",
url: "https://hooks.example.invalid/weir",
events: ["job_completed", "job_failed"],
enabled: false,
});
await write(
"POST",
`/api/v1/suite/notification-channels/${channel.body.id}/test`,
);
await read("/api/v1/suite/notification-channels");
await write(
"DELETE",
`/api/v1/suite/notification-channels/${channel.body.id}`,
undefined,
undefined,
"header",
);
// Download clients.
const client = await write("POST", "/api/v1/download-clients/connections", {
kind: "sabnzbd",
name: "SABnzbd on localhost",
base_url: "http://localhost:8085",
api_key: "recorded-key",
enabled: true,
username: "",
password: "",
});
await write("PUT", `/api/v1/download-clients/connections/${client.body.id}`, {
name: "SABnzbd on localhost",
base_url: "http://localhost:8085",
enabled: false,
});
await write(
"POST",
`/api/v1/download-clients/connections/${client.body.id}/test`,
);
await read("/api/v1/download-clients/connections");
await read("/api/v1/download-clients/suggestions?media_type=movie");
await write(
"DELETE",
`/api/v1/download-clients/connections/${client.body.id}`,
);
// Media managers.
const manager = await write("POST", "/api/v1/media-managers/connections", {
kind: "deluno",
name: "Deluno on localhost",
base_url: "http://localhost:5099",
api_key: "recorded-key",
enabled: true,
downloaded_scan_enabled: false,
});
const managerId = manager.body.id;
await write("PUT", `/api/v1/media-managers/connections/${managerId}`, {
name: "Deluno on localhost",
nickname: "Recorded",
base_url: "http://localhost:5099",
enabled: true,
});
await write("POST", `/api/v1/media-managers/connections/${managerId}/test`);
await write(
"POST",
`/api/v1/media-managers/connections/${managerId}/webhook-secret`,
);
await read("/api/v1/media-managers/connections");
await read(`/api/v1/media-managers/connections/${managerId}/folder-chain`);
await read(`/api/v1/processing/libraries/discover/${managerId}`);
await read(`/api/v1/processing/libraries/discover/${managerId}/drift`);
await write(
"POST",
`/api/v1/processing/libraries/discover/${managerId}/import`,
{ keys: ["movies"] },
);
await read(`/api/v1/processing/reject-support?connection_ids=${managerId}`);
await read(
`/api/v1/processing/reject-support?connection_ids=${managerId}&connection_ids=${managerId + 1}`,
);
await read(
`/api/v1/processing/manager-setup?media_type=tv&watched_folder=${encodeURIComponent(folder("watched"))}&output_folder=${encodeURIComponent(folder("output"))}&remove_original_after_success=false&connection_ids=${managerId}&connection_ids=${managerId + 1}`,
);
await write(
"POST",
`/api/v1/media-managers/connections/${managerId}/unlink-workflows`,
);
await write(
"POST",
`/api/v1/media-managers/connections/${managerId}/give-up-reports`,
);
await write("DELETE", `/api/v1/media-managers/connections/${managerId}`);
// A pass over one file, and the next look at the watched folder.
await write("POST", "/api/v1/processing/jobs/file-remux-pass/enqueue", {
relative_media_path: "Recorded Film (2020).mkv",
media_scope: "movie",
library_id: 1,
});
// Maintenance.
await write("POST", "/api/v1/processing/maintenance/run", {
family: "work_temp_stale_sweep",
media_scope: "movie",
});
// What a person does to the file last, since it ends the file's record.
await write("POST", `/api/v1/processing/files/${fileId}/move-to-top`);
await write("POST", `/api/v1/processing/files/${fileId}/manual-plan`, {
keep: [{ index: 1, default: true, forced: false }],
order: [1],
});
await write("POST", `/api/v1/processing/files/${fileId}/requeue`);
await write("POST", "/api/v1/processing/files/requeue", {
file_ids: [fileId],
});
await write("POST", "/api/v1/processing/files/rejected/process-again", {
library_id: 1,
});
await write("POST", "/api/v1/processing/kept-files/9999/process-again");
await write(
"DELETE",
`/api/v1/processing/files/${fileId}`,
{ resolution: "retry" },
"DELETE /api/v1/processing/files/1 (try again)",
);
await write("DELETE", `/api/v1/processing/files/${fileId}`);
// Skipping sign-in on the home network: off until it is turned on, and a refusal for a wrong password and for a bad entry.
await read("/api/v1/suite/home-network-sign-in");
const homeNetwork = (current_password, extra_networks) => ({
enabled: true,
extra_networks,
allow_list: [],
current_password,
});
await write(
"PUT",
"/api/v1/suite/home-network-sign-in",
homeNetwork("not the password", []),
"PUT /api/v1/suite/home-network-sign-in (wrong password)",
);
await write(
"PUT",
"/api/v1/suite/home-network-sign-in",
homeNetwork(password, ["banana"]),
"PUT /api/v1/suite/home-network-sign-in (bad entry)",
);
await write(
"PUT",
"/api/v1/suite/home-network-sign-in",
homeNetwork(password, ["100.64.0.0/10"]),
);
await stream.stop();
}
/** Stops the server this script started, by its process id, and waits for it to let go of the scratch folder. */
async function stopServer(child) {
if (child.exitCode !== null) return;
const exited = new Promise((resolve) => child.once("exit", resolve));
child.kill();
await exited;
}
async function main() {
if (!existsSync(serverDll))
throw new Error(`There is no built server at ${serverDll}.`);
rmSync(work, { recursive: true, force: true });
for (const name of ["watched", "work", "output", "library"])
mkdirSync(folder(name), { recursive: true });
makeVideo(path.join(folder("watched"), "Recorded Film (2020).mkv"), {
long: true,
});
makeVideo(path.join(folder("library"), "Library Film (2019).mkv"));
const child = spawn(
"dotnet",
[serverDll, "--host", "localhost", "--port", String(port), "--no-browser"],
{
cwd: work,
stdio: "ignore",
env: {
...process.env,
WEIR_HOME: path.join(work, "home"),
WEIR_SESSION_SECRET: randomBytes(48).toString("hex"),
WEIR_ENV: "development",
},
},
);
try {
await until(async () => {
try {
return (await fetch(`${base}/api/v1/auth/bootstrap/status`)).ok;
} catch {
return false;
}
}, "the server to start");
await record();
mkdirSync(outDir, { recursive: true });
const files = {
session: [],
connections: [],
activity: [],
processing: [],
};
for (const [route, answer] of recordings) {
const url = route.split(" ")[1];
const group = url.includes("/auth/")
? "session"
: /\/(media-managers|download-clients)\/|\/suite\/notification/.test(
url,
)
? "connections"
: url.includes("/activity/")
? "activity"
: "processing";
files[group].push([route, answer]);
}
for (const [group, entries] of Object.entries(files)) {
writeFileSync(
path.join(outDir, `${group}.json`),
`${JSON.stringify(Object.fromEntries(entries), null, 2)}\n`,
);
}
console.log(`Recorded ${recordings.size} answers into ${outDir}.`);
} finally {
await stopServer(child);
rmSync(work, { recursive: true, force: true });
}
}
await main();