#!/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 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 The built server, Weir.dll. Defaults to the Release build of apps/server/src/Weir.Host. // --ffmpeg FFmpeg, to make the two small test videos. Defaults to `ffmpeg` on the PATH. // --port 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();