generated from fahricansecer/boilerplate-fe
main
Some checks failed
UI Deploy - indir.bilgich.com 🎨 / build-and-deploy (push) Has been cancelled
Some checks failed
UI Deploy - indir.bilgich.com 🎨 / build-and-deploy (push) Has been cancelled
fix: ideo indirme çözüldü
This commit is contained in:
113
src/app/api/download/serve/route.ts
Normal file
113
src/app/api/download/serve/route.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as os from "os";
|
||||
|
||||
const MERGE_DIR = path.join(os.tmpdir(), "yt-merge");
|
||||
|
||||
// UUID v4 format validation to prevent path traversal
|
||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.mp4$/i;
|
||||
|
||||
/**
|
||||
* GET /api/download/serve?file=<uuid>.mp4&filename=<title>.mp4
|
||||
* Streams a server-side merged video file to the client.
|
||||
* Deletes the file after the response is complete.
|
||||
*/
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const file = searchParams.get("file");
|
||||
let filename = searchParams.get("filename") || "download.mp4";
|
||||
|
||||
if (!file || !UUID_REGEX.test(file)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid or missing file parameter" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const filePath = path.join(MERGE_DIR, file);
|
||||
|
||||
// Ensure the resolved path is still inside MERGE_DIR (extra path traversal guard)
|
||||
if (!path.resolve(filePath).startsWith(path.resolve(MERGE_DIR))) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid file path" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return NextResponse.json(
|
||||
{ error: "File not found or expired" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
const fileSize = stat.size;
|
||||
|
||||
// Encode filename for Content-Disposition
|
||||
const safeFilename = encodeURIComponent(filename)
|
||||
.replace(/['()]/g, escape)
|
||||
.replace(/\*/g, "%2A");
|
||||
|
||||
// Create a ReadableStream from the file
|
||||
const fileStream = fs.createReadStream(filePath);
|
||||
const readable = new ReadableStream({
|
||||
start(controller) {
|
||||
fileStream.on("data", (chunk: Buffer | string) => {
|
||||
const buf = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
||||
controller.enqueue(new Uint8Array(buf));
|
||||
});
|
||||
fileStream.on("end", () => {
|
||||
controller.close();
|
||||
// Schedule file deletion after stream completes
|
||||
scheduleCleanup(filePath);
|
||||
});
|
||||
fileStream.on("error", (err) => {
|
||||
console.error("[SERVE] Stream error:", err);
|
||||
controller.error(err);
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
fileStream.destroy();
|
||||
scheduleCleanup(filePath);
|
||||
},
|
||||
});
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set("Content-Type", "video/mp4");
|
||||
headers.set("Content-Length", fileSize.toString());
|
||||
headers.set(
|
||||
"Content-Disposition",
|
||||
`attachment; filename*=UTF-8''${safeFilename}`
|
||||
);
|
||||
|
||||
return new NextResponse(readable, {
|
||||
status: 200,
|
||||
headers,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[SERVE] Error serving file:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to serve file" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the file after a short delay to ensure the stream has fully flushed.
|
||||
*/
|
||||
function scheduleCleanup(filePath: string): void {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
console.log("[SERVE] Cleaned up:", path.basename(filePath));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[SERVE] Cleanup error:", err);
|
||||
}
|
||||
}, 5000); // 5 second delay
|
||||
}
|
||||
@@ -98,7 +98,11 @@ export function DownloadResult({ result, onReset }: DownloadResultProps) {
|
||||
{/* Actions */}
|
||||
<Flex gap={3} direction={{ base: 'column', sm: 'row' }}>
|
||||
<LinkButton
|
||||
href={`/api/proxy?url=${encodeURIComponent(data.downloadUrl)}&filename=${encodeURIComponent(data.filename)}`}
|
||||
href={
|
||||
data.downloadUrl.startsWith('/api/download/serve')
|
||||
? data.downloadUrl
|
||||
: `/api/proxy?url=${encodeURIComponent(data.downloadUrl)}&filename=${encodeURIComponent(data.filename)}`
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
colorPalette="primary"
|
||||
|
||||
@@ -8,7 +8,11 @@
|
||||
*/
|
||||
|
||||
import { snapsave } from "snapsave-media-downloader";
|
||||
import { spawn } from "child_process";
|
||||
import { spawn, execSync } from "child_process";
|
||||
import { randomUUID } from "crypto";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as os from "os";
|
||||
import {
|
||||
DownloadRequest,
|
||||
DownloadResponse,
|
||||
@@ -247,8 +251,113 @@ async function runYtdlp(url: string, args: string[] = []): Promise<YtDlpVideoInf
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Server-Side Merge Helpers (for 1080p+)
|
||||
// ============================================
|
||||
|
||||
const MERGE_DIR = path.join(os.tmpdir(), "yt-merge");
|
||||
const MERGE_FILE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
/**
|
||||
* Download YouTube video via yt-dlp
|
||||
* Removes merged files older than TTL from the temp directory.
|
||||
* Called before each merge to prevent disk bloat.
|
||||
*/
|
||||
function cleanupMergedFiles(): void {
|
||||
try {
|
||||
if (!fs.existsSync(MERGE_DIR)) return;
|
||||
const now = Date.now();
|
||||
for (const file of fs.readdirSync(MERGE_DIR)) {
|
||||
const filePath = path.join(MERGE_DIR, file);
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (now - stat.mtimeMs > MERGE_FILE_TTL_MS) {
|
||||
fs.unlinkSync(filePath);
|
||||
console.log("[CLEANUP] Deleted old merge file:", file);
|
||||
}
|
||||
} catch {
|
||||
// ignore per-file errors
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[CLEANUP] Error during cleanup:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads and merges separate video+audio DASH streams using yt-dlp + ffmpeg.
|
||||
* Returns the local file path of the merged mp4.
|
||||
*/
|
||||
async function mergeYoutubeStreams(
|
||||
url: string,
|
||||
targetHeight: number
|
||||
): Promise<string> {
|
||||
// Ensure merge directory exists
|
||||
if (!fs.existsSync(MERGE_DIR)) {
|
||||
fs.mkdirSync(MERGE_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Cleanup old files before creating new ones
|
||||
cleanupMergedFiles();
|
||||
|
||||
const fileId = randomUUID();
|
||||
const outputPath = path.join(MERGE_DIR, `${fileId}.mp4`);
|
||||
|
||||
// Build yt-dlp format string:
|
||||
// Force H.264 (avc1) codec for maximum player compatibility.
|
||||
// AV1 is smaller but most players can't decode it.
|
||||
const formatStr = [
|
||||
`bestvideo[height<=${targetHeight}][vcodec^=avc1]+bestaudio[ext=m4a]`,
|
||||
`bestvideo[height<=${targetHeight}][vcodec^=avc1]+bestaudio`,
|
||||
`bestvideo[height<=${targetHeight}][ext=mp4]+bestaudio[ext=m4a]`,
|
||||
`bestvideo[height<=${targetHeight}]+bestaudio`,
|
||||
`best[height<=${targetHeight}]`,
|
||||
].join("/");
|
||||
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const args = [
|
||||
"-f", formatStr,
|
||||
"-S", "vcodec:h264,acodec:aac", // Sort preference: H.264 video + AAC audio
|
||||
"--merge-output-format", "mp4",
|
||||
"--no-warnings",
|
||||
"--no-check-certificates",
|
||||
"--no-playlist",
|
||||
"-o", outputPath,
|
||||
url,
|
||||
];
|
||||
|
||||
console.log("[YT-DLP-MERGE] Running: yt-dlp", args.join(" "));
|
||||
|
||||
const proc = spawn("yt-dlp", args);
|
||||
|
||||
let stderr = "";
|
||||
proc.stderr.on("data", (d) => { stderr += d.toString(); });
|
||||
proc.stdout.on("data", (d) => { console.log("[YT-DLP-MERGE]", d.toString().trim()); });
|
||||
|
||||
proc.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
console.error("[YT-DLP-MERGE] Failed:", stderr);
|
||||
reject(new Error(stderr || `yt-dlp merge exited with code ${code}`));
|
||||
return;
|
||||
}
|
||||
if (!fs.existsSync(outputPath)) {
|
||||
reject(new Error("Merge output file not found"));
|
||||
return;
|
||||
}
|
||||
console.log("[YT-DLP-MERGE] Success:", outputPath);
|
||||
resolve(outputPath);
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
console.error("[YT-DLP-MERGE] Spawn error:", err);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
/**
|
||||
* Download YouTube video via yt-dlp.
|
||||
* Tries muxed formats first (fast, direct URL).
|
||||
* Falls back to server-side merge for 1080p+ when no muxed format is available.
|
||||
*/
|
||||
async function downloadYoutubeVideo(
|
||||
url: string,
|
||||
@@ -261,8 +370,7 @@ async function downloadYoutubeVideo(
|
||||
const info = await runYtdlp(url);
|
||||
|
||||
// Find formats that have BOTH video and audio mixed into a single MP4 file.
|
||||
// yt-dlp lists these as having vcodec and acodec
|
||||
const videoFormats = info.formats?.filter(
|
||||
const muxedFormats = info.formats?.filter(
|
||||
(f: YtDlpFormat) =>
|
||||
f.url &&
|
||||
f.ext === "mp4" &&
|
||||
@@ -272,33 +380,49 @@ async function downloadYoutubeVideo(
|
||||
!f.url.includes("/manifest/")
|
||||
);
|
||||
|
||||
if (!videoFormats?.length) {
|
||||
const safeTitle = info.title?.replace(/[^a-zA-Z0-9ğüşıöçĞÜŞİÖÇ\s]/g, "_") || "youtube_video";
|
||||
|
||||
// Try to find a muxed format at the requested quality
|
||||
if (muxedFormats?.length) {
|
||||
const sortedMuxed = muxedFormats.sort((a, b) => (b.height || 0) - (a.height || 0));
|
||||
const bestMuxed = sortedMuxed.find(f => (f.height || 0) <= targetHeight) || sortedMuxed[0];
|
||||
const bestMuxedHeight = bestMuxed.height || 0;
|
||||
|
||||
// If muxed format satisfies the requested quality, use it directly (fast path)
|
||||
if (bestMuxedHeight >= targetHeight || targetHeight <= 720) {
|
||||
console.log(`[YT-DLP-YOUTUBE] Using muxed format: ${bestMuxedHeight}p`);
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: "error.download.no_result",
|
||||
message: "Uygun video formatı bulunamadı.",
|
||||
success: true,
|
||||
data: {
|
||||
downloadUrl: bestMuxed.url!,
|
||||
filename: `${safeTitle}.mp4`,
|
||||
platform: "youtube",
|
||||
title: info.title,
|
||||
thumbnail: info.thumbnail,
|
||||
mediaType: "video",
|
||||
duration: info.duration_string,
|
||||
author: info.uploader,
|
||||
views: info.view_count?.toString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Sort to find the requested quality or closest lower quality
|
||||
const sortedFormats = videoFormats.sort((a, b) => (b.height || 0) - (a.height || 0));
|
||||
|
||||
let bestFormat = sortedFormats.find(f => (f.height || 0) <= targetHeight) || sortedFormats[0];
|
||||
|
||||
// Some 1080p combined might not exist (YouTube usually separates them), fallback gracefully
|
||||
if (!bestFormat) {
|
||||
bestFormat = sortedFormats[0];
|
||||
}
|
||||
|
||||
const safeTitle = info.title?.replace(/[^a-zA-Z0-9ğüşıöçĞÜŞİÖÇ\s]/g, "_") || "youtube_video";
|
||||
// No muxed format at requested quality — do server-side merge
|
||||
console.log(`[YT-DLP-YOUTUBE] No muxed ${targetHeight}p found, falling back to server-side merge...`);
|
||||
|
||||
const mergedPath = await mergeYoutubeStreams(url, targetHeight);
|
||||
const fileId = path.basename(mergedPath);
|
||||
const filename = `${safeTitle}.mp4`;
|
||||
|
||||
// Return a serve URL instead of a direct CDN URL
|
||||
const serveUrl = `/api/download/serve?file=${encodeURIComponent(fileId)}&filename=${encodeURIComponent(filename)}`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
downloadUrl: bestFormat.url!,
|
||||
filename: `${safeTitle}.mp4`,
|
||||
downloadUrl: serveUrl,
|
||||
filename,
|
||||
platform: "youtube",
|
||||
title: info.title,
|
||||
thumbnail: info.thumbnail,
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user