-
-
Notifications
You must be signed in to change notification settings - Fork 6
docs: re-record the README demos and make re-recording reproducible #244
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| # Services used only for re-recording the README demos. | ||
| # | ||
| # bun run recording:up # start + configure, write .tsarr.json and .recording-bin | ||
| # vhs docs/vhs/hero.tape | ||
| # vhs docs/vhs/workflow.tape | ||
| # bun run recording:down | ||
| # | ||
| # Readarr is absent: upstream is archived and publishes no arm64 image. | ||
| # Separate from docker/compose.test.yml on purpose: that one is for CI and must | ||
| # stay fast, this one exists so the GIFs can be regenerated without hand setup. | ||
|
|
||
| name: tsarr-recording | ||
|
|
||
| x-arr: &arr | ||
| environment: | ||
| - PUID=1000 | ||
| - PGID=1000 | ||
| - TZ=Etc/UTC | ||
| restart: 'no' | ||
|
|
||
| services: | ||
| radarr: | ||
| <<: *arr | ||
| image: lscr.io/linuxserver/radarr:latest | ||
| container_name: tsarr-rec-radarr | ||
| ports: ['127.0.0.1:17878:7878'] | ||
| sonarr: | ||
| <<: *arr | ||
| image: lscr.io/linuxserver/sonarr:latest | ||
| container_name: tsarr-rec-sonarr | ||
| ports: ['127.0.0.1:18989:8989'] | ||
| lidarr: | ||
| <<: *arr | ||
| image: lscr.io/linuxserver/lidarr:latest | ||
| container_name: tsarr-rec-lidarr | ||
| ports: ['127.0.0.1:18686:8686'] | ||
| prowlarr: | ||
| <<: *arr | ||
| image: lscr.io/linuxserver/prowlarr:latest | ||
| container_name: tsarr-rec-prowlarr | ||
| ports: ['127.0.0.1:19696:9696'] | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,146 @@ | ||
| #!/usr/bin/env bun | ||
| /** | ||
| * Environment for re-recording the README demos. | ||
| * | ||
| * bun run recording:up start the services, wait for them, write config | ||
| * vhs docs/vhs/hero.tape | ||
| * vhs docs/vhs/workflow.tape | ||
| * bun run recording:down stop everything and remove the local config | ||
| * | ||
| * The GIFs went stale once because re-recording meant hand-configuring a stack. | ||
| * This makes it one command. | ||
| * | ||
| * Writes `.tsarr.json` and `.recording-bin/tsarr` — both gitignored. The tapes | ||
| * put `.recording-bin` first on PATH so `tsarr` runs from source. | ||
| */ | ||
|
|
||
| import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; | ||
|
|
||
| const COMPOSE_FILE = './docs/vhs/compose.recording.yml'; | ||
| const CONFIG_FILE = './.tsarr.json'; | ||
| const BIN_DIR = './.recording-bin'; | ||
|
|
||
| interface Service { | ||
| name: string; | ||
| container: string; | ||
| baseUrl: string; | ||
| } | ||
|
|
||
| const SERVICES: Service[] = [ | ||
| { name: 'radarr', container: 'tsarr-rec-radarr', baseUrl: 'http://localhost:17878' }, | ||
| { name: 'sonarr', container: 'tsarr-rec-sonarr', baseUrl: 'http://localhost:18989' }, | ||
| { name: 'lidarr', container: 'tsarr-rec-lidarr', baseUrl: 'http://localhost:18686' }, | ||
| { name: 'prowlarr', container: 'tsarr-rec-prowlarr', baseUrl: 'http://localhost:19696' }, | ||
| ]; | ||
|
|
||
| const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); | ||
|
|
||
| function run(cmd: string[]) { | ||
| const proc = Bun.spawnSync(cmd, { stdout: 'inherit', stderr: 'pipe' }); | ||
| if (proc.exitCode !== 0) { | ||
| throw new Error( | ||
| `Command failed: ${cmd.join(' ')}\n${proc.stderr ? new TextDecoder().decode(proc.stderr) : ''}` | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| function capture(cmd: string[]): string { | ||
| const proc = Bun.spawnSync(cmd, { stdout: 'pipe', stderr: 'pipe' }); | ||
| return proc.stdout ? new TextDecoder().decode(proc.stdout) : ''; | ||
| } | ||
|
|
||
| /** The arr apps write their generated key into config.xml on first start. */ | ||
| async function waitForApiKey(service: Service, timeoutMs = 240_000): Promise<string> { | ||
| const start = Date.now(); | ||
| process.stdout.write(`⏳ ${service.name}`); | ||
| while (Date.now() - start < timeoutMs) { | ||
| const xml = capture(['docker', 'exec', service.container, 'cat', '/config/config.xml']); | ||
| const key = xml.match(/<ApiKey>([^<]+)<\/ApiKey>/)?.[1]; | ||
| if (key) { | ||
| // The key exists before the HTTP API is listening; wait for both. | ||
| try { | ||
| const response = await fetch(`${service.baseUrl}/api/v3/system/status`, { | ||
| headers: { 'X-Api-Key': key }, | ||
| signal: AbortSignal.timeout(5000), | ||
| }); | ||
| if (response.ok || response.status === 404) { | ||
| console.log(`\n✅ ${service.name} ready at ${service.baseUrl}`); | ||
| return key; | ||
| } | ||
| } catch { | ||
| // not listening yet | ||
| } | ||
| } | ||
| process.stdout.write('.'); | ||
| await sleep(3000); | ||
| } | ||
| throw new Error(`${service.name} did not become ready within ${timeoutMs}ms`); | ||
| } | ||
|
|
||
| function writeShim() { | ||
| mkdirSync(BIN_DIR, { recursive: true }); | ||
| const shim = `${BIN_DIR}/tsarr`; | ||
| writeFileSync( | ||
| shim, | ||
| `#!/bin/sh\n# Runs the CLI from source so recordings show current behaviour.\nexec bun run "${process.cwd()}/src/cli/index.ts" "$@"\n` | ||
| ); | ||
| chmodSync(shim, 0o755); | ||
| console.log(`🔧 Wrote ${shim}`); | ||
| } | ||
|
|
||
| async function up() { | ||
| console.log('🐳 Starting recording services...'); | ||
| run(['docker', 'compose', '-f', COMPOSE_FILE, 'up', '-d']); | ||
|
|
||
| const services: Record<string, unknown> = {}; | ||
| for (const service of SERVICES) { | ||
| services[service.name] = { baseUrl: service.baseUrl, apiKey: await waitForApiKey(service) }; | ||
| } | ||
|
|
||
| // Reuse the Jellyfin the integration test bed already provisions, if it is up. | ||
| if (existsSync('./.env.test')) { | ||
| const env = Object.fromEntries( | ||
| require('node:fs') | ||
| .readFileSync('./.env.test', 'utf-8') | ||
| .split('\n') | ||
| .filter((l: string) => l.includes('=')) | ||
| .map((l: string) => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)]) | ||
| ); | ||
| if (env.JELLYFIN_BASE_URL && env.JELLYFIN_API_KEY) { | ||
| services.jellyfin = { baseUrl: env.JELLYFIN_BASE_URL, apiKey: env.JELLYFIN_API_KEY }; | ||
|
Comment on lines
+101
to
+110
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win Check Jellyfin before adding it to the recording configuration.
Probe the configured Jellyfin URL with its API key before adding 🤖 Prompt for AI Agents |
||
| console.log('✅ jellyfin picked up from the integration test bed'); | ||
| } | ||
| } else { | ||
| console.log('ℹ️ Run `bun run testbed:up` first to include Jellyfin in the recording.'); | ||
| } | ||
|
|
||
| writeFileSync(CONFIG_FILE, `${JSON.stringify({ services }, null, 2)}\n`); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Preserve an existing local configuration.
Fail before modifying an existing file, or back it up during Also applies to: 129-131 🤖 Prompt for AI Agents |
||
| console.log(`📝 Wrote ${CONFIG_FILE}`); | ||
| writeShim(); | ||
|
|
||
| console.log('\n🎬 Ready to record:\n'); | ||
| console.log(' vhs docs/vhs/hero.tape'); | ||
| console.log(' vhs docs/vhs/workflow.tape'); | ||
| console.log(' bun run recording:down'); | ||
| } | ||
|
|
||
| function down() { | ||
| console.log('🧹 Stopping recording services...'); | ||
| run(['docker', 'compose', '-f', COMPOSE_FILE, 'down', '-v']); | ||
| rmSync(CONFIG_FILE, { force: true }); | ||
| rmSync(BIN_DIR, { recursive: true, force: true }); | ||
| console.log('✅ Recording environment removed'); | ||
| } | ||
|
|
||
| const command = process.argv[2]; | ||
| try { | ||
| if (command === 'up') await up(); | ||
| else if (command === 'down') down(); | ||
| else { | ||
| console.error('Usage: bun run scripts/recording.ts <up|down>'); | ||
| process.exit(1); | ||
| } | ||
| } catch (error) { | ||
| console.error(`\n💥 ${error instanceof Error ? error.message : String(error)}`); | ||
| process.exit(1); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: robbeverhelst/Tsarr
Length of output: 6748
Pin the recording image references.
The four services use mutable
latesttags. A later recording can pull different service versions, so the workflow does not guarantee reproducible GIF output or CLI behavior. Pin each image to a tested version tag or digest, and confirm host-architecture support.🤖 Prompt for AI Agents