# Bulk backup of all Plaud.ai recordings (audio, transcripts, AI summaries) across multiple accounts, using Plaud's official CLI (@plaud-ai/cli). Resumable, per-account logins.

> Source: <https://gist.github.com/gareebr/55a3ecae918f609ba7b16d6974379279>
> Published: 2026-11-03 22:08:43+00:00

|  | #!/usr/bin/env node | 
|  | // plaud-backup.mjs — back up every Plaud recording (audio, transcripts, summaries) | 
|  | // from one or more Plaud accounts, using Plaud's official CLI (@plaud-ai/cli). | 
|  | // | 
|  | // Usage (PowerShell): | 
|  | //   node plaud-backup.mjs                      -> prompts for an account label, logs in, backs up | 
|  | //   node plaud-backup.mjs --out "D:\PlaudBackup" | 
|  | //   node plaud-backup.mjs --account work       -> skip the prompt | 
|  | //   node plaud-backup.mjs --account work --yes -> also skip the "is this the right account?" check | 
|  | //   node plaud-backup.mjs --no-audio           -> skip audio files | 
|  | //   node plaud-backup.mjs --no-images          -> skip photos from highlights/summaries | 
|  | // | 
|  | // Each account gets its own login profile under <out>\_profiles\<label>, so you can | 
|  | // back up several accounts without logging out/in. Re-running resumes: recordings | 
|  | // already finished are skipped (but photos missing from older runs are fetched). | 
|  |  | 
|  | import { spawnSync, execSync } from "node:child_process"; | 
|  | import { existsSync, mkdirSync, writeFileSync, readFileSync, appendFileSync, renameSync, createWriteStream, unlinkSync, readdirSync } from "node:fs"; | 
|  | import { join, extname } from "node:path"; | 
|  | import { homedir } from "node:os"; | 
|  | import { createInterface } from "node:readline/promises"; | 
|  | import { Readable } from "node:stream"; | 
|  | import { pipeline } from "node:stream/promises"; | 
|  |  | 
|  | // ---------- args ---------- | 
|  | const args = process.argv.slice(2); | 
|  | const argVal = (name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; }; | 
|  | const OUT_ROOT = argVal("--out") ?? join(homedir(), "Documents", "PlaudBackup"); | 
|  | let ACCOUNT = argVal("--account"); | 
|  | const SKIP_AUDIO = args.includes("--no-audio"); | 
|  | const AUTO_YES = args.includes("--yes"); | 
|  | const SKIP_IMAGES = args.includes("--no-images"); | 
|  |  | 
|  | // ---------- locate the official CLI ---------- | 
|  | function findCli() { | 
|  | if (process.env.PLAUD_CLI_PATH) return process.env.PLAUD_CLI_PATH; | 
|  | let root; | 
|  | try { root = execSync("npm root -g", { encoding: "utf8" }).trim(); } catch { root = ""; } | 
|  | const p = join(root, "@plaud-ai", "cli", "dist", "index.js"); | 
|  | if (!existsSync(p)) { | 
|  | console.error("Plaud CLI not found. Install it first:  npm install -g @plaud-ai/cli"); | 
|  | process.exit(1); | 
|  | } | 
|  | return p; | 
|  | } | 
|  | const CLI = findCli(); | 
|  |  | 
|  | const ANSI = /\x1b\[[0-9;]*[A-Za-z]/g; | 
|  | const strip = (s) => (s ?? "").replace(ANSI, ""); | 
|  | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); | 
|  | const safe = (s, max = 60) => | 
|  | (s \|\| "untitled").replace(/[<>:"/\\\|?*\x00-\x1f]/g, "_").replace(/\s+/g, " ").trim().replace(/[. ]+$/, "").slice(0, max) \|\| "untitled"; | 
|  |  | 
|  | // ---------- run CLI under a per-account home ---------- | 
|  | let PROFILE_HOME; | 
|  | function cliEnv() { | 
|  | return { | 
|  | ...process.env, | 
|  | USERPROFILE: PROFILE_HOME, // Windows: os.homedir() -> tokens in <PROFILE_HOME>\.plaud | 
|  | HOME: PROFILE_HOME, // macOS/Linux | 
|  | FORCE_COLOR: "0", | 
|  | NO_COLOR: "1", | 
|  | PLAUD_TELEMETRY_DISABLED: "1", | 
|  | PLAUD_NO_UPDATE_NOTIFIER: "1", | 
|  | }; | 
|  | } | 
|  | function runCli(cliArgs, { inherit = false } = {}) { | 
|  | const r = spawnSync(process.execPath, [CLI, ...cliArgs], { | 
|  | env: cliEnv(), | 
|  | encoding: "utf8", | 
|  | stdio: inherit ? "inherit" : "pipe", | 
|  | maxBuffer: 256 * 1024 * 1024, | 
|  | }); | 
|  | return { code: r.status ?? 1, out: strip(r.stdout), err: strip(r.stderr) }; | 
|  | } | 
|  | // Retry on network (3) / timeout (4) / generic (1); stop hard on auth (2). | 
|  | async function cli(cliArgs, tries = 4) { | 
|  | let last; | 
|  | for (let i = 0; i < tries; i++) { | 
|  | last = runCli(cliArgs); | 
|  | if (last.code === 0) return last; | 
|  | if (last.code === 2) throw new Error("AUTH: login expired or invalid — re-run the script to log in again."); | 
|  | await sleep(2000 * (i + 1)); | 
|  | } | 
|  | return last; | 
|  | } | 
|  |  | 
|  | // ---------- parsing ---------- | 
|  | function parseFilesPage(out) { | 
|  | const ids = []; | 
|  | let inTable = false; | 
|  | for (const line of out.split(/\r?\n/)) { | 
|  | if (line.includes("\u2500\u2500\u2500")) { inTable = true; continue; } | 
|  | if (!inTable) continue; | 
|  | if (/^\s*Page \d+/.test(line)) break; | 
|  | const m = line.match(/^\s{2}(\S+)\s{2,}/); | 
|  | if (m) ids.push(m[1]); | 
|  | } | 
|  | const countMatch = out.match(/Files on this page:\s*(\d+)/); | 
|  | return { ids, count: countMatch ? Number(countMatch[1]) : ids.length }; | 
|  | } | 
|  | function parseDetails(out) { | 
|  | const get = (k) => { const m = out.match(new RegExp(`^\\s*${k}:\\s*(.*)$`, "m")); return m ? m[1].trim() : ""; }; | 
|  | return { name: get("name"), created_at: get("created_at"), start_at: get("start_at"), duration: get("duration") }; | 
|  | } | 
|  | function stamp(iso) { | 
|  | const d = new Date(iso); | 
|  | if (isNaN(d)) return "unknown-date"; | 
|  | const p = (n) => String(n).padStart(2, "0"); | 
|  | return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}_${p(d.getHours())}${p(d.getMinutes())}`; | 
|  | } | 
|  | function audioExt(url, contentType) { | 
|  | const fromUrl = extname(new URL(url).pathname).toLowerCase(); | 
|  | if (fromUrl && fromUrl.length <= 6) return fromUrl; | 
|  | const ct = (contentType \|\| "").toLowerCase(); | 
|  | if (ct.includes("mpeg")) return ".mp3"; | 
|  | if (ct.includes("ogg") \|\| ct.includes("opus")) return ".opus"; | 
|  | if (ct.includes("wav")) return ".wav"; | 
|  | if (ct.includes("mp4") \|\| ct.includes("m4a") \|\| ct.includes("aac")) return ".m4a"; | 
|  | return ".audio"; | 
|  | } | 
|  |  | 
|  | // ---------- images (highlight photos + images embedded in summaries) ---------- | 
|  | function imageExt(url, contentType) { | 
|  | let fromUrl = ""; | 
|  | try { fromUrl = extname(new URL(url).pathname).toLowerCase(); } catch {} | 
|  | if (/^\.(png\|jpe?g\|webp\|gif\|heic\|bmp)$/.test(fromUrl)) return fromUrl; | 
|  | const ct = (contentType \|\| "").toLowerCase(); | 
|  | if (ct.includes("png")) return ".png"; | 
|  | if (ct.includes("jpeg") \|\| ct.includes("jpg")) return ".jpg"; | 
|  | if (ct.includes("webp")) return ".webp"; | 
|  | if (ct.includes("gif")) return ".gif"; | 
|  | if (ct.includes("heic")) return ".heic"; | 
|  | return ".img"; | 
|  | } | 
|  | function mmss(ms) { | 
|  | const s = Math.floor((Number(ms) \|\| 0) / 1000); | 
|  | return `${String(Math.floor(s / 60)).padStart(2, "0")}m${String(s % 60).padStart(2, "0")}s`; | 
|  | } | 
|  | function findImageLinks(dir) { | 
|  | const links = []; | 
|  | const hl = join(dir, "highlights.txt"); | 
|  | if (existsSync(hl)) { | 
|  | try { | 
|  | const items = JSON.parse(readFileSync(hl, "utf8")); | 
|  | if (Array.isArray(items)) items.forEach((it, i) => { | 
|  | const url = it?.picture_full_url \|\| it?.picture_link; | 
|  | if (typeof url === "string" && /^https?:\/\//.test(url)) | 
|  | links.push({ url, base: `highlight_${mmss(it.timestamp)}_${String(i + 1).padStart(2, "0")}` }); | 
|  | }); | 
|  | } catch {} | 
|  | } | 
|  | const sm = join(dir, "summary_and_notes.md"); | 
|  | if (existsSync(sm)) { | 
|  | const re = /!\[[^\]]*\]\((https?:\/\/[^)\s]+)/g; | 
|  | let m, k = 0; | 
|  | const text = readFileSync(sm, "utf8"); | 
|  | while ((m = re.exec(text))) links.push({ url: m[1], base: `summary_${String(++k).padStart(2, "0")}` }); | 
|  | } | 
|  | return links; | 
|  | } | 
|  | async function downloadImages(dir) { | 
|  | const problems = []; | 
|  | const imgDir = join(dir, "images"); | 
|  | if (existsSync(join(imgDir, ".done"))) return problems; | 
|  | const links = findImageLinks(dir); | 
|  | if (!links.length) return problems; | 
|  | mkdirSync(imgDir, { recursive: true }); | 
|  | for (const { url, base } of links) { | 
|  | if (readdirSync(imgDir).some((f) => f.startsWith(base + "."))) continue; // already have it | 
|  | try { | 
|  | const res = await fetch(url); | 
|  | if (!res.ok) throw new Error(`HTTP ${res.status}`); | 
|  | const target = join(imgDir, base + imageExt(url, res.headers.get("content-type"))); | 
|  | await pipeline(Readable.fromWeb(res.body), createWriteStream(target + ".part")); | 
|  | renameSync(target + ".part", target); | 
|  | } catch (e) { | 
|  | problems.push(`image ${base}: ${e.message}`); | 
|  | } | 
|  | } | 
|  | if (!problems.length) writeFileSync(join(imgDir, ".done"), new Date().toISOString()); | 
|  | return problems; | 
|  | } | 
|  |  | 
|  | // ---------- main ---------- | 
|  | let rl; | 
|  | const ask = async (q) => { rl ??= createInterface({ input: process.stdin, output: process.stdout }); return rl.question(q); }; | 
|  | if (!ACCOUNT) ACCOUNT = (await ask("Label for this Plaud account (e.g. personal, work): ")).trim(); | 
|  | ACCOUNT = safe(ACCOUNT, 40); | 
|  | PROFILE_HOME = join(OUT_ROOT, "_profiles", ACCOUNT); | 
|  | mkdirSync(PROFILE_HOME, { recursive: true }); | 
|  | const ACCT_DIR = join(OUT_ROOT, ACCOUNT); | 
|  | mkdirSync(ACCT_DIR, { recursive: true }); | 
|  | const ERRLOG = join(ACCT_DIR, "errors.log"); | 
|  |  | 
|  | console.log(`\nBacking up account "${ACCOUNT}" to ${ACCT_DIR}\n`); | 
|  |  | 
|  | // Login if needed (each account has its own token store) | 
|  | let me = runCli(["me"]); | 
|  | if (me.code !== 0) { | 
|  | console.log("Signing in. A browser window will open."); | 
|  | console.log("IMPORTANT: if your browser is already signed in to a DIFFERENT Plaud account, sign out of"); | 
|  | console.log("web.plaud.ai first, or copy the printed URL into an InPrivate/Incognito window.\n"); | 
|  | runCli(["login"], { inherit: true }); | 
|  | me = runCli(["me"]); | 
|  | if (me.code !== 0) { console.error("Login failed:\n" + me.err); process.exit(2); } | 
|  | } | 
|  | console.log(me.out.trim()); | 
|  | const ok = AUTO_YES ? "y" : (await ask("\nIs this the account you want to back up? (y/n): ")).trim().toLowerCase(); | 
|  | if (ok !== "y") { | 
|  | console.log(`Logging this profile out. Re-run and sign in with the right account.`); | 
|  | runCli(["logout"], { inherit: true }); | 
|  | process.exit(0); | 
|  | } | 
|  | rl?.close(); | 
|  |  | 
|  | // 1) List every recording | 
|  | const allIds = []; | 
|  | for (let page = 1; page <= 1000; page++) { | 
|  | const r = await cli(["files", "-p", String(page), "-s", "100"]); | 
|  | if (r.code !== 0) { console.error(`Failed listing page ${page}:\n${r.err}`); process.exit(1); } | 
|  | const { ids, count } = parseFilesPage(r.out); | 
|  | allIds.push(...ids); | 
|  | process.stdout.write(`\rListed ${allIds.length} recordings...`); | 
|  | if (count < 100) break; | 
|  | } | 
|  | const ids = [...new Set(allIds)]; | 
|  | console.log(`\nFound ${ids.length} recordings.\n`); | 
|  |  | 
|  | // Map already-finished folders (resume support) | 
|  | const doneById = new Map(); | 
|  | for (const d of readdirSync(ACCT_DIR, { withFileTypes: true })) { | 
|  | if (d.isDirectory() && existsSync(join(ACCT_DIR, d.name, ".done"))) { | 
|  | const m = d.name.match(/__(.{1,8})$/); | 
|  | if (m) doneById.set(m[1], d.name); | 
|  | } | 
|  | } | 
|  |  | 
|  | const indexPath = join(ACCT_DIR, "index.csv"); | 
|  | if (!existsSync(indexPath)) writeFileSync(indexPath, "id,date,name,duration,folder,audio,transcript,summary,status\n"); | 
|  | const csv = (v) => `"${String(v ?? "").replace(/"/g, '""')}"`; | 
|  |  | 
|  | // Catch-up pass: recordings finished by an older version of this script may have | 
|  | // photos that were never downloaded. Refresh their links and fetch the images. | 
|  | if (!SKIP_IMAGES) { | 
|  | const needImages = [...doneById.entries()].filter(([, folder]) => { | 
|  | const dir = join(ACCT_DIR, folder); | 
|  | return !existsSync(join(dir, "images", ".done")) && findImageLinks(dir).length > 0; | 
|  | }); | 
|  | if (needImages.length) console.log(`Fetching images for ${needImages.length} previously saved recording(s)...`); | 
|  | for (const [tag, folder] of needImages) { | 
|  | const dir = join(ACCT_DIR, folder); | 
|  | const fullId = ids.find((x) => x.endsWith(tag)); | 
|  | if (fullId) await cli(["transcript", fullId, "--block", "mark_memo", "-o", join(dir, "highlights.txt")]); // fresh links | 
|  | const probs = await downloadImages(dir); | 
|  | if (probs.length) appendFileSync(ERRLOG, `[${new Date().toISOString()}] ${folder} (images)\n  ` + probs.join("\n  ") + "\n"); | 
|  | } | 
|  | } | 
|  |  | 
|  | let n = 0, failed = 0, skipped = 0; | 
|  | for (const id of ids) { | 
|  | n++; | 
|  | const tag = id.slice(-8); | 
|  | if (doneById.has(tag)) { skipped++; continue; } | 
|  |  | 
|  | const problems = []; | 
|  | try { | 
|  | const det = await cli(["file", id]); | 
|  | if (det.code !== 0) throw new Error(`file details: ${det.err.trim()}`); | 
|  | const info = parseDetails(det.out); | 
|  | const folderName = `${stamp(info.start_at && info.start_at !== "-" ? info.start_at : info.created_at)}_${safe(info.name)}__${tag}`; | 
|  | const dir = join(ACCT_DIR, folderName); | 
|  | mkdirSync(dir, { recursive: true }); | 
|  | console.log(`[${n}/${ids.length}] ${info.name \|\| id}`); | 
|  | writeFileSync(join(dir, "details.txt"), `id: ${id}\n` + det.out.trim() + "\n"); | 
|  |  | 
|  | // Transcripts (missing blocks are simply skipped by the CLI) | 
|  | const blocks = [ | 
|  | ["transaction", "transcript.txt"], | 
|  | ["transaction_polish", "transcript_polished.txt"], | 
|  | ["outline", "outline.txt"], | 
|  | ["mark_memo", "highlights.txt"], | 
|  | ]; | 
|  | for (const [block, fname] of blocks) { | 
|  | const r = await cli(["transcript", id, "--block", block, "-o", join(dir, fname)]); | 
|  | if (r.code !== 0) problems.push(`transcript(${block}): ${r.err.trim()}`); | 
|  | } | 
|  |  | 
|  | // Summaries / notes: readable + raw JSON | 
|  | let r = await cli(["summary", id, "--all", "-o", join(dir, "summary_and_notes.md")]); | 
|  | if (r.code !== 0) problems.push(`summary: ${r.err.trim()}`); | 
|  | r = await cli(["summary", id, "--json", "-o", join(dir, "notes.json")]); | 
|  | if (r.code !== 0) problems.push(`notes json: ${r.err.trim()}`); | 
|  |  | 
|  | // Images (photos from highlights + images embedded in summaries) | 
|  | if (!SKIP_IMAGES) problems.push(...await downloadImages(dir)); | 
|  |  | 
|  | // Audio | 
|  | let gotAudio = false, noAudio = false; | 
|  | if (!SKIP_AUDIO) { | 
|  | for (let attempt = 0; attempt < 3 && !gotAudio; attempt++) { | 
|  | const a = await cli(["audio", id]); | 
|  | const url = (a.out.match(/https?:\/\/\S+/) \|\| [])[0]; | 
|  | if (!url) { | 
|  | if (/not available for this recording/i.test(a.out)) { noAudio = true; break; } // genuinely no audio | 
|  | await sleep(5000); | 
|  | continue; | 
|  | } | 
|  | try { | 
|  | const res = await fetch(url); | 
|  | if (!res.ok) throw new Error(`HTTP ${res.status}`); | 
|  | const target = join(dir, "audio" + audioExt(url, res.headers.get("content-type"))); | 
|  | const part = target + ".part"; | 
|  | await pipeline(Readable.fromWeb(res.body), createWriteStream(part)); | 
|  | renameSync(part, target); | 
|  | gotAudio = true; | 
|  | } catch (e) { | 
|  | problems.push(`audio download attempt ${attempt + 1}: ${e.message}`); | 
|  | for (const f of readdirSync(dir)) if (f.endsWith(".part")) try { unlinkSync(join(dir, f)); } catch {} | 
|  | await sleep(5000); | 
|  | } | 
|  | } | 
|  | if (!gotAudio && !noAudio) problems.push("audio: not downloaded"); | 
|  | } | 
|  |  | 
|  | const has = (f) => existsSync(join(dir, f)) ? "yes" : "no"; | 
|  | const audioFile = readdirSync(dir).find((f) => f.startsWith("audio.")); | 
|  | const status = problems.length ? "partial" : "ok"; | 
|  | appendFileSync(indexPath, [id, (info.start_at && info.start_at !== "-" ? info.start_at : info.created_at), info.name, info.duration, folderName, | 
|  | audioFile ? "yes" : "no", has("transcript.txt"), has("summary_and_notes.md"), status].map(csv).join(",") + "\n"); | 
|  |  | 
|  | if (problems.length) { | 
|  | failed++; | 
|  | appendFileSync(ERRLOG, `[${new Date().toISOString()}] ${id} ${info.name}\n  ` + problems.join("\n  ") + "\n"); | 
|  | } else { | 
|  | writeFileSync(join(dir, ".done"), new Date().toISOString()); | 
|  | } | 
|  | } catch (e) { | 
|  | failed++; | 
|  | appendFileSync(ERRLOG, `[${new Date().toISOString()}] ${id}\n  ${e.message}\n`); | 
|  | console.error(`  ! ${e.message}`); | 
|  | if (e.message.startsWith("AUTH")) process.exit(2); | 
|  | } | 
|  | } | 
|  |  | 
|  | console.log(`\nDone. ${ids.length} recordings: ${ids.length - failed - skipped} saved, ${skipped} already done, ${failed} with problems.`); | 
|  | if (failed) console.log(`See ${ERRLOG}. Re-run the same command to retry anything incomplete.`); | 
|  | console.log(`Backup folder: ${ACCT_DIR}`); |
