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 */}
|
{/* Actions */}
|
||||||
<Flex gap={3} direction={{ base: 'column', sm: 'row' }}>
|
<Flex gap={3} direction={{ base: 'column', sm: 'row' }}>
|
||||||
<LinkButton
|
<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"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
colorPalette="primary"
|
colorPalette="primary"
|
||||||
|
|||||||
@@ -8,7 +8,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { snapsave } from "snapsave-media-downloader";
|
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 {
|
import {
|
||||||
DownloadRequest,
|
DownloadRequest,
|
||||||
DownloadResponse,
|
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(
|
async function downloadYoutubeVideo(
|
||||||
url: string,
|
url: string,
|
||||||
@@ -261,8 +370,7 @@ async function downloadYoutubeVideo(
|
|||||||
const info = await runYtdlp(url);
|
const info = await runYtdlp(url);
|
||||||
|
|
||||||
// Find formats that have BOTH video and audio mixed into a single MP4 file.
|
// Find formats that have BOTH video and audio mixed into a single MP4 file.
|
||||||
// yt-dlp lists these as having vcodec and acodec
|
const muxedFormats = info.formats?.filter(
|
||||||
const videoFormats = info.formats?.filter(
|
|
||||||
(f: YtDlpFormat) =>
|
(f: YtDlpFormat) =>
|
||||||
f.url &&
|
f.url &&
|
||||||
f.ext === "mp4" &&
|
f.ext === "mp4" &&
|
||||||
@@ -272,33 +380,49 @@ async function downloadYoutubeVideo(
|
|||||||
!f.url.includes("/manifest/")
|
!f.url.includes("/manifest/")
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!videoFormats?.length) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: {
|
|
||||||
code: "error.download.no_result",
|
|
||||||
message: "Uygun video formatı bulunamadı.",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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";
|
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: 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(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
downloadUrl: bestFormat.url!,
|
downloadUrl: serveUrl,
|
||||||
filename: `${safeTitle}.mp4`,
|
filename,
|
||||||
platform: "youtube",
|
platform: "youtube",
|
||||||
title: info.title,
|
title: info.title,
|
||||||
thumbnail: info.thumbnail,
|
thumbnail: info.thumbnail,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user