372 lines
13 KiB
TypeScript
372 lines
13 KiB
TypeScript
import type { APIRoute } from "astro";
|
|
import { getSession, isLoginEnabled } from "../../lib/session";
|
|
import { tApi } from "../../lib/i18n";
|
|
import { mkdir, unlink, readFile, readdir, writeFile } from "node:fs/promises";
|
|
import { existsSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import { createRequire } from "module";
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const YTDlpWrap = require("yt-dlp-wrap").default || require("yt-dlp-wrap");
|
|
|
|
export const POST: APIRoute = async ({ request }) => {
|
|
// Session prüfen (nur wenn Login aktiviert ist)
|
|
const loginEnabled = isLoginEnabled();
|
|
if (loginEnabled) {
|
|
const session = await getSession(request);
|
|
if (!session) {
|
|
return new Response(
|
|
JSON.stringify({ error: tApi(request, "api.notAuthenticated") }),
|
|
{
|
|
status: 401,
|
|
headers: { "Content-Type": "application/json" },
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
// Cookie-Unterstützung: Temporäre Datei außerhalb des try-Blocks definieren
|
|
let tempCookiesFile: string | null = null;
|
|
|
|
try {
|
|
const { url, cookies } = await request.json();
|
|
|
|
if (!url || typeof url !== "string") {
|
|
return new Response(
|
|
JSON.stringify({ error: tApi(request, "api.invalidUrl") }),
|
|
{
|
|
status: 400,
|
|
headers: { "Content-Type": "application/json" },
|
|
}
|
|
);
|
|
}
|
|
|
|
// Cookie-Unterstützung: Wenn Cookies im Request sind, temporäre Datei erstellen
|
|
if (cookies && typeof cookies === "string" && cookies.trim()) {
|
|
try {
|
|
tempCookiesFile = path.join(tmpdir(), `cookies-${Date.now()}.txt`);
|
|
const cookiesContent = cookies.trim();
|
|
await writeFile(tempCookiesFile, cookiesContent, "utf-8");
|
|
console.log(
|
|
`Cookie-Datei erstellt: ${tempCookiesFile}, Größe: ${cookiesContent.length} Zeichen`
|
|
);
|
|
// Prüfe ob Datei existiert und lesbar ist
|
|
if (existsSync(tempCookiesFile)) {
|
|
const fileContent = await readFile(tempCookiesFile, "utf-8");
|
|
console.log(
|
|
`Cookie-Datei gelesen: ${
|
|
fileContent.length
|
|
} Zeichen, erste Zeile: ${fileContent.split("\n")[0]}`
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.error(
|
|
"Fehler beim Erstellen der temporären Cookie-Datei:",
|
|
error
|
|
);
|
|
// Weiter ohne Cookies
|
|
}
|
|
}
|
|
|
|
// YouTube URL validieren
|
|
if (!url.includes("youtube.com") && !url.includes("youtu.be")) {
|
|
return new Response(
|
|
JSON.stringify({ error: tApi(request, "api.invalidYouTubeUrl") }),
|
|
{ status: 400, headers: { "Content-Type": "application/json" } }
|
|
);
|
|
}
|
|
|
|
// Prüfe ob Stream-Modus aktiviert ist
|
|
const streamOnly = process.env.STREAM_ONLY === "true";
|
|
|
|
// yt-dlp-wrap Instanz erstellen
|
|
const ytDlpWrap = new YTDlpWrap();
|
|
|
|
// Format aus Request Header (optional, Standard: MP4)
|
|
const format = request.headers.get("x-format") || "mp4";
|
|
|
|
// Cookie-Unterstützung für YouTube Bot-Erkennung
|
|
// Priorität: 1. Cookies aus Request, 2. Umgebungsvariable, 3. Browser-Cookies
|
|
const cookiesFile = tempCookiesFile || process.env.YT_DLP_COOKIES;
|
|
const cookiesFromBrowser = process.env.YT_DLP_COOKIES_FROM_BROWSER;
|
|
|
|
// JavaScript Runtime für yt-dlp (Standard: deno)
|
|
const jsRuntime = process.env.YT_DLP_JS_RUNTIME || "deno";
|
|
|
|
// Zuerst Video-Informationen abrufen, um den Dateinamen zu erhalten
|
|
// getVideoInfo verwendet intern yt-dlp mit --dump-json
|
|
// Deno wird beim eigentlichen Download verwendet
|
|
const videoInfoOptions: string[] = [
|
|
"--user-agent",
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
"--extractor-args",
|
|
"youtube:player_client=ios,tv_embedded,android,web",
|
|
];
|
|
if (cookiesFile) {
|
|
videoInfoOptions.push("--cookies", cookiesFile);
|
|
console.log(`Verwende Cookie-Datei für getVideoInfo: ${cookiesFile}`);
|
|
} else if (cookiesFromBrowser) {
|
|
videoInfoOptions.push("--cookies-from-browser", cookiesFromBrowser);
|
|
console.log(
|
|
`Verwende Browser-Cookies für getVideoInfo: ${cookiesFromBrowser}`
|
|
);
|
|
} else {
|
|
console.log("Keine Cookies für getVideoInfo verfügbar");
|
|
}
|
|
console.log(`getVideoInfo Optionen: ${JSON.stringify(videoInfoOptions)}`);
|
|
const videoInfo = await ytDlpWrap.getVideoInfo(url, videoInfoOptions);
|
|
const title = videoInfo.title || "Video";
|
|
|
|
// Dateiendung bestimmen
|
|
let ext = "mp4";
|
|
if (format === "audio") {
|
|
ext = "mp3"; // yt-dlp wird zu MP3 konvertieren
|
|
} else if (format === "mp4") {
|
|
ext = "mp4";
|
|
} else {
|
|
ext = videoInfo.ext || "mp4";
|
|
}
|
|
const filename = `${title}.${ext}`;
|
|
|
|
// Format-String für yt-dlp
|
|
// Für "best": Kein Format-String = yt-dlp wählt automatisch bestes Format und merged
|
|
const formatString =
|
|
format === "mp4"
|
|
? "bestvideo[ext=mp4]+bestaudio[ext=m4a]/bestvideo[ext=mp4]+bestaudio/best[ext=mp4]/best"
|
|
: format === "audio"
|
|
? "bestaudio[ext=m4a]/bestaudio[ext=mp3]/bestaudio"
|
|
: format === "best"
|
|
? undefined // Kein Format = yt-dlp wählt automatisch bestes Format
|
|
: undefined; // Fallback: yt-dlp wählt automatisch
|
|
|
|
if (streamOnly) {
|
|
// STREAM-MODUS: Datei temporär speichern, streamen, dann löschen
|
|
const tempDir = tmpdir();
|
|
const tempFilePath = path.join(tempDir, `${Date.now()}-${filename}`);
|
|
|
|
try {
|
|
// Download in temporäres Verzeichnis
|
|
await new Promise<void>((resolve, reject) => {
|
|
const execArgs = [
|
|
url,
|
|
"-o",
|
|
tempFilePath,
|
|
"--no-mtime",
|
|
"--no-playlist",
|
|
"--js-runtimes",
|
|
jsRuntime,
|
|
// Zusätzliche Optionen zur Umgehung der Bot-Erkennung
|
|
"--user-agent",
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
"--extractor-args",
|
|
"youtube:player_client=android,web",
|
|
];
|
|
|
|
// Cookie-Unterstützung hinzufügen
|
|
if (cookiesFile) {
|
|
execArgs.push("--cookies", cookiesFile);
|
|
} else if (cookiesFromBrowser) {
|
|
execArgs.push("--cookies-from-browser", cookiesFromBrowser);
|
|
}
|
|
|
|
// Format nur hinzufügen wenn definiert
|
|
if (formatString) {
|
|
execArgs.push("-f", formatString);
|
|
}
|
|
|
|
if (format === "mp4") {
|
|
execArgs.push("--merge-output-format", "mp4");
|
|
} else if (format === "audio") {
|
|
// Für Audio: Konvertiere zu MP3
|
|
execArgs.push("--extract-audio", "--audio-format", "mp3");
|
|
} else if (format === "best") {
|
|
// Für "best": Keine zusätzlichen Optionen, yt-dlp wählt automatisch
|
|
// Optional: Merge-Format setzen falls Video+Audio getrennt sind
|
|
execArgs.push("--merge-output-format", "mp4");
|
|
}
|
|
|
|
ytDlpWrap
|
|
.exec(execArgs)
|
|
.on("error", (error: Error) => {
|
|
reject(error);
|
|
})
|
|
.on("close", (code: number) => {
|
|
if (code === 0) {
|
|
resolve();
|
|
} else {
|
|
reject(new Error(`yt-dlp exited with code ${code}`));
|
|
}
|
|
});
|
|
});
|
|
|
|
// Datei als Stream zurückgeben
|
|
const fileBuffer = await readFile(tempFilePath);
|
|
|
|
// Temporäre Datei löschen
|
|
await unlink(tempFilePath).catch((err) => {
|
|
console.error("Fehler beim Löschen der temporären Datei:", err);
|
|
});
|
|
|
|
// Stream als Response zurückgeben
|
|
let contentType = "application/octet-stream";
|
|
if (format === "mp4") {
|
|
contentType = "video/mp4";
|
|
} else if (format === "audio") {
|
|
contentType = "audio/mpeg"; // MP3
|
|
}
|
|
|
|
return new Response(fileBuffer, {
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type": contentType,
|
|
"Content-Disposition": `attachment; filename="${encodeURIComponent(
|
|
filename
|
|
)}"`,
|
|
"Content-Length": fileBuffer.length.toString(),
|
|
},
|
|
});
|
|
} catch (error) {
|
|
// Aufräumen bei Fehler
|
|
if (existsSync(tempFilePath)) {
|
|
await unlink(tempFilePath).catch(() => {});
|
|
}
|
|
throw error;
|
|
}
|
|
} else {
|
|
// NORMALER MODUS: Datei speichern wie bisher
|
|
const downloadDir =
|
|
process.env.DOWNLOAD_DIR || path.join(process.cwd(), "downloaded");
|
|
|
|
// Verzeichnis erstellen falls nicht vorhanden
|
|
try {
|
|
if (!existsSync(downloadDir)) {
|
|
await mkdir(downloadDir, { recursive: true });
|
|
}
|
|
} catch (mkdirError) {
|
|
console.error(
|
|
"Fehler beim Erstellen des Download-Verzeichnisses:",
|
|
mkdirError
|
|
);
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: tApi(request, "api.couldNotCreateDirectory", {
|
|
dir: downloadDir,
|
|
}),
|
|
}),
|
|
{ status: 500, headers: { "Content-Type": "application/json" } }
|
|
);
|
|
}
|
|
|
|
const outputPath = path.join(downloadDir, "%(title)s.%(ext)s");
|
|
|
|
// Download durchführen mit yt-dlp-wrap
|
|
await new Promise<void>((resolve, reject) => {
|
|
const execArgs = [
|
|
url,
|
|
"-o",
|
|
outputPath,
|
|
"--no-mtime",
|
|
"--no-playlist",
|
|
"--js-runtimes",
|
|
jsRuntime,
|
|
// Zusätzliche Optionen zur Umgehung der Bot-Erkennung
|
|
"--user-agent",
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
"--extractor-args",
|
|
"youtube:player_client=ios,tv_embedded,android,web",
|
|
];
|
|
|
|
// Cookie-Unterstützung hinzufügen
|
|
if (cookiesFile) {
|
|
execArgs.push("--cookies", cookiesFile);
|
|
} else if (cookiesFromBrowser) {
|
|
execArgs.push("--cookies-from-browser", cookiesFromBrowser);
|
|
}
|
|
|
|
// Format nur hinzufügen wenn definiert
|
|
if (formatString) {
|
|
execArgs.push("-f", formatString);
|
|
}
|
|
|
|
if (format === "mp4") {
|
|
execArgs.push("--merge-output-format", "mp4");
|
|
} else if (format === "audio") {
|
|
// Für Audio: Konvertiere zu MP3
|
|
execArgs.push("--extract-audio", "--audio-format", "mp3");
|
|
} else if (format === "best") {
|
|
// Für "best": Keine zusätzlichen Optionen, yt-dlp wählt automatisch
|
|
// Optional: Merge-Format setzen falls Video+Audio getrennt sind
|
|
execArgs.push("--merge-output-format", "mp4");
|
|
}
|
|
|
|
ytDlpWrap
|
|
.exec(execArgs)
|
|
.on("progress", (progress: any) => {
|
|
// Progress-Events können hier verarbeitet werden
|
|
})
|
|
.on("ytDlpEvent", (eventType: string, eventData: any) => {
|
|
if (eventType === "download") {
|
|
// Download-Event verarbeiten
|
|
}
|
|
})
|
|
.on("error", (error: Error) => {
|
|
reject(error);
|
|
})
|
|
.on("close", (code: number) => {
|
|
if (code === 0) {
|
|
resolve();
|
|
} else {
|
|
reject(new Error(`yt-dlp exited with code ${code}`));
|
|
}
|
|
});
|
|
});
|
|
|
|
// Dateiname aus dem tatsächlichen Output-Pfad extrahieren
|
|
// Für Audio: yt-dlp erstellt .mp3 Datei
|
|
const actualExt = format === "audio" ? "mp3" : ext;
|
|
const actualFilename = path.join(downloadDir, `${title}.${actualExt}`);
|
|
let finalFilename = filename;
|
|
if (existsSync(actualFilename)) {
|
|
finalFilename = path.basename(actualFilename);
|
|
} else if (format === "audio") {
|
|
// Fallback: Suche nach .mp3 Datei mit möglicherweise anderem Namen
|
|
const files = await readdir(downloadDir);
|
|
const mp3File = files.find(
|
|
(f: string) => f.endsWith(".mp3") && f.includes(title)
|
|
);
|
|
if (mp3File) {
|
|
finalFilename = mp3File;
|
|
}
|
|
}
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
success: true,
|
|
message: tApi(request, "api.downloadSuccessful"),
|
|
filename: finalFilename,
|
|
}),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.error("Download-Fehler:", error);
|
|
return new Response(
|
|
JSON.stringify({
|
|
error:
|
|
error instanceof Error
|
|
? error.message
|
|
: tApi(request, "api.downloadFailed"),
|
|
}),
|
|
{ status: 500, headers: { "Content-Type": "application/json" } }
|
|
);
|
|
} finally {
|
|
// Temporäre Cookie-Datei löschen falls vorhanden
|
|
if (tempCookiesFile && existsSync(tempCookiesFile)) {
|
|
await unlink(tempCookiesFile).catch((err) => {
|
|
console.error("Fehler beim Löschen der temporären Cookie-Datei:", err);
|
|
});
|
|
}
|
|
}
|
|
};
|