{"node_modules":{"directory":{"youtube-playlist-downloader":{"directory":{"README.md":{"file":{"contents":"# YouTube Playlist Downloader\n\nDownload YouTube videos or playlists as MP3 files, optionally splitting recordings into named tracks.\n\n![yt-download fetching a sample playlist and splitting each mix into named tracks](docs/media/demo.webp)\n\n**[Try it in your browser](https://yt-download.soleone.xyz)**, no install. A sample playlist stands in for YouTube.\n\nA Node.js/TypeScript CLI orchestrates `yt-dlp` and `ffmpeg`. No YouTube API key is needed.\n\n## Setup\n\nRequires:\n\n- Node.js **22+** and npm\n- A current `yt-dlp` installation with its EJS challenge solver\n- `ffmpeg`, plus `ffprobe` for splitting (usually installed together)\n\n### Ubuntu 26.04: apt backports\n\nWith `resolute-backports` enabled, install or update the current backported version:\n\n```bash\nsudo apt-get update\nsudo apt-get install -t resolute-backports yt-dlp\n/usr/bin/yt-dlp --version\n```\n\nA plain `sudo apt install yt-dlp` can keep an older version from the main release repository: backports have lower priority unless explicitly selected. The available `2026.08.19` backport includes the EJS solver and has been checked against a small audio sample from YouTube.\n\nUse the backports repository matching your Ubuntu release; `resolute-backports` is specific to Ubuntu 26.04. Other distributions may offer different versions or backports instructions.\n\n**Switching from a uv installation:** the CLI prefers `~/.local/bin/yt-dlp` over `/usr/bin/yt-dlp`. After successfully upgrading and verifying the apt copy, remove the uv-managed copy to let the CLI use apt:\n\n```bash\nuv tool uninstall yt-dlp\n```\n\n### User-local alternative: uv or pipx\n\nIf your distribution does not offer a recent enough version, install a current user-local `yt-dlp`, including the EJS solver, with [uv](https://docs.astral.sh/uv/):\n\n```bash\nuv tool install --upgrade \"yt-dlp[default]\"\n```\n\nThis command also updates an existing uv installation. Alternatively, use pipx:\n\n```bash\npipx install \"yt-dlp[default]\"\n# To update an existing pipx installation:\npipx upgrade yt-dlp\n```\n\nOr use `python3 -m pip install --user -U \"yt-dlp[default]\"` if your Python environment permits user installs. The CLI prefers `~/.local/bin` over system copies. Distribution packages such as apt can lag behind YouTube changes.\n\nInstall `ffmpeg` with your system package manager:\n\n```bash\n# macOS\nbrew install ffmpeg\n\n# Ubuntu/Debian\nsudo apt update && sudo apt install -y ffmpeg\n```\n\nThen, from this repository:\n\n```bash\nnpm install\n```\n\nThis installs the development dependencies and builds the CLI. There are no npm runtime dependencies. After editing TypeScript, rebuild with `npm run build`.\n\nInstall external tools separately. The CLI uses Node as yt-dlp's JavaScript runtime. See [yt-dlp's EJS setup guide](https://github.com/yt-dlp/yt-dlp/wiki/EJS) for other installation methods.\n\n## Quick start\n\n```bash\n./bin/yt-download \"YOUTUBE_PLAYLIST_OR_VIDEO_URL\"\n./bin/yt-download \"URL\" ~/Music/youtube\n```\n\nTo install the `yt-download` command from this checkout:\n\n```bash\nnpm link\nyt-download \"URL\"\nyt-download --timestamp-split \"URL\"\n```\n\n`bin/yt-download` is the executable Node entry point, registered in `package.json`'s `bin` field. You can also run it with `npm start -- \"URL\"`.\n\nDownloads go to `./downloads` relative to your working directory by default. Existing MP3s are skipped unless `--force` is supplied. With no URL, an interactive terminal prompts for one.\n\n## Timestamp splitting\n\n```bash\n./bin/yt-download --timestamp-split \"URL\"\n./bin/yt-download --timestamp-split \"URL\" ~/Music/youtube\n```\n\nFor each video, including videos inside playlists:\n\n1. Use chapter metadata supplied by `yt-dlp`.\n2. If there is no usable chapter list, parse the video description for timestamps and track names.\n3. Split from each timestamp to the next, and from the last timestamp to the recording's end.\n\nSupported description formats include:\n\n```text\n00:00 - Opening Theme\n03:12 Second Track\n[07:45] Third Track\nFourth Track 1:02:03\n```\n\nTrack names come from the labels alongside timestamps and are numbered sequentially:\n\n```text\ndownloads/Album/\n├── 01 - Album.mp3\n└── 01 - Album - tracks/\n    ├── 01 - Opening Theme.mp3\n    ├── 02 - Second Track.mp3\n    ├── 03 - Third Track.mp3\n    └── 04 - Fourth Track.mp3\n```\n\nIf a timestamp has no label, its timestamp becomes the name, for example `02 - 03-12.mp3`. Colons and other unsafe filename characters are replaced with hyphens. Track title, track number, and album title are also written as MP3 metadata.\n\n- At least two valid timestamps/chapters are required, in strictly increasing order and within the recording's duration. Invalid lists are not guessed or reordered.\n- If the first timestamp is after zero, an `Intro` track preserves the preceding audio. Short tracks are not discarded.\n- If no usable timestamps are found, keep the full recording and report that it could not be split. **There is no silence fallback with `--timestamp-split`.**\n- The original MP3 is always retained. Each downloaded source filename gets its own track folder rather than writing all playlist tracks into one shared directory.\n- Existing track folders are skipped unless `--force` is provided. Splitting can run against an already-downloaded MP3; timestamps are refreshed through `yt-dlp`.\n- All cuts finish in a temporary staging folder before being published. Failed cuts leave the original and existing tracks intact.\n- `--force` replaces generated files with matching names, not unrelated files in the track folder. If track names change, older differently named tracks are left in place.\n- Splitting stream-copies the audio without re-encoding. Boundaries are limited to MP3 frame precision. Embedded artwork and other source tags are not copied to the split tracks; they remain in the full MP3.\n\n## Other options\n\n```bash\n./bin/yt-download --force \"URL\"\n./bin/yt-download --force --timestamp-split \"URL\"\n./bin/yt-download --split \"URL\"\n./bin/yt-download --help\n```\n\n`--split` enables automatic splitting: timestamps first, then silence detection for recordings at least 15 minutes long. Silence detection uses `-35dB` and a 1.2-second minimum silence, keeping tracks at least 45 seconds long without discarding short fragments. Do not combine `--split` and `--timestamp-split`.\n\nThe CLI deliberately ignores external `yt-dlp` configuration so output paths, overwrite behavior, and postprocessing remain predictable. It uses yt-dlp's current default YouTube clients rather than pinning clients in the wrapper.\n\n## Troubleshooting YouTube HTTP 403\n\nA `403: Forbidden` while downloading video data happens before timestamp splitting: YouTube rejected the media request. It can be intermittent, or caused by an outdated extractor.\n\nFirst update `yt-dlp` using the installer that manages it. For Ubuntu 26.04 with backports enabled:\n\n```bash\nsudo apt-get update\nsudo apt-get install -t resolute-backports yt-dlp\n/usr/bin/yt-dlp --version\n```\n\nFor uv, include the `default` extras for the EJS solver:\n\n```bash\nuv tool install --upgrade \"yt-dlp[default]\"\n~/.local/bin/yt-dlp --version\n```\n\nThen retry the same `yt-download` command. If both copies are installed, the CLI still prefers the user-local copy; see the switching instructions above. A user-local installation leaves apt's package untouched. Do not hide the outdated-version warning with `--no-update`; that does not fix extraction. If a current version still returns 403, network/IP restrictions or other YouTube changes may need separate investigation.\n\n## Development\n\n```bash\nnpm run check\nnpm test\nnpm pack --dry-run\n```\n\nPackaging rebuilds the CLI and includes `bin/`, compiled `dist/src/`, and the README, excluding tests and downloads. The package remains private until it is ready to publish.\n\nTests cover timestamp parsing, filename safety, track tags, overwrite behavior, playlist isolation, and failure cleanup. Audio integration tests use local fixtures and skip if `ffmpeg`/`ffprobe` are unavailable. A real `yt-dlp` metadata smoke test also runs when it is installed, against a local HTTP server rather than YouTube.\n"}},"bin":{"directory":{"yt-download":{"file":{"contents":"#!/usr/bin/env node\nimport { main } from '../dist/src/cli.js';\n\nmain().catch((error) => {\n  console.error(`✗ ${error instanceof Error ? error.message : String(error)}`);\n  process.exitCode = 1;\n});\n"}}}},"package.json":{"file":{"contents":"{\n  \"name\": \"youtube-playlist-downloader\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"engines\": { \"node\": \">=22\" },\n  \"bin\": { \"yt-download\": \"bin/yt-download\" },\n  \"files\": [\"bin/\", \"dist/src/\"],\n  \"scripts\": {\n    \"build\": \"tsc\",\n    \"prepare\": \"npm run build\",\n    \"prepack\": \"npm run build\",\n    \"start\": \"node bin/yt-download\",\n    \"test\": \"npm run build && node --test dist/test/*.test.js\",\n    \"check\": \"tsc --noEmit\",\n    \"demo\": \"showcase terminal --serve\",\n    \"media:capture\": \"node scripts/capture-demo.mjs\"\n  },\n  \"devDependencies\": {\n    \"@soleone/showcase\": \"^0.2.0\",\n    \"@types/node\": \"^22.0.0\",\n    \"typescript\": \"^6.0.3\"\n  }\n}\n"}},"dist":{"directory":{"src":{"directory":{"audio.js":{"file":{"contents":"import { spawn } from 'node:child_process';\nimport { lstat, mkdir, mkdtemp, rename, rm } from 'node:fs/promises';\nimport { basename, dirname, join } from 'node:path';\nimport { trackFilename } from './timestamps.js';\nexport function runCommand(command, args, capture = false) {\n    return new Promise((resolve, reject) => {\n        const child = spawn(command, args, { stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit' });\n        let stdout = '';\n        let stderr = '';\n        child.stdout?.setEncoding('utf8').on('data', (chunk) => { stdout += chunk; });\n        child.stderr?.setEncoding('utf8').on('data', (chunk) => { stderr += chunk; });\n        child.on('error', reject);\n        child.on('close', (code, signal) => {\n            if (code !== 0)\n                reject(new Error(`${command} failed (${signal ?? code})${stderr ? `: ${stderr.slice(-3000).trim()}` : ''}`));\n            else\n                resolve({ stdout, stderr });\n        });\n    });\n}\nexport async function audioDuration(file) {\n    const { stdout } = await runCommand('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', file], true);\n    const duration = Number(stdout.trim());\n    if (!Number.isFinite(duration) || duration <= 0)\n        throw new Error(`Cannot determine audio duration: ${file}`);\n    return duration;\n}\nexport async function silenceSegments(file, duration) {\n    if (duration < 900)\n        return [];\n    const { stderr } = await runCommand('ffmpeg', ['-nostdin', '-hide_banner', '-i', file, '-af', 'silencedetect=noise=-35dB:d=1.2', '-f', 'null', '-'], true);\n    const boundaries = [0];\n    for (const match of stderr.matchAll(/silence_end:\\s*([0-9.]+)/g)) {\n        const cut = Number(match[1]);\n        if (cut - boundaries.at(-1) >= 45 && duration - cut >= 45)\n            boundaries.push(cut);\n    }\n    if (boundaries.length < 2)\n        return [];\n    return boundaries.map((start, index) => ({ start, end: boundaries[index + 1] ?? duration, title: `Part ${String(index + 1).padStart(2, '0')}` }));\n}\nasync function existingDirectory(path) {\n    const stat = await lstat(path).catch((error) => {\n        if (error.code === 'ENOENT')\n            return undefined;\n        throw error;\n    });\n    if (!stat)\n        return false;\n    if (!stat.isDirectory() || stat.isSymbolicLink())\n        throw new Error(`Track output is not a regular directory: ${path}`);\n    return true;\n}\nexport async function splitAudio(video, segments, force) {\n    if (segments.length < 2)\n        throw new Error('Splitting requires at least two tracks');\n    const directory = join(dirname(video.filepath), `${basename(video.filepath, '.mp3')} - tracks`);\n    const exists = await existingDirectory(directory);\n    if (exists && !force) {\n        console.log(`→ Skipping existing track folder: ${directory} (use --force to overwrite)`);\n        return false;\n    }\n    const staging = await mkdtemp(join(dirname(video.filepath), '.yt-tracks-'));\n    let createdDirectory = false;\n    const published = [];\n    try {\n        for (const [index, segment] of segments.entries()) {\n            const output = join(staging, trackFilename(segment, index, segments.length));\n            await runCommand('ffmpeg', [\n                '-nostdin', '-hide_banner', '-loglevel', 'error', '-n',\n                '-ss', segment.start.toFixed(3), '-i', video.filepath, '-t', (segment.end - segment.start).toFixed(3),\n                '-map', '0:a:0', '-c:a', 'copy', '-map_metadata', '-1',\n                '-metadata', `title=${segment.title}`, '-metadata', `track=${index + 1}/${segments.length}`,\n                '-metadata', `album=${video.title}`, output,\n            ], true);\n        }\n        // No existing tracks are touched until every ffmpeg cut succeeds.\n        if (!exists) {\n            await mkdir(directory);\n            createdDirectory = true;\n        }\n        for (const [index, segment] of segments.entries()) {\n            const name = trackFilename(segment, index, segments.length);\n            await rename(join(staging, name), join(directory, name));\n            published.push(join(directory, name));\n        }\n        console.log(`✓ ${segments.length} tracks → ${directory} (original retained)`);\n        return true;\n    }\n    catch (error) {\n        if (createdDirectory) {\n            for (const path of published)\n                await rm(path, { force: true });\n            await rm(directory, { recursive: true, force: true });\n        }\n        throw error;\n    }\n    finally {\n        await rm(staging, { recursive: true, force: true });\n    }\n}\n"}},"cli.js":{"file":{"contents":"import { createReadStream } from 'node:fs';\nimport { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';\nimport { homedir, tmpdir } from 'node:os';\nimport { delimiter, join, resolve } from 'node:path';\nimport { createInterface } from 'node:readline';\nimport { createInterface as createPrompt } from 'node:readline/promises';\nimport { parseArgs } from 'node:util';\nimport { audioDuration, runCommand, silenceSegments, splitAudio } from './audio.js';\nimport { readMetadata, timestampSegments } from './timestamps.js';\nconst HELP = `YouTube Playlist Downloader\n\nUsage: yt-download [options] URL [output_directory]\n       ./bin/yt-download [options] URL [output_directory]\n\nDownloads MP3 audio into ./downloads by default.\n\n  --timestamp-split  Split by chapters, then description timestamps. No silence fallback.\n  --split            Split by timestamps, then silence detection for recordings >=15 minutes.\n  --force            Overwrite downloads and generated tracks.\n  --help, -h         Show this help.\n\nTracks are sequentially numbered using timestamp labels, or the timestamp if unlabeled.\nEach recording gets a separate track folder. The original MP3 is always retained.\nRequires Node.js 22+, yt-dlp (with EJS support), and ffmpeg. Splitting also needs ffprobe.\n`;\nasync function requireTool(command, args, hint) {\n    try {\n        await runCommand(command, args, true);\n    }\n    catch (error) {\n        throw new Error(`${command} is missing or unusable. ${hint}`, { cause: error });\n    }\n}\nasync function splitDownloads(manifest, silenceFallback, force) {\n    const lines = createInterface({ input: createReadStream(manifest), crlfDelay: Infinity });\n    try {\n        for await (const line of lines) {\n            if (!line.trim())\n                continue;\n            const video = readMetadata(JSON.parse(line));\n            video.filepath = resolve(video.filepath);\n            await access(video.filepath);\n            const duration = await audioDuration(video.filepath);\n            let { segments, source } = timestampSegments(video, duration);\n            if (!segments.length && silenceFallback) {\n                segments = await silenceSegments(video.filepath, duration);\n                source = 'silence detection';\n            }\n            if (!segments.length) {\n                console.log(`→ No usable ${silenceFallback ? 'split points' : 'timestamps'}: ${video.title}. Keeping full MP3.`);\n                continue;\n            }\n            console.log(`→ Splitting ${video.title} by ${source}...`);\n            await splitAudio(video, segments, force);\n        }\n    }\n    finally {\n        lines.close();\n    }\n}\nexport async function main() {\n    const { values, positionals } = parseArgs({\n        allowPositionals: true,\n        options: {\n            force: { type: 'boolean' },\n            split: { type: 'boolean' },\n            'timestamp-split': { type: 'boolean' },\n            help: { type: 'boolean', short: 'h' },\n        },\n    });\n    if (values.help) {\n        console.log(HELP);\n        return;\n    }\n    if (positionals.length > 2)\n        throw new Error('Expected a URL and optional output directory. Use --help for usage.');\n    if (values.split && values['timestamp-split'])\n        throw new Error('Choose --split or --timestamp-split, not both.');\n    if (Number(process.versions.node.split('.')[0]) < 22)\n        throw new Error('Node.js 22 or newer is required.');\n    let url = positionals[0];\n    if (!url) {\n        if (!process.stdin.isTTY)\n            throw new Error('Provide a YouTube URL. Use --help for usage.');\n        const prompt = createPrompt({ input: process.stdin, output: process.stdout });\n        try {\n            url = (await prompt.question('YouTube playlist/video URL: ')).trim();\n        }\n        finally {\n            prompt.close();\n        }\n        if (!url)\n            return;\n    }\n    const parsedUrl = new URL(url);\n    if (!['https:', 'http:'].includes(parsedUrl.protocol))\n        throw new Error('URL must start with https:// or http://');\n    const output = resolve(positionals[1] ?? './downloads');\n    const split = Boolean(values.split || values['timestamp-split']);\n    const force = Boolean(values.force);\n    process.env.PATH = `${join(homedir(), '.local', 'bin')}${delimiter}${process.env.PATH ?? ''}`;\n    await requireTool('yt-dlp', ['--version'], 'Install with: pipx install \"yt-dlp[default]\"');\n    await requireTool('ffmpeg', ['-version'], 'Install ffmpeg with your system package manager.');\n    if (split)\n        await requireTool('ffprobe', ['-version'], 'Install ffmpeg (which includes ffprobe).');\n    await mkdir(output, { recursive: true });\n    console.log(`→ ${output}`);\n    console.log(`→ Downloading (${force ? 'overwrite mode' : 'skipping existing files'})...`);\n    const temporary = await mkdtemp(join(tmpdir(), 'yt-download-'));\n    try {\n        const manifest = join(temporary, 'downloads.jsonl');\n        const args = [\n            '--ignore-config', '--no-simulate', '--no-split-chapters',\n            '-x', '--audio-format', 'mp3', '--audio-quality', '0', '--embed-thumbnail', '--add-metadata',\n            '--no-js-runtimes', '--js-runtimes', `node:${process.execPath}`,\n            '--paths', output, '--output', '%(playlist,title)s/%(playlist_index,autonumber)02d - %(title)s.%(ext)s',\n            force ? '--force-overwrites' : '--no-overwrites',\n        ];\n        if (split) {\n            await writeFile(manifest, '');\n            args.push('--print-to-file', 'after_move:%()j', manifest);\n        }\n        args.push('--', url);\n        await runCommand('yt-dlp', args);\n        if (split)\n            await splitDownloads(manifest, Boolean(values.split), force);\n    }\n    finally {\n        await rm(temporary, { recursive: true, force: true });\n    }\n    console.log(`✓ Done! → ${output}`);\n}\n"}},"timestamps.js":{"file":{"contents":"export function readMetadata(value) {\n    if (!value || typeof value !== 'object')\n        throw new Error('Invalid yt-dlp metadata');\n    const data = value;\n    if (typeof data.filepath !== 'string' || !data.filepath || typeof data.title !== 'string') {\n        throw new Error('yt-dlp metadata is missing the final filepath or title');\n    }\n    const chapters = [];\n    if (Array.isArray(data.chapters)) {\n        for (const chapter of data.chapters) {\n            if (!chapter || typeof chapter !== 'object' || typeof chapter.start_time !== 'number'\n                || !Number.isFinite(chapter.start_time)) {\n                // Partial chapter lists could omit tracks. Fall back to the description instead.\n                chapters.length = 0;\n                break;\n            }\n            chapters.push({ start: chapter.start_time, title: typeof chapter.title === 'string' ? chapter.title.trim() : '' });\n        }\n    }\n    return {\n        filepath: data.filepath,\n        title: data.title,\n        description: typeof data.description === 'string' ? data.description : '',\n        chapters,\n    };\n}\nfunction parseTime(value) {\n    const parts = value.split(':').map(Number);\n    const seconds = parts.at(-1);\n    if (seconds >= 60 || (parts.length === 3 && parts[1] >= 60))\n        return undefined;\n    return parts.reduce((total, part) => total * 60 + part, 0);\n}\nfunction trimLabel(value) {\n    return value.replace(/^[\\s\\-–—|:]+|[\\s\\-–—|]+$/g, '').trim();\n}\nexport function descriptionMarkers(description) {\n    const markers = [];\n    for (const line of description.split(/\\r?\\n/)) {\n        // Accept timestamps at either end of a track-list line, not links or prose mentions.\n        const leading = line.match(/^\\s*(?:(?:\\d+[.)]|[-*•])\\s+)?\\[?(\\d{1,3}:\\d{2}(?::\\d{2})?)\\]?(?:\\s+|\\s*[-–—|]\\s*|$)(.*)$/);\n        const trailing = leading ? null : line.match(/^\\s*(.+?)\\s+\\[?(\\d{1,3}:\\d{2}(?::\\d{2})?)\\]?\\s*$/);\n        if (!leading && !trailing) {\n            if (/^\\s*(?:(?:\\d+[.)]|[-*•])\\s+)?\\[?\\d{1,3}:\\d/.test(line))\n                return [];\n            continue;\n        }\n        const time = leading ? leading[1] : trailing[2];\n        const start = parseTime(time);\n        if (start === undefined)\n            return [];\n        const label = leading ? leading[2] : trailing[1];\n        markers.push({ start, title: trimLabel(label.replace(/^\\d+[.)]\\s+/, '')) });\n    }\n    return markers;\n}\nexport function formatTimestamp(seconds) {\n    const whole = Math.floor(seconds);\n    const minutes = Math.floor(whole / 60);\n    const remainder = String(whole % 60).padStart(2, '0');\n    return minutes < 60\n        ? `${String(minutes).padStart(2, '0')}:${remainder}`\n        : `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}:${remainder}`;\n}\nfunction segmentsFromMarkers(markers, duration) {\n    if (markers.length < 2 || !Number.isFinite(duration) || duration <= 0)\n        return [];\n    if (markers.some((marker, index) => !Number.isFinite(marker.start) || marker.start < 0\n        || marker.start >= duration || (index > 0 && marker.start <= markers[index - 1].start)))\n        return [];\n    const complete = markers[0].start > 0 ? [{ start: 0, title: 'Intro' }, ...markers] : markers;\n    return complete.map((marker, index) => ({\n        start: marker.start,\n        end: complete[index + 1]?.start ?? duration,\n        title: marker.title || formatTimestamp(marker.start),\n    }));\n}\nexport function timestampSegments(video, duration) {\n    const chapters = segmentsFromMarkers(video.chapters, duration);\n    if (chapters.length)\n        return { segments: chapters, source: 'chapters' };\n    return { segments: segmentsFromMarkers(descriptionMarkers(video.description), duration), source: 'description timestamps' };\n}\nexport function trackFilename(segment, index, count) {\n    const title = segment.title.replace(/[\\x00-\\x1f\\x7f\\\\/:*?\"<>|]/g, '-').replace(/\\s+/g, ' ').trim().replace(/[ .]+$/g, '');\n    // Stay below common 255-byte filename limits even with multibyte track names.\n    let shortened = '';\n    for (const character of title) {\n        if (Buffer.byteLength(shortened + character) > 180)\n            break;\n        shortened += character;\n    }\n    const number = String(index + 1).padStart(Math.max(2, String(count).length), '0');\n    return `${number} - ${shortened || 'Untitled'}.mp3`;\n}\n"}}}}}}}}}},".demo":{"directory":{"catalog.json":{"file":{"contents":"{\n  \"playlist\": {\n    \"id\": \"PL-sample-mixes\",\n    \"title\": \"Sample Mixes\",\n    \"videos\": [\"nightDrive1\", \"rainyWindow\", \"harborSet03\"]\n  },\n  \"videos\": {\n    \"nightDrive1\": {\n      \"title\": \"Night Drive (Synthwave Mix)\",\n      \"duration\": 1830,\n      \"sizeMiB\": 41.87,\n      \"description\": \"Six tracks for the drive home. Chapters are in the progress bar.\",\n      \"chapters\": [\n        { \"start_time\": 0, \"title\": \"Neon Overpass\" },\n        { \"start_time\": 284, \"title\": \"Tail Lights\" },\n        { \"start_time\": 571, \"title\": \"Coastal Highway\" },\n        { \"start_time\": 902, \"title\": \"Rearview\" },\n        { \"start_time\": 1196, \"title\": \"Exit 49\" },\n        { \"start_time\": 1517, \"title\": \"Last Gas Station\" }\n      ]\n    },\n    \"rainyWindow\": {\n      \"title\": \"Rainy Window Lo-fi\",\n      \"duration\": 1512,\n      \"sizeMiB\": 34.6,\n      \"description\": \"Beats to read to. No chapters, so the track list lives here:\\n\\n0:42 - Kettle On\\n04:10 Drizzle\\n[08:31] Second Cup\\nPaper Lanterns 12:55\\n17:20 - Window Seat\\n21:48 - Fog Lifts\\n\\nThanks for listening!\",\n      \"chapters\": []\n    },\n    \"harborSet03\": {\n      \"title\": \"Live at the Harbor Stage (full set)\",\n      \"duration\": 1620,\n      \"sizeMiB\": 37.08,\n      \"description\": \"Recorded on the pier last summer. No track list, sorry!\",\n      \"chapters\": [],\n      \"silences\": [\n        [401.2, 403.0],\n        [812.5, 814.4],\n        [1203.8, 1205.6]\n      ]\n    }\n  }\n}\n"}},"ffmpeg.mjs":{"file":{"contents":"// Stand-in for ffmpeg in the browser demo. It answers the two jobs the CLI gives it, using the placeholder MP3s the\n// yt-dlp stand-in wrote: silence detection (from the sample's listed silences) and cutting a track (a smaller placeholder).\nimport { existsSync, readFileSync, writeFileSync } from 'node:fs';\n\nconst args = process.argv.slice(2);\nconst option = (name) => args[args.indexOf(name) + 1];\nconst sleep = (ms) => new Promise((done) => setTimeout(done, ms));\n\nif (args.includes('-version')) {\n  console.log('ffmpeg version 7.1.1 (demo stand-in)');\n  process.exit(0);\n}\n\nconst input = option('-i');\nlet audio;\ntry {\n  audio = JSON.parse(readFileSync(input, 'utf8'));\n} catch {\n  console.error(`${input}: Invalid data found when processing input`);\n  process.exit(1);\n}\n\nif (args.includes('-af')) {\n  await sleep(600);\n  for (const [start, end] of audio.silences ?? []) {\n    console.error(`[silencedetect @ 0x5f3c2a1b8e40] silence_start: ${start}`);\n    console.error(`[silencedetect @ 0x5f3c2a1b8e40] silence_end: ${end} | silence_duration: ${(end - start).toFixed(3)}`);\n  }\n  process.exit(0);\n}\n\nconst output = args.at(-1);\nif (args.includes('-n') && existsSync(output)) {\n  console.error(`File '${output}' already exists. Exiting.`);\n  process.exit(1);\n}\nconst metadata = Object.fromEntries(\n  args.flatMap((value, index) => (args[index - 1] === '-metadata' ? [value.split(/=(.*)/s).slice(0, 2)] : [])),\n);\nawait sleep(80);\nwriteFileSync(output, JSON.stringify({\n  placeholder: audio.placeholder,\n  duration: Number(option('-t')),\n  ...metadata,\n}) + '\\n');\n"}},"ffprobe.mjs":{"file":{"contents":"// Stand-in for ffprobe in the browser demo: reads the duration the yt-dlp stand-in wrote into its placeholder MP3.\nimport { readFileSync } from 'node:fs';\n\nconst args = process.argv.slice(2);\nif (args.includes('-version')) {\n  console.log('ffprobe version 7.1.1 (demo stand-in)');\n  process.exit(0);\n}\nconst file = args.at(-1);\ntry {\n  console.log(String(JSON.parse(readFileSync(file, 'utf8')).duration));\n} catch {\n  console.error(`${file}: Invalid data found when processing input`);\n  process.exit(1);\n}\n"}},"yt-dlp.mjs":{"file":{"contents":"// Stand-in for yt-dlp in the browser demo: nothing is fetched from YouTube. It knows the sample playlist in\n// catalog.json, prints yt-dlp-style progress and writes small placeholder MP3s that the ffmpeg and ffprobe stand-ins read.\nimport { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';\nimport { dirname, join, resolve } from 'node:path';\n\nconst catalog = JSON.parse(readFileSync(new URL('./catalog.json', import.meta.url), 'utf8'));\nconst args = process.argv.slice(2);\nconst sleep = (ms) => new Promise((done) => setTimeout(done, ms));\nconst option = (name) => (args.includes(name) ? args[args.indexOf(name) + 1] : undefined);\nconst sample = `https://www.youtube.com/playlist?list=${catalog.playlist.id}`;\n\nif (args.includes('--version')) {\n  console.log('2026.08.19 (demo stand-in)');\n  process.exit(0);\n}\n\nfunction fail(message) {\n  console.error(`ERROR: ${message}`);\n  process.exit(1);\n}\n\nconst url = args[args.indexOf('--') + 1] ?? args.at(-1);\nlet parsed;\ntry { parsed = new URL(url); } catch { fail(`'${url}' is not a valid URL.`); }\nif (!/(^|\\.)youtube\\.com$|^youtu\\.be$/.test(parsed.hostname)) fail(`Unsupported URL: ${url}`);\nconst listId = parsed.searchParams.get('list');\nconst videoId = parsed.hostname === 'youtu.be' ? parsed.pathname.slice(1) : parsed.searchParams.get('v');\nconst offline = 'This demo runs offline in your browser and only knows the sample playlist and its videos.';\nif (listId && listId !== catalog.playlist.id) fail(`[youtube:tab] ${listId}: ${offline} Try ${sample}`);\nif (!listId && !catalog.videos[videoId]) fail(`[youtube] ${videoId ?? url}: ${offline} Try ${sample}`);\n\nconst output = resolve(option('--paths') ?? '.');\nconst force = args.includes('--force-overwrites');\nconst printTo = args.includes('--print-to-file') ? args[args.indexOf('--print-to-file') + 2] : undefined;\nconst playlist = listId ? catalog.playlist : undefined;\nconst ids = playlist ? playlist.videos : [videoId];\n\nasync function progress(sizeMiB) {\n  const seconds = Math.max(2, Math.round(sizeMiB / 9.5));\n  for (let step = 1; step <= 12; step++) {\n    const percent = (step / 12) * 100;\n    const eta = Math.round(seconds * (1 - step / 12));\n    process.stdout.write(`\\r[download] ${percent.toFixed(1).padStart(5)}% of ${sizeMiB.toFixed(2).padStart(7)}MiB at  ${(9 + step / 6).toFixed(2)}MiB/s ETA 00:0${eta}`);\n    await sleep(90);\n  }\n  process.stdout.write(`\\r[download] 100% of ${sizeMiB.toFixed(2).padStart(7)}MiB in 00:00:0${seconds} at 9.71MiB/s          \\n`);\n}\n\nif (playlist) {\n  console.log(`[youtube:tab] Extracting URL: ${url}`);\n  await sleep(150);\n  console.log(`[youtube:tab] ${playlist.id}: Downloading webpage`);\n  await sleep(200);\n  console.log(`[download] Downloading playlist: ${playlist.title}`);\n  console.log(`[youtube:tab] Playlist ${playlist.title}: Downloading ${ids.length} items of ${ids.length}`);\n}\n\nfor (const [index, id] of ids.entries()) {\n  const video = catalog.videos[id];\n  if (playlist) console.log(`[download] Downloading item ${index + 1} of ${ids.length}`);\n  console.log(`[youtube] Extracting URL: https://www.youtube.com/watch?v=${id}`);\n  await sleep(120);\n  console.log(`[youtube] ${id}: Downloading webpage`);\n  await sleep(120);\n  console.log(`[info] ${id}: Downloading 1 format(s): 251`);\n  // The CLI's output template: %(playlist,title)s/%(playlist_index,autonumber)02d - %(title)s.%(ext)s\n  const filepath = join(output, playlist?.title ?? video.title, `${String(index + 1).padStart(2, '0')} - ${video.title}.mp3`);\n  if (existsSync(filepath) && !force) {\n    console.log(`[download] ${filepath} has already been downloaded`);\n  } else {\n    console.log(`[download] Destination: ${filepath.replace(/\\.mp3$/, '.webm')}`);\n    await progress(video.sizeMiB);\n    console.log(`[ExtractAudio] Destination: ${filepath}`);\n    await sleep(250);\n    mkdirSync(dirname(filepath), { recursive: true });\n    writeFileSync(filepath, JSON.stringify({\n      placeholder: 'Demo audio: nothing was downloaded, so there is no sound.',\n      duration: video.duration,\n      silences: video.silences ?? [],\n    }) + '\\n');\n    console.log(`Deleting original file ${filepath.replace(/\\.mp3$/, '.webm')} (pass -k to keep)`);\n    console.log(`[Metadata] Adding metadata to \"${filepath}\"`);\n    console.log(`[EmbedThumbnail] ffmpeg: Adding thumbnail to \"${filepath}\"`);\n  }\n  if (printTo) {\n    const chapters = video.chapters.map((chapter, number) => ({\n      ...chapter,\n      end_time: video.chapters[number + 1]?.start_time ?? video.duration,\n    }));\n    const info = { id, title: video.title, description: video.description, duration: video.duration, chapters, filepath };\n    appendFileSync(printTo, JSON.stringify(info) + '\\n');\n  }\n}\nif (playlist) console.log(`[download] Finished downloading playlist: ${playlist.title}`);\n"}}}},"bin":{"directory":{"ffmpeg":{"file":{"contents":"#!/usr/bin/env node\n// Stand-in for ffmpeg (see ../.demo/ffmpeg.mjs). CommonJS on purpose: extensionless ES modules do not run in the sandbox.\nimport(\"../.demo/ffmpeg.mjs\");\n"}},"ffprobe":{"file":{"contents":"#!/usr/bin/env node\n// Stand-in for ffprobe (see ../.demo/ffprobe.mjs). CommonJS on purpose: extensionless ES modules do not run in the sandbox.\nimport(\"../.demo/ffprobe.mjs\");\n"}},"yt-dlp":{"file":{"contents":"#!/usr/bin/env node\n// Stand-in for yt-dlp (see ../.demo/yt-dlp.mjs). CommonJS on purpose: extensionless ES modules do not run in the sandbox.\nimport(\"../.demo/yt-dlp.mjs\");\n"}},"yt-download":{"file":{"contents":"#!/usr/bin/env node\n// CommonJS on purpose: extensionless ES modules do not run in the sandbox.\nimport(\"../node_modules/youtube-playlist-downloader/dist/src/cli.js\").then(({ main }) => main()).catch((error) => {\n  console.error(`✗ ${error instanceof Error ? error.message : String(error)}`);\n  process.exitCode = 1;\n});\n"}}}}}