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:
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.astro
|
||||||
|
.env
|
||||||
|
.env.production
|
||||||
|
downloaded
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
|
||||||
150
.gitignore
vendored
150
.gitignore
vendored
@@ -1,138 +1,26 @@
|
|||||||
# ---> Node
|
# build output
|
||||||
# Logs
|
dist/
|
||||||
logs
|
.output/
|
||||||
*.log
|
|
||||||
|
# generated types
|
||||||
|
.astro/
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# logs
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
lerna-debug.log*
|
pnpm-debug.log*
|
||||||
.pnpm-debug.log*
|
|
||||||
|
|
||||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
# environment variables
|
||||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
|
||||||
|
|
||||||
# Runtime data
|
|
||||||
pids
|
|
||||||
*.pid
|
|
||||||
*.seed
|
|
||||||
*.pid.lock
|
|
||||||
|
|
||||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
|
||||||
lib-cov
|
|
||||||
|
|
||||||
# Coverage directory used by tools like istanbul
|
|
||||||
coverage
|
|
||||||
*.lcov
|
|
||||||
|
|
||||||
# nyc test coverage
|
|
||||||
.nyc_output
|
|
||||||
|
|
||||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
|
||||||
.grunt
|
|
||||||
|
|
||||||
# Bower dependency directory (https://bower.io/)
|
|
||||||
bower_components
|
|
||||||
|
|
||||||
# node-waf configuration
|
|
||||||
.lock-wscript
|
|
||||||
|
|
||||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
|
||||||
build/Release
|
|
||||||
|
|
||||||
# Dependency directories
|
|
||||||
node_modules/
|
|
||||||
jspm_packages/
|
|
||||||
|
|
||||||
# Snowpack dependency directory (https://snowpack.dev/)
|
|
||||||
web_modules/
|
|
||||||
|
|
||||||
# TypeScript cache
|
|
||||||
*.tsbuildinfo
|
|
||||||
|
|
||||||
# Optional npm cache directory
|
|
||||||
.npm
|
|
||||||
|
|
||||||
# Optional eslint cache
|
|
||||||
.eslintcache
|
|
||||||
|
|
||||||
# Optional stylelint cache
|
|
||||||
.stylelintcache
|
|
||||||
|
|
||||||
# Microbundle cache
|
|
||||||
.rpt2_cache/
|
|
||||||
.rts2_cache_cjs/
|
|
||||||
.rts2_cache_es/
|
|
||||||
.rts2_cache_umd/
|
|
||||||
|
|
||||||
# Optional REPL history
|
|
||||||
.node_repl_history
|
|
||||||
|
|
||||||
# Output of 'npm pack'
|
|
||||||
*.tgz
|
|
||||||
|
|
||||||
# Yarn Integrity file
|
|
||||||
.yarn-integrity
|
|
||||||
|
|
||||||
# dotenv environment variable files
|
|
||||||
.env
|
.env
|
||||||
.env.development.local
|
.env.production
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
.env.local
|
|
||||||
|
|
||||||
# parcel-bundler cache (https://parceljs.org/)
|
# OS
|
||||||
.cache
|
.DS_Store
|
||||||
.parcel-cache
|
|
||||||
|
|
||||||
# Next.js build output
|
|
||||||
.next
|
|
||||||
out
|
|
||||||
|
|
||||||
# Nuxt.js build / generate output
|
|
||||||
.nuxt
|
|
||||||
dist
|
|
||||||
|
|
||||||
# Gatsby files
|
|
||||||
.cache/
|
|
||||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
|
||||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
|
||||||
# public
|
|
||||||
|
|
||||||
# vuepress build output
|
|
||||||
.vuepress/dist
|
|
||||||
|
|
||||||
# vuepress v2.x temp and cache directory
|
|
||||||
.temp
|
|
||||||
.cache
|
|
||||||
|
|
||||||
# vitepress build output
|
|
||||||
**/.vitepress/dist
|
|
||||||
|
|
||||||
# vitepress cache directory
|
|
||||||
**/.vitepress/cache
|
|
||||||
|
|
||||||
# Docusaurus cache and generated files
|
|
||||||
.docusaurus
|
|
||||||
|
|
||||||
# Serverless directories
|
|
||||||
.serverless/
|
|
||||||
|
|
||||||
# FuseBox cache
|
|
||||||
.fusebox/
|
|
||||||
|
|
||||||
# DynamoDB Local files
|
|
||||||
.dynamodb/
|
|
||||||
|
|
||||||
# TernJS port file
|
|
||||||
.tern-port
|
|
||||||
|
|
||||||
# Stores VSCode versions used for testing VSCode extensions
|
|
||||||
.vscode-test
|
|
||||||
|
|
||||||
# yarn v2
|
|
||||||
.yarn/cache
|
|
||||||
.yarn/unplugged
|
|
||||||
.yarn/build-state.yml
|
|
||||||
.yarn/install-state.gz
|
|
||||||
.pnp.*
|
|
||||||
|
|
||||||
|
# downloaded videos
|
||||||
|
downloaded/
|
||||||
|
.history/
|
||||||
|
|||||||
55
BUILD-MACOS.md
Normal file
55
BUILD-MACOS.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# macOS Build-Anleitung
|
||||||
|
|
||||||
|
## Electron-App für macOS erstellen
|
||||||
|
|
||||||
|
### Schritt 1: Dependencies installieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schritt 2: Astro-App bauen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schritt 3: Electron-App für macOS bauen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run electron:build:mac
|
||||||
|
```
|
||||||
|
|
||||||
|
Das erstellt in `dist-electron/`:
|
||||||
|
- **YouTube Downloader.dmg** - Installer für macOS
|
||||||
|
- **YouTube Downloader-mac.zip** - Portable Version
|
||||||
|
|
||||||
|
## Für Endnutzer (z.B. deine Oma)
|
||||||
|
|
||||||
|
### Option 1: DMG Installer (empfohlen)
|
||||||
|
|
||||||
|
1. Öffne die `.dmg` Datei
|
||||||
|
2. Ziehe "YouTube Downloader" in den Applications-Ordner
|
||||||
|
3. Öffne Applications und starte "YouTube Downloader"
|
||||||
|
4. Bei der ersten Ausführung: Rechtsklick → Öffnen (wegen Gatekeeper)
|
||||||
|
|
||||||
|
### Option 2: Portable Version
|
||||||
|
|
||||||
|
1. Entpacke die `.zip` Datei
|
||||||
|
2. Doppelklicke auf "YouTube Downloader.app"
|
||||||
|
3. Bei der ersten Ausführung: Rechtsklick → Öffnen
|
||||||
|
|
||||||
|
## Code-Signing (optional, für Verteilung)
|
||||||
|
|
||||||
|
Falls du die App signieren möchtest (für bessere Kompatibilität):
|
||||||
|
|
||||||
|
1. Apple Developer Account erstellen
|
||||||
|
2. In `package.json` unter `build.mac.identity` hinzufügen:
|
||||||
|
```json
|
||||||
|
"identity": "Developer ID Application: Dein Name"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notarization (optional, für App Store)
|
||||||
|
|
||||||
|
Für App Store Verteilung benötigst du zusätzliche Konfiguration.
|
||||||
|
|
||||||
30
Dockerfile
Normal file
30
Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
# Set default locale to German
|
||||||
|
ENV LOCALE=de
|
||||||
|
|
||||||
|
# yt-dlp und ffmpeg installieren
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
python3 \
|
||||||
|
py3-pip \
|
||||||
|
ffmpeg \
|
||||||
|
&& pip3 install --no-cache-dir yt-dlp
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Package-Dateien kopieren und Dependencies installieren
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Rest der Anwendung kopieren
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build durchführen
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Port freigeben
|
||||||
|
EXPOSE 4321
|
||||||
|
|
||||||
|
# Start-Befehl
|
||||||
|
CMD ["node", "./dist/server/entry.mjs"]
|
||||||
|
|
||||||
176
INSTALLATION.md
Normal file
176
INSTALLATION.md
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
# Einfache Installationsanleitung
|
||||||
|
|
||||||
|
Diese Anleitung führt dich Schritt für Schritt durch die Installation der YouTube Downloader Anwendung.
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
Du brauchst:
|
||||||
|
- **Node.js** (Version 20 oder höher) - [Download hier](https://nodejs.org/)
|
||||||
|
- **yt-dlp** - wird automatisch installiert (siehe unten)
|
||||||
|
|
||||||
|
## Schnellinstallation (empfohlen)
|
||||||
|
|
||||||
|
Falls du Node.js bereits installiert hast, führe einfach aus:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run install:setup
|
||||||
|
```
|
||||||
|
|
||||||
|
Dieses Skript führt automatisch alle Installationsschritte aus!
|
||||||
|
|
||||||
|
## Manuelle Installation Schritt für Schritt
|
||||||
|
|
||||||
|
### Schritt 1: Node.js installieren
|
||||||
|
|
||||||
|
1. Gehe zu [https://nodejs.org/](https://nodejs.org/)
|
||||||
|
2. Lade die **LTS-Version** herunter (empfohlen)
|
||||||
|
3. Installiere Node.js mit den Standard-Einstellungen
|
||||||
|
4. Öffne ein Terminal/Fenster (macOS: Terminal, Windows: PowerShell oder CMD)
|
||||||
|
|
||||||
|
### Schritt 2: Projekt herunterladen
|
||||||
|
|
||||||
|
Falls du das Projekt noch nicht hast:
|
||||||
|
```bash
|
||||||
|
# Falls du Git hast:
|
||||||
|
git clone <repository-url>
|
||||||
|
cd yt
|
||||||
|
|
||||||
|
# Oder lade das Projekt als ZIP herunter und entpacke es
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schritt 3: Projekt öffnen
|
||||||
|
|
||||||
|
Öffne ein Terminal im Projektordner:
|
||||||
|
```bash
|
||||||
|
cd yt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schritt 4: Abhängigkeiten installieren
|
||||||
|
|
||||||
|
Führe diesen Befehl aus:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Das kann einige Minuten dauern. Warte, bis es fertig ist.
|
||||||
|
|
||||||
|
### Schritt 5: yt-dlp installieren
|
||||||
|
|
||||||
|
**Für macOS:**
|
||||||
|
```bash
|
||||||
|
brew install yt-dlp
|
||||||
|
```
|
||||||
|
|
||||||
|
**Für Linux (Ubuntu/Debian):**
|
||||||
|
```bash
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install yt-dlp
|
||||||
|
```
|
||||||
|
|
||||||
|
**Für Windows:**
|
||||||
|
1. Lade yt-dlp von [https://github.com/yt-dlp/yt-dlp/releases](https://github.com/yt-dlp/yt-dlp/releases)
|
||||||
|
2. Entpacke die Datei
|
||||||
|
3. Füge den Ordner zu deinem PATH hinzu (optional, aber empfohlen)
|
||||||
|
|
||||||
|
**Oder mit pip (funktioniert auf allen Systemen):**
|
||||||
|
```bash
|
||||||
|
pip install yt-dlp
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schritt 6: Environment-Variablen einrichten
|
||||||
|
|
||||||
|
Erstelle eine Datei namens `.env` im Projektordner:
|
||||||
|
|
||||||
|
**Windows (PowerShell):**
|
||||||
|
```powershell
|
||||||
|
New-Item -Path .env -ItemType File
|
||||||
|
notepad .env
|
||||||
|
```
|
||||||
|
|
||||||
|
**macOS/Linux:**
|
||||||
|
```bash
|
||||||
|
touch .env
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Füge folgende Zeilen ein (ändere die Werte nach deinen Wünschen):
|
||||||
|
```
|
||||||
|
LOGIN_USERNAME=admin
|
||||||
|
LOGIN_PASSWORD=mein-sicheres-passwort
|
||||||
|
DOWNLOAD_DIR=./downloaded
|
||||||
|
SESSION_SECRET=mein-geheimes-session-secret
|
||||||
|
```
|
||||||
|
|
||||||
|
Speichere die Datei (in nano: Strg+X, dann Y, dann Enter).
|
||||||
|
|
||||||
|
### Schritt 7: Anwendung starten
|
||||||
|
|
||||||
|
Führe diesen Befehl aus:
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Du solltest eine Meldung sehen wie:
|
||||||
|
```
|
||||||
|
Local: http://localhost:4321
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schritt 8: Im Browser öffnen
|
||||||
|
|
||||||
|
Öffne deinen Browser und gehe zu:
|
||||||
|
```
|
||||||
|
http://localhost:4321
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schritt 9: Anmelden
|
||||||
|
|
||||||
|
- Benutzername: Der Wert aus `LOGIN_USERNAME` in deiner `.env` Datei
|
||||||
|
- Passwort: Der Wert aus `LOGIN_PASSWORD` in deiner `.env` Datei
|
||||||
|
|
||||||
|
## Mit Docker (einfacher, aber Docker muss installiert sein)
|
||||||
|
|
||||||
|
Wenn du Docker installiert hast, ist es noch einfacher:
|
||||||
|
|
||||||
|
### Schritt 1: Docker installieren
|
||||||
|
|
||||||
|
- **macOS**: [Docker Desktop für Mac](https://www.docker.com/products/docker-desktop/)
|
||||||
|
- **Windows**: [Docker Desktop für Windows](https://www.docker.com/products/docker-desktop/)
|
||||||
|
- **Linux**: Folge der [Docker Installationsanleitung](https://docs.docker.com/engine/install/)
|
||||||
|
|
||||||
|
### Schritt 2: Environment-Variablen setzen
|
||||||
|
|
||||||
|
Erstelle eine `.env` Datei (siehe Schritt 6 oben).
|
||||||
|
|
||||||
|
### Schritt 3: Starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Anwendung läuft dann auf `http://localhost:4321`
|
||||||
|
|
||||||
|
## Häufige Probleme
|
||||||
|
|
||||||
|
### "npm: command not found"
|
||||||
|
- Node.js ist nicht installiert oder nicht im PATH
|
||||||
|
- Installiere Node.js neu und starte das Terminal neu
|
||||||
|
|
||||||
|
### "yt-dlp: command not found"
|
||||||
|
- yt-dlp ist nicht installiert
|
||||||
|
- Folge Schritt 5 oben
|
||||||
|
|
||||||
|
### "Port 4321 already in use"
|
||||||
|
- Ein anderer Prozess verwendet den Port
|
||||||
|
- Ändere den Port in `package.json` oder beende den anderen Prozess
|
||||||
|
|
||||||
|
### Login funktioniert nicht
|
||||||
|
- Prüfe, ob die `.env` Datei existiert und die richtigen Werte enthält
|
||||||
|
- Starte den Server neu nach Änderungen an der `.env` Datei
|
||||||
|
|
||||||
|
## Hilfe
|
||||||
|
|
||||||
|
Bei Problemen:
|
||||||
|
1. Prüfe, ob alle Schritte korrekt ausgeführt wurden
|
||||||
|
2. Stelle sicher, dass Node.js und yt-dlp installiert sind
|
||||||
|
3. Prüfe die Fehlermeldungen im Terminal
|
||||||
|
|
||||||
152
README.md
152
README.md
@@ -1,2 +1,152 @@
|
|||||||
# yt
|
# YouTube Downloader
|
||||||
|
|
||||||
|
Eine einfache Web-Anwendung zum Herunterladen von YouTube-Videos mit Astro.js und yt-dlp.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Einfache Login-Seite
|
||||||
|
- YouTube URL Download-Interface
|
||||||
|
- yt-dlp Integration für Video-Downloads (läuft innerhalb der App über npm-Package)
|
||||||
|
- Tailwind CSS für modernes Design
|
||||||
|
- Docker-Support
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
- Node.js 20 oder höher
|
||||||
|
- Docker (optional, für Container-Deployment)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
**Für unerfahrene Nutzer:** Siehe [INSTALLATION.md](./INSTALLATION.md) für eine detaillierte Schritt-für-Schritt-Anleitung.
|
||||||
|
|
||||||
|
### Schnellstart
|
||||||
|
|
||||||
|
1. **Dependencies installieren:**
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **yt-dlp installieren:**
|
||||||
|
```bash
|
||||||
|
# macOS
|
||||||
|
brew install yt-dlp
|
||||||
|
|
||||||
|
# Linux
|
||||||
|
sudo apt-get install yt-dlp
|
||||||
|
|
||||||
|
# Windows oder mit pip
|
||||||
|
pip install yt-dlp
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Environment-Variablen einrichten:**
|
||||||
|
|
||||||
|
Erstelle eine `.env` Datei im Projektordner:
|
||||||
|
```env
|
||||||
|
LOGIN_USERNAME=admin
|
||||||
|
LOGIN_PASSWORD=dein-passwort
|
||||||
|
DOWNLOAD_DIR=./downloaded
|
||||||
|
SESSION_SECRET=dein-session-secret
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Development-Server starten:**
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Anwendung läuft dann auf `http://localhost:4321`
|
||||||
|
|
||||||
|
### Mit Docker
|
||||||
|
|
||||||
|
1. Docker Image bauen und starten:
|
||||||
|
```bash
|
||||||
|
docker-compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Anwendung läuft dann auf `http://localhost:4321`
|
||||||
|
|
||||||
|
### Als Desktop-App (Electron)
|
||||||
|
|
||||||
|
**Für macOS:**
|
||||||
|
```bash
|
||||||
|
npm run electron:build:mac
|
||||||
|
```
|
||||||
|
|
||||||
|
**Für Windows:**
|
||||||
|
```bash
|
||||||
|
npm run electron:build:win
|
||||||
|
```
|
||||||
|
|
||||||
|
Die fertige App wird in `dist-electron/` erstellt:
|
||||||
|
- **macOS**: `.dmg` Installer oder `.zip` Portable Version
|
||||||
|
- **Windows**: `.exe` Installer oder Portable `.exe`
|
||||||
|
|
||||||
|
Siehe [BUILD-MACOS.md](./BUILD-MACOS.md) für detaillierte Anleitung.
|
||||||
|
|
||||||
|
## Verwendung
|
||||||
|
|
||||||
|
1. Öffne die Anwendung im Browser
|
||||||
|
2. Melde dich mit den konfigurierten Credentials an (siehe Environment-Variablen)
|
||||||
|
3. Gib eine YouTube URL in das Eingabefeld ein
|
||||||
|
4. Klicke auf "Download"
|
||||||
|
5. Das Video wird in den konfigurierten Ordner heruntergeladen (Standard: `/downloaded`)
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
### Environment-Variablen
|
||||||
|
|
||||||
|
- `LOGIN_USERNAME`: Benutzername für den Login (erforderlich wenn `LOGIN` nicht auf `false` gesetzt ist)
|
||||||
|
- `LOGIN_PASSWORD`: Passwort für den Login (erforderlich wenn `LOGIN` nicht auf `false` gesetzt ist)
|
||||||
|
- `LOGIN`: Wenn auf `false` gesetzt, wird der Login deaktiviert und alle Seiten sind ohne Authentifizierung zugänglich (Standard: `true`)
|
||||||
|
- `DOWNLOAD_DIR`: Verzeichnis für Downloads (Standard: `./downloaded`)
|
||||||
|
- `SESSION_SECRET`: Secret für Session-Cookies (Standard: `default-secret-change-in-production`)
|
||||||
|
- `STREAM_ONLY`: Wenn auf `true` gesetzt, werden Dateien nicht physisch gespeichert, sondern direkt als Download-Stream angeboten (Standard: `false`)
|
||||||
|
- `LOCALE`: Sprache der Anwendung - `de` für Deutsch oder `en` für Englisch (Standard: `de`)
|
||||||
|
|
||||||
|
**Beispiel `.env` Datei mit Login:**
|
||||||
|
```
|
||||||
|
LOGIN_USERNAME=admin
|
||||||
|
LOGIN_PASSWORD=mein-sicheres-passwort
|
||||||
|
LOGIN=true
|
||||||
|
DOWNLOAD_DIR=./downloaded
|
||||||
|
SESSION_SECRET=mein-geheimes-session-secret
|
||||||
|
STREAM_ONLY=false
|
||||||
|
LOCALE=de
|
||||||
|
```
|
||||||
|
|
||||||
|
**Beispiel `.env` Datei ohne Login:**
|
||||||
|
```
|
||||||
|
LOGIN=false
|
||||||
|
DOWNLOAD_DIR=./downloaded
|
||||||
|
STREAM_ONLY=false
|
||||||
|
LOCALE=de
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hinweis zu `LOGIN`:**
|
||||||
|
- Wenn `LOGIN=false`: Keine Login-Seite, alle Seiten sind öffentlich zugänglich. `LOGIN_USERNAME` und `LOGIN_PASSWORD` werden ignoriert.
|
||||||
|
- Wenn `LOGIN=true` oder nicht gesetzt: Login ist aktiviert. `LOGIN_USERNAME` und `LOGIN_PASSWORD` müssen gesetzt sein.
|
||||||
|
|
||||||
|
**Hinweis zu `STREAM_ONLY`:**
|
||||||
|
- Wenn `STREAM_ONLY=true`: Dateien werden temporär gespeichert, direkt als Download-Stream angeboten und danach automatisch gelöscht. Keine Dateien bleiben auf der Festplatte.
|
||||||
|
- Wenn `STREAM_ONLY=false` oder nicht gesetzt: Dateien werden im `DOWNLOAD_DIR` Verzeichnis gespeichert und können später über die Dateiliste abgerufen werden.
|
||||||
|
|
||||||
|
## Projektstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
/
|
||||||
|
├── src/
|
||||||
|
│ ├── components/ # Astro-Komponenten
|
||||||
|
│ ├── layouts/ # Layout-Templates
|
||||||
|
│ ├── lib/ # Utility-Funktionen
|
||||||
|
│ └── pages/ # Seiten und API-Routes
|
||||||
|
├── Dockerfile # Docker-Konfiguration
|
||||||
|
├── docker-compose.yml # Docker Compose Setup
|
||||||
|
└── package.json # Dependencies
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hinweise
|
||||||
|
|
||||||
|
- Die Login-Credentials müssen in den Environment-Variablen `LOGIN_USERNAME` und `LOGIN_PASSWORD` gesetzt werden
|
||||||
|
- Für Produktionsumgebungen sollte ein starkes Passwort verwendet werden
|
||||||
|
- yt-dlp läuft innerhalb der App über das npm-Package `yt-dlp-wrap`
|
||||||
|
- Im Docker-Container wird yt-dlp automatisch installiert
|
||||||
|
- Für lokale Entwicklung muss yt-dlp separat installiert sein (siehe Installation)
|
||||||
|
|||||||
23
astro.config.mjs
Normal file
23
astro.config.mjs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// @ts-check
|
||||||
|
import { defineConfig } from 'astro/config';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import node from '@astrojs/node';
|
||||||
|
|
||||||
|
// https://astro.build/config
|
||||||
|
export default defineConfig({
|
||||||
|
vite: {
|
||||||
|
plugins: [tailwindcss()],
|
||||||
|
},
|
||||||
|
output: 'server',
|
||||||
|
adapter: node({
|
||||||
|
mode: 'standalone'
|
||||||
|
}),
|
||||||
|
i18n: {
|
||||||
|
defaultLocale: 'de',
|
||||||
|
locales: ['de', 'en'],
|
||||||
|
routing: {
|
||||||
|
prefixDefaultLocale: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
19
docker-compose.yml
Normal file
19
docker-compose.yml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "4321:4321"
|
||||||
|
environment:
|
||||||
|
- LOGIN_USERNAME=${LOGIN_USERNAME:-admin}
|
||||||
|
- LOGIN_PASSWORD=${LOGIN_PASSWORD:-change-me-in-production}
|
||||||
|
- DOWNLOAD_DIR=/downloaded
|
||||||
|
- SESSION_SECRET=${SESSION_SECRET:-change-me-in-production}
|
||||||
|
- LOCALE=${LOCALE:-de}
|
||||||
|
- LOGIN=${LOGIN:-true}
|
||||||
|
- STREAM_ONLY=${STREAM_ONLY:-false}
|
||||||
|
volumes:
|
||||||
|
- ./downloaded:/downloaded
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
91
electron/main.cjs
Normal file
91
electron/main.cjs
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
const { app, BrowserWindow } = require("electron");
|
||||||
|
const { spawn } = require("child_process");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
let mainWindow;
|
||||||
|
let astroProcess;
|
||||||
|
|
||||||
|
// Prüfe ob wir im Development-Modus sind
|
||||||
|
const isDev = process.env.NODE_ENV === "development" || !app.isPackaged;
|
||||||
|
|
||||||
|
function createWindow() {
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: 1200,
|
||||||
|
height: 800,
|
||||||
|
webPreferences: {
|
||||||
|
nodeIntegration: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
},
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
titleBarStyle: "default",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isDev) {
|
||||||
|
// Im Development-Modus: Verbinde zu lokalem Dev-Server
|
||||||
|
mainWindow.loadURL("http://localhost:4321");
|
||||||
|
mainWindow.webContents.openDevTools();
|
||||||
|
} else {
|
||||||
|
// Im Production-Modus: Starte lokalen Server und verbinde
|
||||||
|
startLocalServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
mainWindow.on("closed", () => {
|
||||||
|
mainWindow = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startLocalServer() {
|
||||||
|
const serverPath = path.join(__dirname, "../dist/server/entry.mjs");
|
||||||
|
|
||||||
|
// Starte den Astro-Server
|
||||||
|
astroProcess = spawn("node", [serverPath], {
|
||||||
|
cwd: path.join(__dirname, ".."),
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PORT: "4321",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
astroProcess.stdout.on("data", (data) => {
|
||||||
|
console.log(`Server: ${data}`);
|
||||||
|
if (data.toString().includes("Local") || data.toString().includes("4321")) {
|
||||||
|
// Warte kurz, dann lade die Seite
|
||||||
|
setTimeout(() => {
|
||||||
|
mainWindow.loadURL("http://localhost:4321");
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
astroProcess.stderr.on("data", (data) => {
|
||||||
|
console.error(`Server Error: ${data}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
astroProcess.on("close", (code) => {
|
||||||
|
console.log(`Server process exited with code ${code}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
createWindow();
|
||||||
|
|
||||||
|
app.on("activate", () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
createWindow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on("window-all-closed", () => {
|
||||||
|
if (astroProcess) {
|
||||||
|
astroProcess.kill();
|
||||||
|
}
|
||||||
|
if (process.platform !== "darwin") {
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on("before-quit", () => {
|
||||||
|
if (astroProcess) {
|
||||||
|
astroProcess.kill();
|
||||||
|
}
|
||||||
|
});
|
||||||
86
install.js
Normal file
86
install.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { existsSync, writeFileSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname } from 'path';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
console.log('🚀 YouTube Downloader - Installation\n');
|
||||||
|
|
||||||
|
// Prüfe Node.js Version
|
||||||
|
const nodeVersion = process.version;
|
||||||
|
const majorVersion = parseInt(nodeVersion.slice(1).split('.')[0]);
|
||||||
|
if (majorVersion < 20) {
|
||||||
|
console.error('❌ Node.js Version 20 oder höher ist erforderlich.');
|
||||||
|
console.error(` Aktuelle Version: ${nodeVersion}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(`✅ Node.js Version: ${nodeVersion}\n`);
|
||||||
|
|
||||||
|
// Schritt 1: npm install
|
||||||
|
console.log('📦 Installiere Dependencies...');
|
||||||
|
try {
|
||||||
|
execSync('npm install', { stdio: 'inherit', cwd: __dirname });
|
||||||
|
console.log('✅ Dependencies installiert\n');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Fehler beim Installieren der Dependencies');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schritt 2: Prüfe yt-dlp
|
||||||
|
console.log('🔍 Prüfe yt-dlp Installation...');
|
||||||
|
let ytdlpInstalled = false;
|
||||||
|
try {
|
||||||
|
execSync('yt-dlp --version', { stdio: 'ignore' });
|
||||||
|
ytdlpInstalled = true;
|
||||||
|
console.log('✅ yt-dlp ist bereits installiert\n');
|
||||||
|
} catch (error) {
|
||||||
|
console.log('⚠️ yt-dlp ist nicht installiert');
|
||||||
|
console.log('\n📥 Bitte installiere yt-dlp:');
|
||||||
|
console.log(' macOS: brew install yt-dlp');
|
||||||
|
console.log(' Linux: sudo apt-get install yt-dlp');
|
||||||
|
console.log(' Windows: pip install yt-dlp');
|
||||||
|
console.log(' Oder: pip install yt-dlp (funktioniert auf allen Plattformen)\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schritt 3: Erstelle .env Datei falls nicht vorhanden
|
||||||
|
const envPath = join(__dirname, '.env');
|
||||||
|
if (!existsSync(envPath)) {
|
||||||
|
console.log('📝 Erstelle .env Datei...');
|
||||||
|
const envContent = `LOGIN_USERNAME=admin
|
||||||
|
LOGIN_PASSWORD=change-me-in-production
|
||||||
|
DOWNLOAD_DIR=./downloaded
|
||||||
|
SESSION_SECRET=change-me-in-production-$(Date.now())
|
||||||
|
`;
|
||||||
|
writeFileSync(envPath, envContent);
|
||||||
|
console.log('✅ .env Datei erstellt');
|
||||||
|
console.log('⚠️ WICHTIG: Bitte ändere LOGIN_PASSWORD und SESSION_SECRET in der .env Datei!\n');
|
||||||
|
} else {
|
||||||
|
console.log('✅ .env Datei existiert bereits\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schritt 4: Erstelle downloaded Verzeichnis
|
||||||
|
const downloadDir = join(__dirname, 'downloaded');
|
||||||
|
if (!existsSync(downloadDir)) {
|
||||||
|
console.log('📁 Erstelle Download-Verzeichnis...');
|
||||||
|
execSync(`mkdir -p "${downloadDir}"`, { cwd: __dirname });
|
||||||
|
console.log('✅ Download-Verzeichnis erstellt\n');
|
||||||
|
} else {
|
||||||
|
console.log('✅ Download-Verzeichnis existiert bereits\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✨ Installation abgeschlossen!\n');
|
||||||
|
console.log('📋 Nächste Schritte:');
|
||||||
|
console.log(' 1. Bearbeite die .env Datei und setze LOGIN_PASSWORD und SESSION_SECRET');
|
||||||
|
if (!ytdlpInstalled) {
|
||||||
|
console.log(' 2. Installiere yt-dlp (siehe Anweisungen oben)');
|
||||||
|
console.log(' 3. Starte die Anwendung mit: npm run dev');
|
||||||
|
} else {
|
||||||
|
console.log(' 2. Starte die Anwendung mit: npm run dev');
|
||||||
|
}
|
||||||
|
console.log(' 4. Öffne http://localhost:4321 im Browser\n');
|
||||||
|
|
||||||
11004
package-lock.json
generated
Normal file
11004
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
82
package.json
Normal file
82
package.json
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
{
|
||||||
|
"name": "yt-downloader",
|
||||||
|
"type": "module",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "YouTube Video Downloader - Einfache Desktop-Anwendung zum Herunterladen von YouTube-Videos",
|
||||||
|
"author": "YouTube Downloader",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "astro dev",
|
||||||
|
"start": "astro dev",
|
||||||
|
"build": "astro build",
|
||||||
|
"preview": "astro preview",
|
||||||
|
"astro": "astro",
|
||||||
|
"install:setup": "node install.js",
|
||||||
|
"electron:dev": "NODE_ENV=development electron electron/main.cjs",
|
||||||
|
"electron:build": "npm run build && electron-builder",
|
||||||
|
"electron:build:mac": "npm run build && electron-builder --mac",
|
||||||
|
"electron:build:win": "npm run build && electron-builder --win",
|
||||||
|
"electron:start": "electron electron/main.cjs"
|
||||||
|
},
|
||||||
|
"main": "electron/main.cjs",
|
||||||
|
"build": {
|
||||||
|
"appId": "com.youtubedownloader.app",
|
||||||
|
"productName": "YouTube Downloader",
|
||||||
|
"directories": {
|
||||||
|
"output": "dist-electron"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist/**/*",
|
||||||
|
"electron/**/*",
|
||||||
|
"package.json",
|
||||||
|
"node_modules/**/*"
|
||||||
|
],
|
||||||
|
"mac": {
|
||||||
|
"target": [
|
||||||
|
"dmg",
|
||||||
|
"zip"
|
||||||
|
],
|
||||||
|
"category": "public.app-category.utilities"
|
||||||
|
},
|
||||||
|
"win": {
|
||||||
|
"target": [
|
||||||
|
{
|
||||||
|
"target": "nsis",
|
||||||
|
"arch": [
|
||||||
|
"x64"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target": "portable",
|
||||||
|
"arch": [
|
||||||
|
"x64"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"target": "AppImage"
|
||||||
|
},
|
||||||
|
"nsis": {
|
||||||
|
"oneClick": false,
|
||||||
|
"allowToChangeInstallationDirectory": true,
|
||||||
|
"createDesktopShortcut": true,
|
||||||
|
"createStartMenuShortcut": true
|
||||||
|
},
|
||||||
|
"dmg": {
|
||||||
|
"title": "YouTube Downloader"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@astrojs/node": "^9.5.1",
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"astro": "^5.16.0",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"yt-dlp-wrap": "^2.3.12"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.10.0",
|
||||||
|
"daisyui": "^5.5.14",
|
||||||
|
"electron": "^39.2.7",
|
||||||
|
"electron-builder": "^26.0.12"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
public/favicon.svg
Normal file
5
public/favicon.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<rect width="100" height="100" fill="#FF0000"/>
|
||||||
|
<polygon points="40,30 40,70 70,50" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 174 B |
213
src/components/DownloadForm.astro
Normal file
213
src/components/DownloadForm.astro
Normal 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>
|
||||||
45
src/components/LoginForm.astro
Normal file
45
src/components/LoginForm.astro
Normal 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
59
src/i18n/de.json
Normal 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
59
src/i18n/en.json
Normal 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
75
src/layouts/Layout.astro
Normal 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
101
src/lib/i18n.ts
Normal 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
55
src/lib/session.ts
Normal 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`;
|
||||||
|
}
|
||||||
|
|
||||||
58
src/pages/api/download-file.ts
Normal file
58
src/pages/api/download-file.ts
Normal 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
227
src/pages/api/download.ts
Normal 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
83
src/pages/api/files.ts
Normal 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
57
src/pages/api/login.ts
Normal 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
13
src/pages/api/logout.ts
Normal 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
18
src/pages/download.astro
Normal 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
218
src/pages/files.astro
Normal 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
23
src/pages/index.astro
Normal 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
3
src/styles/global.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@plugin "daisyui";
|
||||||
|
|
||||||
13
tailwind.config.mjs
Normal file
13
tailwind.config.mjs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import daisyui from "daisyui";
|
||||||
|
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ["./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}"],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [daisyui],
|
||||||
|
daisyui: {
|
||||||
|
themes: ["light", "dark", "cupcake"],
|
||||||
|
},
|
||||||
|
};
|
||||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strict",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["node"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user