feat: add YouTube segments CLI using yt-dlp

This commit is contained in:
Kilo Code Cloud
2026-01-14 18:46:21 +00:00
parent dbb067fbab
commit a657434723
6 changed files with 393 additions and 6 deletions

101
CONTEXT.md Normal file
View File

@@ -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 |
|--------|-------------|
| `<url>` | YouTube video URL (required) |
| `-o, --output <dir>` | Output directory (default: `./downloads`) |
| `-f, --format <fmt>` | 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

View File

@@ -12,6 +12,7 @@
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.3", "@eslint/eslintrc": "^3.3.3",
"@tailwindcss/postcss": "^4.1.17", "@tailwindcss/postcss": "^4.1.17",
"@types/bun": "^1.3.6",
"@types/node": "^24.10.2", "@types/node": "^24.10.2",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@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=="], "@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/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@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=="], "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": ["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=="], "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=="],

View File

@@ -1,12 +1,18 @@
{ {
"name": "nextjs-template", "name": "yt-segments",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"type": "module",
"bin": {
"yt-segments": "./src/cli/index.ts"
},
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint",
"typecheck": "tsc --noEmit",
"cli": "bun run src/cli/index.ts"
}, },
"dependencies": { "dependencies": {
"react": "^19.2.1", "react": "^19.2.1",
@@ -14,14 +20,15 @@
"next": "15.5.9" "next": "15.5.9"
}, },
"devDependencies": { "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/node": "^24.10.2",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@tailwindcss/postcss": "^4.1.17",
"tailwindcss": "^4.1.17",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-config-next": "^15.5.7", "eslint-config-next": "^15.5.7",
"@eslint/eslintrc": "^3.3.3" "tailwindcss": "^4.1.17",
"typescript": "^5.9.3"
} }
} }

58
src/cli/args.ts Normal file
View File

@@ -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 <url> [options]
Arguments:
<url> YouTube video URL (required)
Options:
-o, --output <dir> Output directory (default: ./downloads)
-f, --format <fmt> 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;
}

176
src/cli/downloader.ts Normal file
View File

@@ -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<void> {
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<void> {
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}`);
}

40
src/cli/index.ts Normal file
View File

@@ -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 <url> [options]");
console.log("Options:");
console.log(" -o, --output <dir> Output directory (default: ./downloads)");
console.log(" -f, --format <fmt> 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();