Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
355 changes: 355 additions & 0 deletions apps/web/src/app/api/download-videos/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,355 @@
// apps/web/src/app/api/download-videos/route.ts

import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { spawn } from "child_process";
Comment on lines +3 to +5
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Use node: protocol for built-ins and import types correctly. Also import randomUUID.

Aligns with guidelines and avoids relying on a possibly-missing global crypto.

-import { NextRequest, NextResponse } from "next/server";
+import type { NextRequest } from "next/server";
+import { NextResponse } from "next/server";
-import { spawn } from "child_process";
+import { spawn } from "node:child_process";
+import { randomUUID } from "node:crypto";
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { spawn } from "child_process";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { z } from "zod";
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
🤖 Prompt for AI Agents
In apps/web/src/app/api/download-videos/route.ts lines 3-5, change the built-in
imports to use the node: protocol and import types correctly: replace
"child_process" with "node:child_process" and import randomUUID from
"node:crypto"; also import NextRequest and NextResponse as types (using import
type) from "next/server" so they are erased at runtime. Update the top-of-file
imports accordingly and ensure you only use spawn from node:child_process and
randomUUID from node:crypto in the implementation.


/**
* Validates the request body for video URL.
*/
const infoRequestSchema = z.object({
url: z.string().url("Invalid URL provided"),
});

/**
* Validates the request body for search keyword.
*/
const searchRequestSchema = z.object({
keyword: z.string().min(1, "Keyword is required"),
page: z.number().int().positive().optional().default(1),
pageSize: z.number().int().min(1).max(50).optional().default(20),
});

/**
* Handles POST requests to /api/download-videos with action parameter.
* Supports: search, info, download
*/
export async function POST(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const action = searchParams.get("action");

switch (action) {
case "search":
return await handleSearch(request);
case "info":
return await handleVideoInfo(request);
case "download":
return await handleDownload(request);
default:
return NextResponse.json(
{ error: "Invalid action. Use 'search', 'info', or 'download'." },
{ status: 400 }
);
}
} catch (error) {
console.error("Video API error:", error);
return NextResponse.json(
{
error: "Internal server error",
message: "An unexpected error occurred",
},
{ status: 500 }
);
}
}

/**
* Handles video search using yt-dlp.
* Searches videos based on keyword and pagination.
*/
async function handleSearch(request: NextRequest) {
try {
const rawBody = await request.json().catch(() => null);
if (!rawBody) {
return NextResponse.json(
{ error: "Invalid JSON in request body" },
{ status: 400 }
);
}

const validationResult = searchRequestSchema.safeParse(rawBody);
if (!validationResult.success) {
return NextResponse.json(
{
error: "Invalid request parameters",
details: validationResult.error.flatten().fieldErrors,
},
{ status: 400 }
);
}

const { keyword, page, pageSize } = validationResult.data;
const startIndex = (page - 1) * pageSize + 1;
const endIndex = page * pageSize;

return new Promise((resolve) => {
const ytDlp = spawn("yt-dlp", [
"--dump-single-json",
"--flat-playlist",
"--no-warnings",
"--playlist-start",
startIndex.toString(),
"--playlist-end",
endIndex.toString(),
`ytsearch${pageSize}:${keyword}`,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
`ytsearch${pageSize}:${keyword}`,
`ytsearch${endIndex}:${keyword}`,

The pagination logic is broken - pages beyond the first will return empty results because yt-dlp is only searching for pageSize total results instead of enough results to support pagination.

View Details

Analysis

Pagination bug in handleSearch() returns empty results for pages beyond first

What fails: handleSearch() in apps/web/src/app/api/download-videos/route.ts uses ytsearch${pageSize}:${keyword} which limits yt-dlp to find only pageSize total results, but pagination logic tries to extract ranges beyond that limit

How to reproduce:

# Page 2 with pageSize=20 tries to get results 21-40 from only 20 total results
yt-dlp --playlist-start 21 --playlist-end 40 "ytsearch20:python tutorial"

Result: Returns empty array: {"entries": [], "requested_entries": []}

Expected: Should return 20 results (items 21-40) like when using ytsearch40:python tutorial

Root cause: Line 95 searches for pageSize results but needs endIndex results to support the requested page range

]);
Comment on lines +82 to +96
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Search pagination bug: ytsearch limit must reflect endIndex.

With page > 1, ytsearch${pageSize}: returns too few items; --playlist-start shifts past the available results, yielding empty pages.

-    const { keyword, page, pageSize } = validationResult.data;
-    const startIndex = (page - 1) * pageSize + 1;
-    const endIndex = page * pageSize;
+    const { keyword, page, pageSize } = validationResult.data;
+    const startIndex = (page - 1) * pageSize + 1;
+    const endIndex = page * pageSize;
@@
-      const ytDlp = spawn("yt-dlp", [
+      const ytDlp = spawn("yt-dlp", [
         "--dump-single-json",
         "--flat-playlist",
         "--no-warnings",
         "--playlist-start",
         startIndex.toString(),
         "--playlist-end",
         endIndex.toString(),
-        `ytsearch${pageSize}:${keyword}`,
+        `ytsearch${endIndex}:${keyword}`,
       ]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { keyword, page, pageSize } = validationResult.data;
const startIndex = (page - 1) * pageSize + 1;
const endIndex = page * pageSize;
return new Promise((resolve) => {
const ytDlp = spawn("yt-dlp", [
"--dump-single-json",
"--flat-playlist",
"--no-warnings",
"--playlist-start",
startIndex.toString(),
"--playlist-end",
endIndex.toString(),
`ytsearch${pageSize}:${keyword}`,
]);
const { keyword, page, pageSize } = validationResult.data;
const startIndex = (page - 1) * pageSize + 1;
const endIndex = page * pageSize;
return new Promise((resolve) => {
const ytDlp = spawn("yt-dlp", [
"--dump-single-json",
"--flat-playlist",
"--no-warnings",
"--playlist-start",
startIndex.toString(),
"--playlist-end",
endIndex.toString(),
`ytsearch${endIndex}:${keyword}`,
]);


let stdoutData = "";
let stderrData = "";

ytDlp.stdout.on("data", (data) => {
stdoutData += data.toString();
});

ytDlp.stderr.on("data", (data) => {
stderrData += data.toString();
});

ytDlp.on("close", (code) => {
try {
if (code !== 0) {
return resolve(
NextResponse.json(
{
success: false,
error: `yt-dlp process exited with code ${code}`,
details: stderrData,
},
{ status: 500 }
)
);
}

if (!stdoutData.trim()) {
return resolve(
NextResponse.json(
{
success: false,
error: "No output from yt-dlp",
details: stderrData || "Process completed with no output",
},
{ status: 500 }
)
);
}

const result = JSON.parse(stdoutData);
resolve(
NextResponse.json({
success: true,
data: Array.isArray(result) ? result : [result],
})
);
} catch (parseError) {
Comment on lines +137 to +144
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Parse yt-dlp search output via entries array.

--dump-single-json --flat-playlist ytsearch… returns an object with entries. Current code wraps the object in an array.

-          const result = JSON.parse(stdoutData);
-          resolve(
-            NextResponse.json({
-              success: true,
-              data: Array.isArray(result) ? result : [result],
-            })
-          );
+          const parsed = JSON.parse(stdoutData);
+          const entries = Array.isArray(parsed?.entries) ? parsed.entries : [];
+          resolve(
+            NextResponse.json({
+              success: true,
+              data: entries,
+              pagination: { page, pageSize },
+            })
+          );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const result = JSON.parse(stdoutData);
resolve(
NextResponse.json({
success: true,
data: Array.isArray(result) ? result : [result],
})
);
} catch (parseError) {
const parsed = JSON.parse(stdoutData);
const entries = Array.isArray(parsed?.entries) ? parsed.entries : [];
resolve(
NextResponse.json({
success: true,
data: entries,
pagination: { page, pageSize },
})
);
} catch (parseError) {
🤖 Prompt for AI Agents
In apps/web/src/app/api/download-videos/route.ts around lines 137 to 144, the
code parses yt-dlp JSON and always wraps the parsed object in an array, but
yt-dlp with --dump-single-json --flat-playlist returns an object with an entries
array; change the logic to detect if parsed result has an entries property (and
it's an array) and use that array as the data, otherwise if parsed result is
already an array use it, and only wrap a non-array, non-entries object in an
array before returning in NextResponse.json.

resolve(
NextResponse.json(
{
success: false,
error: "Error parsing yt-dlp output",
details: parseError instanceof Error ? parseError.message : "Unknown error",
rawOutput: stdoutData,
},
{ status: 500 }
)
);
}
});

ytDlp.on("error", (error) => {
resolve(
NextResponse.json(
{
success: false,
error: "Failed to start yt-dlp process",
details: error.message,
},
{ status: 500 }
)
);
});
});
} catch (error) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "Unknown error occurred",
},
{ status: 500 }
);
}
}

/**
* Fetches video metadata using yt-dlp.
* Returns title, duration, formats, thumbnails, etc.
*/
async function handleVideoInfo(request: NextRequest) {
try {
const rawBody = await request.json().catch(() => null);
if (!rawBody) {
return NextResponse.json(
{ error: "Invalid JSON in request body" },
{ status: 400 }
);
}

const validationResult = infoRequestSchema.safeParse(rawBody);
if (!validationResult.success) {
return NextResponse.json(
{
error: "Invalid request parameters",
details: validationResult.error.flatten().fieldErrors,
},
{ status: 400 }
);
}

const { url } = validationResult.data;

return new Promise((resolve) => {
const ytDlp = spawn("yt-dlp", [
"--dump-single-json",
"--no-warnings",
"--compat-options",
"no-youtube-channel-redirect",
url,
]);

let stdoutData = "";
let stderrData = "";

ytDlp.stdout.on("data", (data) => {
stdoutData += data.toString();
});

ytDlp.stderr.on("data", (data) => {
stderrData += data.toString();
});

ytDlp.on("close", (code) => {
try {
if (code !== 0) {
return resolve(
NextResponse.json(
{
success: false,
error: `yt-dlp process exited with code ${code}`,
details: stderrData,
},
{ status: 500 }
)
);
}

if (!stdoutData.trim()) {
return resolve(
NextResponse.json(
{
success: false,
error: "No output from yt-dlp",
details: stderrData || "Process completed with no output",
},
{ status: 500 }
)
);
}

const result = JSON.parse(stdoutData);
resolve(
NextResponse.json({
success: true,
data: result,
})
);
} catch (parseError) {
resolve(
NextResponse.json(
{
success: false,
error: "Error parsing yt-dlp output",
details: parseError instanceof Error ? parseError.message : "Unknown error",
rawOutput: stdoutData,
},
{ status: 500 }
)
);
}
});

ytDlp.on("error", (error) => {
resolve(
NextResponse.json(
{
success: false,
error: "Failed to start yt-dlp process",
details: error.message,
},
{ status: 500 }
)
);
});
});
} catch (error) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "Unknown error occurred",
},
{ status: 500 }
);
}
}

/**
* Initiates video download process (placeholder).
* In the future, this will spawn yt-dlp to download the video.
*
* @param request - Incoming NextRequest
* @returns NextResponse with download status
*/
async function handleDownload(request: NextRequest) {
try {
const rawBody = await request.json().catch(() => null);
if (!rawBody) {
return NextResponse.json(
{ error: "Invalid JSON in request body" },
{ status: 400 }
);
}

const validationResult = infoRequestSchema.safeParse(rawBody);
if (!validationResult.success) {
return NextResponse.json(
{
error: "Invalid request parameters",
details: validationResult.error.flatten().fieldErrors,
},
{ status: 400 }
);
}

const { url } = validationResult.data;

// TODO: 实际调用 yt-dlp 下载视频文件
// 当前返回模拟响应
return NextResponse.json({
success: true,
message: "Download started (placeholder)",
data: {
url,
status: "queued",
downloadId: crypto.randomUUID(),
startedAt: new Date().toISOString(),
},
});
} catch (error) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "Unknown error occurred",
},
{ status: 500 }
);
}
}