diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..6d1cf75 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,101 @@ +# YouTube Segments Downloader + +A CLI tool that downloads the most watched segments (chapters) of a YouTube video using the battle-tested `yt-dlp` library. + +## Project Purpose + +This project provides a command-line interface for extracting and downloading video chapters/segments from YouTube videos. It's built with Bun, TypeScript, and integrates with yt-dlp for robust video downloading. + +## Key Features + +- **Chapter Extraction**: Automatically detects and extracts video chapters marked by creators +- **Segment Download**: Downloads each chapter as a separate video file +- **Full Video Download**: Also saves the complete video for reference +- **Chapter List Export**: Generates a text file with chapter timestamps and titles +- **Customizable Output**: Configurable output directory and video format +- **Bun Runtime**: Fast execution with Bun's native TypeScript support + +## Architecture + +### CLI Entry Point +- [`src/cli/index.ts`](src/cli/index.ts) - Main CLI entry point with argument parsing + +### Core Modules +- [`src/cli/args.ts`](src/cli/args.ts) - Command-line argument parser +- [`src/cli/downloader.ts`](src/cli/downloader.ts) - yt-dlp integration and segment download logic + +### Technology Stack +- **Runtime**: Bun +- **Language**: TypeScript +- **Video Processing**: yt-dlp (external dependency) +- **Framework**: Next.js 15 (for potential web interface) + +## Usage + +### Prerequisites +- [yt-dlp](https://github.com/yt-dlp/yt-dlp) must be installed on the system +- [FFmpeg](https://ffmpeg.org/) is recommended for better format handling + +### Installation + +```bash +# Install dependencies +bun install + +# Make the CLI accessible (optional, requires linking) +# bun link +``` + +### Commands + +```bash +# Basic usage +bun cli "https://www.youtube.com/watch?v=VIDEO_ID" + +# With custom output directory +bun cli "https://www.youtube.com/watch?v=VIDEO_ID" -o ./my-videos + +# With specific format +bun cli "https://www.youtube.com/watch?v=VIDEO_ID" -f "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best" + +# Skip chapter extraction +bun cli "https://www.youtube.com/watch?v=VIDEO_ID" --no-chapters +``` + +### Command-Line Options + +| Option | Description | +|--------|-------------| +| `` | YouTube video URL (required) | +| `-o, --output ` | Output directory (default: `./downloads`) | +| `-f, --format ` | Video format (default: `best`) | +| `--no-chapters` | Skip chapter extraction | +| `-h, --help` | Show help message | + +## Output Structure + +After running the CLI, the output directory will contain: + +``` +downloads/ +├── video_title_01_chapter_name.ext +├── video_title_02_another_chapter.ext +├── ... +├── video_title_full.ext +└── video_title_chapters.txt +``` + +## Major Changes + +### v0.1.0 (2026-01-14) +- Initial project setup +- Created CLI tool with chapter extraction +- Integrated yt-dlp for video downloading +- Added argument parsing and help documentation + +## Notes + +- The CLI requires `yt-dlp` to be installed on the system (`pip install yt-dlp` or `brew install yt-dlp`) +- Chapters are extracted from video metadata - creators must have added them +- If no chapters are found, the full video is downloaded +- Chapter titles and timestamps are saved to a text file for reference diff --git a/bun.lock b/bun.lock index eea5258..c1f53cb 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,7 @@ "devDependencies": { "@eslint/eslintrc": "^3.3.3", "@tailwindcss/postcss": "^4.1.17", + "@types/bun": "^1.3.6", "@types/node": "^24.10.2", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", @@ -185,6 +186,8 @@ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + "@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], @@ -299,6 +302,8 @@ "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + "bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="], + "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], diff --git a/package.json b/package.json index 2d77585..f317ead 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,18 @@ { - "name": "nextjs-template", + "name": "yt-segments", "version": "0.1.0", "private": true, + "type": "module", + "bin": { + "yt-segments": "./src/cli/index.ts" + }, "scripts": { "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "typecheck": "tsc --noEmit", + "cli": "bun run src/cli/index.ts" }, "dependencies": { "react": "^19.2.1", @@ -14,14 +20,15 @@ "next": "15.5.9" }, "devDependencies": { - "typescript": "^5.9.3", + "@eslint/eslintrc": "^3.3.3", + "@tailwindcss/postcss": "^4.1.17", + "@types/bun": "^1.3.6", "@types/node": "^24.10.2", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", - "@tailwindcss/postcss": "^4.1.17", - "tailwindcss": "^4.1.17", "eslint": "^9.39.1", "eslint-config-next": "^15.5.7", - "@eslint/eslintrc": "^3.3.3" + "tailwindcss": "^4.1.17", + "typescript": "^5.9.3" } } diff --git a/src/cli/args.ts b/src/cli/args.ts new file mode 100644 index 0000000..b0ade52 --- /dev/null +++ b/src/cli/args.ts @@ -0,0 +1,58 @@ +export interface CliArgs { + url?: string; + output: string; + format: string; + noChapters: boolean; +} + +export function parseArgs(): CliArgs { + const args: CliArgs = { + output: "./downloads", + format: "best", + noChapters: false, + }; + + const rawArgs = Bun.argv; + + for (let i = 0; i < rawArgs.length; i++) { + const arg = rawArgs[i]; + const nextArg = rawArgs[i + 1]; + + if (arg === "-o" || arg === "--output") { + args.output = nextArg || "./downloads"; + i++; + } else if (arg === "-f" || arg === "--format") { + args.format = nextArg || "best"; + i++; + } else if (arg === "--no-chapters") { + args.noChapters = true; + } else if (arg === "-h" || arg === "--help") { + console.log(`YouTube Video Segments Downloader + +Usage: yt-segments [options] + +Arguments: + YouTube video URL (required) + +Options: + -o, --output Output directory (default: ./downloads) + -f, --format Video format (default: best) + --no-chapters Skip chapter extraction + -h, --help Show this help message + +Examples: + yt-segments "https://www.youtube.com/watch?v=abc123" + yt-segments "https://youtu.be/abc123" -o ./videos -f mp4 + yt-segments "https://www.youtube.com/watch?v=abc123" --no-chapters +`); + process.exit(0); + } else if (!arg.startsWith("-") && !arg.includes("bun")) { + // This is likely the URL + if (!arg.startsWith("bun") && !arg.includes("node")) { + args.url = arg; + } + } + } + + return args; +} diff --git a/src/cli/downloader.ts b/src/cli/downloader.ts new file mode 100644 index 0000000..281e38b --- /dev/null +++ b/src/cli/downloader.ts @@ -0,0 +1,176 @@ +import { spawn } from "child_process"; +import { writeFileSync, mkdirSync, existsSync } from "fs"; +import { join } from "path"; + +export interface DownloadOptions { + url: string; + outputDir: string; + format: string; + extractChapters: boolean; +} + +interface Chapter { + title: string; + start: number; + end: number; +} + +async function getVideoInfo(url: string): Promise<{ title: string; chapters: Chapter[] }> { + return new Promise((resolve, reject) => { + const ytDlp = spawn("yt-dlp", [ + "--dump-json", + "--no-download", + url, + ]); + + let stdout = ""; + let stderr = ""; + + ytDlp.stdout.on("data", (data) => { + stdout += data.toString(); + }); + + ytDlp.stderr.on("data", (data) => { + stderr += data.toString(); + }); + + ytDlp.on("close", (code) => { + if (code !== 0) { + reject(new Error(`yt-dlp failed: ${stderr}`)); + return; + } + + try { + const info = JSON.parse(stdout); + const chapters: Chapter[] = []; + + if (info.chapters && Array.isArray(info.chapters)) { + for (const chapter of info.chapters) { + chapters.push({ + title: chapter.title || `Chapter ${chapters.length + 1}`, + start: chapter.start_time || 0, + end: chapter.end_time || 0, + }); + } + } + + resolve({ + title: info.title || "video", + chapters, + }); + } catch (parseError) { + reject(new Error(`Failed to parse video info: ${parseError}`)); + } + }); + + ytDlp.on("error", (err) => { + reject(new Error(`Failed to run yt-dlp: ${err.message}`)); + }); + }); +} + +async function downloadSection( + url: string, + outputPath: string, + section: string, + format: string +): Promise { + return new Promise((resolve, reject) => { + const ytDlp = spawn("yt-dlp", [ + "-f", format, + "--download-sections", section, + "-o", outputPath, + url, + ]); + + let stderr = ""; + + ytDlp.stderr.on("data", (data) => { + stderr += data.toString(); + }); + + ytDlp.on("close", (code) => { + if (code !== 0) { + reject(new Error(`yt-dlp failed: ${stderr}`)); + return; + } + resolve(); + }); + + ytDlp.on("error", (err) => { + reject(new Error(`Failed to run yt-dlp: ${err.message}`)); + }); + }); +} + +function sanitizeFilename(filename: string): string { + return filename + .replace(/[^a-zA-Z0-9\s\-_]/g, "") + .replace(/\s+/g, "_") + .substring(0, 100); +} + +function formatTime(seconds: number): string { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, "0")}`; +} + +export async function downloadVideoSegments(options: DownloadOptions): Promise { + const { url, outputDir, format } = options; + + // Create output directory if it doesn't exist + if (!existsSync(outputDir)) { + mkdirSync(outputDir, { recursive: true }); + } + + // Skip chapter extraction if not requested + if (!options.extractChapters) { + console.log("Downloading full video (chapters disabled)..."); + const safeTitle = sanitizeFilename("video"); + const outputPath = join(outputDir, `${safeTitle}.%(ext)s`); + await downloadSection(url, outputPath, "*", format); + return; + } + + // Get video info including chapters + console.log("Fetching video information..."); + const { title, chapters } = await getVideoInfo(url); + const safeTitle = sanitizeFilename(title); + + console.log(`Video: ${title}`); + console.log(`Found ${chapters.length} chapters\n`); + + if (chapters.length === 0) { + console.log("No chapters found. Downloading full video..."); + const outputPath = join(outputDir, `${safeTitle}.%(ext)s`); + await downloadSection(url, outputPath, "*", format); + return; + } + + // Download each chapter as a separate segment + for (let i = 0; i < chapters.length; i++) { + const chapter = chapters[i]; + const segmentNumber = (i + 1).toString().padStart(2, "0"); + const outputPath = join(outputDir, `${safeTitle}_${segmentNumber}_${sanitizeFilename(chapter.title)}.%(ext)s`); + const section = `*${formatTime(chapter.start)}-${formatTime(chapter.end)}`; + + console.log(`[${i + 1}/${chapters.length}] Downloading: ${chapter.title} (${formatTime(chapter.end - chapter.start)})`); + + await downloadSection(url, outputPath, section, format); + } + + // Also download the full video + console.log("\nDownloading full video..."); + const fullVideoPath = join(outputDir, `${safeTitle}_full.%(ext)s`); + await downloadSection(url, fullVideoPath, "*", format); + + // Save chapter list + const chapterListPath = join(outputDir, `${safeTitle}_chapters.txt`); + let chapterList = `# ${title}\n\n`; + for (const chapter of chapters) { + chapterList += `${formatTime(chapter.start)} - ${chapter.title}\n`; + } + writeFileSync(chapterListPath, chapterList); + console.log(`Chapter list saved to: ${chapterListPath}`); +} diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 0000000..c2760e1 --- /dev/null +++ b/src/cli/index.ts @@ -0,0 +1,40 @@ +#!/usr/bin/env bun + +import { downloadVideoSegments } from "./downloader.js"; +import { parseArgs } from "./args.js"; + +async function main() { + const args = parseArgs(); + + if (!args.url) { + console.error("Error: YouTube URL is required"); + console.log("Usage: yt-segments [options]"); + console.log("Options:"); + console.log(" -o, --output Output directory (default: ./downloads)"); + console.log(" -f, --format Video format (default: best)"); + console.log(" --no-chapters Skip chapter extraction"); + console.log(" -h, --help Show help"); + process.exit(1); + } + + console.log(`Downloading segments from: ${args.url}`); + console.log(`Output directory: ${args.output}`); + console.log(`Format: ${args.format}`); + console.log(`Extract chapters: ${!args.noChapters}`); + console.log(""); + + try { + await downloadVideoSegments({ + url: args.url, + outputDir: args.output, + format: args.format, + extractChapters: !args.noChapters, + }); + console.log("\nDownload complete!"); + } catch (error) { + console.error("Error:", error instanceof Error ? error.message : error); + process.exit(1); + } +} + +main();