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,213 @@
---
import { t } from "../lib/i18n";
const streamOnly = import.meta.env.STREAM_ONLY === "true";
---
<div class="container mx-auto px-4 py-8 max-w-2xl">
<h1 class="text-2xl font-bold mb-6">{t(Astro, "downloadForm.title")}</h1>
<form id="downloadForm" class="space-y-4">
<div class="form-control">
<label class="label" for="url">
<span class="label-text">{t(Astro, "downloadForm.urlLabel")}</span>
</label>
<div class="join w-full">
<input
type="url"
id="url"
name="url"
required
class="input input-bordered join-item flex-1 border border-base-300"
placeholder={t(Astro, "downloadForm.urlPlaceholder")}
/>
<button
type="submit"
id="downloadBtn"
class="btn btn-primary join-item"
>
{t(Astro, "common.download")}
</button>
</div>
</div>
<div class="form-control">
<label class="label" for="format">
<span class="label-text">{t(Astro, "downloadForm.formatLabel")}</span>
</label>
<select id="format" class="select select-bordered w-full">
<option value="mp4" selected
>{t(Astro, "downloadForm.formatMp4")}</option
>
<option value="best">{t(Astro, "downloadForm.formatBest")}</option>
</select>
</div>
</form>
<div id="status" class="mt-4 hidden"></div>
<div id="loading" class="mt-4 hidden">
<div class="flex items-center gap-2">
<span class="loading loading-spinner loading-md"></span>
<span>{t(Astro, "downloadForm.downloadInProgress")}</span>
</div>
</div>
<div id="lastFile" class="mt-4 hidden">
<div class="alert alert-success">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div>
<div class="font-bold">{t(Astro, "downloadForm.lastFile")}</div>
<div id="lastFileName" class="text-sm"></div>
</div>
<div>
<a id="lastFileLink" href="#" class="btn btn-sm btn-primary" download>
{t(Astro, "common.download")}
</a>
{
!streamOnly && (
<a href="/files" class="btn btn-sm btn-ghost ml-2">
{t(Astro, "common.allFiles")}
</a>
)
}
</div>
</div>
</div>
</div>
<script>
const form = document.getElementById("downloadForm");
const status = document.getElementById("status");
const loading = document.getElementById("loading");
const downloadBtn = document.getElementById("downloadBtn");
const lastFile = document.getElementById("lastFile");
const lastFileName = document.getElementById("lastFileName");
const lastFileLink = document.getElementById(
"lastFileLink"
) as HTMLAnchorElement;
form?.addEventListener("submit", async (e) => {
e.preventDefault();
const urlInput = document.getElementById("url") as HTMLInputElement;
const formatSelect = document.getElementById("format") as HTMLSelectElement;
const url = urlInput?.value;
const format = formatSelect?.value || "mp4";
if (!url || !downloadBtn || !status || !loading) return;
(downloadBtn as HTMLButtonElement).disabled = true;
status.classList.add("hidden");
loading?.classList.remove("hidden");
try {
const response = await fetch("/api/download", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-format": format,
},
body: JSON.stringify({ url }),
});
// Prüfe ob Response ein Stream ist (Content-Type ist nicht JSON)
const contentType = response.headers.get("content-type");
const isStream = contentType && !contentType.includes("application/json");
if (isStream && response.ok) {
// Stream-Modus: Datei direkt herunterladen
const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = downloadUrl;
// Dateiname aus Content-Disposition Header extrahieren
const contentDisposition = response.headers.get("content-disposition");
let filename = "video." + format;
if (contentDisposition) {
const filenameMatch = contentDisposition.match(
/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/
);
if (filenameMatch && filenameMatch[1]) {
filename = filenameMatch[1].replace(/['"]/g, "");
// URL-dekodieren falls nötig
try {
filename = decodeURIComponent(filename);
} catch (e) {
// Falls Dekodierung fehlschlägt, Original verwenden
}
}
}
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(downloadUrl);
loading?.classList.add("hidden");
if (status) {
status.classList.remove("hidden");
status.className = "alert alert-success";
status.innerHTML = `<span>${document.documentElement.lang === "en" ? "Download successful!" : "Download erfolgreich!"}</span>`;
}
urlInput.value = "";
} else {
// Normaler Modus: JSON Response
const data = await response.json();
loading?.classList.add("hidden");
if (response.ok && status) {
status.classList.remove("hidden");
status.className = "alert alert-success";
status.innerHTML = `<span>${document.documentElement.lang === "en" ? "Download successful!" : "Download erfolgreich!"}</span>`;
urlInput.value = "";
// Show last file
if (data.filename && lastFile && lastFileName && lastFileLink) {
lastFileName.textContent = data.filename;
lastFileLink.href = `/api/download-file?file=${encodeURIComponent(data.filename)}`;
lastFile.classList.remove("hidden");
}
} else if (status) {
status.classList.remove("hidden");
status.className = "alert alert-error";
const errorMsg =
data.error ||
(document.documentElement.lang === "en"
? "Unknown error"
: "Unbekannter Fehler");
status.innerHTML = `<span>${document.documentElement.lang === "en" ? "Error: " : "Fehler: "}${errorMsg}</span>`;
}
}
} catch (error) {
loading?.classList.add("hidden");
if (status) {
status.classList.remove("hidden");
status.className = "alert alert-error";
const errorMsg =
error instanceof Error
? error.message
: document.documentElement.lang === "en"
? "Network error"
: "Netzwerkfehler";
status.innerHTML = `<span>${document.documentElement.lang === "en" ? "Error: " : "Fehler: "}${errorMsg}</span>`;
}
} finally {
if (downloadBtn) {
(downloadBtn as HTMLButtonElement).disabled = false;
}
}
});
</script>

View File

@@ -0,0 +1,45 @@
---
import { t } from '../lib/i18n';
---
<div class="min-h-screen flex items-center justify-center bg-base-200">
<div class="card bg-base-100 shadow-xl w-full max-w-md">
<div class="card-body">
<h1 class="card-title text-2xl justify-center mb-4">{t(Astro, "login.title")}</h1>
<form method="POST" action="/api/login" class="space-y-4">
<div class="form-control">
<label class="label" for="username">
<span class="label-text">{t(Astro, "login.username")}</span>
</label>
<input
type="text"
id="username"
name="username"
required
class="input input-bordered w-full border border-base-300"
placeholder={t(Astro, "login.usernamePlaceholder")}
/>
</div>
<div class="form-control">
<label class="label" for="password">
<span class="label-text">{t(Astro, "login.password")}</span>
</label>
<input
type="password"
id="password"
name="password"
required
class="input input-bordered w-full border border-base-300"
placeholder={t(Astro, "login.passwordPlaceholder")}
/>
</div>
<div class="form-control mt-6">
<button type="submit" class="btn btn-primary w-full">
{t(Astro, "common.login")}
</button>
</div>
</form>
</div>
</div>
</div>

59
src/i18n/de.json Normal file
View File

@@ -0,0 +1,59 @@
{
"common": {
"download": "Download",
"allFiles": "Alle Dateien",
"logout": "Abmelden",
"login": "Anmelden",
"error": "Fehler",
"success": "Erfolg",
"loading": "Lädt...",
"name": "Name",
"size": "Größe",
"created": "Erstellt am",
"actions": "Aktionen",
"copyright": "© {{year}} Peter Meier",
"email": "mail@justpm.de"
},
"downloadForm": {
"title": "YouTube Downloader",
"urlLabel": "YouTube URL",
"urlPlaceholder": "https://www.youtube.com/watch?v=...",
"formatLabel": "Format",
"formatMp4": "MP4 (empfohlen - beste Kompatibilität)",
"formatBest": "Bestes verfügbares Format",
"downloadInProgress": "Download läuft...",
"downloadSuccessful": "Download erfolgreich!",
"lastFile": "Letzte Datei:",
"unknownError": "Unbekannter Fehler",
"networkError": "Netzwerkfehler"
},
"files": {
"title": "Heruntergeladene Medien",
"noFilesFound": "Keine Dateien gefunden",
"errorLoadingFiles": "Fehler beim Laden der Dateien",
"streamModeEnabled": "Stream-Modus aktiviert",
"streamModeDescription": "Im Stream-Modus werden Dateien nicht gespeichert, sondern direkt als Download angeboten. Die Dateiliste ist daher nicht verfügbar."
},
"login": {
"title": "Login",
"username": "Benutzername",
"usernamePlaceholder": "Benutzername eingeben",
"password": "Passwort",
"passwordPlaceholder": "Passwort eingeben"
},
"api": {
"notAuthenticated": "Nicht angemeldet",
"invalidUrl": "Ungültige URL",
"invalidYouTubeUrl": "Keine gültige YouTube URL",
"downloadSuccessful": "Download erfolgreich",
"downloadFailed": "Download fehlgeschlagen",
"filenameMissing": "Dateiname fehlt",
"invalidFilePath": "Ungültiger Dateipfad",
"fileNotFound": "Datei nicht gefunden",
"errorDownloadingFile": "Fehler beim Download",
"fileListNotAvailable": "Dateiliste ist im Stream-Modus nicht verfügbar",
"errorReadingFiles": "Fehler beim Lesen der Dateien",
"couldNotCreateDirectory": "Konnte Download-Verzeichnis nicht erstellen: {{dir}}. Bitte prüfe die Berechtigungen."
}
}

59
src/i18n/en.json Normal file
View File

@@ -0,0 +1,59 @@
{
"common": {
"download": "Download",
"allFiles": "All Files",
"logout": "Logout",
"login": "Login",
"error": "Error",
"success": "Success",
"loading": "Loading...",
"name": "Name",
"size": "Size",
"created": "Created",
"actions": "Actions",
"copyright": "© {{year}} Peter Meier",
"email": "mail@justpm.de"
},
"downloadForm": {
"title": "YouTube Downloader",
"urlLabel": "YouTube URL",
"urlPlaceholder": "https://www.youtube.com/watch?v=...",
"formatLabel": "Format",
"formatMp4": "MP4 (recommended - best compatibility)",
"formatBest": "Best available format",
"downloadInProgress": "Download in progress...",
"downloadSuccessful": "Download successful!",
"lastFile": "Last file:",
"unknownError": "Unknown error",
"networkError": "Network error"
},
"files": {
"title": "Downloaded Files",
"noFilesFound": "No files found",
"errorLoadingFiles": "Error loading files",
"streamModeEnabled": "Stream mode enabled",
"streamModeDescription": "In stream mode, files are not saved but offered directly as downloads. The file list is therefore not available."
},
"login": {
"title": "Login",
"username": "Username",
"usernamePlaceholder": "Enter username",
"password": "Password",
"passwordPlaceholder": "Enter password"
},
"api": {
"notAuthenticated": "Not authenticated",
"invalidUrl": "Invalid URL",
"invalidYouTubeUrl": "Not a valid YouTube URL",
"downloadSuccessful": "Download successful",
"downloadFailed": "Download failed",
"filenameMissing": "Filename missing",
"invalidFilePath": "Invalid file path",
"fileNotFound": "File not found",
"errorDownloadingFile": "Error downloading file",
"fileListNotAvailable": "File list is not available in stream mode",
"errorReadingFiles": "Error reading files",
"couldNotCreateDirectory": "Could not create download directory: {{dir}}. Please check permissions."
}
}

75
src/layouts/Layout.astro Normal file
View File

@@ -0,0 +1,75 @@
---
import "../styles/global.css";
import { getSession, isLoginEnabled } from "../lib/session";
import { t, getLocale } from "../lib/i18n";
interface Props {
title: string;
}
const { title } = Astro.props;
const loginEnabled = isLoginEnabled();
const session = await getSession(Astro.request);
const streamOnly = import.meta.env.STREAM_ONLY === "true";
const locale = getLocale(Astro);
---
<!doctype html>
<html lang={locale || "de"} data-theme="light">
<head>
<meta charset="UTF-8" />
<meta name="description" content="YouTube Downloader" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
</head>
<body class="min-h-screen flex flex-col">
{
(!loginEnabled || session) && (
<header class="navbar bg-base-100 mb-4 sticky top-0 z-50 border-b border-base-300">
<div class="flex-1">
<a href="/download" class="btn btn-ghost text-xl">
YT
</a>
</div>
<div class="flex gap-2">
{!streamOnly && (
<a href="/files" class="btn btn-ghost btn-sm">
{t(Astro, "common.allFiles")}
</a>
)}
{loginEnabled && (
<form method="POST" action="/api/logout">
<button type="submit" class="btn btn-ghost btn-sm">
{t(Astro, "common.logout")}
</button>
</form>
)}
</div>
</header>
)
}
<main class="flex-1">
<slot />
</main>
<footer
class="footer footer-center p-4 bg-base-200 text-base-content mt-auto"
>
<div>
<p class="text-sm">
{
t(Astro, "common.copyright", {
year: new Date().getFullYear().toString(),
})
}
</p>
<p class="text-sm">
<a href="mailto:mail@justpm.de" class="link link-hover">
{t(Astro, "common.email")}
</a>
</p>
</div>
</footer>
</body>
</html>

101
src/lib/i18n.ts Normal file
View File

@@ -0,0 +1,101 @@
import type { AstroGlobal, Request } from 'astro';
import enTranslations from '../i18n/en.json';
import deTranslations from '../i18n/de.json';
type Translations = typeof enTranslations;
const translations: Record<string, Translations> = {
en: enTranslations,
de: deTranslations,
};
/**
* Get the current locale from Astro
*/
export function getLocale(astro: AstroGlobal): string {
// Check environment variable first (for Docker/container environments)
const envLocale = import.meta.env.LOCALE;
if (envLocale && (envLocale === 'de' || envLocale === 'en')) {
return envLocale;
}
return astro.locale || 'de';
}
/**
* Get locale from Request (for API routes)
*/
export function getLocaleFromRequest(request: Request): string {
// Check environment variable first (for Docker/container environments)
const envLocale = import.meta.env.LOCALE;
if (envLocale && (envLocale === 'de' || envLocale === 'en')) {
return envLocale;
}
// Try to get locale from Accept-Language header
const acceptLanguage = request.headers.get('accept-language');
if (acceptLanguage) {
if (acceptLanguage.includes('de')) return 'de';
if (acceptLanguage.includes('en')) return 'en';
}
return 'de'; // Default to German
}
/**
* Get translation for a key
* Supports nested keys like "common.download"
* Supports interpolation with {{variable}}
*/
function getTranslation(locale: string, key: string, params?: Record<string, string>): string {
const translation = translations[locale] || translations.de;
// Navigate through nested keys
const keys = key.split('.');
let value: any = translation;
for (const k of keys) {
if (value && typeof value === 'object' && k in value) {
value = value[k];
} else {
// Fallback to German if key not found
value = deTranslations;
for (const k2 of keys) {
if (value && typeof value === 'object' && k2 in value) {
value = value[k2];
} else {
return key; // Return key if translation not found
}
}
break;
}
}
if (typeof value !== 'string') {
return key;
}
// Replace placeholders like {{variable}}
if (params) {
return value.replace(/\{\{(\w+)\}\}/g, (match, paramKey) => {
return params[paramKey] || match;
});
}
return value;
}
/**
* Get translation in Astro component
*/
export function t(astro: AstroGlobal, key: string, params?: Record<string, string>): string {
const locale = getLocale(astro);
return getTranslation(locale, key, params);
}
/**
* Get translation in API route
*/
export function tApi(request: Request, key: string, params?: Record<string, string>): string {
const locale = getLocaleFromRequest(request);
return getTranslation(locale, key, params);
}

55
src/lib/session.ts Normal file
View File

@@ -0,0 +1,55 @@
import type { Request } from 'astro';
const SESSION_COOKIE = 'session';
const SESSION_SECRET = import.meta.env.SESSION_SECRET || 'default-secret-change-in-production';
export interface Session {
username: string;
loggedIn: boolean;
}
/**
* Prüft ob Login aktiviert ist
* Wenn LOGIN=false oder nicht gesetzt, ist Login deaktiviert
*/
export function isLoginEnabled(): boolean {
const loginEnabled = import.meta.env.LOGIN;
// Wenn LOGIN explizit auf "false" gesetzt ist, ist Login deaktiviert
// Ansonsten ist Login aktiviert (Standard-Verhalten)
return loginEnabled !== "false";
}
export async function getSession(request: Request): Promise<Session | null> {
const cookie = request.headers.get('cookie');
if (!cookie) return null;
const sessionCookie = cookie
.split(';')
.find(c => c.trim().startsWith(`${SESSION_COOKIE}=`));
if (!sessionCookie) return null;
const sessionValue = sessionCookie.split('=')[1];
// Einfache Session-Validierung (in Produktion sollte man hier eine echte Session-Verwaltung verwenden)
try {
const session = JSON.parse(decodeURIComponent(sessionValue));
if (session.loggedIn && session.username) {
return session;
}
} catch {
return null;
}
return null;
}
export function createSessionCookie(session: Session): string {
const sessionValue = JSON.stringify(session);
return `${SESSION_COOKIE}=${encodeURIComponent(sessionValue)}; HttpOnly; Path=/; Max-Age=86400; SameSite=Lax`;
}
export function clearSessionCookie(): string {
return `${SESSION_COOKIE}=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax`;
}

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(),
},
});
};

18
src/pages/download.astro Normal file
View File

@@ -0,0 +1,18 @@
---
import Layout from '../layouts/Layout.astro';
import DownloadForm from '../components/DownloadForm.astro';
import { getSession, isLoginEnabled } from '../lib/session';
const loginEnabled = isLoginEnabled();
const session = await getSession(Astro.request);
// Nur Session-Prüfung wenn Login aktiviert ist
if (loginEnabled && !session) {
return Astro.redirect('/');
}
---
<Layout title="YouTube Downloader">
<DownloadForm />
</Layout>

218
src/pages/files.astro Normal file
View File

@@ -0,0 +1,218 @@
---
import Layout from '../layouts/Layout.astro';
import { getSession, isLoginEnabled } from '../lib/session';
import { t } from '../lib/i18n';
const loginEnabled = isLoginEnabled();
const session = await getSession(Astro.request);
// Nur Session-Prüfung wenn Login aktiviert ist
if (loginEnabled && !session) {
return Astro.redirect('/');
}
const streamOnly = import.meta.env.STREAM_ONLY === "true";
---
<Layout title={t(Astro, "files.title")}>
<div class="min-h-screen bg-base-200 p-4">
<div class="max-w-7xl mx-auto">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h1 class="card-title text-2xl mb-4">{t(Astro, "files.title")}</h1>
{streamOnly ? (
<div class="alert alert-info">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div>
<h3 class="font-bold">{t(Astro, "files.streamModeEnabled")}</h3>
<div class="text-sm">{t(Astro, "files.streamModeDescription")}</div>
</div>
</div>
) : (
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>
<button class="btn btn-ghost btn-xs" onclick="sortTable('name')">
{t(Astro, "common.name")}
<span id="sort-name-icon">↕</span>
</button>
</th>
<th>
<button class="btn btn-ghost btn-xs" onclick="sortTable('size')">
{t(Astro, "common.size")}
<span id="sort-size-icon">↕</span>
</button>
</th>
<th>
<button class="btn btn-ghost btn-xs" onclick="sortTable('createdAt')">
{t(Astro, "common.created")}
<span id="sort-createdAt-icon">↕</span>
</button>
</th>
<th>{t(Astro, "common.actions")}</th>
</tr>
</thead>
<tbody id="filesTableBody">
<tr>
<td colspan="4" class="text-center">
<span class="loading loading-spinner loading-lg"></span>
</td>
</tr>
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
</div>
</Layout>
<script>
let files: Array<{name: string; size: number; createdAt: string; modifiedAt: string}> = [];
let sortColumn: string | null = null;
let sortDirection: 'asc' | 'desc' = 'desc';
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleString('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function sortTable(column: string) {
if (sortColumn === column) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
} else {
sortColumn = column;
sortDirection = 'asc';
}
files.sort((a, b) => {
let aVal: any = a[column as keyof typeof a];
let bVal: any = b[column as keyof typeof b];
if (column === 'size') {
aVal = Number(aVal);
bVal = Number(bVal);
} else if (column === 'createdAt' || column === 'modifiedAt') {
aVal = new Date(aVal).getTime();
bVal = new Date(bVal).getTime();
} else {
aVal = String(aVal).toLowerCase();
bVal = String(bVal).toLowerCase();
}
if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1;
if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1;
return 0;
});
updateSortIcons();
renderTable();
}
function updateSortIcons() {
// Reset all icons
document.querySelectorAll('[id^="sort-"]').forEach(icon => {
(icon as HTMLElement).textContent = '↕';
});
if (sortColumn) {
const icon = document.getElementById(`sort-${sortColumn}-icon`);
if (icon) {
icon.textContent = sortDirection === 'asc' ? '↑' : '↓';
}
}
}
function renderTable() {
const tbody = document.getElementById('filesTableBody');
if (!tbody) return;
if (files.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="4" class="text-center">No files found</td>
</tr>
`;
return;
}
tbody.innerHTML = files.map(file => `
<tr>
<td>${file.name}</td>
<td>${formatFileSize(file.size)}</td>
<td>${formatDate(file.createdAt)}</td>
<td>
<a href="/api/download-file?file=${encodeURIComponent(file.name)}" class="btn btn-sm btn-primary" download>
Download
</a>
</td>
</tr>
`).join('');
}
async function loadFiles() {
try {
const response = await fetch('/api/files');
const data = await response.json();
if (response.ok) {
files = data.files || [];
if (files.length > 0) {
sortTable('createdAt'); // Standard: nach Erstellungsdatum sortieren
} else {
renderTable();
}
} else {
console.error('Error loading files:', data.error);
const tbody = document.getElementById('filesTableBody');
if (tbody) {
tbody.innerHTML = `
<tr>
<td colspan="4" class="text-center text-error" data-i18n="files.errorLoadingFiles">Error loading files</td>
</tr>
`;
}
}
} catch (error) {
console.error('Error:', error);
const tbody = document.getElementById('filesTableBody');
if (tbody) {
tbody.innerHTML = `
<tr>
<td colspan="4" class="text-center text-error" data-i18n="downloadForm.networkError">Network error</td>
</tr>
`;
}
}
}
// Globale Funktion für onclick
(window as any).sortTable = sortTable;
// Dateien beim Laden der Seite abrufen (nur wenn nicht im Stream-Modus)
const streamOnly = false; // Wird server-seitig geprüft
if (!streamOnly) {
loadFiles();
}
</script>

23
src/pages/index.astro Normal file
View File

@@ -0,0 +1,23 @@
---
import Layout from '../layouts/Layout.astro';
import LoginForm from '../components/LoginForm.astro';
import { getSession, isLoginEnabled } from '../lib/session';
const loginEnabled = isLoginEnabled();
// Wenn Login deaktiviert ist, direkt zu /download weiterleiten
if (!loginEnabled) {
return Astro.redirect('/download');
}
const session = await getSession(Astro.request);
if (session) {
return Astro.redirect('/download');
}
---
<Layout title="Login">
<LoginForm />
</Layout>

3
src/styles/global.css Normal file
View File

@@ -0,0 +1,3 @@
@import "tailwindcss";
@plugin "daisyui";