main
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:
2026-03-18 16:07:18 +03:00
parent b8ccdb0d9b
commit 597f5e827f
4 changed files with 269 additions and 28 deletions

View 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
}

View File

@@ -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"

View File

@@ -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) {
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";
// 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 {
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