From 128a81722c03528b531bc73a6967d186f7457b61 Mon Sep 17 00:00:00 2001 From: Kilo Code Cloud Date: Wed, 14 Jan 2026 19:42:45 +0000 Subject: [PATCH] fix: properly extract YouTube heatmap data for most watched segments --- src/cli/downloader.ts | 130 +++++++++++++++++------------------------- src/cli/index.ts | 9 +-- 2 files changed, 56 insertions(+), 83 deletions(-) diff --git a/src/cli/downloader.ts b/src/cli/downloader.ts index e549ee1..0adde6d 100644 --- a/src/cli/downloader.ts +++ b/src/cli/downloader.ts @@ -6,20 +6,18 @@ export interface DownloadOptions { url: string; outputDir: string; format: string; - extractChapters: boolean; } -interface MostWatchedSegment { - start: number; - end: number; - intensity?: number; +interface HeatmapSegment { + start_seconds: number; + end_seconds: number; + intensity: number; } interface VideoInfo { title: string; - chapters: Array<{ title: string; start_time: number; end_time: number }>; - heatmap?: Array<{ start_seconds: number; end_seconds: number; intensity: number }>; duration: number; + heatmap?: HeatmapSegment[]; } async function getVideoInfo(url: string): Promise { @@ -27,6 +25,8 @@ async function getVideoInfo(url: string): Promise { const ytDlp = spawn("yt-dlp", [ "--dump-json", "--no-download", + "--compat-option", + "no-youtube-channel-redirect", url, ]); @@ -50,11 +50,14 @@ async function getVideoInfo(url: string): Promise { try { const info = JSON.parse(stdout); + // Extract heatmap data from YouTube's internal API + // The heatmap shows what segments were re-watched the most + const heatmapData = info.heatmap; + resolve({ title: info.title || "video", - chapters: info.chapters || [], - heatmap: info.heatmap || [], duration: info.duration || 0, + heatmap: heatmapData, }); } catch (parseError) { reject(new Error(`Failed to parse video info: ${parseError}`)); @@ -67,12 +70,15 @@ async function getVideoInfo(url: string): Promise { }); } -async function downloadSection( +async function downloadSegment( url: string, outputPath: string, - section: string, + startTime: number, + endTime: number, format: string ): Promise { + const section = `*${startTime.toFixed(3)}-${endTime.toFixed(3)}`; + return new Promise((resolve, reject) => { const ytDlp = spawn("yt-dlp", [ "-f", format, @@ -114,29 +120,7 @@ function formatTime(seconds: number): string { return `${mins}:${secs.toString().padStart(2, "0")}`; } -function getMostWatchedSegments( - heatmap: Array<{ start_seconds: number; end_seconds: number; intensity: number }>, - duration: number, - topN: number = 1 -): MostWatchedSegment[] { - if (!heatmap || heatmap.length === 0) { - return []; - } - - // Sort by intensity (most watched first) - const sorted = [...heatmap].sort((a, b) => b.intensity - a.intensity); - - // Get top N segments - const topSegments = sorted.slice(0, topN); - - return topSegments.map((segment) => ({ - start: segment.start_seconds, - end: segment.end_seconds, - intensity: segment.intensity, - })); -} - -export async function downloadVideoSegments(options: DownloadOptions): Promise { +export async function downloadMostWatchedSegment(options: DownloadOptions): Promise { const { url, outputDir, format } = options; // Create output directory if it doesn't exist @@ -144,65 +128,57 @@ export async function downloadVideoSegments(options: DownloadOptions): Promise 0) { - console.log("\nNo most watched segments found. Falling back to chapters..."); - const chapter = info.chapters[0]; // Download first chapter as most relevant - const outputPath = join(outputDir, `${safeTitle}_most_watched.%(ext)s`); - const section = `*${formatTime(chapter.start_time || 0)}-${formatTime(chapter.end_time || 60)}`; - - console.log(`Downloading chapter: ${chapter.title || "First Chapter"}`); - await downloadSection(url, outputPath, section, format); - return; - } - - if (mostWatchedSegments.length === 0) { - console.log("No segments found. Downloading full video..."); + // Check for heatmap data - this shows what was re-watched the most + if (!info.heatmap || info.heatmap.length === 0) { + console.log("\nNo heatmap data available for this video."); + console.log("The video may not have enough view data to determine most watched segments."); + console.log("Downloading full video instead..."); + const outputPath = join(outputDir, `${safeTitle}.%(ext)s`); - await downloadSection(url, outputPath, "*", format); + await downloadSegment(url, outputPath, 0, info.duration, format); return; } + // Find the most watched segment (highest intensity) + const mostWatched = info.heatmap.reduce((max, current) => { + return current.intensity > max.intensity ? current : max; + }); + + console.log(`\nHeatmap data found: ${info.heatmap.length} segments`); + console.log(`Most watched segment intensity: ${(mostWatched.intensity * 100).toFixed(1)}%`); + console.log(`Segment: ${formatTime(mostWatched.start_seconds)} - ${formatTime(mostWatched.end_seconds)}`); + // Download the most watched segment - const topSegment = mostWatchedSegments[0]; const outputPath = join(outputDir, `${safeTitle}_most_watched.%(ext)s`); - const section = `*${formatTime(topSegment.start)}-${formatTime(topSegment.end)}`; - console.log(`\nMost watched segment: ${formatTime(topSegment.start)} - ${formatTime(topSegment.end)}`); - console.log(`Duration: ${formatTime(topSegment.end - topSegment.start)}`); - console.log(`Intensity: ${((topSegment.intensity || 0) * 100).toFixed(1)}%`); console.log(`\nDownloading most watched segment...`); - - await downloadSection(url, outputPath, section, format); + await downloadSegment( + url, + outputPath, + mostWatched.start_seconds, + mostWatched.end_seconds, + format + ); // Save segment info - const segmentInfoPath = join(outputDir, `${safeTitle}_most_watched.txt`); - let segmentInfo = `# ${info.title}\n\n`; - segmentInfo += `Most watched segment:\n`; - segmentInfo += ` Start: ${formatTime(topSegment.start)}\n`; - segmentInfo += ` End: ${formatTime(topSegment.end)}\n`; - segmentInfo += ` Duration: ${formatTime(topSegment.end - topSegment.start)}\n`; - segmentInfo += ` Intensity: ${((topSegment.intensity || 0) * 100).toFixed(1)}%\n`; - - if (mostWatchedSegments.length > 1) { - segmentInfo += `\nOther top segments:\n`; - for (let i = 1; i < mostWatchedSegments.length; i++) { - const seg = mostWatchedSegments[i]; - segmentInfo += ` ${formatTime(seg.start)} - ${formatTime(seg.end)} (${((seg.intensity || 0) * 100).toFixed(1)}%)\n`; - } - } + const segmentInfoPath = join(outputDir, `${safeTitle}_segment_info.txt`); + const segmentInfo = `# ${info.title}\n\n` + + `Most watched segment (from YouTube heatmap):\n` + + ` Start: ${formatTime(mostWatched.start_seconds)} (${mostWatched.start_seconds}s)\n` + + ` End: ${formatTime(mostWatched.end_seconds)} (${mostWatched.end_seconds}s)\n` + + ` Duration: ${formatTime(mostWatched.end_seconds - mostWatched.start_seconds)}\n` + + ` Intensity: ${(mostWatched.intensity * 100).toFixed(1)}%\n\n` + + `Note: This segment had the highest re-watch rate according to YouTube's analytics.\n`; writeFileSync(segmentInfoPath, segmentInfo); - console.log(`Segment info saved to: ${segmentInfoPath}`); + console.log(`\nSegment info saved to: ${segmentInfoPath}`); + console.log("Download complete!"); } diff --git a/src/cli/index.ts b/src/cli/index.ts index f8cdeae..630295a 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,6 +1,6 @@ #!/usr/bin/env bun -import { downloadVideoSegments } from "./downloader.js"; +import { downloadMostWatchedSegment } from "./downloader.js"; import { parseArgs } from "./args.js"; async function main() { @@ -17,20 +17,17 @@ async function main() { process.exit(1); } - console.log(`Downloading most watched segment(s) from: ${args.url}`); + console.log(`Downloading most watched segment from: ${args.url}`); console.log(`Output directory: ${args.output}`); console.log(`Format: ${args.format}`); - console.log(`Segments to download: ${args.segments}`); console.log(""); try { - await downloadVideoSegments({ + await downloadMostWatchedSegment({ url: args.url, outputDir: args.output, format: args.format, - extractChapters: true, }); - console.log("\nDownload complete!"); } catch (error) { console.error("Error:", error instanceof Error ? error.message : error); process.exit(1);