Initial commit of YouTube Downloader application, including core functionality for downloading videos, user authentication, and Docker support. Added configuration files, environment setup, and basic UI components using Astro.js and Tailwind CSS.

This commit is contained in:
Peter Meier
2025-12-22 10:59:01 +01:00
parent 79d8a95391
commit 486639aaea
31 changed files with 13078 additions and 132 deletions

View File

@@ -0,0 +1,58 @@
import type { APIRoute } from "astro";
import { getSession, isLoginEnabled } from "../../lib/session";
import { tApi } from "../../lib/i18n";
import { readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import path from "node:path";
export const GET: 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(tApi(request, "api.notAuthenticated"), { status: 401 });
}
}
try {
const url = new URL(request.url);
const fileName = url.searchParams.get("file");
if (!fileName) {
return new Response(tApi(request, "api.filenameMissing"), { status: 400 });
}
// Download-Verzeichnis aus Environment-Variable
const downloadDir =
import.meta.env.DOWNLOAD_DIR || path.join(process.cwd(), "downloaded");
const filePath = path.join(downloadDir, fileName);
// Sicherheitsprüfung: Verhindere Path Traversal
if (!filePath.startsWith(downloadDir)) {
return new Response(tApi(request, "api.invalidFilePath"), { status: 400 });
}
if (!existsSync(filePath)) {
return new Response(tApi(request, "api.fileNotFound"), { status: 404 });
}
const fileContent = await readFile(filePath);
return new Response(fileContent, {
status: 200,
headers: {
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename="${fileName}"`,
},
});
} catch (error) {
console.error("Fehler beim Download der Datei:", error);
return new Response(
error instanceof Error ? error.message : tApi(request, "api.errorDownloadingFile"),
{ status: 500 }
);
}
};

227
src/pages/api/download.ts Normal file
View File

@@ -0,0 +1,227 @@
import type { APIRoute } from "astro";
import { getSession, isLoginEnabled } from "../../lib/session";
import { tApi } from "../../lib/i18n";
import { mkdir, unlink, readFile } 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" },
}
);
}
}
try {
const { url } = 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" },
}
);
}
// 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 = import.meta.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";
// Zuerst Video-Informationen abrufen, um den Dateinamen zu erhalten
const videoInfo = await ytDlpWrap.getVideoInfo(url);
const title = videoInfo.title || "Video";
const ext = format === "mp4" ? "mp4" : videoInfo.ext || "mp4";
const filename = `${title}.${ext}`;
// Format-String für yt-dlp
const formatString =
format === "mp4"
? "bestvideo[ext=mp4]+bestaudio[ext=m4a]/bestvideo[ext=mp4]+bestaudio/best[ext=mp4]/best"
: "best";
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,
"-f",
formatString,
"--no-mtime",
"--no-playlist",
];
if (format === "mp4") {
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
return new Response(fileBuffer, {
status: 200,
headers: {
"Content-Type":
format === "mp4" ? "video/mp4" : "application/octet-stream",
"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 =
import.meta.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,
"-f",
formatString,
"--no-mtime",
"--no-playlist",
];
if (format === "mp4") {
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
const actualFilename = path.join(downloadDir, `${title}.${ext}`);
let finalFilename = filename;
if (existsSync(actualFilename)) {
finalFilename = path.basename(actualFilename);
}
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" } }
);
}
};

83
src/pages/api/files.ts Normal file
View File

@@ -0,0 +1,83 @@
import type { APIRoute } from "astro";
import { getSession, isLoginEnabled } from "../../lib/session";
import { tApi } from "../../lib/i18n";
import { readdir, stat } from "node:fs/promises";
import { existsSync } from "node:fs";
import path from "node:path";
export const GET: 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" },
});
}
}
// Prüfe ob Stream-Modus aktiviert ist
const streamOnly = import.meta.env.STREAM_ONLY === "true";
if (streamOnly) {
return new Response(
JSON.stringify({
error: tApi(request, "api.fileListNotAvailable"),
files: []
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
}
);
}
try {
// Download-Verzeichnis aus Environment-Variable
const downloadDir =
import.meta.env.DOWNLOAD_DIR || path.join(process.cwd(), "downloaded");
if (!existsSync(downloadDir)) {
return new Response(JSON.stringify({ files: [] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
// Alle Dateien im Verzeichnis lesen
const files = await readdir(downloadDir);
const fileList = await Promise.all(
files.map(async (filename) => {
const filePath = path.join(downloadDir, filename);
const stats = await stat(filePath);
return {
name: filename,
size: stats.size,
createdAt: stats.birthtime.toISOString(),
modifiedAt: stats.mtime.toISOString(),
};
})
);
// Nach Erstellungsdatum sortieren (neueste zuerst)
fileList.sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
return new Response(JSON.stringify({ files: fileList }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("Fehler beim Lesen der Dateien:", error);
return new Response(
JSON.stringify({
error: error instanceof Error ? error.message : tApi(request, "api.errorReadingFiles"),
}),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}
};

57
src/pages/api/login.ts Normal file
View File

@@ -0,0 +1,57 @@
import type { APIRoute } from 'astro';
import { createSessionCookie, isLoginEnabled } from '../../lib/session';
export const POST: APIRoute = async ({ request }) => {
// Wenn Login deaktiviert ist, weiterleiten
if (!isLoginEnabled()) {
return new Response(null, {
status: 302,
headers: {
'Location': new URL('/download', request.url).toString(),
},
});
}
const formData = await request.formData();
const username = formData.get('username')?.toString();
const password = formData.get('password')?.toString();
// Credentials aus Environment-Variablen
const envUsername = import.meta.env.LOGIN_USERNAME;
const envPassword = import.meta.env.LOGIN_PASSWORD;
// Prüfe ob Credentials konfiguriert sind
if (!envUsername || !envPassword) {
console.error('LOGIN_USERNAME oder LOGIN_PASSWORD nicht in Environment-Variablen gesetzt!');
return new Response(null, {
status: 302,
headers: {
'Location': new URL('/', request.url).toString(),
},
});
}
// Authentifizierung gegen Environment-Variablen
if (username === envUsername && password === envPassword) {
const session = {
username,
loggedIn: true,
};
return new Response(null, {
status: 302,
headers: {
'Location': new URL('/download', request.url).toString(),
'Set-Cookie': createSessionCookie(session),
},
});
}
// Falsche Credentials
return new Response(null, {
status: 302,
headers: {
'Location': new URL('/', request.url).toString(),
},
});
};

13
src/pages/api/logout.ts Normal file
View File

@@ -0,0 +1,13 @@
import type { APIRoute } from 'astro';
import { clearSessionCookie } from '../../lib/session';
export const POST: APIRoute = async ({ request }) => {
return new Response(null, {
status: 302,
headers: {
'Location': new URL('/', request.url).toString(),
'Set-Cookie': clearSessionCookie(),
},
});
};